Browse Source

mysql: annotation support. Fixes #8382

Simple query editor - a text area with a Show Help section.
Validation for empty query and if the time_sec column is missing.
Daniel Lee 8 years ago
parent
commit
73cb035231

+ 6 - 1
public/app/features/annotations/annotations_srv.ts

@@ -57,7 +57,12 @@ export class AnnotationsSrv {
       };
 
     }).catch(err => {
-      this.$rootScope.appEvent('alert-error', ['Annotations failed', (err.message || 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 [];
     });
   }
 

+ 62 - 0
public/app/plugins/datasource/mysql/datasource.ts

@@ -52,6 +52,68 @@ export class MysqlDatasource {
     }).then(this.processQueryResult.bind(this));
   }
 
+  annotationQuery(options) {
+    if (!options.annotation.rawQuery) {
+      return this.$q.reject({message: 'Query missing in annotation definition'});
+    }
+
+    const query = {
+      refId: options.annotation.name,
+      datasourceId: this.id,
+      rawSql: this.templateSrv.replace(options.annotation.rawQuery, options.scopedVars, this.interpolateVariable),
+      format: 'table',
+    };
+
+    return this.backendSrv.datasourceRequest({
+      url: '/api/tsdb/query',
+      method: 'POST',
+      data: {
+        from: options.range.from.valueOf().toString(),
+        to: options.range.to.valueOf().toString(),
+        queries: [query],
+      }
+    }).then(this.transformAnnotationResponse.bind(this, options));
+  }
+
+  transformAnnotationResponse(options, data) {
+    const table = data.data.results[options.annotation.name].tables[0];
+
+    let timeColumnIndex = -1;
+    let titleColumnIndex = -1;
+    let textColumnIndex = -1;
+    let tagsColumnIndex = -1;
+
+    for (let i = 0; i < table.columns.length; i++) {
+      if (table.columns[i].text === 'time_sec') {
+        timeColumnIndex = i;
+      } else if (table.columns[i].text === 'title') {
+        titleColumnIndex = i;
+      } else if (table.columns[i].text === 'text') {
+        textColumnIndex = i;
+      } else if (table.columns[i].text === 'tags') {
+        tagsColumnIndex = i;
+      }
+    }
+
+    if (timeColumnIndex === -1) {
+      return this.$q.reject({message: 'Missing mandatory time column (with time_sec column alias) in annotation query.'});
+    }
+
+    const list = [];
+    for (let i = 0; i < table.rows.length; i++) {
+      const row = table.rows[i];
+      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*/) : []
+      });
+    }
+
+    return list;
+  }
+
   testDatasource() {
     return this.backendSrv.datasourceRequest({
       url: '/api/tsdb/query',

+ 23 - 0
public/app/plugins/datasource/mysql/module.ts

@@ -9,10 +9,33 @@ class MysqlConfigCtrl {
   static templateUrl = 'partials/config.html';
 }
 
+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>
+  WHERE $__timeFilter(time_column)
+  ORDER BY <time_column> ASC
+  LIMIT 100
+  `;
+
+class MysqlAnnotationsQueryCtrl {
+  static templateUrl = 'partials/annotations.editor.html';
+
+  annotation: any;
+
+  /** @ngInject **/
+  constructor() {
+    this.annotation.rawQuery = this.annotation.rawQuery || defaultQuery;
+  }
+}
+
 export {
   MysqlDatasource,
   MysqlDatasource as Datasource,
   MysqlQueryCtrl as QueryCtrl,
   MysqlConfigCtrl as ConfigCtrl,
+  MysqlAnnotationsQueryCtrl as AnnotationsQueryCtrl,
 };
 

+ 28 - 14
public/app/plugins/datasource/mysql/partials/annotations.editor.html

@@ -1,20 +1,34 @@
 
 <div class="gf-form-group">
-	<h6>Filters</h6>
-	<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: 'Alert', value: 'alert'}]">
-				</select>
-			</div>
+  <div class="gf-form-inline">
+		<div class="gf-form gf-form--grow">
+			<textarea rows="10" class="gf-form-input" ng-model="ctrl.annotation.rawQuery" spellcheck="false" placeholder="query expression" data-min-length=0 data-items=100 ng-model-onblur ng-change="ctrl.panelCtrl.refresh()"></textarea>
 		</div>
-		<div class="gf-form">
-			<span class="gf-form-label width-7">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>
-			</div>
+	</div>
+
+  <div class="gf-form-inline">
+    <div class="gf-form">
+      <label class="gf-form-label query-keyword" ng-click="ctrl.showHelp = !ctrl.showHelp">
+        Show Help
+        <i class="fa fa-caret-down" ng-show="ctrl.showHelp"></i>
+        <i class="fa fa-caret-right" ng-hide="ctrl.showHelp"></i>
+      </label>
 		</div>
+  </div>
+
+  <div class="gf-form"  ng-show="ctrl.showHelp">
+		<pre class="gf-form-pre alert alert-info"><h6>Annotation Query Format</h6>
+An annotation is an event that is overlayed on top of graphs. The query can have up to four columns per row, the time_sec column is mandatory. Annotation rendering is expensive so it is important to limit the number of rows returned.
+
+- column with alias: <b>time_sec</b> for the annotation event. Format is UTC in seconds, use UNIX_TIMESTAMP(column)
+- column with alias <b>title</b> for the annotation title
+- column with alias: <b>text</b> for the annotation text
+- column with alias: <b>tags</b> for annotation tags. This is a comma separated string of tags e.g. 'tag1,tag2'
+
+
+Macros:
+- $__time(column) -&gt; UNIX_TIMESTAMP(column) as time_sec
+- $__timeFilter(column) -&gt;  UNIX_TIMESTAMP(time_date_time) &gt; from AND UNIX_TIMESTAMP(time_date_time) &lt; 1492750877
+		</pre>
 	</div>
 </div>

+ 2 - 2
public/app/plugins/datasource/mysql/query_ctrl.ts

@@ -17,7 +17,7 @@ export interface QueryMeta {
 }
 
 
-var defaulQuery = `SELECT
+const defaultQuery = `SELECT
   UNIX_TIMESTAMP(<time_column>) as time_sec,
   <value column> as value,
   <series name column> as metric
@@ -54,7 +54,7 @@ export class MysqlQueryCtrl extends QueryCtrl {
         this.target.format = 'table';
         this.target.rawSql = "SELECT 1";
       } else {
-        this.target.rawSql = defaulQuery;
+        this.target.rawSql = defaultQuery;
       }
     }
 

+ 79 - 0
public/app/plugins/datasource/mysql/specs/datasource_specs.ts

@@ -0,0 +1,79 @@
+import {describe, beforeEach, it, sinon, expect, angularMocks} from 'test/lib/common';
+import moment from 'moment';
+import helpers from 'test/specs/helpers';
+import {MysqlDatasource} from '../datasource';
+
+describe('MySQLDatasource', function() {
+  var ctx = new helpers.ServiceTestContext();
+  var instanceSettings = {name: 'mysql'};
+
+  beforeEach(angularMocks.module('grafana.core'));
+  beforeEach(angularMocks.module('grafana.services'));
+  beforeEach(ctx.providePhase(['backendSrv']));
+
+  beforeEach(angularMocks.inject(function($q, $rootScope, $httpBackend, $injector) {
+    ctx.$q = $q;
+    ctx.$httpBackend =  $httpBackend;
+    ctx.$rootScope = $rootScope;
+    ctx.ds = $injector.instantiate(MysqlDatasource, {instanceSettings: instanceSettings});
+    $httpBackend.when('GET', /\.html$/).respond('');
+  }));
+
+  describe('When performing annotationQuery', function() {
+    let results;
+
+    const annotationName = 'MyAnno';
+
+    const options = {
+      annotation: {
+        name: annotationName,
+        rawQuery: 'select time_sec, title, text, tags from table;'
+      },
+      range: {
+        from: moment(1432288354),
+        to: moment(1432288401)
+      }
+    };
+
+    const response = {
+      results: {
+        MyAnno: {
+          refId: annotationName,
+          tables: [
+            {
+              columns: [{text: 'time_sec'}, {text: 'title'}, {text: 'text'}, {text: 'tags'}],
+              rows: [
+                [1432288355, 'aTitle', 'some text', 'TagA,TagB'],
+                [1432288390, 'aTitle2', 'some text2', ' TagB , TagC'],
+                [1432288400, 'aTitle3', 'some text3']
+              ]
+            }
+          ]
+        }
+      }
+    };
+
+    beforeEach(function() {
+      ctx.backendSrv.datasourceRequest = function(options) {
+        return ctx.$q.when({data: response, status: 200});
+      };
+      ctx.ds.annotationQuery(options).then(function(data) { results = data; });
+      ctx.$rootScope.$apply();
+    });
+
+    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');
+
+      expect(results[1].tags[0]).to.be('TagB');
+      expect(results[1].tags[1]).to.be('TagC');
+
+      expect(results[2].tags.length).to.be(0);
+    });
+  });
+
+});