Merge branch 'master' into develop

This commit is contained in:
Torkel Ödegaard
2017-10-09 16:01:54 +02:00
176 changed files with 5940 additions and 2717 deletions

View File

@@ -1,69 +0,0 @@
///<reference path="../../headers/common.d.ts" />
import coreModule from 'app/core/core_module';
var template = `
<div class="graph-legend-popover">
<div ng-show="ctrl.series" class="p-b-1">
<label>Y Axis:</label>
<button ng-click="ctrl.toggleAxis(yaxis);" class="btn btn-small"
ng-class="{'btn-success': ctrl.series.yaxis === 1,
'btn-inverse': ctrl.series.yaxis === 2}">
Left
</button>
<button ng-click="ctrl.toggleAxis(yaxis);"
class="btn btn-small"
ng-class="{'btn-success': ctrl.series.yaxis === 2,
'btn-inverse': ctrl.series.yaxis === 1}">
Right
</button>
</div>
<p class="m-b-0">
<i ng-repeat="color in ctrl.colors" class="pointer fa fa-circle"
ng-style="{color:color}"
ng-click="ctrl.colorSelected(color);">&nbsp;</i>
</p>
</div>
`;
export class ColorPickerCtrl {
colors: any;
autoClose: boolean;
series: any;
showAxisControls: boolean;
/** @ngInject */
constructor(private $scope, $rootScope) {
this.colors = $rootScope.colors;
this.autoClose = $scope.autoClose;
this.series = $scope.series;
}
toggleAxis(yaxis) {
this.$scope.toggleAxis();
if (this.$scope.autoClose) {
this.$scope.dismiss();
}
}
colorSelected(color) {
this.$scope.colorSelected(color);
if (this.$scope.autoClose) {
this.$scope.dismiss();
}
}
}
export function colorPicker() {
return {
restrict: 'E',
controller: ColorPickerCtrl,
bindToController: true,
controllerAs: 'ctrl',
template: template,
};
}
coreModule.directive('gfColorPicker', colorPicker);

View File

@@ -0,0 +1,45 @@
import React from 'react';
import coreModule from 'app/core/core_module';
import { sortedColors } from 'app/core/utils/colors';
export interface IProps {
color: string;
onColorSelect: (c: string) => void;
}
export class GfColorPalette extends React.Component<IProps, any> {
paletteColors: string[];
constructor(props) {
super(props);
this.paletteColors = sortedColors;
this.onColorSelect = this.onColorSelect.bind(this);
}
onColorSelect(color) {
return () => {
this.props.onColorSelect(color);
};
}
render() {
const colorPaletteItems = this.paletteColors.map((paletteColor) => {
const cssClass = paletteColor.toLowerCase() === this.props.color.toLowerCase() ? 'fa-circle-o' : 'fa-circle';
return (
<i key={paletteColor} className={"pointer fa " + cssClass}
style={{'color': paletteColor}}
onClick={this.onColorSelect(paletteColor)}>&nbsp;
</i>
);
});
return (
<div className="graph-legend-popover">
<p className="m-b-0">{colorPaletteItems}</p>
</div>
);
}
}
coreModule.directive('gfColorPalette', function (reactDirective) {
return reactDirective(GfColorPalette, ['color', 'onColorSelect']);
});

View File

@@ -0,0 +1,84 @@
import React from 'react';
import ReactDOM from 'react-dom';
import $ from 'jquery';
import Drop from 'tether-drop';
import coreModule from 'app/core/core_module';
import { ColorPickerPopover } from './ColorPickerPopover';
export interface IProps {
color: string;
onChange: (c: string) => void;
}
export class ColorPicker extends React.Component<IProps, any> {
pickerElem: any;
colorPickerDrop: any;
constructor(props) {
super(props);
this.openColorPicker = this.openColorPicker.bind(this);
this.closeColorPicker = this.closeColorPicker.bind(this);
this.setPickerElem = this.setPickerElem.bind(this);
this.onColorSelect = this.onColorSelect.bind(this);
}
setPickerElem(elem) {
this.pickerElem = $(elem);
}
openColorPicker() {
const dropContent = (
<ColorPickerPopover color={this.props.color} onColorSelect={this.onColorSelect} />
);
let dropContentElem = document.createElement('div');
ReactDOM.render(dropContent, dropContentElem);
let drop = new Drop({
target: this.pickerElem[0],
content: dropContentElem,
position: 'top center',
classes: 'drop-popover drop-popover--form',
openOn: 'hover',
hoverCloseDelay: 200,
tetherOptions: {
constraints: [{ to: 'scrollParent', attachment: "none both" }]
}
});
drop.on('close', this.closeColorPicker);
this.colorPickerDrop = drop;
this.colorPickerDrop.open();
}
closeColorPicker() {
setTimeout(() => {
if (this.colorPickerDrop && this.colorPickerDrop.tether) {
this.colorPickerDrop.destroy();
}
}, 100);
}
onColorSelect(color) {
this.props.onChange(color);
}
render() {
return (
<div className="sp-replacer sp-light" onClick={this.openColorPicker} ref={this.setPickerElem}>
<div className="sp-preview">
<div className="sp-preview-inner" style={{backgroundColor: this.props.color}}>
</div>
</div>
</div>
);
}
}
coreModule.directive('colorPicker', function (reactDirective) {
return reactDirective(ColorPicker, [
'color',
['onChange', { watchDepth: 'reference', wrapApply: true }]
]);
});

View File

@@ -0,0 +1,121 @@
import React from 'react';
import $ from 'jquery';
import tinycolor from 'tinycolor2';
import coreModule from 'app/core/core_module';
import { GfColorPalette } from './ColorPalette';
import { GfSpectrumPicker } from './SpectrumPicker';
const DEFAULT_COLOR = '#000000';
export interface IProps {
color: string;
onColorSelect: (c: string) => void;
}
export class ColorPickerPopover extends React.Component<IProps, any> {
pickerNavElem: any;
constructor(props) {
super(props);
this.state = {
tab: 'palette',
color: this.props.color || DEFAULT_COLOR,
colorString: this.props.color || DEFAULT_COLOR
};
}
setPickerNavElem(elem) {
this.pickerNavElem = $(elem);
}
setColor(color) {
let newColor = tinycolor(color);
if (newColor.isValid()) {
this.setState({
color: newColor.toString(),
colorString: newColor.toString()
});
this.props.onColorSelect(color);
}
}
sampleColorSelected(color) {
this.setColor(color);
}
spectrumColorSelected(color) {
let rgbColor = color.toRgbString();
this.setColor(rgbColor);
}
onColorStringChange(e) {
let colorString = e.target.value;
this.setState({
colorString: colorString
});
let newColor = tinycolor(colorString);
if (newColor.isValid()) {
// Update only color state
this.setState({
color: newColor.toString(),
});
this.props.onColorSelect(newColor);
}
}
onColorStringBlur(e) {
let colorString = e.target.value;
this.setColor(colorString);
}
componentDidMount() {
this.pickerNavElem.find('li:first').addClass('active');
this.pickerNavElem.on('show', (e) => {
// use href attr (#name => name)
let tab = e.target.hash.slice(1);
this.setState({
tab: tab
});
});
}
render() {
const paletteTab = (
<div id="palette">
<GfColorPalette color={this.state.color} onColorSelect={this.sampleColorSelected.bind(this)} />
</div>
);
const spectrumTab = (
<div id="spectrum">
<GfSpectrumPicker color={this.state.color} onColorSelect={this.spectrumColorSelected.bind(this)} options={{}} />
</div>
);
const currentTab = this.state.tab === 'palette' ? paletteTab : spectrumTab;
return (
<div className="gf-color-picker">
<ul className="nav nav-tabs" id="colorpickernav" ref={this.setPickerNavElem.bind(this)}>
<li className="gf-tabs-item-colorpicker">
<a href="#palette" data-toggle="tab">Colors</a>
</li>
<li className="gf-tabs-item-colorpicker">
<a href="#spectrum" data-toggle="tab">Custom</a>
</li>
</ul>
<div className="gf-color-picker__body">
{currentTab}
</div>
<div>
<input className="gf-form-input gf-form-input--small" value={this.state.colorString}
onChange={this.onColorStringChange.bind(this)} onBlur={this.onColorStringBlur.bind(this)}>
</input>
</div>
</div>
);
}
}
coreModule.directive('gfColorPickerPopover', function (reactDirective) {
return reactDirective(ColorPickerPopover, ['color', 'onColorSelect']);
});

View File

@@ -0,0 +1,55 @@
import React from 'react';
import coreModule from 'app/core/core_module';
import {ColorPickerPopover} from './ColorPickerPopover';
export interface IProps {
series: any;
onColorChange: (color: string) => void;
onToggleAxis: () => void;
}
export class SeriesColorPicker extends React.Component<IProps, any> {
constructor(props) {
super(props);
this.onColorChange = this.onColorChange.bind(this);
this.onToggleAxis = this.onToggleAxis.bind(this);
}
onColorChange(color) {
this.props.onColorChange(color);
}
onToggleAxis() {
this.props.onToggleAxis();
}
renderAxisSelection() {
const leftButtonClass = this.props.series.yaxis === 1 ? 'btn-success' : 'btn-inverse';
const rightButtonClass = this.props.series.yaxis === 2 ? 'btn-success' : 'btn-inverse';
return (
<div className="p-b-1">
<label className="small p-r-1">Y Axis:</label>
<button onClick={this.onToggleAxis} className={'btn btn-small ' + leftButtonClass}>
Left
</button>
<button onClick={this.onToggleAxis} className={'btn btn-small ' + rightButtonClass}>
Right
</button>
</div>
);
}
render() {
return (
<div className="graph-legend-popover">
{this.props.series && this.renderAxisSelection()}
<ColorPickerPopover color={this.props.series.color} onColorSelect={this.onColorChange} />
</div>
);
}
}
coreModule.directive('seriesColorPicker', function(reactDirective) {
return reactDirective(SeriesColorPicker, ['series', 'onColorChange', 'onToggleAxis']);
});

View File

@@ -0,0 +1,76 @@
import React from 'react';
import coreModule from 'app/core/core_module';
import _ from 'lodash';
import $ from 'jquery';
import 'vendor/spectrum';
export interface IProps {
color: string;
options: object;
onColorSelect: (c: string) => void;
}
export class GfSpectrumPicker extends React.Component<IProps, any> {
elem: any;
isMoving: boolean;
constructor(props) {
super(props);
this.onSpectrumMove = this.onSpectrumMove.bind(this);
this.setComponentElem = this.setComponentElem.bind(this);
}
setComponentElem(elem) {
this.elem = $(elem);
}
onSpectrumMove(color) {
this.isMoving = true;
this.props.onColorSelect(color);
}
componentDidMount() {
let spectrumOptions = _.assignIn({
flat: true,
showAlpha: true,
showButtons: false,
color: this.props.color,
appendTo: this.elem,
move: this.onSpectrumMove,
}, this.props.options);
this.elem.spectrum(spectrumOptions);
this.elem.spectrum('show');
this.elem.spectrum('set', this.props.color);
}
componentWillUpdate(nextProps) {
// If user move pointer over spectrum field this produce 'move' event and component
// may update props.color. We don't want to update spectrum color in this case, so we can use
// isMoving flag for tracking moving state. Flag should be cleared in componentDidUpdate() which
// is called after updating occurs (when user finished moving).
if (!this.isMoving) {
this.elem.spectrum('set', nextProps.color);
}
}
componentDidUpdate() {
if (this.isMoving) {
this.isMoving = false;
}
}
componentWillUnmount() {
this.elem.spectrum('destroy');
}
render() {
return (
<div className="spectrum-container" ref={this.setComponentElem}></div>
);
}
}
coreModule.directive('gfSpectrumPicker', function (reactDirective) {
return reactDirective(GfSpectrumPicker, ['color', 'options', 'onColorSelect']);
});

View File

