Merge pull request #10 from walmartlabs/version-control

History and Version Control for Dashboard Updates
This commit is contained in:
sanchitraizada
2017-05-25 14:50:47 -07:00
committed by GitHub
60 changed files with 7843 additions and 84 deletions

View File

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

View File

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

View 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);

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ define([
'./panellinks/module',
'./dashlinks/module',
'./annotations/all',
'./annotations/annotations_srv',
'./templating/all',
'./dashboard/all',
'./playlist/all',

View File

@@ -1,10 +1,12 @@
define([
'./dashboard_ctrl',
'./alerting_srv',
'./audit/audit_srv',
'./dashboardLoaderSrv',
'./dashnav/dashnav',
'./submenu/submenu',
'./saveDashboardAsCtrl',
'./saveDashboardMessageCtrl',
'./shareModalCtrl',
'./shareSnapshotCtrl',
'./dashboard_srv',

View 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);

View 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);

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

View 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&hellip;</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>&nbsp;&nbsp;Restore
</a>
<a class="btn btn-outline-disabled btn-small" ng-show="revision.version === ctrl.dashboard.version">
<i class="fa fa-check"></i>&nbsp;&nbsp;Current
</a>
</td>
</tr>
</tbody>
</table>
<div ng-if="ctrl.appending">
<i class="fa fa-spinner fa-spin"></i>
<em>Fetching more entries&hellip;</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>&nbsp;&nbsp;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&hellip;</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>&nbsp;&nbsp;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>

View File

@@ -0,0 +1,4 @@
<a class="change list-linenum diff-linenum btn btn-inverse btn-small" ng-click="ctrl.goToLine(link)">
Line {{ line }}
</a>

View File

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

View File

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

View File

@@ -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 &hellip;"
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>

View File

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

View 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});
};
});
});

View 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
});
});
});

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

View File

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

View File

@@ -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 &hellip;"
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>

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -255,4 +255,3 @@ div.flot-text {
padding: 0.5rem .5rem .2rem .5rem;
}
}

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