diff --git a/conf/sample.ini b/conf/sample.ini index 65ada5b9468..b863f26bdb1 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -298,7 +298,7 @@ # Use space to separate multiple modes, e.g. "console file" ;mode = console file -# Either "trace", "debug", "info", "warn", "error", "critical", default is "info" +# Either "debug", "info", "warn", "error", "critical", default is "info" ;level = info # optional settings to set different levels for specific loggers. Ex filters = sqlstore:debug diff --git a/docker/blocks/graphite/fig b/docker/blocks/graphite/fig index 60acb8c1131..b7e030e388e 100644 --- a/docker/blocks/graphite/fig +++ b/docker/blocks/graphite/fig @@ -4,7 +4,6 @@ graphite: - "8080:80" - "2003:2003" volumes: - - /var/docker/gfdev/graphite:/opt/graphite/storage/whisper - /etc/localtime:/etc/localtime:ro - /etc/timezone:/etc/timezone:ro diff --git a/docs/sources/http_api/snapshot.md b/docs/sources/http_api/snapshot.md index f0b8304cd24..d466d01e051 100644 --- a/docs/sources/http_api/snapshot.md +++ b/docs/sources/http_api/snapshot.md @@ -52,6 +52,15 @@ parent = "http_api" "expires": 3600 } +JSON Body schema: + +- **dashboard** – Required. The complete dashboard model. +- **name** – Optional. snapshot name +- **expires** - Optional. When the snapshot should expire in seconds. 3600 is 1 hour, 86400 is 1 day. Default is never to expire. +- **external** - Optional. Save the snapshot on an external server rather than locally. Default is `false`. +- **key** - Optional. Define the unique key. Required if **external** is `true`. +- **deleteKey** - Optional. Unique key used to delete the snapshot. It is different from the **key** so that only the creator can delete the snapshot. Required if **external** is `true`. + **Example Response**: HTTP/1.1 200 diff --git a/public/app/core/components/collapse_box.ts b/public/app/core/components/collapse_box.ts new file mode 100644 index 00000000000..7fc234cb583 --- /dev/null +++ b/public/app/core/components/collapse_box.ts @@ -0,0 +1,58 @@ +/// + +import coreModule from 'app/core/core_module'; + +const template = ` +
+ +
+
+
+`; + +export class CollapseBoxCtrl { + isOpen: boolean; + stateChanged: () => void; + + /** @ngInject **/ + constructor(private $timeout) { + this.isOpen = false; + } + + toggle() { + this.isOpen = !this.isOpen; + this.$timeout(() => { + this.stateChanged(); + }); + } +} + +export function collapseBox() { + return { + restrict: 'E', + template: template, + controller: CollapseBoxCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + "title": "@", + "isOpen": "=?", + "stateChanged": "&" + }, + transclude: { + 'actions': '?collapseBoxActions', + 'body': 'collapseBoxBody', + }, + link: function(scope, elem, attrs) { + } + }; +} + +coreModule.directive('collapseBox', collapseBox); diff --git a/public/app/core/components/form_dropdown/form_dropdown.ts b/public/app/core/components/form_dropdown/form_dropdown.ts new file mode 100644 index 00000000000..72a101388b7 --- /dev/null +++ b/public/app/core/components/form_dropdown/form_dropdown.ts @@ -0,0 +1,248 @@ +/// + +import config from 'app/core/config'; +import _ from 'lodash'; +import $ from 'jquery'; +import coreModule from '../../core_module'; + +function typeaheadMatcher(item) { + var str = this.query; + if (str[0] === '/') { str = str.substring(1); } + if (str[str.length - 1] === '/') { str = str.substring(0, str.length-1); } + return item.toLowerCase().match(str.toLowerCase()); +} + +export class FormDropdownCtrl { + inputElement: any; + linkElement: any; + model: any; + display: any; + text: any; + options: any; + cssClass: any; + cssClasses: any; + allowCustom: any; + labelMode: boolean; + linkMode: boolean; + cancelBlur: any; + onChange: any; + getOptions: any; + optionCache: any; + lookupText: boolean; + + constructor(private $scope, $element, private $sce, private templateSrv, private $q) { + this.inputElement = $element.find('input').first(); + this.linkElement = $element.find('a').first(); + this.linkMode = true; + this.cancelBlur = null; + + // listen to model changes + $scope.$watch("ctrl.model", this.modelChanged.bind(this)); + + if (this.labelMode) { + this.cssClasses = 'gf-form-label ' + this.cssClass; + } else { + this.cssClasses = 'gf-form-input gf-form-input--dropdown ' + this.cssClass; + } + + this.inputElement.attr('data-provide', 'typeahead'); + this.inputElement.typeahead({ + source: this.typeaheadSource.bind(this), + minLength: 0, + items: 10000, + updater: this.typeaheadUpdater.bind(this), + matcher: typeaheadMatcher, + }); + + // modify typeahead lookup + // this = typeahead + var typeahead = this.inputElement.data('typeahead'); + typeahead.lookup = function () { + this.query = this.$element.val() || ''; + var items = this.source(this.query, $.proxy(this.process, this)); + return items ? this.process(items) : items; + }; + + this.linkElement.keydown(evt => { + // trigger typeahead on down arrow or enter key + if (evt.keyCode === 40 || evt.keyCode === 13) { + this.linkElement.click(); + } + }); + + this.inputElement.keydown(evt => { + if (evt.keyCode === 13) { + this.inputElement.blur(); + } + }); + + this.inputElement.blur(this.inputBlur.bind(this)); + } + + getOptionsInternal(query) { + var result = this.getOptions({$query: query}); + if (this.isPromiseLike(result)) { + return result; + } + return this.$q.when(result); + } + + isPromiseLike(obj) { + return obj && (typeof obj.then === 'function'); + } + + modelChanged() { + if (_.isObject(this.model)) { + this.updateDisplay(this.model.text); + } else { + // if we have text use it + if (this.lookupText) { + this.getOptionsInternal("").then(options => { + var item = _.find(options, {value: this.model}); + this.updateDisplay(item ? item.text : this.model); + }); + } else { + this.updateDisplay(this.model); + } + } + } + + typeaheadSource(query, callback) { + this.getOptionsInternal(query).then(options => { + this.optionCache = options; + + // extract texts + let optionTexts = _.map(options, 'text'); + + // add custom values + if (this.allowCustom) { + if (_.indexOf(optionTexts, this.text) === -1) { + options.unshift(this.text); + } + } + + callback(optionTexts); + }); + } + + typeaheadUpdater(text) { + if (text === this.text) { + clearTimeout(this.cancelBlur); + this.inputElement.focus(); + return text; + } + + this.inputElement.val(text); + this.switchToLink(true); + return text; + } + + switchToLink(fromClick) { + if (this.linkMode && !fromClick) { return; } + + clearTimeout(this.cancelBlur); + this.cancelBlur = null; + this.linkMode = true; + this.inputElement.hide(); + this.linkElement.show(); + this.updateValue(this.inputElement.val()); + } + + inputBlur() { + // happens long before the click event on the typeahead options + // need to have long delay because the blur + this.cancelBlur = setTimeout(this.switchToLink.bind(this), 200); + } + + updateValue(text) { + if (text === '' || this.text === text) { + return; + } + + this.$scope.$apply(() => { + var option = _.find(this.optionCache, {text: text}); + + if (option) { + if (_.isObject(this.model)) { + this.model = option; + } else { + this.model = option.value; + } + this.text = option.text; + } else if (this.allowCustom) { + if (_.isObject(this.model)) { + this.model.text = this.model.value = text; + } else { + this.model = text; + } + this.text = text; + } + + // needs to call this after digest so + // property is synced with outerscope + this.$scope.$$postDigest(() => { + this.$scope.$apply(() => { + this.onChange({$option: option}); + }); + }); + + }); + } + + updateDisplay(text) { + this.text = text; + this.display = this.$sce.trustAsHtml(this.templateSrv.highlightVariablesAsHtml(text)); + } + + open() { + this.inputElement.show(); + + this.inputElement.css('width', (Math.max(this.linkElement.width(), 80) + 16) + 'px'); + this.inputElement.focus(); + + this.linkElement.hide(); + this.linkMode = false; + + var typeahead = this.inputElement.data('typeahead'); + if (typeahead) { + this.inputElement.val(''); + typeahead.lookup(); + } + } +} + +const template = ` + + + + +`; + +export function formDropdownDirective() { + return { + restrict: 'E', + template: template, + controller: FormDropdownCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + model: "=", + getOptions: "&", + onChange: "&", + cssClass: "@", + allowCustom: "@", + labelMode: "@", + lookupText: "@", + }, + }; +} + +coreModule.directive('gfFormDropdown', formDropdownDirective); diff --git a/public/app/core/components/json_explorer/helpers.ts b/public/app/core/components/json_explorer/helpers.ts new file mode 100644 index 00000000000..2f2b80e73ea --- /dev/null +++ b/public/app/core/components/json_explorer/helpers.ts @@ -0,0 +1,113 @@ +// Based on work https://github.com/mohsen1/json-formatter-js +// Licence MIT, Copyright (c) 2015 Mohsen Azimi + +/* + * Escapes `"` charachters from string +*/ +function escapeString(str: string): string { + return str.replace('"', '\"'); +} + +/* + * Determines if a value is an object +*/ +export function isObject(value: any): boolean { + var type = typeof value; + return !!value && (type === 'object'); +} + +/* + * Gets constructor name of an object. + * From http://stackoverflow.com/a/332429 + * +*/ +export function getObjectName(object: Object): string { + if (object === undefined) { + return ''; + } + if (object === null) { + return 'Object'; + } + if (typeof object === 'object' && !object.constructor) { + return 'Object'; + } + + const funcNameRegex = /function ([^(]*)/; + const results = (funcNameRegex).exec((object).constructor.toString()); + if (results && results.length > 1) { + return results[1]; + } else { + return ''; + } +} + +/* + * Gets type of an object. Returns "null" for null objects +*/ +export function getType(object: Object): string { + if (object === null) { return 'null'; } + return typeof object; +} + +/* + * Generates inline preview for a JavaScript object based on a value +*/ +export function getValuePreview (object: Object, value: string): string { + var type = getType(object); + + if (type === 'null' || type === 'undefined') { return type; } + + if (type === 'string') { + value = '"' + escapeString(value) + '"'; + } + if (type === 'function'){ + + // Remove content of the function + return object.toString() + .replace(/[\r\n]/g, '') + .replace(/\{.*\}/, '') + '{…}'; + } + return value; +} + +/* + * Generates inline preview for a JavaScript object +*/ +export function getPreview(object: string): string { + let value = ''; + if (isObject(object)) { + value = getObjectName(object); + if (Array.isArray(object)) { + value += '[' + object.length + ']'; + } + } else { + value = getValuePreview(object, object); + } + return value; +} + +/* + * Generates a prefixed CSS class name +*/ +export function cssClass(className: string): string { + return `json-formatter-${className}`; +} + +/* + * Creates a new DOM element wiht given type and class + * TODO: move me to helpers +*/ +export function createElement(type: string, className?: string, content?: Element|string): Element { + const el = document.createElement(type); + if (className) { + el.classList.add(cssClass(className)); + } + if (content !== undefined) { + if (content instanceof Node) { + el.appendChild(content); + } else { + el.appendChild(document.createTextNode(String(content))); + } + } + return el; +} diff --git a/public/app/core/components/json_explorer/json_explorer.ts b/public/app/core/components/json_explorer/json_explorer.ts new file mode 100644 index 00000000000..c2810f23b54 --- /dev/null +++ b/public/app/core/components/json_explorer/json_explorer.ts @@ -0,0 +1,431 @@ +// Based on work https://github.com/mohsen1/json-formatter-js +// Licence MIT, Copyright (c) 2015 Mohsen Azimi + +import { + isObject, + getObjectName, + getType, + getValuePreview, + getPreview, + cssClass, + createElement +} from './helpers'; + +import _ from 'lodash'; + +const DATE_STRING_REGEX = /(^\d{1,4}[\.|\\/|-]\d{1,2}[\.|\\/|-]\d{1,4})(\s*(?:0?[1-9]:[0-5]|1(?=[012])\d:[0-5])\d\s*[ap]m)?$/; +const PARTIAL_DATE_REGEX = /\d{2}:\d{2}:\d{2} GMT-\d{4}/; +const JSON_DATE_REGEX = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + +// When toggleing, don't animated removal or addition of more than a few items +const MAX_ANIMATED_TOGGLE_ITEMS = 10; + +const requestAnimationFrame = window.requestAnimationFrame || function(cb: ()=>void) { cb(); return 0; }; + +export interface JsonExplorerConfig { + animateOpen?: boolean; + animateClose?: boolean; + theme?: string; +} + +const _defaultConfig: JsonExplorerConfig = { + animateOpen: true, + animateClose: true, + theme: null +}; + + +/** + * @class JsonExplorer + * + * JsonExplorer allows you to render JSON objects in HTML with a + * **collapsible** navigation. +*/ +export class JsonExplorer { + + // Hold the open state after the toggler is used + private _isOpen: boolean = null; + + // A reference to the element that we render to + private element: Element; + + private skipChildren = false; + + /** + * @param {object} json The JSON object you want to render. It has to be an + * object or array. Do NOT pass raw JSON string. + * + * @param {number} [open=1] his number indicates up to how many levels the + * rendered tree should expand. Set it to `0` to make the whole tree collapsed + * or set it to `Infinity` to expand the tree deeply + * + * @param {object} [config=defaultConfig] - + * defaultConfig = { + * hoverPreviewEnabled: false, + * hoverPreviewArrayCount: 100, + * hoverPreviewFieldCount: 5 + * } + * + * Available configurations: + * #####Hover Preview + * * `hoverPreviewEnabled`: enable preview on hover + * * `hoverPreviewArrayCount`: number of array items to show in preview Any + * array larger than this number will be shown as `Array[XXX]` where `XXX` + * is length of the array. + * * `hoverPreviewFieldCount`: number of object properties to show for object + * preview. Any object with more properties that thin number will be + * truncated. + * + * @param {string} [key=undefined] The key that this object in it's parent + * context + */ + constructor(public json: any, private open = 1, private config: JsonExplorerConfig = _defaultConfig, private key?: string) { + } + + /* + * is formatter open? + */ + private get isOpen(): boolean { + if (this._isOpen !== null) { + return this._isOpen; + } else { + return this.open > 0; + } + } + + /* + * set open state (from toggler) + */ + private set isOpen(value: boolean) { + this._isOpen = value; + } + + /* + * is this a date string? + */ + private get isDate(): boolean { + return (this.type === 'string') && + (DATE_STRING_REGEX.test(this.json) || + JSON_DATE_REGEX.test(this.json) || + PARTIAL_DATE_REGEX.test(this.json)); + } + + /* + * is this a URL string? + */ + private get isUrl(): boolean { + return this.type === 'string' && (this.json.indexOf('http') === 0); + } + + /* + * is this an array? + */ + private get isArray(): boolean { + return Array.isArray(this.json); + } + + /* + * is this an object? + * Note: In this context arrays are object as well + */ + private get isObject(): boolean { + return isObject(this.json); + } + + /* + * is this an empty object with no properties? + */ + private get isEmptyObject(): boolean { + return !this.keys.length && !this.isArray; + } + + /* + * is this an empty object or array? + */ + private get isEmpty(): boolean { + return this.isEmptyObject || (this.keys && !this.keys.length && this.isArray); + } + + /* + * did we recieve a key argument? + * This means that the formatter was called as a sub formatter of a parent formatter + */ + private get hasKey(): boolean { + return typeof this.key !== 'undefined'; + } + + /* + * if this is an object, get constructor function name + */ + private get constructorName(): string { + return getObjectName(this.json); + } + + /* + * get type of this value + * Possible values: all JavaScript primitive types plus "array" and "null" + */ + private get type(): string { + return getType(this.json); + } + + /* + * get object keys + * If there is an empty key we pad it wit quotes to make it visible + */ + private get keys(): string[] { + if (this.isObject) { + return Object.keys(this.json).map((key)=> key ? key : '""'); + } else { + return []; + } + } + + /** + * Toggles `isOpen` state + * + */ + toggleOpen() { + this.isOpen = !this.isOpen; + + if (this.element) { + if (this.isOpen) { + this.appendChildren(this.config.animateOpen); + } else{ + this.removeChildren(this.config.animateClose); + } + this.element.classList.toggle(cssClass('open')); + } + } + + /** + * Open all children up to a certain depth. + * Allows actions such as expand all/collapse all + * + */ + openAtDepth(depth = 1) { + if (depth < 0) { + return; + } + + this.open = depth; + this.isOpen = (depth !== 0); + + if (this.element) { + this.removeChildren(false); + + if (depth === 0) { + this.element.classList.remove(cssClass('open')); + } else { + this.appendChildren(this.config.animateOpen); + this.element.classList.add(cssClass('open')); + } + } + } + + isNumberArray() { + return (this.json.length > 0 && this.json.length < 4) && + (_.isNumber(this.json[0]) || _.isNumber(this.json[1])); + } + + renderArray() { + const arrayWrapperSpan = createElement('span'); + arrayWrapperSpan.appendChild(createElement('span', 'bracket', '[')); + + // some pretty handling of number arrays + if (this.isNumberArray()) { + this.json.forEach((val, index) => { + if (index > 0) { + arrayWrapperSpan.appendChild(createElement('span', 'array-comma', ',')); + } + arrayWrapperSpan.appendChild(createElement('span', 'number', val)); + }); + this.skipChildren = true; + } else { + arrayWrapperSpan.appendChild(createElement('span', 'number', (this.json.length))); + } + + arrayWrapperSpan.appendChild(createElement('span', 'bracket', ']')); + return arrayWrapperSpan; + } + + /** + * Renders an HTML element and installs event listeners + * + * @returns {HTMLDivElement} + */ + render(skipRoot = false): HTMLDivElement { + // construct the root element and assign it to this.element + this.element = createElement('div', 'row'); + + // construct the toggler link + const togglerLink = createElement('a', 'toggler-link'); + const togglerIcon = createElement('span', 'toggler'); + + // if this is an object we need a wrapper span (toggler) + if (this.isObject) { + togglerLink.appendChild(togglerIcon); + } + + // if this is child of a parent formatter we need to append the key + if (this.hasKey) { + togglerLink.appendChild(createElement('span', 'key', `${this.key}:`)); + } + + // Value for objects and arrays + if (this.isObject) { + // construct the value holder element + const value = createElement('span', 'value'); + + // we need a wrapper span for objects + const objectWrapperSpan = createElement('span'); + + // get constructor name and append it to wrapper span + var constructorName = createElement('span', 'constructor-name', this.constructorName); + objectWrapperSpan.appendChild(constructorName); + + // if it's an array append the array specific elements like brackets and length + if (this.isArray) { + const arrayWrapperSpan = this.renderArray(); + objectWrapperSpan.appendChild(arrayWrapperSpan); + } + + // append object wrapper span to toggler link + value.appendChild(objectWrapperSpan); + togglerLink.appendChild(value); + // Primitive values + } else { + + // make a value holder element + const value = this.isUrl ? createElement('a') : createElement('span'); + + // add type and other type related CSS classes + value.classList.add(cssClass(this.type)); + if (this.isDate) { + value.classList.add(cssClass('date')); + } + if (this.isUrl) { + value.classList.add(cssClass('url')); + value.setAttribute('href', this.json); + } + + // Append value content to value element + const valuePreview = getValuePreview(this.json, this.json); + value.appendChild(document.createTextNode(valuePreview)); + + // append the value element to toggler link + togglerLink.appendChild(value); + } + + // construct a children element + const children = createElement('div', 'children'); + + // set CSS classes for children + if (this.isObject) { + children.classList.add(cssClass('object')); + } + if (this.isArray) { + children.classList.add(cssClass('array')); + } + if (this.isEmpty) { + children.classList.add(cssClass('empty')); + } + + // set CSS classes for root element + if (this.config && this.config.theme) { + this.element.classList.add(cssClass(this.config.theme)); + } + if (this.isOpen) { + this.element.classList.add(cssClass('open')); + } + + // append toggler and children elements to root element + if (!skipRoot) { + this.element.appendChild(togglerLink); + } + + if (!this.skipChildren) { + this.element.appendChild(children); + } else { + // remove togglerIcon + togglerLink.removeChild(togglerIcon); + } + + // if formatter is set to be open call appendChildren + if (this.isObject && this.isOpen) { + this.appendChildren(); + } + + // add event listener for toggling + if (this.isObject) { + togglerLink.addEventListener('click', this.toggleOpen.bind(this)); + } + + return this.element as HTMLDivElement; + } + + /** + * Appends all the children to children element + * Animated option is used when user triggers this via a click + */ + appendChildren(animated = false) { + const children = this.element.querySelector(`div.${cssClass('children')}`); + + if (!children || this.isEmpty) { return; } + + if (animated) { + let index = 0; + const addAChild = ()=> { + const key = this.keys[index]; + const formatter = new JsonExplorer(this.json[key], this.open - 1, this.config, key); + children.appendChild(formatter.render()); + + index += 1; + + if (index < this.keys.length) { + if (index > MAX_ANIMATED_TOGGLE_ITEMS) { + addAChild(); + } else { + requestAnimationFrame(addAChild); + } + } + }; + + requestAnimationFrame(addAChild); + + } else { + this.keys.forEach(key => { + const formatter = new JsonExplorer(this.json[key], this.open - 1, this.config, key); + children.appendChild(formatter.render()); + }); + } + } + + /** + * Removes all the children from children element + * Animated option is used when user triggers this via a click + */ + removeChildren(animated = false) { + const childrenElement = this.element.querySelector(`div.${cssClass('children')}`) as HTMLDivElement; + + if (animated) { + let childrenRemoved = 0; + const removeAChild = ()=> { + if (childrenElement && childrenElement.children.length) { + childrenElement.removeChild(childrenElement.children[0]); + childrenRemoved += 1; + if (childrenRemoved > MAX_ANIMATED_TOGGLE_ITEMS) { + removeAChild(); + } else { + requestAnimationFrame(removeAChild); + } + } + }; + requestAnimationFrame(removeAChild); + } else { + if (childrenElement) { + childrenElement.innerHTML = ''; + } + } + } +} diff --git a/public/app/core/core.ts b/public/app/core/core.ts index eef62b510be..92db42be8c4 100644 --- a/public/app/core/core.ts +++ b/public/app/core/core.ts @@ -34,6 +34,7 @@ import {switchDirective} from './components/switch'; import {dashboardSelector} from './components/dashboard_selector'; import {queryPartEditorDirective} from './components/query_part/query_part_editor'; import {WizardFlow} from './components/wizard/wizard'; +import {formDropdownDirective} from './components/form_dropdown/form_dropdown'; import 'app/core/controllers/all'; import 'app/core/services/all'; import 'app/core/routes/routes'; @@ -45,6 +46,8 @@ import {assignModelProperties} from './utils/model_utils'; import {contextSrv} from './services/context_srv'; import {KeybindingSrv} from './services/keybindingSrv'; import {helpModal} from './components/help/help'; +import {collapseBox} from './components/collapse_box'; +import {JsonExplorer} from './components/json_explorer/json_explorer'; import {NavModelSrv, NavModel} from './nav_model_srv'; import {userPicker} from './components/user_picker'; import {userGroupPicker} from './components/user_group_picker'; @@ -67,10 +70,13 @@ export { queryPartEditorDirective, WizardFlow, colors, + formDropdownDirective, assignModelProperties, contextSrv, KeybindingSrv, helpModal, + collapseBox, + JsonExplorer, NavModelSrv, NavModel, userPicker, diff --git a/public/app/core/directives/dash_edit_link.js b/public/app/core/directives/dash_edit_link.js index 3e4bdd4c5c7..a4c1ad53b3c 100644 --- a/public/app/core/directives/dash_edit_link.js +++ b/public/app/core/directives/dash_edit_link.js @@ -35,7 +35,7 @@ function ($, angular, coreModule) { options.html = editViewMap[options.editview].html; } - if (lastEditView === options.editview) { + if (lastEditView && lastEditView === options.editview) { hideEditorPane(false); return; } diff --git a/public/app/core/directives/misc.js b/public/app/core/directives/misc.js index 8f6f1fc52a7..f3667c42c13 100644 --- a/public/app/core/directives/misc.js +++ b/public/app/core/directives/misc.js @@ -1,9 +1,10 @@ define([ 'angular', + 'require', '../core_module', 'app/core/utils/kbn', ], -function (angular, coreModule, kbn) { +function (angular, require, coreModule, kbn) { 'use strict'; coreModule.default.directive('tip', function($compile) { @@ -18,6 +19,29 @@ function (angular, coreModule, kbn) { }; }); + coreModule.default.directive('clipboardButton', function() { + return { + scope: { + getText: '&clipboardButton' + }, + link: function(scope, elem) { + require(['vendor/clipboard/dist/clipboard'], function(Clipboard) { + scope.clipboard = new Clipboard(elem[0], { + text: function() { + return scope.getText(); + } + }); + }); + + scope.$on('$destroy', function() { + if (scope.clipboard) { + scope.clipboard.destroy(); + } + }); + } + }; + }); + coreModule.default.directive('compile', function($compile) { return { restrict: 'A', @@ -77,10 +101,10 @@ function (angular, coreModule, kbn) { text + tip + ''; var template = - '' + - ' '; + '' + + ' '; template = template + label; elem.addClass('gf-form-checkbox'); @@ -105,7 +129,7 @@ function (angular, coreModule, kbn) { var li = '' + '' + (item.text || '') + ''; + '>' + (item.text || '') + ''; if (item.submenu && item.submenu.length) { li += buildTemplate(item.submenu).join('\n'); diff --git a/public/app/core/directives/plugin_component.ts b/public/app/core/directives/plugin_component.ts index 22c83f9b557..d0f21501e7e 100644 --- a/public/app/core/directives/plugin_component.ts +++ b/public/app/core/directives/plugin_component.ts @@ -109,7 +109,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ baseUrl: ds.meta.baseUrl, name: 'query-ctrl-' + ds.meta.id, bindings: {target: "=", panelCtrl: "=", datasource: "="}, - attrs: {"target": "target", "panel-ctrl": "ctrl", datasource: "datasource"}, + attrs: {"target": "target", "panel-ctrl": "ctrl.panelCtrl", datasource: "datasource"}, Component: dsModule.QueryCtrl }; }); @@ -127,7 +127,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ baseUrl: ds.meta.baseUrl, name: 'query-options-ctrl-' + ds.meta.id, bindings: {panelCtrl: "="}, - attrs: {"panel-ctrl": "ctrl"}, + attrs: {"panel-ctrl": "ctrl.panelCtrl"}, Component: dsModule.QueryOptionsCtrl }; }); diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index ff1de594949..0dc6d07dc53 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -4,6 +4,7 @@ import angular from 'angular'; import _ from 'lodash'; import config from 'app/core/config'; import coreModule from 'app/core/core_module'; +import appEvents from 'app/core/app_events'; export class BackendSrv { inFlightRequests = {}; @@ -150,7 +151,10 @@ export class BackendSrv { } } - return this.$http(options).catch(err => { + return this.$http(options).then(response => { + appEvents.emit('ds-request-response', response); + return response; + }).catch(err => { if (err.status === this.HTTP_REQUEST_CANCELLED) { throw {err, cancelled: true}; } @@ -166,7 +170,7 @@ export class BackendSrv { }); } - //populate error obj on Internal Error + // populate error obj on Internal Error if (_.isString(err.data) && err.status === 500) { err.data = { error: err.statusText, @@ -179,7 +183,9 @@ export class BackendSrv { err.data.message = err.data.error; } + appEvents.emit('ds-request-error', err); throw err; + }).finally(() => { // clean up if (options.requestId) { diff --git a/public/app/features/dashboard/export/exporter.ts b/public/app/features/dashboard/export/exporter.ts index 0a0b4cbdaab..d76e782905b 100644 --- a/public/app/features/dashboard/export/exporter.ts +++ b/public/app/features/dashboard/export/exporter.ts @@ -103,7 +103,7 @@ export class DashboardExporter { templateizeDatasourceUsage(variable); variable.options = []; variable.current = {}; - variable.refresh = 1; + variable.refresh = variable.refresh > 0 ? variable.refresh : 1; } } diff --git a/public/app/features/dashboard/history/history.ts b/public/app/features/dashboard/history/history.ts index 10ab3f3fa28..f199d84ffb4 100644 --- a/public/app/features/dashboard/history/history.ts +++ b/public/app/features/dashboard/history/history.ts @@ -27,6 +27,7 @@ export class HistoryListCtrl { /** @ngInject */ constructor(private $scope, + private $route, private $rootScope, private $location, private $window, @@ -179,6 +180,7 @@ export class HistoryListCtrl { this.loading = true; return this.historySrv.restoreDashboard(this.dashboard, version).then(response => { this.$location.path('dashboard/db/' + response.slug); + this.$route.reload(); this.$rootScope.appEvent('alert-success', ['Dashboard restored', 'Restored from version ' + version]); }).catch(() => { this.mode = 'list'; diff --git a/public/app/features/dashboard/partials/shareModal.html b/public/app/features/dashboard/partials/shareModal.html index f9bf5ccf4ad..382baec1aba 100644 --- a/public/app/features/dashboard/partials/shareModal.html +++ b/public/app/features/dashboard/partials/shareModal.html @@ -86,7 +86,7 @@
- +
@@ -143,7 +143,7 @@ {{snapshotUrl}}
- + diff --git a/public/app/features/dashboard/shareModalCtrl.js b/public/app/features/dashboard/shareModalCtrl.js index 6f3cb320bef..211b0efbac9 100644 --- a/public/app/features/dashboard/shareModalCtrl.js +++ b/public/app/features/dashboard/shareModalCtrl.js @@ -2,10 +2,9 @@ define(['angular', 'lodash', 'jquery', 'moment', - 'require', 'app/core/config', ], -function (angular, _, $, moment, require, config) { +function (angular, _, $, moment, config) { 'use strict'; var module = angular.module('grafana.controllers'); @@ -89,20 +88,10 @@ function (angular, _, $, moment, require, config) { $scope.imageUrl += '&tz=UTC' + encodeURIComponent(moment().format("Z")); }; - }); - - module.directive('clipboardButton',function() { - return function(scope, elem) { - require(['vendor/clipboard/dist/clipboard'], function(Clipboard) { - scope.clipboard = new Clipboard(elem[0]); - }); - - scope.$on('$destroy', function() { - if (scope.clipboard) { - scope.clipboard.destroy(); - } - }); + $scope.getShareUrl = function() { + return $scope.shareUrl; }; + }); }); diff --git a/public/app/features/dashboard/shareSnapshotCtrl.js b/public/app/features/dashboard/shareSnapshotCtrl.js index 846dc0f7ed7..a30caf7f1c9 100644 --- a/public/app/features/dashboard/shareSnapshotCtrl.js +++ b/public/app/features/dashboard/shareSnapshotCtrl.js @@ -96,6 +96,10 @@ function (angular, _) { }); }; + $scope.getSnapshotUrl = function() { + return $scope.snapshotUrl; + }; + $scope.scrubDashboard = function(dash) { // change title dash.title = $scope.snapshot.name; diff --git a/public/app/features/panel/all.js b/public/app/features/panel/all.js index 2f978e65345..cba296643ef 100644 --- a/public/app/features/panel/all.js +++ b/public/app/features/panel/all.js @@ -5,5 +5,5 @@ define([ './query_ctrl', './panel_editor_tab', './query_editor_row', - './metrics_ds_selector', + './query_troubleshooter', ], function () {}); diff --git a/public/app/features/panel/metrics_ds_selector.ts b/public/app/features/panel/metrics_ds_selector.ts deleted file mode 100644 index 7c4bc0ef495..00000000000 --- a/public/app/features/panel/metrics_ds_selector.ts +++ /dev/null @@ -1,113 +0,0 @@ -/// - -import angular from 'angular'; -import _ from 'lodash'; - -var module = angular.module('grafana.directives'); - -var template = ` -
-
-
- - - - -
- -
- - - -
-
-
-`; - - -export class MetricsDsSelectorCtrl { - dsSegment: any; - mixedDsSegment: any; - dsName: string; - panelCtrl: any; - datasources: any[]; - current: any; - - /** @ngInject */ - constructor(private uiSegmentSrv, datasourceSrv) { - this.datasources = datasourceSrv.getMetricSources(); - - var dsValue = this.panelCtrl.panel.datasource || null; - - for (let ds of this.datasources) { - if (ds.value === dsValue) { - this.current = ds; - } - } - - if (!this.current) { - this.current = {name: dsValue + ' not found', value: null}; - } - - this.dsSegment = uiSegmentSrv.newSegment({value: this.current.name, selectMode: true}); - this.mixedDsSegment = uiSegmentSrv.newSegment({value: 'Add query', selectMode: true}); - } - - getOptions(includeBuiltin) { - return Promise.resolve(this.datasources.filter(value => { - return includeBuiltin || !value.meta.builtIn; - }).map(value => { - return this.uiSegmentSrv.newSegment(value.name); - })); - } - - datasourceChanged() { - var ds = _.find(this.datasources, {name: this.dsSegment.value}); - if (ds) { - this.current = ds; - this.panelCtrl.setDatasource(ds); - } - } - - mixedDatasourceChanged() { - var target: any = {isNew: true}; - var ds = _.find(this.datasources, {name: this.mixedDsSegment.value}); - if (ds) { - target.datasource = ds.name; - this.panelCtrl.panel.targets.push(target); - this.mixedDsSegment.value = ''; - } - } - - addDataQuery() { - var target: any = {isNew: true}; - this.panelCtrl.panel.targets.push(target); - } -} - -module.directive('metricsDsSelector', function() { - return { - restrict: 'E', - template: template, - controller: MetricsDsSelectorCtrl, - bindToController: true, - controllerAs: 'ctrl', - transclude: true, - scope: { - panelCtrl: "=" - } - }; -}); diff --git a/public/app/features/panel/metrics_panel_ctrl.ts b/public/app/features/panel/metrics_panel_ctrl.ts index af9d06d8742..e3b4e9c77cf 100644 --- a/public/app/features/panel/metrics_panel_ctrl.ts +++ b/public/app/features/panel/metrics_panel_ctrl.ts @@ -1,5 +1,6 @@ /// +import angular from 'angular'; import config from 'app/core/config'; import $ from 'jquery'; import _ from 'lodash'; @@ -10,6 +11,7 @@ import * as rangeUtil from 'app/core/utils/rangeutil'; import * as dateMath from 'app/core/utils/datemath'; import {Subject} from 'vendor/npm/rxjs/Subject'; +import {metricsTabDirective} from './metrics_tab'; class MetricsPanelCtrl extends PanelCtrl { scope: any; @@ -32,6 +34,7 @@ class MetricsPanelCtrl extends PanelCtrl { dataStream: any; dataSubscription: any; dataList: any; + nextRefId: string; constructor($scope, $injector) { super($scope, $injector); @@ -61,7 +64,7 @@ class MetricsPanelCtrl extends PanelCtrl { } private onInitMetricsPanelEditMode() { - this.addEditorTab('Metrics', 'public/app/partials/metrics.html'); + this.addEditorTab('Metrics', metricsTabDirective); this.addEditorTab('Time range', 'public/app/features/panel/partials/panelTime.html'); } @@ -256,7 +259,7 @@ class MetricsPanelCtrl extends PanelCtrl { result = {data: []}; } - return this.events.emit('data-received', result.data); + this.events.emit('data-received', result.data); } handleDataStream(stream) { @@ -306,6 +309,25 @@ class MetricsPanelCtrl extends PanelCtrl { this.datasource = null; this.refresh(); } + + addQuery(target) { + target.refId = this.dashboard.getNextQueryLetter(this.panel); + + this.panel.targets.push(target); + this.nextRefId = this.dashboard.getNextQueryLetter(this.panel); + } + + removeQuery(target) { + var index = _.indexOf(this.panel.targets, target); + this.panel.targets.splice(index, 1); + this.nextRefId = this.dashboard.getNextQueryLetter(this.panel); + this.refresh(); + } + + moveQuery(target, direction) { + var index = _.indexOf(this.panel.targets, target); + _.move(this.panel.targets, index, index + direction); + } } export {MetricsPanelCtrl}; diff --git a/public/app/features/panel/metrics_tab.ts b/public/app/features/panel/metrics_tab.ts new file mode 100644 index 00000000000..c03c4d83bc2 --- /dev/null +++ b/public/app/features/panel/metrics_tab.ts @@ -0,0 +1,83 @@ +/// + +import _ from 'lodash'; +//import {coreModule} from 'app/core/core'; +import {DashboardModel} from '../dashboard/model'; + +export class MetricsTabCtrl { + dsName: string; + panel: any; + panelCtrl: any; + datasources: any[]; + current: any; + nextRefId: string; + dashboard: DashboardModel; + panelDsValue: any; + addQueryDropdown: any; + + /** @ngInject */ + constructor($scope, private uiSegmentSrv, private datasourceSrv) { + this.panelCtrl = $scope.ctrl; + $scope.ctrl = this; + + this.panel = this.panelCtrl.panel; + this.dashboard = this.panelCtrl.dashboard; + this.datasources = datasourceSrv.getMetricSources(); + this.panelDsValue = this.panelCtrl.panel.datasource || null; + + for (let ds of this.datasources) { + if (ds.value === this.panelDsValue) { + this.current = ds; + } + } + + this.addQueryDropdown = {text: 'Add Query', value: null, fake: true}; + + // update next ref id + this.panelCtrl.nextRefId = this.dashboard.getNextQueryLetter(this.panel); + } + + getOptions(includeBuiltin) { + return Promise.resolve(this.datasources.filter(value => { + return includeBuiltin || !value.meta.builtIn; + }).map(ds => { + return {value: ds.value, text: ds.name, datasource: ds}; + })); + } + + datasourceChanged(option) { + if (!option) { + return; + } + + this.current = option.datasource; + this.panelCtrl.setDatasource(option.datasource); + } + + addMixedQuery(option) { + if (!option) { + return; + } + + var target: any = {isNew: true}; + this.panelCtrl.addQuery({isNew: true, datasource: option.datasource.name}); + this.addQueryDropdown = {text: 'Add Query', value: null, fake: true}; + } + + addQuery() { + this.panelCtrl.addQuery({isNew: true}); + } +} + +/** @ngInject **/ +export function metricsTabDirective() { + 'use strict'; + return { + restrict: 'E', + scope: true, + templateUrl: 'public/app/features/panel/partials/metrics_tab.html', + controller: MetricsTabCtrl, + }; +} + +//coreModule.directive('metricsTab', metricsTabDirective); diff --git a/public/app/features/panel/panel_ctrl.ts b/public/app/features/panel/panel_ctrl.ts index 83c79f4123b..5ed2b3abffb 100644 --- a/public/app/features/panel/panel_ctrl.ts +++ b/public/app/features/panel/panel_ctrl.ts @@ -75,7 +75,7 @@ export class PanelCtrl { } refresh() { - this.events.emit('refresh', null); + this.events.emit('refresh', null); } publishAppEvent(evtName, evt) { diff --git a/public/app/features/panel/panel_editor_tab.ts b/public/app/features/panel/panel_editor_tab.ts index f6c11306678..9bbb979bcc0 100644 --- a/public/app/features/panel/panel_editor_tab.ts +++ b/public/app/features/panel/panel_editor_tab.ts @@ -16,10 +16,15 @@ function panelEditorTab(dynamicDirectiveSrv) { directive: scope => { var pluginId = scope.ctrl.pluginId; var tabIndex = scope.index; + // create a wrapper for directiveFn + // required for metrics tab directive + // that is the same for many panels but + // given different names in this function + var fn = () => scope.editorTab.directiveFn(); return Promise.resolve({ name: `panel-editor-tab-${pluginId}${tabIndex}`, - fn: scope.editorTab.directiveFn, + fn: fn, }); } }); diff --git a/public/app/features/panel/partials/metrics_tab.html b/public/app/features/panel/partials/metrics_tab.html new file mode 100644 index 00000000000..bc0bcf7c6b2 --- /dev/null +++ b/public/app/features/panel/partials/metrics_tab.html @@ -0,0 +1,50 @@ +
+
+ + + + +
+ +
+
+ + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + + + + diff --git a/public/app/features/panel/query_editor_row.ts b/public/app/features/panel/query_editor_row.ts index 8249d84774f..1241d45db69 100644 --- a/public/app/features/panel/query_editor_row.ts +++ b/public/app/features/panel/query_editor_row.ts @@ -21,7 +21,7 @@ export class QueryRowCtrl { this.panel = this.panelCtrl.panel; if (!this.target.refId) { - this.target.refId = this.getNextQueryLetter(); + this.target.refId = this.panelCtrl.dashboard.getNextQueryLetter(this.panel); } this.toggleCollapse(true); @@ -40,16 +40,6 @@ export class QueryRowCtrl { this.panelCtrl.refresh(); } - getNextQueryLetter() { - var letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - - return _.find(letters, refId => { - return _.every(this.panel.targets, function(other) { - return other.refId !== refId; - }); - }); - } - toggleCollapse(init) { if (!this.canCollapse) { return; @@ -87,19 +77,16 @@ export class QueryRowCtrl { delete this.panelCtrl.__collapsedQueryCache[this.target.refId]; } - this.panel.targets = _.without(this.panel.targets, this.target); - this.panelCtrl.refresh(); + this.panelCtrl.removeQuery(this.target); } duplicateQuery() { var clone = angular.copy(this.target); - clone.refId = this.getNextQueryLetter(); - this.panel.targets.push(clone); + this.panelCtrl.addQuery(clone); } moveQuery(direction) { - var index = _.indexOf(this.panel.targets, this.target); - _.move(this.panel.targets, index, index + direction); + this.panelCtrl.moveQuery(this.target, direction); } } diff --git a/public/app/features/panel/query_troubleshooter.ts b/public/app/features/panel/query_troubleshooter.ts new file mode 100644 index 00000000000..84ae2045dc7 --- /dev/null +++ b/public/app/features/panel/query_troubleshooter.ts @@ -0,0 +1,154 @@ +/// + +import _ from 'lodash'; +import appEvents from 'app/core/app_events'; +import {coreModule, JsonExplorer} from 'app/core/core'; + +const template = ` + + + + Expand All + + + Collapse All + + Copy to Clipboard + + + +
+
+
+`; + +export class QueryTroubleshooterCtrl { + isOpen: any; + isLoading: boolean; + showResponse: boolean; + panelCtrl: any; + renderJsonExplorer: (data) => void; + onRequestErrorEventListener: any; + onRequestResponseEventListener: any; + hasError: boolean; + allNodesExpanded: boolean; + jsonExplorer: JsonExplorer; + + /** @ngInject **/ + constructor($scope, private $timeout) { + this.onRequestErrorEventListener = this.onRequestError.bind(this); + this.onRequestResponseEventListener = this.onRequestResponse.bind(this); + + appEvents.on('ds-request-response', this.onRequestResponseEventListener); + appEvents.on('ds-request-error', this.onRequestErrorEventListener); + $scope.$on('$destroy', this.removeEventsListeners.bind(this)); + } + + removeEventsListeners() { + appEvents.off('ds-request-response', this.onRequestResponseEventListener); + appEvents.off('ds-request-error', this.onRequestErrorEventListener); + } + + onRequestError(err) { + this.isOpen = true; + this.hasError = true; + this.onRequestResponse(err); + } + + stateChanged() { + if (this.isOpen) { + this.panelCtrl.refresh(); + this.isLoading = true; + } + } + + getClipboardText() { + if (this.jsonExplorer) { + return JSON.stringify(this.jsonExplorer.json, null, 2); + } + } + + onRequestResponse(data) { + // ignore if closed + if (!this.isOpen) { + return; + } + + this.isLoading = false; + data = _.cloneDeep(data); + + if (data.headers) { + delete data.headers; + } + + if (data.config) { + data.request = data.config; + delete data.config; + delete data.request.transformRequest; + delete data.request.transformResponse; + delete data.request.paramSerializer; + delete data.request.jsonpCallbackParam; + delete data.request.headers; + delete data.request.requestId; + delete data.request.inspect; + delete data.request.retry; + delete data.request.timeout; + } + + if (data.data) { + data.response = data.data; + + if (data.status === 200) { + // if we are in error state, assume we automatically opened + // and auto close it again + if (this.hasError) { + this.hasError = false; + this.isOpen = false; + } + } + + delete data.data; + delete data.status; + delete data.statusText; + delete data.$$config; + } + + this.$timeout(_.partial(this.renderJsonExplorer, data)); + } + + toggleExpand(depth) { + if (this.jsonExplorer) { + this.allNodesExpanded = !this.allNodesExpanded; + this.jsonExplorer.openAtDepth(this.allNodesExpanded ? 20 : 1); + } + } +} + +export function queryTroubleshooter() { + return { + restrict: 'E', + template: template, + controller: QueryTroubleshooterCtrl, + bindToController: true, + controllerAs: 'ctrl', + scope: { + panelCtrl: "=" + }, + link: function(scope, elem, attrs, ctrl) { + + ctrl.renderJsonExplorer = function(data) { + var jsonElem = elem.find('.query-troubleshooter-json'); + + ctrl.jsonExplorer = new JsonExplorer(data, 3, { + animateOpen: true, + }); + + const html = ctrl.jsonExplorer.render(true); + jsonElem.html(html); + }; + } + }; +} + +coreModule.directive('queryTroubleshooter', queryTroubleshooter); diff --git a/public/app/headers/common.d.ts b/public/app/headers/common.d.ts index 314258be21e..9ea5e96654d 100644 --- a/public/app/headers/common.d.ts +++ b/public/app/headers/common.d.ts @@ -72,4 +72,3 @@ declare module 'd3' { var d3: any; export default d3; } - diff --git a/public/app/partials/login.html b/public/app/partials/login.html index 38517951cea..fe19d34f8d5 100644 --- a/public/app/partials/login.html +++ b/public/app/partials/login.html @@ -60,7 +60,7 @@ GitHub - + Grafana.com diff --git a/public/app/partials/metrics.html b/public/app/partials/metrics.html deleted file mode 100644 index 39c471eabd1..00000000000 --- a/public/app/partials/metrics.html +++ /dev/null @@ -1,20 +0,0 @@ - -
-
- - - - -
-
- - - -
- - - - -
- -
diff --git a/public/app/plugins/datasource/elasticsearch/bucket_agg.js b/public/app/plugins/datasource/elasticsearch/bucket_agg.js index b2cfc819579..5adaed173af 100644 --- a/public/app/plugins/datasource/elasticsearch/bucket_agg.js +++ b/public/app/plugins/datasource/elasticsearch/bucket_agg.js @@ -26,13 +26,21 @@ function (angular, _, queryDef) { var bucketAggs = $scope.target.bucketAggs; $scope.orderByOptions = []; - $scope.bucketAggTypes = queryDef.bucketAggTypes; - $scope.orderOptions = queryDef.orderOptions; - $scope.sizeOptions = queryDef.sizeOptions; + + $scope.getBucketAggTypes = function() { + return queryDef.bucketAggTypes; + }; + + $scope.getOrderOptions = function() { + return queryDef.orderOptions; + }; + + $scope.getSizeOptions = function() { + return queryDef.sizeOptions; + }; $rootScope.onAppEvent('elastic-query-updated', function() { $scope.validateModel(); - $scope.updateOrderByOptions(); }, $scope); $scope.init = function() { @@ -166,11 +174,10 @@ function (angular, _, queryDef) { $scope.toggleOptions = function() { $scope.showOptions = !$scope.showOptions; - $scope.updateOrderByOptions(); }; - $scope.updateOrderByOptions = function() { - $scope.orderByOptions = queryDef.getOrderByOptions($scope.target); + $scope.getOrderByOptions = function() { + return queryDef.getOrderByOptions($scope.target); }; $scope.getFieldsInternal = function() { diff --git a/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html b/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html index 8c14f3ef356..a180f97c994 100644 --- a/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html +++ b/public/app/plugins/datasource/elasticsearch/partials/bucket_agg.html @@ -5,8 +5,22 @@ Then by - - + + + +
@@ -33,7 +47,13 @@
- + +
@@ -66,11 +86,23 @@
- + +
- + +
@@ -78,7 +110,13 @@
- + +