The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
grafana/public/app/features/dashboard/specs/audit_ctrl_specs.ts

417 lines
14 KiB

History and Version Control for Dashboard Updates A simple version control system for dashboards. Closes #1504. Goals 1. To create a new dashboard version every time a dashboard is saved. 2. To allow users to view all versions of a given dashboard. 3. To allow users to rollback to a previous version of a dashboard. 4. To allow users to compare two versions of a dashboard. Usage Navigate to a dashboard, and click the settings cog. From there, click the "Changelog" button to be brought to the Changelog view. In this view, a table containing each version of a dashboard can be seen. Each entry in the table represents a dashboard version. A selectable checkbox, the version number, date created, name of the user who created that version, and commit message is shown in the table, along with a button that allows a user to restore to a previous version of that dashboard. If a user wants to restore to a previous version of their dashboard, they can do so by clicking the previously mentioned button. If a user wants to compare two different versions of a dashboard, they can do so by clicking the checkbox of two different dashboard versions, then clicking the "Compare versions" button located below the dashboard. From there, the user is brought to a view showing a summary of the dashboard differences. Each summarized change contains a link that can be clicked to take the user a JSON diff highlighting the changes line by line. Overview of Changes Backend Changes - A `dashboard_version` table was created to store each dashboard version, along with a dashboard version model and structs to represent the queries and commands necessary for the dashboard version API methods. - API endpoints were created to support working with dashboard versions. - Methods were added to create, update, read, and destroy dashboard versions in the database. - Logic was added to compute the diff between two versions, and display it to the user. - The dashboard migration logic was updated to save a "Version 1" of each existing dashboard in the database. Frontend Changes - New views - Methods to pull JSON and HTML from endpoints New API Endpoints Each endpoint requires the authorization header to be sent in the format, ``` Authorization: Bearer <jwt> ``` where `<jwt>` is a JSON web token obtained from the Grafana admin panel. `GET "/api/dashboards/db/:dashboardId/versions?orderBy=<string>&limit=<int>&start=<int>"` Get all dashboard versions for the given dashboard ID. Accepts three URL parameters: - `orderBy` String to order the results by. Possible values are `version`, `created`, `created_by`, `message`. Default is `versions`. Ordering is always in descending order. - `limit` Maximum number of results to return - `start` Position in results to start from `GET "/api/dashboards/db/:dashboardId/versions/:id"` Get an individual dashboard version by ID, for the given dashboard ID. `POST "/api/dashboards/db/:dashboardId/restore"` Restore to the given dashboard version. Post body is of content-type `application/json`, and must contain. ```json { "dashboardId": <int>, "version": <int> } ``` `GET "/api/dashboards/db/:dashboardId/compare/:versionA...:versionB"` Compare two dashboard versions by ID for the given dashboard ID, returning a JSON delta formatted representation of the diff. The URL format follows what GitHub does. For example, visiting [/api/dashboards/db/18/compare/22...33](http://ec2-54-80-139-44.compute-1.amazonaws.com:3000/api/dashboards/db/18/compare/22...33) will return the diff between versions 22 and 33 for the dashboard ID 18. Dependencies Added - The Go package [gojsondiff](https://github.com/yudai/gojsondiff) was added and vendored.
9 years ago
import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
import _ from 'lodash';
import {AuditLogCtrl} from 'app/features/dashboard/audit/audit_ctrl';
import { versions, compare, restore } from 'test/mocks/audit-mocks';
import config from 'app/core/config';
describe('AuditLogCtrl', function() {
var RESTORE_ID = 4;
var ctx: any = {};
var versionsResponse: any = versions();
var restoreResponse: any = restore(7, RESTORE_ID);
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(angularMocks.inject($rootScope => {
ctx.scope = $rootScope.$new();
}));
var auditSrv;
var $rootScope;
beforeEach(function() {
auditSrv = {
getAuditLog: sinon.stub(),
compareVersions: sinon.stub(),
restoreDashboard: sinon.stub(),
};
$rootScope = {
appEvent: sinon.spy(),
onAppEvent: sinon.spy(),
};
});
describe('when the audit log component is loaded', function() {
var deferred;
beforeEach(angularMocks.inject(($controller, $q) => {
deferred = $q.defer();
auditSrv.getAuditLog.returns(deferred.promise);
ctx.ctrl = $controller(AuditLogCtrl, {
auditSrv,
$rootScope,
$scope: ctx.scope,
});
}));
it('should immediately attempt to fetch the audit log', function() {
expect(auditSrv.getAuditLog.calledOnce).to.be(true);
});
describe('and the audit log is successfully fetched', function() {
beforeEach(function() {
deferred.resolve(versionsResponse);
ctx.ctrl.$scope.$apply();
});
it('should reset the controller\'s state', function() {
expect(ctx.ctrl.mode).to.be('list');
expect(ctx.ctrl.delta).to.eql({ basic: '', html: '' });
expect(ctx.ctrl.selected.length).to.be(0);
expect(ctx.ctrl.selected).to.eql([]);
expect(_.find(ctx.ctrl.revisions, rev => rev.checked)).to.be.undefined;
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
it('should store the revisions sorted desc by version id', function() {
expect(ctx.ctrl.revisions[0].version).to.be(4);
expect(ctx.ctrl.revisions[1].version).to.be(3);
expect(ctx.ctrl.revisions[2].version).to.be(2);
expect(ctx.ctrl.revisions[3].version).to.be(1);
});
it('should add a checked property to each revision', function() {
var actual = _.filter(ctx.ctrl.revisions, rev => rev.hasOwnProperty('checked'));
expect(actual.length).to.be(4);
});
it('should set all checked properties to false on reset', function() {
ctx.ctrl.revisions[0].checked = true;
ctx.ctrl.revisions[2].checked = true;
ctx.ctrl.selected = [0, 2];
ctx.ctrl.reset();
var actual = _.filter(ctx.ctrl.revisions, rev => !rev.checked);
expect(actual.length).to.be(4);
expect(ctx.ctrl.selected).to.eql([]);
});
it('should add a default message to versions without a message', function() {
expect(ctx.ctrl.revisions[0].message).to.be('Dashboard saved');
});
it('should add a message to revisions restored from another version', function() {
expect(ctx.ctrl.revisions[1].message).to.be('Restored from version 1');
});
it('should add a message to entries that overwrote version history', function() {
expect(ctx.ctrl.revisions[2].message).to.be('Dashboard overwritten');
});
it('should add a message to the initial dashboard save', function() {
expect(ctx.ctrl.revisions[3].message).to.be('Dashboard\'s initial save');
});
});
describe('and fetching the audit log fails', function() {
beforeEach(function() {
deferred.reject(new Error('AuditLogError'));
ctx.ctrl.$scope.$apply();
});
it('should reset the controller\'s state', function() {
expect(ctx.ctrl.mode).to.be('list');
expect(ctx.ctrl.delta).to.eql({ basic: '', html: '' });
expect(ctx.ctrl.selected.length).to.be(0);
expect(ctx.ctrl.selected).to.eql([]);
expect(_.find(ctx.ctrl.revisions, rev => rev.checked)).to.be.undefined;
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
it('should broadcast an event indicating the failure', function() {
expect($rootScope.appEvent.calledOnce).to.be(true);
expect($rootScope.appEvent.calledWith('alert-error')).to.be(true);
});
it('should have an empty revisions list', function() {
expect(ctx.ctrl.revisions).to.eql([]);
});
});
describe('should update the audit log when the dashboard is saved', function() {
beforeEach(function() {
ctx.ctrl.dashboard = { version: 3 };
ctx.ctrl.resetFromSource = sinon.spy();
});
it('should listen for the `dashboard-saved` appEvent', function() {
expect($rootScope.onAppEvent.calledOnce).to.be(true);
expect($rootScope.onAppEvent.getCall(0).args[0]).to.be('dashboard-saved');
});
it('should call `onDashboardSaved` when the appEvent is received', function() {
expect($rootScope.onAppEvent.getCall(0).args[1]).to.not.be(ctx.ctrl.onDashboardSaved);
expect($rootScope.onAppEvent.getCall(0).args[1].toString).to.be(ctx.ctrl.onDashboardSaved.toString);
});
it('should emit an appEvent to hide the changelog', function() {
ctx.ctrl.onDashboardSaved();
expect($rootScope.appEvent.calledOnce).to.be(true);
expect($rootScope.appEvent.getCall(0).args[0]).to.be('hide-dash-editor');
});
});
});
describe('when the user wants to compare two revisions', function() {
var deferred;
beforeEach(angularMocks.inject(($controller, $q) => {
deferred = $q.defer();
auditSrv.getAuditLog.returns($q.when(versionsResponse));
auditSrv.compareVersions.returns(deferred.promise);
ctx.ctrl = $controller(AuditLogCtrl, {
auditSrv,
$rootScope,
$scope: ctx.scope,
});
ctx.ctrl.$scope.onDashboardSaved = sinon.spy();
ctx.ctrl.$scope.$apply();
}));
it('should have already fetched the audit log', function() {
expect(auditSrv.getAuditLog.calledOnce).to.be(true);
expect(ctx.ctrl.revisions.length).to.be.above(0);
});
it('should check that two valid versions are selected', function() {
// []
expect(ctx.ctrl.isComparable()).to.be(false);
// single value
ctx.ctrl.selected = [4];
expect(ctx.ctrl.isComparable()).to.be(false);
// both values in range
ctx.ctrl.selected = [4, 2];
expect(ctx.ctrl.isComparable()).to.be(true);
// values out of range
ctx.ctrl.selected = [7, 4];
expect(ctx.ctrl.isComparable()).to.be(false);
});
describe('and the basic diff is successfully fetched', function() {
beforeEach(function() {
deferred.resolve(compare('basic'));
ctx.ctrl.selected = [3, 1];
ctx.ctrl.getDiff('basic');
ctx.ctrl.$scope.$apply();
});
it('should fetch the basic diff if two valid versions are selected', function() {
expect(auditSrv.compareVersions.calledOnce).to.be(true);
expect(ctx.ctrl.delta.basic).to.be('<div></div>');
expect(ctx.ctrl.delta.html).to.be('');
});
it('should set the basic diff view as active', function() {
expect(ctx.ctrl.mode).to.be('compare');
expect(ctx.ctrl.diff).to.be('basic');
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
});
describe('and the json diff is successfully fetched', function() {
beforeEach(function() {
deferred.resolve(compare('html'));
ctx.ctrl.selected = [3, 1];
ctx.ctrl.getDiff('html');
ctx.ctrl.$scope.$apply();
});
it('should fetch the json diff if two valid versions are selected', function() {
expect(auditSrv.compareVersions.calledOnce).to.be(true);
expect(ctx.ctrl.delta.basic).to.be('');
expect(ctx.ctrl.delta.html).to.be('<pre><code></code></pre>');
});
it('should set the json diff view as active', function() {
expect(ctx.ctrl.mode).to.be('compare');
expect(ctx.ctrl.diff).to.be('html');
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
});
describe('and diffs have already been fetched', function() {
beforeEach(function() {
deferred.resolve(compare('basic'));
ctx.ctrl.selected = [3, 1];
ctx.ctrl.delta.basic = 'cached basic';
ctx.ctrl.getDiff('basic');
ctx.ctrl.$scope.$apply();
});
it('should use the cached diffs instead of fetching', function() {
expect(auditSrv.compareVersions.calledOnce).to.be(false);
expect(ctx.ctrl.delta.basic).to.be('cached basic');
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
});
describe('and fetching the diff fails', function() {
beforeEach(function() {
deferred.reject(new Error('DiffError'));
ctx.ctrl.selected = [4, 2];
ctx.ctrl.getDiff('basic');
ctx.ctrl.$scope.$apply();
});
it('should fetch the diff if two valid versions are selected', function() {
expect(auditSrv.compareVersions.calledOnce).to.be(true);
});
it('should return to the audit log view', function() {
expect(ctx.ctrl.mode).to.be('list');
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
it('should broadcast an event indicating the failure', function() {
expect($rootScope.appEvent.calledOnce).to.be(true);
expect($rootScope.appEvent.calledWith('alert-error')).to.be(true);
});
it('should have an empty delta/changeset', function() {
expect(ctx.ctrl.delta).to.eql({ basic: '', html: '' });
});
});
});
describe('when the user wants to restore a revision', function() {
var deferred;
beforeEach(angularMocks.inject(($controller, $q) => {
deferred = $q.defer();
auditSrv.getAuditLog.returns($q.when(versionsResponse));
auditSrv.restoreDashboard.returns(deferred.promise);
ctx.ctrl = $controller(AuditLogCtrl, {
auditSrv,
contextSrv: { user: { name: 'Carlos' }},
$rootScope,
$scope: ctx.scope,
});
ctx.ctrl.$scope.setupDashboard = sinon.stub();
ctx.ctrl.dashboard = { id: 1 };
ctx.ctrl.restore();
ctx.ctrl.$scope.$apply();
}));
it('should display a modal allowing the user to restore or cancel', function() {
expect($rootScope.appEvent.calledOnce).to.be(true);
expect($rootScope.appEvent.calledWith('confirm-modal')).to.be(true);
});
describe('from the diff view', function() {
it('should return to the list view on restore', function() {
ctx.ctrl.mode = 'compare';
deferred.resolve(restoreResponse);
ctx.ctrl.restoreConfirm(RESTORE_ID);
ctx.ctrl.$scope.$apply();
expect(ctx.ctrl.mode).to.be('list');
});
});
describe('and restore is selected and successful', function() {
beforeEach(function() {
deferred.resolve(restoreResponse);
ctx.ctrl.restoreConfirm(RESTORE_ID);
ctx.ctrl.$scope.$apply();
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
it('should add an entry for the restored revision to the audit log', function() {
expect(ctx.ctrl.revisions.length).to.be(5);
});
describe('the restored revision', function() {
var first;
beforeEach(function() { first = ctx.ctrl.revisions[0]; });
it('should have its `id` and `version` numbers incremented', function() {
expect(first.id).to.be(5);
expect(first.version).to.be(5);
});
it('should set `parentVersion` to the reverted version', function() {
expect(first.parentVersion).to.be(RESTORE_ID);
});
it('should set `dashboardId` to the dashboard\'s id', function() {
expect(first.dashboardId).to.be(1);
});
it('should set `created` to date to the current time', function() {
expect(_.isDate(first.created)).to.be(true);
});
it('should set `createdBy` to the username of the user who reverted', function() {
expect(first.createdBy).to.be('Carlos');
});
it('should set `message` to the user\'s commit message', function() {
expect(first.message).to.be('Restored from version 4');
});
});
it('should reset the controller\'s state', function() {
expect(ctx.ctrl.mode).to.be('list');
expect(ctx.ctrl.delta).to.eql({ basic: '', html: '' });
expect(ctx.ctrl.selected.length).to.be(0);
expect(ctx.ctrl.selected).to.eql([]);
expect(_.find(ctx.ctrl.revisions, rev => rev.checked)).to.be.undefined;
});
it('should set the dashboard object to the response dashboard data', function() {
expect(ctx.ctrl.dashboard).to.eql(restoreResponse.dashboard.dashboard);
expect(ctx.ctrl.dashboard.meta).to.eql(restoreResponse.dashboard.meta);
});
it('should call setupDashboard to render new revision', function() {
expect(ctx.ctrl.$scope.setupDashboard.calledOnce).to.be(true);
expect(ctx.ctrl.$scope.setupDashboard.getCall(0).args[0]).to.eql(restoreResponse.dashboard);
});
});
describe('and restore fails to fetch', function() {
beforeEach(function() {
deferred.reject(new Error('RestoreError'));
ctx.ctrl.restoreConfirm(RESTORE_ID);
ctx.ctrl.$scope.$apply();
});
it('should indicate loading has finished', function() {
expect(ctx.ctrl.loading).to.be(false);
});
it('should broadcast an event indicating the failure', function() {
expect($rootScope.appEvent.callCount).to.be(2);
expect($rootScope.appEvent.getCall(0).calledWith('confirm-modal')).to.be(true);
expect($rootScope.appEvent.getCall(1).args[0]).to.be('alert-error');
expect($rootScope.appEvent.getCall(1).args[1][0]).to.be('There was an error restoring the dashboard');
});
// TODO: test state after failure i.e. do we hide the modal or keep it visible
});
});
});