@@ -4,9 +4,6 @@ import coreModule from 'app/core/core_module';
var template = `
<select class="gf-form-input" ng-model="ctrl.model" ng-options="f.value as f.text for f in ctrl.options"></select>
<info-popover mode="right-absolute">
Not finding dashboard you want? Star it first, then it should appear in this select box.
</info-popover>
`;
export class DashboardSelectorCtrl {

View File

@@ -7,6 +7,7 @@ import $ from 'jquery';
import coreModule from 'app/core/core_module';
import {profiler} from 'app/core/profiler';
import appEvents from 'app/core/app_events';
import Drop from 'tether-drop';
export class GrafanaCtrl {
@@ -105,6 +106,11 @@ export function grafanaAppDirective(playlistSrv, contextSrv, $timeout, $rootScop
if (data.params.kiosk) {
appEvents.emit('toggle-kiosk-mode');
}
// close all drops
for (let drop of Drop.drops) {
drop.destroy();
}
});
// handle kiosk mode

View File

@@ -27,6 +27,8 @@ export function infoPopover() {
transclude(function(clone, newScope) {
var content = document.createElement("div");
content.className = 'markdown-html';
_.each(clone, (node) => {
content.appendChild(node);
});

View File

@@ -1,201 +1,22 @@
/** Created by: Alex Wendland (me@alexwendland.com), 2014-08-06
*
* angular-json-tree
*
* Directive for creating a tree-view out of a JS Object. Only loads
* sub-nodes on demand in order to improve performance of rendering large
* objects.
*
* Attributes:
* - object (Object, 2-way): JS object to build the tree from
* - start-expanded (Boolean, 1-way, ?=true): should the tree default to expanded
*
* Usage:
* // In the controller
* scope.someObject = {
* test: 'hello',
* array: [1,1,2,3,5,8]
* };
* // In the html
* <json-tree object="someObject"></json-tree>
*
* Dependencies:
* - utils (json-tree.js)
* - ajsRecursiveDirectiveHelper (json-tree.js)
*
* Test: json-tree-test.js
*/
import angular from 'angular';
import coreModule from 'app/core/core_module';
var utils = {
/* See link for possible type values to check against.
* http://stackoverflow.com/questions/4622952/json-object-containing-array
*
* Value Class Type
* -------------------------------------
* "foo" String string
* new String("foo") String object
* 1.2 Number number
* new Number(1.2) Number object
* true Boolean boolean
* new Boolean(true) Boolean object
* new Date() Date object
* new Error() Error object
* [1,2,3] Array object
* new Array(1, 2, 3) Array object
* new Function("") Function function
* /abc/g RegExp object (function in Nitro/V8)
* new RegExp("meow") RegExp object (function in Nitro/V8)
* {} Object object
* new Object() Object object
*/
is: function is(obj, clazz) {
return Object.prototype.toString.call(obj).slice(8, -1) === clazz;
},
// See above for possible values
whatClass: function whatClass(obj) {
return Object.prototype.toString.call(obj).slice(8, -1);
},
// Iterate over an objects keyset
forKeys: function forKeys(obj, f) {
for (var key in obj) {
if (obj.hasOwnProperty(key) && typeof obj[key] !== 'function') {
if (f(key, obj[key])) {
break;
}
}
}
}
};
import {JsonExplorer} from '../json_explorer/json_explorer';
coreModule.directive('jsonTree', [function jsonTreeDirective() {
return {
return{
restrict: 'E',
scope: {
object: '=',
startExpanded: '@',
rootName: '@',
},
template: '<json-node key="rootName" value="object" start-expanded="startExpanded"></json-node>'
};
}]);
link: function(scope, elem) {
coreModule.directive('jsonNode', ['ajsRecursiveDirectiveHelper', function jsonNodeDirective(ajsRecursiveDirectiveHelper) {
return {
restrict: 'E',
scope: {
key: '=',
value: '=',
startExpanded: '@'
},
compile: function jsonNodeDirectiveCompile(elem) {
return ajsRecursiveDirectiveHelper.compile(elem, this);
},
template: ' <span class="json-tree-key" ng-click="toggleExpanded()">{{key}}</span>' +
' <span class="json-tree-leaf-value" ng-if="!isExpandable">{{value}}</span>' +
' <span class="json-tree-branch-preview" ng-if="isExpandable" ng-show="!isExpanded" ng-click="toggleExpanded()">' +
' {{preview}}</span>' +
' <ul class="json-tree-branch-value" ng-if="isExpandable && shouldRender" ng-show="isExpanded">' +
' <li ng-repeat="(subkey,subval) in value">' +
' <json-node key="subkey" value="subval"></json-node>' +
' </li>' +
' </ul>',
pre: function jsonNodeDirectiveLink(scope, elem, attrs) {
// Set value's type as Class for CSS styling
elem.addClass(utils.whatClass(scope.value).toLowerCase());
// If the value is an Array or Object, use expandable view type
if (utils.is(scope.value, 'Object') || utils.is(scope.value, 'Array')) {
scope.isExpandable = true;
// Add expandable class for CSS usage
elem.addClass('expandable');
// Setup preview text
var isArray = utils.is(scope.value, 'Array');
scope.preview = isArray ? '[ ' : '{ ';
utils.forKeys(scope.value, function jsonNodeDirectiveLinkForKeys(key, value) {
if (value === null) { scope.value[key] = 'null'; }
if (isArray) {
scope.preview += value + ', ';
} else {
scope.preview += key + ': ' + value + ', ';
}
});
scope.preview = scope.preview.substring(0, scope.preview.length - (scope.preview.length > 2 ? 2 : 0)) + (isArray ? ' ]' : ' }');
// If directive initially has isExpanded set, also set shouldRender to true
if (scope.startExpanded) {
scope.shouldRender = true;
elem.addClass('expanded');
}
// Setup isExpanded state handling
scope.isExpanded = scope.startExpanded;
scope.toggleExpanded = function jsonNodeDirectiveToggleExpanded() {
scope.isExpanded = !scope.isExpanded;
if (scope.isExpanded) {
elem.addClass('expanded');
} else {
elem.removeClass('expanded');
}
// For delaying subnode render until requested
scope.shouldRender = true;
};
} else {
scope.isExpandable = false;
// Add expandable class for CSS usage
elem.addClass('not-expandable');
}
}
};
}]);
/** Added by: Alex Wendland (me@alexwendland.com), 2014-08-09
* Source: http://stackoverflow.com/questions/14430655/recursion-in-angular-directives
*
* Used to allow for recursion within directives
*/
coreModule.factory('ajsRecursiveDirectiveHelper', ['$compile', function RecursiveDirectiveHelper($compile) {
return {
/**
* Manually compiles the element, fixing the recursion loop.
* @param element
* @param [link] A post-link function, or an object with function(s) registered via pre and post properties.
* @returns An object containing the linking functions.
*/
compile: function RecursiveDirectiveHelperCompile(element, link) {
// Normalize the link parameter
if (angular.isFunction(link)) {
link = {
post: link
};
}
// Break the recursion loop by removing the contents
var contents = element.contents().remove();
var compiledContents;
return {
pre: (link && link.pre) ? link.pre : null,
/**
* Compiles and re-adds the contents
*/
post: function RecursiveDirectiveHelperCompilePost(scope, element) {
// Compile the contents
if (!compiledContents) {
compiledContents = $compile(contents);
}
// Re-add the compiled contents to the element
compiledContents(scope, function (clone) {
element.append(clone);
});
// Call the post-linking function, if any
if (link && link.post) {
link.post.apply(null, arguments);
}
}
};
var jsonExp = new JsonExplorer(scope.object, 3, {
animateOpen: true
});
const html = jsonExp.render(true);
elem.html(html);
}
};
}]);

View File

@@ -5,7 +5,6 @@ import "./directives/dropdown_typeahead";
import "./directives/metric_segment";
import "./directives/misc";
import "./directives/ng_model_on_blur";
import "./directives/spectrum_picker";
import "./directives/tags";
import "./directives/value_select_dropdown";
import "./directives/rebuild_on_change";
@@ -16,12 +15,13 @@ import './partials';
import './components/jsontree/jsontree';
import './components/code_editor/code_editor';
import './utils/outline';
import './components/colorpicker/ColorPicker';
import './components/colorpicker/SeriesColorPicker';
import {grafanaAppDirective} from './components/grafana_app';
import {sideMenuDirective} from './components/sidemenu/sidemenu';
import {searchDirective} from './components/search/search';
import {infoPopover} from './components/info_popover';
import {colorPicker} from './components/colorpicker';
import {navbarDirective} from './components/navbar/navbar';
import {arrayJoin} from './directives/array_join';
import {liveSrv} from './live/live_srv';
@@ -58,7 +58,6 @@ export {
sideMenuDirective,
navbarDirective,
searchDirective,
colorPicker,
liveSrv,
layoutSelector,
switchDirective,

View File

@@ -79,7 +79,9 @@ function (_, $, coreModule) {
$scope.$apply(function() {
$scope.getOptions({ $query: query }).then(function(altSegments) {
$scope.altSegments = altSegments;
options = _.map($scope.altSegments, function(alt) { return alt.value; });
options = _.map($scope.altSegments, function(alt) {
return _.escape(alt.value);
});
// add custom values
if (segment.custom !== 'false') {

View File

@@ -1,41 +0,0 @@
define([
'angular',
'../core_module',
'vendor/spectrum',
],
function (angular, coreModule) {
'use strict';
coreModule.default.directive('spectrumPicker', function() {
return {
restrict: 'E',
require: 'ngModel',
scope: false,
replace: true,
template: "<span><input class='input-small' /></span>",
link: function(scope, element, attrs, ngModel) {
var input = element.find('input');
var options = angular.extend({
showAlpha: true,
showButtons: false,
color: ngModel.$viewValue,
change: function(color) {
scope.$apply(function() {
ngModel.$setViewValue(color.toRgbString());
});
}
}, scope.$eval(attrs.options));
ngModel.$render = function() {
input.spectrum('set', ngModel.$viewValue || '');
};
input.spectrum(options);
scope.$on('$destroy', function() {
input.spectrum('destroy');
});
}
};
});
});

View File

@@ -88,6 +88,7 @@ function (angular, $, coreModule) {
typeahead: {
source: angular.isFunction(scope.$parent[attrs.typeaheadSource]) ? scope.$parent[attrs.typeaheadSource] : null
},
widthClass: attrs.widthClass,
itemValue: getItemProperty(scope, attrs.itemvalue),
itemText : getItemProperty(scope, attrs.itemtext),
tagClass : angular.isFunction(scope.$parent[attrs.tagclass]) ?

View File

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

View File

@@ -90,7 +90,7 @@ export class NavModelSrv {
menu.push({
title: 'Annotations',
icon: 'fa fa-fw fa-bolt',
icon: 'fa fa-fw fa-comment',
clickHandler: () => dashNavCtrl.openEditView('annotations')
});

View File

@@ -1,6 +1,15 @@
import _ from 'lodash';
import tinycolor from 'tinycolor2';
export const PALETTE_ROWS = 4;
export const PALETTE_COLUMNS = 14;
export const DEFAULT_ANNOTATION_COLOR = 'rgba(0, 211, 255, 1)';
export const OK_COLOR = "rgba(11, 237, 50, 1)";
export const ALERTING_COLOR = "rgba(237, 46, 24, 1)";
export const NO_DATA_COLOR = "rgba(150, 150, 150, 1)";
export const REGION_FILL_ALPHA = 0.09;
export default [
let colors = [
"#7EB26D","#EAB839","#6ED0E0","#EF843C","#E24D42","#1F78C1","#BA43A9","#705DA0",
"#508642","#CCA300","#447EBC","#C15C17","#890F02","#0A437C","#6D1F62","#584477",
"#B7DBAB","#F4D598","#70DBED","#F9BA8F","#F29191","#82B5D8","#E5A8E2","#AEA2E0",
@@ -10,3 +19,26 @@ export default [
"#E0F9D7","#FCEACA","#CFFAFF","#F9E2D2","#FCE2DE","#BADFF4","#F9D9F9","#DEDAF7"
];
export function sortColorsByHue(hexColors) {
let hslColors = _.map(hexColors, hexToHsl);
let sortedHSLColors = _.sortBy(hslColors, ['h']);
sortedHSLColors = _.chunk(sortedHSLColors, PALETTE_ROWS);
sortedHSLColors = _.map(sortedHSLColors, chunk => {
return _.sortBy(chunk, 'l');
});
sortedHSLColors = _.flattenDeep(_.zip(...sortedHSLColors));
return _.map(sortedHSLColors, hslToHex);
}
export function hexToHsl(color) {
return tinycolor(color).toHsl();
}
export function hslToHex(color) {
return tinycolor(color).toHexString();
}
export let sortedColors = sortColorsByHue(colors);
export default colors;

View File

@@ -5,7 +5,7 @@ import moment from 'moment';
var units = ['y', 'M', 'w', 'd', 'h', 'm', 's'];
export function parse(text, roundUp?) {
export function parse(text, roundUp?, timezone?) {
if (!text) { return undefined; }
if (moment.isMoment(text)) { return text; }
if (_.isDate(text)) { return moment(text); }
@@ -16,7 +16,11 @@ export function parse(text, roundUp?) {
var parseString;
if (text.substring(0, 3) === 'now') {
time = moment();
if (timezone === 'utc') {
time = moment.utc();
} else {
time = moment();
}
mathString = text.substring('now'.length);
} else {
index = text.indexOf('||');

View File

@@ -1,14 +1,12 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import moment from 'moment';
declare var window: any;
const DEFAULT_DATETIME_FORMAT: String = 'YYYY-MM-DDTHH:mm:ssZ';
const DEFAULT_DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm:ssZ';
export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
var text = excel ? 'sep=;\n' : '' + 'Series;Time;Value\n';
var text = (excel ? 'sep=;\n' : '') + 'Series;Time;Value\n';
_.each(seriesList, function(series) {
_.each(series.datapoints, function(dp) {
text += series.alias + ';' + moment(dp[1]).format(dateTimeFormat) + ';' + dp[0] + '\n';
@@ -18,7 +16,7 @@ export function exportSeriesListToCsv(seriesList, dateTimeFormat = DEFAULT_DATET
}
export function exportSeriesListToCsvColumns(seriesList, dateTimeFormat = DEFAULT_DATETIME_FORMAT, excel = false) {
var text = excel ? 'sep=;\n' : '' + 'Time;';
var text = (excel ? 'sep=;\n' : '') + 'Time;';
// add header
_.each(seriesList, function(series) {
text += series.alias + ';';

View File

@@ -402,6 +402,10 @@ function($, _, moment) {
kbn.valueFormats.currencyRUB = kbn.formatBuilders.currency('₽');
kbn.valueFormats.currencyUAH = kbn.formatBuilders.currency('₴');
kbn.valueFormats.currencyBRL = kbn.formatBuilders.currency('R$');
kbn.valueFormats.currencyDKK = kbn.formatBuilders.currency('kr');
kbn.valueFormats.currencyISK = kbn.formatBuilders.currency('kr');
kbn.valueFormats.currencyNOK = kbn.formatBuilders.currency('kr');
kbn.valueFormats.currencySEK = kbn.formatBuilders.currency('kr');
// Data (Binary)
kbn.valueFormats.bits = kbn.formatBuilders.binarySIPrefix('b');
@@ -756,6 +760,10 @@ function($, _, moment) {
{text: 'Rubles (₽)', value: 'currencyRUB'},
{text: 'Hryvnias (₴)', value: 'currencyUAH'},
{text: 'Real (R$)', value: 'currencyBRL'},
{text: 'Danish Krone (kr)', value: 'currencyDKK'},
{text: 'Icelandic Krone (kr)', value: 'currencyISK'},
{text: 'Norwegian Krone (kr)', value: 'currencyNOK'},
{text: 'Swedish Krone (kr)', value: 'currencySEK'},
]
},
{

View File

@@ -41,7 +41,7 @@
<form name="passwordForm" class="gf-form-group">
<div class="gf-form" >
<editor-checkbox text="Grafana Admin" model="permissions.isGrafanaAdmin" style="line-height: 1.5rem;"></editor-checkbox>
<gf-form-switch class="gf-form" label="Grafana Admin" checked="permissions.isGrafanaAdmin" switch-class="max-width-6"></gf-form-switch>
</div>
<div class="gf-form-button-row">

View File

@@ -128,7 +128,6 @@ function joinEvalMatches(matches, separator: string) {
}
function getAlertAnnotationInfo(ah) {
// backward compatability, can be removed in grafana 5.x
// old way stored evalMatches in data property directly,
// new way stores it in evalMatches property on new data object

View File

@@ -1,12 +1,10 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import $ from 'jquery';
import coreModule from 'app/core/core_module';
import alertDef from '../alerting/alert_def';
/** @ngInject **/
export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) {
export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv, popoverSrv, $compile) {
function sanitizeString(str) {
try {
@@ -21,6 +19,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) {
restrict: 'E',
scope: {
"event": "=",
"onEdit": "&"
},
link: function(scope, element) {
var event = scope.event;
@@ -31,33 +30,46 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) {
var tooltip = '<div class="graph-annotation">';
var titleStateClass = '';
if (event.source.name === 'panel-alert') {
if (event.alertId) {
var stateModel = alertDef.getStateDisplayModel(event.newState);
titleStateClass = stateModel.stateClass;
title = `<i class="icon-gf ${stateModel.iconClass}"></i> ${stateModel.text}`;
text = alertDef.getAlertAnnotationInfo(event);
if (event.text) {
text = text + '<br />' + event.text;
}
} else if (title) {
text = title + '<br />' + text;
title = '';
}
tooltip += `
<div class="graph-annotation-header">
<span class="graph-annotation-title ${titleStateClass}">${sanitizeString(title)}</span>
<span class="graph-annotation-time">${dashboard.formatDate(event.min)}</span>
</div>
var header = `<div class="graph-annotation__header">`;
if (event.login) {
header += `<div class="graph-annotation__user" bs-tooltip="'Created by ${event.login}'"><img src="${event.avatarUrl}" /></div>`;
}
header += `
<span class="graph-annotation__title ${titleStateClass}">${sanitizeString(title)}</span>
<span class="graph-annotation__time">${dashboard.formatDate(event.min)}</span>
`;
tooltip += '<div class="graph-annotation-body">';
// Show edit icon only for users with at least Editor role
if (event.id && contextSrv.isEditor) {
header += `
<span class="pointer graph-annotation__edit-icon" ng-click="onEdit()">
<i class="fa fa-pencil-square"></i>
</span>
`;
}
header += `</div>`;
tooltip += header;
tooltip += '<div class="graph-annotation__body">';
if (text) {
tooltip += sanitizeString(text).replace(/\n/g, '<br>') + '<br>';
tooltip += '<div>' + sanitizeString(text).replace(/\n/g, '<br>') + '</div>';
}
var tags = event.tags;
if (_.isString(event.tags)) {
tags = event.tags.split(',');
if (tags.length === 1) {
tags = event.tags.split(' ');
}
}
if (tags && tags.length) {
scope.tags = tags;
@@ -65,6 +77,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) {
}
tooltip += "</div>";
tooltip += '</div>';
var $tooltip = $(tooltip);
$tooltip.appendTo(element);

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import './editor_ctrl';
import angular from 'angular';
@@ -11,11 +9,7 @@ export class AnnotationsSrv {
alertStatesPromise: any;
/** @ngInject */
constructor(private $rootScope,
private $q,
private datasourceSrv,
private backendSrv,
private timeSrv) {
constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) {
$rootScope.onAppEvent('refresh', this.clearCache.bind(this), $rootScope);
$rootScope.onAppEvent('dashboard-initialized', this.clearCache.bind(this), $rootScope);
}
@@ -26,64 +20,40 @@ export class AnnotationsSrv {
}
getAnnotations(options) {
return this.$q.all([
this.getGlobalAnnotations(options),
this.getPanelAnnotations(options),
this.getAlertStates(options)
]).then(results => {
return this.$q
.all([this.getGlobalAnnotations(options), this.getAlertStates(options)])
.then(results => {
// combine the annotations and flatten results
var annotations = _.flattenDeep(results[0]);
// combine the annotations and flatten results
var annotations = _.flattenDeep([results[0], results[1]]);
// filter out annotations that do not belong to requesting panel
annotations = _.filter(annotations, item => {
// shownIn === 1 requires annotation matching panel id
if (item.source.showIn === 1) {
if (item.panelId && options.panel.id === item.panelId) {
return true;
// filter out annotations that do not belong to requesting panel
annotations = _.filter(annotations, item => {
// if event has panel id and query is of type dashboard then panel and requesting panel id must match
if (item.panelId && item.source.type === 'dashboard') {
return item.panelId === options.panel.id;
}
return false;
return true;
});
annotations = dedupAnnotations(annotations);
annotations = makeRegions(annotations, options);
// look for alert state for this panel
var alertState = _.find(results[1], {panelId: options.panel.id});
return {
annotations: annotations,
alertState: alertState,
};
})
.catch(err => {
if (!err.message && err.data && err.data.message) {
err.message = err.data.message;
}
return true;
console.log('AnnotationSrv.query error', err);
this.$rootScope.appEvent('alert-error', ['Annotation Query Failed', err.message || err]);
return [];
});
// look for alert state for this panel
var alertState = _.find(results[2], {panelId: options.panel.id});
return {
annotations: annotations,
alertState: alertState,
};
}).catch(err => {
if (!err.message && err.data && err.data.message) {
err.message = err.data.message;
}
this.$rootScope.appEvent('alert-error', ['Annotation Query Failed', (err.message || err)]);
return [];
});
}
getPanelAnnotations(options) {
var panel = options.panel;
var dashboard = options.dashboard;
if (dashboard.id && panel && panel.alert) {
return this.backendSrv.get('/api/annotations', {
from: options.range.from.valueOf(),
to: options.range.to.valueOf(),
limit: 100,
panelId: panel.id,
dashboardId: dashboard.id,
}).then(results => {
// this built in annotation source name `panel-alert` is used in annotation tooltip
// to know that this annotation is from panel alert
return this.translateQueryResult({iconColor: '#AA0000', name: 'panel-alert'}, results);
});
}
return this.$q.when([]);
}
getAlertStates(options) {
@@ -104,43 +74,55 @@ export class AnnotationsSrv {
return this.alertStatesPromise;
}
this.alertStatesPromise = this.backendSrv.get('/api/alerts/states-for-dashboard', {dashboardId: options.dashboard.id});
this.alertStatesPromise = this.backendSrv.get('/api/alerts/states-for-dashboard', {
dashboardId: options.dashboard.id,
});
return this.alertStatesPromise;
}
getGlobalAnnotations(options) {
var dashboard = options.dashboard;
if (dashboard.annotations.list.length === 0) {
return this.$q.when([]);
}
if (this.globalAnnotationsPromise) {
return this.globalAnnotationsPromise;
}
var annotations = _.filter(dashboard.annotations.list, {enable: true});
var range = this.timeSrv.timeRange();
var promises = [];
for (let annotation of dashboard.annotations.list) {
if (!annotation.enable) {
continue;
}
this.globalAnnotationsPromise = this.$q.all(_.map(annotations, annotation => {
if (annotation.snapshotData) {
return this.translateQueryResult(annotation, annotation.snapshotData);
}
return this.datasourceSrv.get(annotation.datasource).then(datasource => {
// issue query against data source
return datasource.annotationQuery({range: range, rangeRaw: range.raw, annotation: annotation});
})
.then(results => {
// store response in annotation object if this is a snapshot call
if (dashboard.snapshot) {
annotation.snapshotData = angular.copy(results);
}
// translate result
return this.translateQueryResult(annotation, results);
});
}));
promises.push(
this.datasourceSrv
.get(annotation.datasource)
.then(datasource => {
// issue query against data source
return datasource.annotationQuery({
range: range,
rangeRaw: range.raw,
annotation: annotation,
dashboard: dashboard,
});
})
.then(results => {
// store response in annotation object if this is a snapshot call
if (dashboard.snapshot) {
annotation.snapshotData = angular.copy(results);
}
// translate result
return this.translateQueryResult(annotation, results);
}),
);
}
this.globalAnnotationsPromise = this.$q.all(promises);
return this.globalAnnotationsPromise;
}
@@ -149,6 +131,21 @@ export class AnnotationsSrv {
return this.backendSrv.post('/api/annotations', annotation);
}
updateAnnotationEvent(annotation) {
this.globalAnnotationsPromise = null;
return this.backendSrv.put(`/api/annotations/${annotation.id}`, annotation);
}
deleteAnnotationEvent(annotation) {
this.globalAnnotationsPromise = null;
let deleteUrl = `/api/annotations/${annotation.id}`;
if (annotation.isRegion) {
deleteUrl = `/api/annotations/region/${annotation.regionId}`;
}
return this.backendSrv.delete(deleteUrl);
}
translateQueryResult(annotation, results) {
// if annotation has snapshotData
// make clone and remove it
@@ -159,13 +156,88 @@ export class AnnotationsSrv {
for (var item of results) {
item.source = annotation;
item.min = item.time;
item.max = item.time;
item.scope = 1;
item.eventType = annotation.name;
}
return results;
}
}
/**
* This function converts annotation events into set
* of single events and regions (event consist of two)
* @param annotations
* @param options
*/
function makeRegions(annotations, options) {
let [regionEvents, singleEvents] = _.partition(annotations, 'regionId');
let regions = getRegions(regionEvents, options.range);
annotations = _.concat(regions, singleEvents);
return annotations;
}
function getRegions(events, range) {
let region_events = _.filter(events, event => {
return event.regionId;
});
let regions = _.groupBy(region_events, 'regionId');
regions = _.compact(
_.map(regions, region_events => {
let region_obj = _.head(region_events);
if (region_events && region_events.length > 1) {
region_obj.timeEnd = region_events[1].time;
region_obj.isRegion = true;
return region_obj;
} else {
if (region_events && region_events.length) {
// Don't change proper region object
if (!region_obj.time || !region_obj.timeEnd) {
// This is cut region
if (isStartOfRegion(region_obj)) {
region_obj.timeEnd = range.to.valueOf() - 1;
} else {
// Start time = null
region_obj.timeEnd = region_obj.time;
region_obj.time = range.from.valueOf() + 1;
}
region_obj.isRegion = true;
}
return region_obj;
}
}
}),
);
return regions;
}
function isStartOfRegion(event): boolean {
return event.id && event.id === event.regionId;
}
function dedupAnnotations(annotations) {
let dedup = [];
// Split events by annotationId property existance
let events = _.partition(annotations, 'id');
let eventsById = _.groupBy(events[0], 'id');
dedup = _.map(eventsById, eventGroup => {
if (eventGroup.length > 1 && !_.every(eventGroup, isPanelAlert)) {
// Get first non-panel alert
return _.find(eventGroup, event => {
return event.eventType !== 'panel-alert';
});
} else {
return _.head(eventGroup);
}
});
dedup = _.concat(dedup, events[1]);
return dedup;
}
function isPanelAlert(event) {
return event.eventType === 'panel-alert';
}
coreModule.service('annotationsSrv', AnnotationsSrv);

View File

@@ -1,5 +1,3 @@
///<reference path="../../headers/common.d.ts" />
import angular from 'angular';
import _ from 'lodash';
import $ from 'jquery';
@@ -36,11 +34,7 @@ export class AnnotationsEditorCtrl {
this.annotations = $scope.dashboard.annotations.list;
this.reset();
$scope.$watch('mode', newVal => {
if (newVal === 'new') {
this.reset();
}
});
this.onColorChange = this.onColorChange.bind(this);
}
datasourceChanged() {
@@ -71,6 +65,11 @@ export class AnnotationsEditorCtrl {
this.$scope.broadcastRefresh();
}
setupNew() {
this.mode = 'new';
this.reset();
}
add() {
this.annotations.push(this.currentAnnotation);
this.reset();
@@ -85,6 +84,18 @@ export class AnnotationsEditorCtrl {
this.$scope.dashboard.updateSubmenuVisibility();
this.$scope.broadcastRefresh();
}
onColorChange(newColor) {
this.currentAnnotation.iconColor = newColor;
}
annotationEnabledChange() {
this.$scope.broadcastRefresh();
}
annotationHiddenChanged() {
this.$scope.dashboard.updateSubmenuVisibility();
}
}
coreModule.controller('AnnotationsEditorCtrl', AnnotationsEditorCtrl);

View File

@@ -2,9 +2,11 @@
export class AnnotationEvent {
dashboardId: number;
panelId: number;
userId: number;
time: any;
timeEnd: any;
isRegion: boolean;
title: string;
text: string;
type: string;
tags: string;
}

View File

@@ -1,6 +1,5 @@
///<reference path="../../headers/common.d.ts" />
import _ from 'lodash';
import moment from 'moment';
import {coreModule} from 'app/core/core';
import {MetricsPanelCtrl} from 'app/plugins/sdk';
import {AnnotationEvent} from './event';
@@ -11,11 +10,20 @@ export class EventEditorCtrl {
timeRange: {from: number, to: number};
form: any;
close: any;
timeFormated: string;
/** @ngInject **/
constructor(private annotationsSrv) {
this.event.panelId = this.panelCtrl.panel.id;
this.event.dashboardId = this.panelCtrl.dashboard.id;
// Annotations query returns time as Unix timestamp in milliseconds
this.event.time = tryEpochToMoment(this.event.time);
if (this.event.isRegion) {
this.event.timeEnd = tryEpochToMoment(this.event.timeEnd);
}
this.timeFormated = this.panelCtrl.dashboard.formatDate(this.event.time);
}
save() {
@@ -28,7 +36,7 @@ export class EventEditorCtrl {
saveModel.timeEnd = 0;
if (saveModel.isRegion) {
saveModel.timeEnd = saveModel.timeEnd.valueOf();
saveModel.timeEnd = this.event.timeEnd.valueOf();
if (saveModel.timeEnd < saveModel.time) {
console.log('invalid time');
@@ -36,14 +44,48 @@ export class EventEditorCtrl {
}
}
this.annotationsSrv.saveAnnotationEvent(saveModel).then(() => {
if (saveModel.id) {
this.annotationsSrv.updateAnnotationEvent(saveModel)
.then(() => {
this.panelCtrl.refresh();
this.close();
})
.catch(() => {
this.panelCtrl.refresh();
this.close();
});
} else {
this.annotationsSrv.saveAnnotationEvent(saveModel)
.then(() => {
this.panelCtrl.refresh();
this.close();
})
.catch(() => {
this.panelCtrl.refresh();
this.close();
});
}
}
delete() {
return this.annotationsSrv.deleteAnnotationEvent(this.event)
.then(() => {
this.panelCtrl.refresh();
this.close();
})
.catch(() => {
this.panelCtrl.refresh();
this.close();
});
}
}
timeChanged() {
this.panelCtrl.render();
function tryEpochToMoment(timestamp) {
if (timestamp && _.isNumber(timestamp)) {
let epoch = Number(timestamp);
return moment(epoch);
} else {
return timestamp;
}
}

View File

@@ -1,27 +1,28 @@
import _ from 'lodash';
import moment from 'moment';
import tinycolor from 'tinycolor2';
import {MetricsPanelCtrl} from 'app/plugins/sdk';
import {AnnotationEvent} from './event';
import {OK_COLOR, ALERTING_COLOR, NO_DATA_COLOR, DEFAULT_ANNOTATION_COLOR, REGION_FILL_ALPHA} from 'app/core/utils/colors';
export class EventManager {
event: AnnotationEvent;
editorOpen: boolean;
constructor(private panelCtrl: MetricsPanelCtrl, private elem, private popoverSrv) {
}
constructor(private panelCtrl: MetricsPanelCtrl) {}
editorClosed() {
console.log('editorClosed');
this.event = null;
this.editorOpen = false;
this.panelCtrl.render();
}
updateTime(range) {
let newEvent = true;
editorOpened() {
this.editorOpen = true;
}
if (this.event) {
newEvent = false;
} else {
// init new event
updateTime(range) {
if (!this.event) {
this.event = new AnnotationEvent();
this.event.dashboardId = this.panelCtrl.dashboard.id;
this.event.panelId = this.panelCtrl.panel.id;
@@ -35,25 +36,11 @@ export class EventManager {
this.event.isRegion = true;
}
// newEvent means the editor is not visible
if (!newEvent) {
this.panelCtrl.render();
return;
}
this.popoverSrv.show({
element: this.elem[0],
classNames: 'drop-popover drop-popover--form',
position: 'bottom center',
openOn: null,
template: '<event-editor panel-ctrl="panelCtrl" event="event" close="dismiss()"></event-editor>',
onClose: this.editorClosed.bind(this),
model: {
event: this.event,
panelCtrl: this.panelCtrl,
},
});
this.panelCtrl.render();
}
editEvent(event, elem?) {
this.event = event;
this.panelCtrl.render();
}
@@ -63,36 +50,60 @@ export class EventManager {
}
var types = {
'$__alerting': {
color: 'rgba(237, 46, 24, 1)',
$__alerting: {
color: ALERTING_COLOR,
position: 'BOTTOM',
markerSize: 5,
},
'$__ok': {
color: 'rgba(11, 237, 50, 1)',
$__ok: {
color: OK_COLOR,
position: 'BOTTOM',
markerSize: 5,
},
'$__no_data': {
color: 'rgba(150, 150, 150, 1)',
$__no_data: {
color: NO_DATA_COLOR,
position: 'BOTTOM',
markerSize: 5,
},
$__editing: {
color: DEFAULT_ANNOTATION_COLOR,
position: 'BOTTOM',
markerSize: 5,
},
};
if (this.event) {
annotations = [
{
min: this.event.time.valueOf(),
title: this.event.title,
text: this.event.text,
eventType: '$__alerting',
}
];
if (this.event.isRegion) {
annotations = [
{
isRegion: true,
min: this.event.time.valueOf(),
timeEnd: this.event.timeEnd.valueOf(),
text: this.event.text,
eventType: '$__editing',
editModel: this.event,
},
];
} else {
annotations = [
{
min: this.event.time.valueOf(),
text: this.event.text,
editModel: this.event,
eventType: '$__editing',
},
];
}
} else {
// annotations from query
for (var i = 0; i < annotations.length; i++) {
var item = annotations[i];
// add properties used by jquery flot events
item.min = item.time;
item.max = item.time;
item.eventType = item.source.name;
if (item.newState) {
item.eventType = '$__' + item.newState;
continue;
@@ -108,10 +119,50 @@ export class EventManager {
}
}
let regions = getRegions(annotations);
addRegionMarking(regions, flotOptions);
let eventSectionHeight = 20;
let eventSectionMargin = 7;
flotOptions.grid.eventSectionHeight = eventSectionMargin;
flotOptions.xaxis.eventSectionHeight = eventSectionHeight;
flotOptions.events = {
levels: _.keys(types).length + 1,
data: annotations,
types: types,
manager: this,
};
}
}
function getRegions(events) {
return _.filter(events, 'isRegion');
}
function addRegionMarking(regions, flotOptions) {
let markings = flotOptions.grid.markings;
let defaultColor = DEFAULT_ANNOTATION_COLOR;
let fillColor;
_.each(regions, region => {
if (region.source) {
fillColor = region.source.iconColor || defaultColor;
} else {
fillColor = defaultColor;
}
fillColor = addAlphaToRGB(fillColor, REGION_FILL_ALPHA);
markings.push({xaxis: {from: region.min, to: region.timeEnd}, color: fillColor});
});
}
function addAlphaToRGB(colorString: string, alpha: number): string {
let color = tinycolor(colorString);
if (color.isValid()) {
color.setAlpha(alpha);
return color.toRgbString();
} else {
return colorString;
}
}

View File

@@ -40,10 +40,11 @@
Annotations provide a way to integrate event data into your graphs. They are visualized as vertical lines and icons
on all graph panels. When you hover over an annotation icon you can get title, tags, and text information for the event.
In the <i>Queries</i> tab you can add queries that return annotation events.
<br>
<br>
Checkout the <a class="external-link" target="_blank" href="http://docs.grafana.org/reference/annotations/">Annotations documentation</a> for more information.
</p>
<p>
You can add annotations directly from grafana by holding CTRL or CMD + click on graph (or drag region). These will be stored in Grafana's annotation database.
</p>
Checkout the <a class="external-link" target="_blank" href="http://docs.grafana.org/reference/annotations/">Annotations documentation</a> for more information.
</div>
</div>
@@ -53,13 +54,16 @@
</div>
<table class="grafana-options-table">
<tr ng-repeat="annotation in ctrl.annotations">
<td style="width:90%">
<i class="fa fa-bolt" style="color:{{annotation.iconColor}}"></i> &nbsp;
<td style="width:90%" ng-hide="annotation.builtIn">
<i class="fa fa-comment" style="color:{{annotation.iconColor}}"></i> &nbsp;
{{annotation.name}}
</td>
<td style="width:90%" ng-show="annotation.builtIn">
<i class="fa fa-comment"></i> &nbsp;
<em class="muted">{{annotation.name}} (Built-in)</em>
</td>
<td style="width: 1%"><i ng-click="_.move(ctrl.annotations,$index,$index-1)" ng-hide="$first" class="pointer fa fa-arrow-up"></i></td>
<td style="width: 1%"><i ng-click="_.move(ctrl.annotations,$index,$index+1)" ng-hide="$last" class="pointer fa fa-arrow-down"></i></td>
<td style="width: 1%">
<a ng-click="ctrl.edit(annotation)" class="btn btn-inverse btn-mini">
<i class="fa fa-edit"></i>
@@ -67,7 +71,7 @@
</a>
</td>
<td style="width: 1%">
<a ng-click="ctrl.removeAnnotation(annotation)" class="btn btn-danger btn-mini">
<a ng-click="ctrl.removeAnnotation(annotation)" class="btn btn-danger btn-mini" ng-hide="annotation.builtIn">
<i class="fa fa-remove"></i>
</a>
</td>
@@ -77,60 +81,65 @@
<div class="gf-form" ng-show="ctrl.mode === 'list'">
<div class="gf-form-button-row">
<a type="button" class="btn gf-form-button btn-success" ng-click="ctrl.mode = 'new';"><i class="fa fa-plus" ></i>&nbsp;&nbsp;New</a>
<a type="button" class="btn gf-form-button btn-success" ng-click="ctrl.setupNew()"><i class="fa fa-plus" ></i>&nbsp;&nbsp;New</a>
</div>
</div>
<div class="annotations-basic-settings" ng-if="ctrl.mode === 'edit' || ctrl.mode === 'new'">
<div class="gf-form-group">
<h5 class="section-heading">Options</h5>
<div>
<div class="gf-form-group">
<h5 class="section-heading">General</h5>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-9">Name</span>
<input type="text" class="gf-form-input width-12" ng-model='ctrl.currentAnnotation.name' placeholder="name"></input>
<span class="gf-form-label width-7">Name</span>
<input type="text" class="gf-form-input width-20" ng-model='ctrl.currentAnnotation.name' placeholder="name"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-9">Data source</span>
<div class="gf-form-select-wrapper width-12">
<span class="gf-form-label width-7">Data source</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.currentAnnotation.datasource" ng-options="f.name as f.name for f in ctrl.datasources" ng-change="ctrl.datasourceChanged()"></select>
</div>
</div>
</div>
<div class="gf-form-group">
<div class="gf-form-inline">
<!-- <div class="gf&#45;form"> -->
<!-- <span class="gf&#45;form&#45;label width&#45;7">Show in</span> -->
<!-- <div class="gf&#45;form&#45;select&#45;wrapper width&#45;12"> -->
<!-- <select class="gf&#45;form&#45;input" ng&#45;model="ctrl.currentAnnotation.showIn" ng&#45;options="f.value as f.text for f in ctrl.showOptions"></select> -->
<!-- </div> -->
<!-- </div> -->
<gf-form-switch class="gf-form"
label="Hide toggle"
tooltip="Hides the annotation query toggle from showing at the top of the dashboard"
checked="ctrl.currentAnnotation.hide"
label-class="width-9">
</gf-form-switch>
</div>
</div>
<div class="gf-form-group">
<div class="gf-form-inline">
<gf-form-switch class="gf-form"
label="Enabled"
checked="ctrl.currentAnnotation.enable"
on-change="ctrl.annotationEnabledChange()"
label-class="width-7">
</gf-form-switch>
<gf-form-switch class="gf-form"
label="Hidden"
tooltip="Hides the annotation query toggle from showing at the top of the dashboard"
checked="ctrl.currentAnnotation.hide"
on-change="ctrl.annotationHiddenChanged()"
label-class="width-7">
</gf-form-switch>
<div class="gf-form">
<label class="gf-form-label width-9">Color</label>
<spectrum-picker class="gf-form-input width-3" ng-model="ctrl.currentAnnotation.iconColor"></spectrum-picker>
<span class="gf-form-label">
<color-picker color="ctrl.currentAnnotation.iconColor" onChange="ctrl.onColorChange"></color-picker>
</span>
</div>
</div>
</div>
</div>
<h5 class="section-heading">Query</h5>
<rebuild-on-change property="ctrl.currentDatasource">
<plugin-component type="annotations-query-ctrl">
</plugin-component>
</rebuild-on-change>
<h5 class="section-heading">Query</h5>
<rebuild-on-change property="ctrl.currentDatasource">
<plugin-component type="annotations-query-ctrl">
</plugin-component>
</rebuild-on-change>
<div class="gf-form">
<div class="gf-form-button-row p-y-0">
<button ng-show="ctrl.mode === 'new'" type="button" class="btn gf-form-button btn-success" ng-click="ctrl.add()">Add</button>
<button ng-show="ctrl.mode === 'edit'" type="button" class="btn btn-success pull-left" ng-click="ctrl.update()">Update</button>
</div>
<div class="gf-form">
<div class="gf-form-button-row p-y-0">
<button ng-show="ctrl.mode === 'new'" type="button" class="btn gf-form-button btn-success" ng-click="ctrl.add()">Add</button>
<button ng-show="ctrl.mode === 'edit'" type="button" class="btn btn-success pull-left" ng-click="ctrl.update()">Update</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,38 +1,35 @@
<h5 class="section-heading text-center">Add annotation</h5>
<form name="ctrl.form" class="text-center">
<div style="display: inline-block">
<div class="gf-form">
<span class="gf-form-label width-7">Title</span>
<input type="text" ng-model="ctrl.event.title" class="gf-form-input max-width-20" required>
<div class="graph-annotation">
<div class="graph-annotation__header">
<div class="graph-annotation__user" bs-tooltip="'Created by {{ctrl.login}}'">
</div>
<!-- single event -->
<div ng-if="!ctrl.event.isRegion">
<div class="gf-form">
<span class="gf-form-label width-7">Time</span>
<input type="text" ng-model="ctrl.event.time" class="gf-form-input max-width-20" input-datetime required ng-change="ctrl.timeChanged()">
</div>
</div>
<!-- region event -->
<div ng-if="ctrl.event.isRegion">
<div class="gf-form">
<span class="gf-form-label width-7">Start</span>
<input type="text" ng-model="ctrl.event.time" class="gf-form-input max-width-20" input-datetime required ng-change="ctrl.timeChanged()">
</div>
<div class="gf-form">
<span class="gf-form-label width-7">End</span>
<input type="text" ng-model="ctrl.event.timeEnd" class="gf-form-input max-width-20" input-datetime required ng-change="ctrl.timeChanged()">
</div>
</div>
<div class="gf-form gf-form--v-stretch">
<span class="gf-form-label width-7">Description</span>
<textarea class="gf-form-input width-20" rows="3" ng-model="ctrl.event.text" placeholder="Event description"></textarea>
</div>
<div class="gf-form-button-row">
<button type="submit" class="btn gf-form-btn btn-success" ng-click="ctrl.save()">Save</button>
<a class="btn-text" ng-click="ctrl.close();">Cancel</a>
</div>
</div>
</form>
<div class="graph-annotation__title">
<span ng-if="!ctrl.event.id">Add Annotation</span>
<span ng-if="ctrl.event.id">Edit Annotation</span>
</div>
<div class="graph-annotation__time">{{ctrl.timeFormated}}</div>
</div>
<form name="ctrl.form" class="graph-annotation__body text-center">
<div style="display: inline-block">
<div class="gf-form gf-form--v-stretch">
<span class="gf-form-label width-7">Description</span>
<textarea class="gf-form-input width-20" rows="2" ng-model="ctrl.event.text" placeholder="Description"></textarea>
</div>
<div class="gf-form">
<span class="gf-form-label width-7">Tags</span>
<bootstrap-tagsinput ng-model="ctrl.event.tags" tagclass="label label-tag" placeholder="add tags">
</bootstrap-tagsinput>
</div>
<div class="gf-form-button-row">
<button type="submit" class="btn btn-success" ng-click="ctrl.save()">Save</button>
<button ng-if="ctrl.event.id" type="submit" class="btn btn-danger" ng-click="ctrl.delete()">Delete</button>
<a class="btn-text" ng-click="ctrl.close();">Cancel</a>
</div>
</div>
</form>
</div>

View File

@@ -0,0 +1,40 @@
import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common';
import '../annotations_srv';
import helpers from 'test/specs/helpers';
describe('AnnotationsSrv', function() {
var ctx = new helpers.ServiceTestContext();
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
beforeEach(() => {
ctx.createService('annotationsSrv');
});
describe('When translating the query result', () => {
const annotationSource = {
datasource: '-- Grafana --',
enable: true,
hide: false,
limit: 200,
name: 'test',
scope: 'global',
tags: [
'test'
],
type: 'event',
};
const time = 1507039543000;
const annotations = [{id: 1, panelId: 1, text: 'text', time: time}];
let translatedAnnotations;
beforeEach(() => {
translatedAnnotations = ctx.service.translateQueryResult(annotationSource, annotations);
});
it('should set defaults', () => {
expect(translatedAnnotations[0].source).to.eql(annotationSource);
});
});
});

View File

@@ -1,5 +1,3 @@
///<reference path="../../../headers/common.d.ts" />
import angular from 'angular';
import * as fileExport from 'app/core/utils/file_export';
import appEvents from 'app/core/app_events';
@@ -8,7 +6,7 @@ export class ExportDataModalCtrl {
private data: any[];
private panel: string;
asRows: Boolean = true;
dateTimeFormat: String = 'YYYY-MM-DDTHH:mm:ssZ';
dateTimeFormat = 'YYYY-MM-DDTHH:mm:ssZ';
excel: false;
export() {

View File

@@ -5,6 +5,7 @@ import moment from 'moment';
import _ from 'lodash';
import $ from 'jquery';
import {DEFAULT_ANNOTATION_COLOR} from 'app/core/utils/colors';
import {Emitter, contextSrv, appEvents} from 'app/core/core';
import {DashboardRow} from './row/row_model';
import sortByKeys from 'app/core/utils/sort_by_keys';
@@ -82,10 +83,35 @@ export class DashboardModel {
this.panels = data.panels || [];
this.rows = [];
this.addBuiltInAnnotationQuery();
this.initMeta(meta);
this.updateSchema(data);
}
addBuiltInAnnotationQuery() {
let found = false;
for (let item of this.annotations.list) {
if (item.builtIn === 1) {
found = true;
break;
}
}
if (found) {
return;
}
this.annotations.list.unshift({
datasource: '-- Grafana --',
name: 'Annotations & Alerts',
type: 'dashboard',
iconColor: DEFAULT_ANNOTATION_COLOR,
enable: true,
hide: true,
builtIn: 1,
});
}
private initMeta(meta) {
meta = meta || {};

View File

@@ -53,9 +53,10 @@ describe('DashImportCtrl', function() {
// setup api mock
backendSrv.get = sinon.spy(() => {
return Promise.resolve({
json: {}
});
});
ctx.ctrl.checkGnetDashboard();
return ctx.ctrl.checkGnetDashboard();
});
it('should call gnet api with correct dashboard id', function() {
@@ -69,9 +70,10 @@ describe('DashImportCtrl', function() {
// setup api mock
backendSrv.get = sinon.spy(() => {
return Promise.resolve({
json: {}
});
});
ctx.ctrl.checkGnetDashboard();
return ctx.ctrl.checkGnetDashboard();
});
it('should call gnet api with correct dashboard id', function() {

View File

@@ -46,8 +46,8 @@ describe('DashboardModel', function() {
var saveModel = model.getSaveModelClone();
var keys = _.keys(saveModel);
expect(keys[0]).to.be('addEmptyRow');
expect(keys[1]).to.be('addPanel');
expect(keys[0]).to.be('addBuiltInAnnotationQuery');
expect(keys[1]).to.be('addEmptyRow');
});
});
@@ -220,26 +220,6 @@ describe('DashboardModel', function() {
});
});
describe('when creating dashboard model with missing list for annoations or templating', function() {
var model;
beforeEach(function() {
model = new DashboardModel({
annotations: {
enable: true,
},
templating: {
enable: true
}
});
});
it('should add empty list', function() {
expect(model.annotations.list.length).to.be(0);
expect(model.templating.list.length).to.be(0);
});
});
describe('Given editable false dashboard', function() {
var model;
@@ -339,7 +319,12 @@ describe('DashboardModel', function() {
});
it('should add empty list', function() {
expect(model.annotations.list.length).to.be(0);
expect(model.annotations.list.length).to.be(1);
expect(model.templating.list.length).to.be(0);
});
it('should add builtin annotation query', function() {
expect(model.annotations.list[0].builtIn).to.be(1);
expect(model.templating.list.length).to.be(0);
});
});

View File

@@ -80,6 +80,10 @@ describe('given dashboard with repeated panels', function() {
name: 'mixed',
meta: {id: "mixed", info: {version: "1.2.1"}, name: "Mixed", builtIn: true}
}));
datasourceSrvStub.get.withArgs('-- Grafana --').returns(Promise.resolve({
name: '-- Grafana --',
meta: {id: "grafana", info: {version: "1.2.1"}, name: "grafana", builtIn: true}
}));
config.panels['graph'] = {
id: "graph",
@@ -116,7 +120,7 @@ describe('given dashboard with repeated panels', function() {
});
it('should replace datasource in annotation query', function() {
expect(exported.annotations.list[0].datasource).to.be("${DS_GFDB}");
expect(exported.annotations.list[1].datasource).to.be("${DS_GFDB}");
});
it('should add datasource as input', function() {

View File

@@ -21,50 +21,48 @@ describe('historySrv', function() {
return [200, restoreResponse(parsedData.version)];
});
}));
beforeEach(ctx.createService('historySrv'));
function wrapPromise(ctx, angularPromise) {
return new Promise((resolve, reject) => {
angularPromise.then(resolve, reject);
ctx.$httpBackend.flush();
});
}
describe('getHistoryList', function() {
it('should return a versions array for the given dashboard id', function(done) {
ctx.service.getHistoryList({ id: 1 }).then(function(versions) {
it('should return a versions array for the given dashboard id', function() {
return wrapPromise(ctx, ctx.service.getHistoryList({ id: 1 }).then(function(versions) {
expect(versions).to.eql(versionsResponse);
done();
});
ctx.$httpBackend.flush();
}));
});
it('should return an empty array when not given an id', function(done) {
ctx.service.getHistoryList({ }).then(function(versions) {
it('should return an empty array when not given an id', function() {
return wrapPromise(ctx, ctx.service.getHistoryList({ }).then(function(versions) {
expect(versions).to.eql([]);
done();
});
ctx.$httpBackend.flush();
}));
});
it('should return an empty array when not given a dashboard', function(done) {
ctx.service.getHistoryList().then(function(versions) {
it('should return an empty array when not given a dashboard', function() {
return wrapPromise(ctx, ctx.service.getHistoryList().then(function(versions) {
expect(versions).to.eql([]);
done();
});
ctx.$httpBackend.flush();
}));
});
});
describe('restoreDashboard', function() {
it('should return a success response given valid parameters', function(done) {
var version = 6;
ctx.service.restoreDashboard({ id: 1 }, version).then(function(response) {
it('should return a success response given valid parameters', function() {
let version = 6;
return wrapPromise(ctx, ctx.service.restoreDashboard({ id: 1 }, version).then(function(response) {
expect(response).to.eql(restoreResponse(version));
done();
});
ctx.$httpBackend.flush();
}));
});
it('should return an empty object when not given an id', function(done) {
ctx.service.restoreDashboard({}, 6).then(function(response) {
it('should return an empty object when not given an id', function() {
return wrapPromise(ctx, ctx.service.restoreDashboard({}, 6).then(function(response) {
expect(response).to.eql({});
done();
});
ctx.$httpBackend.flush();
}));
});
});
});

View File

@@ -1,4 +1,4 @@
import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common';
import {describe, beforeEach, it, expect, sinon, angularMocks} from 'test/lib/common';
import helpers from 'test/specs/helpers';
import '../time_srv';
@@ -8,6 +8,7 @@ describe('timeSrv', function() {
var ctx = new helpers.ServiceTestContext();
var _dashboard: any = {
time: {from: 'now-6h', to: 'now'},
getTimezone: sinon.stub().returns('browser')
};
beforeEach(angularMocks.module('grafana.core'));

View File

@@ -12,7 +12,7 @@
<div ng-if="ctrl.dashboard.annotations.list.length > 0">
<div ng-repeat="annotation in ctrl.dashboard.annotations.list" ng-hide="annotation.hide" class="submenu-item" ng-class="{'annotation-disabled': !annotation.enable}">
<gf-form-switch class="gf-form" label="{{annotation.name}}" checked="annotation.enable" on-change="ctrl.annotationStateChanged()"></gf-form-switch>
<gf-form-switch class="gf-form" label="{{annotation.name}}" checked="annotation.enable" on-change="ctrl.annotationStateChanged()"></gf-form-switch>
</div>
</div>

View File

@@ -196,9 +196,11 @@ class TimeSrv {
to: moment.isMoment(this.time.to) ? moment(this.time.to) : this.time.to,
};
var timezone = this.dashboard && this.dashboard.getTimezone();
return {
from: dateMath.parse(raw.from, false),
to: dateMath.parse(raw.to, true),
from: dateMath.parse(raw.from, false, timezone),
to: dateMath.parse(raw.to, true, timezone),
raw: raw
};
}

View File

@@ -41,15 +41,13 @@
</div>
<div class="gf-form-inline gf-form-group">
<div class="gf-form">
<a class="btn btn-inverse btn-small" ng-click="addInvite()">
<div class="gf-form" style="margin-right:.25rem">
<a class="btn btn-inverse gf-form-button" ng-click="addInvite()">
<i class="fa fa-plus"></i>
Invite another
</a>
</div>
<div class="gf-form">
<editor-checkbox text="Skip sending invite email" model="options.skipEmails" change="targetBlur()"></editor-checkbox>
</div>
<gf-form-switch class="gf-form" label="Skip sending invite email" checked="options.skipEmails" switch-class="max-width-6"></gf-form-switch>
</div>
<div class="gf-form-button-row">

View File

@@ -59,9 +59,13 @@ var template = `
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Home Dashboard</span>
<dashboard-selector class="gf-form-select-wrapper max-width-20 gf-form-select-wrapper--has-help-icon"
model="ctrl.prefs.homeDashboardId">
<span class="gf-form-label width-10">
Home Dashboard
<info-popover mode="right-normal">
Not finding dashboard you want? Star it first, then it should appear in this select box.
</info-popover>
</span>
<dashboard-selector class="gf-form-select-wrapper max-width-20" model="ctrl.prefs.homeDashboardId">
</dashboard-selector>
</div>

View File

@@ -41,6 +41,7 @@ class MetricsPanelCtrl extends PanelCtrl {
this.timeSrv = $injector.get('timeSrv');
this.templateSrv = $injector.get('templateSrv');
this.scope = $scope;
this.panel.datasource = this.panel.datasource || null;
if (!this.panel.targets) {
this.panel.targets = [{}];
@@ -218,6 +219,7 @@ class MetricsPanelCtrl extends PanelCtrl {
});
var metricsQuery = {
timezone: this.dashboard.getTimezone(),
panelId: this.panel.id,
range: this.range,
rangeRaw: this.range.raw,

View File

@@ -0,0 +1,47 @@
import * as graphitePlugin from 'app/plugins/datasource/graphite/module';
import * as cloudwatchPlugin from 'app/plugins/datasource/cloudwatch/module';
import * as elasticsearchPlugin from 'app/plugins/datasource/elasticsearch/module';
import * as opentsdbPlugin from 'app/plugins/datasource/opentsdb/module';
import * as grafanaPlugin from 'app/plugins/datasource/grafana/module';
import * as influxdbPlugin from 'app/plugins/datasource/influxdb/module';
import * as mixedPlugin from 'app/plugins/datasource/mixed/module';
import * as mysqlPlugin from 'app/plugins/datasource/mysql/module';
import * as prometheusPlugin from 'app/plugins/datasource/prometheus/module';
import * as textPanel from 'app/plugins/panel/text/module';
import * as graphPanel from 'app/plugins/panel/graph/module';
import * as dashListPanel from 'app/plugins/panel/dashlist/module';
import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module';
import * as alertListPanel from 'app/plugins/panel/alertlist/module';
import * as heatmapPanel from 'app/plugins/panel/heatmap/module';
import * as tablePanel from 'app/plugins/panel/table/module';
import * as singlestatPanel from 'app/plugins/panel/singlestat/module';
import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module';
import * as testDataAppPlugin from 'app/plugins/app/testdata/module';
import * as testDataDSPlugin from 'app/plugins/app/testdata/datasource/module';
const builtInPlugins = {
"app/plugins/datasource/graphite/module": graphitePlugin,
"app/plugins/datasource/cloudwatch/module": cloudwatchPlugin,
"app/plugins/datasource/elasticsearch/module": elasticsearchPlugin,
"app/plugins/datasource/opentsdb/module": opentsdbPlugin,
"app/plugins/datasource/grafana/module": grafanaPlugin,
"app/plugins/datasource/influxdb/module": influxdbPlugin,
"app/plugins/datasource/mixed/module": mixedPlugin,
"app/plugins/datasource/mysql/module": mysqlPlugin,
"app/plugins/datasource/prometheus/module": prometheusPlugin,
"app/plugins/app/testdata/module": testDataAppPlugin,
"app/plugins/app/testdata/datasource/module": testDataDSPlugin,
"app/plugins/panel/text/module": textPanel,
"app/plugins/panel/graph/module": graphPanel,
"app/plugins/panel/dashlist/module": dashListPanel,
"app/plugins/panel/pluginlist/module": pluginsListPanel,
"app/plugins/panel/alertlist/module": alertListPanel,
"app/plugins/panel/heatmap/module": heatmapPanel,
"app/plugins/panel/table/module": tablePanel,
"app/plugins/panel/singlestat/module": singlestatPanel,
"app/plugins/panel/gettingstarted/module": gettingStartedPanel,
};
export default builtInPlugins;

View File

@@ -7,53 +7,12 @@ import angular from 'angular';
import jquery from 'jquery';
import config from 'app/core/config';
import TimeSeries from 'app/core/time_series2';
import TableModel from 'app/core/table_model';
import appEvents from 'app/core/app_events';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
import * as datemath from 'app/core/utils/datemath';
import * as graphitePlugin from 'app/plugins/datasource/graphite/module';
import * as cloudwatchPlugin from 'app/plugins/datasource/cloudwatch/module';
import * as elasticsearchPlugin from 'app/plugins/datasource/elasticsearch/module';
import * as opentsdbPlugin from 'app/plugins/datasource/opentsdb/module';
import * as grafanaPlugin from 'app/plugins/datasource/grafana/module';
import * as influxdbPlugin from 'app/plugins/datasource/influxdb/module';
import * as mixedPlugin from 'app/plugins/datasource/mixed/module';
import * as mysqlPlugin from 'app/plugins/datasource/mysql/module';
import * as prometheusPlugin from 'app/plugins/datasource/prometheus/module';
import * as textPanel from 'app/plugins/panel/text/module';
import * as graphPanel from 'app/plugins/panel/graph/module';
import * as dashListPanel from 'app/plugins/panel/dashlist/module';
import * as pluginsListPanel from 'app/plugins/panel/pluginlist/module';
import * as alertListPanel from 'app/plugins/panel/alertlist/module';
import * as heatmapPanel from 'app/plugins/panel/heatmap/module';
import * as tablePanel from 'app/plugins/panel/table/module';
import * as singlestatPanel from 'app/plugins/panel/singlestat/module';
import * as gettingStartedPanel from 'app/plugins/panel/gettingstarted/module';
import * as testDataAppPlugin from 'app/plugins/app/testdata/module';
import * as testDataDSPlugin from 'app/plugins/app/testdata/datasource/module';
let builtInPlugins = {
"app/plugins/datasource/graphite/module": graphitePlugin,
"app/plugins/datasource/cloudwatch/module": cloudwatchPlugin,
"app/plugins/datasource/elasticsearch/module": elasticsearchPlugin,
"app/plugins/datasource/opentsdb/module": opentsdbPlugin,
"app/plugins/datasource/grafana/module": grafanaPlugin,
"app/plugins/datasource/influxdb/module": influxdbPlugin,
"app/plugins/datasource/mixed/module": mixedPlugin,
"app/plugins/datasource/mysql/module": mysqlPlugin,
"app/plugins/datasource/prometheus/module": prometheusPlugin,
"app/plugins/app/testdata/module": testDataAppPlugin,
"app/plugins/app/testdata/datasource/module": testDataDSPlugin,
"app/plugins/panel/text/module": textPanel,
"app/plugins/panel/graph/module": graphPanel,
"app/plugins/panel/dashlist/module": dashListPanel,
"app/plugins/panel/pluginlist/module": pluginsListPanel,
"app/plugins/panel/alertlist/module": alertListPanel,
"app/plugins/panel/heatmap/module": heatmapPanel,
"app/plugins/panel/table/module": tablePanel,
"app/plugins/panel/singlestat/module": singlestatPanel,
"app/plugins/panel/gettingstarted/module": gettingStartedPanel,
};
import builtInPlugins from './buit_in_plugins';
System.config({
baseURL: 'public',
@@ -89,12 +48,17 @@ exposeToPlugin('lodash', _);
exposeToPlugin('moment', moment);
exposeToPlugin('jquery', jquery);
exposeToPlugin('angular', angular);
exposeToPlugin('rxjs/Subject', Subject);
exposeToPlugin('rxjs/Observable', Observable);
exposeToPlugin('app/plugins/sdk', sdk);
exposeToPlugin('app/core/utils/datemath', datemath);
exposeToPlugin('app/core/utils/kbn', kbn);
exposeToPlugin('app/core/config', config);
exposeToPlugin('app/core/time_series', TimeSeries);
exposeToPlugin('app/core/time_series2', TimeSeries);
exposeToPlugin('app/core/table_model', TableModel);
exposeToPlugin('app/core/app_events', appEvents);
import 'vendor/flot/jquery.flot';
import 'vendor/flot/jquery.flot.selection';
@@ -107,7 +71,7 @@ import 'vendor/flot/jquery.flot.crosshair';
import 'vendor/flot/jquery.flot.dashes';
for (let flotDep of ['jquery.flot', 'jquery.flot.pie', 'jquery.flot.time']) {
System.registerDynamic(flotDep, [], true, function(require, exports, module) { module.exports = {fakeDep: 1}; });
exposeToPlugin(flotDep, {fakeDep: 1});
}
export function importPluginModule(path: string): Promise<any> {

View File

@@ -1,72 +1,10 @@
declare var System: any;
// dummy modules
declare module 'app/core/config' {
var config: any;
export default config;
}
declare module 'lodash' {
var lodash: any;
export default lodash;
}
declare module 'moment' {
var moment: any;
export default moment;
}
declare module 'angular' {
var angular: any;
export default angular;
}
declare module 'jquery' {
var jquery: any;
export default jquery;
}
declare module 'app/core/utils/kbn' {
var kbn: any;
export default kbn;
}
declare module 'app/core/store' {
var store: any;
export default store;
}
declare module 'tether' {
var config: any;
export default config;
}
declare module 'tether-drop' {
var config: any;
export default config;
}
declare module 'eventemitter3' {
var config: any;
export default config;
}
declare module 'mousetrap' {
var config: any;
export default config;
}
declare module 'remarkable' {
var config: any;
export default config;
}
declare module 'd3' {
var d3: any;
export default d3;
}
declare module 'gridstack' {
var gridstack: any;
export default gridstack;
@@ -76,9 +14,3 @@ declare module 'gemini-scrollbar' {
var d3: any;
export default d3;
}
declare module 'ace' {
var ace: any;
export default ace;
}

View File

@@ -37,13 +37,13 @@
<i class="fa fa-minus error-minus"></i>
<div class="error-column error-space-between">
<div class="error-row error-space-between">
<p>Chance your are one the page your'e looking for.</p>
<p>Chances you are on the page you are looking for.</p>
<p class="left-margin">0%</p>
</div>
<div>
<h3>Sorry for the inconvenience</h3>
<p>Please go back to your <a href="#" class="error-link">home dashboard</a> and try again.</p>
<p>If the error persists, seek help att the <a href="#" class="error-link">community site</a>.</p>
<p>If the error persists, seek help on the <a href="#" class="error-link">community site</a>.</p>
</div>
</div>
</div>

View File

@@ -1,47 +0,0 @@
<div class="tabbed-view-header">
<h2 class="tabbed-view-title">
Row settings
</h2>
<button class="tabbed-view-close-btn" ng-click="dismiss();">
<i class="fa fa-remove"></i>
</button>
</div>
<div class="tabbed-view-body">
<div class="row">
<div class="col-md-8">
<div class="page-heading">
<h5>Row details</h5>
</div>
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-6">Title</span>
<input type="text" class="gf-form-input max-width-14" ng-model='row.title'></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-6">Height</span>
<input type="text" class="gf-form-input max-width-8" ng-model='row.height'></input>
<editor-checkbox text="Show Title" model="row.showTitle"></editor-checkbox>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="page-heading">
<h5>Templating options</h5>
</div>
<div class="gf-form-group">
<div class="gf-form">
<span class="gf-form-label">Repeat Row</span>
<div class="gf-form-select-wrapper max-width-10">
<select class="gf-form-input" ng-model="row.repeat" ng-options="f.name as f.name for f in dashboard.templating.list">
<option value=""></option>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -3,18 +3,16 @@
<div class="editor-row" style="padding: 2rem 0">
<div class="section">
<h5>Prefix matching</h5>
<div class="editor-option">
<editor-checkbox text="Enable" model="ctrl.annotation.prefixMatching"></editor-checkbox>
</div>
<div class="editor-option" ng-if="ctrl.annotation.prefixMatching">
<label class="small">Action</label>
<input type="text" class="input-small" ng-model='ctrl.annotation.actionPrefix'></input>
</div>
<div class="editor-option" ng-if="ctrl.annotation.prefixMatching">
<label class="small">Alarm Name</label>
<input type="text" class="input-small" ng-model='ctrl.annotation.alarmNamePrefix'></input>
<div class="gf-form-inline">
<gf-form-switch class="gf-form" label="Enable" checked="ctrl.annotation.prefixMatching" switch-class="max-width-6"></gf-form-switch>
<div class="gf-form" ng-if="ctrl.annotation.prefixMatching">
<span class="gf-form-label">Action</span>
<input type="text" class="gf-form-input" ng-model='ctrl.annotation.actionPrefix'></input>
</div>
<div class="gf-form" ng-if="ctrl.annotation.prefixMatching">
<span class="gf-form-label">Alarm Name</span>
<input type="text" class="gf-form-input" ng-model='ctrl.annotation.alarmNamePrefix'></input>
</div>
</div>
</div>
</div>

View File

@@ -83,7 +83,6 @@ export class ElasticDatasource {
var timeField = annotation.timeField || '@timestamp';
var queryString = annotation.query || '*';
var tagsField = annotation.tagsField || 'tags';
var titleField = annotation.titleField || 'desc';
var textField = annotation.textField || null;
var range = {};
@@ -146,9 +145,6 @@ export class ElasticDatasource {
}
}
if (_.isArray(fieldValue)) {
fieldValue = fieldValue.join(', ');
}
return fieldValue;
};
@@ -165,16 +161,27 @@ export class ElasticDatasource {
var event = {
annotation: annotation,
time: moment.utc(time).valueOf(),
title: getFieldFromSource(source, titleField),
text: getFieldFromSource(source, textField),
tags: getFieldFromSource(source, tagsField),
text: getFieldFromSource(source, textField)
};
// legacy support for title tield
if (annotation.titleField) {
const title = getFieldFromSource(source, annotation.titleField);
if (title) {
event.text = title + '\n' + event.text;
}
}
if (typeof event.tags === 'string') {
event.tags = event.tags.split(',');
}
list.push(event);
}
return list;
});
};
}
testDatasource() {
this.timeSrv.setTime({ from: 'now-1m', to: 'now' }, true);
@@ -242,7 +249,7 @@ export class ElasticDatasource {
return this.post('_msearch', payload).then(function(res) {
return new ElasticResponse(sentTargets, res).getTimeSeries();
});
};
}
getFields(query) {
return this.get('/_mapping').then(function(result) {

View File

@@ -1,5 +1,3 @@
///<reference path="../../../headers/common.d.ts" />
import moment from 'moment';
const intervalMap = {
@@ -20,7 +18,7 @@ export class IndexPattern {
} else {
return this.pattern;
}
};
}
getIndexList(from, to) {
if (!this.interval) {
@@ -29,10 +27,10 @@ export class IndexPattern {
var intervalInfo = intervalMap[this.interval];
var start = moment(from).utc().startOf(intervalInfo.startOf);
var end = moment(to).utc().startOf(intervalInfo.startOf).valueOf();
var endEpoch = moment(to).utc().startOf(intervalInfo.startOf).valueOf();
var indexList = [];
while (start <= end) {
while (start.valueOf() <= endEpoch) {
indexList.push(start.format(this.pattern));
start.add(1, intervalInfo.amount);
}

View File

@@ -15,24 +15,20 @@
<h6>Field mappings</h6>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-10">Time</span>
<input type="text" class="gf-form-input max-width-16" ng-model='ctrl.annotation.timeField' placeholder="@timestamp"></input>
<span class="gf-form-label">Time</span>
<input type="text" class="gf-form-input max-width-14" ng-model='ctrl.annotation.timeField' placeholder="@timestamp"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Title</span>
<span class="gf-form-label">Text</span>
<input type="text" class="gf-form-input max-width-14" ng-model='ctrl.annotation.textField' placeholder=""></input>
</div>
<div class="gf-form">
<span class="gf-form-label">Tags</span>
<input type="text" class="gf-form-input max-width-10" ng-model='ctrl.annotation.tagsField' placeholder="tags"></input>
</div>
<div class="gf-form" ng-show="ctrl.annotation.titleField">
<span class="gf-form-label">Title <em class="muted">(depricated)</em></span>
<input type="text" class="gf-form-input max-width-16" ng-model='ctrl.annotation.titleField' placeholder="desc"></input>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-10">Tags</span>
<input type="text" class="gf-form-input max-width-16" ng-model='ctrl.annotation.tagsField' placeholder="tags"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-10">Text</span>
<input type="text" class="gf-form-input max-width-16" ng-model='ctrl.annotation.textField' placeholder=""></input>
</div>
</div>
</div>

View File

@@ -167,7 +167,7 @@ export class ElasticQueryBuilder {
break;
}
}
};
}
build(target, adhocFilters?, queryString?) {
// make sure query has defaults;

View File

@@ -188,4 +188,4 @@ export function describeOrderBy(orderBy, target) {
} else {
return "metric not found";
}
};
}

View File

@@ -1,5 +1,3 @@
///<reference path="../../../headers/common.d.ts" />
import _ from 'lodash';
class GrafanaDatasource {
@@ -8,42 +6,62 @@ class GrafanaDatasource {
constructor(private backendSrv, private $q) {}
query(options) {
return this.backendSrv.get('/api/tsdb/testdata/random-walk', {
from: options.range.from.valueOf(),
to: options.range.to.valueOf(),
intervalMs: options.intervalMs,
maxDataPoints: options.maxDataPoints,
}).then(res => {
var data = [];
return this.backendSrv
.get('/api/tsdb/testdata/random-walk', {
from: options.range.from.valueOf(),
to: options.range.to.valueOf(),
intervalMs: options.intervalMs,
maxDataPoints: options.maxDataPoints,
})
.then(res => {
var data = [];
if (res.results) {
_.forEach(res.results, queryRes => {
for (let series of queryRes.series) {
data.push({
target: series.name,
datapoints: series.points
});
}
});
}
if (res.results) {
_.forEach(res.results, queryRes => {
for (let series of queryRes.series) {
data.push({
target: series.name,
datapoints: series.points,
});
}
});
}
return {data: data};
});
return {data: data};
});
}
metricFindQuery(options) {
return this.$q.when({data: []});
}
annotationQuery(options) {
return this.backendSrv.get('/api/annotations', {
const params: any = {
from: options.range.from.valueOf(),
to: options.range.to.valueOf(),
limit: options.limit,
type: options.type,
});
}
limit: options.annotation.limit,
tags: options.annotation.tags,
};
if (options.annotation.type === 'dashboard') {
// if no dashboard id yet return
if (!options.dashboard.id) {
return this.$q.when([]);
}
// filter by dashboard id
params.dashboardId = options.dashboard.id;
// remove tags filter if any
delete params.tags;
} else {
// require at least one tag
if (!_.isArray(options.annotation.tags) || options.annotation.tags.length === 0) {
return this.$q.when([]);
}
}
return this.backendSrv.get('/api/annotations', params);
}
}
export {GrafanaDatasource};

View File

@@ -1,5 +1,3 @@
///<reference path="../../../headers/common.d.ts" />
import {GrafanaDatasource} from './datasource';
import {QueryCtrl} from 'app/plugins/sdk';
@@ -10,19 +8,22 @@ class GrafanaQueryCtrl extends QueryCtrl {
class GrafanaAnnotationsQueryCtrl {
annotation: any;
types = [
{text: 'Dashboard', value: 'dashboard'},
{text: 'Tags', value: 'tags'}
];
constructor() {
this.annotation.type = this.annotation.type || 'alert';
this.annotation.type = this.annotation.type || 'tags';
this.annotation.limit = this.annotation.limit || 100;
}
static templateUrl = 'partials/annotations.editor.html';
}
export {
GrafanaDatasource,
GrafanaDatasource as Datasource,
GrafanaQueryCtrl as QueryCtrl,
GrafanaAnnotationsQueryCtrl as AnnotationsQueryCtrl,
};

View File

@@ -2,14 +2,29 @@
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-7">Type</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.annotation.type" ng-options="f.value as f.text for f in [{text: 'Event', value: 'event'}, {text: 'Alert', value: 'alert'}]">
<span class="gf-form-label width-8">
Filter by
<info-popover mode="right-normal">
<ul>
<li>Dashboard: This will fetch annotation and alert state changes for whole dashboard and show them only on the event's originating panel.</li>
<li>All: This will fetch any annotation events that match the tags filter.</li>
</ul>
</info-popover>
</span>
<div class="gf-form-select-wrapper width-8">
<select class="gf-form-input" ng-model="ctrl.annotation.type" ng-options="f.value as f.text for f in ctrl.types">
</select>
</div>
</div>
<div class="gf-form" ng-if="ctrl.annotation.type === 'tags'">
<span class="gf-form-label">Tags</span>
<bootstrap-tagsinput ng-model="ctrl.annotation.tags" tagclass="label label-tag" placeholder="add tags">
</bootstrap-tagsinput>
</div>
<div class="gf-form">
<span class="gf-form-label width-7">Max limit</span>
<span class="gf-form-label">Max limit</span>
<div class="gf-form-select-wrapper">
<select class="gf-form-input" ng-model="ctrl.annotation.limit" ng-options="f for f in [10,50,100,200,300,500,1000,2000]">
</select>
@@ -17,3 +32,5 @@
</div>
</div>
</div>

View File

@@ -68,6 +68,18 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
return result;
};
this.parseTags = function(tagString) {
let tags = [];
tags = tagString.split(',');
if (tags.length === 1) {
tags = tagString.split(' ');
if (tags[0] === '') {
tags = [];
}
}
return tags;
};
this.annotationQuery = function(options) {
// Graphite metric as annotation
if (options.annotation.target) {
@@ -102,19 +114,25 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
} else {
// Graphite event as annotation
var tags = templateSrv.replace(options.annotation.tags);
return this.events({range: options.rangeRaw, tags: tags}).then(function(results) {
return this.events({range: options.rangeRaw, tags: tags}).then(results => {
var list = [];
for (var i = 0; i < results.data.length; i++) {
var e = results.data[i];
var tags = e.tags;
if (_.isString(e.tags)) {
tags = this.parseTags(e.tags);
}
list.push({
annotation: options.annotation,
time: e.when * 1000,
title: e.what,
tags: e.tags,
tags: tags,
text: e.data
});
}
return list;
});
}
@@ -126,7 +144,6 @@ export function GraphiteDatasource(instanceSettings, $q, backendSrv, templateSrv
if (options.tags) {
tags = '&tags=' + options.tags;
}
return this.doGraphiteRequest({
method: 'GET',
url: '/events/get_data?from=' + this.translateTime(options.range.from, false) +

View File

@@ -2,10 +2,12 @@
import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common';
import helpers from 'test/specs/helpers';
import {GraphiteDatasource} from "../datasource";
import moment from 'moment';
import _ from 'lodash';
describe('graphiteDatasource', function() {
var ctx = new helpers.ServiceTestContext();
var instanceSettings: any = {url: [''], name: 'graphiteProd', jsonData: {}};
let ctx = new helpers.ServiceTestContext();
let instanceSettings: any = {url: [''], name: 'graphiteProd', jsonData: {}};
beforeEach(angularMocks.module('grafana.core'));
beforeEach(angularMocks.module('grafana.services'));
@@ -22,16 +24,16 @@ describe('graphiteDatasource', function() {
ctx.ds = ctx.$injector.instantiate(GraphiteDatasource, {instanceSettings: instanceSettings});
});
describe('When querying influxdb with one target using query editor target spec', function() {
var query = {
describe('When querying graphite with one target using query editor target spec', function() {
let query = {
panelId: 3,
rangeRaw: { from: 'now-1h', to: 'now' },
targets: [{ target: 'prod1.count' }, {target: 'prod2.count'}],
maxDataPoints: 500,
};
var results;
var requestOptions;
let results;
let requestOptions;
beforeEach(function() {
ctx.backendSrv.datasourceRequest = function(options) {
@@ -52,7 +54,7 @@ describe('graphiteDatasource', function() {
});
it('should query correctly', function() {
var params = requestOptions.data.split('&');
let params = requestOptions.data.split('&');
expect(params).to.contain('target=prod1.count');
expect(params).to.contain('target=prod2.count');
expect(params).to.contain('from=-1h');
@@ -60,7 +62,7 @@ describe('graphiteDatasource', function() {
});
it('should exclude undefined params', function() {
var params = requestOptions.data.split('&');
let params = requestOptions.data.split('&');
expect(params).to.not.contain('cacheTimeout=undefined');
});
@@ -75,58 +77,130 @@ describe('graphiteDatasource', function() {
});
describe('when fetching Graphite Events as annotations', () => {
let results;
const options = {
annotation: {
tags: 'tag1'
},
range: {
from: moment(1432288354),
to: moment(1432288401)
},
rangeRaw: {from: "now-24h", to: "now"}
};
describe('and tags are returned as string', () => {
const response = {
data: [
{
when: 1507222850,
tags: 'tag1 tag2',
data: 'some text',
id: 2,
what: 'Event - deploy'
}
]};
beforeEach(() => {
ctx.backendSrv.datasourceRequest = function(options) {
return ctx.$q.when(response);
};
ctx.ds.annotationQuery(options).then(function(data) { results = data; });
ctx.$rootScope.$apply();
});
it('should parse the tags string into an array', () => {
expect(_.isArray(results[0].tags)).to.eql(true);
expect(results[0].tags.length).to.eql(2);
expect(results[0].tags[0]).to.eql('tag1');
expect(results[0].tags[1]).to.eql('tag2');
});
});
describe('and tags are returned as an array', () => {
const response = {
data: [
{
when: 1507222850,
tags: ['tag1', 'tag2'],
data: 'some text',
id: 2,
what: 'Event - deploy'
}
]};
beforeEach(() => {
ctx.backendSrv.datasourceRequest = function(options) {
return ctx.$q.when(response);
};
ctx.ds.annotationQuery(options).then(function(data) { results = data; });
ctx.$rootScope.$apply();
});
it('should parse the tags string into an array', () => {
expect(_.isArray(results[0].tags)).to.eql(true);
expect(results[0].tags.length).to.eql(2);
expect(results[0].tags[0]).to.eql('tag1');
expect(results[0].tags[1]).to.eql('tag2');
});
});
});
describe('building graphite params', function() {
it('should return empty array if no targets', function() {
var results = ctx.ds.buildGraphiteParams({
let results = ctx.ds.buildGraphiteParams({
targets: [{}]
});
expect(results.length).to.be(0);
});
it('should uri escape targets', function() {
var results = ctx.ds.buildGraphiteParams({
let results = ctx.ds.buildGraphiteParams({
targets: [{target: 'prod1.{test,test2}'}, {target: 'prod2.count'}]
});
expect(results).to.contain('target=prod1.%7Btest%2Ctest2%7D');
});
it('should replace target placeholder', function() {
var results = ctx.ds.buildGraphiteParams({
let results = ctx.ds.buildGraphiteParams({
targets: [{target: 'series1'}, {target: 'series2'}, {target: 'asPercent(#A,#B)'}]
});
expect(results[2]).to.be('target=asPercent(series1%2Cseries2)');
});
it('should replace target placeholder for hidden series', function() {
var results = ctx.ds.buildGraphiteParams({
let results = ctx.ds.buildGraphiteParams({
targets: [{target: 'series1', hide: true}, {target: 'sumSeries(#A)', hide: true}, {target: 'asPercent(#A,#B)'}]
});
expect(results[0]).to.be('target=' + encodeURIComponent('asPercent(series1,sumSeries(series1))'));
});
it('should replace target placeholder when nesting query references', function() {
var results = ctx.ds.buildGraphiteParams({
let results = ctx.ds.buildGraphiteParams({
targets: [{target: 'series1'}, {target: 'sumSeries(#A)'}, {target: 'asPercent(#A,#B)'}]
});
expect(results[2]).to.be('target=' + encodeURIComponent("asPercent(series1,sumSeries(series1))"));
});
it('should fix wrong minute interval parameters', function() {
var results = ctx.ds.buildGraphiteParams({
let results = ctx.ds.buildGraphiteParams({
targets: [{target: "summarize(prod.25m.count, '25m', 'sum')" }]
});
expect(results[0]).to.be('target=' + encodeURIComponent("summarize(prod.25m.count, '25min', 'sum')"));
});
it('should fix wrong month interval parameters', function() {
var results = ctx.ds.buildGraphiteParams({
let results = ctx.ds.buildGraphiteParams({
targets: [{target: "summarize(prod.5M.count, '5M', 'sum')" }]
});
expect(results[0]).to.be('target=' + encodeURIComponent("summarize(prod.5M.count, '5mon', 'sum')"));
});
it('should ignore empty targets', function() {
var results = ctx.ds.buildGraphiteParams({
let results = ctx.ds.buildGraphiteParams({
targets: [{target: 'series1'}, {target: ''}]
});
expect(results.length).to.be(2);

View File

@@ -1,5 +1,3 @@
///<reference path="../../../headers/common.d.ts" />
import _ from 'lodash';
import * as dateMath from 'app/core/utils/datemath';

View File

@@ -9,18 +9,16 @@
<div class="gf-form-group">
<div class="gf-form-inline">
<div class="gf-form">
<span class="gf-form-label width-4">Title</span>
<input type="text" class="gf-form-input max-width-10" ng-model='ctrl.annotation.titleColumn' placeholder=""></input>
<span class="gf-form-label width-4">Text</span>
<input type="text" class="gf-form-input max-width-10" ng-model='ctrl.annotation.textColumn' placeholder=""></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-4">Tags</span>
<input type="text" class="gf-form-input max-width-10" ng-model='ctrl.annotation.tagsColumn' placeholder=""></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-4">Text</span>
<input type="text" class="gf-form-input max-width-10" ng-model='ctrl.annotation.textColumn' placeholder=""></input>
<div class="gf-form" ng-show="ctrl.annotation.titleColumn">
<span class="gf-form-label width-4">Title <em class="muted">(depricated)</em></span>
<input type="text" class="gf-form-input max-width-10" ng-model='ctrl.annotation.titleColumn' placeholder=""></input>
</div>
</div>
</div>

View File

@@ -5,5 +5,9 @@
"builtIn": true,
"mixed": true,
"metrics": true
"metrics": true,
"queryOptions": {
"minInterval": true
}
}

View File

@@ -9,7 +9,6 @@ class MysqlConfigCtrl {
const defaultQuery = `SELECT
UNIX_TIMESTAMP(<time_column>) as time_sec,
<title_column> as title,
<text_column> as text,
<tags_column> as tags
FROM <table name>

View File

@@ -106,7 +106,6 @@ export default class ResponseParser {
const table = data.data.results[options.annotation.name].tables[0];
let timeColumnIndex = -1;
let titleColumnIndex = -1;
let textColumnIndex = -1;
let tagsColumnIndex = -1;
@@ -114,7 +113,7 @@ export default class ResponseParser {
if (table.columns[i].text === 'time_sec') {
timeColumnIndex = i;
} else if (table.columns[i].text === 'title') {
titleColumnIndex = i;
return this.$q.reject({message: 'Title return column on annotations are depricated, return only a column named text'});
} else if (table.columns[i].text === 'text') {
textColumnIndex = i;
} else if (table.columns[i].text === 'tags') {
@@ -132,7 +131,6 @@ export default class ResponseParser {
list.push({
annotation: options.annotation,
time: Math.floor(row[timeColumnIndex]) * 1000,
title: row[titleColumnIndex],
text: row[textColumnIndex],
tags: row[tagsColumnIndex] ? row[tagsColumnIndex].trim().split(/\s*,\s*/) : []
});

View File

@@ -27,7 +27,7 @@ describe('MySQLDatasource', function() {
const options = {
annotation: {
name: annotationName,
rawQuery: 'select time_sec, title, text, tags from table;'
rawQuery: 'select time_sec, text, tags from table;'
},
range: {
from: moment(1432288354),
@@ -41,11 +41,11 @@ describe('MySQLDatasource', function() {
refId: annotationName,
tables: [
{
columns: [{text: 'time_sec'}, {text: 'title'}, {text: 'text'}, {text: 'tags'}],
columns: [{text: 'time_sec'}, {text: 'text'}, {text: 'tags'}],
rows: [
[1432288355, 'aTitle', 'some text', 'TagA,TagB'],
[1432288390, 'aTitle2', 'some text2', ' TagB , TagC'],
[1432288400, 'aTitle3', 'some text3']
[1432288355, 'some text', 'TagA,TagB'],
[1432288390, 'some text2', ' TagB , TagC'],
[1432288400, 'some text3']
]
}
]
@@ -64,7 +64,6 @@ describe('MySQLDatasource', function() {
it('should return annotation list', function() {
expect(results.length).to.be(3);
expect(results[0].title).to.be('aTitle');
expect(results[0].text).to.be('some text');
expect(results[0].tags[0]).to.be('TagA');
expect(results[0].tags[1]).to.be('TagB');

View File

@@ -91,9 +91,8 @@ function (angular, _, dateMath) {
if(annotationObject) {
_.each(annotationObject, function(annotation) {
var event = {
title: annotation.description,
text: annotation.description,
time: Math.floor(annotation.startTime) * 1000,
text: annotation.notes,
annotation: options.annotation
};

View File

@@ -3,8 +3,5 @@
<span class="gf-form-label width-13">OpenTSDB metrics query</span>
<input type="text" class="gf-form-input" ng-model='ctrl.annotation.target' placeholder="events.eventname"></input>
</div>
<div class="gf-form">
<span class="gf-form-label width-13">Show Global Annotations?</span>
<editor-checkbox text="" model="ctrl.annotation.isGlobal"></editor-checkbox>
</div>
<gf-form-switch class="gf-form" label="Show Global Annotations?" checked="ctrl.annotation.isGlobal" switch-class="max-width-6" label-class="width-13"></gf-form-switch>
</div>

View File

@@ -1,30 +1,76 @@
///<reference path="../../../headers/common.d.ts" />
import {PrometheusDatasource} from "./datasource";
import _ from 'lodash';
export class PromCompleter {
labelQueryCache: any;
labelNameCache: any;
labelValueCache: any;
identifierRegexps = [/[\[\]a-zA-Z_0-9=]/];
constructor(private datasource: PrometheusDatasource) {
this.labelQueryCache = {};
this.labelNameCache = {};
this.labelValueCache = {};
}
getCompletions(editor, session, pos, prefix, callback) {
let token = session.getTokenAt(pos.row, pos.column);
var metricName;
switch (token.type) {
case 'label.name':
callback(null, ['instance', 'job'].map(function (key) {
return {
caption: key,
value: key,
meta: "label name",
score: Number.MAX_VALUE
};
}));
return;
case 'label.value':
callback(null, []);
return;
case 'entity.name.tag':
metricName = this.findMetricName(session, pos.row, pos.column);
if (!metricName) {
callback(null, this.transformToCompletions(['__name__', 'instance', 'job'], 'label name'));
return;
}
if (this.labelNameCache[metricName]) {
callback(null, this.labelNameCache[metricName]);
return;
}
return this.getLabelNameAndValueForMetric(metricName).then(result => {
var labelNames = this.transformToCompletions(
_.uniq(_.flatten(result.map(r => {
return Object.keys(r.metric);
})))
, 'label name');
this.labelNameCache[metricName] = labelNames;
callback(null, labelNames);
});
case 'string.quoted':
metricName = this.findMetricName(session, pos.row, pos.column);
if (!metricName) {
callback(null, []);
return;
}
var labelNameToken = this.findToken(session, pos.row, pos.column, 'entity.name.tag', null, 'paren.lparen');
if (!labelNameToken) {
callback(null, []);
return;
}
var labelName = labelNameToken.value;
if (this.labelValueCache[metricName] && this.labelValueCache[metricName][labelName]) {
callback(null, this.labelValueCache[metricName][labelName]);
return;
}
return this.getLabelNameAndValueForMetric(metricName).then(result => {
var labelValues = this.transformToCompletions(
_.uniq(result.map(r => {
return r.metric[labelName];
}))
, 'label value');
this.labelValueCache[metricName] = this.labelValueCache[metricName] || {};
this.labelValueCache[metricName][labelName] = labelValues;
callback(null, labelValues);
});
}
if (prefix === '[') {
@@ -56,4 +102,87 @@ export class PromCompleter {
});
}
getLabelNameAndValueForMetric(metricName) {
if (this.labelQueryCache[metricName]) {
return Promise.resolve(this.labelQueryCache[metricName]);
}
var op = '=~';
if (/[a-zA-Z_:][a-zA-Z0-9_:]*/.test(metricName)) {
op = '=';
}
var expr = '{__name__' + op + '"' + metricName + '"}';
return this.datasource.performInstantQuery({ expr: expr }, new Date().getTime() / 1000).then(response => {
this.labelQueryCache[metricName] = response.data.data.result;
return response.data.data.result;
});
}
transformToCompletions(words, meta) {
return words.map(name => {
return {
caption: name,
value: name,
meta: meta,
score: Number.MAX_VALUE
};
});
}
findMetricName(session, row, column) {
var metricName = '';
var tokens;
var nameLabelNameToken = this.findToken(session, row, column, 'entity.name.tag', '__name__', 'paren.lparen');
if (nameLabelNameToken) {
tokens = session.getTokens(nameLabelNameToken.row);
var nameLabelValueToken = tokens[nameLabelNameToken.index + 2];
if (nameLabelValueToken && nameLabelValueToken.type === 'string.quoted') {
metricName = nameLabelValueToken.value.slice(1, -1); // cut begin/end quotation
}
} else {
var metricNameToken = this.findToken(session, row, column, 'identifier', null, null);
if (metricNameToken) {
tokens = session.getTokens(metricNameToken.row);
if (tokens[metricNameToken.index + 1].type === 'paren.lparen') {
metricName = metricNameToken.value;
}
}
}
return metricName;
}
findToken(session, row, column, target, value, guard) {
var tokens, idx;
for (var r = row; r >= 0; r--) {
tokens = session.getTokens(r);
if (r === row) { // current row
var c = 0;
for (idx = 0; idx < tokens.length; idx++) {
c += tokens[idx].value.length;
if (c >= column) {
break;
}
}
} else {
idx = tokens.length - 1;
}
for (; idx >= 0; idx--) {
if (tokens[idx].type === guard) {
return null;
}
if (tokens[idx].type === target
&& (!value || tokens[idx].value === value)) {
tokens[idx].row = r;
tokens[idx].index = idx;
return tokens[idx];
}
}
}
return null;
}
}

View File

@@ -1,15 +1,12 @@
///<reference path="../../../headers/common.d.ts" />
import _ from 'lodash';
import moment from 'moment';
import kbn from 'app/core/utils/kbn';
import * as dateMath from 'app/core/utils/datemath';
import PrometheusMetricFindQuery from './metric_find_query';
import TableModel from 'app/core/table_model';
var durationSplitRegexp = /(\d+)(ms|s|m|h|d|w|M|y)/;
function prometheusSpecialRegexEscape(value) {
return value.replace(/[\\^$*+?.()|[\]{}]/g, '\\\\$&');
}
@@ -83,6 +80,7 @@ export class PrometheusDatasource {
var self = this;
var start = this.getPrometheusTime(options.range.from, false);
var end = this.getPrometheusTime(options.range.to, true);
var range = Math.ceil(end - start);
var queries = [];
var activeTargets = [];
@@ -95,18 +93,7 @@ export class PrometheusDatasource {
}
activeTargets.push(target);
var query: any = {};
query.expr = this.templateSrv.replace(target.expr, options.scopedVars, self.interpolateQueryExpr);
query.requestId = options.panelId + target.refId;
query.instant = target.instant;
var interval = this.templateSrv.replace(target.interval, options.scopedVars) || options.interval;
var intervalFactor = target.intervalFactor || 1;
target.step = query.step = this.calculateInterval(interval, intervalFactor);
var range = Math.ceil(end - start);
target.step = query.step = this.adjustStep(query.step, this.intervalSeconds(options.interval), range);
queries.push(query);
queries.push(this.createQuery(target, options, range));
}
// No valid targets, return the empty result to save a round trip.
@@ -147,13 +134,41 @@ export class PrometheusDatasource {
});
}
adjustStep(step, autoStep, range) {
// Prometheus drop query if range/step > 11000
// calibrate step if it is too big
if (step !== 0 && range / step > 11000) {
step = Math.ceil(range / 11000);
createQuery(target, options, range) {
var query: any = {};
query.instant = target.instant;
var interval = kbn.interval_to_seconds(options.interval);
// Minimum interval ("Min step"), if specified for the query. or same as interval otherwise
var minInterval = kbn.interval_to_seconds(this.templateSrv.replace(target.interval, options.scopedVars) || options.interval);
var intervalFactor = target.intervalFactor || 1;
// Adjust the interval to take into account any specified minimum and interval factor plus Prometheus limits
var adjustedInterval = this.adjustInterval(interval, minInterval, range, intervalFactor);
var scopedVars = options.scopedVars;
// If the interval was adjusted, make a shallow copy of scopedVars with updated interval vars
if (interval !== adjustedInterval) {
interval = adjustedInterval;
scopedVars = Object.assign({}, options.scopedVars, {
"__interval": {text: interval + "s", value: interval + "s"},
"__interval_ms": {text: interval * 1000, value: interval * 1000},
});
}
return Math.max(step, autoStep);
target.step = query.step = interval;
// Only replace vars in expression after having (possibly) updated interval vars
query.expr = this.templateSrv.replace(target.expr, scopedVars, this.interpolateQueryExpr);
query.requestId = options.panelId + target.refId;
return query;
}
adjustInterval(interval, minInterval, range, intervalFactor) {
// Prometheus will drop queries that might return more than 11000 data points.
// Calibrate interval if it is too small.
if (interval !== 0 && range / intervalFactor / interval > 11000) {
interval = Math.ceil(range / intervalFactor / 11000);
}
return Math.max(interval * intervalFactor, minInterval);
}
performTimeSeriesQuery(query, start, end) {
@@ -218,7 +233,7 @@ export class PrometheusDatasource {
var end = this.getPrometheusTime(options.range.to, true);
var query = {
expr: interpolated,
step: this.adjustStep(kbn.interval_to_seconds(step), 0, Math.ceil(end - start)) + 's'
step: this.adjustInterval(kbn.interval_to_seconds(step), 0, Math.ceil(end - start), 1) + 's'
};
var self = this;
@@ -257,21 +272,6 @@ export class PrometheusDatasource {
});
}
calculateInterval(interval, intervalFactor) {
return Math.ceil(this.intervalSeconds(interval) * intervalFactor);
}
intervalSeconds(interval) {
var m = interval.match(durationSplitRegexp);
var dur = moment.duration(parseInt(m[1]), m[2]);
var sec = dur.asSeconds();
if (sec < 1) {
sec = 1;
}
return sec;
}
transformMetricData(md, options, start, end) {
var dps = [],
metricLabel = null;

View File

@@ -65,13 +65,13 @@ var PrometheusHighlightRules = function() {
regex : "\\s+"
} ],
"start-label-matcher" : [ {
token : "keyword",
token : "entity.name.tag",
regex : '[a-zA-Z_][a-zA-Z0-9_]*'
}, {
token : "keyword.operator",
regex : '=~|=|!~|!='
}, {
token : "string",
token : "string.quoted",
regex : '"[^"]*"|\'[^\']*\''
}, {
token : "punctuation.operator",
@@ -401,7 +401,7 @@ var PrometheusCompletions = function() {};
(function() {
this.getCompletions = function(state, session, pos, prefix, callback) {
var token = session.getTokenAt(pos.row, pos.column);
if (token.type === 'label.name' || token.type === 'label.value') {
if (token.type === 'entity.name.tag' || token.type === 'string.quoted') {
return callback(null, []);
}

View File

@@ -4,24 +4,115 @@ import {PromCompleter} from '../completer';
import {PrometheusDatasource} from '../datasource';
describe('Prometheus editor completer', function() {
function getSessionStub(data) {
return {
getTokenAt: sinon.stub().returns(data.currentToken),
getTokens: sinon.stub().returns(data.tokens),
getLine: sinon.stub().returns(data.line),
};
}
let editor = {};
let session = {
getTokenAt: sinon.stub().returns({}),
getLine: sinon.stub().returns(""),
let datasourceStub = <PrometheusDatasource>{
performInstantQuery: sinon
.stub()
.withArgs({expr: '{__name__="node_cpu"'})
.returns(
Promise.resolve({
data: {
data: {
result: [
{
metric: {
job: 'node',
instance: 'localhost:9100',
},
},
],
},
},
}),
),
performSuggestQuery: sinon
.stub()
.withArgs('node', true)
.returns(Promise.resolve(['node_cpu'])),
};
let datasourceStub = <PrometheusDatasource>{};
let completer = new PromCompleter(datasourceStub);
describe("When inside brackets", () => {
it("Should return range vectors", () => {
completer.getCompletions(editor, session, 10, "[", (s, res) => {
describe('When inside brackets', () => {
it('Should return range vectors', () => {
const session = getSessionStub({
currentToken: {},
tokens: [],
line: '',
});
completer.getCompletions(editor, session, {row: 0, column: 10}, '[', (s, res) => {
expect(res[0]).to.eql({caption: '1s', value: '[1s', meta: 'range vector'});
});
});
});
describe('When inside label matcher, and located at label name', () => {
it('Should return label name list', () => {
const session = getSessionStub({
currentToken: {type: 'entity.name.tag', value: 'j', index: 2, start: 9},
tokens: [
{type: 'identifier', value: 'node_cpu'},
{type: 'paren.lparen', value: '{'},
{type: 'entity.name.tag', value: 'j', index: 2, start: 9},
{type: 'paren.rparen', value: '}'},
],
line: 'node_cpu{j}',
});
return completer.getCompletions(editor, session, {row: 0, column: 10}, 'j', (s, res) => {
expect(res[0].meta).to.eql('label name');
});
});
});
describe('When inside label matcher, and located at label name with __name__ match', () => {
it('Should return label name list', () => {
const session = getSessionStub({
currentToken: {type: 'entity.name.tag', value: 'j', index: 5, start: 22},
tokens: [
{type: 'paren.lparen', value: '{'},
{type: 'entity.name.tag', value: '__name__'},
{type: 'keyword.operator', value: '=~'},
{type: 'string.quoted', value: '"node_cpu"'},
{type: 'punctuation.operator', value: ','},
{type: 'entity.name.tag', value: 'j', index: 5, start: 22},
{type: 'paren.rparen', value: '}'},
],
line: '{__name__=~"node_cpu",j}',
});
return completer.getCompletions(editor, session, {row: 0, column: 23}, 'j', (s, res) => {
expect(res[0].meta).to.eql('label name');
});
});
});
describe('When inside label matcher, and located at label value', () => {
it('Should return label value list', () => {
const session = getSessionStub({
currentToken: {type: 'string.quoted', value: '"n"', index: 4, start: 13},
tokens: [
{type: 'identifier', value: 'node_cpu'},
{type: 'paren.lparen', value: '{'},
{type: 'entity.name.tag', value: 'job'},
{type: 'keyword.operator', value: '='},
{type: 'string.quoted', value: '"n"', index: 4, start: 13},
{type: 'paren.rparen', value: '}'},
],
line: 'node_cpu{job="n"}',
});
return completer.getCompletions(editor, session, {row: 0, column: 15}, 'n', (s, res) => {
expect(res[0].meta).to.eql('label value');
});
});
});
});

View File

@@ -269,4 +269,311 @@ describe('PrometheusDatasource', function() {
);
});
});
describe('The "step" query parameter', function() {
var response = {
status: "success",
data: {
resultType: "matrix",
result: []
}
};
it('should be min interval when greater than auto interval', function() {
var query = {
// 6 hour range
range: { from: moment(1443438674760), to: moment(1443460274760) },
targets: [{
expr: 'test',
interval: '10s'
}],
interval: '5s'
};
var urlExpected = 'proxied/api/v1/query_range?query=test' +
'&start=1443438675&end=1443460275&step=10';
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
ctx.ds.query(query);
ctx.$httpBackend.verifyNoOutstandingExpectation();
});
it('should be auto interval when greater than min interval', function() {
var query = {
// 6 hour range
range: { from: moment(1443438674760), to: moment(1443460274760) },
targets: [{
expr: 'test',
interval: '5s'
}],
interval: '10s'
};
var urlExpected = 'proxied/api/v1/query_range?query=test' +
'&start=1443438675&end=1443460275&step=10';
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
ctx.ds.query(query);
ctx.$httpBackend.verifyNoOutstandingExpectation();
});
it('should result in querying fewer than 11000 data points', function() {
var query = {
// 6 hour range
range: { from: moment(1443438674760), to: moment(1443460274760) },
targets: [{ expr: 'test' }],
interval: '1s'
};
var urlExpected = 'proxied/api/v1/query_range?query=test' +
'&start=1443438675&end=1443460275&step=2';
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
ctx.ds.query(query);
ctx.$httpBackend.verifyNoOutstandingExpectation();
});
it('should not apply min interval when interval * intervalFactor greater', function() {
var query = {
// 6 hour range
range: { from: moment(1443438674760), to: moment(1443460274760) },
targets: [{
expr: 'test',
interval: '10s',
intervalFactor: 10
}],
interval: '5s'
};
var urlExpected = 'proxied/api/v1/query_range?query=test' +
'&start=1443438675&end=1443460275&step=50';
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
ctx.ds.query(query);
ctx.$httpBackend.verifyNoOutstandingExpectation();
});
it('should apply min interval when interval * intervalFactor smaller', function() {
var query = {
// 6 hour range
range: { from: moment(1443438674760), to: moment(1443460274760) },
targets: [{
expr: 'test',
interval: '15s',
intervalFactor: 2
}],
interval: '5s'
};
var urlExpected = 'proxied/api/v1/query_range?query=test' +
'&start=1443438675&end=1443460275&step=15';
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
ctx.ds.query(query);
ctx.$httpBackend.verifyNoOutstandingExpectation();
});
it('should apply intervalFactor to auto interval when greater', function() {
var query = {
// 6 hour range
range: { from: moment(1443438674760), to: moment(1443460274760) },
targets: [{
expr: 'test',
interval: '5s',
intervalFactor: 10
}],
interval: '10s'
};
var urlExpected = 'proxied/api/v1/query_range?query=test' +
'&start=1443438675&end=1443460275&step=100';
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
ctx.ds.query(query);
ctx.$httpBackend.verifyNoOutstandingExpectation();
});
it('should not not be affected by the 11000 data points limit when large enough', function() {
var query = {
// 1 week range
range: { from: moment(1443438674760), to: moment(1444043474760) },
targets: [{
expr: 'test',
intervalFactor: 10
}],
interval: '10s'
};
var urlExpected = 'proxied/api/v1/query_range?query=test' +
'&start=1443438675&end=1444043475&step=100';
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
ctx.ds.query(query);
ctx.$httpBackend.verifyNoOutstandingExpectation();
});
it('should be determined by the 11000 data points limit when too small', function() {
var query = {
// 1 week range
range: { from: moment(1443438674760), to: moment(1444043474760) },
targets: [{
expr: 'test',
intervalFactor: 10
}],
interval: '5s'
};
var urlExpected = 'proxied/api/v1/query_range?query=test' +
'&start=1443438675&end=1444043475&step=60';
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
ctx.ds.query(query);
ctx.$httpBackend.verifyNoOutstandingExpectation();
});
});
describe('The __interval and __interval_ms template variables', function() {
var response = {
status: "success",
data: {
resultType: "matrix",
result: []
}
};
it('should be unchanged when auto interval is greater than min interval', function() {
var query = {
// 6 hour range
range: { from: moment(1443438674760), to: moment(1443460274760) },
targets: [{
expr: 'rate(test[$__interval])',
interval: '5s'
}],
interval: '10s',
scopedVars: {
"__interval": {text: "10s", value: "10s"},
"__interval_ms": {text: 10 * 1000, value: 10 * 1000},
}
};
var urlExpected = 'proxied/api/v1/query_range?query=' +
encodeURIComponent('rate(test[10s])') +
'&start=1443438675&end=1443460275&step=10';
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
ctx.ds.query(query);
ctx.$httpBackend.verifyNoOutstandingExpectation();
expect(query.scopedVars.__interval.text).to.be("10s");
expect(query.scopedVars.__interval.value).to.be("10s");
expect(query.scopedVars.__interval_ms.text).to.be(10 * 1000);
expect(query.scopedVars.__interval_ms.value).to.be(10 * 1000);
});
it('should be min interval when it is greater than auto interval', function() {
var query = {
// 6 hour range
range: { from: moment(1443438674760), to: moment(1443460274760) },
targets: [{
expr: 'rate(test[$__interval])',
interval: '10s'
}],
interval: '5s',
scopedVars: {
"__interval": {text: "5s", value: "5s"},
"__interval_ms": {text: 5 * 1000, value: 5 * 1000},
}
};
var urlExpected = 'proxied/api/v1/query_range?query=' +
encodeURIComponent('rate(test[10s])') +
'&start=1443438675&end=1443460275&step=10';
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
ctx.ds.query(query);
ctx.$httpBackend.verifyNoOutstandingExpectation();
expect(query.scopedVars.__interval.text).to.be("5s");
expect(query.scopedVars.__interval.value).to.be("5s");
expect(query.scopedVars.__interval_ms.text).to.be(5 * 1000);
expect(query.scopedVars.__interval_ms.value).to.be(5 * 1000);
});
it('should account for intervalFactor', function() {
var query = {
// 6 hour range
range: { from: moment(1443438674760), to: moment(1443460274760) },
targets: [{
expr: 'rate(test[$__interval])',
interval: '5s',
intervalFactor: 10
}],
interval: '10s',
scopedVars: {
"__interval": {text: "10s", value: "10s"},
"__interval_ms": {text: 10 * 1000, value: 10 * 1000},
}
};
var urlExpected = 'proxied/api/v1/query_range?query=' +
encodeURIComponent('rate(test[100s])') +
'&start=1443438675&end=1443460275&step=100';
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
ctx.ds.query(query);
ctx.$httpBackend.verifyNoOutstandingExpectation();
expect(query.scopedVars.__interval.text).to.be("10s");
expect(query.scopedVars.__interval.value).to.be("10s");
expect(query.scopedVars.__interval_ms.text).to.be(10 * 1000);
expect(query.scopedVars.__interval_ms.value).to.be(10 * 1000);
});
it('should be interval * intervalFactor when greater than min interval', function() {
var query = {
// 6 hour range
range: { from: moment(1443438674760), to: moment(1443460274760) },
targets: [{
expr: 'rate(test[$__interval])',
interval: '10s',
intervalFactor: 10
}],
interval: '5s',
scopedVars: {
"__interval": {text: "5s", value: "5s"},
"__interval_ms": {text: 5 * 1000, value: 5 * 1000},
}
};
var urlExpected = 'proxied/api/v1/query_range?query=' +
encodeURIComponent('rate(test[50s])') +
'&start=1443438675&end=1443460275&step=50';
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
ctx.ds.query(query);
ctx.$httpBackend.verifyNoOutstandingExpectation();
expect(query.scopedVars.__interval.text).to.be("5s");
expect(query.scopedVars.__interval.value).to.be("5s");
expect(query.scopedVars.__interval_ms.text).to.be(5 * 1000);
expect(query.scopedVars.__interval_ms.value).to.be(5 * 1000);
});
it('should be min interval when greater than interval * intervalFactor', function() {
var query = {
// 6 hour range
range: { from: moment(1443438674760), to: moment(1443460274760) },
targets: [{
expr: 'rate(test[$__interval])',
interval: '15s',
intervalFactor: 2
}],
interval: '5s',
scopedVars: {
"__interval": {text: "5s", value: "5s"},
"__interval_ms": {text: 5 * 1000, value: 5 * 1000},
}
};
var urlExpected = 'proxied/api/v1/query_range?query=' +
encodeURIComponent('rate(test[15s])') +
'&start=1443438675&end=1443460275&step=15';
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
ctx.ds.query(query);
ctx.$httpBackend.verifyNoOutstandingExpectation();
expect(query.scopedVars.__interval.text).to.be("5s");
expect(query.scopedVars.__interval.value).to.be("5s");
expect(query.scopedVars.__interval_ms.text).to.be(5 * 1000);
expect(query.scopedVars.__interval_ms.value).to.be(5 * 1000);
});
it('should be determined by the 11000 data points limit, accounting for intervalFactor', function() {
var query = {
// 1 week range
range: { from: moment(1443438674760), to: moment(1444043474760) },
targets: [{
expr: 'rate(test[$__interval])',
intervalFactor: 10
}],
interval: '5s',
scopedVars: {
"__interval": {text: "5s", value: "5s"},
"__interval_ms": {text: 5 * 1000, value: 5 * 1000},
}
};
var urlExpected = 'proxied/api/v1/query_range?query=' +
encodeURIComponent('rate(test[60s])') +
'&start=1443438675&end=1444043475&step=60';
ctx.$httpBackend.expect('GET', urlExpected).respond(response);
ctx.ds.query(query);
ctx.$httpBackend.verifyNoOutstandingExpectation();
expect(query.scopedVars.__interval.text).to.be("5s");
expect(query.scopedVars.__interval.value).to.be("5s");
expect(query.scopedVars.__interval_ms.text).to.be(5 * 1000);
expect(query.scopedVars.__interval_ms.value).to.be(5 * 1000);
});
});
});

View File

@@ -33,7 +33,7 @@
<i class="{{al.stateModel.iconClass}}"></i>
</div>
<div class="alert-list-main">
<p class="alert-list-title">{{al.title}}</p>
<p class="alert-list-title">{{al.alertName}}</p>
<div class="alert-list-text">
<span class="alert-list-state {{al.stateModel.stateClass}}">{{al.stateModel.text}}</span>
<span class="alert-list-info alert-list-info-left">{{al.info}}</span>

View File

@@ -22,7 +22,7 @@ import {EventManager} from 'app/features/annotations/all';
import {convertValuesToHistogram, getSeriesValues} from './histogram';
/** @ngInject **/
function graphDirective($rootScope, timeSrv, popoverSrv) {
function graphDirective($rootScope, timeSrv, popoverSrv, contextSrv) {
return {
restrict: 'A',
template: '',
@@ -37,7 +37,7 @@ function graphDirective($rootScope, timeSrv, popoverSrv) {
var legendSideLastValue = null;
var rootScope = scope.$root;
var panelWidth = 0;
var eventManager = new EventManager(ctrl, elem, popoverSrv);
var eventManager = new EventManager(ctrl);
var thresholdManager = new ThresholdManager(ctrl);
var tooltip = new GraphTooltip(elem, dashboard, scope, function() {
return sortedSeries;
@@ -268,6 +268,7 @@ function graphDirective($rootScope, timeSrv, popoverSrv) {
clickable: true,
color: '#c8c8c8',
margin: { left: 0, right: 0 },
labelMarginX: 0,
},
selection: {
mode: "x",
@@ -651,10 +652,10 @@ function graphDirective($rootScope, timeSrv, popoverSrv) {
}
elem.bind("plotselected", function (event, ranges) {
if (ranges.ctrlKey || ranges.metaKey) {
// scope.$apply(() => {
// eventManager.updateTime(ranges.xaxis);
// });
if ((ranges.ctrlKey || ranges.metaKey) && contextSrv.isEditor) {
setTimeout(() => {
eventManager.updateTime(ranges.xaxis);
}, 100);
} else {
scope.$apply(function() {
timeSrv.setTime({
@@ -666,13 +667,13 @@ function graphDirective($rootScope, timeSrv, popoverSrv) {
});
elem.bind("plotclick", function (event, pos, item) {
if (pos.ctrlKey || pos.metaKey || eventManager.event) {
if ((pos.ctrlKey || pos.metaKey) && contextSrv.isEditor) {
// Skip if range selected (added in "plotselected" event handler)
let isRangeSelection = pos.x !== pos.x1;
if (!isRangeSelection) {
// scope.$apply(() => {
// eventManager.updateTime({from: pos.x, to: null});
// });
setTimeout(() => {
eventManager.updateTime({from: pos.x, to: null});
}, 100);
}
}
});

View File

@@ -7,14 +7,18 @@ define([
function ($, _, angular, Drop) {
'use strict';
function createAnnotationToolip(element, event) {
function createAnnotationToolip(element, event, plot) {
var injector = angular.element(document).injector();
var content = document.createElement('div');
content.innerHTML = '<annotation-tooltip event="event"></annotation-tooltip>';
content.innerHTML = '<annotation-tooltip event="event" on-edit="onEdit()"></annotation-tooltip>';
injector.invoke(["$compile", "$rootScope", function($compile, $rootScope) {
var eventManager = plot.getOptions().events.manager;
var tmpScope = $rootScope.$new(true);
tmpScope.event = event;
tmpScope.onEdit = function() {
eventManager.editEvent(event);
};
$compile(content)(tmpScope);
tmpScope.$digest();
@@ -42,6 +46,69 @@ function ($, _, angular, Drop) {
}]);
}
var markerElementToAttachTo = null;
function createEditPopover(element, event, plot) {
var eventManager = plot.getOptions().events.manager;
if (eventManager.editorOpen) {
// update marker element to attach to (needed in case of legend on the right
// when there is a double render pass and the inital marker element is removed)
markerElementToAttachTo = element;
return;
}
// mark as openend
eventManager.editorOpened();
// set marker elment to attache to
markerElementToAttachTo = element;
// wait for element to be attached and positioned
setTimeout(function() {
var injector = angular.element(document).injector();
var content = document.createElement('div');
content.innerHTML = '<event-editor panel-ctrl="panelCtrl" event="event" close="close()"></event-editor>';
injector.invoke(["$compile", "$rootScope", function($compile, $rootScope) {
var scope = $rootScope.$new(true);
var drop;
scope.event = event;
scope.panelCtrl = eventManager.panelCtrl;
scope.close = function() {
drop.close();
};
$compile(content)(scope);
scope.$digest();
drop = new Drop({
target: markerElementToAttachTo[0],
content: content,
position: "bottom center",
classes: 'drop-popover drop-popover--form',
openOn: 'click',
tetherOptions: {
constraints: [{to: 'window', pin: true, attachment: "both"}]
}
});
drop.open();
eventManager.editorOpened();
drop.on('close', function() {
// need timeout here in order call drop.destroy
setTimeout(function() {
eventManager.editorClosed();
scope.$destroy();
drop.destroy();
});
});
}]);
}, 100);
}
/*
* jquery.flot.events
*
@@ -121,11 +188,20 @@ function ($, _, angular, Drop) {
*/
this.setupEvents = function(events) {
var that = this;
var parts = _.partition(events, 'isRegion');
var regions = parts[0];
events = parts[1];
$.each(events, function(index, event) {
var ve = new VisualEvent(event, that._buildDiv(event));
_events.push(ve);
});
$.each(regions, function (index, event) {
var vre = new VisualEvent(event, that._buildRegDiv(event));
_events.push(vre);
});
_events.sort(function(a, b) {
var ao = a.getOptions(), bo = b.getOptions();
if (ao.min > bo.min) { return 1; }
@@ -232,7 +308,10 @@ function ($, _, angular, Drop) {
lineWidth = this._types[eventTypeId].lineWidth;
}
top = o.top + this._plot.height();
var topOffset = xaxis.options.eventSectionHeight || 0;
topOffset = topOffset / 3;
top = o.top + this._plot.height() + topOffset;
left = xaxis.p2c(event.min) + o.left;
var line = $('<div class="events_line flot-temp-elem"></div>').css({
@@ -241,25 +320,27 @@ function ($, _, angular, Drop) {
"left": left + 'px',
"top": 8,
"width": lineWidth + "px",
"height": this._plot.height(),
"height": this._plot.height() + topOffset * 0.8,
"border-left-width": lineWidth + "px",
"border-left-style": lineStyle,
"border-left-color": color
"border-left-color": color,
"color": color
})
.appendTo(container);
if (markerShow) {
var marker = $('<div class="events_marker"></div>').css({
"position": "absolute",
"left": (-markerSize-Math.round(lineWidth/2)) + "px",
"left": (-markerSize - Math.round(lineWidth / 2)) + "px",
"font-size": 0,
"line-height": 0,
"width": 0,
"height": 0,
"border-left": markerSize+"px solid transparent",
"border-right": markerSize+"px solid transparent"
})
.appendTo(line);
});
marker.appendTo(line);
if (this._types[eventTypeId] && this._types[eventTypeId].position && this._types[eventTypeId].position.toUpperCase() === 'BOTTOM') {
marker.css({
@@ -280,9 +361,13 @@ function ($, _, angular, Drop) {
});
var mouseenter = function() {
createAnnotationToolip(marker, $(this).data("event"));
createAnnotationToolip(marker, $(this).data("event"), that._plot);
};
if (event.editModel) {
createEditPopover(marker, event.editModel, that._plot);
}
var mouseleave = function() {
that._plot.clearSelection();
};
@@ -312,6 +397,127 @@ function ($, _, angular, Drop) {
return drawableEvent;
};
/**
* create a DOM element for the given region
*/
this._buildRegDiv = function (event) {
var that = this;
var container = this._plot.getPlaceholder();
var o = this._plot.getPlotOffset();
var axes = this._plot.getAxes();
var xaxis = this._plot.getXAxes()[this._plot.getOptions().events.xaxis - 1];
var yaxis, top, left, lineWidth, regionWidth, lineStyle, color, markerTooltip;
// determine the y axis used
if (axes.yaxis && axes.yaxis.used) { yaxis = axes.yaxis; }
if (axes.yaxis2 && axes.yaxis2.used) { yaxis = axes.yaxis2; }
// map the eventType to a types object
var eventTypeId = event.eventType;
if (this._types === null || !this._types[eventTypeId] || !this._types[eventTypeId].color) {
color = '#666';
} else {
color = this._types[eventTypeId].color;
}
if (this._types === null || !this._types[eventTypeId] || this._types[eventTypeId].markerTooltip === undefined) {
markerTooltip = true;
} else {
markerTooltip = this._types[eventTypeId].markerTooltip;
}
if (this._types == null || !this._types[eventTypeId] || this._types[eventTypeId].lineWidth === undefined) {
lineWidth = 1; //default line width
} else {
lineWidth = this._types[eventTypeId].lineWidth;
}
if (this._types == null || !this._types[eventTypeId] || !this._types[eventTypeId].lineStyle) {
lineStyle = 'dashed'; //default line style
} else {
lineStyle = this._types[eventTypeId].lineStyle.toLowerCase();
}
var topOffset = 2;
top = o.top + this._plot.height() + topOffset;
var timeFrom = Math.min(event.min, event.timeEnd);
var timeTo = Math.max(event.min, event.timeEnd);
left = xaxis.p2c(timeFrom) + o.left;
var right = xaxis.p2c(timeTo) + o.left;
regionWidth = right - left;
_.each([left, right], function(position) {
var line = $('<div class="events_line flot-temp-elem"></div>').css({
"position": "absolute",
"opacity": 0.8,
"left": position + 'px',
"top": 8,
"width": lineWidth + "px",
"height": that._plot.height() + topOffset,
"border-left-width": lineWidth + "px",
"border-left-style": lineStyle,
"border-left-color": color,
"color": color
});
line.appendTo(container);
});
var region = $('<div class="events_marker region_marker flot-temp-elem"></div>').css({
"position": "absolute",
"opacity": 0.5,
"left": left + 'px',
"top": top,
"width": Math.round(regionWidth + lineWidth) + "px",
"height": "0.5rem",
"border-left-color": color,
"color": color,
"background-color": color
});
region.appendTo(container);
region.data({
"event": event
});
var mouseenter = function () {
createAnnotationToolip(region, $(this).data("event"), that._plot);
};
if (event.editModel) {
createEditPopover(region, event.editModel, that._plot);
}
var mouseleave = function () {
that._plot.clearSelection();
};
if (markerTooltip) {
region.css({ "cursor": "help" });
region.hover(mouseenter, mouseleave);
}
var drawableEvent = new DrawableEvent(
region,
function drawFunc(obj) { obj.show(); },
function (obj) { obj.remove(); },
function (obj, position) {
obj.css({
top: position.top,
left: position.left
});
},
left,
top,
region.width(),
region.height()
);
return drawableEvent;
};
/**
* check if the event is inside visible range
*/
@@ -395,5 +601,4 @@ function ($, _, angular, Drop) {
name: "events",
version: "0.2.5"
});
});

View File

@@ -45,7 +45,8 @@ function (angular, _, $) {
popoverSrv.show({
element: el[0],
position: 'bottom center',
template: '<gf-color-picker></gf-color-picker>',
template: '<series-color-picker series="series" onToggleAxis="toggleAxis" onColorChange="colorSelected">' +
'</series-color-picker>',
openOn: 'hover',
model: {
series: series,

View File

@@ -57,7 +57,7 @@ define([
element: $element.find(".dropdown")[0],
position: 'top center',
openOn: 'click',
template: '<gf-color-picker></gf-color-picker>',
template: '<series-color-picker onColorChange="colorSelected" />',
model: {
autoClose: true,
colorSelected: $scope.colorSelected,

View File

@@ -37,6 +37,20 @@ export class ThresholdFormCtrl {
render() {
this.panelCtrl.render();
}
onFillColorChange(index) {
return (newColor) => {
this.panel.thresholds[index].fillColor = newColor;
this.render();
};
}
onLineColorChange(index) {
return (newColor) => {
this.panel.thresholds[index].lineColor = newColor;
this.render();
};
}
}
var template = `
@@ -77,7 +91,7 @@ var template = `
<div class="gf-form" ng-if="threshold.fill && threshold.colorMode === 'custom'">
<label class="gf-form-label">Fill color</label>
<span class="gf-form-label">
<spectrum-picker ng-model="threshold.fillColor" ng-change="ctrl.render()" ></spectrum-picker>
<color-picker color="threshold.fillColor" onChange="ctrl.onFillColorChange($index)"></color-picker>
</span>
</div>
@@ -87,7 +101,7 @@ var template = `
<div class="gf-form" ng-if="threshold.line && threshold.colorMode === 'custom'">
<label class="gf-form-label">Line color</label>
<span class="gf-form-label">
<spectrum-picker ng-model="threshold.lineColor" ng-change="ctrl.render()" ></spectrum-picker>
<color-picker color="threshold.lineColor" onChange="ctrl.onLineColorChange($index)"></color-picker>
</span>
</div>

View File

@@ -119,6 +119,8 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
this.events.on('data-error', this.onDataError.bind(this));
this.events.on('data-snapshot-load', this.onDataReceived.bind(this));
this.events.on('init-edit-mode', this.onInitEditMode.bind(this));
this.onCardColorChange = this.onCardColorChange.bind(this);
}
onInitEditMode() {
@@ -236,6 +238,11 @@ export class HeatmapCtrl extends MetricsPanelCtrl {
this.render();
}
onCardColorChange(newColor) {
this.panel.color.cardColor = newColor;
this.render();
}
seriesHandler(seriesData) {
let series = new TimeSeries({
datapoints: seriesData.datapoints,

View File

@@ -12,7 +12,7 @@
<div class="gf-form">
<label class="gf-form-label width-9">Color</label>
<span class="gf-form-label">
<spectrum-picker ng-model="ctrl.panel.color.cardColor" ng-change="ctrl.render()" ></spectrum-picker>
<color-picker color="ctrl.panel.color.cardColor" onChange="ctrl.onCardColorChange"></color-picker>
</span>
</div>
<div class="gf-form">

View File

@@ -68,14 +68,8 @@
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Colors</label>
<span class="gf-form-label">
<spectrum-picker ng-model="ctrl.panel.colors[0]" ng-change="ctrl.render()" ></spectrum-picker>
</span>
<span class="gf-form-label">
<spectrum-picker ng-model="ctrl.panel.colors[1]" ng-change="ctrl.render()" ></spectrum-picker>
</span>
<span class="gf-form-label">
<spectrum-picker ng-model="ctrl.panel.colors[2]" ng-change="ctrl.render()" ></spectrum-picker>
<span class="gf-form-label" ng-repeat="color in ctrl.panel.colors track by $index">
<color-picker color="color" onChange="ctrl.onColorChange($index)"></color-picker>
</span>
<span class="gf-form-label">
<a ng-click="ctrl.invertColorOrder()">
@@ -93,13 +87,13 @@
<div class="gf-form">
<label class="gf-form-label width-9">Line Color</label>
<span class="gf-form-label">
<spectrum-picker ng-model="ctrl.panel.sparkline.lineColor" ng-change="ctrl.render()" ></spectrum-picker>
<color-picker color="ctrl.panel.sparkline.lineColor" onChange="ctrl.onSparklineColorChange"></color-picker>
</span>
</div>
<div class="gf-form">
<label class="gf-form-label width-9">Fill Color</label>
<span class="gf-form-label">
<spectrum-picker ng-model="ctrl.panel.sparkline.fillColor" ng-change="ctrl.render()" ></spectrum-picker>
<color-picker color="ctrl.panel.sparkline.fillColor" onChange="ctrl.onSparklineFillChange"></color-picker>
</span>
</div>
</div>

View File

@@ -92,6 +92,9 @@ class SingleStatCtrl extends MetricsPanelCtrl {
this.events.on('data-error', this.onDataError.bind(this));
this.events.on('data-snapshot-load', this.onDataReceived.bind(this));
this.events.on('init-edit-mode', this.onInitEditMode.bind(this));
this.onSparklineColorChange = this.onSparklineColorChange.bind(this);
this.onSparklineFillChange = this.onSparklineFillChange.bind(this);
}
onInitEditMode() {
@@ -214,6 +217,23 @@ class SingleStatCtrl extends MetricsPanelCtrl {
this.render();
}
onColorChange(panelColorIndex) {
return (color) => {
this.panel.colors[panelColorIndex] = color;
this.render();
};
}
onSparklineColorChange(newColor) {
this.panel.sparkline.lineColor = newColor;
this.render();
}
onSparklineFillChange(newColor) {
this.panel.sparkline.fillColor = newColor;
this.render();
}
getDecimalsForValue(value) {
if (_.isNumber(this.panel.decimals)) {
return {decimals: this.panel.decimals, scaledDecimals: null};
@@ -425,7 +445,8 @@ class SingleStatCtrl extends MetricsPanelCtrl {
function addGauge() {
var width = elem.width();
var height = elem.height();
var dimension = Math.min(width, height);
// Allow to use a bit more space for wide gauges
var dimension = Math.min(width, height * 1.3);
ctrl.invalidGaugeRange = false;
if (panel.gauge.minValue > panel.gauge.maxValue) {
@@ -462,8 +483,11 @@ class SingleStatCtrl extends MetricsPanelCtrl {
var fontScale = parseInt(panel.valueFontSize) / 100;
var fontSize = Math.min(dimension/5, 100) * fontScale;
var gaugeWidth = Math.min(dimension/6, 60);
// Reduce gauge width if threshold labels enabled
var gaugeWidthReduceRatio = panel.gauge.thresholdLabels ? 1.5 : 1;
var gaugeWidth = Math.min(dimension/6, 60) / gaugeWidthReduceRatio;
var thresholdMarkersWidth = gaugeWidth/5;
var thresholdLabelFontSize = fontSize / 2.5;
var options = {
series: {
@@ -484,8 +508,8 @@ class SingleStatCtrl extends MetricsPanelCtrl {
values: thresholds,
label: {
show: panel.gauge.thresholdLabels,
margin: 8,
font: { size: 18 }
margin: thresholdMarkersWidth + 1,
font: { size: thresholdLabelFontSize }
},
show: panel.gauge.thresholdMarkers,
width: thresholdMarkersWidth,

View File

@@ -35,13 +35,15 @@
<div class="gf-form">
<label class="gf-form-label width-11">Type</label>
<div class="gf-form-select-wrapper width-10">
<div class="gf-form-select-wrapper width-16">
<select class="gf-form-input" ng-model="style.type" ng-options="c.value as c.text for c in editor.columnTypes" ng-change="editor.render()"></select>
</div>
</div>
<div class="gf-form" ng-if="style.type === 'date'">
<label class="gf-form-label width-11">Date Format</label>
<metric-segment-model property="style.dateFormat" options="editor.dateFormats" on-change="editor.render()" custom="true"></metric-segment-model>
<div class="gf-form-select-wrapper width-16">
<select class="gf-form-input" ng-model="style.dateFormat" ng-options="c.value as c.text for c in editor.dateFormats" ng-change="editor.render()"></select>
</div>
</div>
<div ng-if="style.type === 'string'">
@@ -54,7 +56,7 @@
<div ng-if="style.type === 'number'">
<div class="gf-form">
<label class="gf-form-label width-11">Unit</label>
<div class="gf-form-dropdown-typeahead width-10" ng-model="style.unit" dropdown-typeahead2="editor.unitFormats" dropdown-typeahead-on-select="editor.setUnitFormat(style, $subItem)"></div>
<div class="gf-form-dropdown-typeahead width-16" ng-model="style.unit" dropdown-typeahead2="editor.unitFormats" dropdown-typeahead-on-select="editor.setUnitFormat(style, $subItem)"></div>
</div>
<div class="gf-form">
<label class="gf-form-label width-11">Decimals</label>
@@ -67,7 +69,7 @@
<h5 class="section-heading">Thresholds</h5>
<div class="gf-form">
<label class="gf-form-label width-8">Thresholds<tip>Comma separated values</tip></label>
<input type="text" class="gf-form-input width-10" ng-model="style.thresholds" placeholder="50,80" ng-blur="editor.render()" array-join ng-model-onblur>
<input type="text" class="gf-form-input width-10" ng-model="style.thresholds" placeholder="50,80" ng-blur="editor.render()" array-join>
</div>
<div class="gf-form">
<label class="gf-form-label width-8">Color Mode</label>
@@ -78,13 +80,13 @@
<div class="gf-form">
<label class="gf-form-label width-8">Colors</label>
<span class="gf-form-label">
<spectrum-picker ng-model="style.colors[0]" ng-change="editor.render()"></spectrum-picker>
<color-picker color="style.colors[0]" onChange="editor.onColorChange($index, 0)"></color-picker>
</span>
<span class="gf-form-label">
<spectrum-picker ng-model="style.colors[1]" ng-change="editor.render()"></spectrum-picker>
<color-picker color="style.colors[1]" onChange="editor.onColorChange($index, 1)"></color-picker>
</span>
<span class="gf-form-label">
<spectrum-picker ng-model="style.colors[2]" ng-change="editor.render()"></spectrum-picker>
<color-picker color="style.colors[2]" onChange="editor.onColorChange($index, 2)"></color-picker>
</span>
<div class="gf-form-label">
<a class="pointer" ng-click="editor.invertColorOrder($index)">Invert</a>

View File

@@ -40,6 +40,7 @@ export class ColumnOptionsCtrl {
this.fontSizes = ['80%', '90%', '100%', '110%', '120%', '130%', '150%', '160%', '180%', '200%', '220%', '250%'];
this.dateFormats = [
{text: 'YYYY-MM-DD HH:mm:ss', value: 'YYYY-MM-DD HH:mm:ss'},
{text: 'YYYY-MM-DD HH:mm:ss.SSS', value: 'YYYY-MM-DD HH:mm:ss.SSS'},
{text: 'MM/DD/YY h:mm:ss a', value: 'MM/DD/YY h:mm:ss a'},
{text: 'MMMM D, YYYY LT', value: 'MMMM D, YYYY LT'},
];
@@ -52,6 +53,8 @@ export class ColumnOptionsCtrl {
return col.text;
});
};
this.onColorChange = this.onColorChange.bind(this);
}
render() {
@@ -103,6 +106,13 @@ export class ColumnOptionsCtrl {
ref[2] = copy;
this.panelCtrl.render();
}
onColorChange(styleIndex, colorIndex) {
return (newColor) => {
this.panel.styles[styleIndex].colors[colorIndex] = newColor;
this.render();
};
}
}
/** @ngInject */

83
public/app/system.conf.js Normal file
View File

@@ -0,0 +1,83 @@
System.config({
defaultJSExtenions: true,
baseURL: 'public',
paths: {
'virtual-scroll': 'vendor/npm/virtual-scroll/src/index.js',
'mousetrap': 'vendor/npm/mousetrap/mousetrap.js',
'remarkable': 'vendor/npm/remarkable/dist/remarkable.js',
'tether': 'vendor/npm/tether/dist/js/tether.js',
'eventemitter3': 'vendor/npm/eventemitter3/index.js',
'tether-drop': 'vendor/npm/tether-drop/dist/js/drop.js',
'moment': 'vendor/moment.js',
"jquery": "vendor/jquery/dist/jquery.js",
'lodash-src': 'vendor/lodash/dist/lodash.js',
"lodash": 'app/core/lodash_extended.js',
"angular": "vendor/angular/angular.js",
"bootstrap": "vendor/bootstrap/bootstrap.js",
'angular-route': 'vendor/angular-route/angular-route.js',
'angular-sanitize': 'vendor/angular-sanitize/angular-sanitize.js',
"angular-ui": "vendor/angular-ui/ui-bootstrap-tpls.js",
"angular-strap": "vendor/angular-other/angular-strap.js",
"angular-dragdrop": "vendor/angular-native-dragdrop/draganddrop.js",
"angular-bindonce": "vendor/angular-bindonce/bindonce.js",
"spectrum": "vendor/spectrum.js",
"bootstrap-tagsinput": "vendor/tagsinput/bootstrap-tagsinput.js",
"jquery.flot": "vendor/flot/jquery.flot",
"jquery.flot.pie": "vendor/flot/jquery.flot.pie",
"jquery.flot.selection": "vendor/flot/jquery.flot.selection",
"jquery.flot.stack": "vendor/flot/jquery.flot.stack",
"jquery.flot.stackpercent": "vendor/flot/jquery.flot.stackpercent",
"jquery.flot.time": "vendor/flot/jquery.flot.time",
"jquery.flot.crosshair": "vendor/flot/jquery.flot.crosshair",
"jquery.flot.fillbelow": "vendor/flot/jquery.flot.fillbelow",
"jquery.flot.gauge": "vendor/flot/jquery.flot.gauge",
"d3": "vendor/d3/d3.js",
"jquery.flot.dashes": "vendor/flot/jquery.flot.dashes",
"twemoji": "vendor/npm/twemoji/2/twemoji.amd.js",
"ace": "vendor/npm/ace-builds/src-noconflict/ace",
},
packages: {
app: {
defaultExtension: 'js',
},
vendor: {
defaultExtension: 'js',
},
plugins: {
defaultExtension: 'js',
},
test: {
defaultExtension: 'js',
},
},
map: {
text: 'vendor/plugin-text/text.js',
css: 'app/core/utils/css_loader.js'
},
meta: {
'vendor/npm/virtual-scroll/src/indx.js': {
format: 'cjs',
exports: 'VirtualScroll',
},
'vendor/angular/angular.js': {
format: 'global',
deps: ['jquery'],
exports: 'angular',
},
'vendor/npm/eventemitter3/index.js': {
format: 'cjs',
exports: 'EventEmitter'
},
'vendor/npm/mousetrap/mousetrap.js': {
format: 'global',
exports: 'Mousetrap'
},
'vendor/npm/ace-builds/src-noconflict/ace.js': {
format: 'global',
exports: 'ace'
}
}
});

BIN
public/img/mixed_styles.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -269,7 +269,8 @@ $alert-info-bg: linear-gradient(100deg, #1a4552, #00374a);
// popover
$popover-bg: $panel-bg;
$popover-color: $text-color;
$popover-border-color: $gray-1;
$popover-border-color: $dark-4;
$popover-shadow: 0 0 20px black;
$popover-help-bg: $btn-secondary-bg;
$popover-help-color: $text-color;

View File

@@ -284,9 +284,11 @@ $alert-warning-bg: linear-gradient(90deg, #d44939, #e0603d);
$alert-info-bg: $blue-dark;
// popover
$popover-bg: $gray-5;
$popover-bg: $panel-bg;
$popover-color: $text-color;
$popover-border-color: $gray-3;
$popover-border-color: $gray-5;
$popover-shadow: 0 0 20px $white;
$popover-help-bg: $blue-dark;
$popover-help-color: $gray-6;
$popover-error-bg: $btn-danger-bg;

View File

@@ -35,3 +35,14 @@
float: left;
z-index: 0;
}
.gf-color-picker__body {
padding-bottom: 10px;
padding-left: 6px;
}
.drop-popover.gf-color-picker {
.drop-content {
width: 210px;
}
}

View File

@@ -51,9 +51,16 @@ $easing: cubic-bezier(0, 0, 0.265, 1.00);
}
}
.drop-element.drop-popover {
.drop-content {
box-shadow: $popover-shadow;
}
}
.drop-element.drop-popover--form {
.drop-content {
max-width: none;
padding: 0;
}
}

View File

@@ -176,6 +176,12 @@ $gf-form-margin: 1px;
pointer-events: none;
}
}
&--small {
padding-top: 4px;
padding-bottom: 4px;
font-size: $font-size-sm;
}
}
.gf-form-hint {

View File

@@ -0,0 +1,26 @@
.gf-icon-picker {
width: 400px;
height: 450px;
.icon-filter {
padding-bottom: 10px;
margin: auto;
width: 50%;
}
.icon-container {
max-height: 350px;
overflow: auto;
.gf-event-icon {
margin: 0.4rem;
height: 1.5rem;
}
}
}
.gf-icon-picker-button {
.gf-event-icon {
height: 1.2rem;
}
}

View File

@@ -287,19 +287,27 @@
margin-top: 8px;
}
.graph-annotation-header {
background-color: $input-label-bg;
.graph-annotation__header {
background-color: $popover-border-color;
padding: 0.40rem 0.65rem;
display: flex;
}
.graph-annotation-title {
.graph-annotation__title {
font-weight: $font-weight-semi-bold;
padding-right: $spacer;
position: relative;
top: 2px;
overflow: hidden;
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
flex-grow: 1;
}
.graph-annotation-time {
.graph-annotation__edit-icon {
padding-left: $spacer;
}
.graph-annotation__time {
color: $text-muted;
font-style: italic;
font-weight: normal;
@@ -308,15 +316,22 @@
top: 1px;
}
.graph-annotation-body {
.graph-annotation__body {
padding: 0.65rem;
}
a {
.graph-annotation__user {
img {
border-radius: 50%;
width: 16px;
height: 16px;
}
}
a[href] {
color: $blue;
text-decoration: underline;
}
}
.left-yaxis-label {

View File

@@ -16,10 +16,6 @@
max-width: 20rem;
border: 1px solid $border-color;
@if $theme-bg != $border-color {
box-shadow: 0 0 15px $border-color;
}
&:before {
content: "";
display: block;

View File

@@ -46,6 +46,14 @@ describe("DateMath", () => {
expect(startOfDay).to.be(expected.getTime());
});
it("now/d on a utc dashboard should be start of the current day in UTC time", () => {
var today = new Date();
var expected = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0));
var startOfDay = dateMath.parse('now/d', false, 'utc').valueOf();
expect(startOfDay).to.be(expected.getTime());
});
describe('subtraction', () => {
var now;
var anchored;

View File

@@ -21,7 +21,7 @@ angular.module('grafana.directives', []);
angular.module('grafana.filters', []);
angular.module('grafana.routes', ['ngRoute']);
const context = (<any>require).context('../', true, /specs/);
const context = (<any>require).context('../', true, /specs\.(tsx?|js)/);
for (let key of context.keys()) {
context(key);
}

View File

@@ -103,7 +103,7 @@ define([
};
this.createService = function(name) {
return window.inject(function($q, $rootScope, $httpBackend, $injector, $location) {
return window.inject(function($q, $rootScope, $httpBackend, $injector, $location, $timeout) {
self.$q = $q;
self.$rootScope = $rootScope;
self.$httpBackend = $httpBackend;
@@ -111,6 +111,7 @@ define([
self.$rootScope.onAppEvent = function() {};
self.$rootScope.appEvent = function() {};
self.$timeout = $timeout;
self.service = $injector.get(name);
});

130
public/test/test-main.js Normal file
View File

@@ -0,0 +1,130 @@
(function() {
"use strict";
// Tun on full stack traces in errors to help debugging
Error.stackTraceLimit=Infinity;
window.__karma__.loaded = function() {};
System.config({
baseURL: '/base/',
defaultJSExtensions: true,
paths: {
'mousetrap': 'vendor/npm/mousetrap/mousetrap.js',
'eventemitter3': 'vendor/npm/eventemitter3/index.js',
'remarkable': 'vendor/npm/remarkable/dist/remarkable.js',
'tether': 'vendor/npm/tether/dist/js/tether.js',
'tether-drop': 'vendor/npm/tether-drop/dist/js/drop.js',
'moment': 'vendor/moment.js',
"jquery": "vendor/jquery/dist/jquery.js",
'lodash-src': 'vendor/lodash/dist/lodash.js',
"lodash": 'app/core/lodash_extended.js',
"angular": 'vendor/angular/angular.js',
'angular-mocks': 'vendor/angular-mocks/angular-mocks.js',
"bootstrap": "vendor/bootstrap/bootstrap.js",
'angular-route': 'vendor/angular-route/angular-route.js',
'angular-sanitize': 'vendor/angular-sanitize/angular-sanitize.js',
"angular-ui": "vendor/angular-ui/ui-bootstrap-tpls.js",
"angular-strap": "vendor/angular-other/angular-strap.js",
"angular-dragdrop": "vendor/angular-native-dragdrop/draganddrop.js",
"angular-bindonce": "vendor/angular-bindonce/bindonce.js",
"spectrum": "vendor/spectrum.js",
"bootstrap-tagsinput": "vendor/tagsinput/bootstrap-tagsinput.js",
"jquery.flot": "vendor/flot/jquery.flot",
"jquery.flot.pie": "vendor/flot/jquery.flot.pie",
"jquery.flot.selection": "vendor/flot/jquery.flot.selection",
"jquery.flot.stack": "vendor/flot/jquery.flot.stack",
"jquery.flot.stackpercent": "vendor/flot/jquery.flot.stackpercent",
"jquery.flot.time": "vendor/flot/jquery.flot.time",
"jquery.flot.crosshair": "vendor/flot/jquery.flot.crosshair",
"jquery.flot.fillbelow": "vendor/flot/jquery.flot.fillbelow",
"jquery.flot.gauge": "vendor/flot/jquery.flot.gauge",
"d3": "vendor/d3/d3.js",
"jquery.flot.dashes": "vendor/flot/jquery.flot.dashes",
"twemoji": "vendor/npm/twemoji/2/twemoji.amd.js",
"ace": "vendor/npm/ace-builds/src-noconflict/ace",
},
packages: {
app: {
defaultExtension: 'js',
},
vendor: {
defaultExtension: 'js',
},
},
map: {
},
meta: {
'vendor/angular/angular.js': {
format: 'global',
deps: ['jquery'],
exports: 'angular',
},
'vendor/angular-mocks/angular-mocks.js': {
format: 'global',
deps: ['angular'],
},
'vendor/npm/eventemitter3/index.js': {
format: 'cjs',
exports: 'EventEmitter'
},
'vendor/npm/mousetrap/mousetrap.js': {
format: 'global',
exports: 'Mousetrap'
},
'vendor/npm/ace-builds/src-noconflict/ace.js': {
format: 'global',
exports: 'ace'
},
}
});
function file2moduleName(filePath) {
return filePath.replace(/\\/g, '/')
.replace(/^\/base\//, '')
.replace(/\.\w*$/, '');
}
function onlySpecFiles(path) {
return /specs.*/.test(path);
}
window.grafanaBootData = {settings: {}};
var modules = ['angular', 'angular-mocks', 'app/app'];
var promises = modules.map(function(name) {
return System.import(name);
});
Promise.all(promises).then(function(deps) {
var angular = deps[0];
angular.module('grafana', ['ngRoute']);
angular.module('grafana.services', ['ngRoute', '$strap.directives']);
angular.module('grafana.panels', []);
angular.module('grafana.controllers', []);
angular.module('grafana.directives', []);
angular.module('grafana.filters', []);
angular.module('grafana.routes', ['ngRoute']);
// load specs
return Promise.all(
Object.keys(window.__karma__.files) // All files served by Karma.
.filter(onlySpecFiles)
.map(file2moduleName)
.map(function(path) {
// console.log(path);
return System.import(path);
}));
}).then(function() {
window.__karma__.start();
}, function(error) {
window.__karma__.error(error.stack || error);
}).catch(function(error) {
window.__karma__.error(error.stack || error);
});
})();

View File

@@ -602,6 +602,7 @@ Licensed under the MIT license.
tickColor: null, // color for the ticks, e.g. "rgba(0,0,0,0.15)"
margin: 0, // distance from the canvas edge to the grid
labelMargin: 5, // in pixels
eventSectionHeight: 0, // space for event section
axisMargin: 8, // in pixels
borderWidth: 2, // in pixels
minBorderMargin: null, // in pixels, null means taken from points radius
@@ -1450,6 +1451,7 @@ Licensed under the MIT license.
tickLength = axis.options.tickLength,
axisMargin = options.grid.axisMargin,
padding = options.grid.labelMargin,
eventSectionPadding = options.grid.eventSectionHeight,
innermost = true,
outermost = true,
first = true,
@@ -1490,7 +1492,9 @@ Licensed under the MIT license.
padding += +tickLength;
if (isXAxis) {
// Add space for event section
lh += padding;
lh += eventSectionPadding;
if (pos == "bottom") {
plotOffset.bottom += lh + axisMargin;
@@ -1518,6 +1522,7 @@ Licensed under the MIT license.
axis.position = pos;
axis.tickLength = tickLength;
axis.box.padding = padding;
axis.box.eventSectionPadding = eventSectionPadding;
axis.innermost = innermost;
}
@@ -2225,7 +2230,7 @@ Licensed under the MIT license.
halign = "center";
x = plotOffset.left + axis.p2c(tick.v);
if (axis.position == "bottom") {
y = box.top + box.padding;
y = box.top + box.padding + box.eventSectionPadding;
} else {
y = box.top + box.height - box.padding;
valign = "bottom";

View File

@@ -28,15 +28,14 @@
this.$element = $(element);
this.$element.hide();
this.widthClass = options.widthClass || 'width-9';
this.isSelect = (element.tagName === 'SELECT');
this.multiple = (this.isSelect && element.hasAttribute('multiple'));
this.objectItems = options && options.itemValue;
this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : '';
this.inputSize = Math.max(1, this.placeholderText.length);
this.$container = $('<div class="bootstrap-tagsinput"></div>');
this.$input = $('<input class="gf-form-input" size="' +
this.inputSize + '" type="text" placeholder="' + this.placeholderText + '"/>').appendTo(this.$container);
this.$input = $('<input class="gf-form-input ' + this.widthClass + '" type="text" placeholder="' + this.placeholderText + '"/>').appendTo(this.$container);
this.$element.after(this.$container);
@@ -292,6 +291,13 @@
self.$input.focus();
}, self));
self.$container.on('blur', 'input', $.proxy(function(event) {
var $input = $(event.target);
self.add($input.val());
$input.val('');
event.preventDefault();
}, self));
self.$container.on('keydown', 'input', $.proxy(function(event) {
var $input = $(event.target),
$inputWrapper = self.findInputWrapper();