Scenes: Render versions in dashboard settings (#80229)

* Add versions tab in dashboard settings

* Fetch and render dashboard versions

* PR discussion changes

* remove unnecessary async in test

* PR discussion mods

* linter fix
This commit is contained in:
Victor Marin 2024-01-15 16:57:45 +02:00 committed by GitHub
parent d5db67a073
commit 1cf53a34d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1470 additions and 16 deletions

View File

@ -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"]

View File

@ -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 };
}

View File

@ -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<VersionsEditViewState> 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<VersionsEditView>) {
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 (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<div>TODO</div>
{isLoading ? (
<VersionsHistorySpinner msg="Fetching history list&hellip;" />
) : (
<VersionHistoryTable
versions={model.versions}
onCheck={(x, y) => {
console.log('todo');
}}
canCompare={canCompare}
/>
)}
{isAppending && <VersionsHistorySpinner msg="Fetching more entries&hellip;" />}
</Page>
);
}
export const VersionsHistorySpinner = ({ msg }: { msg: string }) => (
<HorizontalGroup>
<Spinner />
<em>{msg}</em>
</HorizontalGroup>
);

View File

@ -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 (
<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),
}),
});

View File

@ -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 ? (
<>
<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),
}),
});

View File

@ -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 && <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),
});

View File

@ -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 (
<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>
);
};

View File

@ -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({});
});
});
});

View File

@ -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 };

View File

@ -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 (
<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}`}
/>
);
};

View File

@ -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) => (
<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>
);

View File

@ -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 (
<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,
}),
});

View File

@ -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 (
<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),
}),
});

View File

@ -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<HTMLInputElement>, versionId: number) => void;
};
export const VersionHistoryTable = ({ versions, canCompare, onCheck }: VersionsTableProps) => (
<table className="filter-table">
<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>
);

View File

@ -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,
};
}

View File

@ -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';

View File

@ -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 };
};

View File

@ -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<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 as unknown as Dashboard, rhs as unknown as Dashboard)).toStrictEqual(expected);
});
});

View File

@ -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<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';
};

View File

@ -63,7 +63,7 @@ export class VersionsSettings extends PureComponent<Props, State> {
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<Props, State> {
}
}
const VersionsHistorySpinner = ({ msg }: { msg: string }) => (
export const VersionsHistorySpinner = ({ msg }: { msg: string }) => (
<HorizontalGroup>
<Spinner />
<em>{msg}</em>

View File

@ -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([]);
});
});

View File

@ -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) {