diff --git a/.betterer.results b/.betterer.results index 2730e52414c..8c7ff46d795 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2440,6 +2440,10 @@ exports[`better eslint`] = { "public/app/features/dashboard-scene/settings/variables/utils.ts:5381": [ [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": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx new file mode 100644 index 00000000000..7b927360567 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/VersionsEditView.test.tsx @@ -0,0 +1,112 @@ +import { SceneGridItem, SceneGridLayout, SceneTimeRange } from '@grafana/scenes'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { activateFullSceneTree } from '../utils/test-utils'; + +import { VERSIONS_FETCH_LIMIT, VersionsEditView } from './VersionsEditView'; +import { historySrv } from './version-history'; + +jest.mock('./version-history/HistorySrv'); + +describe('VersionsEditView', () => { + describe('Dashboard Versions state', () => { + let dashboard: DashboardScene; + let versionsView: VersionsEditView; + + beforeEach(async () => { + jest.mocked(historySrv.getHistoryList).mockResolvedValue(getVersions()); + + const result = await buildTestScene(); + dashboard = result.dashboard; + versionsView = result.versionsView; + }); + + it('should return the correct urlKey', () => { + expect(versionsView.getUrlKey()).toBe('versions'); + }); + + it('should return the dashboard', () => { + expect(versionsView.getDashboard()).toBe(dashboard); + }); + + it('should return the decorated list of versions', () => { + const versions = versionsView.versions; + + expect(versions).toHaveLength(2); + expect(versions[0].createdDateString).toBe('2017-02-22 20:43:01'); + expect(versions[0].ageString).toBe('7 years ago'); + expect(versions[1].createdDateString).toBe('2017-02-22 20:43:01'); + expect(versions[1].ageString).toBe('7 years ago'); + }); + + it('should bump the start threshold when fetching more versions', async () => { + expect(versionsView.start).toBe(VERSIONS_FETCH_LIMIT); + + versionsView.fetchVersions(true); + await new Promise(process.nextTick); + + expect(versionsView.start).toBe(VERSIONS_FETCH_LIMIT * 2); + }); + }); +}); + +function getVersions() { + 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: '', + }, + ]; +} + +async function buildTestScene() { + const versionsView = new VersionsEditView({ versions: [] }); + const dashboard = new DashboardScene({ + $timeRange: new SceneTimeRange({}), + title: 'hello', + uid: 'dash-1', + meta: { + canEdit: true, + }, + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 10, + height: 12, + body: undefined, + }), + ], + }), + editview: versionsView, + }); + + activateFullSceneTree(dashboard); + + await new Promise((r) => setTimeout(r, 1)); + + dashboard.onEnterEditMode(); + versionsView.activate(); + + return { dashboard, versionsView }; +} diff --git a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx index 1805a00096a..e5f69fee760 100644 --- a/public/app/features/dashboard-scene/settings/VersionsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/VersionsEditView.tsx @@ -1,36 +1,139 @@ import React from 'react'; -import { PageLayoutType } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; +import { PageLayoutType, dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data'; +import { SceneComponentProps, SceneObjectBase, sceneGraph } from '@grafana/scenes'; +import { HorizontalGroup, Spinner } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { DashboardScene } from '../scene/DashboardScene'; import { getDashboardSceneFor } from '../utils/utils'; import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils'; +import { RevisionsModel, VersionHistoryTable, historySrv } from './version-history'; -export interface VersionsEditViewState extends DashboardEditViewState {} +export const VERSIONS_FETCH_LIMIT = 10; + +export type DecoratedRevisionModel = RevisionsModel & { + createdDateString: string; + ageString: string; +}; + +export interface VersionsEditViewState extends DashboardEditViewState { + versions?: DecoratedRevisionModel[]; + isLoading?: boolean; + isAppending?: boolean; +} export class VersionsEditView extends SceneObjectBase implements DashboardEditView { public static Component = VersionsEditorSettingsListView; + private _limit: number = VERSIONS_FETCH_LIMIT; + private _start = 0; + + constructor(state: VersionsEditViewState) { + super({ + ...state, + versions: [], + isLoading: true, + isAppending: true, + }); + + this.addActivationHandler(() => { + this.fetchVersions(); + }); + } + + private get _dashboard(): DashboardScene { + return getDashboardSceneFor(this); + } + + public get versions(): DecoratedRevisionModel[] { + return this.state.versions ?? []; + } + + public get limit(): number { + return this._limit; + } + + public get start(): number { + return this._start; + } public getUrlKey(): string { return 'versions'; } public getDashboard(): DashboardScene { - return getDashboardSceneFor(this); + return this._dashboard; + } + + public getTimeRange() { + return sceneGraph.getTimeRange(this._dashboard); + } + + public fetchVersions(append = false): void { + const uid = this._dashboard.state.uid; + + if (!uid) { + return; + } + + this.setState({ isAppending: append }); + + historySrv + .getHistoryList(uid, { limit: this._limit, start: this._start }) + .then((result) => { + this.setState({ + isLoading: false, + versions: [...(this.state.versions ?? []), ...this.decorateVersions(result)], + }); + this._start += this._limit; + }) + .catch((err) => console.log(err)) + .finally(() => this.setState({ isAppending: false })); + } + + private decorateVersions(versions: RevisionsModel[]): DecoratedRevisionModel[] { + const timeZone = this.getTimeRange().getTimeZone(); + + return versions.map((version) => { + return { + ...version, + createdDateString: dateTimeFormat(version.created, { timeZone: timeZone }), + ageString: dateTimeFormatTimeAgo(version.created, { timeZone: timeZone }), + checked: false, + }; + }); } } function VersionsEditorSettingsListView({ model }: SceneComponentProps) { const dashboard = model.getDashboard(); - + const { isLoading, isAppending } = model.useState(); const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); + const canCompare = model.versions.filter((version) => version.checked).length === 2; + return ( -
TODO
+ {isLoading ? ( + + ) : ( + { + console.log('todo'); + }} + canCompare={canCompare} + /> + )} + {isAppending && }
); } + +export const VersionsHistorySpinner = ({ msg }: { msg: string }) => ( + + + {msg} + +); diff --git a/public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx new file mode 100644 index 00000000000..d5d67c34d83 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/DiffGroup.tsx @@ -0,0 +1,57 @@ +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 ( +
+ +
+ ); + } + + return ( +
+ +
    + {diffs.map((diff: Diff, idx: number) => { + return ( +
  • + {getDiffText(diff)} +
  • + ); + })} +
+
+ ); +}; + +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), + }), +}); diff --git a/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx new file mode 100644 index 00000000000..7622da29b7f --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/DiffTitle.tsx @@ -0,0 +1,61 @@ +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 ? ( + <> + {title}{' '} + {getDiffText(diff, diff.path.length > 1)} + + ) : ( +
+ {title}{' '} + {getDiffText(replaceDiff, false)} +
+ ); +}; + +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), + }), +}); diff --git a/public/app/features/dashboard-scene/settings/version-history/DiffValues.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffValues.tsx new file mode 100644 index 00000000000..181b9c07541 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/DiffValues.tsx @@ -0,0 +1,37 @@ +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 && {String(diff.originalValue)}} + {hasLeftValue && hasRightValue ? : null} + {hasRightValue && {String(diff.value)}} + + ); +}; + +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), + }); diff --git a/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx new file mode 100644 index 00000000000..89060012b00 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx @@ -0,0 +1,73 @@ +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 ( +
+ +
+ ); +}; diff --git a/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts new file mode 100644 index 00000000000..488b7722f89 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.test.ts @@ -0,0 +1,75 @@ +import { createDashboardModelFixture } from 'app/features/dashboard/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({}); + }); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts new file mode 100644 index 00000000000..f68390807c1 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/HistorySrv.ts @@ -0,0 +1,50 @@ +import { isNumber } from 'lodash'; + +import { getBackendSrv } from '@grafana/runtime'; +import { DashboardModel } from 'app/features/dashboard/state'; + +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 }; diff --git a/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx b/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx new file mode 100644 index 00000000000..e24c2155693 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/RevertDashboardModal.tsx @@ -0,0 +1,34 @@ +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 ( + Are you sure you want to restore the dashboard to version {version}? All unsaved changes will be lost.

