Merge branch 'master' into 10427_addpanel_filter

This commit is contained in:
Marcus Efraimsson
2018-04-18 11:36:13 +02:00
124 changed files with 2317 additions and 1355 deletions

View File

@@ -39,7 +39,7 @@ class AddPermissions extends Component<IProps, any> {
permissions.newItem.setUser(null, null);
return;
}
return permissions.newItem.setUser(user.id, user.login);
return permissions.newItem.setUser(user.id, user.login, user.avatarUrl);
}
teamPicked(team: Team) {
@@ -48,7 +48,7 @@ class AddPermissions extends Component<IProps, any> {
permissions.newItem.setTeam(null, null);
return;
}
return permissions.newItem.setTeam(team.id, team.name);
return permissions.newItem.setTeam(team.id, team.name, team.avatarUrl);
}
permissionPicked(permission: OptionWithDescription) {

View File

@@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React, { Component } from 'react';
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
@@ -12,9 +12,12 @@ export default class DisabledPermissionListItem extends Component<IProps, any> {
return (
<tr className="gf-form-disabled">
<td style={{ width: '100%' }}>
<i className={`fa--permissions-list ${item.icon}`} />
<span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
<td style={{ width: '1%' }}>
<i style={{ width: '25px', height: '25px' }} className="gicon gicon-shield" />
</td>
<td style={{ width: '90%' }}>
{item.name}
<span className="filter-table__weak-italic"> (Role)</span>
</td>
<td />
<td className="query-keyword">Can</td>

View File

@@ -15,9 +15,8 @@ export interface DashboardAcl {
permissionName?: string;
role?: string;
icon?: string;
nameHtml?: string;
name?: string;
inherited?: boolean;
sortName?: string;
sortRank?: number;
}

View File

@@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React, { Component } from 'react';
import PermissionsListItem from './PermissionsListItem';
import DisabledPermissionsListItem from './DisabledPermissionsListItem';
import { observer } from 'mobx-react';
@@ -23,7 +23,7 @@ class PermissionsList extends Component<IProps, any> {
<DisabledPermissionsListItem
key={0}
item={{
nameHtml: 'Everyone with <span class="query-keyword">Admin</span> Role',
name: 'Admin',
permission: 4,
icon: 'fa fa-fw fa-street-view',
}}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React from 'react';
import { observer } from 'mobx-react';
import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
@@ -7,6 +7,30 @@ const setClassNameHelper = inherited => {
return inherited ? 'gf-form-disabled' : '';
};
function ItemAvatar({ item }) {
if (item.userAvatarUrl) {
return <img className="filter-table__avatar" src={item.userAvatarUrl} />;
}
if (item.teamAvatarUrl) {
return <img className="filter-table__avatar" src={item.teamAvatarUrl} />;
}
if (item.role === 'Editor') {
return <i style={{ width: '25px', height: '25px' }} className="gicon gicon-editor" />;
}
return <i style={{ width: '25px', height: '25px' }} className="gicon gicon-viewer" />;
}
function ItemDescription({ item }) {
if (item.userId) {
return <span className="filter-table__weak-italic">(User)</span>;
}
if (item.teamId) {
return <span className="filter-table__weak-italic">(Team)</span>;
}
return <span className="filter-table__weak-italic">(Role)</span>;
}
export default observer(({ item, removeItem, permissionChanged, itemIndex, folderInfo }) => {
const handleRemoveItem = evt => {
evt.preventDefault();
@@ -21,9 +45,11 @@ export default observer(({ item, removeItem, permissionChanged, itemIndex, folde
return (
<tr className={setClassNameHelper(item.inherited)}>
<td style={{ width: '100%' }}>
<i className={`fa--permissions-list ${item.icon}`} />
<span dangerouslySetInnerHTML={{ __html: item.nameHtml }} />
<td style={{ width: '1%' }}>
<ItemAvatar item={item} />
</td>
<td style={{ width: '90%' }}>
{item.name} <ItemDescription item={item} />
</td>
<td>
{item.inherited &&

View File

@@ -37,7 +37,7 @@
<i class="fa fa-fw fa-sign-in"></i>
</span>
</a>
<a href="{{ctrl.loginUrl}}">
<a href="{{ctrl.loginUrl}}" target="_self">
<ul class="dropdown-menu dropdown-menu--sidemenu" role="menu">
<li class="side-menu-header">
<span class="sidemenu-item-text">Sign In</span>

View File

@@ -6,6 +6,7 @@ import './dashnav/dashnav';
import './submenu/submenu';
import './save_as_modal';
import './save_modal';
import './save_provisioned_modal';
import './shareModalCtrl';
import './share_snapshot_ctrl';
import './dashboard_srv';

View File

@@ -0,0 +1,186 @@
import angular from 'angular';
import _ from 'lodash';
import { DashboardModel } from './dashboard_model';
export class ChangeTracker {
current: any;
originalPath: any;
scope: any;
original: any;
next: any;
$window: any;
/** @ngInject */
constructor(
dashboard,
scope,
originalCopyDelay,
private $location,
$window,
private $timeout,
private contextSrv,
private $rootScope
) {
this.$location = $location;
this.$window = $window;
this.current = dashboard;
this.originalPath = $location.path();
this.scope = scope;
// register events
scope.onAppEvent('dashboard-saved', () => {
this.original = this.current.getSaveModelClone();
this.originalPath = $location.path();
});
$window.onbeforeunload = () => {
if (this.ignoreChanges()) {
return undefined;
}
if (this.hasChanges()) {
return 'There are unsaved changes to this dashboard';
}
return undefined;
};
scope.$on('$locationChangeStart', (event, next) => {
// check if we should look for changes
if (this.originalPath === $location.path()) {
return true;
}
if (this.ignoreChanges()) {
return true;
}
if (this.hasChanges()) {
event.preventDefault();
this.next = next;
this.$timeout(() => {
this.open_modal();
});
}
return false;
});
if (originalCopyDelay) {
this.$timeout(() => {
// wait for different services to patch the dashboard (missing properties)
this.original = dashboard.getSaveModelClone();
}, originalCopyDelay);
} else {
this.original = dashboard.getSaveModelClone();
}
}
// for some dashboards and users
// changes should be ignored
ignoreChanges() {
if (!this.original) {
return true;
}
if (!this.contextSrv.isEditor) {
return true;
}
if (!this.current || !this.current.meta) {
return true;
}
var meta = this.current.meta;
return !meta.canSave || meta.fromScript || meta.fromFile;
}
// remove stuff that should not count in diff
cleanDashboardFromIgnoredChanges(dashData) {
// need to new up the domain model class to get access to expand / collapse row logic
let model = new DashboardModel(dashData);
// Expand all rows before making comparison. This is required because row expand / collapse
// change order of panel array and panel positions.
model.expandRows();
let dash = model.getSaveModelClone();
// ignore time and refresh
dash.time = 0;
dash.refresh = 0;
dash.schemaVersion = 0;
// ignore iteration property
delete dash.iteration;
dash.panels = _.filter(dash.panels, panel => {
if (panel.repeatPanelId) {
return false;
}
// remove scopedVars
panel.scopedVars = null;
// ignore panel legend sort
if (panel.legend) {
delete panel.legend.sort;
delete panel.legend.sortDesc;
}
return true;
});
// ignore template variable values
_.each(dash.templating.list, function(value) {
value.current = null;
value.options = null;
value.filters = null;
});
return dash;
}
hasChanges() {
let current = this.cleanDashboardFromIgnoredChanges(this.current.getSaveModelClone());
let original = this.cleanDashboardFromIgnoredChanges(this.original);
var currentTimepicker = _.find(current.nav, { type: 'timepicker' });
var originalTimepicker = _.find(original.nav, { type: 'timepicker' });
if (currentTimepicker && originalTimepicker) {
currentTimepicker.now = originalTimepicker.now;
}
var currentJson = angular.toJson(current, true);
var originalJson = angular.toJson(original, true);
return currentJson !== originalJson;
}
discardChanges() {
this.original = null;
this.gotoNext();
}
open_modal() {
this.$rootScope.appEvent('show-modal', {
templateHtml: '<unsaved-changes-modal dismiss="dismiss()"></unsaved-changes-modal>',
modalClass: 'modal--narrow confirm-modal',
});
}
saveChanges() {
var self = this;
var cancel = this.$rootScope.$on('dashboard-saved', () => {
cancel();
this.$timeout(() => {
self.gotoNext();
});
});
this.$rootScope.appEvent('save-dashboard');
}
gotoNext() {
var baseLen = this.$location.absUrl().length - this.$location.url().length;
var nextUrl = this.next.substring(baseLen);
this.$location.url(nextUrl);
}
}

View File

@@ -649,6 +649,7 @@ export class DashboardModel {
for (let panel of row.panels) {
// make sure y is adjusted (in case row moved while collapsed)
// console.log('yDiff', yDiff);
panel.gridPos.y -= yDiff;
// insert after row
this.panels.splice(insertPos, 0, new PanelModel(panel));
@@ -657,7 +658,7 @@ export class DashboardModel {
yMax = Math.max(yMax, panel.gridPos.y + panel.gridPos.h);
}
const pushDownAmount = yMax - row.gridPos.y;
const pushDownAmount = yMax - row.gridPos.y - 1;
// push panels below down
for (let panelIndex = insertPos; panelIndex < this.panels.length; panelIndex++) {

View File

@@ -105,6 +105,10 @@ export class DashboardSrv {
this.setCurrent(this.create(clone, this.dash.meta));
}
if (this.dash.meta.provisioned) {
return this.showDashboardProvisionedModal();
}
if (!this.dash.meta.canSave && options.makeEditable !== true) {
return Promise.resolve();
}
@@ -120,6 +124,12 @@ export class DashboardSrv {
return this.save(this.dash.getSaveModelClone(), options);
}
showDashboardProvisionedModal() {
this.$rootScope.appEvent('show-modal', {
templateHtml: '<save-provisioned-dashboard-modal dismiss="dismiss()"></save-provisioned-dashboard-modal>',
});
}
showSaveAsModal() {
this.$rootScope.appEvent('show-modal', {
templateHtml: '<save-dashboard-as-modal dismiss="dismiss()"></save-dashboard-as-modal>',

View File

@@ -19,9 +19,12 @@ export class FolderPickerCtrl {
newFolderNameTouched: boolean;
hasValidationError: boolean;
validationError: any;
isEditor: boolean;
/** @ngInject */
constructor(private backendSrv, private validationSrv) {
constructor(private backendSrv, private validationSrv, private contextSrv) {
this.isEditor = this.contextSrv.isEditor;
if (!this.labelClass) {
this.labelClass = 'width-7';
}
@@ -38,19 +41,20 @@ export class FolderPickerCtrl {
return this.backendSrv.get('api/search', params).then(result => {
if (
query === '' ||
query.toLowerCase() === 'g' ||
query.toLowerCase() === 'ge' ||
query.toLowerCase() === 'gen' ||
query.toLowerCase() === 'gene' ||
query.toLowerCase() === 'gener' ||
query.toLowerCase() === 'genera' ||
query.toLowerCase() === 'general'
this.isEditor &&
(query === '' ||
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 });
}
if (this.enableCreateNew && query === '') {
if (this.isEditor && this.enableCreateNew && query === '') {
result.unshift({ title: '-- New Folder --', id: -1 });
}

View File

@@ -0,0 +1,77 @@
import angular from 'angular';
import { saveAs } from 'file-saver';
import coreModule from 'app/core/core_module';
const template = `
<div class="modal-body">
<div class="modal-header">
<h2 class="modal-header-title">
<i class="fa fa-save"></i><span class="p-l-1">Cannot save provisioned dashboard</span>
</h2>
<a class="modal-header-close" ng-click="ctrl.dismiss();">
<i class="fa fa-remove"></i>
</a>
</div>
<div class="modal-content">
<small>
This dashboard cannot be saved from Grafana's UI since it has been provisioned from another source.
Copy the JSON or save it to a file below. Then you can update your dashboard in corresponding provisioning source.<br/>
<i>See <a class="external-link" href="http://docs.grafana.org/administration/provisioning/#dashboards" target="_blank">
documentation</a> for more information about provisioning.</i>
</small>
<div class="p-t-2">
<div class="gf-form">
<code-editor content="ctrl.dashboardJson" data-mode="json" data-max-lines=15></code-editor>
</div>
<div class="gf-form-button-row">
<button class="btn btn-success" clipboard-button="ctrl.getJsonForClipboard()">
<i class="fa fa-clipboard"></i>&nbsp;Copy JSON to Clipboard
</button>
<button class="btn btn-secondary" clipboard-button="ctrl.save()">
<i class="fa fa-save"></i>&nbsp;Save JSON to file
</button>
<a class="btn btn-link" ng-click="ctrl.dismiss();">Cancel</a>
</div>
</div>
</div>
</div>
`;
export class SaveProvisionedDashboardModalCtrl {
dash: any;
dashboardJson: string;
dismiss: () => void;
/** @ngInject */
constructor(dashboardSrv) {
this.dash = dashboardSrv.getCurrent().getSaveModelClone();
delete this.dash.id;
this.dashboardJson = JSON.stringify(this.dash, null, 2);
}
save() {
var blob = new Blob([angular.toJson(this.dash, true)], {
type: 'application/json;charset=utf-8',
});
saveAs(blob, this.dash.title + '-' + new Date().getTime() + '.json');
}
getJsonForClipboard() {
return this.dashboardJson;
}
}
export function saveProvisionedDashboardModalDirective() {
return {
restrict: 'E',
template: template,
controller: SaveProvisionedDashboardModalCtrl,
bindToController: true,
controllerAs: 'ctrl',
scope: { dismiss: '&' },
};
}
coreModule.directive('saveProvisionedDashboardModal', saveProvisionedDashboardModalDirective);

View File

@@ -0,0 +1,99 @@
import { ChangeTracker } from 'app/features/dashboard/change_tracker';
import { contextSrv } from 'app/core/services/context_srv';
import { DashboardModel } from '../dashboard_model';
import { PanelModel } from '../panel_model';
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
user: { orgId: 1 },
},
}));
describe('ChangeTracker', () => {
let rootScope;
let location;
let timeout;
let tracker: ChangeTracker;
let dash;
let scope;
beforeEach(() => {
dash = new DashboardModel({
refresh: false,
panels: [
{
id: 1,
type: 'graph',
gridPos: { x: 0, y: 0, w: 24, h: 6 },
legend: { sortDesc: false },
},
{
id: 2,
type: 'row',
gridPos: { x: 0, y: 6, w: 24, h: 2 },
collapsed: true,
panels: [
{ id: 3, type: 'graph', gridPos: { x: 0, y: 6, w: 12, h: 2 } },
{ id: 4, type: 'graph', gridPos: { x: 12, y: 6, w: 12, h: 2 } },
],
},
{ id: 5, type: 'row', gridPos: { x: 0, y: 6, w: 1, h: 1 } },
],
});
scope = {
appEvent: jest.fn(),
onAppEvent: jest.fn(),
$on: jest.fn(),
};
rootScope = {
appEvent: jest.fn(),
onAppEvent: jest.fn(),
$on: jest.fn(),
};
location = {
path: jest.fn(),
};
tracker = new ChangeTracker(dash, scope, undefined, location, window, timeout, contextSrv, rootScope);
});
it('No changes should not have changes', () => {
expect(tracker.hasChanges()).toBe(false);
});
it('Simple change should be registered', () => {
dash.title = 'google';
expect(tracker.hasChanges()).toBe(true);
});
it('Should ignore a lot of changes', () => {
dash.time = { from: '1h' };
dash.refresh = true;
dash.schemaVersion = 10;
expect(tracker.hasChanges()).toBe(false);
});
it('Should ignore .iteration changes', () => {
dash.iteration = new Date().getTime() + 1;
expect(tracker.hasChanges()).toBe(false);
});
it('Should ignore row collapse change', () => {
dash.toggleRow(dash.panels[1]);
expect(tracker.hasChanges()).toBe(false);
});
it('Should ignore panel legend changes', () => {
dash.panels[0].legend.sortDesc = true;
dash.panels[0].legend.sort = 'avg';
expect(tracker.hasChanges()).toBe(false);
});
it('Should ignore panel repeats', () => {
dash.panels.push(new PanelModel({ repeatPanelId: 10 }));
expect(tracker.hasChanges()).toBe(false);
});
});

View File

@@ -374,14 +374,14 @@ describe('DashboardModel', function() {
{
id: 2,
type: 'row',
gridPos: { x: 0, y: 6, w: 24, h: 2 },
gridPos: { x: 0, y: 6, w: 24, h: 1 },
collapsed: true,
panels: [
{ id: 3, type: 'graph', gridPos: { x: 0, y: 2, w: 12, h: 2 } },
{ id: 4, type: 'graph', gridPos: { x: 12, y: 2, w: 12, h: 2 } },
{ id: 3, type: 'graph', gridPos: { x: 0, y: 7, w: 12, h: 2 } },
{ id: 4, type: 'graph', gridPos: { x: 12, y: 7, w: 12, h: 2 } },
],
},
{ id: 5, type: 'row', gridPos: { x: 0, y: 6, w: 1, h: 1 } },
{ id: 5, type: 'row', gridPos: { x: 0, y: 7, w: 1, h: 1 } },
],
});
dashboard.toggleRow(dashboard.panels[1]);
@@ -399,16 +399,16 @@ describe('DashboardModel', function() {
it('should position them below row', function() {
expect(dashboard.panels[2].gridPos).toMatchObject({
x: 0,
y: 8,
y: 7,
w: 12,
h: 2,
});
});
it('should move panels below down', function() {
it.only('should move panels below down', function() {
expect(dashboard.panels[4].gridPos).toMatchObject({
x: 0,
y: 10,
y: 9,
w: 1,
h: 1,
});

View File

@@ -0,0 +1,30 @@
import { SaveProvisionedDashboardModalCtrl } from '../save_provisioned_modal';
describe('SaveProvisionedDashboardModalCtrl', () => {
var json = {
title: 'name',
id: 5,
};
var mockDashboardSrv = {
getCurrent: function() {
return {
id: 5,
meta: {},
getSaveModelClone: function() {
return json;
},
};
},
};
var ctrl = new SaveProvisionedDashboardModalCtrl(mockDashboardSrv);
it('should remove id from dashboard model', () => {
expect(ctrl.dash.id).toBeUndefined();
});
it('should remove id from dashboard model in clipboard json', () => {
expect(ctrl.getJsonForClipboard()).toBe(JSON.stringify({ title: 'name' }, null, 2));
});
});

View File

@@ -1,95 +0,0 @@
import { describe, beforeEach, it, expect, sinon, angularMocks } from 'test/lib/common';
import { Tracker } from 'app/features/dashboard/unsaved_changes_srv';
import 'app/features/dashboard/dashboard_srv';
import { contextSrv } from 'app/core/core';
describe('unsavedChangesSrv', function() {
var _dashboardSrv;
var _contextSrvStub = { isEditor: true };
var _rootScope;
var _location;
var _timeout;
var _window;
var tracker;
var dash;
var scope;
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(
angularMocks.module(function($provide) {
$provide.value('contextSrv', _contextSrvStub);
$provide.value('$window', {});
})
);
beforeEach(
angularMocks.inject(function($location, $rootScope, dashboardSrv, $timeout, $window) {
_dashboardSrv = dashboardSrv;
_rootScope = $rootScope;
_location = $location;
_timeout = $timeout;
_window = $window;
})
);
beforeEach(function() {
dash = _dashboardSrv.create({
refresh: false,
panels: [{ test: 'asd', legend: {} }],
rows: [
{
panels: [{ test: 'asd', legend: {} }],
},
],
});
scope = _rootScope.$new();
scope.appEvent = sinon.spy();
scope.onAppEvent = sinon.spy();
tracker = new Tracker(dash, scope, undefined, _location, _window, _timeout, contextSrv, _rootScope);
});
it('No changes should not have changes', function() {
expect(tracker.hasChanges()).to.be(false);
});
it('Simple change should be registered', function() {
dash.property = 'google';
expect(tracker.hasChanges()).to.be(true);
});
it('Should ignore a lot of changes', function() {
dash.time = { from: '1h' };
dash.refresh = true;
dash.schemaVersion = 10;
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);
});
it('Should ignore panel legend changes', function() {
dash.panels[0].legend.sortDesc = true;
dash.panels[0].legend.sort = 'avg';
expect(tracker.hasChanges()).to.be(false);
});
it.skip('Should ignore panel repeats', function() {
dash.rows[0].panels.push({ repeatPanelId: 10 });
expect(tracker.hasChanges()).to.be(false);
});
it.skip('Should ignore row repeats', function() {
dash.addEmptyRow();
dash.rows[1].repeatRowId = 10;
expect(tracker.hasChanges()).to.be(false);
});
});

View File

@@ -1,217 +1,10 @@
import angular from 'angular';
import _ from 'lodash';
export class Tracker {
current: any;
originalPath: any;
scope: any;
original: any;
next: any;
$window: any;
/** @ngInject */
constructor(
dashboard,
scope,
originalCopyDelay,
private $location,
$window,
private $timeout,
private contextSrv,
private $rootScope
) {
this.$location = $location;
this.$window = $window;
this.current = dashboard;
this.originalPath = $location.path();
this.scope = scope;
// register events
scope.onAppEvent('dashboard-saved', () => {
this.original = this.current.getSaveModelClone();
this.originalPath = $location.path();
});
$window.onbeforeunload = () => {
if (this.ignoreChanges()) {
return undefined;
}
if (this.hasChanges()) {
return 'There are unsaved changes to this dashboard';
}
return undefined;
};
scope.$on('$locationChangeStart', (event, next) => {
// check if we should look for changes
if (this.originalPath === $location.path()) {
return true;
}
if (this.ignoreChanges()) {
return true;
}
if (this.hasChanges()) {
event.preventDefault();
this.next = next;
this.$timeout(() => {
this.open_modal();
});
}
return false;
});
if (originalCopyDelay) {
this.$timeout(() => {
// wait for different services to patch the dashboard (missing properties)
this.original = dashboard.getSaveModelClone();
}, originalCopyDelay);
} else {
this.original = dashboard.getSaveModelClone();
}
}
// for some dashboards and users
// changes should be ignored
ignoreChanges() {
if (!this.original) {
return true;
}
if (!this.contextSrv.isEditor) {
return true;
}
if (!this.current || !this.current.meta) {
return true;
}
var meta = this.current.meta;
return !meta.canSave || meta.fromScript || meta.fromFile;
}
// remove stuff that should not count in diff
cleanDashboardFromIgnoredChanges(dash) {
// ignore time and refresh
dash.time = 0;
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) {
return false;
}
row.panels = _.filter(row.panels, function(panel) {
if (panel.repeatPanelId) {
return false;
}
// remove scopedVars
panel.scopedVars = null;
// ignore span changes
panel.span = null;
// ignore panel legend sort
if (panel.legend) {
delete panel.legend.sort;
delete panel.legend.sortDesc;
}
return true;
});
// ignore collapse state
row.collapse = false;
return true;
});
dash.panels = _.filter(dash.panels, panel => {
if (panel.repeatPanelId) {
return false;
}
// remove scopedVars
panel.scopedVars = null;
// ignore panel legend sort
if (panel.legend) {
delete panel.legend.sort;
delete panel.legend.sortDesc;
}
return true;
});
// ignore template variable values
_.each(dash.templating.list, function(value) {
value.current = null;
value.options = null;
value.filters = null;
});
}
hasChanges() {
var current = this.current.getSaveModelClone();
var original = this.original;
this.cleanDashboardFromIgnoredChanges(current);
this.cleanDashboardFromIgnoredChanges(original);
var currentTimepicker = _.find(current.nav, { type: 'timepicker' });
var originalTimepicker = _.find(original.nav, { type: 'timepicker' });
if (currentTimepicker && originalTimepicker) {
currentTimepicker.now = originalTimepicker.now;
}
var currentJson = angular.toJson(current);
var originalJson = angular.toJson(original);
return currentJson !== originalJson;
}
discardChanges() {
this.original = null;
this.gotoNext();
}
open_modal() {
this.$rootScope.appEvent('show-modal', {
templateHtml: '<unsaved-changes-modal dismiss="dismiss()"></unsaved-changes-modal>',
modalClass: 'modal--narrow confirm-modal',
});
}
saveChanges() {
var self = this;
var cancel = this.$rootScope.$on('dashboard-saved', () => {
cancel();
this.$timeout(() => {
self.gotoNext();
});
});
this.$rootScope.appEvent('save-dashboard');
}
gotoNext() {
var baseLen = this.$location.absUrl().length - this.$location.url().length;
var nextUrl = this.next.substring(baseLen);
this.$location.url(nextUrl);
}
}
import { ChangeTracker } from './change_tracker';
/** @ngInject */
export function unsavedChangesSrv($rootScope, $q, $location, $timeout, contextSrv, dashboardSrv, $window) {
this.Tracker = Tracker;
this.init = function(dashboard, scope) {
this.tracker = new Tracker(dashboard, scope, 1000, $location, $window, $timeout, contextSrv, $rootScope);
this.tracker = new ChangeTracker(dashboard, scope, 1000, $location, $window, $timeout, contextSrv, $rootScope);
return this.tracker;
};
}

View File

@@ -91,7 +91,6 @@ describe('QueryVariable', () => {
it('should return in same order', () => {
var i = 0;
console.log(result);
expect(result.length).toBe(11);
expect(result[i++].text).toBe('');
expect(result[i++].text).toBe('0');

View File

@@ -320,7 +320,7 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
method: 'GET',
url: '/tags/autoComplete/tags',
params: {
expr: _.map(expressions, expression => templateSrv.replace(expression)),
expr: _.map(expressions, expression => templateSrv.replace((expression || '').trim())),
},
// for cancellations
requestId: options.requestId,
@@ -355,8 +355,8 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
method: 'GET',
url: '/tags/autoComplete/values',
params: {
expr: _.map(expressions, expression => templateSrv.replace(expression)),
tag: templateSrv.replace(tag),
expr: _.map(expressions, expression => templateSrv.replace((expression || '').trim())),
tag: templateSrv.replace((tag || '').trim()),
},
// for cancellations
requestId: options.requestId,

View File

@@ -222,4 +222,99 @@ describe('graphiteDatasource', function() {
expect(results.length).to.be(2);
});
});
describe('querying for template variables', () => {
let results;
let requestOptions;
beforeEach(() => {
ctx.backendSrv.datasourceRequest = function(options) {
requestOptions = options;
return ctx.$q.when({
data: [{ target: 'prod1.count', datapoints: [[10, 1], [12, 1]] }],
});
};
});
it('should generate tags query', () => {
ctx.ds.metricFindQuery('tags()').then(data => {
results = data;
});
ctx.$rootScope.$apply();
expect(requestOptions.url).to.be('/tags/autoComplete/tags');
expect(requestOptions.params.expr).to.eql([]);
expect(results).not.to.be(null);
});
it('should generate tags query with a filter expression', () => {
ctx.ds.metricFindQuery('tags(server=backend_01)').then(data => {
results = data;
});
ctx.$rootScope.$apply();
expect(requestOptions.url).to.be('/tags/autoComplete/tags');
expect(requestOptions.params.expr).to.eql(['server=backend_01']);
expect(results).not.to.be(null);
});
it('should generate tag query for an expression with whitespace after', () => {
ctx.ds.metricFindQuery('tags(server=backend_01 )').then(data => {
results = data;
});
ctx.$rootScope.$apply();
expect(requestOptions.url).to.be('/tags/autoComplete/tags');
expect(requestOptions.params.expr).to.eql(['server=backend_01']);
expect(results).not.to.be(null);
});
it('should generate tag values query for one tag', () => {
ctx.ds.metricFindQuery('tag_values(server)').then(data => {
results = data;
});
ctx.$rootScope.$apply();
expect(requestOptions.url).to.be('/tags/autoComplete/values');
expect(requestOptions.params.tag).to.be('server');
expect(requestOptions.params.expr).to.eql([]);
expect(results).not.to.be(null);
});
it('should generate tag values query for a tag and expression', () => {
ctx.ds.metricFindQuery('tag_values(server,server=~backend*)').then(data => {
results = data;
});
ctx.$rootScope.$apply();
expect(requestOptions.url).to.be('/tags/autoComplete/values');
expect(requestOptions.params.tag).to.be('server');
expect(requestOptions.params.expr).to.eql(['server=~backend*']);
expect(results).not.to.be(null);
});
it('should generate tag values query for a tag with whitespace after', () => {
ctx.ds.metricFindQuery('tag_values(server )').then(data => {
results = data;
});
ctx.$rootScope.$apply();
expect(requestOptions.url).to.be('/tags/autoComplete/values');
expect(requestOptions.params.tag).to.be('server');
expect(requestOptions.params.expr).to.eql([]);
expect(results).not.to.be(null);
});
it('should generate tag values query for a tag and expression with whitespace after', () => {
ctx.ds.metricFindQuery('tag_values(server , server=~backend* )').then(data => {
results = data;
});
ctx.$rootScope.$apply();
expect(requestOptions.url).to.be('/tags/autoComplete/values');
expect(requestOptions.params.tag).to.be('server');
expect(requestOptions.params.expr).to.eql(['server=~backend*']);
expect(results).not.to.be(null);
});
});
});

View File

@@ -28,12 +28,12 @@ An annotation is an event that is overlaid on top of graphs. The query can have
Macros:
- $__time(column) -&gt; column as "time"
- $__timeEpoch -&gt; extract(epoch from column) as "time"
- $__timeFilter(column) -&gt; column &ge; to_timestamp(1492750877) AND column &le; to_timestamp(1492750877)
- $__unixEpochFilter(column) -&gt; column &gt; 1492750877 AND column &lt; 1492750877
- $__timeFilter(column) -&gt; column BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:01:17Z'
- $__unixEpochFilter(column) -&gt; column &gt;= 1492750877 AND column &lt;= 1492750877
Or build your own conditionals using these macros which just return the values:
- $__timeFrom() -&gt; to_timestamp(1492750877)
- $__timeTo() -&gt; to_timestamp(1492750877)
- $__timeFrom() -&gt; '2017-04-21T05:01:17Z'
- $__timeTo() -&gt; '2017-04-21T05:01:17Z'
- $__unixEpochFrom() -&gt; 1492750877
- $__unixEpochTo() -&gt; 1492750877
</pre>

View File

@@ -48,8 +48,8 @@ Table:
Macros:
- $__time(column) -&gt; column as "time"
- $__timeEpoch -&gt; extract(epoch from column) as "time"
- $__timeFilter(column) -&gt; extract(epoch from column) BETWEEN 1492750877 AND 1492750877
- $__unixEpochFilter(column) -&gt; column &gt; 1492750877 AND column &lt; 1492750877
- $__timeFilter(column) -&gt; column BETWEEN '2017-04-21T05:01:17Z' AND '2017-04-21T05:01:17Z'
- $__unixEpochFilter(column) -&gt; column &gt;= 1492750877 AND column &lt;= 1492750877
- $__timeGroup(column,'5m') -&gt; (extract(epoch from column)/300)::bigint*300 AS time
Example of group by and order by with $__timeGroup:
@@ -61,8 +61,8 @@ GROUP BY time
ORDER BY time
Or build your own conditionals using these macros which just return the values:
- $__timeFrom() -&gt; to_timestamp(1492750877)
- $__timeTo() -&gt; to_timestamp(1492750877)
- $__timeFrom() -&gt; '2017-04-21T05:01:17Z'
- $__timeTo() -&gt; '2017-04-21T05:01:17Z'
- $__unixEpochFrom() -&gt; 1492750877
- $__unixEpochTo() -&gt; 1492750877
</pre>

View File

@@ -15,7 +15,23 @@ describe('PermissionsStore', () => {
permission: 1,
permissionName: 'View',
teamId: 1,
teamName: 'MyTestTeam',
team: 'MyTestTeam',
},
{
id: 5,
dashboardId: 1,
permission: 1,
permissionName: 'View',
userId: 1,
userLogin: 'MyTestUser',
},
{
id: 6,
dashboardId: 1,
permission: 1,
permissionName: 'Edit',
teamId: 2,
team: 'MyTestTeam2',
},
])
);
@@ -48,15 +64,24 @@ describe('PermissionsStore', () => {
});
it('should save removed permissions automatically', async () => {
expect(store.items.length).toBe(3);
expect(store.items.length).toBe(5);
await store.removeStoreItem(2);
expect(store.items.length).toBe(2);
expect(store.items.length).toBe(4);
expect(backendSrv.post.mock.calls.length).toBe(1);
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/permissions');
});
it('should be sorted by sort rank and alphabetically', async () => {
expect(store.items[0].name).toBe('MyTestTeam');
expect(store.items[0].dashboardId).toBe(10);
expect(store.items[1].name).toBe('Editor');
expect(store.items[2].name).toBe('Viewer');
expect(store.items[3].name).toBe('MyTestTeam2');
expect(store.items[4].name).toBe('MyTestUser');
});
describe('when one inherited and one not inherited team permission are added', () => {
beforeEach(async () => {
const overridingItemForChildDashboard = {
@@ -73,7 +98,18 @@ describe('PermissionsStore', () => {
});
it('should add new overriding permission', () => {
expect(store.items.length).toBe(4);
expect(store.items.length).toBe(6);
});
it('should be sorted by sort rank and alphabetically', async () => {
expect(store.items[0].name).toBe('MyTestTeam');
expect(store.items[0].dashboardId).toBe(10);
expect(store.items[1].name).toBe('Editor');
expect(store.items[2].name).toBe('Viewer');
expect(store.items[3].name).toBe('MyTestTeam');
expect(store.items[3].dashboardId).toBe(1);
expect(store.items[4].name).toBe('MyTestTeam2');
expect(store.items[5].name).toBe('MyTestUser');
});
});
});

View File

@@ -30,6 +30,8 @@ export const NewPermissionsItem = types
),
userId: types.maybe(types.number),
userLogin: types.maybe(types.string),
userAvatarUrl: types.maybe(types.string),
teamAvatarUrl: types.maybe(types.string),
teamId: types.maybe(types.number),
team: types.maybe(types.string),
permission: types.optional(types.number, 1),
@@ -50,17 +52,19 @@ export const NewPermissionsItem = types
},
}))
.actions(self => ({
setUser(userId: number, userLogin: string) {
setUser(userId: number, userLogin: string, userAvatarUrl: string) {
self.userId = userId;
self.userLogin = userLogin;
self.userAvatarUrl = userAvatarUrl;
self.teamId = null;
self.team = null;
},
setTeam(teamId: number, team: string) {
setTeam(teamId: number, team: string, teamAvatarUrl: string) {
self.userId = null;
self.userLogin = null;
self.teamId = teamId;
self.team = team;
self.teamAvatarUrl = teamAvatarUrl;
},
setPermission(permission: number) {
self.permission = permission;
@@ -121,16 +125,20 @@ export const PermissionsStore = types
teamId: undefined,
userLogin: undefined,
userId: undefined,
userAvatarUrl: undefined,
teamAvatarUrl: undefined,
role: undefined,
};
switch (self.newItem.type) {
case aclTypeValues.GROUP.value:
item.team = self.newItem.team;
item.teamId = self.newItem.teamId;
item.teamAvatarUrl = self.newItem.teamAvatarUrl;
break;
case aclTypeValues.USER.value:
item.userLogin = self.newItem.userLogin;
item.userId = self.newItem.userId;
item.userAvatarUrl = self.newItem.userAvatarUrl;
break;
case aclTypeValues.VIEWER.value:
case aclTypeValues.EDITOR.value:
@@ -147,6 +155,8 @@ export const PermissionsStore = types
try {
yield updateItems(self, updatedItems);
self.items.push(newItem);
let sortedItems = self.items.sort((a, b) => b.sortRank - a.sortRank || a.name.localeCompare(b.name));
self.items = sortedItems;
resetNewTypeInternal();
} catch {}
yield Promise.resolve();
@@ -206,9 +216,11 @@ const updateItems = (self, items) => {
};
const prepareServerResponse = (response, dashboardId: number, isFolder: boolean, isInRoot: boolean) => {
return response.map(item => {
return prepareItem(item, dashboardId, isFolder, isInRoot);
});
return response
.map(item => {
return prepareItem(item, dashboardId, isFolder, isInRoot);
})
.sort((a, b) => b.sortRank - a.sortRank || a.name.localeCompare(b.name));
};
const prepareItem = (item, dashboardId: number, isFolder: boolean, isInRoot: boolean) => {
@@ -216,21 +228,16 @@ const prepareItem = (item, dashboardId: number, isFolder: boolean, isInRoot: boo
item.sortRank = 0;
if (item.userId > 0) {
item.icon = 'fa fa-fw fa-user';
item.nameHtml = item.userLogin;
item.sortName = item.userLogin;
item.name = item.userLogin;
item.sortRank = 10;
} else if (item.teamId > 0) {
item.icon = 'fa fa-fw fa-users';
item.nameHtml = item.team;
item.sortName = item.team;
item.name = item.team;
item.sortRank = 20;
} else if (item.role) {
item.icon = 'fa fa-fw fa-street-view';
item.nameHtml = `Everyone with <span class="query-keyword">${item.role}</span> Role`;
item.sortName = item.role;
item.name = item.role;
item.sortRank = 30;
if (item.role === 'Viewer') {
if (item.role === 'Editor') {
item.sortRank += 1;
}
}

View File

@@ -14,8 +14,9 @@ export const PermissionsStoreItem = types
inherited: types.maybe(types.boolean),
sortRank: types.maybe(types.number),
icon: types.maybe(types.string),
nameHtml: types.maybe(types.string),
sortName: types.maybe(types.string),
name: types.maybe(types.string),
teamAvatarUrl: types.maybe(types.string),
userAvatarUrl: types.maybe(types.string),
})
.actions(self => ({
updateRole: role => {

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="64px" height="64px" viewBox="-479 353 64 64" style="enable-background:new -479 353 64 64;" xml:space="preserve">
<style type="text/css">
.st0{fill:#E3E2E2;}
</style>
<g>
<path class="st0" d="M-470.4,410h34.4c4.7,0,8.6-3.8,8.6-8.6v-17.3l-4.2,4.2v13.1c0,2.4-1.9,4.3-4.3,4.3h-34.4
c-2.4,0-4.3-1.9-4.3-4.3V376c0-2.4,1.9-4.3,4.3-4.3h32.1l4.2-4.2h-36.3c-4.7,0-8.6,3.8-8.6,8.6v25.5
C-479,406.2-475.2,410-470.4,410z"/>
<rect x="-438.3" y="364.5" transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 -1008.7032 339.9824)" class="st0" width="8.7" height="28.8"/>
<path class="st0" d="M-425.5,364.3l6.2,6.2l1.4-1.4l1.6-1.6c1.7-1.7,1.7-4.5,0-6.2c-1.7-1.7-4.5-1.7-6.2,0l-1.6,1.6L-425.5,364.3z"
/>
<polygon class="st0" points="-444.8,393.9 -442.3,393.5 -448.5,387.3 -448.9,389.8 -449.8,394.8 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="64px" height="64px" viewBox="-479 353 64 64" style="enable-background:new -479 353 64 64;" xml:space="preserve">
<style type="text/css">
.st0{fill:#E2E2E2;}
</style>
<path class="st0" d="M-415.1,384c-0.4-0.7-9.5-16.6-31.6-16.6c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0-0.1,0-0.1,0
c-22,0.1-31.3,15.9-31.6,16.6c-0.3,0.6-0.3,1.3,0,1.9c0.4,0.7,9.6,16.5,31.6,16.6c0,0,0.1,0,0.1,0c0,0,0,0,0,0c0,0,0.1,0,0.1,0
c22.2,0,31.2-16,31.6-16.6C-414.8,385.3-414.8,384.6-415.1,384z M-446.9,399.3c-7.9,0-14.3-6.4-14.3-14.3c0-7.9,6.4-14.3,14.3-14.3
c7.9,0,14.3,6.4,14.3,14.3C-432.6,392.9-439,399.3-446.9,399.3z"/>
<g>
<path class="st0" d="M-446.9,378.3c-0.9,0-1.8,0.2-2.6,0.5c1.2,0.4,2,1.5,2,2.9c0,1.7-1.4,3-3,3c-1.2,0-2.2-0.7-2.7-1.7
c-0.2,0.6-0.3,1.3-0.3,2c0,3.7,3,6.7,6.7,6.7c3.7,0,6.7-3,6.7-6.7S-443.2,378.3-446.9,378.3z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="64px" height="64px" viewBox="-479 353 64 64" style="enable-background:new -479 353 64 64;" xml:space="preserve">
<style type="text/css">
.st0{fill:#52545C;}
</style>
<g>
<path class="st0" d="M-470.4,410h34.4c4.7,0,8.6-3.8,8.6-8.6v-17.3l-4.2,4.2v13.1c0,2.4-1.9,4.3-4.3,4.3h-34.4
c-2.4,0-4.3-1.9-4.3-4.3V376c0-2.4,1.9-4.3,4.3-4.3h32.1l4.2-4.2h-36.3c-4.7,0-8.6,3.8-8.6,8.6v25.5
C-479,406.2-475.2,410-470.4,410z"/>
<rect x="-438.3" y="364.5" transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 -1008.7032 339.9824)" class="st0" width="8.7" height="28.8"/>
<path class="st0" d="M-425.5,364.3l6.2,6.2l1.4-1.4l1.6-1.6c1.7-1.7,1.7-4.5,0-6.2c-1.7-1.7-4.5-1.7-6.2,0l-1.6,1.6L-425.5,364.3z"
/>
<polygon class="st0" points="-444.8,393.9 -442.3,393.5 -448.5,387.3 -448.9,389.8 -449.8,394.8 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="64px" height="64px" viewBox="-479 353 64 64" style="enable-background:new -479 353 64 64;" xml:space="preserve">
<style type="text/css">
.st0{fill:#52545C;}
</style>
<path class="st0" d="M-415.1,384c-0.4-0.7-9.5-16.6-31.6-16.6c0,0-0.1,0-0.1,0c0,0,0,0,0,0c0,0-0.1,0-0.1,0
c-22,0.1-31.3,15.9-31.6,16.6c-0.3,0.6-0.3,1.3,0,1.9c0.4,0.7,9.6,16.5,31.6,16.6c0,0,0.1,0,0.1,0c0,0,0,0,0,0c0,0,0.1,0,0.1,0
c22.2,0,31.2-16,31.6-16.6C-414.8,385.3-414.8,384.6-415.1,384z M-446.9,399.3c-7.9,0-14.3-6.4-14.3-14.3c0-7.9,6.4-14.3,14.3-14.3
c7.9,0,14.3,6.4,14.3,14.3C-432.6,392.9-439,399.3-446.9,399.3z"/>
<g>
<path class="st0" d="M-446.9,378.3c-0.9,0-1.8,0.2-2.6,0.5c1.2,0.4,2,1.5,2,2.9c0,1.7-1.4,3-3,3c-1.2,0-2.2-0.7-2.7-1.7
c-0.2,0.6-0.3,1.3-0.3,2c0,3.7,3,6.7,6.7,6.7c3.7,0,6.7-3,6.7-6.7S-443.2,378.3-446.9,378.3z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -120,6 +120,10 @@
background-image: url('../img/icons_#{$theme-name}_theme/icon_data_sources.svg');
}
.gicon-editor {
background-image: url('../img/icons_#{$theme-name}_theme/icon_editor.svg');
}
.gicon-folder-new {
background-image: url('../img/icons_#{$theme-name}_theme/icon_add_folder.svg');
}
@@ -180,6 +184,10 @@
background-image: url('../img/icons_#{$theme-name}_theme/icon_variable.svg');
}
.gicon-viewer {
background-image: url('../img/icons_#{$theme-name}_theme/icon_viewer.svg');
}
.gicon-zoom-out {
background-image: url('../img/icons_#{$theme-name}_theme/icon_zoom_out.svg');
}

View File

@@ -64,8 +64,13 @@
background: $page-bg;
}
i {
padding-right: 5px;
.gicon {
margin-bottom: 2px;
}
.fa {
font-size: 17px;
width: 16px;
}
}

View File

@@ -85,3 +85,7 @@
}
}
}
.filter-table__weak-italic {
font-style: italic;
color: $text-color-weak;
}

View File

@@ -4,7 +4,7 @@
display: inline-block;
padding: 2px 4px;
font-size: $font-size-base * 0.846;
font-weight: bold;
font-weight: $font-weight-semi-bold;
line-height: 14px; // ensure proper line-height if floated
color: $white;
vertical-align: baseline;