Merge branch 'master' into mssql_datasource

This commit is contained in:
Marcus Efraimsson
2018-03-13 16:03:02 +01:00
2626 changed files with 339805 additions and 172731 deletions

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import { QueryPartDef, QueryPart } from 'app/core/components/query_part/query_part';
@@ -140,10 +138,6 @@ function getAlertAnnotationInfo(ah) {
return 'Error: ' + ah.data.error;
}
if (ah.data.noData || ah.data.no_data) {
return 'No Data';
}
return '';
}

View File

@@ -1,74 +0,0 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import moment from 'moment';
import { coreModule, appEvents } from 'app/core/core';
import alertDef from './alert_def';
export class AlertListCtrl {
alerts: any;
stateFilters = [
{ text: 'All', value: null },
{ text: 'OK', value: 'ok' },
{ text: 'Not OK', value: 'not_ok' },
{ text: 'Alerting', value: 'alerting' },
{ text: 'No Data', value: 'no_data' },
{ text: 'Paused', value: 'paused' },
];
filters = {
state: 'ALL',
};
navModel: any;
/** @ngInject */
constructor(private backendSrv, private $location, navModelSrv) {
this.navModel = navModelSrv.getNav('alerting', 'alert-list', 0);
var params = $location.search();
this.filters.state = params.state || null;
this.loadAlerts();
}
filtersChanged() {
this.$location.search(this.filters);
}
loadAlerts() {
this.backendSrv.get('/api/alerts', this.filters).then(result => {
this.alerts = _.map(result, alert => {
alert.stateModel = alertDef.getStateDisplayModel(alert.state);
alert.newStateDateAgo = moment(alert.newStateDate)
.fromNow()
.replace(' ago', '');
if (alert.evalData && alert.evalData.no_data) {
alert.no_data = true;
}
return alert;
});
});
}
pauseAlertRule(alertId: any) {
var alert = _.find(this.alerts, { id: alertId });
var payload = {
paused: alert.state !== 'paused',
};
this.backendSrv.post(`/api/alerts/${alert.id}/pause`, payload).then(result => {
alert.state = result.state;
alert.stateModel = alertDef.getStateDisplayModel(result.state);
});
}
openHowTo() {
appEvents.emit('show-modal', {
src: 'public/app/features/alerting/partials/alert_howto.html',
modalClass: 'confirm-modal',
model: {},
});
}
}
coreModule.controller('AlertListCtrl', AlertListCtrl);

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import { ThresholdMapper } from './threshold_mapper';
import { QueryPart } from 'app/core/components/query_part/query_part';
@@ -77,7 +75,7 @@ export class AlertTabCtrl {
getAlertHistory() {
this.backendSrv
.get(`/api/annotations?dashboardId=${this.panelCtrl.dashboard.id}&panelId=${this.panel.id}&limit=50`)
.get(`/api/annotations?dashboardId=${this.panelCtrl.dashboard.id}&panelId=${this.panel.id}&limit=50&type=alert`)
.then(res => {
this.alertHistory = _.map(res, ah => {
ah.time = this.dashboardSrv.getCurrent().formatDate(ah.time, 'MMM D, YYYY HH:mm:ss');

View File

@@ -1,3 +1,2 @@
import './alert_list_ctrl';
import './notifications_list_ctrl';
import './notification_edit_ctrl';

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import { appEvents, coreModule } from 'app/core/core';
@@ -60,15 +58,29 @@ export class AlertNotificationEditCtrl {
}
if (this.model.id) {
this.backendSrv.put(`/api/alert-notifications/${this.model.id}`, this.model).then(res => {
this.model = res;
appEvents.emit('alert-success', ['Notification updated', '']);
});
this.backendSrv
.put(`/api/alert-notifications/${this.model.id}`, this.model)
.then(res => {
this.model = res;
appEvents.emit('alert-success', ['Notification updated', '']);
})
.catch(err => {
if (err.data && err.data.error) {
appEvents.emit('alert-error', [err.data.error]);
}
});
} else {
this.backendSrv.post(`/api/alert-notifications`, this.model).then(res => {
appEvents.emit('alert-success', ['Notification created', '']);
this.$location.path('alerting/notifications');
});
this.backendSrv
.post(`/api/alert-notifications`, this.model)
.then(res => {
appEvents.emit('alert-success', ['Notification created', '']);
this.$location.path('alerting/notifications');
})
.catch(err => {
if (err.data && err.data.error) {
appEvents.emit('alert-error', [err.data.error]);
}
});
}
}

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import { coreModule } from 'app/core/core';
export class AlertNotificationsListCtrl {

View File

@@ -1,61 +0,0 @@
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<div class="page-action-bar">
<div class="gf-form">
<label class="gf-form-label">Filter by state</label>
<div class="gf-form-select-wrapper width-13">
<select class="gf-form-input" ng-model="ctrl.filters.state" ng-options="f.value as f.text for f in ctrl.stateFilters" ng-change="ctrl.filtersChanged()">
</select>
</div>
</div>
<div class="page-action-bar__spacer">
</div>
<a class="btn btn-secondary" ng-click="ctrl.openHowTo()">
<i class="fa fa-info-circle"></i>
How to add an alert
</a>
</div>
<section class="card-section card-list-layout-list">
<ol class="card-list" >
<li class="card-item-wrapper" ng-repeat="alert in ctrl.alerts">
<div class="card-item card-item--alert">
<div class="card-item-header">
<div class="card-item-type">
<a class="card-item-cog" bs-tooltip="'Pausing an alert rule prevents it from executing'" ng-click="ctrl.pauseAlertRule(alert.id)">
<i ng-show="alert.state !== 'paused'" class="fa fa-pause"></i>
<i ng-show="alert.state === 'paused'" class="fa fa-play"></i>
</a>
<a class="card-item-cog" href="dashboard/{{alert.dashboardUri}}?panelId={{alert.panelId}}&fullscreen&edit&tab=alert" bs-tooltip="'Edit alert rule'">
<i class="icon-gf icon-gf-settings"></i>
</a>
</div>
</div>
<div class="card-item-body">
<div class="card-item-details">
<div class="card-item-name">
<a href="dashboard/{{alert.dashboardUri}}?panelId={{alert.panelId}}&fullscreen&edit&tab=alert">
{{alert.name}}
</a>
</div>
<div class="card-item-sub-name">
<span class="alert-list-item-state {{alert.stateModel.stateClass}}">
<i class="{{alert.stateModel.iconClass}}"></i>
{{alert.stateModel.text}} <span class="small muted" ng-show="alert.no_data">(due to no data)</span>
</span> for {{alert.newStateDateAgo}}
</div>
<div class="small muted" ng-show="alert.executionError !== ''">
Error: "{{alert.executionError}}"
</div>
</div>
</div>
</div>
</li>
</ol>
</section>
</div>

View File

@@ -12,7 +12,7 @@
<li ng-class="{active: ctrl.subTabIndex === 2}">
<a ng-click="ctrl.changeTabIndex(2)">State history</a>
</li>
<li>
<li>
<a ng-click="ctrl.delete()">Delete</a>
</li>
</ul>
@@ -143,36 +143,33 @@
<i>No state changes recorded</i>
</div>
<section class="card-section card-list-layout-list">
<ol class="card-list" >
<li class="card-item-wrapper" ng-repeat="ah in ctrl.alertHistory">
<div class="alert-list card-item card-item--alert">
<div class="alert-list-body">
<div class="alert-list-icon alert-list-item-state {{ah.stateModel.stateClass}}">
<i class="{{ah.stateModel.iconClass}}"></i>
</div>
<div class="alert-list-main alert-list-text">
<span class="alert-list-state {{ah.stateModel.stateClass}}">{{ah.stateModel.text}}</span>
<span class="alert-list-info">{{ah.info}}</span>
</div>
</div>
<div class="alert-list-footer alert-list-text">
<span>{{ah.time}}</span>
<span><!--Img Link--></span>
</div>
</div>
</li>
</ol>
</section>
</div>
</div>
<ol class="alert-rule-list" >
<li class="alert-rule-item" ng-repeat="al in ctrl.alertHistory">
<div class="alert-rule-item__icon {{al.stateModel.stateClass}}">
<i class="{{al.stateModel.iconClass}}"></i>
</div>
<div class="alert-rule-item__body">
<div class="alert-rule-item__header">
<div class="alert-rule-item__text">
<span class="{{al.stateModel.stateClass}}">{{al.stateModel.text}}</span>
</div>
</div>
<span class="alert-list-info">{{al.info}}</span>
</div>
<div class="alert-rule-item__time">
<span>{{al.time}}</span>
</div>
</li>
</ol>
</div>
</div>
</div>
<div class="gf-form-group" ng-if="!ctrl.alert">
<div class="gf-form-button-row">
<button class="btn btn-inverse" ng-click="ctrl.enable()">
<i class="icon-gf icon-gf-alert"></i>
Create Alert
</button>
</div>
<div class="gf-form-button-row">
<button class="btn btn-inverse" ng-click="ctrl.enable()">
<i class="icon-gf icon-gf-alert"></i>
Create Alert
</button>
</div>
</div>

View File

@@ -48,7 +48,7 @@
<div ng-if="ctrl.notifications.length === 0">
<empty-list-cta model="{
title: 'There are no notification channels defined yet',
buttonIcon: 'gicon gicon-alert-notification-channel',
buttonIcon: 'gicon gicon-add-alert-notification-channel',
buttonLink: 'alerting/notification/new',
buttonTitle: 'Add channel',
proTip: 'You can include images in your alert notifications.',

View File

@@ -8,5 +8,8 @@ define([
'./playlist/all',
'./snapshot/all',
'./panel/all',
'./org/all',
'./admin/admin',
'./alerting/all',
'./styleguide/styleguide',
], function () {});

View File

@@ -54,7 +54,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv,
`;
// Show edit icon only for users with at least Editor role
if (event.id && contextSrv.isEditor) {
if (event.id && dashboard.meta.canEdit) {
header += `
<span class="pointer graph-annotation__edit-icon" ng-click="onEdit()">
<i class="fa fa-pencil-square"></i>

View File

@@ -1,126 +0,0 @@
<div class="modal-body">
<div class="modal-header">
<h2 class="modal-header-title">
<i class="fa fa-lock"></i>
<span class="p-l-1">Permissions</span>
</h2>
<a class="modal-header-close" ng-click="ctrl.dismiss();">
<i class="fa fa-remove"></i>
</a>
</div>
<div class="modal-content">
<table class="filter-table gf-form-group">
<tr ng-repeat="acl in ctrl.items" ng-class="{'gf-form-disabled': acl.inherited}">
<td style="width: 100%;">
<i class="{{acl.icon}}"></i>
<span ng-bind-html="acl.nameHtml"></span>
</td>
<td>
<em class="muted no-wrap" ng-show="acl.inherited">Inherited from folder</em>
</td>
<td class="query-keyword">Can</td>
<td>
<div class="gf-form-select-wrapper">
<select class="gf-form-input gf-size-auto" ng-model="acl.permission" ng-options="p.value as p.text for p in ctrl.permissionOptions" ng-change="ctrl.permissionChanged(acl)" ng-disabled="acl.inherited"></select>
</div>
</td>
<td>
<a class="btn btn-inverse btn-small" ng-click="ctrl.removeItem($index)" ng-hide="acl.inherited">
<i class="fa fa-remove"></i>
</a>
</td>
</tr>
<tr ng-show="ctrl.aclItems.length === 0">
<td colspan="4">
<em>No permissions. Will only be accessible by admins.</em>
</td>
</tr>
</table>
<div class="gf-form-inline">
<form name="addPermission" class="gf-form-group">
<h6 class="muted">Add Permission For</h6>
<div class="gf-form-inline">
<div class="gf-form">
<div class="gf-form-select-wrapper">
<select class="gf-form-input gf-size-auto" ng-model="ctrl.newType" ng-options="p.value as p.text for p in ctrl.aclTypes" ng-change="ctrl.typeChanged()"></select>
</div>
</div>
<div class="gf-form" ng-show="ctrl.newType === 'User'">
<user-picker user-picked="ctrl.userPicked($user)"></user-picker>
</div>
<div class="gf-form" ng-show="ctrl.newType === 'Group'">
<team-picker team-picked="ctrl.groupPicked($group)"></team-picker>
</div>
</div>
</form>
<div class="gf-form width-17">
<span ng-if="ctrl.error" class="text-error p-l-1">
<i class="fa fa-warning"></i>
{{ctrl.error}}
</span>
</div>
</div>
<div class="gf-form-button-row text-center">
<button type="button" class="btn btn-danger" ng-disabled="!ctrl.canUpdate" ng-click="ctrl.update()">
Update Permissions
</button>
<a class="btn-text" ng-click="ctrl.dismiss();">Close</a>
</div>
</div>
</div>
<!-- <br> -->
<!-- <br> -->
<!-- <br> -->
<!-- -->
<!-- <div class="permissionlist"> -->
<!-- <div class="permissionlist__section"> -->
<!-- <div class="permissionlist__section&#45;header"> -->
<!-- <h6>Permissions</h6> -->
<!-- </div> -->
<!-- <table class="filter&#45;table form&#45;inline"> -->
<!-- <thead> -->
<!-- <tr> -->
<!-- <th style="width: 50px;"></th> -->
<!-- <th>Name</th> -->
<!-- <th style="width: 220px;">Permission</th> -->
<!-- <th style="width: 120px"></th> -->
<!-- </tr> -->
<!-- </thead> -->
<!-- <tbody> -->
<!-- <tr ng&#45;repeat="permission in ctrl.userPermissions" class="permissionlist__item"> -->
<!-- <td><i class="fa fa&#45;fw fa&#45;user"></i></td> -->
<!-- <td>{{permission.userLogin}}</td> -->
<!-- <td class="text&#45;right"> -->
<!-- <a ng&#45;click="ctrl.removePermission(permission)" class="btn btn&#45;danger btn&#45;small"> -->
<!-- <i class="fa fa&#45;remove"></i> -->
<!-- </a> -->
<!-- </td> -->
<!-- </tr> -->
<!-- <tr ng&#45;repeat="permission in ctrl.teamPermissions" class="permissionlist__item"> -->
<!-- <td><i class="fa fa&#45;fw fa&#45;users"></i></td> -->
<!-- <td>{{permission.team}}</td> -->
<!-- <td><select class="gf&#45;form&#45;input gf&#45;size&#45;auto" ng&#45;model="permission.permissions" ng&#45;options="p.value as p.text for p in ctrl.permissionTypeOptions" ng&#45;change="ctrl.updatePermission(permission)"></select></td> -->
<!-- <td class="text&#45;right"> -->
<!-- <a ng&#45;click="ctrl.removePermission(permission)" class="btn btn&#45;danger btn&#45;small"> -->
<!-- <i class="fa fa&#45;remove"></i> -->
<!-- </a> -->
<!-- </td> -->
<!-- </tr> -->
<!-- <tr ng&#45;repeat="role in ctrl.roles" class="permissionlist__item"> -->
<!-- <td></td> -->
<!-- <td>{{role.name}}</td> -->
<!-- <td><select class="gf&#45;form&#45;input gf&#45;size&#45;auto" ng&#45;model="role.permissions" ng&#45;options="p.value as p.text for p in ctrl.roleOptions" ng&#45;change="ctrl.updatePermission(role)"></select></td> -->
<!-- <td class="text&#45;right"> -->
<!-- -->
<!-- </td> -->
<!-- </tr> -->
<!-- </tbody> -->
<!-- </table> -->
<!-- </div> -->
<!-- </div> -->
<!-- </div> -->

View File

@@ -1,203 +0,0 @@
///<reference path="../../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
import _ from 'lodash';
export class AclCtrl {
dashboard: any;
items: DashboardAcl[];
permissionOptions = [{ value: 1, text: 'View' }, { value: 2, text: 'Edit' }, { value: 4, text: 'Admin' }];
aclTypes = [
{ value: 'Group', text: 'Team' },
{ value: 'User', text: 'User' },
{ value: 'Viewer', text: 'Everyone With Viewer Role' },
{ value: 'Editor', text: 'Everyone With Editor Role' },
];
dismiss: () => void;
newType: string;
canUpdate: boolean;
error: string;
readonly duplicateError = 'This permission exists already.';
/** @ngInject */
constructor(private backendSrv, dashboardSrv, private $sce, private $scope) {
this.items = [];
this.resetNewType();
this.dashboard = dashboardSrv.getCurrent();
this.get(this.dashboard.id);
}
resetNewType() {
this.newType = 'Group';
}
get(dashboardId: number) {
return this.backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`).then(result => {
this.items = _.map(result, this.prepareViewModel.bind(this));
this.sortItems();
});
}
sortItems() {
this.items = _.orderBy(this.items, ['sortRank', 'sortName'], ['desc', 'asc']);
}
prepareViewModel(item: DashboardAcl): DashboardAcl {
item.inherited = !this.dashboard.meta.isFolder && this.dashboard.id !== item.dashboardId;
item.sortRank = 0;
if (item.userId > 0) {
item.icon = 'fa fa-fw fa-user';
item.nameHtml = this.$sce.trustAsHtml(item.userLogin);
item.sortName = item.userLogin;
item.sortRank = 10;
} else if (item.teamId > 0) {
item.icon = 'fa fa-fw fa-users';
item.nameHtml = this.$sce.trustAsHtml(item.team);
item.sortName = item.team;
item.sortRank = 20;
} else if (item.role) {
item.icon = 'fa fa-fw fa-street-view';
item.nameHtml = this.$sce.trustAsHtml(`Everyone with <span class="query-keyword">${item.role}</span> Role`);
item.sortName = item.role;
item.sortRank = 30;
if (item.role === 'Viewer') {
item.sortRank += 1;
}
}
if (item.inherited) {
item.sortRank += 100;
}
return item;
}
update() {
var updated = [];
for (let item of this.items) {
if (item.inherited) {
continue;
}
updated.push({
id: item.id,
userId: item.userId,
teamId: item.teamId,
role: item.role,
permission: item.permission,
});
}
return this.backendSrv.post(`/api/dashboards/id/${this.dashboard.id}/acl`, { items: updated }).then(() => {
return this.dismiss();
});
}
typeChanged() {
if (this.newType === 'Viewer' || this.newType === 'Editor') {
this.addNewItem({ permission: 1, role: this.newType });
this.canUpdate = true;
this.resetNewType();
}
}
permissionChanged() {
this.canUpdate = true;
}
addNewItem(item) {
if (!this.isValid(item)) {
return;
}
this.error = '';
item.dashboardId = this.dashboard.id;
this.items.push(this.prepareViewModel(item));
this.sortItems();
this.canUpdate = true;
}
isValid(item) {
const dupe = _.find(this.items, it => {
return this.isDuplicate(it, item);
});
if (dupe) {
this.error = this.duplicateError;
return false;
}
return true;
}
isDuplicate(origItem, newItem) {
if (origItem.inherited) {
return false;
}
return (
(origItem.role && newItem.role && origItem.role === newItem.role) ||
(origItem.userId && newItem.userId && origItem.userId === newItem.userId) ||
(origItem.teamId && newItem.teamId && origItem.teamId === newItem.teamId)
);
}
userPicked(user) {
this.addNewItem({ userId: user.id, userLogin: user.login, permission: 1 });
this.$scope.$broadcast('user-picker-reset');
}
groupPicked(group) {
this.addNewItem({ teamId: group.id, team: group.name, permission: 1 });
this.$scope.$broadcast('team-picker-reset');
}
removeItem(index) {
this.items.splice(index, 1);
this.canUpdate = true;
}
}
export function dashAclModal() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/acl/acl.html',
controller: AclCtrl,
bindToController: true,
controllerAs: 'ctrl',
scope: {
dismiss: '&',
},
};
}
export interface FormModel {
dashboardId: number;
userId?: number;
teamId?: number;
PermissionType: number;
}
export interface DashboardAcl {
id?: number;
dashboardId?: number;
userId?: number;
userLogin?: string;
userEmail?: string;
teamId?: number;
team?: string;
permission?: number;
permissionName?: string;
role?: string;
icon?: string;
nameHtml?: string;
inherited?: boolean;
sortName?: string;
sortRank?: number;
}
coreModule.directive('dashAclModal', dashAclModal);

View File

@@ -1,188 +0,0 @@
import { describe, beforeEach, it, expect, sinon, angularMocks } from 'test/lib/common';
import { AclCtrl } from '../acl';
describe('AclCtrl', () => {
const ctx: any = {};
const backendSrv = {
get: sinon.stub().returns(Promise.resolve([])),
post: sinon.stub().returns(Promise.resolve([])),
};
const dashboardSrv = {
getCurrent: sinon.stub().returns({ id: 1, meta: { isFolder: false } }),
};
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.controllers'));
beforeEach(
angularMocks.inject(($rootScope, $controller, $q, $compile) => {
ctx.$q = $q;
ctx.scope = $rootScope.$new();
AclCtrl.prototype.dashboard = { dashboard: { id: 1 } };
ctx.ctrl = $controller(
AclCtrl,
{
$scope: ctx.scope,
backendSrv: backendSrv,
dashboardSrv: dashboardSrv,
},
{
dismiss: () => {
return;
},
}
);
})
);
describe('when permissions are added', () => {
beforeEach(() => {
backendSrv.get.reset();
backendSrv.post.reset();
const userItem = {
id: 2,
login: 'user2',
};
ctx.ctrl.userPicked(userItem);
const teamItem = {
id: 2,
name: 'ug1',
};
ctx.ctrl.groupPicked(teamItem);
ctx.ctrl.newType = 'Editor';
ctx.ctrl.typeChanged();
ctx.ctrl.newType = 'Viewer';
ctx.ctrl.typeChanged();
});
it('should sort the result by role, team and user', () => {
expect(ctx.ctrl.items[0].role).to.eql('Viewer');
expect(ctx.ctrl.items[1].role).to.eql('Editor');
expect(ctx.ctrl.items[2].teamId).to.eql(2);
expect(ctx.ctrl.items[3].userId).to.eql(2);
});
it('should save permissions to db', done => {
ctx.ctrl.update().then(() => {
done();
});
expect(backendSrv.post.getCall(0).args[0]).to.eql('/api/dashboards/id/1/acl');
expect(backendSrv.post.getCall(0).args[1].items[0].role).to.eql('Viewer');
expect(backendSrv.post.getCall(0).args[1].items[0].permission).to.eql(1);
expect(backendSrv.post.getCall(0).args[1].items[1].role).to.eql('Editor');
expect(backendSrv.post.getCall(0).args[1].items[1].permission).to.eql(1);
expect(backendSrv.post.getCall(0).args[1].items[2].teamId).to.eql(2);
expect(backendSrv.post.getCall(0).args[1].items[2].permission).to.eql(1);
expect(backendSrv.post.getCall(0).args[1].items[3].userId).to.eql(2);
expect(backendSrv.post.getCall(0).args[1].items[3].permission).to.eql(1);
});
});
describe('when duplicate role permissions are added', () => {
beforeEach(() => {
backendSrv.get.reset();
backendSrv.post.reset();
ctx.ctrl.items = [];
ctx.ctrl.newType = 'Editor';
ctx.ctrl.typeChanged();
ctx.ctrl.newType = 'Editor';
ctx.ctrl.typeChanged();
});
it('should throw a validation error', () => {
expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
});
it('should not add the duplicate permission', () => {
expect(ctx.ctrl.items.length).to.eql(1);
});
});
describe('when duplicate user permissions are added', () => {
beforeEach(() => {
backendSrv.get.reset();
backendSrv.post.reset();
ctx.ctrl.items = [];
const userItem = {
id: 2,
login: 'user2',
};
ctx.ctrl.userPicked(userItem);
ctx.ctrl.userPicked(userItem);
});
it('should throw a validation error', () => {
expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
});
it('should not add the duplicate permission', () => {
expect(ctx.ctrl.items.length).to.eql(1);
});
});
describe('when duplicate team permissions are added', () => {
beforeEach(() => {
backendSrv.get.reset();
backendSrv.post.reset();
ctx.ctrl.items = [];
const teamItem = {
id: 2,
name: 'ug1',
};
ctx.ctrl.groupPicked(teamItem);
ctx.ctrl.groupPicked(teamItem);
});
it('should throw a validation error', () => {
expect(ctx.ctrl.error).to.eql(ctx.ctrl.duplicateError);
});
it('should not add the duplicate permission', () => {
expect(ctx.ctrl.items.length).to.eql(1);
});
});
describe('when one inherited and one not inherited team permission are added', () => {
beforeEach(() => {
backendSrv.get.reset();
backendSrv.post.reset();
ctx.ctrl.items = [];
const inheritedTeamItem = {
id: 2,
name: 'ug1',
dashboardId: -1,
};
ctx.ctrl.items.push(inheritedTeamItem);
const teamItem = {
id: 2,
name: 'ug1',
};
ctx.ctrl.groupPicked(teamItem);
});
it('should not throw a validation error', () => {
expect(ctx.ctrl.error).to.eql('');
});
it('should add both permissions', () => {
expect(ctx.ctrl.items.length).to.eql(2);
});
});
});

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import angular from 'angular';
import coreModule from 'app/core/core_module';

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
export class AlertingSrv {

View File

@@ -23,7 +23,6 @@ import './repeat_option/repeat_option';
import './dashgrid/DashboardGridDirective';
import './dashgrid/PanelLoader';
import './dashgrid/RowOptions';
import './acl/acl';
import './folder_picker/folder_picker';
import './move_to_folder_modal/move_to_folder';
import './settings/settings';
@@ -31,14 +30,12 @@ import './settings/settings';
import coreModule from 'app/core/core_module';
import { DashboardListCtrl } from './dashboard_list_ctrl';
import { FolderDashboardsCtrl } from './folder_dashboards_ctrl';
import { FolderPermissionsCtrl } from './folder_permissions_ctrl';
import { FolderSettingsCtrl } from './folder_settings_ctrl';
import { DashboardImportCtrl } from './dashboard_import_ctrl';
import { CreateFolderCtrl } from './create_folder_ctrl';
coreModule.controller('DashboardListCtrl', DashboardListCtrl);
coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl);
coreModule.controller('FolderPermissionsCtrl', FolderPermissionsCtrl);
coreModule.controller('FolderSettingsCtrl', FolderSettingsCtrl);
coreModule.controller('DashboardImportCtrl', DashboardImportCtrl);
coreModule.controller('CreateFolderCtrl', CreateFolderCtrl);

View File

@@ -1,4 +1,5 @@
import appEvents from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
export class CreateFolderCtrl {
title = '';
@@ -17,11 +18,9 @@ export class CreateFolderCtrl {
return;
}
return this.backendSrv.createDashboardFolder(this.title).then(result => {
return this.backendSrv.createFolder({ title: this.title }).then(result => {
appEvents.emit('alert-success', ['Folder Created', 'OK']);
var folderUrl = `dashboards/folder/${result.dashboard.id}/${result.meta.slug}`;
this.$location.url(folderUrl);
this.$location.url(locationUtil.stripBaseFromUrl(result.url));
});
}
@@ -29,7 +28,7 @@ export class CreateFolderCtrl {
this.titleTouched = true;
this.validationSrv
.validateNewDashboardOrFolderName(this.title)
.validateNewFolderName(this.title)
.then(() => {
this.hasValidationError = false;
})

View File

@@ -3,6 +3,7 @@ import config from 'app/core/config';
import coreModule from 'app/core/core_module';
import { PanelContainer } from './dashgrid/PanelContainer';
import { DashboardModel } from './dashboard_model';
import { PanelModel } from './panel_model';
export class DashboardCtrl implements PanelContainer {
dashboard: DashboardModel;
@@ -130,9 +131,47 @@ export class DashboardCtrl implements PanelContainer {
return this;
}
onRemovingPanel(evt, options) {
options = options || {};
if (!options.panelId) {
return;
}
var panelInfo = this.dashboard.getPanelInfoById(options.panelId);
this.removePanel(panelInfo.panel, true);
}
removePanel(panel: PanelModel, ask: boolean) {
// confirm deletion
if (ask !== false) {
var text2, confirmText;
if (panel.alert) {
text2 = 'Panel includes an alert rule, removing panel will also remove alert rule';
confirmText = 'YES';
}
this.$scope.appEvent('confirm-modal', {
title: 'Remove Panel',
text: 'Are you sure you want to remove this panel?',
text2: text2,
icon: 'fa-trash',
confirmText: confirmText,
yesText: 'Remove',
onConfirm: () => {
this.removePanel(panel, false);
},
});
return;
}
this.dashboard.removePanel(panel);
}
init(dashboard) {
this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this));
this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this));
this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this));
this.setupDashboard(dashboard);
}
}

View File

@@ -18,7 +18,7 @@ export class DashboardImportCtrl {
nameValidationError: any;
/** @ngInject */
constructor(private backendSrv, private validationSrv, navModelSrv, private $location, private $scope, $routeParams) {
constructor(private backendSrv, private validationSrv, navModelSrv, private $location, $routeParams) {
this.navModel = navModelSrv.getNav('create', 'import');
this.step = 1;
@@ -93,7 +93,7 @@ export class DashboardImportCtrl {
this.nameExists = false;
this.validationSrv
.validateNewDashboardOrFolderName(this.dash.title)
.validateNewDashboardName(0, this.dash.title)
.then(() => {
this.hasNameValidationError = false;
})
@@ -124,8 +124,7 @@ export class DashboardImportCtrl {
inputs: inputs,
})
.then(res => {
this.$location.url('dashboard/' + res.importedUri);
this.$scope.dismiss();
this.$location.url(res.importedUrl);
});
}

View File

@@ -35,18 +35,18 @@ export class DashboardLoaderSrv {
};
}
loadDashboard(type, slug) {
loadDashboard(type, slug, uid) {
var promise;
if (type === 'script') {
promise = this._loadScriptedDashboard(slug);
} else if (type === 'snapshot') {
promise = this.backendSrv.get('/api/snapshots/' + this.$routeParams.slug).catch(() => {
promise = this.backendSrv.get('/api/snapshots/' + slug).catch(() => {
return this._dashboardLoadFailed('Snapshot not found', true);
});
} else {
promise = this.backendSrv
.getDashboard(this.$routeParams.type, this.$routeParams.slug)
.getDashboardByUid(uid)
.then(result => {
if (result.meta.isFolder) {
this.$rootScope.appEvent('alert-error', ['Dashboard not found']);

View File

@@ -18,7 +18,7 @@ export class DashboardMigrator {
}
updateSchema(old) {
var i, j, k;
var i, j, k, n;
var oldVersion = this.dashboard.schemaVersion;
var panelUpgrades = [];
this.dashboard.schemaVersion = 16;
@@ -63,11 +63,17 @@ export class DashboardMigrator {
}
if (panel.y_format) {
if (!panel.y_formats) {
panel.y_formats = [];
}
panel.y_formats[0] = panel.y_format;
delete panel.y_format;
}
if (panel.y2_format) {
if (!panel.y_formats) {
panel.y_formats = [];
}
panel.y_formats[1] = panel.y2_format;
delete panel.y2_format;
}
@@ -372,6 +378,11 @@ export class DashboardMigrator {
for (j = 0; j < this.dashboard.panels.length; j++) {
for (k = 0; k < panelUpgrades.length; k++) {
panelUpgrades[k].call(this, this.dashboard.panels[j]);
if (this.dashboard.panels[j].panels) {
for (n = 0; n < this.dashboard.panels[j].panels.length; n++) {
panelUpgrades[k].call(this, this.dashboard.panels[j].panels[n]);
}
}
}
}
}
@@ -429,6 +440,9 @@ export class DashboardMigrator {
for (let panel of row.panels) {
panel.span = panel.span || DEFAULT_PANEL_SPAN;
if (panel.minSpan) {
panel.minSpan = Math.min(GRID_COLUMN_COUNT, GRID_COLUMN_COUNT / 12 * panel.minSpan);
}
const panelWidth = Math.floor(panel.span) * widthFactor;
const panelHeight = panel.height ? getGridHeight(panel.height) : rowGridHeight;

View File

@@ -12,6 +12,7 @@ import { DashboardMigrator } from './dashboard_migration';
export class DashboardModel {
id: any;
uid: any;
title: any;
autoUpdate: any;
description: any;
@@ -56,6 +57,7 @@ export class DashboardModel {
this.events = new Emitter();
this.id = data.id || null;
this.uid = data.uid || null;
this.revision = data.revision;
this.title = data.title || 'No Title';
this.autoUpdate = data.autoUpdate;
@@ -145,7 +147,10 @@ export class DashboardModel {
};
// get panel save models
copy.panels = _.map(this.panels, panel => panel.getSaveModel());
copy.panels = _.chain(this.panels)
.filter(panel => panel.type !== 'add-panel')
.map(panel => panel.getSaveModel())
.value();
// sort by keys
copy = sortByKeys(copy);
@@ -230,10 +235,6 @@ export class DashboardModel {
}
cleanUpRepeats() {
this.processRepeats(true);
}
processRepeats(cleanUpOnly?: boolean) {
if (this.snapshot || this.templating.list.length === 0) {
return;
}
@@ -248,11 +249,7 @@ export class DashboardModel {
for (let i = 0; i < this.panels.length; i++) {
let panel = this.panels[i];
if (panel.repeat) {
if (!cleanUpOnly) {
this.repeatPanel(panel, i);
}
} else if (panel.repeatPanelId && panel.repeatIteration !== this.iteration) {
if ((!panel.repeat || panel.repeatedByRow) && panel.repeatPanelId && panel.repeatIteration !== this.iteration) {
panelsToRemove.push(panel);
}
}
@@ -264,6 +261,60 @@ export class DashboardModel {
this.events.emit('repeats-processed');
}
processRepeats() {
if (this.snapshot || this.templating.list.length === 0) {
return;
}
this.cleanUpRepeats();
this.iteration = (this.iteration || new Date().getTime()) + 1;
for (let i = 0; i < this.panels.length; i++) {
let panel = this.panels[i];
if (panel.repeat) {
this.repeatPanel(panel, i);
}
}
this.sortPanelsByGridPos();
this.events.emit('repeats-processed');
}
cleanUpRowRepeats(rowPanels) {
let panelsToRemove = [];
for (let i = 0; i < rowPanels.length; i++) {
let panel = rowPanels[i];
if (!panel.repeat && panel.repeatPanelId) {
panelsToRemove.push(panel);
}
}
_.pull(rowPanels, ...panelsToRemove);
_.pull(this.panels, ...panelsToRemove);
}
processRowRepeats(row: PanelModel) {
if (this.snapshot || this.templating.list.length === 0) {
return;
}
let rowPanels = row.panels;
if (!row.collapsed) {
let rowPanelIndex = _.findIndex(this.panels, p => p.id === row.id);
rowPanels = this.getRowPanels(rowPanelIndex);
}
this.cleanUpRowRepeats(rowPanels);
for (let i = 0; i < rowPanels.length; i++) {
let panel = rowPanels[i];
if (panel.repeat) {
let panelIndex = _.findIndex(this.panels, p => p.id === panel.id);
this.repeatPanel(panel, panelIndex);
}
}
}
getPanelRepeatClone(sourcePanel, valueIndex, sourcePanelIndex) {
// if first clone return source
if (valueIndex === 0) {
@@ -282,21 +333,21 @@ export class DashboardModel {
return clone;
}
getRowRepeatClone(sourcePanel, valueIndex, sourcePanelIndex) {
getRowRepeatClone(sourceRowPanel, valueIndex, sourcePanelIndex) {
// if first clone return source
if (valueIndex === 0) {
if (!sourcePanel.collapsed) {
if (!sourceRowPanel.collapsed) {
let rowPanels = this.getRowPanels(sourcePanelIndex);
sourcePanel.panels = rowPanels;
sourceRowPanel.panels = rowPanels;
}
return sourcePanel;
return sourceRowPanel;
}
let clone = new PanelModel(sourcePanel.getSaveModel());
let clone = new PanelModel(sourceRowPanel.getSaveModel());
// for row clones we need to figure out panels under row to clone and where to insert clone
let rowPanels, insertPos;
if (sourcePanel.collapsed) {
rowPanels = _.cloneDeep(sourcePanel.panels);
if (sourceRowPanel.collapsed) {
rowPanels = _.cloneDeep(sourceRowPanel.panels);
clone.panels = rowPanels;
// insert copied row after preceding row
insertPos = sourcePanelIndex + valueIndex;
@@ -333,16 +384,17 @@ export class DashboardModel {
let copy;
copy = this.getPanelRepeatClone(panel, index, panelIndex);
copy.scopedVars = {};
copy.scopedVars = copy.scopedVars || {};
copy.scopedVars[variable.name] = option;
if (panel.repeatDirection === REPEAT_DIR_VERTICAL) {
if (index > 0) {
yPos += copy.gridPos.h;
}
copy.gridPos.y = yPos;
yPos += copy.gridPos.h;
} else {
// set width based on how many are selected
// assumed the repeated panels should take up full row width
copy.gridPos.w = Math.max(GRID_COLUMN_COUNT / selectedOptions.length, minWidth);
copy.gridPos.x = xPos;
copy.gridPos.y = yPos;
@@ -356,6 +408,15 @@ export class DashboardModel {
}
}
}
// Update gridPos for panels below
let yOffset = yPos - panel.gridPos.y;
if (yOffset > 0) {
let panelBelowIndex = panelIndex + selectedOptions.length;
for (let i = panelBelowIndex; i < this.panels.length; i++) {
this.panels[i].gridPos.y += yOffset;
}
}
}
repeatRow(panel: PanelModel, panelIndex: number, variable) {
@@ -363,7 +424,7 @@ export class DashboardModel {
let yPos = panel.gridPos.y;
function setScopedVars(panel, variableOption) {
panel.scopedVars = {};
panel.scopedVars = panel.scopedVars || {};
panel.scopedVars[variable.name] = variableOption;
}
@@ -381,7 +442,7 @@ export class DashboardModel {
_.each(rowPanels, (rowPanel, i) => {
setScopedVars(rowPanel, option);
if (optionIndex > 0) {
this.updateRepeatedPanelIds(rowPanel);
this.updateRepeatedPanelIds(rowPanel, true);
}
});
rowCopy.gridPos.y += optionIndex;
@@ -394,7 +455,7 @@ export class DashboardModel {
setScopedVars(rowPanel, option);
if (optionIndex > 0) {
let cloneRowPanel = new PanelModel(rowPanel);
this.updateRepeatedPanelIds(cloneRowPanel);
this.updateRepeatedPanelIds(cloneRowPanel, true);
// For exposed row additionally set proper Y grid position and add it to dashboard panels
cloneRowPanel.gridPos.y += rowHeight * optionIndex;
this.panels.splice(insertPos + i, 0, cloneRowPanel);
@@ -413,11 +474,15 @@ export class DashboardModel {
}
}
updateRepeatedPanelIds(panel: PanelModel) {
updateRepeatedPanelIds(panel: PanelModel, repeatedByRow?: boolean) {
panel.repeatPanelId = panel.id;
panel.id = this.getNextPanelId();
panel.repeatIteration = this.iteration;
panel.repeat = null;
if (repeatedByRow) {
panel.repeatedByRow = true;
} else {
panel.repeat = null;
}
return panel;
}
@@ -435,11 +500,12 @@ export class DashboardModel {
if (!rowPanel.panels || rowPanel.panels.length === 0) {
return 0;
}
const rowYPos = rowPanel.gridPos.y;
const positions = _.map(rowPanel.panels, 'gridPos');
const maxPos = _.maxBy(positions, pos => {
return pos.y + pos.h;
});
return maxPos.h + 1;
return maxPos.y + maxPos.h - rowYPos;
}
removePanel(panel: PanelModel) {
@@ -458,6 +524,34 @@ export class DashboardModel {
this.removePanel(row);
}
expandRows() {
for (let i = 0; i < this.panels.length; i++) {
var panel = this.panels[i];
if (panel.type !== 'row') {
continue;
}
if (panel.collapsed) {
this.toggleRow(panel);
}
}
}
collapseRows() {
for (let i = 0; i < this.panels.length; i++) {
var panel = this.panels[i];
if (panel.type !== 'row') {
continue;
}
if (!panel.collapsed) {
this.toggleRow(panel);
}
}
}
setPanelFocus(id) {
this.meta.focusPanelId = id;
}
@@ -540,6 +634,7 @@ export class DashboardModel {
if (row.collapsed) {
row.collapsed = false;
let hasRepeat = _.some(row.panels, p => p.repeat);
if (row.panels.length > 0) {
// Use first panel to figure out if it was moved or pushed
@@ -570,6 +665,10 @@ export class DashboardModel {
}
row.panels = [];
if (hasRepeat) {
this.processRowRepeats(row);
}
}
// sort panels

View File

@@ -1,5 +1,6 @@
import coreModule from 'app/core/core_module';
import { DashboardModel } from './dashboard_model';
import locationUtil from 'app/core/utils/location_util';
export class DashboardSrv {
dash: any;
@@ -19,7 +20,10 @@ export class DashboardSrv {
return this.dash;
}
handleSaveDashboardError(clone, err) {
handleSaveDashboardError(clone, options, err) {
options = options || {};
options.overwrite = true;
if (err.data && err.data.status === 'version-mismatch') {
err.isHandled = true;
@@ -30,7 +34,7 @@ export class DashboardSrv {
yesText: 'Save & Overwrite',
icon: 'fa-warning',
onConfirm: () => {
this.save(clone, { overwrite: true });
this.save(clone, options);
},
});
}
@@ -40,12 +44,12 @@ export class DashboardSrv {
this.$rootScope.appEvent('confirm-modal', {
title: 'Conflict',
text: 'Dashboard with the same name exists.',
text: 'A dashboard with the same name in selected folder already exists.',
text2: 'Would you still like to save this dashboard?',
yesText: 'Save & Overwrite',
icon: 'fa-warning',
onConfirm: () => {
this.save(clone, { overwrite: true });
this.save(clone, options);
},
});
}
@@ -73,9 +77,11 @@ export class DashboardSrv {
postSave(clone, data) {
this.dash.version = data.version;
var dashboardUrl = '/dashboard/db/' + data.slug;
if (dashboardUrl !== this.$location.path()) {
this.$location.url(dashboardUrl);
const newUrl = locationUtil.stripBaseFromUrl(data.url);
const currentPath = this.$location.path();
if (newUrl !== currentPath) {
this.$location.url(newUrl).replace();
}
this.$rootScope.appEvent('dashboard-saved', this.dash);
@@ -86,12 +92,12 @@ export class DashboardSrv {
save(clone, options) {
options = options || {};
options.folderId = this.dash.meta.folderId;
options.folderId = options.folderId >= 0 ? options.folderId : this.dash.meta.folderId || clone.folderId;
return this.backendSrv
.saveDashboard(clone, options)
.then(this.postSave.bind(this, clone))
.catch(this.handleSaveDashboardError.bind(this, clone));
.catch(this.handleSaveDashboardError.bind(this, clone, options));
}
saveDashboard(options, clone) {

View File

@@ -21,6 +21,8 @@ export interface AddPanelPanelState {
export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelPanelState> {
constructor(props) {
super(props);
this.handleCloseAddPanel = this.handleCloseAddPanel.bind(this);
this.renderPanelItem = this.renderPanelItem.bind(this);
this.state = {
panelPlugins: this.getPanelPlugins(),
@@ -83,8 +85,14 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
dashboard.removePanel(this.props.panel);
};
handleCloseAddPanel(evt) {
evt.preventDefault();
const panelContainer = this.props.getPanelContainer();
const dashboard = panelContainer.getDashboard();
dashboard.removePanel(dashboard.panels[0]);
}
renderPanelItem(panel, index) {
console.log('render panel', index);
return (
<div key={index} className="add-panel__item" onClick={() => this.onAddPanel(panel)} title={panel.name}>
<img className="add-panel__item-img" src={panel.info.logos.small} />
@@ -101,10 +109,11 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
<i className="gicon gicon-add-panel" />
<span className="add-panel__title">New Panel</span>
<span className="add-panel__sub-title">Select a visualization</span>
<button className="add-panel__close" onClick={this.handleCloseAddPanel}>
<i className="fa fa-close" />
</button>
</div>
<ScrollBar className="add-panel__items">
{this.state.panelPlugins.map(this.renderPanelItem.bind(this))}
</ScrollBar>
<ScrollBar className="add-panel__items">{this.state.panelPlugins.map(this.renderPanelItem)}</ScrollBar>
</div>
</div>
);

View File

@@ -1,5 +1,5 @@
import React from 'react';
import ReactGridLayout from 'react-grid-layout';
import ReactGridLayout from 'react-grid-layout-grafana';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { DashboardPanel } from './DashboardPanel';
import { DashboardModel } from '../dashboard_model';
@@ -50,7 +50,8 @@ function GridWrapper({
onResize={onResize}
onResizeStop={onResizeStop}
onDragStop={onDragStop}
onLayoutChange={onLayoutChange}>
onLayoutChange={onLayoutChange}
>
{children}
</ReactGridLayout>
);
@@ -178,7 +179,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
panelElements.push(
<div key={panel.id.toString()} className={panelClasses}>
<DashboardPanel panel={panel} getPanelContainer={this.props.getPanelContainer} />
</div>,
</div>
);
}
@@ -196,7 +197,8 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
onWidthChange={this.onWidthChange}
onDragStop={this.onDragStop}
onResize={this.onResize}
onResizeStop={this.onResizeStop}>
onResizeStop={this.onResizeStop}
>
{this.renderPanels()}
</SizedReactLayoutGrid>
);

View File

@@ -1,9 +1,10 @@
import React from "react";
import classNames from "classnames";
import { PanelModel } from "../panel_model";
import { PanelContainer } from "./PanelContainer";
import templateSrv from "app/features/templating/template_srv";
import appEvents from "app/core/app_events";
import React from 'react';
import classNames from 'classnames';
import { PanelModel } from '../panel_model';
import { PanelContainer } from './PanelContainer';
import templateSrv from 'app/features/templating/template_srv';
import appEvents from 'app/core/app_events';
import config from 'app/core/config';
export interface DashboardRowProps {
panel: PanelModel;
@@ -18,7 +19,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
super(props);
this.state = {
collapsed: this.props.panel.collapsed
collapsed: this.props.panel.collapsed,
};
this.panelContainer = this.props.getPanelContainer();
@@ -27,6 +28,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
this.toggle = this.toggle.bind(this);
this.openSettings = this.openSettings.bind(this);
this.delete = this.delete.bind(this);
this.update = this.update.bind(this);
}
toggle() {
@@ -37,23 +39,28 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
});
}
update() {
this.dashboard.processRepeats();
this.forceUpdate();
}
openSettings() {
appEvents.emit("show-modal", {
appEvents.emit('show-modal', {
templateHtml: `<row-options row="model.row" on-updated="model.onUpdated()" dismiss="dismiss()"></row-options>`,
modalClass: "modal--narrow",
modalClass: 'modal--narrow',
model: {
row: this.props.panel,
onUpdated: this.forceUpdate.bind(this)
}
onUpdated: this.update.bind(this),
},
});
}
delete() {
appEvents.emit("confirm-modal", {
title: "Delete Row",
text: "Are you sure you want to remove this row and all its panels?",
altActionText: "Delete row only",
icon: "fa-trash",
appEvents.emit('confirm-modal', {
title: 'Delete Row',
text: 'Are you sure you want to remove this row and all its panels?',
altActionText: 'Delete row only',
icon: 'fa-trash',
onConfirm: () => {
const panelContainer = this.props.getPanelContainer();
const dashboard = panelContainer.getDashboard();
@@ -63,46 +70,41 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
const panelContainer = this.props.getPanelContainer();
const dashboard = panelContainer.getDashboard();
dashboard.removeRow(this.props.panel, false);
}
},
});
}
render() {
const classes = classNames({
"dashboard-row": true,
"dashboard-row--collapsed": this.state.collapsed
'dashboard-row': true,
'dashboard-row--collapsed': this.state.collapsed,
});
const chevronClass = classNames({
fa: true,
"fa-chevron-down": !this.state.collapsed,
"fa-chevron-right": this.state.collapsed
'fa-chevron-down': !this.state.collapsed,
'fa-chevron-right': this.state.collapsed,
});
let title = templateSrv.replaceWithText(
this.props.panel.title,
this.props.panel.scopedVars
);
const hiddenPanels = this.props.panel.panels
? this.props.panel.panels.length
: 0;
let title = templateSrv.replaceWithText(this.props.panel.title, this.props.panel.scopedVars);
const hiddenPanels = this.props.panel.panels ? this.props.panel.panels.length : 0;
return (
<div className={classes}>
<a className="dashboard-row__title pointer" onClick={this.toggle}>
<i className={chevronClass} />
{title}
<span className="dashboard-row__panel_count">
({hiddenPanels} hidden panels)
</span>
<span className="dashboard-row__panel_count">({hiddenPanels} hidden panels)</span>
</a>
<div className="dashboard-row__actions">
<a className="pointer" onClick={this.openSettings}>
<i className="fa fa-cog" />
</a>
<a className="pointer" onClick={this.delete}>
<i className="fa fa-trash" />
</a>
</div>
{config.bootData.user.orgRole !== 'Viewer' && (
<div className="dashboard-row__actions">
<a className="pointer" onClick={this.openSettings}>
<i className="fa fa-cog" />
</a>
<a className="pointer" onClick={this.delete}>
<i className="fa fa-trash" />
</a>
</div>
)}
<div className="dashboard-row__drag grid-drag-handle" />
</div>
);

View File

@@ -35,8 +35,7 @@ export class DashNavCtrl {
let search = this.$location.search();
if (search.editview) {
delete search.editview;
}
if (search.fullscreen) {
} else if (search.fullscreen) {
delete search.fullscreen;
delete search.edit;
}
@@ -73,9 +72,10 @@ export class DashNavCtrl {
}
addPanel() {
appEvents.emit('dash-scroll', { animate: true, evt: 0 });
if (this.dashboard.panels.length > 0 && this.dashboard.panels[0].type === 'add-panel') {
this.dashboard.removePanel(this.dashboard.panels[0]);
return;
return; // Return if the "Add panel" exists already
}
this.dashboard.addPanel({

View File

@@ -1,17 +1,25 @@
import { FolderPageLoader } from './folder_page_loader';
import locationUtil from 'app/core/utils/location_util';
export class FolderDashboardsCtrl {
navModel: any;
folderId: number;
uid: string;
/** @ngInject */
constructor(private backendSrv, navModelSrv, private $routeParams) {
if (this.$routeParams.folderId && this.$routeParams.slug) {
this.folderId = $routeParams.folderId;
constructor(private backendSrv, navModelSrv, private $routeParams, $location) {
if (this.$routeParams.uid) {
this.uid = $routeParams.uid;
const loader = new FolderPageLoader(this.backendSrv, this.$routeParams);
const loader = new FolderPageLoader(this.backendSrv);
loader.load(this, this.folderId, 'manage-folder-dashboards');
loader.load(this, this.uid, 'manage-folder-dashboards').then(folder => {
const url = locationUtil.stripBaseFromUrl(folder.url);
if (url !== $location.path()) {
$location.path(url).replace();
}
});
}
}
}

51
public/app/features/dashboard/folder_page_loader.ts Normal file → Executable file
View File

@@ -1,9 +1,7 @@
import _ from 'lodash';
export class FolderPageLoader {
constructor(private backendSrv, private $routeParams) {}
constructor(private backendSrv) {}
load(ctrl, folderId, activeChildId) {
load(ctrl, uid, activeChildId) {
ctrl.navModel = {
main: {
icon: 'fa fa-folder-open',
@@ -11,60 +9,53 @@ export class FolderPageLoader {
subTitle: 'Manage folder dashboards & permissions',
url: '',
text: '',
breadcrumbs: [{ title: 'Dashboards', url: '/dashboards' }, { title: ' ' }],
breadcrumbs: [{ title: 'Dashboards', url: 'dashboards' }],
children: [
{
active: activeChildId === 'manage-folder-dashboards',
icon: 'fa fa-fw fa-th-large',
id: 'manage-folder-dashboards',
text: 'Dashboards',
url: '/dashboards',
url: 'dashboards',
},
{
active: activeChildId === 'manage-folder-permissions',
icon: 'fa fa-fw fa-lock',
id: 'manage-folder-permissions',
text: 'Permissions',
url: '/dashboards/permissions',
url: 'dashboards/permissions',
},
{
active: activeChildId === 'manage-folder-settings',
icon: 'fa fa-fw fa-cog',
id: 'manage-folder-settings',
text: 'Settings',
url: '/dashboards/settings',
url: 'dashboards/settings',
},
],
},
};
return this.backendSrv.getDashboard('db', this.$routeParams.slug).then(result => {
const folderTitle = result.dashboard.title;
ctrl.navModel.main.text = '';
ctrl.navModel.main.breadcrumbs = [{ title: 'Dashboards', url: '/dashboards' }, { title: folderTitle }];
return this.backendSrv.getFolderByUid(uid).then(folder => {
ctrl.folderId = folder.id;
const folderTitle = folder.title;
const folderUrl = folder.url;
ctrl.navModel.main.text = folderTitle;
const folderUrl = this.createFolderUrl(folderId, result.meta.type, result.meta.slug);
const dashTab = _.find(ctrl.navModel.main.children, {
id: 'manage-folder-dashboards',
});
const dashTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-dashboards');
dashTab.url = folderUrl;
const permTab = _.find(ctrl.navModel.main.children, {
id: 'manage-folder-permissions',
});
permTab.url = folderUrl + '/permissions';
if (folder.canAdmin) {
const permTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-permissions');
permTab.url = folderUrl + '/permissions';
const settingsTab = _.find(ctrl.navModel.main.children, {
id: 'manage-folder-settings',
});
settingsTab.url = folderUrl + '/settings';
const settingsTab = ctrl.navModel.main.children.find(child => child.id === 'manage-folder-settings');
settingsTab.url = folderUrl + '/settings';
} else {
ctrl.navModel.main.children = [dashTab];
}
return result;
return folder;
});
}
createFolderUrl(folderId: number, type: string, slug: string) {
return `dashboards/folder/${folderId}/${slug}`;
}
}

View File

@@ -3,13 +3,23 @@ import { FolderPageLoader } from './folder_page_loader';
export class FolderPermissionsCtrl {
navModel: any;
folderId: number;
uid: string;
dashboard: any;
meta: any;
/** @ngInject */
constructor(private backendSrv, navModelSrv, private $routeParams) {
if (this.$routeParams.folderId && this.$routeParams.slug) {
this.folderId = $routeParams.folderId;
constructor(private backendSrv, navModelSrv, private $routeParams, $location) {
if (this.$routeParams.uid) {
this.uid = $routeParams.uid;
new FolderPageLoader(this.backendSrv, this.$routeParams).load(this, this.folderId, 'manage-folder-permissions');
new FolderPageLoader(this.backendSrv).load(this, this.uid, 'manage-folder-permissions').then(folder => {
if ($location.path() !== folder.meta.url) {
$location.path(`${folder.meta.url}/permissions`).replace();
}
this.dashboard = folder.dashboard;
this.meta = folder.meta;
});
}
}
}

View File

@@ -9,29 +9,21 @@
</div>
<input type="text"
class="gf-form-input max-width-10"
ng-show="ctrl.createNewFolder"
ng-if="ctrl.createNewFolder"
give-focus="ctrl.createNewFolder"
ng-model="ctrl.newFolderName"
ng-model-options="{ debounce: 400 }"
ng-class="{'validation-error': !ctrl.isNewFolderNameValid()}"
ng-change="ctrl.newFolderNameChanged()" />
</div>
<div class="gf-form" ng-show="ctrl.createNewFolder">
<label class="gf-form-label text-success"
ng-show="ctrl.newFolderNameTouched && !ctrl.hasValidationError">
<i class="fa fa-check"></i>
</label>
</div>
<div class="gf-form" ng-show="ctrl.createNewFolder">
<button class="gf-form-label"
<div class="gf-form" ng-if="ctrl.createNewFolder">
<button class="btn btn-inverse"
ng-click="ctrl.createFolder($event)"
ng-disabled="!ctrl.newFolderNameTouched || ctrl.hasValidationError">
<i class="fa fa-fw fa-save"></i>&nbsp;Create
</button>
</div>
<div class="gf-form" ng-show="ctrl.createNewFolder">
<button class="gf-form-label"
ng-click="ctrl.cancelCreateFolder($event)">
<div class="gf-form" ng-if="ctrl.createNewFolder">
<button class="btn btn-inverse" ng-click="ctrl.cancelCreateFolder($event)">
Cancel
</button>
</div>

View File

@@ -12,7 +12,7 @@ export class FolderPickerCtrl {
enterFolderCreation: any;
exitFolderCreation: any;
enableCreateNew: boolean;
rootName = 'Root';
rootName = 'General';
folder: any;
createNewFolder: boolean;
newFolderName: string;
@@ -30,18 +30,22 @@ export class FolderPickerCtrl {
}
getOptions(query) {
var params = {
const params = {
query: query,
type: 'dash-folder',
permission: 'Edit',
};
return this.backendSrv.search(params).then(result => {
return this.backendSrv.get('api/search', params).then(result => {
if (
query === '' ||
query.toLowerCase() === 'r' ||
query.toLowerCase() === 'ro' ||
query.toLowerCase() === 'roo' ||
query.toLowerCase() === 'root'
query.toLowerCase() === 'g' ||
query.toLowerCase() === 'ge' ||
query.toLowerCase() === 'gen' ||
query.toLowerCase() === 'gene' ||
query.toLowerCase() === 'gener' ||
query.toLowerCase() === 'genera' ||
query.toLowerCase() === 'general'
) {
result.unshift({ title: this.rootName, id: 0 });
}
@@ -69,7 +73,7 @@ export class FolderPickerCtrl {
this.newFolderNameTouched = true;
this.validationSrv
.validateNewDashboardOrFolderName(this.newFolderName)
.validateNewFolderName(this.newFolderName)
.then(() => {
this.hasValidationError = false;
})
@@ -85,13 +89,13 @@ export class FolderPickerCtrl {
evt.preventDefault();
}
return this.backendSrv.createDashboardFolder(this.newFolderName).then(result => {
return this.backendSrv.createFolder({ title: this.newFolderName }).then(result => {
appEvents.emit('alert-success', ['Folder Created', 'OK']);
this.closeCreateFolder();
this.folder = {
text: result.dashboard.title,
value: result.dashboard.id,
text: result.title,
value: result.id,
};
this.onFolderChange(this.folder);
});
@@ -120,6 +124,9 @@ export class FolderPickerCtrl {
if (this.initialFolderId && this.initialFolderId > 0) {
this.getOptions('').then(result => {
this.folder = _.find(result, { value: this.initialFolderId });
if (!this.folder) {
this.folder = { text: this.initialTitle, value: this.initialFolderId };
}
this.onFolderLoad();
});
} else {

View File

@@ -5,23 +5,26 @@ export class FolderSettingsCtrl {
folderPageLoader: FolderPageLoader;
navModel: any;
folderId: number;
uid: string;
canSave = false;
dashboard: any;
meta: any;
folder: any;
title: string;
hasChanged: boolean;
/** @ngInject */
constructor(private backendSrv, navModelSrv, private $routeParams, private $location) {
if (this.$routeParams.folderId && this.$routeParams.slug) {
this.folderId = $routeParams.folderId;
if (this.$routeParams.uid) {
this.uid = $routeParams.uid;
this.folderPageLoader = new FolderPageLoader(this.backendSrv, this.$routeParams);
this.folderPageLoader.load(this, this.folderId, 'manage-folder-settings').then(result => {
this.dashboard = result.dashboard;
this.meta = result.meta;
this.canSave = result.meta.canSave;
this.title = this.dashboard.title;
this.folderPageLoader = new FolderPageLoader(this.backendSrv);
this.folderPageLoader.load(this, this.uid, 'manage-folder-settings').then(folder => {
if ($location.path() !== folder.meta.url) {
$location.path(`${folder.meta.url}/settings`).replace();
}
this.folder = folder;
this.canSave = this.folder.canSave;
this.title = this.folder.title;
});
}
}
@@ -33,14 +36,13 @@ export class FolderSettingsCtrl {
return;
}
this.dashboard.title = this.title.trim();
this.folder.title = this.title.trim();
return this.backendSrv
.saveDashboard(this.dashboard, { overwrite: false })
.updateFolder(this.folder)
.then(result => {
var folderUrl = this.folderPageLoader.createFolderUrl(this.folderId, this.meta.type, result.slug);
if (folderUrl !== this.$location.path()) {
this.$location.url(folderUrl + '/settings');
if (result.url !== this.$location.path()) {
this.$location.url(result.url + '/settings');
}
appEvents.emit('dashboard-saved');
@@ -50,7 +52,7 @@ export class FolderSettingsCtrl {
}
titleChanged() {
this.hasChanged = this.dashboard.title.toLowerCase() !== this.title.trim().toLowerCase();
this.hasChanged = this.folder.title.toLowerCase() !== this.title.trim().toLowerCase();
}
delete(evt) {
@@ -65,8 +67,8 @@ export class FolderSettingsCtrl {
icon: 'fa-trash',
yesText: 'Delete',
onConfirm: () => {
return this.backendSrv.deleteDashboard(this.meta.slug).then(() => {
appEvents.emit('alert-success', ['Folder Deleted', `${this.dashboard.title} has been deleted`]);
return this.backendSrv.deleteFolder(this.uid).then(() => {
appEvents.emit('alert-success', ['Folder Deleted', `${this.folder.title} has been deleted`]);
this.$location.url('dashboards');
});
},
@@ -84,15 +86,9 @@ export class FolderSettingsCtrl {
yesText: 'Save & Overwrite',
icon: 'fa-warning',
onConfirm: () => {
this.backendSrv.saveDashboard(this.dashboard, { overwrite: true });
this.backendSrv.updateFolder(this.folder, { overwrite: true });
},
});
}
if (err.data && err.data.status === 'name-exists') {
err.isHandled = true;
appEvents.emit('alert-error', ['A folder or dashboard with this name exists already.']);
}
}
}

View File

@@ -4,6 +4,7 @@ import _ from 'lodash';
import angular from 'angular';
import moment from 'moment';
import locationUtil from 'app/core/utils/location_util';
import { DashboardModel } from '../dashboard_model';
import { HistoryListOpts, RevisionsModel, CalculateDiffOptions, HistorySrv } from './history_srv';
@@ -185,7 +186,7 @@ export class HistoryListCtrl {
return this.historySrv
.restoreDashboard(this.dashboard, version)
.then(response => {
this.$location.path('dashboard/db/' + response.slug);
this.$location.url(locationUtil.stripBaseFromUrl(response.url)).replace();
this.$route.reload();
this.$rootScope.appEvent('alert-success', ['Dashboard restored', 'Restored from version ' + version]);
})

View File

@@ -26,6 +26,7 @@ export class PanelModel {
repeatIteration?: number;
repeatPanelId?: number;
repeatDirection?: string;
repeatedByRow?: boolean;
minSpan?: number;
collapsed?: boolean;
panels?: any;

View File

@@ -1,5 +1,5 @@
<page-header ng-if="ctrl.navModel" model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<manage-dashboards ng-if="ctrl.folderId" folder-id="ctrl.folderId" />
</div>
<manage-dashboards ng-if="ctrl.folderId && ctrl.uid" folder-id="ctrl.folderId" folder-uid="ctrl.uid" />
</div>

View File

@@ -1,7 +1,7 @@
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body">
<h2 class="page-sub-heading">
Coming soon! Permissions will be added in Grafana 5.0 beta.
</h2>
<dashboard-permissions ng-if="ctrl.dashboard && ctrl.meta"
dashboardId="ctrl.dashboard.id"
/>
</div>

View File

@@ -13,7 +13,7 @@ const template = `
</a>
</div>
<form name="ctrl.saveForm" ng-submit="ctrl.save()" class="modal-content" novalidate>
<form name="ctrl.saveForm" class="modal-content" novalidate>
<div class="p-t-2">
<div class="gf-form">
<label class="gf-form-label width-7">New name</label>
@@ -31,7 +31,7 @@ const template = `
</div>
<div class="gf-form-button-row text-center">
<button type="submit" class="btn btn-success" ng-disabled="ctrl.saveForm.$invalid || !ctrl.isValidFolderSelection">Save</button>
<button type="submit" class="btn btn-success" ng-click="ctrl.save()" ng-disabled="!ctrl.isValidFolderSelection">Save</button>
<a class="btn-text" ng-click="ctrl.dismiss();">Cancel</a>
</div>
</form>
@@ -41,14 +41,15 @@ const template = `
export class SaveDashboardAsModalCtrl {
clone: any;
folderId: any;
isValidFolderSelection = true;
dismiss: () => void;
isValidFolderSelection = true;
/** @ngInject */
constructor(private dashboardSrv) {
var dashboard = this.dashboardSrv.getCurrent();
this.clone = dashboard.getSaveModelClone();
this.clone.id = null;
this.clone.uid = '';
this.clone.title += ' Copy';
this.clone.editable = true;
this.clone.hideControls = false;
@@ -69,7 +70,17 @@ export class SaveDashboardAsModalCtrl {
}
save() {
return this.dashboardSrv.save(this.clone).then(this.dismiss);
return this.dashboardSrv.save(this.clone, { folderId: this.folderId }).then(this.dismiss);
}
keyDown(evt) {
if (evt.keyCode === 13) {
this.save();
}
}
onFolderChange(folder) {
this.folderId = folder.id;
}
onEnterFolderCreation() {
@@ -79,16 +90,6 @@ export class SaveDashboardAsModalCtrl {
onExitFolderCreation() {
this.isValidFolderSelection = true;
}
keyDown(evt) {
if (this.isValidFolderSelection && evt.keyCode === 13) {
this.save();
}
}
onFolderChange(folder) {
this.clone.folderId = folder.id;
}
}
export function saveDashboardAsDirective() {

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
const template = `

View File

@@ -10,11 +10,13 @@
</a>
<div class="dashboard-settings__aside-actions">
<button class="btn btn-success" ng-click="ctrl.saveDashboard()" ng-show="ctrl.canSave">
<i class="fa fa-save"></i> Save
</button>
<button class="btn btn-inverse" ng-click="ctrl.openSaveAsModal()" ng-show="ctrl.canSaveAs">
<i class="fa fa-copy"></i>
Save As...
</button>
<button class="btn btn-danger" ng-click="ctrl.deleteDashboard()" ng-show="ctrl.canDelete">
<i class="fa fa-trash"></i>
Delete
@@ -93,6 +95,17 @@
</div>
</div>
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'permissions'" >
<dashboard-permissions ng-if="ctrl.dashboard && !ctrl.hasUnsavedFolderChange"
dashboardId="ctrl.dashboard.id"
backendSrv="ctrl.backendSrv"
folder="ctrl.getFolder()"
/>
<div ng-if="ctrl.hasUnsavedFolderChange">
<h5>You have changed folder, please save to view permissions.</h5>
</div>
</div>
<div class="dashboard-settings__content" ng-if="ctrl.viewId === '404'">
<h3 class="dashboard-settings__header">Settings view not found</h3>

40
public/app/features/dashboard/settings/settings.ts Normal file → Executable file
View File

@@ -2,6 +2,7 @@ import { coreModule, appEvents, contextSrv } from 'app/core/core';
import { DashboardModel } from '../dashboard_model';
import $ from 'jquery';
import _ from 'lodash';
import config from 'app/core/config';
export class SettingsCtrl {
dashboard: DashboardModel;
@@ -10,8 +11,10 @@ export class SettingsCtrl {
json: string;
alertCount: number;
canSaveAs: boolean;
canSave: boolean;
canDelete: boolean;
sections: any[];
hasUnsavedFolderChange: boolean;
/** @ngInject */
constructor(private $scope, private $location, private $rootScope, private backendSrv, private dashboardSrv) {
@@ -22,15 +25,21 @@ export class SettingsCtrl {
this.$scope.$on('$destroy', () => {
this.dashboard.updateSubmenuVisibility();
this.$rootScope.$broadcast('refresh');
setTimeout(() => {
this.$rootScope.appEvent('dash-scroll', { restore: true });
});
});
this.canSaveAs = contextSrv.isEditor;
this.canSave = this.dashboard.meta.canSave;
this.canDelete = this.dashboard.meta.canSave;
this.buildSectionList();
this.onRouteUpdated();
$rootScope.onAppEvent('$routeUpdate', this.onRouteUpdated.bind(this), $scope);
this.$rootScope.onAppEvent('$routeUpdate', this.onRouteUpdated.bind(this), $scope);
this.$rootScope.appEvent('dash-scroll', { animate: false, pos: 0 });
this.$rootScope.onAppEvent('dashboard-saved', this.onPostSave.bind(this), $scope);
}
buildSectionList() {
@@ -67,6 +76,14 @@ export class SettingsCtrl {
});
}
if (this.dashboard.id && this.dashboard.meta.canAdmin) {
this.sections.push({
title: 'Permissions',
id: 'permissions',
icon: 'fa fa-fw fa-lock',
});
}
if (this.dashboard.meta.canMakeEditable) {
this.sections.push({
title: 'General',
@@ -86,7 +103,7 @@ export class SettingsCtrl {
for (let section of this.sections) {
const sectionParams = _.defaults({ editview: section.id }, params);
section.url = url + '?' + $.param(sectionParams);
section.url = config.appSubUrl + url + '?' + $.param(sectionParams);
}
}
@@ -116,6 +133,14 @@ export class SettingsCtrl {
this.dashboardSrv.showSaveAsModal();
}
saveDashboard() {
this.dashboardSrv.saveDashboard();
}
onPostSave() {
this.hasUnsavedFolderChange = false;
}
hideSettings() {
var urlParams = this.$location.search();
delete urlParams.editview;
@@ -167,7 +192,7 @@ export class SettingsCtrl {
}
deleteDashboardConfirmed() {
this.backendSrv.deleteDashboard(this.dashboard.meta.slug).then(() => {
this.backendSrv.deleteDashboard(this.dashboard.uid).then(() => {
appEvents.emit('alert-success', ['Dashboard Deleted', this.dashboard.title + ' has been deleted']);
this.$location.url('/');
});
@@ -176,6 +201,15 @@ export class SettingsCtrl {
onFolderChange(folder) {
this.dashboard.meta.folderId = folder.id;
this.dashboard.meta.folderTitle = folder.title;
this.hasUnsavedFolderChange = true;
}
getFolder() {
return {
id: this.dashboard.meta.folderId,
title: this.dashboard.meta.folderTitle,
url: this.dashboard.meta.folderUrl,
};
}
}

View File

@@ -74,6 +74,7 @@ export class ShareModalCtrl {
$scope.shareUrl = linkSrv.addParamsToUrl(baseUrl, params);
var soloUrl = baseUrl.replace(config.appSubUrl + '/dashboard/', config.appSubUrl + '/dashboard-solo/');
soloUrl = soloUrl.replace(config.appSubUrl + '/d/', config.appSubUrl + '/d-solo/');
delete params.fullscreen;
delete params.edit;
soloUrl = linkSrv.addParamsToUrl(soloUrl, params);
@@ -84,6 +85,7 @@ export class ShareModalCtrl {
config.appSubUrl + '/dashboard-solo/',
config.appSubUrl + '/render/dashboard-solo/'
);
$scope.imageUrl = $scope.imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/');
$scope.imageUrl += '&width=1000';
$scope.imageUrl += '&height=500';
$scope.imageUrl += '&tz=UTC' + encodeURIComponent(moment().format('Z'));

View File

@@ -2,6 +2,7 @@ import angular from 'angular';
import _ from 'lodash';
export class ShareSnapshotCtrl {
/** @ngInject **/
constructor($scope, $rootScope, $location, backendSrv, $timeout, timeSrv) {
$scope.snapshot = {
name: $scope.dashboard.title,

View File

@@ -2,19 +2,26 @@ import React from 'react';
import { shallow } from 'enzyme';
import { DashboardRow } from '../dashgrid/DashboardRow';
import { PanelModel } from '../panel_model';
import config from '../../../core/config';
describe('DashboardRow', () => {
let wrapper, panel, getPanelContainer, dashboardMock;
beforeEach(() => {
dashboardMock = {toggleRow: jest.fn()};
dashboardMock = { toggleRow: jest.fn() };
config.bootData = {
user: {
orgRole: 'Admin',
},
};
getPanelContainer = jest.fn().mockReturnValue({
getDashboard: jest.fn().mockReturnValue(dashboardMock),
getPanelLoader: jest.fn()
getPanelLoader: jest.fn(),
});
panel = new PanelModel({collapsed: false});
panel = new PanelModel({ collapsed: false });
wrapper = shallow(<DashboardRow panel={panel} getPanelContainer={getPanelContainer} />);
});
@@ -30,4 +37,14 @@ describe('DashboardRow', () => {
expect(dashboardMock.toggleRow.mock.calls).toHaveLength(1);
});
it('should have two actions as admin', () => {
expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(2);
});
it('should have zero actions as viewer', () => {
config.bootData.user.orgRole = 'Viewer';
panel = new PanelModel({ collapsed: false });
wrapper = shallow(<DashboardRow panel={panel} getPanelContainer={getPanelContainer} />);
expect(wrapper.find('.dashboard-row__actions .pointer')).toHaveLength(0);
});
});

View File

@@ -19,10 +19,10 @@ describe('DashboardImportCtrl', function() {
};
validationSrv = {
validateNewDashboardOrFolderName: jest.fn().mockReturnValue(Promise.resolve()),
validateNewDashboardName: jest.fn().mockReturnValue(Promise.resolve()),
};
ctx.ctrl = new DashboardImportCtrl(backendSrv, validationSrv, navModelSrv, {}, {}, {});
ctx.ctrl = new DashboardImportCtrl(backendSrv, validationSrv, navModelSrv, {}, {});
});
describe('when uploading json', function() {

View File

@@ -363,6 +363,22 @@ describe('DashboardModel', function() {
expect(dashboard.panels[0].repeat).toBe('server');
expect(dashboard.panels.length).toBe(2);
});
it('minSpan should be twice', function() {
model.rows = [createRow({ height: 8 }, [[6]])];
model.rows[0].panels[0] = { minSpan: 12 };
let dashboard = new DashboardModel(model);
expect(dashboard.panels[0].minSpan).toBe(24);
});
it('should assign id', function() {
model.rows = [createRow({ collapse: true, height: 8 }, [[6], [6]])];
model.rows[0].panels[0] = {};
let dashboard = new DashboardModel(model);
expect(dashboard.panels[0].id).toBe(1);
});
});
});

View File

@@ -49,6 +49,23 @@ describe('DashboardModel', function() {
expect(keys[0]).toBe('annotations');
expect(keys[1]).toBe('autoUpdate');
});
it('should remove add panel panels', () => {
var model = new DashboardModel({});
model.addPanel({
type: 'add-panel',
});
model.addPanel({
type: 'graph',
});
model.addPanel({
type: 'add-panel',
});
var saveModel = model.getSaveModelClone();
var panels = saveModel.panels;
expect(panels.length).toBe(1);
});
});
describe('row and panel manipulation', function() {

View File

@@ -4,6 +4,57 @@ import { expect } from 'test/lib/common';
jest.mock('app/core/services/context_srv', () => ({}));
describe('given dashboard with panel repeat', function() {
var dashboard;
beforeEach(function() {
let dashboardJSON = {
panels: [
{ id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 } },
{ id: 2, repeat: 'apps', repeatDirection: 'h', gridPos: { x: 0, y: 1, h: 2, w: 8 } },
],
templating: {
list: [
{
name: 'apps',
current: {
text: 'se1, se2, se3',
value: ['se1', 'se2', 'se3'],
},
options: [
{ text: 'se1', value: 'se1', selected: true },
{ text: 'se2', value: 'se2', selected: true },
{ text: 'se3', value: 'se3', selected: true },
{ text: 'se4', value: 'se4', selected: false },
],
},
],
},
};
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
});
it('should repeat panels when row is expanding', function() {
expect(dashboard.panels.length).toBe(4);
// toggle row
dashboard.toggleRow(dashboard.panels[0]);
expect(dashboard.panels.length).toBe(1);
// change variable
dashboard.templating.list[0].options[2].selected = false;
dashboard.templating.list[0].current = {
text: 'se1, se2',
value: ['se1', 'se2'],
};
// toggle row back
dashboard.toggleRow(dashboard.panels[0]);
expect(dashboard.panels.length).toBe(3);
});
});
describe('given dashboard with panel repeat in horizontal direction', function() {
var dashboard;
@@ -142,12 +193,9 @@ describe('given dashboard with panel repeat in vertical direction', function() {
beforeEach(function() {
dashboard = new DashboardModel({
panels: [
{
id: 2,
repeat: 'apps',
repeatDirection: 'v',
gridPos: { x: 5, y: 0, h: 2, w: 8 },
},
{ id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 } },
{ id: 2, repeat: 'apps', repeatDirection: 'v', gridPos: { x: 5, y: 1, h: 2, w: 8 } },
{ id: 3, type: 'row', gridPos: { x: 0, y: 3, h: 1, w: 24 } },
],
templating: {
list: [
@@ -171,24 +219,95 @@ describe('given dashboard with panel repeat in vertical direction', function() {
});
it('should place on items on top of each other and keep witdh', function() {
expect(dashboard.panels[0].gridPos).toMatchObject({
x: 5,
y: 0,
h: 2,
w: 8,
});
expect(dashboard.panels[1].gridPos).toMatchObject({
x: 5,
y: 2,
h: 2,
w: 8,
});
expect(dashboard.panels[2].gridPos).toMatchObject({
x: 5,
y: 4,
h: 2,
w: 8,
});
expect(dashboard.panels[0].gridPos).toMatchObject({ x: 0, y: 0, h: 1, w: 24 }); // first row
expect(dashboard.panels[1].gridPos).toMatchObject({ x: 5, y: 1, h: 2, w: 8 });
expect(dashboard.panels[2].gridPos).toMatchObject({ x: 5, y: 3, h: 2, w: 8 });
expect(dashboard.panels[3].gridPos).toMatchObject({ x: 5, y: 5, h: 2, w: 8 });
expect(dashboard.panels[4].gridPos).toMatchObject({ x: 0, y: 7, h: 1, w: 24 }); // last row
});
});
describe('given dashboard with row repeat and panel repeat in horizontal direction', () => {
let dashboard, dashboardJSON;
beforeEach(() => {
dashboardJSON = {
panels: [
{ id: 1, type: 'row', repeat: 'region', gridPos: { x: 0, y: 0, h: 1, w: 24 } },
{ id: 2, type: 'graph', repeat: 'app', gridPos: { x: 0, y: 1, h: 2, w: 6 } },
],
templating: {
list: [
{
name: 'region',
current: {
text: 'reg1, reg2',
value: ['reg1', 'reg2'],
},
options: [{ text: 'reg1', value: 'reg1', selected: true }, { text: 'reg2', value: 'reg2', selected: true }],
},
{
name: 'app',
current: {
text: 'se1, se2, se3, se4, se5, se6',
value: ['se1', 'se2', 'se3', 'se4', 'se5', 'se6'],
},
options: [
{ text: 'se1', value: 'se1', selected: true },
{ text: 'se2', value: 'se2', selected: true },
{ text: 'se3', value: 'se3', selected: true },
{ text: 'se4', value: 'se4', selected: true },
{ text: 'se5', value: 'se5', selected: true },
{ text: 'se6', value: 'se6', selected: true },
],
},
],
},
};
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats(false);
});
it('should panels in self row', () => {
const panel_types = _.map(dashboard.panels, 'type');
expect(panel_types).toEqual([
'row',
'graph',
'graph',
'graph',
'graph',
'graph',
'graph',
'row',
'graph',
'graph',
'graph',
'graph',
'graph',
'graph',
]);
});
it('should be placed in their places', function() {
expect(dashboard.panels[0].gridPos).toMatchObject({ x: 0, y: 0, h: 1, w: 24 }); // 1st row
expect(dashboard.panels[1].gridPos).toMatchObject({ x: 0, y: 1, h: 2, w: 6 });
expect(dashboard.panels[2].gridPos).toMatchObject({ x: 6, y: 1, h: 2, w: 6 });
expect(dashboard.panels[3].gridPos).toMatchObject({ x: 12, y: 1, h: 2, w: 6 });
expect(dashboard.panels[4].gridPos).toMatchObject({ x: 18, y: 1, h: 2, w: 6 });
expect(dashboard.panels[5].gridPos).toMatchObject({ x: 0, y: 3, h: 2, w: 6 }); // next row
expect(dashboard.panels[6].gridPos).toMatchObject({ x: 6, y: 3, h: 2, w: 6 });
expect(dashboard.panels[7].gridPos).toMatchObject({ x: 0, y: 5, h: 1, w: 24 });
expect(dashboard.panels[8].gridPos).toMatchObject({ x: 0, y: 6, h: 2, w: 6 }); // 2nd row
expect(dashboard.panels[9].gridPos).toMatchObject({ x: 6, y: 6, h: 2, w: 6 });
expect(dashboard.panels[10].gridPos).toMatchObject({ x: 12, y: 6, h: 2, w: 6 });
expect(dashboard.panels[11].gridPos).toMatchObject({ x: 18, y: 6, h: 2, w: 6 }); // next row
expect(dashboard.panels[12].gridPos).toMatchObject({ x: 0, y: 8, h: 2, w: 6 });
expect(dashboard.panels[13].gridPos).toMatchObject({ x: 6, y: 8, h: 2, w: 6 });
});
});
@@ -381,4 +500,168 @@ describe('given dashboard with row repeat', function() {
);
expect(panel_ids.length).toEqual(_.uniq(panel_ids).length);
});
it('should place new panels in proper order', function() {
dashboardJSON.panels = [
{ id: 1, type: 'row', gridPos: { x: 0, y: 0, h: 1, w: 24 }, repeat: 'apps' },
{ id: 2, type: 'graph', gridPos: { x: 0, y: 1, h: 3, w: 12 } },
{ id: 3, type: 'graph', gridPos: { x: 6, y: 1, h: 4, w: 12 } },
{ id: 4, type: 'graph', gridPos: { x: 0, y: 5, h: 2, w: 12 } },
];
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
const panel_types = _.map(dashboard.panels, 'type');
expect(panel_types).toEqual(['row', 'graph', 'graph', 'graph', 'row', 'graph', 'graph', 'graph']);
const panel_y_positions = _.map(dashboard.panels, p => p.gridPos.y);
expect(panel_y_positions).toEqual([0, 1, 1, 5, 7, 8, 8, 12]);
});
});
describe('given dashboard with row and panel repeat', () => {
let dashboard, dashboardJSON;
beforeEach(() => {
dashboardJSON = {
panels: [
{
id: 1,
type: 'row',
repeat: 'region',
gridPos: { x: 0, y: 0, h: 1, w: 24 },
},
{ id: 2, type: 'graph', repeat: 'app', gridPos: { x: 0, y: 1, h: 1, w: 6 } },
],
templating: {
list: [
{
name: 'region',
current: {
text: 'reg1, reg2',
value: ['reg1', 'reg2'],
},
options: [
{ text: 'reg1', value: 'reg1', selected: true },
{ text: 'reg2', value: 'reg2', selected: true },
{ text: 'reg3', value: 'reg3', selected: false },
],
},
{
name: 'app',
current: {
text: 'se1, se2',
value: ['se1', 'se2'],
},
options: [
{ text: 'se1', value: 'se1', selected: true },
{ text: 'se2', value: 'se2', selected: true },
{ text: 'se3', value: 'se3', selected: false },
],
},
],
},
};
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
});
it('should repeat row and panels for each row', () => {
const panel_types = _.map(dashboard.panels, 'type');
expect(panel_types).toEqual(['row', 'graph', 'graph', 'row', 'graph', 'graph']);
});
it('should clean up old repeated panels', () => {
dashboardJSON.panels = [
{
id: 1,
type: 'row',
repeat: 'region',
gridPos: { x: 0, y: 0, h: 1, w: 24 },
},
{ id: 2, type: 'graph', repeat: 'app', gridPos: { x: 0, y: 1, h: 1, w: 6 } },
{ id: 3, type: 'graph', repeatPanelId: 2, repeatIteration: 101, gridPos: { x: 7, y: 1, h: 1, w: 6 } },
{
id: 11,
type: 'row',
repeatPanelId: 1,
repeatIteration: 101,
gridPos: { x: 0, y: 2, h: 1, w: 24 },
},
{ id: 12, type: 'graph', repeatPanelId: 2, repeatIteration: 101, gridPos: { x: 0, y: 3, h: 1, w: 6 } },
];
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
const panel_types = _.map(dashboard.panels, 'type');
expect(panel_types).toEqual(['row', 'graph', 'graph', 'row', 'graph', 'graph']);
});
it('should set scopedVars for each row', () => {
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
expect(dashboard.panels[0].scopedVars).toMatchObject({
region: { text: 'reg1', value: 'reg1' },
});
expect(dashboard.panels[3].scopedVars).toMatchObject({
region: { text: 'reg2', value: 'reg2' },
});
});
it('should set panel-repeat variable for each panel', () => {
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
expect(dashboard.panels[1].scopedVars).toMatchObject({
app: { text: 'se1', value: 'se1' },
});
expect(dashboard.panels[2].scopedVars).toMatchObject({
app: { text: 'se2', value: 'se2' },
});
expect(dashboard.panels[4].scopedVars).toMatchObject({
app: { text: 'se1', value: 'se1' },
});
expect(dashboard.panels[5].scopedVars).toMatchObject({
app: { text: 'se2', value: 'se2' },
});
});
it('should set row-repeat variable for each panel', () => {
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
expect(dashboard.panels[1].scopedVars).toMatchObject({
region: { text: 'reg1', value: 'reg1' },
});
expect(dashboard.panels[2].scopedVars).toMatchObject({
region: { text: 'reg1', value: 'reg1' },
});
expect(dashboard.panels[4].scopedVars).toMatchObject({
region: { text: 'reg2', value: 'reg2' },
});
expect(dashboard.panels[5].scopedVars).toMatchObject({
region: { text: 'reg2', value: 'reg2' },
});
});
it('should repeat panels when row is expanding', function() {
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
expect(dashboard.panels.length).toBe(6);
// toggle row
dashboard.toggleRow(dashboard.panels[0]);
dashboard.toggleRow(dashboard.panels[1]);
expect(dashboard.panels.length).toBe(2);
// change variable
dashboard.templating.list[1].current.value = ['se1', 'se2', 'se3'];
// toggle row back
dashboard.toggleRow(dashboard.panels[1]);
expect(dashboard.panels.length).toBe(4);
});
});

View File

@@ -43,12 +43,23 @@ describe('ShareModalCtrl', function() {
});
it('should generate render url', function() {
ctx.$location.$$absUrl = 'http://dashboards.grafana.com/dashboard/db/my-dash';
ctx.$location.$$absUrl = 'http://dashboards.grafana.com/d/abcdefghi/my-dash';
ctx.scope.panel = { id: 22 };
ctx.scope.init();
var base = 'http://dashboards.grafana.com/render/dashboard-solo/db/my-dash';
var base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash';
var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
expect(ctx.scope.imageUrl).to.contain(base + params);
});
it('should generate render url for scripted dashboard', function() {
ctx.$location.$$absUrl = 'http://dashboards.grafana.com/dashboard/script/my-dash.js';
ctx.scope.panel = { id: 22 };
ctx.scope.init();
var base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js';
var params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&tz=UTC';
expect(ctx.scope.imageUrl).to.contain(base + params);
});

View File

@@ -66,6 +66,11 @@ describe('unsavedChangesSrv', function() {
expect(tracker.hasChanges()).to.be(false);
});
it('Should ignore .iteration changes', () => {
dash.iteration = new Date().getTime() + 1;
expect(tracker.hasChanges()).to.be(false);
});
it.skip('Should ignore row collapse change', function() {
dash.rows[0].collapse = true;
expect(tracker.hasChanges()).to.be(false);

View File

@@ -1,5 +1,3 @@
///<reference path="../../../headers/common.d.ts" />
import angular from 'angular';
import _ from 'lodash';

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import moment from 'moment';
import _ from 'lodash';
import coreModule from 'app/core/core_module';

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
const template = `

View File

@@ -35,12 +35,12 @@ export class Tracker {
$window.onbeforeunload = () => {
if (this.ignoreChanges()) {
return '';
return null;
}
if (this.hasChanges()) {
return 'There are unsaved changes to this dashboard';
}
return '';
return null;
};
scope.$on('$locationChangeStart', (event, next) => {
@@ -97,6 +97,9 @@ export class Tracker {
dash.refresh = 0;
dash.schemaVersion = 0;
// ignore iteration property
delete dash.iteration;
// filter row and panels properties that should be ignored
dash.rows = _.filter(dash.rows, function(row) {
if (row.repeatRowId) {

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
var template = `

View File

@@ -1,13 +1,27 @@
import coreModule from 'app/core/core_module';
const hitTypes = {
FOLDER: 'dash-folder',
DASHBOARD: 'dash-db',
};
export class ValidationSrv {
rootName = 'root';
rootName = 'general';
/** @ngInject */
constructor(private $q, private backendSrv) {}
validateNewDashboardOrFolderName(name) {
validateNewDashboardName(folderId, name) {
return this.validate(folderId, name, 'A dashboard in this folder with the same name already exists');
}
validateNewFolderName(name) {
return this.validate(0, name, 'A folder or dashboard in the general folder with the same name already exists');
}
private validate(folderId, name, existingErrorMessage) {
name = (name || '').trim();
const nameLowerCased = name.toLowerCase();
if (name.length === 0) {
return this.$q.reject({
@@ -16,21 +30,35 @@ export class ValidationSrv {
});
}
if (name.toLowerCase() === this.rootName) {
if (folderId === 0 && nameLowerCased === this.rootName) {
return this.$q.reject({
type: 'EXISTING',
message: 'A folder or dashboard with the same name already exists',
message: 'This is a reserved name and cannot be used for a folder.',
});
}
let deferred = this.$q.defer();
this.backendSrv.search({ query: name }).then(res => {
for (let hit of res) {
if (name.toLowerCase() === hit.title.toLowerCase()) {
const promises = [];
promises.push(this.backendSrv.search({ type: hitTypes.FOLDER, folderIds: [folderId], query: name }));
promises.push(this.backendSrv.search({ type: hitTypes.DASHBOARD, folderIds: [folderId], query: name }));
this.$q.all(promises).then(res => {
let hits = [];
if (res.length > 0 && res[0].length > 0) {
hits = res[0];
}
if (res.length > 1 && res[1].length > 0) {
hits = hits.concat(res[1]);
}
for (let hit of hits) {
if (nameLowerCased === hit.title.toLowerCase()) {
deferred.reject({
type: 'EXISTING',
message: 'A folder or dashboard with the same name already exists',
message: existingErrorMessage,
});
break;
}

View File

@@ -150,6 +150,7 @@ export class DashboardViewState {
this.dashboard.setViewMode(ctrl.panel, false, false);
this.$scope.appEvent('panel-fullscreen-exit', { panelId: ctrl.panel.id });
this.$scope.appEvent('dash-scroll', { restore: true });
if (!render) {
return false;
@@ -177,6 +178,7 @@ export class DashboardViewState {
this.dashboard.setViewMode(ctrl.panel, true, ctrl.editMode);
this.$scope.appEvent('panel-fullscreen-enter', { panelId: ctrl.panel.id });
this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 });
}
registerPanel(panelScope) {

View File

@@ -17,7 +17,7 @@
<div class="grafana-info-box">
<h5>What are Dashboard Links?</h5>
<p>
Dashboad Links allow you to place links to other dashboards and web sites directly in below the dashboard header.
Dashboard Links allow you to place links to other dashboards and web sites directly in below the dashboard header.
</p>
</div>
</div>

View File

@@ -30,7 +30,9 @@ function dashLink($compile, linkSrv) {
if (link.asDropdown) {
template +=
'<ul class="dropdown-menu" role="menu">' +
'<li ng-repeat="dash in link.searchHits"><a href="{{dash.url}}">{{dash.title}}</a></li>' +
'<li ng-repeat="dash in link.searchHits">' +
'<a href="{{dash.url}}" target="{{dash.target}}">{{dash.title}}</a>' +
'</li>' +
'</ul>';
}
@@ -84,6 +86,7 @@ export class DashLinksContainerCtrl {
tags: linkDef.tags,
keepTime: linkDef.keepTime,
includeVars: linkDef.includeVars,
target: linkDef.targetBlank ? '_blank' : '_self',
icon: 'fa fa-bars',
asDropdown: true,
},
@@ -128,6 +131,7 @@ export class DashLinksContainerCtrl {
memo.push({
title: dash.title,
url: 'dashboard/' + dash.uri,
target: link.target,
icon: 'fa fa-th-large',
keepTime: link.keepTime,
includeVars: link.includeVars,

View File

@@ -1,44 +1,26 @@
<div class="container">
<page-header model="navModel"></page-header>
<div class="signup-page-background">
</div>
<div class="page-container page-body">
<div class="login-content">
<div class="login-branding">
<img src="img/logo_transparent_200x75.png">
</div>
<div class="invite-box">
<h3>
<i class="fa fa-users"></i>&nbsp;
Change active organization
</h3>
<div class="signup">
<div class="login-form">
<div class="modal-tagline">
You have been added to another Organization <br>
due to an open invitation!
<br><br>
You have been added to another Organization due to an open invitation!
Please select which organization you want to <br>
use right now (you can change this later at any time).
</div>
<div style="display: inline-block; width: 400px; margin: 30px 0">
<table class="filter-table">
<tr ng-repeat="org in orgs">
<td class="nobg max-width-btns">
<a ng-click="setUsingOrg(org)" class="btn btn-inverse">
{{org.name}} ({{org.role}})
</a>
</td>
</tr>
</table>
<div ng-repeat="org in orgs">
<a ng-click="setUsingOrg(org)" class="btn btn-success">
{{org.name}} ({{org.role}})
</a>
</div>
</div>
</div>
</div>
</div>

View File

@@ -26,11 +26,14 @@
<div class="gf-form-group">
<h3 class="page-heading">Team Members</h3>
<form name="ctrl.addMemberForm" class="gf-form-group">
<form name="ctrl.addMemberForm" class="gf-form-group">
<div class="gf-form">
<span class="gf-form-label width-10">Add member</span>
<user-picker user-picked="ctrl.userPicked($user)"></user-picker>
<!--
Old picker
<user-picker user-picked="ctrl.userPicked($user)"></user-picker>
-->
<select-user-picker class="width-7" handlePicked="ctrl.userPicked" backendSrv="ctrl.backendSrv"></select-user-picker>
</div>
</form>
@@ -61,3 +64,4 @@
This team has no members yet.
</em>
</div>

2
public/app/features/org/partials/teams.html Normal file → Executable file
View File

@@ -8,7 +8,7 @@
</label>
<div class="page-action-bar__spacer"></div>
<a class="btn btn-success" href="/org/teams/new">
<a class="btn btn-success" href="org/teams/new">
<i class="fa fa-plus"></i>
Add Team
</a>

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import config from 'app/core/config';
import coreModule from 'app/core/core_module';

View File

@@ -6,6 +6,14 @@ export class SelectOrgCtrl {
constructor($scope, backendSrv, contextSrv) {
contextSrv.sidemenu = false;
$scope.navModel = {
main: {
icon: 'gicon gicon-branding',
subTitle: 'Preferences',
text: 'Select active organization',
},
};
$scope.init = function() {
$scope.getUserOrgs();
};

View File

@@ -8,6 +8,8 @@ export default class TeamDetailsCtrl {
/** @ngInject **/
constructor(private $scope, private backendSrv, private $routeParams, navModelSrv) {
this.navModel = navModelSrv.getNav('cfg', 'teams', 0);
this.userPicked = this.userPicked.bind(this);
this.get = this.get.bind(this);
this.get();
}
@@ -35,7 +37,7 @@ export default class TeamDetailsCtrl {
}
removeMemberConfirmed(teamMember: TeamMember) {
this.backendSrv.delete(`/api/teams/${this.$routeParams.id}/members/${teamMember.userId}`).then(this.get.bind(this));
this.backendSrv.delete(`/api/teams/${this.$routeParams.id}/members/${teamMember.userId}`).then(this.get);
}
update() {

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';

View File

@@ -241,31 +241,10 @@ export class PanelCtrl {
});
}
removePanel(ask: boolean) {
// confirm deletion
if (ask !== false) {
var text2, confirmText;
if (this.panel.alert) {
text2 = 'Panel includes an alert rule, removing panel will also remove alert rule';
confirmText = 'YES';
}
appEvents.emit('confirm-modal', {
title: 'Remove Panel',
text: 'Are you sure you want to remove this panel?',
text2: text2,
icon: 'fa-trash',
confirmText: confirmText,
yesText: 'Remove',
onConfirm: () => {
this.removePanel(false);
},
});
return;
}
this.dashboard.removePanel(this.panel);
removePanel() {
this.publishAppEvent('panel-remove', {
panelId: this.panel.id,
});
}
editPanelJson() {

View File

@@ -100,7 +100,9 @@ module.directive('grafanaPanel', function($rootScope, $document, $timeout) {
// update scrollbar after mounting
ctrl.events.on('component-did-mount', () => {
if (ctrl.__proto__.constructor.scrollable) {
panelScrollbar = new PerfectScrollbar(panelContent[0]);
panelScrollbar = new PerfectScrollbar(panelContent[0], {
wheelPropagation: true,
});
}
});

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import angular from 'angular';
var directiveModule = angular.module('grafana.directives');

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import $ from 'jquery';
import { coreModule } from 'app/core/core';

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
export class QueryCtrl {

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import angular from 'angular';
var module = angular.module('grafana.directives');

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import appEvents from 'app/core/app_events';
import { coreModule, JsonExplorer } from 'app/core/core';

View File

@@ -1,19 +1,33 @@
import angular from 'angular';
import locationUtil from 'app/core/utils/location_util';
import appEvents from 'app/core/app_events';
export class SoloPanelCtrl {
/** @ngInject */
constructor($scope, $routeParams, $location, dashboardLoaderSrv, contextSrv) {
constructor($scope, $routeParams, $location, dashboardLoaderSrv, contextSrv, backendSrv) {
var panelId;
$scope.init = function() {
contextSrv.sidemenu = false;
appEvents.emit('toggle-sidemenu-hidden');
var params = $location.search();
panelId = parseInt(params.panelId);
$scope.onAppEvent('dashboard-initialized', $scope.initPanelScope);
dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug).then(function(result) {
// if no uid, redirect to new route based on slug
if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
backendSrv.getDashboardBySlug($routeParams.slug).then(res => {
if (res) {
const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
$location.path(url).replace();
}
});
return;
}
dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug, $routeParams.uid).then(function(result) {
result.meta.soloMode = true;
$scope.initDashboard(result, $scope);
});

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import coreModule from '../../core/core_module';
export class PlaylistSearchCtrl {
@@ -14,6 +12,7 @@ export class PlaylistSearchCtrl {
$timeout(() => {
this.query.query = '';
this.query.type = 'dash-db';
this.searchDashboards();
}, 100);
}

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import coreModule from '../../core/core_module';
import kbn from 'app/core/utils/kbn';
import appEvents from 'app/core/app_events';

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import coreModule from '../../core/core_module';

View File

@@ -3,6 +3,7 @@ import './plugin_page_ctrl';
import './plugin_list_ctrl';
import './import_list/import_list';
import './ds_edit_ctrl';
import './ds_dashboards_ctrl';
import './ds_list_ctrl';
import './datasource_srv';
import './plugin_component';

View File

@@ -0,0 +1,45 @@
import { toJS } from 'mobx';
import { coreModule } from 'app/core/core';
import { store } from 'app/stores/store';
export class DataSourceDashboardsCtrl {
datasourceMeta: any;
navModel: any;
current: any;
/** @ngInject */
constructor(private backendSrv, private $routeParams) {
if (store.nav.main === null) {
store.nav.load('cfg', 'datasources');
}
this.navModel = toJS(store.nav);
if (this.$routeParams.id) {
this.getDatasourceById(this.$routeParams.id);
}
}
getDatasourceById(id) {
this.backendSrv
.get('/api/datasources/' + id)
.then(ds => {
this.current = ds;
})
.then(this.getPluginInfo.bind(this));
}
updateNav() {
store.nav.initDatasourceEditNav(this.current, this.datasourceMeta, 'datasource-dashboards');
this.navModel = toJS(store.nav);
}
getPluginInfo() {
return this.backendSrv.get('/api/plugins/' + this.current.type + '/settings').then(pluginInfo => {
this.datasourceMeta = pluginInfo;
this.updateNav();
});
}
}
coreModule.controller('DataSourceDashboardsCtrl', DataSourceDashboardsCtrl);

View File

@@ -1,7 +1,8 @@
import _ from 'lodash';
import { toJS } from 'mobx';
import config from 'app/core/config';
import { coreModule, appEvents } from 'app/core/core';
import { store } from 'app/stores/store';
var datasourceTypes = [];
@@ -23,24 +24,18 @@ export class DataSourceEditCtrl {
types: any;
testing: any;
datasourceMeta: any;
tabIndex: number;
hasDashboards: boolean;
editForm: any;
gettingStarted: boolean;
navModel: any;
/** @ngInject */
constructor(
private $q,
private backendSrv,
private $routeParams,
private $location,
private datasourceSrv,
navModelSrv
) {
this.navModel = navModelSrv.getNav('cfg', 'datasources', 0);
constructor(private $q, private backendSrv, private $routeParams, private $location, private datasourceSrv) {
if (store.nav.main === null) {
store.nav.load('cfg', 'datasources');
}
this.navModel = toJS(store.nav);
this.datasources = [];
this.tabIndex = 0;
this.loadDatasourceTypes().then(() => {
if (this.$routeParams.id) {
@@ -55,8 +50,6 @@ export class DataSourceEditCtrl {
this.isNew = true;
this.current = _.cloneDeep(defaults);
this.navModel.breadcrumbs.push({ text: 'New' });
// We are coming from getting started
if (this.$location.search().gettingstarted) {
this.gettingStarted = true;
@@ -82,12 +75,6 @@ export class DataSourceEditCtrl {
this.backendSrv.get('/api/datasources/' + id).then(ds => {
this.isNew = false;
this.current = ds;
this.navModel.node = {
text: ds.name,
icon: 'icon-gf icon-gf-fw icon-gf-datasources',
id: 'ds-new',
};
this.navModel.breadcrumbs.push(this.navModel.node);
if (datasourceCreated) {
datasourceCreated = false;
@@ -112,11 +99,15 @@ export class DataSourceEditCtrl {
this.typeChanged();
}
updateNav() {
store.nav.initDatasourceEditNav(this.current, this.datasourceMeta, 'datasource-settings');
this.navModel = toJS(store.nav);
}
typeChanged() {
this.hasDashboards = false;
return this.backendSrv.get('/api/plugins/' + this.current.type + '/settings').then(pluginInfo => {
this.datasourceMeta = pluginInfo;
this.hasDashboards = _.find(pluginInfo.includes, { type: 'dashboard' }) !== undefined;
this.updateNav();
});
}
@@ -171,6 +162,7 @@ export class DataSourceEditCtrl {
if (this.current.id) {
return this.backendSrv.put('/api/datasources/' + this.current.id, this.current).then(result => {
this.current = result.datasource;
this.updateNav();
this.updateFrontendSettings().then(() => {
this.testDatasource();
});

View File

@@ -6,12 +6,10 @@
<i class="icon-gf icon-gf-dashboard"></i>
</td>
<td>
<a href="dashboard/{{dash.importedUri}}" ng-show="dash.imported">
<a href="{{dash.importedUrl}}" ng-show="dash.imported">
{{dash.title}}
</a>
<span ng-show="!dash.imported">
{{dash.title}}
</span>
<span ng-show="!dash.imported">{{dash.title}}</span>
</td>
<td style="text-align: right">
<button class="btn btn-secondary btn-small" ng-click="ctrl.import(dash, false)" ng-show="!dash.imported">

View File

@@ -1,5 +1,3 @@
///<reference path="../../../headers/common.d.ts" />
import _ from 'lodash';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';

View File

@@ -0,0 +1,7 @@
<page-header model="ctrl.navModel"></page-header>
<div class="page-container page-body" ng-if="ctrl.datasourceMeta">
<dashboard-import-list plugin="ctrl.datasourceMeta" datasource="ctrl.current"></dashboard-import-list>
</div>

View File

@@ -8,9 +8,6 @@
</div>
</div>
<h3 class="page-sub-heading" ng-hide="ctrl.isNew">Edit Data Source</h3>
<h3 class="page-sub-heading" ng-show="ctrl.isNew">New Data Source</h3>
<form name="ctrl.editForm" ng-if="ctrl.current">
<div class="gf-form-group">
<div class="gf-form-inline">

View File

@@ -52,7 +52,7 @@
<empty-list-cta model="{
title: 'There are no data sources defined yet',
buttonIcon: 'gicon gicon-add-datasources',
buttonLink: '/datasources/new',
buttonLink: 'datasources/new',
buttonTitle: 'Add data source',
proTip: 'You can also define data sources through configuration files.',
proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list',

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import angular from 'angular';
import _ from 'lodash';
import Remarkable from 'remarkable';
@@ -34,8 +32,8 @@ export class PluginEditCtrl {
img: model.info.logos.large,
subTitle: model.info.author.name,
url: '',
text: '',
breadcrumbs: [{ title: 'Plugins', url: '/plugins' }, { title: model.name }],
text: model.name,
breadcrumbs: [{ title: 'Plugins', url: 'plugins' }],
children: [
{
icon: 'fa fa-fw fa-file-text-o',

View File

@@ -40,8 +40,8 @@ export class AppPageCtrl {
img: app.info.logos.large,
subTitle: app.name,
url: '',
text: '',
breadcrumbs: [{ title: app.name, url: pluginNav.main.url }, { title: this.page.name }],
text: this.page.name,
breadcrumbs: [{ title: app.name, url: pluginNav.main.url }],
},
};
}

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
export class DashboardRowCtrl {

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import angular from 'angular';
import _ from 'lodash';

View File

@@ -14,14 +14,6 @@
</div>
</div>
<div class="tab-pane style-guide-icon-list">
<div class="row">
<div ng-repeat="icon in ctrl.icons" class="col-md-2 col-sm-3 col-xs-4">
<i class="icon-gf icon-gf-{{icon}}" bs-tooltip="'icon-gf icon-gf-{{icon}}'"></i>
</div>
</div>
</div>
<h3 class="page-heading">Forms</h3>
<div class="gf-form-inline">

View File

@@ -1,51 +1,17 @@
import coreModule from 'app/core/core_module';
import config from 'app/core/config';
import _ from 'lodash';
class StyleGuideCtrl {
colors: any = [];
theme: string;
buttonNames = ['primary', 'secondary', 'inverse', 'success', 'warning', 'danger'];
buttonSizes = ['btn-small', '', 'btn-large'];
buttonVariants = ['-'];
icons: any = [];
page: any;
pages = ['colors', 'buttons', 'icons', 'plugins'];
navModel: any;
/** @ngInject **/
constructor(private $http, private $routeParams, private backendSrv, navModelSrv) {
constructor(private $routeParams, private backendSrv, navModelSrv) {
this.navModel = navModelSrv.getNav('cfg', 'admin', 'styleguide', 1);
this.theme = config.bootData.user.lightTheme ? 'light' : 'dark';
this.page = {};
if ($routeParams.page) {
this.page[$routeParams.page] = 1;
} else {
this.page.colors = true;
}
if (this.page.colors) {
this.loadColors();
}
if (this.page.icons) {
this.loadIcons();
}
}
loadColors() {
this.$http.get('public/build/styleguide.json').then(res => {
this.colors = _.map(res.data[this.theme], (value, key) => {
return { name: key, value: value };
});
});
}
loadIcons() {
this.$http.get('public/sass/icons.json').then(res => {
this.icons = res.data;
});
}
switchTheme() {

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import { Variable, assignModelProperties, variableTypes } from './variable';
export class ConstantVariable implements Variable {

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import { Variable, assignModelProperties, variableTypes } from './variable';

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import kbn from 'app/core/utils/kbn';
import { Variable, containsVariable, assignModelProperties, variableTypes } from './variable';

View File

@@ -1,6 +1,7 @@
import _ from 'lodash';
import coreModule from 'app/core/core_module';
import { variableTypes } from './variable';
import appEvents from 'app/core/app_events';
export class VariableEditorCtrl {
/** @ngInject **/
@@ -56,16 +57,13 @@ export class VariableEditorCtrl {
}
if (!$scope.current.name.match(/^\w+$/)) {
$scope.appEvent('alert-warning', [
'Validation',
'Only word and digit characters are allowed in variable names',
]);
appEvents.emit('alert-warning', ['Validation', 'Only word and digit characters are allowed in variable names']);
return false;
}
var sameName = _.find($scope.variables, { name: $scope.current.name });
if (sameName && sameName !== $scope.current) {
$scope.appEvent('alert-warning', ['Validation', 'Variable with the same name already exists']);
appEvents.emit('alert-warning', ['Validation', 'Variable with the same name already exists']);
return false;
}
@@ -73,7 +71,7 @@ export class VariableEditorCtrl {
$scope.current.type === 'query' &&
$scope.current.query.match(new RegExp('\\$' + $scope.current.name + '(/| |$)'))
) {
$scope.appEvent('alert-warning', [
appEvents.emit('alert-warning', [
'Validation',
'Query cannot contain a reference to itself. Variable: $' + $scope.current.name,
]);
@@ -96,11 +94,11 @@ export class VariableEditorCtrl {
};
$scope.runQuery = function() {
return variableSrv.updateOptions($scope.current).then(null, function(err) {
return variableSrv.updateOptions($scope.current).catch(err => {
if (err.data && err.data.message) {
err.message = err.data.message;
}
$scope.appEvent('alert-error', ['Templating', 'Template variables could not be initialized: ' + err.message]);
appEvents.emit('alert-error', ['Templating', 'Template variables could not be initialized: ' + err.message]);
});
};

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import kbn from 'app/core/utils/kbn';
import { Variable, assignModelProperties, variableTypes } from './variable';

View File

@@ -16,12 +16,12 @@
Add variable
</a>
<div class="grafana-info-box">
<h5>What does variables do?</h5>
<p>Variables enables more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor names
<h5>What do variables do?</h5>
<p>Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server or sensor names
in your metric queries you can use variables in their place. Variables are shown as dropdown select boxes at the top of
the dashboard. These dropdowns make it easy to change the data being displayed in your dashboard.
Checkout the
Check out the
<a class="external-link" href="http://docs.grafana.org/reference/templating/" target="_blank">
Templating documentation
</a> for more information.
@@ -93,7 +93,7 @@
</div>
<div class="gf-form" ng-show="ctrl.form.name.$error.pattern">
<span class="gf-form-label gf-form-label--error">Template names cannot begin with '__' that's reserved for Grafanas global variables</span>
<span class="gf-form-label gf-form-label--error">Template names cannot begin with '__', that's reserved for Grafana's global variables</span>
</div>
<div class="gf-form-inline">

View File

@@ -0,0 +1,40 @@
import { VariableEditorCtrl } from '../editor_ctrl';
let mockEmit;
jest.mock('app/core/app_events', () => {
mockEmit = jest.fn();
return {
emit: mockEmit,
};
});
describe('VariableEditorCtrl', () => {
let scope = {
runQuery: () => {
return Promise.resolve({});
},
};
describe('When running a variable query and the data source returns an error', () => {
beforeEach(() => {
const variableSrv = {
updateOptions: () => {
return Promise.reject({
data: { message: 'error' },
});
},
};
return new VariableEditorCtrl(scope, {}, variableSrv, {});
});
it('should emit an error', () => {
return scope.runQuery().then(res => {
expect(mockEmit).toBeCalled();
expect(mockEmit.mock.calls[0][0]).toBe('alert-error');
expect(mockEmit.mock.calls[0][1][0]).toBe('Templating');
expect(mockEmit.mock.calls[0][1][1]).toBe('Template variables could not be initialized: error');
});
});
});
});

View File

@@ -31,12 +31,40 @@ describe('templateSrv', function() {
expect(target).toBe('this.mupp.filters');
});
it('should replace ${test} with scoped value', function() {
var target = _templateSrv.replace('this.${test}.filters', {
test: { value: 'mupp', text: 'asd' },
});
expect(target).toBe('this.mupp.filters');
});
it('should replace ${test:glob} with scoped value', function() {
var target = _templateSrv.replace('this.${test:glob}.filters', {
test: { value: 'mupp', text: 'asd' },
});
expect(target).toBe('this.mupp.filters');
});
it('should replace $test with scoped text', function() {
var target = _templateSrv.replaceWithText('this.$test.filters', {
test: { value: 'mupp', text: 'asd' },
});
expect(target).toBe('this.asd.filters');
});
it('should replace ${test} with scoped text', function() {
var target = _templateSrv.replaceWithText('this.${test}.filters', {
test: { value: 'mupp', text: 'asd' },
});
expect(target).toBe('this.asd.filters');
});
it('should replace ${test:glob} with scoped text', function() {
var target = _templateSrv.replaceWithText('this.${test:glob}.filters', {
test: { value: 'mupp', text: 'asd' },
});
expect(target).toBe('this.asd.filters');
});
});
describe('getAdhocFilters', function() {
@@ -84,13 +112,28 @@ describe('templateSrv', function() {
expect(target).toBe('this.{value1,value2}.filters');
});
it('should replace ${test} with globbed value', function() {
var target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
expect(target).toBe('this.{value1,value2}.filters');
});
it('should replace ${test:glob} with globbed value', function() {
var target = _templateSrv.replace('this.${test:glob}.filters', {});
expect(target).toBe('this.{value1,value2}.filters');
});
it('should replace $test with piped value', function() {
var target = _templateSrv.replace('this=$test', {}, 'pipe');
expect(target).toBe('this=value1|value2');
});
it('should replace $test with piped value', function() {
var target = _templateSrv.replace('this=$test', {}, 'pipe');
it('should replace ${test} with piped value', function() {
var target = _templateSrv.replace('this=${test}', {}, 'pipe');
expect(target).toBe('this=value1|value2');
});
it('should replace ${test:pipe} with piped value', function() {
var target = _templateSrv.replace('this=${test:pipe}', {});
expect(target).toBe('this=value1|value2');
});
});
@@ -111,6 +154,16 @@ describe('templateSrv', function() {
var target = _templateSrv.replace('this.$test.filters', {}, 'glob');
expect(target).toBe('this.{value1,value2}.filters');
});
it('should replace ${test} with formatted all value', function() {
var target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
expect(target).toBe('this.{value1,value2}.filters');
});
it('should replace ${test:glob} with formatted all value', function() {
var target = _templateSrv.replace('this.${test:glob}.filters', {});
expect(target).toBe('this.{value1,value2}.filters');
});
});
describe('variable with all option and custom value', function() {
@@ -131,6 +184,16 @@ describe('templateSrv', function() {
expect(target).toBe('this.*.filters');
});
it('should replace ${test} with formatted all value', function() {
var target = _templateSrv.replace('this.${test}.filters', {}, 'glob');
expect(target).toBe('this.*.filters');
});
it('should replace ${test:glob} with formatted all value', function() {
var target = _templateSrv.replace('this.${test:glob}.filters', {});
expect(target).toBe('this.*.filters');
});
it('should not escape custom all value', function() {
var target = _templateSrv.replace('this.$test', {}, 'regex');
expect(target).toBe('this.*');
@@ -143,6 +206,18 @@ describe('templateSrv', function() {
var target = _templateSrv.replace('this:$test', {}, 'lucene');
expect(target).toBe('this:value\\/4');
});
it('should properly escape ${test} with lucene escape sequences', function() {
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]);
var target = _templateSrv.replace('this:${test}', {}, 'lucene');
expect(target).toBe('this:value\\/4');
});
it('should properly escape ${test:lucene} with lucene escape sequences', function() {
initTemplateSrv([{ type: 'query', name: 'test', current: { value: 'value/4' } }]);
var target = _templateSrv.replace('this:${test:lucene}', {});
expect(target).toBe('this:value\\/4');
});
});
describe('format variable to string values', function() {
@@ -185,6 +260,11 @@ describe('templateSrv', function() {
expect(result).toBe('test');
});
it('multi value and csv format should render csv string', function() {
var result = _templateSrv.formatValue(['test', 'test2'], 'csv');
expect(result).toBe('test,test2');
});
it('slash should be properly escaped in regex format', function() {
var result = _templateSrv.formatValue('Gi3/14', 'regex');
expect(result).toBe('Gi3\\/14');

Some files were not shown because too many files have changed in this diff Show More