+ } + confirmText={`Yes, restore to version ${version}`} + /> + ); +}; diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryButtons.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryButtons.tsx new file mode 100644 index 00000000000..073b9c64d8d --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryButtons.tsx @@ -0,0 +1,31 @@ +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) => ( + + {hasMore && ( + + )} + + + + +); diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx new file mode 100644 index 00000000000..a2239fa60a2 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryComparison.tsx @@ -0,0 +1,82 @@ +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 '../VersionsEditView'; + +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: string; rhs: string }; +}; + +export const VersionHistoryComparison = ({ baseInfo, newInfo, diffData, isNewLatest }: DiffViewProps) => { + const diff = jsonDiff(diffData.lhs, diffData.rhs); + const styles = useStyles2(getStyles); + + return ( +
+
+ +
+

+ Version {newInfo.version} updated by {newInfo.createdBy} {newInfo.ageString} -{' '} + {newInfo.message} +

+

+ Version {baseInfo.version} updated by {baseInfo.createdBy} {baseInfo.ageString} -{' '} + {baseInfo.message} +

+
+ {isNewLatest && ( + + {({ showModal, hideModal }) => ( + + )} + + )} +
+
+
+ {Object.entries(diff).map(([key, diffs]) => ( + + ))} +
+ + + +
+ ); +}; + +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, + }), +}); diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx new file mode 100644 index 00000000000..ccfdaa6d17e --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryHeader.tsx @@ -0,0 +1,41 @@ +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 ( +

+ + + Comparing {baseVersion} {newVersion}{' '} + {isNewLatest && (Latest)} + +

+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + header: css({ + 'font-size': theme.typography.h3.fontSize, + display: 'flex', + gap: theme.spacing(2), + 'margin-bottom': theme.spacing(3), + }), +}); diff --git a/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx new file mode 100644 index 00000000000..b766bdac061 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/VersionHistoryTable.tsx @@ -0,0 +1,73 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { Checkbox, Button, Tag, ModalsController } from '@grafana/ui'; + +import { DecoratedRevisionModel } from '../VersionsEditView'; + +import { RevertDashboardModal } from './RevertDashboardModal'; + +type VersionsTableProps = { + versions: DecoratedRevisionModel[]; + canCompare: boolean; + onCheck: (ev: React.FormEvent, versionId: number) => void; +}; + +export const VersionHistoryTable = ({ versions, canCompare, onCheck }: VersionsTableProps) => ( + + + + + + + + + + + + + {versions.map((version, idx) => ( + + + + + + + + + ))} + +
VersionDateUpdated byNotes
+ onCheck(ev, version.id)} + disabled={!version.checked && canCompare} + /> + {version.version}{version.createdDateString}{version.createdBy}{version.message} + {idx === 0 ? ( + + ) : ( + + {({ showModal, hideModal }) => ( + + )} + + )} +
+); diff --git a/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts b/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts new file mode 100644 index 00000000000..0de97c47a95 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/__mocks__/dashboardHistoryMocks.ts @@ -0,0 +1,176 @@ +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, + }; +} diff --git a/public/app/features/dashboard-scene/settings/version-history/index.ts b/public/app/features/dashboard-scene/settings/version-history/index.ts new file mode 100644 index 00000000000..c87d2d0b9b7 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/index.ts @@ -0,0 +1,5 @@ +export { HistorySrv, historySrv, RevisionsModel } from './HistorySrv'; +export { VersionHistoryTable } from './VersionHistoryTable'; +export { VersionHistoryHeader } from './VersionHistoryHeader'; +export { VersionsHistoryButtons } from './VersionHistoryButtons'; +export { VersionHistoryComparison } from './VersionHistoryComparison'; diff --git a/public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx b/public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx new file mode 100644 index 00000000000..447cd2fe392 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/useDashboardRestore.tsx @@ -0,0 +1,39 @@ +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 }; +}; diff --git a/public/app/features/dashboard-scene/settings/version-history/utils.test.ts b/public/app/features/dashboard-scene/settings/version-history/utils.test.ts new file mode 100644 index 00000000000..8d38c5cf6e9 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/utils.test.ts @@ -0,0 +1,295 @@ +import { Dashboard } from '@grafana/schema'; + +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, 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, 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 as unknown as Dashboard, rhs as unknown as Dashboard)).toStrictEqual(expected); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/version-history/utils.ts b/public/app/features/dashboard-scene/settings/version-history/utils.ts new file mode 100644 index 00000000000..ac036e2acae --- /dev/null +++ b/public/app/features/dashboard-scene/settings/version-history/utils.ts @@ -0,0 +1,104 @@ +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'; + +import { Dashboard } from '@grafana/schema'; + +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 type JSONValue = string | Dashboard; + +export const jsonDiff = (lhs: JSONValue, rhs: JSONValue): 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>((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'; +}; diff --git a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx index d2b13040688..bd4585b2c15 100644 --- a/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx @@ -63,7 +63,7 @@ export class VersionsSettings extends PureComponent { getVersions = (append = false) => { this.setState({ isAppending: append }); historySrv - .getHistoryList(this.props.dashboard, { limit: this.limit, start: this.start }) + .getHistoryList(this.props.dashboard.uid, { limit: this.limit, start: this.start }) .then((res) => { this.setState({ isLoading: false, @@ -186,7 +186,7 @@ export class VersionsSettings extends PureComponent { } } -const VersionsHistorySpinner = ({ msg }: { msg: string }) => ( +export const VersionsHistorySpinner = ({ msg }: { msg: string }) => ( {msg} diff --git a/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts index 8571795c054..ab89796f738 100644 --- a/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts +++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.test.ts @@ -1,4 +1,3 @@ -import { DashboardModel } from '../../state/DashboardModel'; import { createDashboardModelFixture } from '../../state/__fixtures__/dashboardFixtures'; import { HistorySrv } from './HistorySrv'; @@ -39,19 +38,19 @@ describe('historySrv', () => { getMock.mockImplementation(() => Promise.resolve(versionsResponse)); historySrv = new HistorySrv(); - return historySrv.getHistoryList(dash, historyListOpts).then((versions) => { + 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, historyListOpts).then((versions) => { + return historySrv.getHistoryList(emptyDash.uid, historyListOpts).then((versions) => { expect(versions).toEqual([]); }); }); - it('should return an empty array when not given a dashboard', () => { - return historySrv.getHistoryList(null as unknown as DashboardModel, historyListOpts).then((versions) => { + 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([]); }); }); diff --git a/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts index 351882ac6dc..bb3aaec7abe 100644 --- a/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts +++ b/public/app/features/dashboard/components/VersionHistory/HistorySrv.ts @@ -27,9 +27,12 @@ export interface DiffTarget { } export class HistorySrv { - getHistoryList(dashboard: DashboardModel, options: HistoryListOpts) { - const uid = dashboard && dashboard.uid ? dashboard.uid : void 0; - return uid ? getBackendSrv().get(`api/dashboards/uid/${uid}/versions`, options) : Promise.resolve([]); + 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) {