mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Scenes: Compare versions in dashboard settings (#80286)
* Add versions tab in dashboard settings * Fetch and render dashboard versions * Be able to compare two versions * PR discussion changes * remove unnecessary async in test * PR discussion mods * linter fix * styles and tests * Fix show more versions bug * migrate files + style fix * fix test * refactor styles - css object keys to camelCase * refactor file migrations * more files migrations * remove unused type, cleanup
This commit is contained in:
parent
9969218231
commit
5a509ef1f1
@ -2433,10 +2433,6 @@ exports[`better eslint`] = {
|
|||||||
"public/app/features/dashboard-scene/settings/variables/utils.ts:5381": [
|
"public/app/features/dashboard-scene/settings/variables/utils.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||||
],
|
],
|
||||||
"public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx:5381": [
|
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
|
||||||
],
|
|
||||||
"public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts:5381": [
|
"public/app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper.ts:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||||
],
|
],
|
||||||
@ -2760,47 +2756,10 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||||
],
|
],
|
||||||
"public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx:5381": [
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "2"]
|
|
||||||
],
|
|
||||||
"public/app/features/dashboard/components/VersionHistory/DiffTitle.tsx:5381": [
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "2"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "3"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "4"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "5"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "6"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "7"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "8"]
|
|
||||||
],
|
|
||||||
"public/app/features/dashboard/components/VersionHistory/DiffValues.tsx:5381": [
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
|
||||||
],
|
|
||||||
"public/app/features/dashboard/components/VersionHistory/DiffViewer.tsx:5381": [
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
|
||||||
],
|
|
||||||
"public/app/features/dashboard/components/VersionHistory/VersionHistoryComparison.tsx:5381": [
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "2"]
|
|
||||||
],
|
|
||||||
"public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx:5381": [
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
|
||||||
],
|
|
||||||
"public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx:5381": [
|
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
|
||||||
],
|
|
||||||
"public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx:5381": [
|
"public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||||
],
|
],
|
||||||
"public/app/features/dashboard/components/VersionHistory/utils.ts:5381": [
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
|
||||||
],
|
|
||||||
"public/app/features/dashboard/containers/DashboardPage.tsx:5381": [
|
"public/app/features/dashboard/containers/DashboardPage.tsx:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||||
@ -7013,9 +6972,6 @@ exports[`no gf-form usage`] = {
|
|||||||
"public/app/features/dashboard/components/SubMenu/SubMenuItems.tsx:5381": [
|
"public/app/features/dashboard/components/SubMenu/SubMenuItems.tsx:5381": [
|
||||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||||
],
|
],
|
||||||
"public/app/features/dashboard/components/VersionHistory/VersionHistoryTable.tsx:5381": [
|
|
||||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
|
||||||
],
|
|
||||||
"public/app/features/datasources/components/BasicSettings.tsx:5381": [
|
"public/app/features/datasources/components/BasicSettings.tsx:5381": [
|
||||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||||
|
@ -68,6 +68,8 @@ export interface DashboardSceneState extends SceneObjectState {
|
|||||||
isDirty?: boolean;
|
isDirty?: boolean;
|
||||||
/** meta flags */
|
/** meta flags */
|
||||||
meta: DashboardMeta;
|
meta: DashboardMeta;
|
||||||
|
/** Version of the dashboard */
|
||||||
|
version?: number;
|
||||||
/** Panel to inspect */
|
/** Panel to inspect */
|
||||||
inspectPanelKey?: string;
|
inspectPanelKey?: string;
|
||||||
/** Panel to view in fullscreen */
|
/** Panel to view in fullscreen */
|
||||||
|
@ -3,9 +3,9 @@ import React from 'react';
|
|||||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneObjectRef } from '@grafana/scenes';
|
import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneObjectRef } from '@grafana/scenes';
|
||||||
import { Drawer } from '@grafana/ui';
|
import { Drawer } from '@grafana/ui';
|
||||||
import { SaveDashboardDiff } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDiff';
|
import { SaveDashboardDiff } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDiff';
|
||||||
import { jsonDiff } from 'app/features/dashboard/components/VersionHistory/utils';
|
|
||||||
|
|
||||||
import { DashboardScene } from '../scene/DashboardScene';
|
import { DashboardScene } from '../scene/DashboardScene';
|
||||||
|
import { jsonDiff } from '../settings/version-history/utils';
|
||||||
|
|
||||||
import { transformSceneToSaveModel } from './transformSceneToSaveModel';
|
import { transformSceneToSaveModel } from './transformSceneToSaveModel';
|
||||||
|
|
||||||
|
@ -263,6 +263,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back
|
|||||||
"timezone": "",
|
"timezone": "",
|
||||||
"title": "Repeating rows",
|
"title": "Repeating rows",
|
||||||
"uid": "Repeating-rows-uid",
|
"uid": "Repeating-rows-uid",
|
||||||
|
"version": 1,
|
||||||
"weekStart": "",
|
"weekStart": "",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@ -593,6 +594,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho
|
|||||||
"timezone": "America/New_York",
|
"timezone": "America/New_York",
|
||||||
"title": "My custom title",
|
"title": "My custom title",
|
||||||
"uid": "nP8rcffGkasd",
|
"uid": "nP8rcffGkasd",
|
||||||
|
"version": 2,
|
||||||
"weekStart": "monday",
|
"weekStart": "monday",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@ -915,6 +917,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr
|
|||||||
"timezone": "America/New_York",
|
"timezone": "America/New_York",
|
||||||
"title": "Dashboard to load1",
|
"title": "Dashboard to load1",
|
||||||
"uid": "nP8rcffGkasd",
|
"uid": "nP8rcffGkasd",
|
||||||
|
"version": 2,
|
||||||
"weekStart": "saturday",
|
"weekStart": "saturday",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
@ -241,6 +241,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
|
|||||||
description: oldModel.description,
|
description: oldModel.description,
|
||||||
editable: oldModel.editable,
|
editable: oldModel.editable,
|
||||||
meta: oldModel.meta,
|
meta: oldModel.meta,
|
||||||
|
version: oldModel.version,
|
||||||
body: new SceneGridLayout({
|
body: new SceneGridLayout({
|
||||||
isLazy: true,
|
isLazy: true,
|
||||||
children: createSceneObjectsForPanels(oldModel.panels),
|
children: createSceneObjectsForPanels(oldModel.panels),
|
||||||
|
@ -134,6 +134,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
|
|||||||
templating: {
|
templating: {
|
||||||
list: variables,
|
list: variables,
|
||||||
},
|
},
|
||||||
|
version: state.version,
|
||||||
timezone: timeRange.timeZone,
|
timezone: timeRange.timeZone,
|
||||||
fiscalYearStartMonth: timeRange.fiscalYearStartMonth,
|
fiscalYearStartMonth: timeRange.fiscalYearStartMonth,
|
||||||
weekStart: timeRange.weekStart,
|
weekStart: timeRange.weekStart,
|
||||||
|
@ -9,9 +9,15 @@ import { historySrv } from './version-history';
|
|||||||
jest.mock('./version-history/HistorySrv');
|
jest.mock('./version-history/HistorySrv');
|
||||||
|
|
||||||
describe('VersionsEditView', () => {
|
describe('VersionsEditView', () => {
|
||||||
describe('Dashboard Versions state', () => {
|
describe('Dashboard versions state', () => {
|
||||||
let dashboard: DashboardScene;
|
let dashboard: DashboardScene;
|
||||||
let versionsView: VersionsEditView;
|
let versionsView: VersionsEditView;
|
||||||
|
const mockEvent = {
|
||||||
|
preventDefault: jest.fn(),
|
||||||
|
currentTarget: {
|
||||||
|
checked: true,
|
||||||
|
},
|
||||||
|
} as unknown as React.FormEvent<HTMLInputElement>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.mocked(historySrv.getHistoryList).mockResolvedValue(getVersions());
|
jest.mocked(historySrv.getHistoryList).mockResolvedValue(getVersions());
|
||||||
@ -32,11 +38,13 @@ describe('VersionsEditView', () => {
|
|||||||
it('should return the decorated list of versions', () => {
|
it('should return the decorated list of versions', () => {
|
||||||
const versions = versionsView.versions;
|
const versions = versionsView.versions;
|
||||||
|
|
||||||
expect(versions).toHaveLength(2);
|
expect(versions).toHaveLength(3);
|
||||||
expect(versions[0].createdDateString).toBe('2017-02-22 20:43:01');
|
expect(versions[0].createdDateString).toBe('2017-02-22 20:43:01');
|
||||||
expect(versions[0].ageString).toBe('7 years ago');
|
expect(versions[0].ageString).toBe('7 years ago');
|
||||||
expect(versions[1].createdDateString).toBe('2017-02-22 20:43:01');
|
expect(versions[1].createdDateString).toBe('2017-02-22 20:43:01');
|
||||||
expect(versions[1].ageString).toBe('7 years ago');
|
expect(versions[1].ageString).toBe('7 years ago');
|
||||||
|
expect(versions[2].createdDateString).toBe('2017-02-23 20:43:01');
|
||||||
|
expect(versions[2].ageString).toBe('7 years ago');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should bump the start threshold when fetching more versions', async () => {
|
it('should bump the start threshold when fetching more versions', async () => {
|
||||||
@ -47,6 +55,59 @@ describe('VersionsEditView', () => {
|
|||||||
|
|
||||||
expect(versionsView.start).toBe(VERSIONS_FETCH_LIMIT * 2);
|
expect(versionsView.start).toBe(VERSIONS_FETCH_LIMIT * 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set the state of a version as checked when onCheck is called', () => {
|
||||||
|
versionsView.onCheck(mockEvent, 3);
|
||||||
|
|
||||||
|
expect(versionsView.versions[0].checked).toBe(false);
|
||||||
|
expect(versionsView.versions[1].checked).toBe(true);
|
||||||
|
expect(versionsView.versions[2].checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset the state of all versions when reset is called', () => {
|
||||||
|
versionsView.onCheck(mockEvent, 3);
|
||||||
|
|
||||||
|
expect(versionsView.versions[1].checked).toBe(true);
|
||||||
|
|
||||||
|
versionsView.reset();
|
||||||
|
|
||||||
|
expect(versionsView.versions[0].checked).toBe(false);
|
||||||
|
expect(versionsView.versions[1].checked).toBe(false);
|
||||||
|
expect(versionsView.versions[2].checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set the diffData', async () => {
|
||||||
|
versionsView.onCheck(mockEvent, 3);
|
||||||
|
versionsView.onCheck(mockEvent, 4);
|
||||||
|
|
||||||
|
jest
|
||||||
|
.mocked(historySrv.getDashboardVersion)
|
||||||
|
.mockResolvedValueOnce({ data: 'lhs' })
|
||||||
|
.mockResolvedValue({ data: 'rhs' });
|
||||||
|
|
||||||
|
await versionsView.getDiff();
|
||||||
|
|
||||||
|
expect(versionsView.diffData).toEqual({
|
||||||
|
lhs: 'lhs',
|
||||||
|
rhs: 'rhs',
|
||||||
|
});
|
||||||
|
expect(versionsView.state.baseInfo).toHaveProperty('version', 3);
|
||||||
|
expect(versionsView.state.newInfo).toHaveProperty('version', 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set the isNewLatest flag if the new selected version is latest', async () => {
|
||||||
|
versionsView.onCheck(mockEvent, 4);
|
||||||
|
versionsView.onCheck(mockEvent, 2);
|
||||||
|
|
||||||
|
jest
|
||||||
|
.mocked(historySrv.getDashboardVersion)
|
||||||
|
.mockResolvedValueOnce({ data: 'lhs' })
|
||||||
|
.mockResolvedValue({ data: 'rhs' });
|
||||||
|
|
||||||
|
await versionsView.getDiff();
|
||||||
|
|
||||||
|
expect(versionsView.state.isNewLatest).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -62,6 +123,7 @@ function getVersions() {
|
|||||||
created: '2017-02-22T17:43:01-08:00',
|
created: '2017-02-22T17:43:01-08:00',
|
||||||
createdBy: 'admin',
|
createdBy: 'admin',
|
||||||
message: '',
|
message: '',
|
||||||
|
checked: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
@ -73,6 +135,19 @@ function getVersions() {
|
|||||||
created: '2017-02-22T17:43:01-08:00',
|
created: '2017-02-22T17:43:01-08:00',
|
||||||
createdBy: 'admin',
|
createdBy: 'admin',
|
||||||
message: '',
|
message: '',
|
||||||
|
checked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
dashboardId: 1,
|
||||||
|
dashboardUID: '_U4zObQMz',
|
||||||
|
parentVersion: 1,
|
||||||
|
restoredFrom: 1,
|
||||||
|
version: 2,
|
||||||
|
created: '2017-02-23T17:43:01-08:00',
|
||||||
|
createdBy: 'admin',
|
||||||
|
message: '',
|
||||||
|
checked: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -83,6 +158,7 @@ async function buildTestScene() {
|
|||||||
$timeRange: new SceneTimeRange({}),
|
$timeRange: new SceneTimeRange({}),
|
||||||
title: 'hello',
|
title: 'hello',
|
||||||
uid: 'dash-1',
|
uid: 'dash-1',
|
||||||
|
version: 4,
|
||||||
meta: {
|
meta: {
|
||||||
canEdit: true,
|
canEdit: true,
|
||||||
},
|
},
|
||||||
|
@ -9,7 +9,14 @@ import { DashboardScene } from '../scene/DashboardScene';
|
|||||||
import { getDashboardSceneFor } from '../utils/utils';
|
import { getDashboardSceneFor } from '../utils/utils';
|
||||||
|
|
||||||
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
|
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
|
||||||
import { RevisionsModel, VersionHistoryTable, historySrv } from './version-history';
|
import {
|
||||||
|
RevisionsModel,
|
||||||
|
VersionHistoryComparison,
|
||||||
|
VersionHistoryHeader,
|
||||||
|
VersionHistoryTable,
|
||||||
|
VersionsHistoryButtons,
|
||||||
|
historySrv,
|
||||||
|
} from './version-history';
|
||||||
|
|
||||||
export const VERSIONS_FETCH_LIMIT = 10;
|
export const VERSIONS_FETCH_LIMIT = 10;
|
||||||
|
|
||||||
@ -22,6 +29,11 @@ export interface VersionsEditViewState extends DashboardEditViewState {
|
|||||||
versions?: DecoratedRevisionModel[];
|
versions?: DecoratedRevisionModel[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isAppending?: boolean;
|
isAppending?: boolean;
|
||||||
|
viewMode?: 'list' | 'compare';
|
||||||
|
diffData?: { lhs: string; rhs: string };
|
||||||
|
newInfo?: DecoratedRevisionModel;
|
||||||
|
baseInfo?: DecoratedRevisionModel;
|
||||||
|
isNewLatest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VersionsEditView extends SceneObjectBase<VersionsEditViewState> implements DashboardEditView {
|
export class VersionsEditView extends SceneObjectBase<VersionsEditViewState> implements DashboardEditView {
|
||||||
@ -35,6 +47,12 @@ export class VersionsEditView extends SceneObjectBase<VersionsEditViewState> imp
|
|||||||
versions: [],
|
versions: [],
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAppending: true,
|
isAppending: true,
|
||||||
|
viewMode: 'list',
|
||||||
|
isNewLatest: false,
|
||||||
|
diffData: {
|
||||||
|
lhs: '',
|
||||||
|
rhs: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.addActivationHandler(() => {
|
this.addActivationHandler(() => {
|
||||||
@ -46,6 +64,10 @@ export class VersionsEditView extends SceneObjectBase<VersionsEditViewState> imp
|
|||||||
return getDashboardSceneFor(this);
|
return getDashboardSceneFor(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get diffData(): { lhs: string; rhs: string } {
|
||||||
|
return this.state.diffData ?? { lhs: '', rhs: '' };
|
||||||
|
}
|
||||||
|
|
||||||
public get versions(): DecoratedRevisionModel[] {
|
public get versions(): DecoratedRevisionModel[] {
|
||||||
return this.state.versions ?? [];
|
return this.state.versions ?? [];
|
||||||
}
|
}
|
||||||
@ -92,6 +114,57 @@ export class VersionsEditView extends SceneObjectBase<VersionsEditViewState> imp
|
|||||||
.finally(() => this.setState({ isAppending: false }));
|
.finally(() => this.setState({ isAppending: false }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getDiff = async () => {
|
||||||
|
const selectedVersions = this.versions.filter((version) => version.checked);
|
||||||
|
const [newInfo, baseInfo] = selectedVersions;
|
||||||
|
const isNewLatest = newInfo.version === this._dashboard.state.version;
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!this._dashboard.state.uid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, baseInfo.version);
|
||||||
|
const rhs = await historySrv.getDashboardVersion(this._dashboard.state.uid, newInfo.version);
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
baseInfo,
|
||||||
|
isLoading: false,
|
||||||
|
isNewLatest,
|
||||||
|
newInfo,
|
||||||
|
viewMode: 'compare',
|
||||||
|
diffData: {
|
||||||
|
lhs: lhs.data,
|
||||||
|
rhs: rhs.data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
public reset = () => {
|
||||||
|
this.setState({
|
||||||
|
baseInfo: undefined,
|
||||||
|
diffData: {
|
||||||
|
lhs: '',
|
||||||
|
rhs: '',
|
||||||
|
},
|
||||||
|
isNewLatest: false,
|
||||||
|
newInfo: undefined,
|
||||||
|
versions: this.versions.map((version) => ({ ...version, checked: false })),
|
||||||
|
viewMode: 'list',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
public onCheck = (ev: React.FormEvent<HTMLInputElement>, versionId: number) => {
|
||||||
|
this.setState({
|
||||||
|
versions: this.versions.map((version) =>
|
||||||
|
version.id === versionId ? { ...version, checked: ev.currentTarget.checked } : version
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
private decorateVersions(versions: RevisionsModel[]): DecoratedRevisionModel[] {
|
private decorateVersions(versions: RevisionsModel[]): DecoratedRevisionModel[] {
|
||||||
const timeZone = this.getTimeRange().getTimeZone();
|
const timeZone = this.getTimeRange().getTimeZone();
|
||||||
|
|
||||||
@ -108,30 +181,58 @@ export class VersionsEditView extends SceneObjectBase<VersionsEditViewState> imp
|
|||||||
|
|
||||||
function VersionsEditorSettingsListView({ model }: SceneComponentProps<VersionsEditView>) {
|
function VersionsEditorSettingsListView({ model }: SceneComponentProps<VersionsEditView>) {
|
||||||
const dashboard = model.getDashboard();
|
const dashboard = model.getDashboard();
|
||||||
const { isLoading, isAppending } = model.useState();
|
const { isLoading, isAppending, viewMode, baseInfo, newInfo, isNewLatest } = model.useState();
|
||||||
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
|
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
|
||||||
|
|
||||||
const canCompare = model.versions.filter((version) => version.checked).length === 2;
|
const canCompare = model.versions.filter((version) => version.checked).length === 2;
|
||||||
|
const showButtons = model.versions.length > 1;
|
||||||
|
const hasMore = model.versions.length >= model.limit;
|
||||||
|
const isLastPage = model.versions.find((rev) => rev.version === 1);
|
||||||
|
|
||||||
|
if (viewMode === 'compare') {
|
||||||
|
return (
|
||||||
|
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
|
||||||
|
<VersionHistoryHeader
|
||||||
|
onClick={model.reset}
|
||||||
|
baseVersion={baseInfo?.version}
|
||||||
|
newVersion={newInfo?.version}
|
||||||
|
isNewLatest={isNewLatest}
|
||||||
|
/>
|
||||||
|
{isLoading ? (
|
||||||
|
<VersionsHistorySpinner msg="Fetching changes…" />
|
||||||
|
) : (
|
||||||
|
<VersionHistoryComparison
|
||||||
|
newInfo={newInfo!}
|
||||||
|
baseInfo={baseInfo!}
|
||||||
|
isNewLatest={isNewLatest!}
|
||||||
|
diffData={model.diffData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
|
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<VersionsHistorySpinner msg="Fetching history list…" />
|
<VersionsHistorySpinner msg="Fetching history list…" />
|
||||||
) : (
|
) : (
|
||||||
<VersionHistoryTable
|
<VersionHistoryTable versions={model.versions} onCheck={model.onCheck} canCompare={canCompare} />
|
||||||
versions={model.versions}
|
|
||||||
onCheck={(x, y) => {
|
|
||||||
console.log('todo');
|
|
||||||
}}
|
|
||||||
canCompare={canCompare}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{isAppending && <VersionsHistorySpinner msg="Fetching more entries…" />}
|
{isAppending && <VersionsHistorySpinner msg="Fetching more entries…" />}
|
||||||
|
{showButtons && (
|
||||||
|
<VersionsHistoryButtons
|
||||||
|
hasMore={hasMore}
|
||||||
|
canCompare={canCompare}
|
||||||
|
getVersions={model.fetchVersions.bind(model)}
|
||||||
|
getDiff={model.getDiff}
|
||||||
|
isLastPage={!!isLastPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VersionsHistorySpinner = ({ msg }: { msg: string }) => (
|
const VersionsHistorySpinner = ({ msg }: { msg: string }) => (
|
||||||
<HorizontalGroup>
|
<HorizontalGroup>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
<em>{msg}</em>
|
<em>{msg}</em>
|
||||||
|
@ -43,15 +43,15 @@ export const DiffGroup = ({ diffs, title }: DiffGroupProps) => {
|
|||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
container: css({
|
container: css({
|
||||||
'background-color': theme.colors.background.secondary,
|
backgroundColor: theme.colors.background.secondary,
|
||||||
'font-size': theme.typography.h6.fontSize,
|
fontSize: theme.typography.h6.fontSize,
|
||||||
'margin-bottom': theme.spacing(2),
|
marginBottom: theme.spacing(2),
|
||||||
padding: theme.spacing(2),
|
padding: theme.spacing(2),
|
||||||
}),
|
}),
|
||||||
list: css({
|
list: css({
|
||||||
'margin-left': theme.spacing(4),
|
marginLeft: theme.spacing(4),
|
||||||
}),
|
}),
|
||||||
listItem: css({
|
listItem: css({
|
||||||
'margin-bottom': theme.spacing(1),
|
marginBottom: theme.spacing(1),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@ -32,7 +32,7 @@ export const DiffTitle = ({ diff, title }: DiffTitleProps) => {
|
|||||||
|
|
||||||
const getDiffTitleStyles = (theme: GrafanaTheme2) => ({
|
const getDiffTitleStyles = (theme: GrafanaTheme2) => ({
|
||||||
embolden: css({
|
embolden: css({
|
||||||
'font-weight': `${theme.typography.fontWeightBold}`,
|
fontWeight: theme.typography.fontWeightBold,
|
||||||
}),
|
}),
|
||||||
add: css({
|
add: css({
|
||||||
color: theme.colors.success.main,
|
color: theme.colors.success.main,
|
||||||
@ -56,6 +56,6 @@ const getDiffTitleStyles = (theme: GrafanaTheme2) => ({
|
|||||||
color: theme.colors.success.main,
|
color: theme.colors.success.main,
|
||||||
}),
|
}),
|
||||||
withoutDiff: css({
|
withoutDiff: css({
|
||||||
'margin-bottom': theme.spacing(2),
|
marginBottom: theme.spacing(2),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@ -28,10 +28,10 @@ export const DiffValues = ({ diff }: DiffProps) => {
|
|||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) =>
|
const getStyles = (theme: GrafanaTheme2) =>
|
||||||
css({
|
css({
|
||||||
'background-color': theme.colors.action.hover,
|
backgroundColor: theme.colors.action.hover,
|
||||||
'border-radius': theme.shape.radius.default,
|
borderRadius: theme.shape.radius.default,
|
||||||
color: theme.colors.text.primary,
|
color: theme.colors.text.primary,
|
||||||
'font-size': theme.typography.body.fontSize,
|
fontSize: theme.typography.body.fontSize,
|
||||||
margin: `0 ${theme.spacing(0.5)}`,
|
margin: `0 ${theme.spacing(0.5)}`,
|
||||||
padding: theme.spacing(0.5, 1),
|
padding: theme.spacing(0.5, 1),
|
||||||
});
|
});
|
||||||
|
@ -42,18 +42,18 @@ export const DiffViewer = ({ oldValue, newValue }: ReactDiffViewerProps) => {
|
|||||||
codeFold: {
|
codeFold: {
|
||||||
fontSize: theme.typography.bodySmall.fontSize,
|
fontSize: theme.typography.bodySmall.fontSize,
|
||||||
},
|
},
|
||||||
gutter: `
|
gutter: {
|
||||||
pre {
|
pre: {
|
||||||
color: ${tinycolor(theme.colors.text.disabled).setAlpha(1).toString()};
|
color: tinycolor(theme.colors.text.disabled).setAlpha(1).toString(),
|
||||||
opacity: 0.61;
|
opacity: 0.61,
|
||||||
}
|
},
|
||||||
`,
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
'font-size': theme.typography.bodySmall.fontSize,
|
fontSize: theme.typography.bodySmall.fontSize,
|
||||||
// prevent global styles interfering with diff viewer
|
// prevent global styles interfering with diff viewer
|
||||||
pre: {
|
pre: {
|
||||||
all: 'revert',
|
all: 'revert',
|
||||||
|
@ -19,12 +19,6 @@ export interface RevisionsModel {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiffTarget {
|
|
||||||
dashboardUID: string;
|
|
||||||
version: number;
|
|
||||||
unsavedDashboard?: DashboardModel; // when doing diffs against unsaved dashboard version
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HistorySrv {
|
export class HistorySrv {
|
||||||
getHistoryList(dashboardUID: string, options: HistoryListOpts) {
|
getHistoryList(dashboardUID: string, options: HistoryListOpts) {
|
||||||
if (typeof dashboardUID !== 'string') {
|
if (typeof dashboardUID !== 'string') {
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { ConfirmModal } from '@grafana/ui';
|
|
||||||
|
|
||||||
import { useDashboardRestore } from './useDashboardRestore';
|
|
||||||
export interface RevertDashboardModalProps {
|
|
||||||
hideModal: () => void;
|
|
||||||
version: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RevertDashboardModal = ({ hideModal, version }: RevertDashboardModalProps) => {
|
|
||||||
// TODO: how should state.error be handled?
|
|
||||||
const { state, onRestoreDashboard } = useDashboardRestore(version);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!state.loading && state.value) {
|
|
||||||
hideModal();
|
|
||||||
}
|
|
||||||
}, [state, hideModal]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={true}
|
|
||||||
title="Restore Version"
|
|
||||||
icon="history"
|
|
||||||
onDismiss={hideModal}
|
|
||||||
onConfirm={onRestoreDashboard}
|
|
||||||
body={
|
|
||||||
<p>Are you sure you want to restore the dashboard to version {version}? All unsaved changes will be lost.</p>
|
|
||||||
}
|
|
||||||
confirmText={`Yes, restore to version ${version}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
@ -3,12 +3,12 @@ import React from 'react';
|
|||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Button, ModalsController, CollapsableSection, HorizontalGroup, useStyles2 } from '@grafana/ui';
|
import { Button, ModalsController, CollapsableSection, HorizontalGroup, useStyles2 } from '@grafana/ui';
|
||||||
|
import { RevertDashboardModal } from 'app/features/dashboard/components/VersionHistory/RevertDashboardModal';
|
||||||
|
|
||||||
import { DecoratedRevisionModel } from '../VersionsEditView';
|
import { DecoratedRevisionModel } from '../VersionsEditView';
|
||||||
|
|
||||||
import { DiffGroup } from './DiffGroup';
|
import { DiffGroup } from './DiffGroup';
|
||||||
import { DiffViewer } from './DiffViewer';
|
import { DiffViewer } from './DiffViewer';
|
||||||
import { RevertDashboardModal } from './RevertDashboardModal';
|
|
||||||
import { jsonDiff } from './utils';
|
import { jsonDiff } from './utils';
|
||||||
|
|
||||||
type DiffViewProps = {
|
type DiffViewProps = {
|
||||||
@ -70,13 +70,13 @@ export const VersionHistoryComparison = ({ baseInfo, newInfo, diffData, isNewLat
|
|||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
spacer: css({
|
spacer: css({
|
||||||
'margin-bottom': theme.spacing(4),
|
marginBottom: theme.spacing(4),
|
||||||
}),
|
}),
|
||||||
versionInfo: css({
|
versionInfo: css({
|
||||||
color: theme.colors.text.secondary,
|
color: theme.colors.text.secondary,
|
||||||
'font-size': theme.typography.bodySmall.fontSize,
|
fontSize: theme.typography.bodySmall.fontSize,
|
||||||
}),
|
}),
|
||||||
noMarginBottom: css({
|
noMarginBottom: css({
|
||||||
'margin-bottom': 0,
|
marginBottom: 0,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@ -33,9 +33,9 @@ export const VersionHistoryHeader = ({
|
|||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
header: css({
|
header: css({
|
||||||
'font-size': theme.typography.h3.fontSize,
|
fontSize: theme.typography.h3.fontSize,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: theme.spacing(2),
|
gap: theme.spacing(2),
|
||||||
'margin-bottom': theme.spacing(3),
|
marginBottom: theme.spacing(3),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@ -1,73 +1,87 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Checkbox, Button, Tag, ModalsController } from '@grafana/ui';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { Checkbox, Button, Tag, ModalsController, useStyles2 } from '@grafana/ui';
|
||||||
|
import { RevertDashboardModal } from 'app/features/dashboard/components/VersionHistory/RevertDashboardModal';
|
||||||
|
|
||||||
import { DecoratedRevisionModel } from '../VersionsEditView';
|
import { DecoratedRevisionModel } from '../VersionsEditView';
|
||||||
|
|
||||||
import { RevertDashboardModal } from './RevertDashboardModal';
|
|
||||||
|
|
||||||
type VersionsTableProps = {
|
type VersionsTableProps = {
|
||||||
versions: DecoratedRevisionModel[];
|
versions: DecoratedRevisionModel[];
|
||||||
canCompare: boolean;
|
canCompare: boolean;
|
||||||
onCheck: (ev: React.FormEvent<HTMLInputElement>, versionId: number) => void;
|
onCheck: (ev: React.FormEvent<HTMLInputElement>, versionId: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VersionHistoryTable = ({ versions, canCompare, onCheck }: VersionsTableProps) => (
|
export const VersionHistoryTable = ({ versions, canCompare, onCheck }: VersionsTableProps) => {
|
||||||
<table className="filter-table">
|
const styles = useStyles2(getStyles);
|
||||||
<thead>
|
|
||||||
<tr>
|
return (
|
||||||
<th className="width-4"></th>
|
<div className={styles.margin}>
|
||||||
<th className="width-4">Version</th>
|
<table className="filter-table">
|
||||||
<th className="width-14">Date</th>
|
<thead>
|
||||||
<th className="width-10">Updated by</th>
|
<tr>
|
||||||
<th>Notes</th>
|
<th className="width-4"></th>
|
||||||
<th></th>
|
<th className="width-4">Version</th>
|
||||||
</tr>
|
<th className="width-14">Date</th>
|
||||||
</thead>
|
<th className="width-10">Updated by</th>
|
||||||
<tbody>
|
<th>Notes</th>
|
||||||
{versions.map((version, idx) => (
|
<th></th>
|
||||||
<tr key={version.id}>
|
</tr>
|
||||||
<td>
|
</thead>
|
||||||
<Checkbox
|
<tbody>
|
||||||
aria-label={`Toggle selection of version ${version.version}`}
|
{versions.map((version, idx) => (
|
||||||
className={css({
|
<tr key={version.id}>
|
||||||
display: 'inline',
|
<td>
|
||||||
})}
|
<Checkbox
|
||||||
checked={version.checked}
|
aria-label={`Toggle selection of version ${version.version}`}
|
||||||
onChange={(ev) => onCheck(ev, version.id)}
|
className={css({
|
||||||
disabled={!version.checked && canCompare}
|
display: 'inline',
|
||||||
/>
|
})}
|
||||||
</td>
|
checked={version.checked}
|
||||||
<td>{version.version}</td>
|
onChange={(ev) => onCheck(ev, version.id)}
|
||||||
<td>{version.createdDateString}</td>
|
disabled={!version.checked && canCompare}
|
||||||
<td>{version.createdBy}</td>
|
/>
|
||||||
<td>{version.message}</td>
|
</td>
|
||||||
<td className="text-right">
|
<td>{version.version}</td>
|
||||||
{idx === 0 ? (
|
<td>{version.createdDateString}</td>
|
||||||
<Tag name="Latest" colorIndex={17} />
|
<td>{version.createdBy}</td>
|
||||||
) : (
|
<td>{version.message}</td>
|
||||||
<ModalsController>
|
<td className="text-right">
|
||||||
{({ showModal, hideModal }) => (
|
{idx === 0 ? (
|
||||||
<Button
|
<Tag name="Latest" colorIndex={17} />
|
||||||
variant="secondary"
|
) : (
|
||||||
size="sm"
|
<ModalsController>
|
||||||
icon="history"
|
{({ showModal, hideModal }) => (
|
||||||
onClick={() => {
|
<Button
|
||||||
showModal(RevertDashboardModal, {
|
variant="secondary"
|
||||||
version: version.version,
|
size="sm"
|
||||||
hideModal,
|
icon="history"
|
||||||
});
|
onClick={() => {
|
||||||
}}
|
showModal(RevertDashboardModal, {
|
||||||
>
|
version: version.version,
|
||||||
Restore
|
hideModal,
|
||||||
</Button>
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ModalsController>
|
||||||
)}
|
)}
|
||||||
</ModalsController>
|
</td>
|
||||||
)}
|
</tr>
|
||||||
</td>
|
))}
|
||||||
</tr>
|
</tbody>
|
||||||
))}
|
</table>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) {
|
||||||
|
return {
|
||||||
|
margin: css({
|
||||||
|
marginBottom: theme.spacing(4),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -1,39 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useAsyncFn } from 'react-use';
|
|
||||||
|
|
||||||
import { locationUtil } from '@grafana/data';
|
|
||||||
import { locationService } from '@grafana/runtime';
|
|
||||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
|
||||||
import { DashboardModel } from 'app/features/dashboard/state';
|
|
||||||
import { useSelector } from 'app/types';
|
|
||||||
|
|
||||||
import { dashboardWatcher } from '../../../live/dashboard/dashboardWatcher';
|
|
||||||
|
|
||||||
import { historySrv } from './HistorySrv';
|
|
||||||
|
|
||||||
const restoreDashboard = async (version: number, dashboard: DashboardModel) => {
|
|
||||||
// Skip the watcher logic for this save since it's handled by the hook
|
|
||||||
dashboardWatcher.ignoreNextSave();
|
|
||||||
return await historySrv.restoreDashboard(dashboard, version);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDashboardRestore = (version: number) => {
|
|
||||||
const dashboard = useSelector((state) => state.dashboard.getModel());
|
|
||||||
const [state, onRestoreDashboard] = useAsyncFn(async () => await restoreDashboard(version, dashboard!), []);
|
|
||||||
const notifyApp = useAppNotification();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (state.value) {
|
|
||||||
const location = locationService.getLocation();
|
|
||||||
const newUrl = locationUtil.stripBaseFromUrl(state.value.url);
|
|
||||||
const prevState = (location.state as any)?.routeReloadCounter;
|
|
||||||
locationService.replace({
|
|
||||||
...location,
|
|
||||||
pathname: newUrl,
|
|
||||||
state: { routeReloadCounter: prevState ? prevState + 1 : 1 },
|
|
||||||
});
|
|
||||||
notifyApp.success('Dashboard restored', `Restored from version ${version}`);
|
|
||||||
}
|
|
||||||
}, [state, version, notifyApp]);
|
|
||||||
return { state, onRestoreDashboard };
|
|
||||||
};
|
|
@ -6,15 +6,15 @@ import { BrowserRouter } from 'react-router-dom';
|
|||||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||||
|
|
||||||
import { GrafanaContext } from 'app/core/context/GrafanaContext';
|
import { GrafanaContext } from 'app/core/context/GrafanaContext';
|
||||||
|
import { historySrv } from 'app/features/dashboard-scene/settings/version-history/HistorySrv';
|
||||||
|
|
||||||
import { configureStore } from '../../../../store/configureStore';
|
import { configureStore } from '../../../../store/configureStore';
|
||||||
import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures';
|
import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures';
|
||||||
import { historySrv } from '../VersionHistory/HistorySrv';
|
|
||||||
|
|
||||||
import { VersionsSettings, VERSIONS_FETCH_LIMIT } from './VersionsSettings';
|
import { VersionsSettings, VERSIONS_FETCH_LIMIT } from './VersionsSettings';
|
||||||
import { versions, diffs } from './__mocks__/versions';
|
import { versions, diffs } from './__mocks__/versions';
|
||||||
|
|
||||||
jest.mock('../VersionHistory/HistorySrv');
|
jest.mock('app/features/dashboard-scene/settings/version-history/HistorySrv');
|
||||||
|
|
||||||
const queryByFullText = (text: string) =>
|
const queryByFullText = (text: string) =>
|
||||||
screen.queryByText((_, node: Element | undefined | null) => {
|
screen.queryByText((_, node: Element | undefined | null) => {
|
||||||
|
@ -3,7 +3,6 @@ import React, { PureComponent } from 'react';
|
|||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
import { Spinner, HorizontalGroup } from '@grafana/ui';
|
import { Spinner, HorizontalGroup } from '@grafana/ui';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
historySrv,
|
historySrv,
|
||||||
RevisionsModel,
|
RevisionsModel,
|
||||||
@ -11,7 +10,7 @@ import {
|
|||||||
VersionHistoryHeader,
|
VersionHistoryHeader,
|
||||||
VersionsHistoryButtons,
|
VersionsHistoryButtons,
|
||||||
VersionHistoryComparison,
|
VersionHistoryComparison,
|
||||||
} from '../VersionHistory';
|
} from 'app/features/dashboard-scene/settings/version-history';
|
||||||
|
|
||||||
import { SettingsPageProps } from './types';
|
import { SettingsPageProps } from './types';
|
||||||
|
|
||||||
@ -22,7 +21,7 @@ type State = {
|
|||||||
isAppending: boolean;
|
isAppending: boolean;
|
||||||
versions: DecoratedRevisionModel[];
|
versions: DecoratedRevisionModel[];
|
||||||
viewMode: 'list' | 'compare';
|
viewMode: 'list' | 'compare';
|
||||||
diffData: { lhs: unknown; rhs: unknown };
|
diffData: { lhs: string; rhs: string };
|
||||||
newInfo?: DecoratedRevisionModel;
|
newInfo?: DecoratedRevisionModel;
|
||||||
baseInfo?: DecoratedRevisionModel;
|
baseInfo?: DecoratedRevisionModel;
|
||||||
isNewLatest: boolean;
|
isNewLatest: boolean;
|
||||||
@ -50,8 +49,8 @@ export class VersionsSettings extends PureComponent<Props, State> {
|
|||||||
viewMode: 'list',
|
viewMode: 'list',
|
||||||
isNewLatest: false,
|
isNewLatest: false,
|
||||||
diffData: {
|
diffData: {
|
||||||
lhs: {},
|
lhs: '',
|
||||||
rhs: {},
|
rhs: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -124,8 +123,8 @@ export class VersionsSettings extends PureComponent<Props, State> {
|
|||||||
this.setState({
|
this.setState({
|
||||||
baseInfo: undefined,
|
baseInfo: undefined,
|
||||||
diffData: {
|
diffData: {
|
||||||
lhs: {},
|
lhs: '',
|
||||||
rhs: {},
|
rhs: '',
|
||||||
},
|
},
|
||||||
isNewLatest: false,
|
isNewLatest: false,
|
||||||
newInfo: undefined,
|
newInfo: undefined,
|
||||||
|
@ -4,10 +4,10 @@ import { useAsync } from 'react-use';
|
|||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Spinner, useStyles2 } from '@grafana/ui';
|
import { Spinner, useStyles2 } from '@grafana/ui';
|
||||||
|
import { Diffs } from 'app/features/dashboard-scene/settings/version-history/utils';
|
||||||
|
|
||||||
import { DiffGroup } from '../VersionHistory/DiffGroup';
|
import { DiffGroup } from '../../../dashboard-scene/settings/version-history/DiffGroup';
|
||||||
import { DiffViewer } from '../VersionHistory/DiffViewer';
|
import { DiffViewer } from '../../../dashboard-scene/settings/version-history/DiffViewer';
|
||||||
import { Diffs } from '../VersionHistory/utils';
|
|
||||||
|
|
||||||
interface SaveDashboardDiffProps {
|
interface SaveDashboardDiffProps {
|
||||||
oldValue?: unknown;
|
oldValue?: unknown;
|
||||||
|
@ -2,8 +2,7 @@ import React, { useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { config, isFetchError } from '@grafana/runtime';
|
import { config, isFetchError } from '@grafana/runtime';
|
||||||
import { Drawer, Tab, TabsBar } from '@grafana/ui';
|
import { Drawer, Tab, TabsBar } from '@grafana/ui';
|
||||||
|
import { jsonDiff } from 'app/features/dashboard-scene/settings/version-history/utils';
|
||||||
import { jsonDiff } from '../VersionHistory/utils';
|
|
||||||
|
|
||||||
import DashboardValidation from './DashboardValidation';
|
import DashboardValidation from './DashboardValidation';
|
||||||
import { SaveDashboardDiff } from './SaveDashboardDiff';
|
import { SaveDashboardDiff } from './SaveDashboardDiff';
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { Dashboard } from '@grafana/schema';
|
import { Dashboard } from '@grafana/schema';
|
||||||
import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||||
|
import { Diffs } from 'app/features/dashboard-scene/settings/version-history/utils';
|
||||||
import { DashboardDataDTO } from 'app/types';
|
import { DashboardDataDTO } from 'app/types';
|
||||||
|
|
||||||
import { Diffs } from '../VersionHistory/utils';
|
|
||||||
|
|
||||||
export interface SaveDashboardData {
|
export interface SaveDashboardData {
|
||||||
clone: Dashboard; // cloned copy
|
clone: Dashboard; // cloned copy
|
||||||
diff: Diffs;
|
diff: Diffs;
|
||||||
|
@ -1,57 +0,0 @@
|
|||||||
import { css } from '@emotion/css';
|
|
||||||
import { last } from 'lodash';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
|
||||||
import { useStyles2 } from '@grafana/ui';
|
|
||||||
|
|
||||||
import { DiffTitle } from './DiffTitle';
|
|
||||||
import { DiffValues } from './DiffValues';
|
|
||||||
import { Diff, getDiffText } from './utils';
|
|
||||||
|
|
||||||
type DiffGroupProps = {
|
|
||||||
diffs: Diff[];
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DiffGroup = ({ diffs, title }: DiffGroupProps) => {
|
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
|
|
||||||
if (diffs.length === 1) {
|
|
||||||
return (
|
|
||||||
<div className={styles.container} data-testid="diffGroup">
|
|
||||||
<DiffTitle title={title} diff={diffs[0]} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container} data-testid="diffGroup">
|
|
||||||
<DiffTitle title={title} />
|
|
||||||
<ul className={styles.list}>
|
|
||||||
{diffs.map((diff: Diff, idx: number) => {
|
|
||||||
return (
|
|
||||||
<li className={styles.listItem} key={`${last(diff.path)}__${idx}`}>
|
|
||||||
<span>{getDiffText(diff)}</span> <DiffValues diff={diff} />
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
|
||||||
container: css`
|
|
||||||
background-color: ${theme.colors.background.secondary};
|
|
||||||
font-size: ${theme.typography.h6.fontSize};
|
|
||||||
margin-bottom: ${theme.spacing(2)};
|
|
||||||
padding: ${theme.spacing(2)};
|
|
||||||
`,
|
|
||||||
list: css`
|
|
||||||
margin-left: ${theme.spacing(4)};
|
|
||||||
`,
|
|
||||||
listItem: css`
|
|
||||||
margin-bottom: ${theme.spacing(1)};
|
|
||||||
`,
|
|
||||||
});
|
|
@ -1,61 +0,0 @@
|
|||||||
import { css } from '@emotion/css';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
|
||||||
import { useStyles2, Icon } from '@grafana/ui';
|
|
||||||
|
|
||||||
import { DiffValues } from './DiffValues';
|
|
||||||
import { Diff, getDiffText } from './utils';
|
|
||||||
|
|
||||||
type DiffTitleProps = {
|
|
||||||
diff?: Diff;
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const replaceDiff: Diff = { op: 'replace', originalValue: undefined, path: [''], value: undefined, startLineNumber: 0 };
|
|
||||||
|
|
||||||
export const DiffTitle = ({ diff, title }: DiffTitleProps) => {
|
|
||||||
const styles = useStyles2(getDiffTitleStyles);
|
|
||||||
|
|
||||||
return diff ? (
|
|
||||||
<>
|
|
||||||
<Icon type="mono" name="circle" className={styles[diff.op]} /> <span className={styles.embolden}>{title}</span>{' '}
|
|
||||||
<span>{getDiffText(diff, diff.path.length > 1)}</span> <DiffValues diff={diff} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className={styles.withoutDiff}>
|
|
||||||
<Icon type="mono" name="circle" className={styles.replace} /> <span className={styles.embolden}>{title}</span>{' '}
|
|
||||||
<span>{getDiffText(replaceDiff, false)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDiffTitleStyles = (theme: GrafanaTheme2) => ({
|
|
||||||
embolden: css`
|
|
||||||
font-weight: ${theme.typography.fontWeightBold};
|
|
||||||
`,
|
|
||||||
add: css`
|
|
||||||
color: ${theme.colors.success.main};
|
|
||||||
`,
|
|
||||||
replace: css`
|
|
||||||
color: ${theme.colors.success.main};
|
|
||||||
`,
|
|
||||||
move: css`
|
|
||||||
color: ${theme.colors.success.main};
|
|
||||||
`,
|
|
||||||
copy: css`
|
|
||||||
color: ${theme.colors.success.main};
|
|
||||||
`,
|
|
||||||
_get: css`
|
|
||||||
color: ${theme.colors.success.main};
|
|
||||||
`,
|
|
||||||
test: css`
|
|
||||||
color: ${theme.colors.success.main};
|
|
||||||
`,
|
|
||||||
remove: css`
|
|
||||||
color: ${theme.colors.success.main};
|
|
||||||
`,
|
|
||||||
withoutDiff: css`
|
|
||||||
margin-bottom: ${theme.spacing(2)};
|
|
||||||
`,
|
|
||||||
});
|
|
@ -1,36 +0,0 @@
|
|||||||
import { css } from '@emotion/css';
|
|
||||||
import { isArray, isObject, isUndefined } from 'lodash';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
|
||||||
import { useStyles2, Icon } from '@grafana/ui';
|
|
||||||
|
|
||||||
import { Diff } from './utils';
|
|
||||||
|
|
||||||
type DiffProps = {
|
|
||||||
diff: Diff;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DiffValues = ({ diff }: DiffProps) => {
|
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
const hasLeftValue =
|
|
||||||
!isUndefined(diff.originalValue) && !isArray(diff.originalValue) && !isObject(diff.originalValue);
|
|
||||||
const hasRightValue = !isUndefined(diff.value) && !isArray(diff.value) && !isObject(diff.value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{hasLeftValue && <span className={styles}>{String(diff.originalValue)}</span>}
|
|
||||||
{hasLeftValue && hasRightValue ? <Icon name="arrow-right" /> : null}
|
|
||||||
{hasRightValue && <span className={styles}>{String(diff.value)}</span>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => css`
|
|
||||||
background-color: ${theme.colors.action.hover};
|
|
||||||
border-radius: ${theme.shape.radius.default};
|
|
||||||
color: ${theme.colors.text.primary};
|
|
||||||
font-size: ${theme.typography.body.fontSize};
|
|
||||||
margin: 0 ${theme.spacing(0.5)};
|
|
||||||
padding: ${theme.spacing(0.5, 1)};
|
|
||||||
`;
|
|
@ -1,73 +0,0 @@
|
|||||||
import { css } from '@emotion/css';
|
|
||||||
import React from 'react';
|
|
||||||
import ReactDiffViewer, { ReactDiffViewerProps, DiffMethod } from 'react-diff-viewer';
|
|
||||||
import tinycolor from 'tinycolor2';
|
|
||||||
|
|
||||||
import { useTheme2 } from '@grafana/ui';
|
|
||||||
|
|
||||||
export const DiffViewer = ({ oldValue, newValue }: ReactDiffViewerProps) => {
|
|
||||||
const theme = useTheme2();
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
variables: {
|
|
||||||
// the light theme supplied by ReactDiffViewer is very similar to Grafana
|
|
||||||
// the dark theme needs some tweaks.
|
|
||||||
dark: {
|
|
||||||
diffViewerBackground: theme.colors.background.canvas,
|
|
||||||
diffViewerColor: theme.colors.text.primary,
|
|
||||||
addedBackground: tinycolor(theme.v1.palette.greenShade).setAlpha(0.3).toString(),
|
|
||||||
addedColor: 'white',
|
|
||||||
removedBackground: tinycolor(theme.v1.palette.redShade).setAlpha(0.3).toString(),
|
|
||||||
removedColor: 'white',
|
|
||||||
wordAddedBackground: tinycolor(theme.v1.palette.greenBase).setAlpha(0.4).toString(),
|
|
||||||
wordRemovedBackground: tinycolor(theme.v1.palette.redBase).setAlpha(0.4).toString(),
|
|
||||||
addedGutterBackground: tinycolor(theme.v1.palette.greenShade).setAlpha(0.2).toString(),
|
|
||||||
removedGutterBackground: tinycolor(theme.v1.palette.redShade).setAlpha(0.2).toString(),
|
|
||||||
gutterBackground: theme.colors.background.primary,
|
|
||||||
gutterBackgroundDark: theme.colors.background.primary,
|
|
||||||
highlightBackground: tinycolor(theme.colors.primary.main).setAlpha(0.4).toString(),
|
|
||||||
highlightGutterBackground: tinycolor(theme.colors.primary.shade).setAlpha(0.2).toString(),
|
|
||||||
codeFoldGutterBackground: theme.colors.background.secondary,
|
|
||||||
codeFoldBackground: theme.colors.background.secondary,
|
|
||||||
emptyLineBackground: theme.colors.background.secondary,
|
|
||||||
gutterColor: theme.colors.text.disabled,
|
|
||||||
addedGutterColor: theme.colors.text.primary,
|
|
||||||
removedGutterColor: theme.colors.text.primary,
|
|
||||||
codeFoldContentColor: theme.colors.text.disabled,
|
|
||||||
diffViewerTitleBackground: theme.colors.background.secondary,
|
|
||||||
diffViewerTitleColor: theme.colors.text.disabled,
|
|
||||||
diffViewerTitleBorderColor: theme.colors.border.strong,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
codeFold: {
|
|
||||||
fontSize: theme.typography.bodySmall.fontSize,
|
|
||||||
},
|
|
||||||
gutter: `
|
|
||||||
pre {
|
|
||||||
color: ${tinycolor(theme.colors.text.disabled).setAlpha(1).toString()};
|
|
||||||
opacity: 0.61;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css`
|
|
||||||
font-size: ${theme.typography.bodySmall.fontSize};
|
|
||||||
// prevent global styles interfering with diff viewer
|
|
||||||
pre {
|
|
||||||
all: revert;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<ReactDiffViewer
|
|
||||||
styles={styles}
|
|
||||||
oldValue={oldValue}
|
|
||||||
newValue={newValue}
|
|
||||||
splitView={false}
|
|
||||||
compareMethod={DiffMethod.CSS}
|
|
||||||
useDarkTheme={theme.isDark}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,75 +0,0 @@
|
|||||||
import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures';
|
|
||||||
|
|
||||||
import { HistorySrv } from './HistorySrv';
|
|
||||||
import { restore, versions } from './__mocks__/dashboardHistoryMocks';
|
|
||||||
|
|
||||||
const getMock = jest.fn().mockResolvedValue({});
|
|
||||||
const postMock = jest.fn().mockResolvedValue({});
|
|
||||||
|
|
||||||
jest.mock('app/core/store');
|
|
||||||
jest.mock('@grafana/runtime', () => {
|
|
||||||
const original = jest.requireActual('@grafana/runtime');
|
|
||||||
|
|
||||||
return {
|
|
||||||
...original,
|
|
||||||
getBackendSrv: () => ({
|
|
||||||
post: postMock,
|
|
||||||
get: getMock,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('historySrv', () => {
|
|
||||||
const versionsResponse = versions();
|
|
||||||
const restoreResponse = restore;
|
|
||||||
|
|
||||||
let historySrv = new HistorySrv();
|
|
||||||
|
|
||||||
const dash = createDashboardModelFixture({ uid: '_U4zObQMz' });
|
|
||||||
const emptyDash = createDashboardModelFixture();
|
|
||||||
const historyListOpts = { limit: 10, start: 0 };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getHistoryList', () => {
|
|
||||||
it('should return a versions array for the given dashboard id', () => {
|
|
||||||
getMock.mockImplementation(() => Promise.resolve(versionsResponse));
|
|
||||||
historySrv = new HistorySrv();
|
|
||||||
|
|
||||||
return historySrv.getHistoryList(dash.uid, historyListOpts).then((versions) => {
|
|
||||||
expect(versions).toEqual(versionsResponse);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return an empty array when not given an id', () => {
|
|
||||||
return historySrv.getHistoryList(emptyDash.uid, historyListOpts).then((versions) => {
|
|
||||||
expect(versions).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return an empty array when not given a dashboard id', () => {
|
|
||||||
return historySrv.getHistoryList(null as unknown as string, historyListOpts).then((versions) => {
|
|
||||||
expect(versions).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('restoreDashboard', () => {
|
|
||||||
it('should return a success response given valid parameters', () => {
|
|
||||||
const version = 6;
|
|
||||||
postMock.mockImplementation(() => Promise.resolve(restoreResponse(version)));
|
|
||||||
historySrv = new HistorySrv();
|
|
||||||
return historySrv.restoreDashboard(dash, version).then((response) => {
|
|
||||||
expect(response).toEqual(restoreResponse(version));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return an empty object when not given an id', async () => {
|
|
||||||
historySrv = new HistorySrv();
|
|
||||||
const rsp = await historySrv.restoreDashboard(emptyDash, 6);
|
|
||||||
expect(rsp).toEqual({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,51 +0,0 @@
|
|||||||
import { isNumber } from 'lodash';
|
|
||||||
|
|
||||||
import { getBackendSrv } from '@grafana/runtime';
|
|
||||||
|
|
||||||
import { DashboardModel } from '../../state/DashboardModel';
|
|
||||||
|
|
||||||
export interface HistoryListOpts {
|
|
||||||
limit: number;
|
|
||||||
start: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RevisionsModel {
|
|
||||||
id: number;
|
|
||||||
checked: boolean;
|
|
||||||
dashboardUID: string;
|
|
||||||
parentVersion: number;
|
|
||||||
version: number;
|
|
||||||
created: Date;
|
|
||||||
createdBy: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiffTarget {
|
|
||||||
dashboardUID: string;
|
|
||||||
version: number;
|
|
||||||
unsavedDashboard?: DashboardModel; // when doing diffs against unsaved dashboard version
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HistorySrv {
|
|
||||||
getHistoryList(dashboardUID: string, options: HistoryListOpts) {
|
|
||||||
if (typeof dashboardUID !== 'string') {
|
|
||||||
return Promise.resolve([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getBackendSrv().get(`api/dashboards/uid/${dashboardUID}/versions`, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
getDashboardVersion(uid: string, version: number) {
|
|
||||||
return getBackendSrv().get(`api/dashboards/uid/${uid}/versions/${version}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreDashboard(dashboard: DashboardModel, version: number) {
|
|
||||||
const uid = dashboard && dashboard.uid ? dashboard.uid : void 0;
|
|
||||||
const url = `api/dashboards/uid/${uid}/restore`;
|
|
||||||
|
|
||||||
return uid && isNumber(version) ? getBackendSrv().post(url, { version }) : Promise.resolve({});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const historySrv = new HistorySrv();
|
|
||||||
export { historySrv };
|
|
@ -1,31 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Tooltip, Button, Stack } from '@grafana/ui';
|
|
||||||
|
|
||||||
type VersionsButtonsType = {
|
|
||||||
hasMore: boolean;
|
|
||||||
canCompare: boolean;
|
|
||||||
getVersions: (append: boolean) => void;
|
|
||||||
getDiff: () => void;
|
|
||||||
isLastPage: boolean;
|
|
||||||
};
|
|
||||||
export const VersionsHistoryButtons = ({
|
|
||||||
hasMore,
|
|
||||||
canCompare,
|
|
||||||
getVersions,
|
|
||||||
getDiff,
|
|
||||||
isLastPage,
|
|
||||||
}: VersionsButtonsType) => (
|
|
||||||
<Stack>
|
|
||||||
{hasMore && (
|
|
||||||
<Button type="button" onClick={() => getVersions(true)} variant="secondary" disabled={isLastPage}>
|
|
||||||
Show more versions
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Tooltip content="Select two versions to start comparing" placement="bottom">
|
|
||||||
<Button type="button" disabled={!canCompare} onClick={getDiff} icon="code-branch">
|
|
||||||
Compare versions
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
@ -1,82 +0,0 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
|
||||||
import { Button, ModalsController, CollapsableSection, HorizontalGroup, useStyles2 } from '@grafana/ui';
|
|
||||||
|
|
||||||
import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings';
|
|
||||||
|
|
||||||
import { DiffGroup } from './DiffGroup';
|
|
||||||
import { DiffViewer } from './DiffViewer';
|
|
||||||
import { RevertDashboardModal } from './RevertDashboardModal';
|
|
||||||
import { jsonDiff } from './utils';
|
|
||||||
|
|
||||||
type DiffViewProps = {
|
|
||||||
isNewLatest: boolean;
|
|
||||||
newInfo: DecoratedRevisionModel;
|
|
||||||
baseInfo: DecoratedRevisionModel;
|
|
||||||
diffData: { lhs: unknown; rhs: unknown };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VersionHistoryComparison = ({ baseInfo, newInfo, diffData, isNewLatest }: DiffViewProps) => {
|
|
||||||
const diff = jsonDiff(diffData.lhs, diffData.rhs);
|
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className={styles.spacer}>
|
|
||||||
<HorizontalGroup justify="space-between" align="center">
|
|
||||||
<div>
|
|
||||||
<p className={styles.versionInfo}>
|
|
||||||
<strong>Version {newInfo.version}</strong> updated by {newInfo.createdBy} {newInfo.ageString} -{' '}
|
|
||||||
{newInfo.message}
|
|
||||||
</p>
|
|
||||||
<p className={cx(styles.versionInfo, styles.noMarginBottom)}>
|
|
||||||
<strong>Version {baseInfo.version}</strong> updated by {baseInfo.createdBy} {baseInfo.ageString} -{' '}
|
|
||||||
{baseInfo.message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{isNewLatest && (
|
|
||||||
<ModalsController>
|
|
||||||
{({ showModal, hideModal }) => (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
icon="history"
|
|
||||||
onClick={() => {
|
|
||||||
showModal(RevertDashboardModal, {
|
|
||||||
version: baseInfo.version,
|
|
||||||
hideModal,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Restore to version {baseInfo.version}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</ModalsController>
|
|
||||||
)}
|
|
||||||
</HorizontalGroup>
|
|
||||||
</div>
|
|
||||||
<div className={styles.spacer}>
|
|
||||||
{Object.entries(diff).map(([key, diffs]) => (
|
|
||||||
<DiffGroup diffs={diffs} key={key} title={key} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<CollapsableSection isOpen={false} label="View JSON Diff">
|
|
||||||
<DiffViewer oldValue={JSON.stringify(diffData.lhs, null, 2)} newValue={JSON.stringify(diffData.rhs, null, 2)} />
|
|
||||||
</CollapsableSection>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
|
||||||
spacer: css`
|
|
||||||
margin-bottom: ${theme.spacing(4)};
|
|
||||||
`,
|
|
||||||
versionInfo: css`
|
|
||||||
color: ${theme.colors.text.secondary};
|
|
||||||
font-size: ${theme.typography.bodySmall.fontSize};
|
|
||||||
`,
|
|
||||||
noMarginBottom: css`
|
|
||||||
margin-bottom: 0;
|
|
||||||
`,
|
|
||||||
});
|
|
@ -1,41 +0,0 @@
|
|||||||
import { css } from '@emotion/css';
|
|
||||||
import { noop } from 'lodash';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
|
||||||
import { Icon, IconButton, useStyles2 } from '@grafana/ui';
|
|
||||||
|
|
||||||
type VersionHistoryHeaderProps = {
|
|
||||||
onClick?: () => void;
|
|
||||||
baseVersion?: number;
|
|
||||||
newVersion?: number;
|
|
||||||
isNewLatest?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VersionHistoryHeader = ({
|
|
||||||
onClick = noop,
|
|
||||||
baseVersion = 0,
|
|
||||||
newVersion = 0,
|
|
||||||
isNewLatest = false,
|
|
||||||
}: VersionHistoryHeaderProps) => {
|
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<h3 className={styles.header}>
|
|
||||||
<IconButton name="arrow-left" size="xl" onClick={onClick} tooltip="Reset version" />
|
|
||||||
<span>
|
|
||||||
Comparing {baseVersion} <Icon name="arrows-h" /> {newVersion}{' '}
|
|
||||||
{isNewLatest && <cite className="muted">(Latest)</cite>}
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
|
||||||
header: css`
|
|
||||||
font-size: ${theme.typography.h3.fontSize};
|
|
||||||
display: flex;
|
|
||||||
gap: ${theme.spacing(2)};
|
|
||||||
margin-bottom: ${theme.spacing(3)};
|
|
||||||
`,
|
|
||||||
});
|
|
@ -1,73 +0,0 @@
|
|||||||
import { css } from '@emotion/css';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Checkbox, Button, Tag, ModalsController } from '@grafana/ui';
|
|
||||||
|
|
||||||
import { DecoratedRevisionModel } from '../DashboardSettings/VersionsSettings';
|
|
||||||
|
|
||||||
import { RevertDashboardModal } from './RevertDashboardModal';
|
|
||||||
|
|
||||||
type VersionsTableProps = {
|
|
||||||
versions: DecoratedRevisionModel[];
|
|
||||||
canCompare: boolean;
|
|
||||||
onCheck: (ev: React.FormEvent<HTMLInputElement>, versionId: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VersionHistoryTable = ({ versions, canCompare, onCheck }: VersionsTableProps) => (
|
|
||||||
<table className="filter-table gf-form-group">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="width-4"></th>
|
|
||||||
<th className="width-4">Version</th>
|
|
||||||
<th className="width-14">Date</th>
|
|
||||||
<th className="width-10">Updated by</th>
|
|
||||||
<th>Notes</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{versions.map((version, idx) => (
|
|
||||||
<tr key={version.id}>
|
|
||||||
<td>
|
|
||||||
<Checkbox
|
|
||||||
aria-label={`Toggle selection of version ${version.version}`}
|
|
||||||
className={css`
|
|
||||||
display: inline;
|
|
||||||
`}
|
|
||||||
checked={version.checked}
|
|
||||||
onChange={(ev) => onCheck(ev, version.id)}
|
|
||||||
disabled={!version.checked && canCompare}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>{version.version}</td>
|
|
||||||
<td>{version.createdDateString}</td>
|
|
||||||
<td>{version.createdBy}</td>
|
|
||||||
<td>{version.message}</td>
|
|
||||||
<td className="text-right">
|
|
||||||
{idx === 0 ? (
|
|
||||||
<Tag name="Latest" colorIndex={17} />
|
|
||||||
) : (
|
|
||||||
<ModalsController>
|
|
||||||
{({ showModal, hideModal }) => (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
icon="history"
|
|
||||||
onClick={() => {
|
|
||||||
showModal(RevertDashboardModal, {
|
|
||||||
version: version.version,
|
|
||||||
hideModal,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Restore
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</ModalsController>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
@ -1,176 +0,0 @@
|
|||||||
export function versions() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
dashboardId: 1,
|
|
||||||
dashboardUID: '_U4zObQMz',
|
|
||||||
parentVersion: 3,
|
|
||||||
restoredFrom: 0,
|
|
||||||
version: 4,
|
|
||||||
created: '2017-02-22T17:43:01-08:00',
|
|
||||||
createdBy: 'admin',
|
|
||||||
message: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
dashboardId: 1,
|
|
||||||
dashboardUID: '_U4zObQMz',
|
|
||||||
parentVersion: 1,
|
|
||||||
restoredFrom: 1,
|
|
||||||
version: 3,
|
|
||||||
created: '2017-02-22T17:43:01-08:00',
|
|
||||||
createdBy: 'admin',
|
|
||||||
message: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
dashboardId: 1,
|
|
||||||
dashboardUID: '_U4zObQMz',
|
|
||||||
parentVersion: 0,
|
|
||||||
restoredFrom: -1,
|
|
||||||
version: 2,
|
|
||||||
created: '2017-02-22T17:29:52-08:00',
|
|
||||||
createdBy: 'admin',
|
|
||||||
message: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
dashboardId: 1,
|
|
||||||
dashboardUID: '_U4zObQMz',
|
|
||||||
parentVersion: 0,
|
|
||||||
restoredFrom: -1,
|
|
||||||
slug: 'history-dashboard',
|
|
||||||
version: 1,
|
|
||||||
created: '2017-02-22T17:06:37-08:00',
|
|
||||||
createdBy: 'admin',
|
|
||||||
message: '',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function restore(version: number, restoredFrom?: number) {
|
|
||||||
return {
|
|
||||||
dashboard: {
|
|
||||||
meta: {
|
|
||||||
type: 'db',
|
|
||||||
canSave: true,
|
|
||||||
canEdit: true,
|
|
||||||
canStar: true,
|
|
||||||
slug: 'history-dashboard',
|
|
||||||
expires: '0001-01-01T00:00:00Z',
|
|
||||||
created: '2017-02-21T18:40:45-08:00',
|
|
||||||
updated: '2017-04-11T21:31:22.59219665-07:00',
|
|
||||||
updatedBy: 'admin',
|
|
||||||
createdBy: 'admin',
|
|
||||||
version: version,
|
|
||||||
},
|
|
||||||
dashboard: {
|
|
||||||
annotations: {
|
|
||||||
list: [],
|
|
||||||
},
|
|
||||||
description: 'A random dashboard for implementing the history list',
|
|
||||||
editable: true,
|
|
||||||
gnetId: null,
|
|
||||||
graphTooltip: 0,
|
|
||||||
id: 1,
|
|
||||||
uid: '_U4zObQMz',
|
|
||||||
links: [],
|
|
||||||
restoredFrom: restoredFrom,
|
|
||||||
rows: [
|
|
||||||
{
|
|
||||||
collapse: false,
|
|
||||||
height: '250px',
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
aliasColors: {},
|
|
||||||
bars: false,
|
|
||||||
datasource: null,
|
|
||||||
fill: 1,
|
|
||||||
id: 1,
|
|
||||||
legend: {
|
|
||||||
avg: false,
|
|
||||||
current: false,
|
|
||||||
max: false,
|
|
||||||
min: false,
|
|
||||||
show: true,
|
|
||||||
total: false,
|
|
||||||
values: false,
|
|
||||||
},
|
|
||||||
lines: true,
|
|
||||||
linewidth: 1,
|
|
||||||
nullPointMode: 'null',
|
|
||||||
percentage: false,
|
|
||||||
pointradius: 5,
|
|
||||||
points: false,
|
|
||||||
renderer: 'flot',
|
|
||||||
seriesOverrides: [],
|
|
||||||
span: 12,
|
|
||||||
stack: false,
|
|
||||||
steppedLine: false,
|
|
||||||
targets: [{}],
|
|
||||||
thresholds: [],
|
|
||||||
timeFrom: null,
|
|
||||||
timeShift: null,
|
|
||||||
title: 'Panel Title',
|
|
||||||
tooltip: {
|
|
||||||
shared: true,
|
|
||||||
sort: 0,
|
|
||||||
value_type: 'individual',
|
|
||||||
},
|
|
||||||
type: 'graph',
|
|
||||||
xaxis: {
|
|
||||||
mode: 'time',
|
|
||||||
name: null,
|
|
||||||
show: true,
|
|
||||||
values: [],
|
|
||||||
},
|
|
||||||
yaxes: [
|
|
||||||
{
|
|
||||||
format: 'short',
|
|
||||||
label: null,
|
|
||||||
logBase: 1,
|
|
||||||
max: null,
|
|
||||||
min: null,
|
|
||||||
show: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
format: 'short',
|
|
||||||
label: null,
|
|
||||||
logBase: 1,
|
|
||||||
max: null,
|
|
||||||
min: null,
|
|
||||||
show: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
repeat: null,
|
|
||||||
repeatIteration: null,
|
|
||||||
repeatRowId: null,
|
|
||||||
showTitle: false,
|
|
||||||
title: 'Dashboard Row',
|
|
||||||
titleSize: 'h6',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
schemaVersion: 14,
|
|
||||||
tags: ['development'],
|
|
||||||
templating: {
|
|
||||||
list: [],
|
|
||||||
},
|
|
||||||
time: {
|
|
||||||
from: 'now-6h',
|
|
||||||
to: 'now',
|
|
||||||
},
|
|
||||||
timepicker: {
|
|
||||||
refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'],
|
|
||||||
time_options: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'],
|
|
||||||
},
|
|
||||||
timezone: 'utc',
|
|
||||||
title: 'History Dashboard',
|
|
||||||
version: version,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
message: 'Dashboard restored to version ' + version,
|
|
||||||
version: version,
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
export { HistorySrv, historySrv, RevisionsModel } from './HistorySrv';
|
|
||||||
export { VersionHistoryTable } from './VersionHistoryTable';
|
|
||||||
export { VersionHistoryHeader } from './VersionHistoryHeader';
|
|
||||||
export { VersionsHistoryButtons } from './VersionHistoryButtons';
|
|
||||||
export { VersionHistoryComparison } from './VersionHistoryComparison';
|
|
@ -4,13 +4,12 @@ import { useAsyncFn } from 'react-use';
|
|||||||
import { locationUtil } from '@grafana/data';
|
import { locationUtil } from '@grafana/data';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { locationService } from '@grafana/runtime';
|
||||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||||
|
import { historySrv } from 'app/features/dashboard-scene/settings/version-history';
|
||||||
import { useSelector } from 'app/types';
|
import { useSelector } from 'app/types';
|
||||||
|
|
||||||
import { dashboardWatcher } from '../../../live/dashboard/dashboardWatcher';
|
import { dashboardWatcher } from '../../../live/dashboard/dashboardWatcher';
|
||||||
import { DashboardModel } from '../../state';
|
import { DashboardModel } from '../../state';
|
||||||
|
|
||||||
import { historySrv } from './HistorySrv';
|
|
||||||
|
|
||||||
const restoreDashboard = async (version: number, dashboard: DashboardModel) => {
|
const restoreDashboard = async (version: number, dashboard: DashboardModel) => {
|
||||||
// Skip the watcher logic for this save since it's handled by the hook
|
// Skip the watcher logic for this save since it's handled by the hook
|
||||||
dashboardWatcher.ignoreNextSave();
|
dashboardWatcher.ignoreNextSave();
|
||||||
|
@ -1,293 +0,0 @@
|
|||||||
import { Diff, getDiffOperationText, getDiffText, jsonDiff } from './utils';
|
|
||||||
|
|
||||||
describe('getDiffOperationText', () => {
|
|
||||||
const cases = [
|
|
||||||
['add', 'added'],
|
|
||||||
['remove', 'deleted'],
|
|
||||||
['replace', 'changed'],
|
|
||||||
['byDefault', 'changed'],
|
|
||||||
];
|
|
||||||
|
|
||||||
test.each(cases)('it returns the correct verb for an operation', (operation, expected) => {
|
|
||||||
expect(getDiffOperationText(operation)).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
type DiffTextCase = [Partial<Diff>, string];
|
|
||||||
describe('getDiffText', () => {
|
|
||||||
const addEmptyArray: DiffTextCase = [
|
|
||||||
{ op: 'add', value: [], path: ['annotations', 'list'], startLineNumber: 24 },
|
|
||||||
'added list',
|
|
||||||
];
|
|
||||||
const addArrayNumericProp: DiffTextCase = [
|
|
||||||
{
|
|
||||||
op: 'add',
|
|
||||||
value: ['tag'],
|
|
||||||
path: ['panels', '3'],
|
|
||||||
},
|
|
||||||
'added item 3',
|
|
||||||
];
|
|
||||||
const addArrayProp: DiffTextCase = [
|
|
||||||
{
|
|
||||||
op: 'add',
|
|
||||||
value: [{ name: 'dummy target 1' }, { name: 'dummy target 2' }],
|
|
||||||
path: ['panels', '3', 'targets'],
|
|
||||||
},
|
|
||||||
'added 2 targets',
|
|
||||||
];
|
|
||||||
const addValueNumericProp: DiffTextCase = [
|
|
||||||
{
|
|
||||||
op: 'add',
|
|
||||||
value: 'foo',
|
|
||||||
path: ['panels', '3'],
|
|
||||||
},
|
|
||||||
'added item 3',
|
|
||||||
];
|
|
||||||
const addValueProp: DiffTextCase = [
|
|
||||||
{
|
|
||||||
op: 'add',
|
|
||||||
value: 'foo',
|
|
||||||
path: ['panels', '3', 'targets'],
|
|
||||||
},
|
|
||||||
'added targets',
|
|
||||||
];
|
|
||||||
|
|
||||||
const removeEmptyArray: DiffTextCase = [
|
|
||||||
{ op: 'remove', originalValue: [], path: ['annotations', 'list'], startLineNumber: 24 },
|
|
||||||
'deleted list',
|
|
||||||
];
|
|
||||||
const removeArrayNumericProp: DiffTextCase = [
|
|
||||||
{
|
|
||||||
op: 'remove',
|
|
||||||
originalValue: ['tag'],
|
|
||||||
path: ['panels', '3'],
|
|
||||||
},
|
|
||||||
'deleted item 3',
|
|
||||||
];
|
|
||||||
const removeArrayProp: DiffTextCase = [
|
|
||||||
{
|
|
||||||
op: 'remove',
|
|
||||||
originalValue: [{ name: 'dummy target 1' }, { name: 'dummy target 2' }],
|
|
||||||
path: ['panels', '3', 'targets'],
|
|
||||||
},
|
|
||||||
'deleted 2 targets',
|
|
||||||
];
|
|
||||||
const removeValueNumericProp: DiffTextCase = [
|
|
||||||
{
|
|
||||||
op: 'remove',
|
|
||||||
originalValue: 'foo',
|
|
||||||
path: ['panels', '3'],
|
|
||||||
},
|
|
||||||
'deleted item 3',
|
|
||||||
];
|
|
||||||
const removeValueProp: DiffTextCase = [
|
|
||||||
{
|
|
||||||
op: 'remove',
|
|
||||||
originalValue: 'foo',
|
|
||||||
path: ['panels', '3', 'targets'],
|
|
||||||
},
|
|
||||||
'deleted targets',
|
|
||||||
];
|
|
||||||
const replaceValueNumericProp: DiffTextCase = [
|
|
||||||
{
|
|
||||||
op: 'replace',
|
|
||||||
originalValue: 'foo',
|
|
||||||
value: 'bar',
|
|
||||||
path: ['panels', '3'],
|
|
||||||
},
|
|
||||||
'changed item 3',
|
|
||||||
];
|
|
||||||
const replaceValueProp: DiffTextCase = [
|
|
||||||
{
|
|
||||||
op: 'replace',
|
|
||||||
originalValue: 'foo',
|
|
||||||
value: 'bar',
|
|
||||||
path: ['panels', '3', 'targets'],
|
|
||||||
},
|
|
||||||
'changed targets',
|
|
||||||
];
|
|
||||||
|
|
||||||
const cases = [
|
|
||||||
addEmptyArray,
|
|
||||||
addArrayNumericProp,
|
|
||||||
addArrayProp,
|
|
||||||
addValueNumericProp,
|
|
||||||
addValueProp,
|
|
||||||
removeEmptyArray,
|
|
||||||
removeArrayNumericProp,
|
|
||||||
removeArrayProp,
|
|
||||||
removeValueNumericProp,
|
|
||||||
removeValueProp,
|
|
||||||
replaceValueNumericProp,
|
|
||||||
replaceValueProp,
|
|
||||||
];
|
|
||||||
|
|
||||||
test.each(cases)(
|
|
||||||
'returns a semantic message based on the type of diff, the values and the location of the change',
|
|
||||||
(diff: Partial<Diff>, expected: string) => {
|
|
||||||
expect(getDiffText(diff as unknown as Diff)).toBe(expected);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('jsonDiff', () => {
|
|
||||||
it('returns data related to each change', () => {
|
|
||||||
const lhs = {
|
|
||||||
annotations: {
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
builtIn: 1,
|
|
||||||
datasource: '-- Grafana --',
|
|
||||||
enable: true,
|
|
||||||
hide: true,
|
|
||||||
iconColor: 'rgba(0, 211, 255, 1)',
|
|
||||||
name: 'Annotations & Alerts',
|
|
||||||
type: 'dashboard',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
editable: true,
|
|
||||||
gnetId: null,
|
|
||||||
graphTooltip: 0,
|
|
||||||
id: 141,
|
|
||||||
links: [],
|
|
||||||
panels: [],
|
|
||||||
schemaVersion: 27,
|
|
||||||
tags: [],
|
|
||||||
templating: {
|
|
||||||
list: [],
|
|
||||||
},
|
|
||||||
time: {
|
|
||||||
from: 'now-6h',
|
|
||||||
to: 'now',
|
|
||||||
},
|
|
||||||
timepicker: {},
|
|
||||||
timezone: '',
|
|
||||||
title: 'test dashboard',
|
|
||||||
uid: '_U4zObQMz',
|
|
||||||
version: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const rhs = {
|
|
||||||
annotations: {
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
builtIn: 1,
|
|
||||||
datasource: '-- Grafana --',
|
|
||||||
enable: true,
|
|
||||||
hide: true,
|
|
||||||
iconColor: 'rgba(0, 211, 255, 1)',
|
|
||||||
name: 'Annotations & Alerts',
|
|
||||||
type: 'dashboard',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
description: 'a description',
|
|
||||||
editable: true,
|
|
||||||
gnetId: null,
|
|
||||||
graphTooltip: 1,
|
|
||||||
id: 141,
|
|
||||||
links: [],
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
type: 'graph',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
schemaVersion: 27,
|
|
||||||
tags: ['the tag'],
|
|
||||||
templating: {
|
|
||||||
list: [],
|
|
||||||
},
|
|
||||||
time: {
|
|
||||||
from: 'now-6h',
|
|
||||||
to: 'now',
|
|
||||||
},
|
|
||||||
timepicker: {
|
|
||||||
refresh_intervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'],
|
|
||||||
},
|
|
||||||
timezone: 'utc',
|
|
||||||
title: 'My favourite dashboard',
|
|
||||||
uid: '_U4zObQMz',
|
|
||||||
version: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
const expected = {
|
|
||||||
description: [
|
|
||||||
{
|
|
||||||
op: 'add',
|
|
||||||
originalValue: undefined,
|
|
||||||
path: ['description'],
|
|
||||||
startLineNumber: 14,
|
|
||||||
value: 'a description',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
graphTooltip: [
|
|
||||||
{
|
|
||||||
op: 'replace',
|
|
||||||
originalValue: 0,
|
|
||||||
path: ['graphTooltip'],
|
|
||||||
startLineNumber: 17,
|
|
||||||
value: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
panels: [
|
|
||||||
{
|
|
||||||
op: 'add',
|
|
||||||
originalValue: undefined,
|
|
||||||
path: ['panels', '0'],
|
|
||||||
startLineNumber: 21,
|
|
||||||
value: {
|
|
||||||
type: 'graph',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tags: [
|
|
||||||
{
|
|
||||||
op: 'add',
|
|
||||||
originalValue: undefined,
|
|
||||||
path: ['tags', '0'],
|
|
||||||
startLineNumber: 27,
|
|
||||||
value: 'the tag',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
timepicker: [
|
|
||||||
{
|
|
||||||
op: 'add',
|
|
||||||
originalValue: undefined,
|
|
||||||
path: ['timepicker', 'refresh_intervals'],
|
|
||||||
startLineNumber: 37,
|
|
||||||
value: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d', '2d'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
timezone: [
|
|
||||||
{
|
|
||||||
op: 'replace',
|
|
||||||
originalValue: '',
|
|
||||||
path: ['timezone'],
|
|
||||||
startLineNumber: 51,
|
|
||||||
value: 'utc',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
title: [
|
|
||||||
{
|
|
||||||
op: 'replace',
|
|
||||||
originalValue: 'test dashboard',
|
|
||||||
path: ['title'],
|
|
||||||
startLineNumber: 52,
|
|
||||||
value: 'My favourite dashboard',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
version: [
|
|
||||||
{
|
|
||||||
op: 'replace',
|
|
||||||
originalValue: 2,
|
|
||||||
path: ['version'],
|
|
||||||
startLineNumber: 54,
|
|
||||||
value: 3,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(jsonDiff(lhs, rhs)).toStrictEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,100 +0,0 @@
|
|||||||
import { compare, Operation } from 'fast-json-patch';
|
|
||||||
// @ts-ignore
|
|
||||||
import jsonMap from 'json-source-map';
|
|
||||||
import { flow, get, isArray, isEmpty, last, sortBy, tail, toNumber, isNaN } from 'lodash';
|
|
||||||
|
|
||||||
export type Diff = {
|
|
||||||
op: 'add' | 'replace' | 'remove' | 'copy' | 'test' | '_get' | 'move';
|
|
||||||
value: unknown;
|
|
||||||
originalValue: unknown;
|
|
||||||
path: string[];
|
|
||||||
startLineNumber: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Diffs = {
|
|
||||||
[key: string]: Diff[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const jsonDiff = (lhs: any, rhs: any): Diffs => {
|
|
||||||
const diffs = compare(lhs, rhs);
|
|
||||||
const lhsMap = jsonMap.stringify(lhs, null, 2);
|
|
||||||
const rhsMap = jsonMap.stringify(rhs, null, 2);
|
|
||||||
|
|
||||||
const getDiffInformation = (diffs: Operation[]): Diff[] => {
|
|
||||||
return diffs.map((diff) => {
|
|
||||||
let originalValue = undefined;
|
|
||||||
let value = undefined;
|
|
||||||
let startLineNumber = 0;
|
|
||||||
|
|
||||||
const path = tail(diff.path.split('/'));
|
|
||||||
|
|
||||||
if (diff.op === 'replace' && rhsMap.pointers[diff.path]) {
|
|
||||||
originalValue = get(lhs, path);
|
|
||||||
value = diff.value;
|
|
||||||
startLineNumber = rhsMap.pointers[diff.path].value.line;
|
|
||||||
}
|
|
||||||
if (diff.op === 'add' && rhsMap.pointers[diff.path]) {
|
|
||||||
value = diff.value;
|
|
||||||
startLineNumber = rhsMap.pointers[diff.path].value.line;
|
|
||||||
}
|
|
||||||
if (diff.op === 'remove' && lhsMap.pointers[diff.path]) {
|
|
||||||
originalValue = get(lhs, path);
|
|
||||||
startLineNumber = lhsMap.pointers[diff.path].value.line;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
op: diff.op,
|
|
||||||
value,
|
|
||||||
path,
|
|
||||||
originalValue,
|
|
||||||
startLineNumber,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortByLineNumber = (diffs: Diff[]) => sortBy(diffs, 'startLineNumber');
|
|
||||||
const groupByPath = (diffs: Diff[]) =>
|
|
||||||
diffs.reduce<Record<string, Diff[]>>((acc, value) => {
|
|
||||||
const groupKey: string = value.path[0];
|
|
||||||
if (!acc[groupKey]) {
|
|
||||||
acc[groupKey] = [];
|
|
||||||
}
|
|
||||||
acc[groupKey].push(value);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
return flow([getDiffInformation, sortByLineNumber, groupByPath])(diffs);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDiffText = (diff: Diff, showProp = true) => {
|
|
||||||
const prop = last(diff.path)!;
|
|
||||||
const propIsNumeric = isNumeric(prop);
|
|
||||||
const val = diff.op === 'remove' ? diff.originalValue : diff.value;
|
|
||||||
let text = getDiffOperationText(diff.op);
|
|
||||||
|
|
||||||
if (showProp) {
|
|
||||||
if (propIsNumeric) {
|
|
||||||
text += ` item ${prop}`;
|
|
||||||
} else {
|
|
||||||
if (isArray(val) && !isEmpty(val)) {
|
|
||||||
text += ` ${val.length} ${prop}`;
|
|
||||||
} else {
|
|
||||||
text += ` ${prop}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return text;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNumeric = (value: string) => !isNaN(toNumber(value));
|
|
||||||
|
|
||||||
export const getDiffOperationText = (operation: string): string => {
|
|
||||||
if (operation === 'add') {
|
|
||||||
return 'added';
|
|
||||||
}
|
|
||||||
if (operation === 'remove') {
|
|
||||||
return 'deleted';
|
|
||||||
}
|
|
||||||
return 'changed';
|
|
||||||
};
|
|
@ -4,5 +4,4 @@ import './services/DashboardSrv';
|
|||||||
// Components
|
// Components
|
||||||
import './components/DashExportModal';
|
import './components/DashExportModal';
|
||||||
import './components/DashNav';
|
import './components/DashNav';
|
||||||
import './components/VersionHistory';
|
|
||||||
import './components/DashboardSettings';
|
import './components/DashboardSettings';
|
||||||
|
Loading…
Reference in New Issue
Block a user