mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge pull request #10 from walmartlabs/version-control
History and Version Control for Dashboard Updates
This commit is contained in:
@@ -15,6 +15,7 @@ import "./directives/value_select_dropdown";
|
||||
import "./directives/plugin_component";
|
||||
import "./directives/rebuild_on_change";
|
||||
import "./directives/give_focus";
|
||||
import "./directives/diff-view";
|
||||
import './jquery_extended';
|
||||
import './partials';
|
||||
import './components/jsontree/jsontree';
|
||||
|
||||
@@ -8,6 +8,7 @@ function ($, coreModule) {
|
||||
var editViewMap = {
|
||||
'settings': { src: 'public/app/features/dashboard/partials/settings.html'},
|
||||
'annotations': { src: 'public/app/features/annotations/partials/editor.html'},
|
||||
'audit': { src: 'public/app/features/dashboard/audit/partials/audit.html'},
|
||||
'templating': { src: 'public/app/features/templating/partials/editor.html'},
|
||||
'import': { src: '<dash-import></dash-import>' }
|
||||
};
|
||||
|
||||
76
public/app/core/directives/diff-view.ts
Normal file
76
public/app/core/directives/diff-view.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
///<reference path="../../headers/common.d.ts" />
|
||||
|
||||
import angular from 'angular';
|
||||
import coreModule from '../core_module';
|
||||
|
||||
export class DeltaCtrl {
|
||||
observer: any;
|
||||
|
||||
constructor(private $rootScope) {
|
||||
const waitForCompile = function(mutations) {
|
||||
if (mutations.length === 1) {
|
||||
this.$rootScope.appEvent('json-diff-ready');
|
||||
}
|
||||
};
|
||||
|
||||
this.observer = new MutationObserver(waitForCompile.bind(this));
|
||||
|
||||
const observerConfig = {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
characterData: false,
|
||||
childList: true,
|
||||
subtree: false,
|
||||
};
|
||||
|
||||
this.observer.observe(angular.element('.delta-html')[0], observerConfig);
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export function delta() {
|
||||
return {
|
||||
controller: DeltaCtrl,
|
||||
replace: false,
|
||||
restrict: 'A',
|
||||
};
|
||||
}
|
||||
coreModule.directive('diffDelta', delta);
|
||||
|
||||
// Link to JSON line number
|
||||
export class LinkJSONCtrl {
|
||||
/** @ngInject */
|
||||
constructor(private $scope, private $rootScope, private $anchorScroll) {}
|
||||
|
||||
goToLine(line: number) {
|
||||
let unbind;
|
||||
|
||||
const scroll = () => {
|
||||
this.$anchorScroll(`l${line}`);
|
||||
unbind();
|
||||
};
|
||||
|
||||
this.$scope.switchView().then(() => {
|
||||
unbind = this.$rootScope.$on('json-diff-ready', scroll.bind(this));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function linkJson() {
|
||||
return {
|
||||
controller: LinkJSONCtrl,
|
||||
controllerAs: 'ctrl',
|
||||
replace: true,
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
line: '@lineDisplay',
|
||||
link: '@lineLink',
|
||||
switchView: '&',
|
||||
},
|
||||
templateUrl: 'public/app/features/dashboard/audit/partials/link-json.html',
|
||||
};
|
||||
}
|
||||
coreModule.directive('diffLinkJson', linkJson);
|
||||
@@ -18,6 +18,20 @@ function (angular, coreModule, kbn) {
|
||||
};
|
||||
});
|
||||
|
||||
coreModule.default.directive('compile', function($compile) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function(scope, element, attrs) {
|
||||
scope.$watch(function(scope) {
|
||||
return scope.$eval(attrs.compile);
|
||||
}, function(value) {
|
||||
element.html(value);
|
||||
$compile(element.contents())(scope);
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
coreModule.default.directive('watchChange', function() {
|
||||
return {
|
||||
scope: { onchange: '&watchChange' },
|
||||
|
||||
@@ -202,7 +202,8 @@ export class BackendSrv {
|
||||
|
||||
saveDashboard(dash, options) {
|
||||
options = (options || {});
|
||||
return this.post('/api/dashboards/db/', {dashboard: dash, overwrite: options.overwrite === true});
|
||||
const message = options.message || '';
|
||||
return this.post('/api/dashboards/db/', {dashboard: dash, overwrite: options.overwrite === true, message});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ define([
|
||||
'./panellinks/module',
|
||||
'./dashlinks/module',
|
||||
'./annotations/all',
|
||||
'./annotations/annotations_srv',
|
||||
'./templating/all',
|
||||
'./dashboard/all',
|
||||
'./playlist/all',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
define([
|
||||
'./dashboard_ctrl',
|
||||
'./alerting_srv',
|
||||
'./audit/audit_srv',
|
||||
'./dashboardLoaderSrv',
|
||||
'./dashnav/dashnav',
|
||||
'./submenu/submenu',
|
||||
'./saveDashboardAsCtrl',
|
||||
'./saveDashboardMessageCtrl',
|
||||
'./shareModalCtrl',
|
||||
'./shareSnapshotCtrl',
|
||||
'./dashboard_srv',
|
||||
|
||||
235
public/app/features/dashboard/audit/audit_ctrl.ts
Normal file
235
public/app/features/dashboard/audit/audit_ctrl.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
///<reference path="../../../headers/common.d.ts" />
|
||||
|
||||
import _ from 'lodash';
|
||||
import angular from 'angular';
|
||||
import moment from 'moment';
|
||||
|
||||
import coreModule from 'app/core/core_module';
|
||||
|
||||
import {DashboardModel} from '../model';
|
||||
import {AuditLogOpts, RevisionsModel} from './models';
|
||||
|
||||
export class AuditLogCtrl {
|
||||
appending: boolean;
|
||||
dashboard: DashboardModel;
|
||||
delta: { basic: string; html: string; };
|
||||
diff: string;
|
||||
limit: number;
|
||||
loading: boolean;
|
||||
max: number;
|
||||
mode: string;
|
||||
orderBy: string;
|
||||
revisions: RevisionsModel[];
|
||||
selected: number[];
|
||||
start: number;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $scope,
|
||||
private $rootScope,
|
||||
private $window,
|
||||
private $q,
|
||||
private contextSrv,
|
||||
private auditSrv) {
|
||||
$scope.ctrl = this;
|
||||
|
||||
this.appending = false;
|
||||
this.dashboard = $scope.dashboard;
|
||||
this.diff = 'basic';
|
||||
this.limit = 10;
|
||||
this.loading = false;
|
||||
this.max = 2;
|
||||
this.mode = 'list';
|
||||
this.orderBy = 'version';
|
||||
this.selected = [];
|
||||
this.start = 0;
|
||||
|
||||
this.resetFromSource();
|
||||
|
||||
$scope.$watch('ctrl.mode', newVal => {
|
||||
$window.scrollTo(0, 0);
|
||||
if (newVal === 'list') {
|
||||
this.reset();
|
||||
}
|
||||
});
|
||||
|
||||
$rootScope.onAppEvent('dashboard-saved', this.onDashboardSaved.bind(this));
|
||||
}
|
||||
|
||||
addToLog() {
|
||||
this.start = this.start + this.limit;
|
||||
this.getLog(true);
|
||||
}
|
||||
|
||||
compareRevisionStateChanged(revision: any) {
|
||||
if (revision.checked) {
|
||||
this.selected.push(revision.version);
|
||||
} else {
|
||||
_.remove(this.selected, version => version === revision.version);
|
||||
}
|
||||
this.selected = _.sortBy(this.selected);
|
||||
}
|
||||
|
||||
compareRevisionDisabled(checked: boolean) {
|
||||
return (this.selected.length === this.max && !checked) || this.revisions.length === 1;
|
||||
}
|
||||
|
||||
formatDate(date) {
|
||||
date = moment.isMoment(date) ? date : moment(date);
|
||||
const format = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
return this.dashboard.timezone === 'browser' ?
|
||||
moment(date).format(format) :
|
||||
moment.utc(date).format(format);
|
||||
}
|
||||
|
||||
formatBasicDate(date) {
|
||||
const now = this.dashboard.timezone === 'browser' ? moment() : moment.utc();
|
||||
const then = this.dashboard.timezone === 'browser' ? moment(date) : moment.utc(date);
|
||||
return then.from(now);
|
||||
}
|
||||
|
||||
getDiff(diff: string) {
|
||||
if (!this.isComparable()) { return; } // disable button but not tooltip
|
||||
|
||||
this.diff = diff;
|
||||
this.mode = 'compare';
|
||||
this.loading = true;
|
||||
|
||||
// instead of using lodash to find min/max we use the index
|
||||
// due to the array being sorted in ascending order
|
||||
const compare = {
|
||||
new: this.selected[1],
|
||||
original: this.selected[0],
|
||||
};
|
||||
|
||||
if (this.delta[this.diff]) {
|
||||
this.loading = false;
|
||||
return this.$q.when(this.delta[this.diff]);
|
||||
} else {
|
||||
return this.auditSrv.compareVersions(this.dashboard, compare, diff).then(response => {
|
||||
this.delta[this.diff] = response;
|
||||
}).catch(err => {
|
||||
this.mode = 'list';
|
||||
this.$rootScope.appEvent('alert-error', ['There was an error fetching the diff', (err.message || err)]);
|
||||
}).finally(() => { this.loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
getLog(append = false) {
|
||||
this.loading = !append;
|
||||
this.appending = append;
|
||||
const options: AuditLogOpts = {
|
||||
limit: this.limit,
|
||||
start: this.start,
|
||||
orderBy: this.orderBy,
|
||||
};
|
||||
return this.auditSrv.getAuditLog(this.dashboard, options).then(revisions => {
|
||||
const formattedRevisions = _.flow(
|
||||
_.partialRight(_.map, rev => _.extend({}, rev, {
|
||||
checked: false,
|
||||
message: (revision => {
|
||||
if (revision.message === '') {
|
||||
if (revision.version === 1) {
|
||||
return 'Dashboard\'s initial save';
|
||||
}
|
||||
|
||||
if (revision.restoredFrom > 0) {
|
||||
return `Restored from version ${revision.restoredFrom}`;
|
||||
}
|
||||
|
||||
if (revision.parentVersion === 0) {
|
||||
return 'Dashboard overwritten';
|
||||
}
|
||||
|
||||
return 'Dashboard saved';
|
||||
}
|
||||
return revision.message;
|
||||
})(rev),
|
||||
})))(revisions);
|
||||
|
||||
this.revisions = append ? this.revisions.concat(formattedRevisions) : formattedRevisions;
|
||||
}).catch(err => {
|
||||
this.$rootScope.appEvent('alert-error', ['There was an error fetching the audit log', (err.message || err)]);
|
||||
}).finally(() => {
|
||||
this.loading = false;
|
||||
this.appending = false;
|
||||
});
|
||||
}
|
||||
|
||||
getMeta(version: number, property: string) {
|
||||
const revision = _.find(this.revisions, rev => rev.version === version);
|
||||
return revision[property];
|
||||
}
|
||||
|
||||
isOriginalCurrent() {
|
||||
return this.selected[1] === this.dashboard.version;
|
||||
}
|
||||
|
||||
isComparable() {
|
||||
const isParamLength = this.selected.length === 2;
|
||||
const areNumbers = this.selected.every(version => _.isNumber(version));
|
||||
const areValidVersions = _.filter(this.revisions, revision => {
|
||||
return revision.version === this.selected[0] || revision.version === this.selected[1];
|
||||
}).length === 2;
|
||||
return isParamLength && areNumbers && areValidVersions;
|
||||
}
|
||||
|
||||
isLastPage() {
|
||||
return _.find(this.revisions, rev => rev.version === 1);
|
||||
}
|
||||
|
||||
onDashboardSaved() {
|
||||
this.$rootScope.appEvent('hide-dash-editor');
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.delta = { basic: '', html: '' };
|
||||
this.diff = 'basic';
|
||||
this.mode = 'list';
|
||||
this.revisions = _.map(this.revisions, rev => _.extend({}, rev, { checked: false }));
|
||||
this.selected = [];
|
||||
this.start = 0;
|
||||
}
|
||||
|
||||
resetFromSource() {
|
||||
this.revisions = [];
|
||||
return this.getLog().then(this.reset.bind(this));
|
||||
}
|
||||
|
||||
restore(version: number) {
|
||||
this.$rootScope.appEvent('confirm-modal', {
|
||||
title: 'Restore version',
|
||||
text: '',
|
||||
text2: `Are you sure you want to restore the dashboard to version ${version}? All unsaved changes will be lost.`,
|
||||
icon: 'fa-rotate-right',
|
||||
yesText: `Yes, restore to version ${version}`,
|
||||
onConfirm: this.restoreConfirm.bind(this, version),
|
||||
});
|
||||
}
|
||||
|
||||
restoreConfirm(version: number) {
|
||||
this.loading = true;
|
||||
return this.auditSrv.restoreDashboard(this.dashboard, version).then(response => {
|
||||
this.revisions.unshift({
|
||||
id: this.revisions[0].id + 1,
|
||||
checked: false,
|
||||
dashboardId: this.dashboard.id,
|
||||
parentVersion: version,
|
||||
version: this.revisions[0].version + 1,
|
||||
created: new Date(),
|
||||
createdBy: this.contextSrv.user.name,
|
||||
message: `Restored from version ${version}`,
|
||||
});
|
||||
|
||||
this.reset();
|
||||
const restoredData = response.dashboard;
|
||||
this.dashboard = restoredData.dashboard;
|
||||
this.dashboard.meta = restoredData.meta;
|
||||
this.$scope.setupDashboard(restoredData);
|
||||
}).catch(err => {
|
||||
this.$rootScope.appEvent('alert-error', ['There was an error restoring the dashboard', (err.message || err)]);
|
||||
}).finally(() => { this.loading = false; });
|
||||
}
|
||||
}
|
||||
|
||||
coreModule.controller('AuditLogCtrl', AuditLogCtrl);
|
||||
32
public/app/features/dashboard/audit/audit_srv.ts
Normal file
32
public/app/features/dashboard/audit/audit_srv.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
///<reference path="../../../headers/common.d.ts" />
|
||||
|
||||
import './audit_ctrl';
|
||||
|
||||
import _ from 'lodash';
|
||||
import coreModule from 'app/core/core_module';
|
||||
import {DashboardModel} from '../model';
|
||||
import {AuditLogOpts} from './models';
|
||||
|
||||
export class AuditSrv {
|
||||
/** @ngInject */
|
||||
constructor(private backendSrv, private $q) {}
|
||||
|
||||
getAuditLog(dashboard: DashboardModel, options: AuditLogOpts) {
|
||||
const id = dashboard && dashboard.id ? dashboard.id : void 0;
|
||||
return id ? this.backendSrv.get(`api/dashboards/db/${id}/versions`, options) : this.$q.when([]);
|
||||
}
|
||||
|
||||
compareVersions(dashboard: DashboardModel, compare: { new: number, original: number }, view = 'html') {
|
||||
const id = dashboard && dashboard.id ? dashboard.id : void 0;
|
||||
const url = `api/dashboards/db/${id}/compare/${compare.original}...${compare.new}/${view}`;
|
||||
return id ? this.backendSrv.get(url) : this.$q.when({});
|
||||
}
|
||||
|
||||
restoreDashboard(dashboard: DashboardModel, version: number) {
|
||||
const id = dashboard && dashboard.id ? dashboard.id : void 0;
|
||||
const url = `api/dashboards/db/${id}/restore`;
|
||||
return id && _.isNumber(version) ? this.backendSrv.post(url, { version }) : this.$q.when({});
|
||||
}
|
||||
}
|
||||
|
||||
coreModule.service('auditSrv', AuditSrv);
|
||||
16
public/app/features/dashboard/audit/models.ts
Normal file
16
public/app/features/dashboard/audit/models.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface AuditLogOpts {
|
||||
limit: number;
|
||||
start: number;
|
||||
orderBy: string;
|
||||
}
|
||||
|
||||
export interface RevisionsModel {
|
||||
id: number;
|
||||
checked: boolean;
|
||||
dashboardId: number;
|
||||
parentVersion: number;
|
||||
version: number;
|
||||
created: Date;
|
||||
createdBy: string;
|
||||
message: string;
|
||||
}
|
||||
161
public/app/features/dashboard/audit/partials/audit.html
Normal file
161
public/app/features/dashboard/audit/partials/audit.html
Normal file
@@ -0,0 +1,161 @@
|
||||
<div ng-controller="AuditLogCtrl">
|
||||
<div class="tabbed-view-header">
|
||||
<h2 class="tabbed-view-title">
|
||||
Changelog
|
||||
</h2>
|
||||
|
||||
<ul class="gf-tabs">
|
||||
<li class="gf-tabs-item" >
|
||||
<a class="gf-tabs-link" ng-click="ctrl.mode = 'list';" ng-class="{active: ctrl.mode === 'list'}">
|
||||
List
|
||||
</a>
|
||||
</li>
|
||||
<li class="gf-tabs-item" ng-show="ctrl.mode === 'compare'">
|
||||
<span ng-if="ctrl.isOriginalCurrent()" class="active gf-tabs-link">
|
||||
Version {{ctrl.selected[0]}} <i class="fa fa-arrows-h" /> Current
|
||||
</span>
|
||||
<span ng-if="!ctrl.isOriginalCurrent()" class="active gf-tabs-link">
|
||||
Version {{ctrl.selected[0]}} <i class="fa fa-arrows-h" /> Version {{ctrl.selected[1]}}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button class="tabbed-view-close-btn" ng-click="dismiss();">
|
||||
<i class="fa fa-remove"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tabbed-view-body">
|
||||
|
||||
<div ng-if="ctrl.mode === 'list'">
|
||||
<div ng-if="ctrl.loading">
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
<em>Fetching audit log…</em>
|
||||
</div>
|
||||
|
||||
<div ng-if="!ctrl.loading">
|
||||
<div class="audit-table gf-form">
|
||||
<div class="gf-form-group">
|
||||
<table class="filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="width-4"></th>
|
||||
<th class="width-4">Version</th>
|
||||
<th class="width-14">Date</th>
|
||||
<th class="width-10">Updated By</th>
|
||||
<th class="width-30">Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="revision in ctrl.revisions">
|
||||
<td bs-tooltip="ctrl.compareRevisionDisabled(revision.checked) ? 'You can only compare 2 versions at a time' : ''">
|
||||
<gf-form-switch
|
||||
checked="revision.checked"
|
||||
on-change="ctrl.compareRevisionStateChanged(revision)"
|
||||
ng-disabled="ctrl.compareRevisionDisabled(revision.checked)">
|
||||
</gf-form-switch>
|
||||
</td>
|
||||
<td>{{revision.version}}</td>
|
||||
<td>{{ctrl.formatDate(revision.created)}}</td>
|
||||
<td>{{revision.createdBy}}</td>
|
||||
<td>{{revision.message}}</td>
|
||||
<td class="text-right">
|
||||
<a class="btn btn-inverse btn-small" ng-show="revision.version !== ctrl.dashboard.version" ng-click="ctrl.restore(revision.version)">
|
||||
<i class="fa fa-rotate-right"></i> Restore
|
||||
</a>
|
||||
<a class="btn btn-outline-disabled btn-small" ng-show="revision.version === ctrl.dashboard.version">
|
||||
<i class="fa fa-check"></i> Current
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div ng-if="ctrl.appending">
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
<em>Fetching more entries…</em>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-group" ng-show="ctrl.mode === 'list'">
|
||||
<div class="gf-form-button-row">
|
||||
<a type="button"
|
||||
class="btn gf-form-button btn-primary"
|
||||
ng-if="ctrl.revisions.length > 1"
|
||||
ng-class="{disabled: !ctrl.isComparable()}"
|
||||
ng-click="ctrl.getDiff(ctrl.diff)"
|
||||
bs-tooltip="ctrl.isComparable() ? '' : 'Select 2 versions to start comparing'">
|
||||
<i class="fa fa-code-fork" ></i> Compare versions
|
||||
</a>
|
||||
<a type="button"
|
||||
class="btn gf-form-button btn-inverse"
|
||||
ng-if="ctrl.revisions.length >= ctrl.limit"
|
||||
ng-click="ctrl.addToLog()"
|
||||
ng-class="{disabled: ctrl.isLastPage()}"
|
||||
ng-disabled="ctrl.isLastPage()">
|
||||
Show more versions
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="audit-log" ng-if="ctrl.mode === 'compare'">
|
||||
<div class="page-container">
|
||||
<div class="page-body">
|
||||
<aside class="page-sidebar">
|
||||
<section class="page-sidebar-section">
|
||||
<ul class="ui-list">
|
||||
<li><a ng-class="{active: ctrl.diff === 'basic'}" ng-click="ctrl.getDiff('basic')" href="">Change Summary</a></li>
|
||||
<li><a ng-class="{active: ctrl.diff === 'html'}" ng-click="ctrl.getDiff('html')" href="">JSON Code View</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div class="tab-content page-content-with-sidebar">
|
||||
<div ng-if="ctrl.loading">
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
<em>Fetching changes…</em>
|
||||
</div>
|
||||
|
||||
<div ng-if="!ctrl.loading" ng-init="new = ctrl.selected[0]; original = ctrl.selected[1]">
|
||||
<a type="button"
|
||||
class="btn gf-form-button btn-primary diff-restore-btn"
|
||||
ng-click="ctrl.restore(new)"
|
||||
ng-if="ctrl.isOriginalCurrent()">
|
||||
<i class="fa fa-rotate-right" ></i> Restore to version {{new}}
|
||||
</a>
|
||||
<h4>
|
||||
Comparing Version {{ctrl.selected[0]}}
|
||||
<i class="fa fa-arrows-h" />
|
||||
Version {{ctrl.selected[1]}}
|
||||
<cite class="muted" ng-if="ctrl.isOriginalCurrent()">(Current)</cite>
|
||||
</h4>
|
||||
<section ng-if="ctrl.diff === 'basic'">
|
||||
<p class="small muted">
|
||||
<strong>Version {{new}}</strong> updated by
|
||||
<span>{{ctrl.getMeta(new, 'createdBy')}} </span>
|
||||
<span>{{ctrl.formatBasicDate(ctrl.getMeta(new, 'created'))}}</span>
|
||||
<span> - {{ctrl.getMeta(new, 'message')}}</span>
|
||||
</p>
|
||||
<p class="small muted">
|
||||
<strong>Version {{original}}</strong> updated by
|
||||
<span>{{ctrl.getMeta(original, 'createdBy')}} </span>
|
||||
<span>{{ctrl.formatBasicDate(ctrl.getMeta(original, 'created'))}}</span>
|
||||
<span> - {{ctrl.getMeta(original, 'message')}}</span>
|
||||
</p>
|
||||
</section>
|
||||
<div id="delta" diff-delta>
|
||||
<div class="delta-basic" ng-show="ctrl.diff === 'basic'" compile="ctrl.delta.basic" />
|
||||
<div class="delta-html" ng-show="ctrl.diff === 'html'" compile="ctrl.delta.html" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<a class="change list-linenum diff-linenum btn btn-inverse btn-small" ng-click="ctrl.goToLine(link)">
|
||||
Line {{ line }}
|
||||
</a>
|
||||
|
||||
@@ -23,32 +23,7 @@ export class DashboardSrv {
|
||||
return this.dash;
|
||||
}
|
||||
|
||||
saveDashboard(options) {
|
||||
if (!this.dash.meta.canSave && options.makeEditable !== true) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.dash.title === 'New dashboard') {
|
||||
return this.saveDashboardAs();
|
||||
}
|
||||
|
||||
var clone = this.dash.getSaveModelClone();
|
||||
|
||||
return this.backendSrv.saveDashboard(clone, options).then(data => {
|
||||
this.dash.version = data.version;
|
||||
|
||||
this.$rootScope.appEvent('dashboard-saved', this.dash);
|
||||
|
||||
var dashboardUrl = '/dashboard/db/' + data.slug;
|
||||
if (dashboardUrl !== this.$location.path()) {
|
||||
this.$location.url(dashboardUrl);
|
||||
}
|
||||
|
||||
this.$rootScope.appEvent('alert-success', ['Dashboard saved', 'Saved as ' + clone.title]);
|
||||
}).catch(this.handleSaveDashboardError.bind(this));
|
||||
}
|
||||
|
||||
handleSaveDashboardError(err) {
|
||||
handleSaveDashboardError(clone, err) {
|
||||
if (err.data && err.data.status === "version-mismatch") {
|
||||
err.isHandled = true;
|
||||
|
||||
@@ -59,7 +34,7 @@ export class DashboardSrv {
|
||||
yesText: "Save & Overwrite",
|
||||
icon: "fa-warning",
|
||||
onConfirm: () => {
|
||||
this.saveDashboard({overwrite: true});
|
||||
this.saveDashboard({overwrite: true}, clone);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -74,7 +49,7 @@ export class DashboardSrv {
|
||||
yesText: "Save & Overwrite",
|
||||
icon: "fa-warning",
|
||||
onConfirm: () => {
|
||||
this.saveDashboard({overwrite: true});
|
||||
this.saveDashboard({overwrite: true}, clone);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -93,12 +68,50 @@ export class DashboardSrv {
|
||||
this.saveDashboardAs();
|
||||
},
|
||||
onConfirm: () => {
|
||||
this.saveDashboard({overwrite: true});
|
||||
this.saveDashboard({overwrite: true}, clone);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
postSave(clone, data) {
|
||||
this.dash.version = data.version;
|
||||
|
||||
var dashboardUrl = '/dashboard/db/' + data.slug;
|
||||
if (dashboardUrl !== this.$location.path()) {
|
||||
this.$location.url(dashboardUrl);
|
||||
}
|
||||
|
||||
this.$rootScope.appEvent('dashboard-saved', this.dash);
|
||||
this.$rootScope.appEvent('alert-success', ['Dashboard saved', 'Saved as ' + clone.title]);
|
||||
}
|
||||
|
||||
save(clone, options) {
|
||||
return this.backendSrv.saveDashboard(clone, options)
|
||||
.then(this.postSave.bind(this, clone))
|
||||
.catch(this.handleSaveDashboardError.bind(this, clone));
|
||||
}
|
||||
|
||||
saveDashboard(options, clone) {
|
||||
if (clone) {
|
||||
this.setCurrent(this.create(clone, this.dash.meta));
|
||||
}
|
||||
|
||||
if (!this.dash.meta.canSave && options.makeEditable !== true) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.dash.title === 'New dashboard') {
|
||||
return this.saveDashboardAs();
|
||||
}
|
||||
|
||||
if (this.dash.version > 0) {
|
||||
return this.saveDashboardMessage();
|
||||
}
|
||||
|
||||
return this.save(this.dash.getSaveModelClone(), options);
|
||||
}
|
||||
|
||||
saveDashboardAs() {
|
||||
var newScope = this.$rootScope.$new();
|
||||
newScope.clone = this.dash.getSaveModelClone();
|
||||
@@ -112,6 +125,16 @@ export class DashboardSrv {
|
||||
});
|
||||
}
|
||||
|
||||
saveDashboardMessage() {
|
||||
var newScope = this.$rootScope.$new();
|
||||
newScope.clone = this.dash.getSaveModelClone();
|
||||
|
||||
this.$rootScope.appEvent('show-modal', {
|
||||
src: 'public/app/features/dashboard/partials/saveDashboardMessage.html',
|
||||
scope: newScope,
|
||||
modalClass: 'modal--narrow'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
coreModule.service('dashboardSrv', DashboardSrv);
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
</ul>
|
||||
</li>
|
||||
<li ng-show="::dashboardMeta.canSave">
|
||||
<a ng-click="saveDashboard()" bs-tooltip="'Save dashboard <br> CTRL+S'" data-placement="bottom"><i class="fa fa-save"></i></a>
|
||||
<a ng-show="dashboard.version === 0" ng-click="saveDashboard()" bs-tooltip="'Save dashboard <br> CTRL+S'" data-placement="bottom"><i class="fa fa-save"></i></a>
|
||||
<a ng-show="dashboard.version > 0" ng-click="saveDashboard()" bs-tooltip="'Save to changelog <br> CTRL+S'" data-placement="bottom"><i class="fa fa-save"></i></a>
|
||||
</li>
|
||||
<li ng-if="dashboard.snapshot.originalUrl">
|
||||
<a ng-href="{{dashboard.snapshot.originalUrl}}" bs-tooltip="'Open original dashboard'" data-placement="bottom"><i class="fa fa-link"></i></a>
|
||||
@@ -66,6 +67,7 @@
|
||||
<ul class="dropdown-menu">
|
||||
<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="openEditView('settings');">Settings</a></li>
|
||||
<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="openEditView('annotations');">Annotations</a></li>
|
||||
<li ng-if="dashboardMeta.canEdit && dashboard.version > 0 && !dashboardMeta.isHome"><a class="pointer" ng-click="openEditView('audit');">Changelog</a></li>
|
||||
<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="openEditView('templating');">Templating</a></li>
|
||||
<li ng-if="dashboardMeta.canEdit"><a class="pointer" ng-click="viewJson();">View JSON</a></li>
|
||||
<li ng-if="contextSrv.isEditor && !dashboard.editable"><a class="pointer" ng-click="makeEditable();">Make Editable</a></li>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<div class="modal-body" ng-controller="SaveDashboardMessageCtrl" ng-init="init();">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-header-title">
|
||||
<i class="fa fa-save"></i>
|
||||
<span class="p-l-1">Save to changelog</span>
|
||||
</h2>
|
||||
|
||||
<a class="modal-header-close" ng-click="dismiss();">
|
||||
<i class="fa fa-remove"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form name="saveMessage" ng-submit="saveVersion(saveMessage.$valid)" class="modal-content" novalidate>
|
||||
<h6 class="text-center">Add a note to describe the changes in this version</h6>
|
||||
<div class="p-t-2">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-hint">
|
||||
<input
|
||||
type="text"
|
||||
name="message"
|
||||
class="gf-form-input"
|
||||
placeholder="Updates to …"
|
||||
give-focus="true"
|
||||
ng-model="clone.message"
|
||||
ng-model-options="{allowInvalid: true}"
|
||||
ng-keydown="keyDown($event)"
|
||||
ng-maxlength="clone.max"
|
||||
autocomplete="off"
|
||||
required />
|
||||
<small class="gf-form-hint-text muted" ng-cloak>
|
||||
<span ng-class="{'text-error': saveMessage.message.$invalid && saveMessage.message.$dirty }">
|
||||
{{clone.message.length || 0}}
|
||||
</span>
|
||||
/ {{clone.max}} characters
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gf-form-button-row text-center">
|
||||
<button type="submit" class="btn btn-success" ng-disabled="saveMessage.$invalid">
|
||||
Save to changelog
|
||||
</button>
|
||||
<button class="btn btn-inverse" ng-click="dismiss();">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ function (angular) {
|
||||
|
||||
var module = angular.module('grafana.controllers');
|
||||
|
||||
module.controller('SaveDashboardAsCtrl', function($scope, backendSrv, $location) {
|
||||
module.controller('SaveDashboardAsCtrl', function($scope, dashboardSrv) {
|
||||
|
||||
$scope.init = function() {
|
||||
$scope.clone.id = null;
|
||||
@@ -24,17 +24,6 @@ function (angular) {
|
||||
delete $scope.clone.autoUpdate;
|
||||
};
|
||||
|
||||
function saveDashboard(options) {
|
||||
return backendSrv.saveDashboard($scope.clone, options).then(function(result) {
|
||||
$scope.appEvent('alert-success', ['Dashboard saved', 'Saved as ' + $scope.clone.title]);
|
||||
|
||||
$location.url('/dashboard/db/' + result.slug);
|
||||
|
||||
$scope.appEvent('dashboard-saved', $scope.clone);
|
||||
$scope.dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
$scope.keyDown = function (evt) {
|
||||
if (evt.keyCode === 13) {
|
||||
$scope.saveClone();
|
||||
@@ -42,22 +31,8 @@ function (angular) {
|
||||
};
|
||||
|
||||
$scope.saveClone = function() {
|
||||
saveDashboard({overwrite: false}).then(null, function(err) {
|
||||
if (err.data && err.data.status === "name-exists") {
|
||||
err.isHandled = true;
|
||||
|
||||
$scope.appEvent('confirm-modal', {
|
||||
title: 'Conflict',
|
||||
text: 'Dashboard with the same name exists.',
|
||||
text2: 'Would you still like to save this dashboard?',
|
||||
yesText: "Save & Overwrite",
|
||||
icon: "fa-warning",
|
||||
onConfirm: function() {
|
||||
saveDashboard({overwrite: true});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return dashboardSrv.save($scope.clone, {overwrite: false})
|
||||
.then(function() { $scope.dismiss(); });
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
29
public/app/features/dashboard/saveDashboardMessageCtrl.js
Normal file
29
public/app/features/dashboard/saveDashboardMessageCtrl.js
Normal file
@@ -0,0 +1,29 @@
|
||||
define([
|
||||
'angular',
|
||||
],
|
||||
function (angular) {
|
||||
'use strict';
|
||||
|
||||
var module = angular.module('grafana.controllers');
|
||||
|
||||
module.controller('SaveDashboardMessageCtrl', function($scope, dashboardSrv) {
|
||||
|
||||
$scope.init = function() {
|
||||
$scope.clone.message = '';
|
||||
$scope.clone.max = 64;
|
||||
};
|
||||
|
||||
function saveDashboard(options) {
|
||||
options.message = $scope.clone.message;
|
||||
return dashboardSrv.save($scope.clone, options)
|
||||
.then(function() { $scope.dismiss(); });
|
||||
}
|
||||
|
||||
$scope.saveVersion = function(isValid) {
|
||||
if (!isValid) { return; }
|
||||
saveDashboard({overwrite: false});
|
||||
};
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
416
public/app/features/dashboard/specs/audit_ctrl_specs.ts
Normal file
416
public/app/features/dashboard/specs/audit_ctrl_specs.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
92
public/app/features/dashboard/specs/audit_srv_specs.ts
Normal file
92
public/app/features/dashboard/specs/audit_srv_specs.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
|
||||
|
||||
import helpers from 'test/specs/helpers';
|
||||
import AuditSrv from '../audit/audit_srv';
|
||||
import { versions, compare, restore } from 'test/mocks/audit-mocks';
|
||||
|
||||
describe('auditSrv', function() {
|
||||
var ctx = new helpers.ServiceTestContext();
|
||||
|
||||
var versionsResponse = versions();
|
||||
var compareResponse = compare();
|
||||
var restoreResponse = restore;
|
||||
|
||||
beforeEach(angularMocks.module('grafana.core'));
|
||||
beforeEach(angularMocks.module('grafana.services'));
|
||||
beforeEach(angularMocks.inject(function($httpBackend) {
|
||||
ctx.$httpBackend = $httpBackend;
|
||||
$httpBackend.whenRoute('GET', 'api/dashboards/db/:id/versions').respond(versionsResponse);
|
||||
$httpBackend.whenRoute('GET', 'api/dashboards/db/:id/compare/:original...:new').respond(compareResponse);
|
||||
$httpBackend.whenRoute('POST', 'api/dashboards/db/:id/restore')
|
||||
.respond(function(method, url, data, headers, params) {
|
||||
const parsedData = JSON.parse(data);
|
||||
return [200, restoreResponse(parsedData.version)];
|
||||
});
|
||||
}));
|
||||
beforeEach(ctx.createService('auditSrv'));
|
||||
|
||||
describe('getAuditLog', function() {
|
||||
it('should return a versions array for the given dashboard id', function(done) {
|
||||
ctx.service.getAuditLog({ id: 1 }).then(function(versions) {
|
||||
expect(versions).to.eql(versionsResponse);
|
||||
done();
|
||||
});
|
||||
ctx.$httpBackend.flush();
|
||||
});
|
||||
|
||||
it('should return an empty array when not given an id', function(done) {
|
||||
ctx.service.getAuditLog({ }).then(function(versions) {
|
||||
expect(versions).to.eql([]);
|
||||
done();
|
||||
});
|
||||
ctx.$httpBackend.flush();
|
||||
});
|
||||
|
||||
it('should return an empty array when not given a dashboard', function(done) {
|
||||
ctx.service.getAuditLog().then(function(versions) {
|
||||
expect(versions).to.eql([]);
|
||||
done();
|
||||
});
|
||||
ctx.$httpBackend.flush();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareVersions', function() {
|
||||
it('should return a diff object for the given dashboard revisions', function(done) {
|
||||
var compare = { original: 6, new: 4 };
|
||||
ctx.service.compareVersions({ id: 1 }, compare).then(function(response) {
|
||||
expect(response).to.eql(compareResponse);
|
||||
done();
|
||||
});
|
||||
ctx.$httpBackend.flush();
|
||||
});
|
||||
|
||||
it('should return an empty object when not given an id', function(done) {
|
||||
var compare = { original: 6, new: 4 };
|
||||
ctx.service.compareVersions({ }, compare).then(function(response) {
|
||||
expect(response).to.eql({});
|
||||
done();
|
||||
});
|
||||
ctx.$httpBackend.flush();
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreDashboard', function() {
|
||||
it('should return a success response given valid parameters', function(done) {
|
||||
var version = 6;
|
||||
ctx.service.restoreDashboard({ id: 1 }, version).then(function(response) {
|
||||
expect(response).to.eql(restoreResponse(version));
|
||||
done();
|
||||
});
|
||||
ctx.$httpBackend.flush();
|
||||
});
|
||||
|
||||
it('should return an empty object when not given an id', function(done) {
|
||||
ctx.service.restoreDashboard({}, 6).then(function(response) {
|
||||
expect(response).to.eql({});
|
||||
done();
|
||||
});
|
||||
ctx.$httpBackend.flush();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ function(angular, _) {
|
||||
|
||||
var module = angular.module('grafana.services');
|
||||
|
||||
module.service('unsavedChangesSrv', function($rootScope, $q, $location, $timeout, contextSrv, $window) {
|
||||
module.service('unsavedChangesSrv', function($rootScope, $q, $location, $timeout, contextSrv, dashboardSrv, $window) {
|
||||
|
||||
function Tracker(dashboard, scope, originalCopyDelay) {
|
||||
var self = this;
|
||||
@@ -136,28 +136,28 @@ function(angular, _) {
|
||||
|
||||
p.open_modal = function() {
|
||||
var tracker = this;
|
||||
var dashboard = this.current;
|
||||
|
||||
var modalScope = this.scope.$new();
|
||||
var clone = dashboard.getSaveModelClone();
|
||||
|
||||
modalScope.clone = clone;
|
||||
modalScope.ignore = function() {
|
||||
tracker.original = null;
|
||||
tracker.goto_next();
|
||||
};
|
||||
|
||||
modalScope.save = function() {
|
||||
var cancel = $rootScope.$on('dashboard-saved', function() {
|
||||
cancel();
|
||||
$timeout(function() {
|
||||
tracker.goto_next();
|
||||
});
|
||||
var cancel = $rootScope.$on('dashboard-saved', function() {
|
||||
cancel();
|
||||
$timeout(function() {
|
||||
tracker.goto_next();
|
||||
});
|
||||
|
||||
$rootScope.$emit('save-dashboard');
|
||||
};
|
||||
});
|
||||
|
||||
$rootScope.appEvent('show-modal', {
|
||||
src: 'public/app/partials/unsaved-changes.html',
|
||||
modalClass: 'confirm-modal',
|
||||
scope: modalScope,
|
||||
modalClass: 'modal--narrow'
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="modal-body">
|
||||
<div class="modal-body" ng-controller="SaveDashboardMessageCtrl" ng-init="init();">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-header-title">
|
||||
<i class="fa fa-exclamation"></i>
|
||||
<i class="fa fa-exclamation"></i>
|
||||
<span class="p-l-1">Unsaved changes</span>
|
||||
</h2>
|
||||
|
||||
@@ -10,18 +10,45 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="modal-content text-center">
|
||||
|
||||
<div class="confirm-modal-text">
|
||||
What do you want to do?
|
||||
<form name="saveMessage" ng-submit="saveVersion(saveMessage.$valid)" class="modal-content" novalidate>
|
||||
<h6 class="text-center">
|
||||
You're leaving without saving your changes, are you sure you want to leave? To save, add a small note to describe the changes in this version.
|
||||
</h6>
|
||||
<div class="p-t-2">
|
||||
<div class="gf-form">
|
||||
<label class="gf-form-hint">
|
||||
<input
|
||||
type="text"
|
||||
name="message"
|
||||
class="gf-form-input"
|
||||
placeholder="Updates to …"
|
||||
give-focus="true"
|
||||
ng-model="clone.message"
|
||||
ng-model-options="{allowInvalid: true}"
|
||||
ng-keydown="keyDown($event)"
|
||||
ng-maxlength="clone.max"
|
||||
autocomplete="off"
|
||||
required />
|
||||
<small class="gf-form-hint-text muted" ng-cloak>
|
||||
<span ng-class="{'text-error': saveMessage.message.$invalid && saveMessage.message.$dirty }">
|
||||
{{clone.message.length || 0}}
|
||||
</span>
|
||||
/ {{clone.max}} characters
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="confirm-modal-buttons">
|
||||
<button type="button" class="btn btn-inverse" ng-click="dismiss()">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" ng-click="ignore();dismiss()">Ignore</button>
|
||||
<button type="button" class="btn btn-success" ng-click="save();dismiss();">Save</button>
|
||||
<div class="gf-form-button-row text-center">
|
||||
<button type="submit" class="btn btn-success" ng-disabled="saveMessage.$invalid">
|
||||
Save changes
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" ng-click="ignore();dismiss()">
|
||||
Discard changes and leave
|
||||
</button>
|
||||
<button class="btn btn-inverse" ng-click="dismiss();">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
@import "pages/playlist";
|
||||
@import "pages/admin";
|
||||
@import "pages/alerting";
|
||||
@import "pages/audit";
|
||||
@import "pages/plugins";
|
||||
@import "pages/signup";
|
||||
@import "pages/styleguide";
|
||||
|
||||
@@ -280,3 +280,28 @@ $card-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, .1), 1px 1px 0 0 rgba(0, 0, 0, .3)
|
||||
$footer-link-color: $gray-1;
|
||||
$footer-link-hover: $gray-4;
|
||||
|
||||
|
||||
// Changelog and diff
|
||||
// -------------------------
|
||||
$diff-label-bg: $dark-2;
|
||||
$diff-label-fg: $white;
|
||||
|
||||
$diff-switch-bg: $dark-5;
|
||||
$diff-switch-disabled: $gray-1;
|
||||
|
||||
$diff-group-bg: $dark-4;
|
||||
$diff-arrow-color: $white;
|
||||
|
||||
$diff-json-bg: $dark-4;
|
||||
$diff-json-fg: $gray-5;
|
||||
|
||||
$diff-json-added: #2f5f40;
|
||||
$diff-json-deleted: #862d2d;
|
||||
|
||||
$diff-json-old: #5a372a;
|
||||
$diff-json-new: #664e33;
|
||||
|
||||
$diff-json-changed-fg: $gray-5;
|
||||
$diff-json-changed-num: $text-muted;
|
||||
|
||||
$diff-json-icon: $gray-7;
|
||||
|
||||
@@ -303,3 +303,28 @@ $card-shadow: -1px -1px 0 0 hsla(0, 0%, 100%, .1), 1px 1px 0 0 rgba(0, 0, 0, .1)
|
||||
// footer
|
||||
$footer-link-color: $gray-3;
|
||||
$footer-link-hover: $dark-5;
|
||||
|
||||
|
||||
// Changelog and diff
|
||||
// -------------------------
|
||||
$diff-label-bg: $gray-5;
|
||||
$diff-label-fg: $gray-2;
|
||||
|
||||
$diff-switch-bg: $gray-7;
|
||||
$diff-switch-disabled: $gray-5;
|
||||
|
||||
$diff-arrow-color: $dark-3;
|
||||
$diff-group-bg: $gray-7;
|
||||
|
||||
$diff-json-bg: $gray-5;
|
||||
$diff-json-fg: $gray-2;
|
||||
|
||||
$diff-json-added: lighten(desaturate($green, 30%), 10%);
|
||||
$diff-json-deleted: desaturate($red, 35%);
|
||||
|
||||
$diff-json-old: #5a372a;
|
||||
$diff-json-new: #664e33;
|
||||
|
||||
$diff-json-changed-fg: $gray-6;
|
||||
$diff-json-changed-num: $gray-4;
|
||||
$diff-json-icon: $gray-4;
|
||||
|
||||
@@ -118,5 +118,16 @@
|
||||
.btn-outline-danger {
|
||||
@include button-outline-variant($btn-danger-bg);
|
||||
}
|
||||
.btn-outline-disabled {
|
||||
@include button-outline-variant($gray-1);
|
||||
@include box-shadow(none);
|
||||
cursor: default;
|
||||
|
||||
&:hover, &:active, &:active:hover, &:focus {
|
||||
color: $gray-1;
|
||||
background-color: transparent;
|
||||
border-color: $gray-1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -163,6 +163,16 @@ $gf-form-margin: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.gf-form-hint {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gf-form-hint-text {
|
||||
display: block;
|
||||
text-align: right;
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
|
||||
.gf-form-select-wrapper {
|
||||
margin-right: $gf-form-margin;
|
||||
position: relative;
|
||||
|
||||
268
public/sass/pages/_audit.scss
Normal file
268
public/sass/pages/_audit.scss
Normal file
@@ -0,0 +1,268 @@
|
||||
// Audit Table
|
||||
.audit-table {
|
||||
// .gf-form overrides
|
||||
.gf-form-label { display: none; }
|
||||
|
||||
.gf-form-switch {
|
||||
margin-bottom: 0;
|
||||
|
||||
input + label {
|
||||
height: 3.6rem;
|
||||
width: 110%;
|
||||
}
|
||||
|
||||
input + label::before, input + label::after {
|
||||
background-color: $diff-switch-bg;
|
||||
background-image: none;
|
||||
border: 0;
|
||||
height: 50px;
|
||||
line-height: 3.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
gf-form-switch[disabled] {
|
||||
.gf-form-switch input + label {
|
||||
&::before {
|
||||
color: $diff-switch-disabled;
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// .filter-table overrides
|
||||
.filter-table {
|
||||
tr {
|
||||
border-bottom: 3px solid $page-bg;
|
||||
}
|
||||
|
||||
thead tr {
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
$date-padding: 1em;
|
||||
|
||||
td {
|
||||
padding: 0;
|
||||
|
||||
&:nth-child(2) {
|
||||
border-left: 5px solid $page-bg;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
&:nth-child(3) { padding-left: $date-padding; }
|
||||
&:last-child { padding-right: 1.5em; }
|
||||
}
|
||||
|
||||
th:nth-child(2) { padding-left: 0.5em; }
|
||||
th:nth-child(3) { padding-left: $date-padding; }
|
||||
}
|
||||
}
|
||||
|
||||
// Diff View
|
||||
.audit-log {
|
||||
h4 {
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.page-sidebar {
|
||||
margin-left: 0;
|
||||
margin-right: 3em;
|
||||
}
|
||||
|
||||
.small.muted { margin-bottom: 0.25em; }
|
||||
|
||||
.ui-list > li {
|
||||
margin-bottom: 1.5em;
|
||||
|
||||
& > a { padding-left: 15px; }
|
||||
& > a.active { @include left-brand-border-gradient(); }
|
||||
}
|
||||
}
|
||||
|
||||
// Actual Diff
|
||||
#delta {
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
// JSON
|
||||
@for $i from 0 through 16 {
|
||||
.diff-indent-#{$i} {
|
||||
padding-left: $i * 1.5rem;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.delta-html {
|
||||
background: $diff-json-bg;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
color: $diff-json-fg;
|
||||
font-family: $font-family-monospace;
|
||||
font-size: $font-size-sm;
|
||||
line-height: 2;
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
|
||||
&:before, &:after {
|
||||
}
|
||||
|
||||
&:after { left: -40px; }
|
||||
}
|
||||
|
||||
.diff-line-number {
|
||||
color: $text-muted;
|
||||
display: inline-block;
|
||||
font-size: $font-size-xs;
|
||||
line-height: 2.3;
|
||||
text-align: right;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.diff-line-number-hide { visibility: hidden; }
|
||||
|
||||
.diff-line-icon {
|
||||
color: $diff-json-icon;
|
||||
font-size: $font-size-xs;
|
||||
float: right;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.diff-json-new, .diff-json-old {
|
||||
color: $diff-json-changed-fg;
|
||||
|
||||
& .diff-line-number { color: $diff-json-changed-num; }
|
||||
}
|
||||
|
||||
.diff-json-new { background-color: $diff-json-new; }
|
||||
.diff-json-old { background-color: $diff-json-old; }
|
||||
.diff-json-added { background-color: $diff-json-added; }
|
||||
.diff-json-deleted { background-color: $diff-json-deleted; }
|
||||
|
||||
.diff-value {
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
// Basic
|
||||
.diff-circle { margin-right: .5em; }
|
||||
.diff-circle-changed { color: #f59433; }
|
||||
.diff-circle-added { color: #29D761; }
|
||||
.diff-circle-deleted { color: #fd474a; }
|
||||
|
||||
.diff-item-added, .diff-item-deleted { list-style: none; }
|
||||
|
||||
.diff-restore-btn {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.diff-group {
|
||||
background: $diff-group-bg;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
padding: 10px 15px;
|
||||
margin: 1rem 0;
|
||||
|
||||
& .diff-group { padding: 0 5px; }
|
||||
}
|
||||
|
||||
.diff-group-name {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
padding-left: 1.75em;
|
||||
margin: 0 0 14px 0;
|
||||
}
|
||||
|
||||
.diff-summary-key {
|
||||
padding-left: .25em;
|
||||
}
|
||||
|
||||
.diff-list {
|
||||
padding-left: 40px;
|
||||
|
||||
& .diff-list { padding-left: 0; }
|
||||
}
|
||||
|
||||
.diff-item {
|
||||
color: $gray-2;
|
||||
line-height: 2.5;
|
||||
|
||||
& > div { display: inline; }
|
||||
}
|
||||
|
||||
.diff-item-changeset {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.diff-label {
|
||||
background-color: $diff-label-bg;
|
||||
border-radius: 3px;
|
||||
color: $diff-label-fg;
|
||||
display: inline;
|
||||
font-size: .95rem;
|
||||
margin: 0 5px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.diff-linenum {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.diff-arrow {
|
||||
color: $diff-arrow-color;
|
||||
}
|
||||
|
||||
.diff-block {
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.diff-block-title {
|
||||
font-size: 16px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.diff-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.diff-change-container {
|
||||
margin: 0 0;
|
||||
padding-left: 3em;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.diff-change-group {
|
||||
width: 100%;
|
||||
color: rgba(223,224,225, .6);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.diff-change-item {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.diff-change-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.bullet-position-container {
|
||||
position: relative;
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
.diff-list-circle {
|
||||
font-size: 10px;
|
||||
}
|
||||
@@ -255,4 +255,3 @@ div.flot-text {
|
||||
padding: 0.5rem .5rem .2rem .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
197
public/test/mocks/audit-mocks.js
Normal file
197
public/test/mocks/audit-mocks.js
Normal file
@@ -0,0 +1,197 @@
|
||||
define([],
|
||||
function() {
|
||||
'use strict';
|
||||
|
||||
return {
|
||||
versions: function() {
|
||||
return [{
|
||||
id: 4,
|
||||
dashboardId: 1,
|
||||
parentVersion: 3,
|
||||
restoredFrom: 0,
|
||||
version: 4,
|
||||
created: '2017-02-22T17:43:01-08:00',
|
||||
createdBy: 'admin',
|
||||
message: '',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
dashboardId: 1,
|
||||
parentVersion: 1,
|
||||
restoredFrom: 1,
|
||||
version: 3,
|
||||
created: '2017-02-22T17:43:01-08:00',
|
||||
createdBy: 'admin',
|
||||
message: '',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
dashboardId: 1,
|
||||
parentVersion: 0,
|
||||
restoredFrom: -1,
|
||||
version: 2,
|
||||
created: '2017-02-22T17:29:52-08:00',
|
||||
createdBy: 'admin',
|
||||
message: '',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
dashboardId: 1,
|
||||
parentVersion: 0,
|
||||
restoredFrom: -1,
|
||||
slug: 'audit-dashboard',
|
||||
version: 1,
|
||||
created: '2017-02-22T17:06:37-08:00',
|
||||
createdBy: 'admin',
|
||||
message: '',
|
||||
}];
|
||||
},
|
||||
compare: function(type) {
|
||||
return type === 'basic' ? '<div></div>' : '<pre><code></code></pre>';
|
||||
},
|
||||
restore: function(version, restoredFrom) {
|
||||
return {
|
||||
dashboard: {
|
||||
meta: {
|
||||
type: 'db',
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canStar: true,
|
||||
slug: 'audit-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 audit log',
|
||||
editable: true,
|
||||
gnetId: null,
|
||||
graphTooltip: 0,
|
||||
hideControls: false,
|
||||
id: 1,
|
||||
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,
|
||||
style: 'dark',
|
||||
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: 'Audit Dashboard',
|
||||
version: version,
|
||||
}
|
||||
},
|
||||
message: 'Dashboard restored to version ' + version,
|
||||
version: version
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user