Browse Source

Create annotations (#8197)

* annotations: add 25px space for events section

* annotations: restored create annotation action

* annotations: able to use fa icons as event markers

* annotations: initial emoji support from twemoji lib

* annotations: adjust fa icon position

* annotations: initial emoji picker

* annotation: include user info into annotation requests

* annotation: add icon info

* annotation: display user info in tooltip

* annotation: fix region saving

* annotation: initial region markers

* annotation: fix region clearing (add flot-temp-elem class)

* annotation: adjust styles a bit

* annotations: minor fixes

* annoations: removed userId look in loop, need a sql join or a user cache for this

* annotation: fix invisible events

* lib: changed twitter emoij lib to be npm dependency

* annotation: add icon picker to Add Annotation dialog

* annotation: save icon to annotation table

* annotation: able to set custom icon for annotation added by user

* annotations: fix emoji after library upgrade (switch to 72px)

* emoji: temporary remove bad code points

* annotations: improve icon picker

* annotations: icon show icon picker at the top

* annotations: use svg for emoji

* annotations: fix region drawing when add annotation editor opened

* annotations: use flot lib for drawing region fill

* annotations: move regions building into event_manager

* annotations: don't draw additional space if no events are got

* annotations: deduplicate events

* annotations: properly render cut regions

* annotations: fix cut region building

* annotations: refactor

* annotations: adjust event section size

* add-annotations: fix undefined default icon

* create-annotations:  edit event (frontend part)

* fixed bug causes error when hover event marker

* create-annotations:  update event (backend)

* ignore grafana-server debug binary in git (created VS Code)

* create-annotations: use PUT request for updating annotation.

* create-annotations: fixed time format when editing existing event

* create-annotations: support for region update

* create-annotations: fix bug with limit and event type

* create-annotations: delete annotation

* create-annotations: show only selected icon in edit mode

* create-annotations: show event editor only for users with at least Editor role

* create-annotations: handle double-sized emoji codepoints

* create-annotations: refactor

use CP_SEPARATOR from emojiDef

* create-annotations: update emoji list, add categories.

* create-annotations: copy SVG emoji into public/vendor/npm and use it as a base path

* create-annotations: initial tabs for emoji picker

* emoji-picker: adjust styles

* emoji-picker: minor refactor

* emoji-picker: refactor - rename and move into one directory

* emoji-picker: build emoji elements on app load, not on picker open

* emoji-picker: fix emoji searching

* emoji-picker: refactor

* emoji-picker: capitalize category name

* emoji-picker: refactor

move buildEmojiElem() into emoji_converter.ts for future reuse.

* jquery.flot.events: refactor

use buildEmojiElem() for making emojis, remove unused code for font awesome based icons.

* emoji_converter: handle converting error

* tech: updated

* merged with master

* shore: clean up some stuff

* annotation: wip tags

* annotation: filtering by tags

* tags: parse out spaces etc. from a tags string

* annotations: use tagsinput component for tag filtering

* annotation: wip work on how we query alert & panel annotations

* annotations: support for updating tags in an annotation

* linting

* annotations: work on unifying how alert history annotations and manual panel annotations are created

* tslint: fixes

* tags: create tag on blur as well

Currently, the tags directive only creates the tag when the
user presses enter. This change means the tag is created on
blur as well (when the user clicks outside the input field).

* annotations: fix update after refactoring

* annotations: progress on how alert annotations are fetched

* annotations: minor progress

* annotations: progress

* annotation: minor progress

* annotations: move tag parsing from tooltip to ds

Instead of parsing a tag string into an array in the annotation_tooltip
class, this moves the parsing to the datasources. InfluxDB ds already
does that parsing. Graphite now has it.

* annotations: more work on querying

* annotations: change from tags as string to array

when saving in the db and in the api.

* annotations: delete tag link if removed on edit

* annotation: more work on depricating annotation title

* annotations: delete tag links on delete

* annotations: fix for find

* annotation: added user to annotation tooltip and added alertName to annoation dto

* annotations: use id from route instead from cmd for updating

* annotations: http api docs

* create annotation: last edits

* annotations: minor fix for querying annotations before dashboard saved

* annotations: fix for popover placement when legend is on the side (and doubel render pass is causing original marker to be removed)

* annotations: changing how the built in query gets added

* annotation: added time to header in edit mode

* tests: fixed jshint built issue
Torkel Ödegaard 8 years ago
parent
commit
25aa9df270
72 changed files with 2238 additions and 533 deletions
  1. 1 0
      .gitignore
  2. 189 0
      docs/sources/http_api/annotations.md
  3. 3 2
      package.json
  4. 80 23
      pkg/api/annotations.go
  5. 3 0
      pkg/api/api.go
  6. 2 2
      pkg/api/avatar/avatar.go
  7. 21 28
      pkg/api/dtos/annotations.go
  8. 60 0
      pkg/models/tags.go
  9. 95 0
      pkg/models/tags_test.go
  10. 1 3
      pkg/services/alerting/result_handler.go
  11. 40 26
      pkg/services/annotations/annotations.go
  12. 154 29
      pkg/services/sqlstore/annotation.go
  13. 208 0
      pkg/services/sqlstore/annotation_test.go
  14. 1 0
      pkg/services/sqlstore/dashboard.go
  15. 33 0
      pkg/services/sqlstore/migrations/annotation_mig.go
  16. 1 0
      pkg/services/sqlstore/migrations/migrations.go
  17. 24 0
      pkg/services/sqlstore/migrations/tag_mig.go
  18. 0 3
      public/app/core/components/dashboard_selector.ts
  19. 6 0
      public/app/core/components/grafana_app.ts
  20. 2 0
      public/app/core/components/info_popover.ts
  21. 1 0
      public/app/core/directives/tags.js
  22. 1 1
      public/app/core/nav_model_srv.ts
  23. 0 1
      public/app/features/alerting/alert_def.ts
  24. 30 17
      public/app/features/annotations/annotation_tooltip.ts
  25. 156 84
      public/app/features/annotations/annotations_srv.ts
  26. 13 8
      public/app/features/annotations/editor_ctrl.ts
  27. 3 1
      public/app/features/annotations/event.ts
  28. 48 6
      public/app/features/annotations/event_editor.ts
  29. 106 37
      public/app/features/annotations/event_manager.ts
  30. 48 41
      public/app/features/annotations/partials/editor.html
  31. 31 34
      public/app/features/annotations/partials/event_editor.html
  32. 40 0
      public/app/features/annotations/specs/annotations_srv_specs.ts
  33. 25 0
      public/app/features/dashboard/model.ts
  34. 8 23
      public/app/features/dashboard/specs/dashboard_model_specs.ts
  35. 5 1
      public/app/features/dashboard/specs/exporter_specs.ts
  36. 1 1
      public/app/features/dashboard/submenu/submenu.html
  37. 7 3
      public/app/features/org/prefs_control.ts
  38. 0 6
      public/app/headers/common.d.ts
  39. 15 8
      public/app/plugins/datasource/elasticsearch/datasource.ts
  40. 1 1
      public/app/plugins/datasource/elasticsearch/index_pattern.ts
  41. 9 13
      public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html
  42. 1 1
      public/app/plugins/datasource/elasticsearch/query_builder.ts
  43. 1 1
      public/app/plugins/datasource/elasticsearch/query_def.ts
  44. 45 27
      public/app/plugins/datasource/grafana/datasource.ts
  45. 6 5
      public/app/plugins/datasource/grafana/module.ts
  46. 21 4
      public/app/plugins/datasource/grafana/partials/annotations.editor.html
  47. 20 3
      public/app/plugins/datasource/graphite/datasource.ts
  48. 90 16
      public/app/plugins/datasource/graphite/specs/datasource_specs.ts
  49. 0 2
      public/app/plugins/datasource/influxdb/datasource.ts
  50. 5 7
      public/app/plugins/datasource/influxdb/partials/annotations.editor.html
  51. 0 1
      public/app/plugins/datasource/mysql/module.ts
  52. 1 3
      public/app/plugins/datasource/mysql/response_parser.ts
  53. 5 6
      public/app/plugins/datasource/mysql/specs/datasource_specs.ts
  54. 1 2
      public/app/plugins/datasource/opentsdb/datasource.js
  55. 1 1
      public/app/plugins/panel/alertlist/module.html
  56. 11 10
      public/app/plugins/panel/graph/graph.ts
  57. 215 10
      public/app/plugins/panel/graph/jquery.flot.events.js
  58. 83 0
      public/app/system.conf.js
  59. 1 0
      public/sass/_grafana.scss
  60. 2 1
      public/sass/_variables.dark.scss
  61. 4 2
      public/sass/_variables.light.scss
  62. 7 0
      public/sass/components/_drop.scss
  63. 26 0
      public/sass/components/_icon-picker.scss
  64. 24 9
      public/sass/components/_panel_graph.scss
  65. 0 4
      public/sass/mixins/_drop_element.scss
  66. 130 0
      public/test/test-main.js
  67. 6 1
      public/vendor/flot/jquery.flot.js
  68. 9 3
      public/vendor/tagsinput/bootstrap-tagsinput.js
  69. 3 6
      scripts/webpack/webpack.common.js
  70. 45 0
      tasks/options/copy.js
  71. 2 4
      tsconfig.json
  72. 1 2
      tslint.json

+ 1 - 0
.gitignore

@@ -41,6 +41,7 @@ profile.cov
 .notouch
 .notouch
 /pkg/cmd/grafana-cli/grafana-cli
 /pkg/cmd/grafana-cli/grafana-cli
 /pkg/cmd/grafana-server/grafana-server
 /pkg/cmd/grafana-server/grafana-server
+/pkg/cmd/grafana-server/debug
 /examples/*/dist
 /examples/*/dist
 /packaging/**/*.rpm
 /packaging/**/*.rpm
 /packaging/**/*.deb
 /packaging/**/*.deb

+ 189 - 0
docs/sources/http_api/annotations.md

@@ -0,0 +1,189 @@
++++
+title = "Annotations HTTP API "
+description = "Grafana Annotations HTTP API"
+keywords = ["grafana", "http", "documentation", "api", "annotation", "annotations", "comment"]
+aliases = ["/http_api/annotations/"]
+type = "docs"
+[menu.docs]
+name = "Annotations"
+identifier = "annotationshttp"
+parent = "http_api"
++++
+
+# Annotations resources / actions
+
+This is the API documentation for the new Grafana Annotations feature released in Grafana 4.6. Annotations are saved in the Grafana database (sqlite, mysql or postgres). Annotations can be global annotations that can be shown on any dashboard by configuring an annotation data source - they are filtered by tags. Or they can be tied to a panel on a dashboard and are then only shown on that panel.
+
+## Find Annotations
+
+`GET /api/annotations?from=1506676478816&to=1507281278816&tags=tag1&tags=tag2&limit=100`
+
+**Example Request**:
+
+```http
+GET /api/annotations?from=1506676478816&to=1507281278816&tags=tag1&tags=tag2&limit=100 HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Basic YWRtaW46YWRtaW4=
+```
+
+
+Query Parameters:
+
+- `from`: epoch datetime in milliseconds. Optional.
+- `to`: epoch datetime in milliseconds. Optional.
+- `limit`: number. Optional - default is 10. Max limit for results returned.
+- `alertId`: number. Optional. Find annotations for a specified alert.
+- `dashboardId`: number. Optional. Find annotations that are scoped to a specific dashboard
+- `panelId`: number. Optional. Find annotations that are scoped to a specific panel
+- `tags`: string. Optional. Use this to filter global annotations. Global annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. To do an "AND" filtering with multiple tags, specify the tags parameter multiple times e.g. `tags=tag1&tags=tag2`.
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+[
+    {
+        "id": 1124,
+        "alertId": 0,
+        "dashboardId": 468,
+        "panelId": 2,
+        "userId": 1,
+        "userName": "",
+        "newState": "",
+        "prevState": "",
+        "time": 1507266395000,
+        "text": "test",
+        "metric": "",
+        "regionId": 1123,
+        "type": "event",
+        "tags": [
+            "tag1",
+            "tag2"
+        ],
+        "data": {}
+    },
+    {
+        "id": 1123,
+        "alertId": 0,
+        "dashboardId": 468,
+        "panelId": 2,
+        "userId": 1,
+        "userName": "",
+        "newState": "",
+        "prevState": "",
+        "time": 1507265111000,
+        "text": "test",
+        "metric": "",
+        "regionId": 1123,
+        "type": "event",
+        "tags": [
+            "tag1",
+            "tag2"
+        ],
+        "data": {}
+    }
+]
+```
+
+## Create Annotation
+
+Creates an annotation in the Grafana database. The `dashboardId` and `panelId` fields are optional. If they are not specified then a global annotation is created and can be queried in any dashboard that adds the Grafana annotations data source.
+
+`POST /api/annotations`
+
+**Example Request**:
+
+```json
+POST /api/annotations HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+
+{
+  "dashboardId":468,
+  "panelId":1,
+  "time":1507037197339,
+  "isRegion":true,
+  "timeEnd":1507180805056,
+  "tags":["tag1","tag2"],
+  "text":"Annotation Description"
+}
+```
+
+**Example Response**:
+
+```json
+HTTP/1.1 200
+Content-Type: application/json
+
+{"message":"Annotation added"}
+```
+
+## Update Annotation
+
+`PUT /api/annotations/:id`
+
+**Example Request**:
+
+```json
+PUT /api/annotations/1141 HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+
+{
+  "time":1507037197339,
+  "isRegion":true,
+  "timeEnd":1507180805056,
+  "text":"Annotation Description",
+  "tags":["tag3","tag4","tag5"]
+}
+```
+
+## Delete Annotation By Id
+
+`DELETE /api/annotation/:id`
+
+Deletes the annotation that matches the specified id.
+
+**Example Request**:
+
+```http
+DELETE /api/annotation/1 HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{"message":"Annotation deleted"}
+```
+
+## Delete Annotation By RegionId
+
+`DELETE /api/annotation/region/:id`
+
+Deletes the annotation that matches the specified region id. A region is an annotation that covers a timerange and has a start and end time. In the Grafana database, this is a stored as two annotations connected by a region id.
+
+**Example Request**:
+
+```http
+DELETE /api/annotation/region/1 HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{"message":"Annotation region deleted"}
+```

+ 3 - 2
package.json

@@ -87,8 +87,8 @@
     "tslint-loader": "^3.5.3",
     "tslint-loader": "^3.5.3",
     "typescript": "^2.5.2",
     "typescript": "^2.5.2",
     "webpack": "^3.6.0",
     "webpack": "^3.6.0",
-    "webpack-bundle-analyzer": "^2.9.0",
     "webpack-cleanup-plugin": "^0.5.1",
     "webpack-cleanup-plugin": "^0.5.1",
+    "webpack-bundle-analyzer": "^2.9.0",
     "webpack-merge": "^4.1.0",
     "webpack-merge": "^4.1.0",
     "zone.js": "^0.7.2"
     "zone.js": "^0.7.2"
   },
   },
@@ -97,13 +97,14 @@
     "watch": "./node_modules/.bin/webpack --progress --colors --watch --config scripts/webpack/webpack.dev.js",
     "watch": "./node_modules/.bin/webpack --progress --colors --watch --config scripts/webpack/webpack.dev.js",
     "build": "./node_modules/.bin/grunt build",
     "build": "./node_modules/.bin/grunt build",
     "test": "./node_modules/.bin/grunt test",
     "test": "./node_modules/.bin/grunt test",
-    "lint" : "./node_modules/.bin/tslint -c tslint.json --project ./tsconfig.json --type-check",
+    "lint" : "./node_modules/.bin/tslint -c tslint.json --project tsconfig.json --type-check",
     "watch-test": "./node_modules/grunt-cli/bin/grunt karma:dev"
     "watch-test": "./node_modules/grunt-cli/bin/grunt karma:dev"
   },
   },
   "license": "Apache-2.0",
   "license": "Apache-2.0",
   "dependencies": {
   "dependencies": {
     "angular": "^1.6.6",
     "angular": "^1.6.6",
     "angular-bindonce": "^0.3.1",
     "angular-bindonce": "^0.3.1",
+    "angular-mocks": "^1.6.6",
     "angular-native-dragdrop": "^1.2.2",
     "angular-native-dragdrop": "^1.2.2",
     "angular-route": "^1.6.6",
     "angular-route": "^1.6.6",
     "angular-sanitize": "^1.6.6",
     "angular-sanitize": "^1.6.6",

+ 80 - 23
pkg/api/annotations.go

@@ -2,6 +2,7 @@ package api
 
 
 import (
 import (
 	"github.com/grafana/grafana/pkg/api/dtos"
 	"github.com/grafana/grafana/pkg/api/dtos"
+	"github.com/grafana/grafana/pkg/components/simplejson"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/middleware"
 	"github.com/grafana/grafana/pkg/services/annotations"
 	"github.com/grafana/grafana/pkg/services/annotations"
 )
 )
@@ -11,13 +12,12 @@ func GetAnnotations(c *middleware.Context) Response {
 	query := &annotations.ItemQuery{
 	query := &annotations.ItemQuery{
 		From:        c.QueryInt64("from") / 1000,
 		From:        c.QueryInt64("from") / 1000,
 		To:          c.QueryInt64("to") / 1000,
 		To:          c.QueryInt64("to") / 1000,
-		Type:        annotations.ItemType(c.Query("type")),
 		OrgId:       c.OrgId,
 		OrgId:       c.OrgId,
 		AlertId:     c.QueryInt64("alertId"),
 		AlertId:     c.QueryInt64("alertId"),
 		DashboardId: c.QueryInt64("dashboardId"),
 		DashboardId: c.QueryInt64("dashboardId"),
 		PanelId:     c.QueryInt64("panelId"),
 		PanelId:     c.QueryInt64("panelId"),
 		Limit:       c.QueryInt64("limit"),
 		Limit:       c.QueryInt64("limit"),
-		NewState:    c.QueryStrings("newState"),
+		Tags:        c.QueryStrings("tags"),
 	}
 	}
 
 
 	repo := annotations.GetRepository()
 	repo := annotations.GetRepository()
@@ -27,25 +27,14 @@ func GetAnnotations(c *middleware.Context) Response {
 		return ApiError(500, "Failed to get annotations", err)
 		return ApiError(500, "Failed to get annotations", err)
 	}
 	}
 
 
-	result := make([]dtos.Annotation, 0)
-
 	for _, item := range items {
 	for _, item := range items {
-		result = append(result, dtos.Annotation{
-			AlertId:   item.AlertId,
-			Time:      item.Epoch * 1000,
-			Data:      item.Data,
-			NewState:  item.NewState,
-			PrevState: item.PrevState,
-			Text:      item.Text,
-			Metric:    item.Metric,
-			Title:     item.Title,
-			PanelId:   item.PanelId,
-			RegionId:  item.RegionId,
-			Type:      string(item.Type),
-		})
+		if item.Email != "" {
+			item.AvatarUrl = dtos.GetGravatarUrl(item.Email)
+		}
+		item.Time = item.Time * 1000
 	}
 	}
 
 
-	return Json(200, result)
+	return Json(200, items)
 }
 }
 
 
 func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response {
 func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response {
@@ -53,14 +42,13 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response
 
 
 	item := annotations.Item{
 	item := annotations.Item{
 		OrgId:       c.OrgId,
 		OrgId:       c.OrgId,
+		UserId:      c.UserId,
 		DashboardId: cmd.DashboardId,
 		DashboardId: cmd.DashboardId,
 		PanelId:     cmd.PanelId,
 		PanelId:     cmd.PanelId,
 		Epoch:       cmd.Time / 1000,
 		Epoch:       cmd.Time / 1000,
-		Title:       cmd.Title,
 		Text:        cmd.Text,
 		Text:        cmd.Text,
-		CategoryId:  cmd.CategoryId,
-		NewState:    cmd.FillColor,
-		Type:        annotations.EventType,
+		Data:        cmd.Data,
+		Tags:        cmd.Tags,
 	}
 	}
 
 
 	if err := repo.Save(&item); err != nil {
 	if err := repo.Save(&item); err != nil {
@@ -71,12 +59,16 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response
 	if cmd.IsRegion {
 	if cmd.IsRegion {
 		item.RegionId = item.Id
 		item.RegionId = item.Id
 
 
+		if item.Data == nil {
+			item.Data = simplejson.New()
+		}
+
 		if err := repo.Update(&item); err != nil {
 		if err := repo.Update(&item); err != nil {
 			return ApiError(500, "Failed set regionId on annotation", err)
 			return ApiError(500, "Failed set regionId on annotation", err)
 		}
 		}
 
 
 		item.Id = 0
 		item.Id = 0
-		item.Epoch = cmd.TimeEnd
+		item.Epoch = cmd.TimeEnd / 1000
 
 
 		if err := repo.Save(&item); err != nil {
 		if err := repo.Save(&item); err != nil {
 			return ApiError(500, "Failed save annotation for region end time", err)
 			return ApiError(500, "Failed save annotation for region end time", err)
@@ -86,6 +78,41 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response
 	return ApiSuccess("Annotation added")
 	return ApiSuccess("Annotation added")
 }
 }
 
 
+func UpdateAnnotation(c *middleware.Context, cmd dtos.UpdateAnnotationsCmd) Response {
+	annotationId := c.ParamsInt64(":annotationId")
+
+	repo := annotations.GetRepository()
+
+	item := annotations.Item{
+		OrgId:  c.OrgId,
+		UserId: c.UserId,
+		Id:     annotationId,
+		Epoch:  cmd.Time / 1000,
+		Text:   cmd.Text,
+		Tags:   cmd.Tags,
+	}
+
+	if err := repo.Update(&item); err != nil {
+		return ApiError(500, "Failed to update annotation", err)
+	}
+
+	if cmd.IsRegion {
+		itemRight := item
+		itemRight.RegionId = item.Id
+		itemRight.Epoch = cmd.TimeEnd / 1000
+
+		// We don't know id of region right event, so set it to 0 and find then using query like
+		// ... WHERE region_id = <item.RegionId> AND id != <item.RegionId> ...
+		itemRight.Id = 0
+
+		if err := repo.Update(&itemRight); err != nil {
+			return ApiError(500, "Failed to update annotation for region end time", err)
+		}
+	}
+
+	return ApiSuccess("Annotation updated")
+}
+
 func DeleteAnnotations(c *middleware.Context, cmd dtos.DeleteAnnotationsCmd) Response {
 func DeleteAnnotations(c *middleware.Context, cmd dtos.DeleteAnnotationsCmd) Response {
 	repo := annotations.GetRepository()
 	repo := annotations.GetRepository()
 
 
@@ -101,3 +128,33 @@ func DeleteAnnotations(c *middleware.Context, cmd dtos.DeleteAnnotationsCmd) Res
 
 
 	return ApiSuccess("Annotations deleted")
 	return ApiSuccess("Annotations deleted")
 }
 }
+
+func DeleteAnnotationById(c *middleware.Context) Response {
+	repo := annotations.GetRepository()
+	annotationId := c.ParamsInt64(":annotationId")
+
+	err := repo.Delete(&annotations.DeleteParams{
+		Id: annotationId,
+	})
+
+	if err != nil {
+		return ApiError(500, "Failed to delete annotation", err)
+	}
+
+	return ApiSuccess("Annotation deleted")
+}
+
+func DeleteAnnotationRegion(c *middleware.Context) Response {
+	repo := annotations.GetRepository()
+	regionId := c.ParamsInt64(":regionId")
+
+	err := repo.Delete(&annotations.DeleteParams{
+		RegionId: regionId,
+	})
+
+	if err != nil {
+		return ApiError(500, "Failed to delete annotation region", err)
+	}
+
+	return ApiSuccess("Annotation region deleted")
+}

+ 3 - 0
pkg/api/api.go

@@ -289,6 +289,9 @@ func (hs *HttpServer) registerRoutes() {
 
 
 		apiRoute.Group("/annotations", func(annotationsRoute RouteRegister) {
 		apiRoute.Group("/annotations", func(annotationsRoute RouteRegister) {
 			annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), wrap(PostAnnotation))
 			annotationsRoute.Post("/", bind(dtos.PostAnnotationsCmd{}), wrap(PostAnnotation))
+			annotationsRoute.Delete("/:annotationId", wrap(DeleteAnnotationById))
+			annotationsRoute.Put("/:annotationId", bind(dtos.UpdateAnnotationsCmd{}), wrap(UpdateAnnotation))
+			annotationsRoute.Delete("/region/:regionId", wrap(DeleteAnnotationRegion))
 		}, reqEditorRole)
 		}, reqEditorRole)
 
 
 		// error test
 		// error test

+ 2 - 2
pkg/api/avatar/avatar.go

@@ -65,7 +65,7 @@ func New(hash string) *Avatar {
 	return &Avatar{
 	return &Avatar{
 		hash: hash,
 		hash: hash,
 		reqParams: url.Values{
 		reqParams: url.Values{
-			"d":    {"404"},
+			"d":    {"retro"},
 			"size": {"200"},
 			"size": {"200"},
 			"r":    {"pg"}}.Encode(),
 			"r":    {"pg"}}.Encode(),
 	}
 	}
@@ -146,7 +146,7 @@ func CacheServer() http.Handler {
 }
 }
 
 
 func newNotFound() *Avatar {
 func newNotFound() *Avatar {
-	avatar := &Avatar{}
+	avatar := &Avatar{notFound: true}
 
 
 	// load transparent png into buffer
 	// load transparent png into buffer
 	path := filepath.Join(setting.StaticRootPath, "img", "transparent.png")
 	path := filepath.Join(setting.StaticRootPath, "img", "transparent.png")

+ 21 - 28
pkg/api/dtos/annotations.go

@@ -2,37 +2,30 @@ package dtos
 
 
 import "github.com/grafana/grafana/pkg/components/simplejson"
 import "github.com/grafana/grafana/pkg/components/simplejson"
 
 
-type Annotation struct {
-	AlertId     int64  `json:"alertId"`
-	DashboardId int64  `json:"dashboardId"`
-	PanelId     int64  `json:"panelId"`
-	NewState    string `json:"newState"`
-	PrevState   string `json:"prevState"`
-	Time        int64  `json:"time"`
-	Title       string `json:"title"`
-	Text        string `json:"text"`
-	Metric      string `json:"metric"`
-	RegionId    int64  `json:"regionId"`
-	Type        string `json:"type"`
-
-	Data *simplejson.Json `json:"data"`
-}
-
 type PostAnnotationsCmd struct {
 type PostAnnotationsCmd struct {
-	DashboardId int64  `json:"dashboardId"`
-	PanelId     int64  `json:"panelId"`
-	CategoryId  int64  `json:"categoryId"`
-	Time        int64  `json:"time"`
-	Title       string `json:"title"`
-	Text        string `json:"text"`
+	DashboardId int64            `json:"dashboardId"`
+	PanelId     int64            `json:"panelId"`
+	Time        int64            `json:"time"`
+	Text        string           `json:"text"`
+	Tags        []string         `json:"tags"`
+	Data        *simplejson.Json `json:"data"`
+	IsRegion    bool             `json:"isRegion"`
+	TimeEnd     int64            `json:"timeEnd"`
+}
 
 
-	FillColor string `json:"fillColor"`
-	IsRegion  bool   `json:"isRegion"`
-	TimeEnd   int64  `json:"timeEnd"`
+type UpdateAnnotationsCmd struct {
+	Id       int64    `json:"id"`
+	Time     int64    `json:"time"`
+	Text     string   `json:"text"`
+	Tags     []string `json:"tags"`
+	IsRegion bool     `json:"isRegion"`
+	TimeEnd  int64    `json:"timeEnd"`
 }
 }
 
 
 type DeleteAnnotationsCmd struct {
 type DeleteAnnotationsCmd struct {
-	AlertId     int64 `json:"alertId"`
-	DashboardId int64 `json:"dashboardId"`
-	PanelId     int64 `json:"panelId"`
+	AlertId      int64 `json:"alertId"`
+	DashboardId  int64 `json:"dashboardId"`
+	PanelId      int64 `json:"panelId"`
+	AnnotationId int64 `json:"annotationId"`
+	RegionId     int64 `json:"regionId"`
 }
 }

+ 60 - 0
pkg/models/tags.go

@@ -0,0 +1,60 @@
+package models
+
+import (
+	"strings"
+)
+
+type Tag struct {
+	Id    int64
+	Key   string
+	Value string
+}
+
+func ParseTagPairs(tagPairs []string) (tags []*Tag) {
+	if tagPairs == nil {
+		return []*Tag{}
+	}
+
+	for _, tagPair := range tagPairs {
+		var tag Tag
+
+		if strings.Contains(tagPair, ":") {
+			keyValue := strings.Split(tagPair, ":")
+			tag.Key = strings.Trim(keyValue[0], " ")
+			tag.Value = strings.Trim(keyValue[1], " ")
+		} else {
+			tag.Key = strings.Trim(tagPair, " ")
+		}
+
+		if tag.Key == "" || ContainsTag(tags, &tag) {
+			continue
+		}
+
+		tags = append(tags, &tag)
+	}
+
+	return tags
+}
+
+func ContainsTag(existingTags []*Tag, tag *Tag) bool {
+	for _, t := range existingTags {
+		if t.Key == tag.Key && t.Value == tag.Value {
+			return true
+		}
+	}
+	return false
+}
+
+func JoinTagPairs(tags []*Tag) []string {
+	tagPairs := []string{}
+
+	for _, tag := range tags {
+		if tag.Value != "" {
+			tagPairs = append(tagPairs, tag.Key+":"+tag.Value)
+		} else {
+			tagPairs = append(tagPairs, tag.Key)
+		}
+	}
+
+	return tagPairs
+}

+ 95 - 0
pkg/models/tags_test.go

@@ -0,0 +1,95 @@
+package models
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestParsingTags(t *testing.T) {
+	Convey("Testing parsing a tag pairs into tags", t, func() {
+		Convey("Can parse one empty tag", func() {
+			tags := ParseTagPairs([]string{""})
+			So(len(tags), ShouldEqual, 0)
+		})
+
+		Convey("Can parse valid tags", func() {
+			tags := ParseTagPairs([]string{"outage", "type:outage", "error"})
+			So(len(tags), ShouldEqual, 3)
+			So(tags[0].Key, ShouldEqual, "outage")
+			So(tags[0].Value, ShouldEqual, "")
+			So(tags[1].Key, ShouldEqual, "type")
+			So(tags[1].Value, ShouldEqual, "outage")
+			So(tags[2].Key, ShouldEqual, "error")
+			So(tags[2].Value, ShouldEqual, "")
+		})
+
+		Convey("Can parse tags with spaces", func() {
+			tags := ParseTagPairs([]string{" outage ", " type : outage ", "error "})
+			So(len(tags), ShouldEqual, 3)
+			So(tags[0].Key, ShouldEqual, "outage")
+			So(tags[0].Value, ShouldEqual, "")
+			So(tags[1].Key, ShouldEqual, "type")
+			So(tags[1].Value, ShouldEqual, "outage")
+			So(tags[2].Key, ShouldEqual, "error")
+			So(tags[2].Value, ShouldEqual, "")
+		})
+
+		Convey("Can parse empty tags", func() {
+			tags := ParseTagPairs([]string{" outage ", "", "", ":", "type : outage ", "error ", "", ""})
+			So(len(tags), ShouldEqual, 3)
+			So(tags[0].Key, ShouldEqual, "outage")
+			So(tags[0].Value, ShouldEqual, "")
+			So(tags[1].Key, ShouldEqual, "type")
+			So(tags[1].Value, ShouldEqual, "outage")
+			So(tags[2].Key, ShouldEqual, "error")
+			So(tags[2].Value, ShouldEqual, "")
+		})
+
+		Convey("Can parse tags with extra colons", func() {
+			tags := ParseTagPairs([]string{" outage", "type : outage:outage2 :outage3 ", "error :"})
+			So(len(tags), ShouldEqual, 3)
+			So(tags[0].Key, ShouldEqual, "outage")
+			So(tags[0].Value, ShouldEqual, "")
+			So(tags[1].Key, ShouldEqual, "type")
+			So(tags[1].Value, ShouldEqual, "outage")
+			So(tags[2].Key, ShouldEqual, "error")
+			So(tags[2].Value, ShouldEqual, "")
+		})
+
+		Convey("Can parse tags that contains key and values with spaces", func() {
+			tags := ParseTagPairs([]string{" outage 1", "type 1: outage 1 ", "has error "})
+			So(len(tags), ShouldEqual, 3)
+			So(tags[0].Key, ShouldEqual, "outage 1")
+			So(tags[0].Value, ShouldEqual, "")
+			So(tags[1].Key, ShouldEqual, "type 1")
+			So(tags[1].Value, ShouldEqual, "outage 1")
+			So(tags[2].Key, ShouldEqual, "has error")
+			So(tags[2].Value, ShouldEqual, "")
+		})
+
+		Convey("Can filter out duplicate tags", func() {
+			tags := ParseTagPairs([]string{"test", "test", "key:val1", "key:val2"})
+			So(len(tags), ShouldEqual, 3)
+			So(tags[0].Key, ShouldEqual, "test")
+			So(tags[0].Value, ShouldEqual, "")
+			So(tags[1].Key, ShouldEqual, "key")
+			So(tags[1].Value, ShouldEqual, "val1")
+			So(tags[2].Key, ShouldEqual, "key")
+			So(tags[2].Value, ShouldEqual, "val2")
+		})
+
+		Convey("Can join tag pairs", func() {
+			tagPairs := []*Tag{
+				{Key: "key1", Value: "val1"},
+				{Key: "key2", Value: ""},
+				{Key: "key3"},
+			}
+			tags := JoinTagPairs(tagPairs)
+			So(len(tags), ShouldEqual, 3)
+			So(tags[0], ShouldEqual, "key1:val1")
+			So(tags[1], ShouldEqual, "key2")
+			So(tags[2], ShouldEqual, "key3")
+		})
+	})
+}

+ 1 - 3
pkg/services/alerting/result_handler.go

@@ -73,10 +73,8 @@ func (handler *DefaultResultHandler) Handle(evalContext *EvalContext) error {
 			OrgId:       evalContext.Rule.OrgId,
 			OrgId:       evalContext.Rule.OrgId,
 			DashboardId: evalContext.Rule.DashboardId,
 			DashboardId: evalContext.Rule.DashboardId,
 			PanelId:     evalContext.Rule.PanelId,
 			PanelId:     evalContext.Rule.PanelId,
-			Type:        annotations.AlertType,
 			AlertId:     evalContext.Rule.Id,
 			AlertId:     evalContext.Rule.Id,
-			Title:       evalContext.Rule.Name,
-			Text:        evalContext.GetStateModel().Text,
+			Text:        "",
 			NewState:    string(evalContext.Rule.State),
 			NewState:    string(evalContext.Rule.State),
 			PrevState:   string(evalContext.PrevAlertState),
 			PrevState:   string(evalContext.PrevAlertState),
 			Epoch:       time.Now().Unix(),
 			Epoch:       time.Now().Unix(),

+ 40 - 26
pkg/services/annotations/annotations.go

@@ -5,7 +5,7 @@ import "github.com/grafana/grafana/pkg/components/simplejson"
 type Repository interface {
 type Repository interface {
 	Save(item *Item) error
 	Save(item *Item) error
 	Update(item *Item) error
 	Update(item *Item) error
-	Find(query *ItemQuery) ([]*Item, error)
+	Find(query *ItemQuery) ([]*ItemDTO, error)
 	Delete(params *DeleteParams) error
 	Delete(params *DeleteParams) error
 }
 }
 
 
@@ -13,11 +13,10 @@ type ItemQuery struct {
 	OrgId       int64    `json:"orgId"`
 	OrgId       int64    `json:"orgId"`
 	From        int64    `json:"from"`
 	From        int64    `json:"from"`
 	To          int64    `json:"to"`
 	To          int64    `json:"to"`
-	Type        ItemType `json:"type"`
 	AlertId     int64    `json:"alertId"`
 	AlertId     int64    `json:"alertId"`
 	DashboardId int64    `json:"dashboardId"`
 	DashboardId int64    `json:"dashboardId"`
 	PanelId     int64    `json:"panelId"`
 	PanelId     int64    `json:"panelId"`
-	NewState    []string `json:"newState"`
+	Tags        []string `json:"tags"`
 
 
 	Limit int64 `json:"limit"`
 	Limit int64 `json:"limit"`
 }
 }
@@ -28,12 +27,15 @@ type PostParams struct {
 	Epoch       int64  `json:"epoch"`
 	Epoch       int64  `json:"epoch"`
 	Title       string `json:"title"`
 	Title       string `json:"title"`
 	Text        string `json:"text"`
 	Text        string `json:"text"`
+	Icon        string `json:"icon"`
 }
 }
 
 
 type DeleteParams struct {
 type DeleteParams struct {
+	Id          int64 `json:"id"`
 	AlertId     int64 `json:"alertId"`
 	AlertId     int64 `json:"alertId"`
 	DashboardId int64 `json:"dashboardId"`
 	DashboardId int64 `json:"dashboardId"`
 	PanelId     int64 `json:"panelId"`
 	PanelId     int64 `json:"panelId"`
+	RegionId    int64 `json:"regionId"`
 }
 }
 
 
 var repositoryInstance Repository
 var repositoryInstance Repository
@@ -46,29 +48,41 @@ func SetRepository(rep Repository) {
 	repositoryInstance = rep
 	repositoryInstance = rep
 }
 }
 
 
-type ItemType string
-
-const (
-	AlertType ItemType = "alert"
-	EventType ItemType = "event"
-)
-
 type Item struct {
 type Item struct {
-	Id          int64    `json:"id"`
-	OrgId       int64    `json:"orgId"`
-	DashboardId int64    `json:"dashboardId"`
-	PanelId     int64    `json:"panelId"`
-	CategoryId  int64    `json:"categoryId"`
-	RegionId    int64    `json:"regionId"`
-	Type        ItemType `json:"type"`
-	Title       string   `json:"title"`
-	Text        string   `json:"text"`
-	Metric      string   `json:"metric"`
-	AlertId     int64    `json:"alertId"`
-	UserId      int64    `json:"userId"`
-	PrevState   string   `json:"prevState"`
-	NewState    string   `json:"newState"`
-	Epoch       int64    `json:"epoch"`
+	Id          int64            `json:"id"`
+	OrgId       int64            `json:"orgId"`
+	UserId      int64            `json:"userId"`
+	DashboardId int64            `json:"dashboardId"`
+	PanelId     int64            `json:"panelId"`
+	RegionId    int64            `json:"regionId"`
+	Text        string           `json:"text"`
+	AlertId     int64            `json:"alertId"`
+	PrevState   string           `json:"prevState"`
+	NewState    string           `json:"newState"`
+	Epoch       int64            `json:"epoch"`
+	Tags        []string         `json:"tags"`
+	Data        *simplejson.Json `json:"data"`
+
+	// needed until we remove it from db
+	Type  string
+	Title string
+}
 
 
-	Data *simplejson.Json `json:"data"`
+type ItemDTO struct {
+	Id          int64            `json:"id"`
+	AlertId     int64            `json:"alertId"`
+	AlertName   string           `json:"alertName"`
+	DashboardId int64            `json:"dashboardId"`
+	PanelId     int64            `json:"panelId"`
+	UserId      int64            `json:"userId"`
+	NewState    string           `json:"newState"`
+	PrevState   string           `json:"prevState"`
+	Time        int64            `json:"time"`
+	Text        string           `json:"text"`
+	RegionId    int64            `json:"regionId"`
+	Tags        []string         `json:"tags"`
+	Login       string           `json:"login"`
+	Email       string           `json:"email"`
+	AvatarUrl   string           `json:"avatarUrl"`
+	Data        *simplejson.Json `json:"data"`
 }
 }

+ 154 - 29
pkg/services/sqlstore/annotation.go

@@ -2,9 +2,11 @@ package sqlstore
 
 
 import (
 import (
 	"bytes"
 	"bytes"
+	"errors"
 	"fmt"
 	"fmt"
 	"strings"
 	"strings"
 
 
+	"github.com/grafana/grafana/pkg/models"
 	"github.com/grafana/grafana/pkg/services/annotations"
 	"github.com/grafana/grafana/pkg/services/annotations"
 )
 )
 
 
@@ -13,19 +15,94 @@ type SqlAnnotationRepo struct {
 
 
 func (r *SqlAnnotationRepo) Save(item *annotations.Item) error {
 func (r *SqlAnnotationRepo) Save(item *annotations.Item) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
-
+		tags := models.ParseTagPairs(item.Tags)
+		item.Tags = models.JoinTagPairs(tags)
 		if _, err := sess.Table("annotation").Insert(item); err != nil {
 		if _, err := sess.Table("annotation").Insert(item); err != nil {
 			return err
 			return err
 		}
 		}
 
 
+		if item.Tags != nil {
+			if tags, err := r.ensureTagsExist(sess, tags); err != nil {
+				return err
+			} else {
+				for _, tag := range tags {
+					if _, err := sess.Exec("INSERT INTO annotation_tag (annotation_id, tag_id) VALUES(?,?)", item.Id, tag.Id); err != nil {
+						return err
+					}
+				}
+			}
+		}
+
 		return nil
 		return nil
 	})
 	})
 }
 }
 
 
+// Will insert if needed any new key/value pars and return ids
+func (r *SqlAnnotationRepo) ensureTagsExist(sess *DBSession, tags []*models.Tag) ([]*models.Tag, error) {
+	for _, tag := range tags {
+		var existingTag models.Tag
+
+		// check if it exists
+		if exists, err := sess.Table("tag").Where("key=? AND value=?", tag.Key, tag.Value).Get(&existingTag); err != nil {
+			return nil, err
+		} else if exists {
+			tag.Id = existingTag.Id
+		} else {
+			if _, err := sess.Table("tag").Insert(tag); err != nil {
+				return nil, err
+			}
+		}
+	}
+
+	return tags, nil
+}
+
 func (r *SqlAnnotationRepo) Update(item *annotations.Item) error {
 func (r *SqlAnnotationRepo) Update(item *annotations.Item) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
+		var (
+			isExist bool
+			err     error
+		)
+		existing := new(annotations.Item)
+
+		if item.Id == 0 && item.RegionId != 0 {
+			// Update region end time
+			isExist, err = sess.Table("annotation").Where("region_id=? AND id!=? AND org_id=?", item.RegionId, item.RegionId, item.OrgId).Get(existing)
+		} else {
+			isExist, err = sess.Table("annotation").Where("id=? AND org_id=?", item.Id, item.OrgId).Get(existing)
+		}
+
+		if err != nil {
+			return err
+		}
+		if !isExist {
+			return errors.New("Annotation not found")
+		}
+
+		existing.Epoch = item.Epoch
+		existing.Text = item.Text
+		if item.RegionId != 0 {
+			existing.RegionId = item.RegionId
+		}
+
+		if item.Tags != nil {
+			if tags, err := r.ensureTagsExist(sess, models.ParseTagPairs(item.Tags)); err != nil {
+				return err
+			} else {
+				if _, err := sess.Exec("DELETE FROM annotation_tag WHERE annotation_id = ?", existing.Id); err != nil {
+					return err
+				}
+				for _, tag := range tags {
+					if _, err := sess.Exec("INSERT INTO annotation_tag (annotation_id, tag_id) VALUES(?,?)", existing.Id, tag.Id); err != nil {
+						return err
+					}
+				}
+			}
+		}
+
+		existing.Tags = item.Tags
 
 
-		if _, err := sess.Table("annotation").Id(item.Id).Update(item); err != nil {
+		if _, err := sess.Table("annotation").Id(existing.Id).Cols("epoch", "text", "region_id", "tags").Update(existing); err != nil {
 			return err
 			return err
 		}
 		}
 
 
@@ -33,51 +110,79 @@ func (r *SqlAnnotationRepo) Update(item *annotations.Item) error {
 	})
 	})
 }
 }
 
 
-func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.Item, error) {
+func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.ItemDTO, error) {
 	var sql bytes.Buffer
 	var sql bytes.Buffer
 	params := make([]interface{}, 0)
 	params := make([]interface{}, 0)
 
 
-	sql.WriteString(`SELECT *
-						from annotation
-						`)
-
-	sql.WriteString(`WHERE org_id = ?`)
+	sql.WriteString(`
+		SELECT
+			annotation.id,
+			annotation.epoch as time,
+			annotation.dashboard_id,
+			annotation.panel_id,
+			annotation.new_state,
+			annotation.prev_state,
+			annotation.alert_id,
+			annotation.region_id,
+			annotation.text,
+			annotation.tags,
+			annotation.data,
+			usr.email,
+			usr.login,
+			alert.name as alert_name
+		FROM annotation
+		LEFT OUTER JOIN ` + dialect.Quote("user") + ` as usr on usr.id = annotation.user_id
+		LEFT OUTER JOIN alert on alert.id = annotation.alert_id
+		`)
+
+	sql.WriteString(`WHERE annotation.org_id = ?`)
 	params = append(params, query.OrgId)
 	params = append(params, query.OrgId)
 
 
 	if query.AlertId != 0 {
 	if query.AlertId != 0 {
-		sql.WriteString(` AND alert_id = ?`)
-		params = append(params, query.AlertId)
-	}
-
-	if query.AlertId != 0 {
-		sql.WriteString(` AND alert_id = ?`)
+		sql.WriteString(` AND annotation.alert_id = ?`)
 		params = append(params, query.AlertId)
 		params = append(params, query.AlertId)
 	}
 	}
 
 
 	if query.DashboardId != 0 {
 	if query.DashboardId != 0 {
-		sql.WriteString(` AND dashboard_id = ?`)
+		sql.WriteString(` AND annotation.dashboard_id = ?`)
 		params = append(params, query.DashboardId)
 		params = append(params, query.DashboardId)
 	}
 	}
 
 
 	if query.PanelId != 0 {
 	if query.PanelId != 0 {
-		sql.WriteString(` AND panel_id = ?`)
+		sql.WriteString(` AND annotation.panel_id = ?`)
 		params = append(params, query.PanelId)
 		params = append(params, query.PanelId)
 	}
 	}
 
 
 	if query.From > 0 && query.To > 0 {
 	if query.From > 0 && query.To > 0 {
-		sql.WriteString(` AND epoch BETWEEN ? AND ?`)
+		sql.WriteString(` AND annotation.epoch BETWEEN ? AND ?`)
 		params = append(params, query.From, query.To)
 		params = append(params, query.From, query.To)
 	}
 	}
 
 
-	if query.Type != "" {
-		sql.WriteString(` AND type = ?`)
-		params = append(params, string(query.Type))
-	}
+	if len(query.Tags) > 0 {
+		keyValueFilters := []string{}
+
+		tags := models.ParseTagPairs(query.Tags)
+		for _, tag := range tags {
+			if tag.Value == "" {
+				keyValueFilters = append(keyValueFilters, "(tag.key = ?)")
+				params = append(params, tag.Key)
+			} else {
+				keyValueFilters = append(keyValueFilters, "(tag.key = ? AND tag.value = ?)")
+				params = append(params, tag.Key, tag.Value)
+			}
+		}
 
 
-	if len(query.NewState) > 0 {
-		sql.WriteString(` AND new_state IN (?` + strings.Repeat(",?", len(query.NewState)-1) + ")")
-		for _, v := range query.NewState {
-			params = append(params, v)
+		if len(tags) > 0 {
+			tagsSubQuery := fmt.Sprintf(`
+        SELECT SUM(1) FROM annotation_tag at
+          INNER JOIN tag on tag.id = at.tag_id
+          WHERE at.annotation_id = annotation.id
+            AND (
+              %s
+            )
+      `, strings.Join(keyValueFilters, " OR "))
+
+			sql.WriteString(fmt.Sprintf(" AND (%s) = %d ", tagsSubQuery, len(tags)))
 		}
 		}
 	}
 	}
 
 
@@ -87,7 +192,7 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I
 
 
 	sql.WriteString(fmt.Sprintf(" ORDER BY epoch DESC LIMIT %v", query.Limit))
 	sql.WriteString(fmt.Sprintf(" ORDER BY epoch DESC LIMIT %v", query.Limit))
 
 
-	items := make([]*annotations.Item, 0)
+	items := make([]*annotations.ItemDTO, 0)
 	if err := x.Sql(sql.String(), params...).Find(&items); err != nil {
 	if err := x.Sql(sql.String(), params...).Find(&items); err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -97,11 +202,31 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I
 
 
 func (r *SqlAnnotationRepo) Delete(params *annotations.DeleteParams) error {
 func (r *SqlAnnotationRepo) Delete(params *annotations.DeleteParams) error {
 	return inTransaction(func(sess *DBSession) error {
 	return inTransaction(func(sess *DBSession) error {
+		var (
+			sql         string
+			annoTagSql  string
+			queryParams []interface{}
+		)
+
+		if params.RegionId != 0 {
+			annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE region_id = ?)"
+			sql = "DELETE FROM annotation WHERE region_id = ?"
+			queryParams = []interface{}{params.RegionId}
+		} else if params.Id != 0 {
+			annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE id = ?)"
+			sql = "DELETE FROM annotation WHERE id = ?"
+			queryParams = []interface{}{params.Id}
+		} else {
+			annoTagSql = "DELETE FROM annotation_tag WHERE annotation_id IN (SELECT id FROM annotation WHERE dashboard_id = ? AND panel_id = ?)"
+			sql = "DELETE FROM annotation WHERE dashboard_id = ? AND panel_id = ?"
+			queryParams = []interface{}{params.DashboardId, params.PanelId}
+		}
 
 
-		sql := "DELETE FROM annotation WHERE dashboard_id = ? AND panel_id = ?"
+		if _, err := sess.Exec(annoTagSql, queryParams...); err != nil {
+			return err
+		}
 
 
-		_, err := sess.Exec(sql, params.DashboardId, params.PanelId)
-		if err != nil {
+		if _, err := sess.Exec(sql, queryParams...); err != nil {
 			return err
 			return err
 		}
 		}
 
 

+ 208 - 0
pkg/services/sqlstore/annotation_test.go

@@ -0,0 +1,208 @@
+package sqlstore
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+
+	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/services/annotations"
+)
+
+func TestSavingTags(t *testing.T) {
+	Convey("Testing annotation saving/loading", t, func() {
+		InitTestDB(t)
+
+		repo := SqlAnnotationRepo{}
+
+		Convey("Can save tags", func() {
+			tagPairs := []*models.Tag{
+				{Key: "outage"},
+				{Key: "type", Value: "outage"},
+				{Key: "server", Value: "server-1"},
+				{Key: "error"},
+			}
+			tags, err := repo.ensureTagsExist(newSession(), tagPairs)
+
+			So(err, ShouldBeNil)
+			So(len(tags), ShouldEqual, 4)
+		})
+	})
+}
+
+func TestAnnotations(t *testing.T) {
+	Convey("Testing annotation saving/loading", t, func() {
+		InitTestDB(t)
+
+		repo := SqlAnnotationRepo{}
+
+		Convey("Can save annotation", func() {
+			err := repo.Save(&annotations.Item{
+				OrgId:       1,
+				UserId:      1,
+				DashboardId: 1,
+				Text:        "hello",
+				Epoch:       10,
+				Tags:        []string{"outage", "error", "type:outage", "server:server-1"},
+			})
+
+			So(err, ShouldBeNil)
+
+			Convey("Can query for annotation", func() {
+				items, err := repo.Find(&annotations.ItemQuery{
+					OrgId:       1,
+					DashboardId: 1,
+					From:        0,
+					To:          15,
+				})
+
+				So(err, ShouldBeNil)
+				So(items, ShouldHaveLength, 1)
+
+				Convey("Can read tags", func() {
+					So(items[0].Tags, ShouldResemble, []string{"outage", "error", "type:outage", "server:server-1"})
+				})
+			})
+
+			Convey("Should not find any when item is outside time range", func() {
+				items, err := repo.Find(&annotations.ItemQuery{
+					OrgId:       1,
+					DashboardId: 1,
+					From:        12,
+					To:          15,
+				})
+
+				So(err, ShouldBeNil)
+				So(items, ShouldHaveLength, 0)
+			})
+
+			Convey("Should not find one when tag filter does not match", func() {
+				items, err := repo.Find(&annotations.ItemQuery{
+					OrgId:       1,
+					DashboardId: 1,
+					From:        1,
+					To:          15,
+					Tags:        []string{"asd"},
+				})
+
+				So(err, ShouldBeNil)
+				So(items, ShouldHaveLength, 0)
+			})
+
+			Convey("Should find one when all tag filters does match", func() {
+				items, err := repo.Find(&annotations.ItemQuery{
+					OrgId:       1,
+					DashboardId: 1,
+					From:        1,
+					To:          15,
+					Tags:        []string{"outage", "error"},
+				})
+
+				So(err, ShouldBeNil)
+				So(items, ShouldHaveLength, 1)
+			})
+
+			Convey("Should find one when all key value tag filters does match", func() {
+				items, err := repo.Find(&annotations.ItemQuery{
+					OrgId:       1,
+					DashboardId: 1,
+					From:        1,
+					To:          15,
+					Tags:        []string{"type:outage", "server:server-1"},
+				})
+
+				So(err, ShouldBeNil)
+				So(items, ShouldHaveLength, 1)
+			})
+
+			Convey("Can update annotation and remove all tags", func() {
+				query := &annotations.ItemQuery{
+					OrgId:       1,
+					DashboardId: 1,
+					From:        0,
+					To:          15,
+				}
+				items, err := repo.Find(query)
+
+				So(err, ShouldBeNil)
+
+				annotationId := items[0].Id
+
+				err = repo.Update(&annotations.Item{
+					Id:    annotationId,
+					OrgId: 1,
+					Text:  "something new",
+					Tags:  []string{},
+				})
+
+				So(err, ShouldBeNil)
+
+				items, err = repo.Find(query)
+
+				So(err, ShouldBeNil)
+
+				Convey("Can read tags", func() {
+					So(items[0].Id, ShouldEqual, annotationId)
+					So(len(items[0].Tags), ShouldEqual, 0)
+					So(items[0].Text, ShouldEqual, "something new")
+				})
+			})
+
+			Convey("Can update annotation with new tags", func() {
+				query := &annotations.ItemQuery{
+					OrgId:       1,
+					DashboardId: 1,
+					From:        0,
+					To:          15,
+				}
+				items, err := repo.Find(query)
+
+				So(err, ShouldBeNil)
+
+				annotationId := items[0].Id
+
+				err = repo.Update(&annotations.Item{
+					Id:    annotationId,
+					OrgId: 1,
+					Text:  "something new",
+					Tags:  []string{"newtag1", "newtag2"},
+				})
+
+				So(err, ShouldBeNil)
+
+				items, err = repo.Find(query)
+
+				So(err, ShouldBeNil)
+
+				Convey("Can read tags", func() {
+					So(items[0].Id, ShouldEqual, annotationId)
+					So(items[0].Tags, ShouldResemble, []string{"newtag1", "newtag2"})
+					So(items[0].Text, ShouldEqual, "something new")
+				})
+			})
+
+			Convey("Can delete annotation", func() {
+				query := &annotations.ItemQuery{
+					OrgId:       1,
+					DashboardId: 1,
+					From:        0,
+					To:          15,
+				}
+				items, err := repo.Find(query)
+				So(err, ShouldBeNil)
+
+				annotationId := items[0].Id
+
+				err = repo.Delete(&annotations.DeleteParams{Id: annotationId})
+
+				items, err = repo.Find(query)
+				So(err, ShouldBeNil)
+
+				Convey("Should be deleted", func() {
+					So(len(items), ShouldEqual, 0)
+				})
+			})
+
+		})
+	})
+}

+ 1 - 0
pkg/services/sqlstore/dashboard.go

@@ -261,6 +261,7 @@ func DeleteDashboard(cmd *m.DeleteDashboardCommand) error {
 			"DELETE FROM dashboard WHERE id = ?",
 			"DELETE FROM dashboard WHERE id = ?",
 			"DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?",
 			"DELETE FROM playlist_item WHERE type = 'dashboard_by_id' AND value = ?",
 			"DELETE FROM dashboard_version WHERE dashboard_id = ?",
 			"DELETE FROM dashboard_version WHERE dashboard_id = ?",
+			"DELETE FROM annotation WHERE dashboard_id = ?",
 		}
 		}
 
 
 		for _, sql := range deletes {
 		for _, sql := range deletes {

+ 33 - 0
pkg/services/sqlstore/migrations/annotation_mig.go

@@ -57,4 +57,37 @@ func addAnnotationMig(mg *Migrator) {
 	mg.AddMigration("Add column region_id to annotation table", NewAddColumnMigration(table, &Column{
 	mg.AddMigration("Add column region_id to annotation table", NewAddColumnMigration(table, &Column{
 		Name: "region_id", Type: DB_BigInt, Nullable: true, Default: "0",
 		Name: "region_id", Type: DB_BigInt, Nullable: true, Default: "0",
 	}))
 	}))
+
+	categoryIdIndex := &Index{Cols: []string{"org_id", "category_id"}, Type: IndexType}
+	mg.AddMigration("Drop category_id index", NewDropIndexMigration(table, categoryIdIndex))
+
+	mg.AddMigration("Add column tags to annotation table", NewAddColumnMigration(table, &Column{
+		Name: "tags", Type: DB_NVarchar, Nullable: true, Length: 500,
+	}))
+
+	///
+	/// Annotation tag
+	///
+	annotationTagTable := Table{
+		Name: "annotation_tag",
+		Columns: []*Column{
+			{Name: "annotation_id", Type: DB_BigInt, Nullable: false},
+			{Name: "tag_id", Type: DB_BigInt, Nullable: false},
+		},
+		Indices: []*Index{
+			{Cols: []string{"annotation_id", "tag_id"}, Type: UniqueIndex},
+		},
+	}
+
+	mg.AddMigration("Create annotation_tag table v2", NewAddTableMigration(annotationTagTable))
+	mg.AddMigration("Add unique index annotation_tag.annotation_id_tag_id", NewAddIndexMigration(annotationTagTable, annotationTagTable.Indices[0]))
+
+	//
+	// clear alert text
+	//
+	updateTextFieldSql := "UPDATE annotation SET TEXT = '' WHERE alert_id > 0"
+	mg.AddMigration("Update alert annotations and set TEXT to empty", new(RawSqlMigration).
+		Sqlite(updateTextFieldSql).
+		Postgres(updateTextFieldSql).
+		Mysql(updateTextFieldSql))
 }
 }

+ 1 - 0
pkg/services/sqlstore/migrations/migrations.go

@@ -26,6 +26,7 @@ func AddMigrations(mg *Migrator) {
 	addAnnotationMig(mg)
 	addAnnotationMig(mg)
 	addTestDataMigrations(mg)
 	addTestDataMigrations(mg)
 	addDashboardVersionMigration(mg)
 	addDashboardVersionMigration(mg)
+	addTagMigration(mg)
 }
 }
 
 
 func addMigrationLogMigrations(mg *Migrator) {
 func addMigrationLogMigrations(mg *Migrator) {

+ 24 - 0
pkg/services/sqlstore/migrations/tag_mig.go

@@ -0,0 +1,24 @@
+package migrations
+
+import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
+
+func addTagMigration(mg *Migrator) {
+
+	tagTable := Table{
+		Name: "tag",
+		Columns: []*Column{
+			{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
+			{Name: "key", Type: DB_NVarchar, Length: 100, Nullable: false},
+			{Name: "value", Type: DB_NVarchar, Length: 100, Nullable: false},
+		},
+		Indices: []*Index{
+			{Cols: []string{"key", "value"}, Type: UniqueIndex},
+		},
+	}
+
+	// create table
+	mg.AddMigration("create tag table", NewAddTableMigration(tagTable))
+
+	// create indices
+	mg.AddMigration("add index tag.key_value", NewAddIndexMigration(tagTable, tagTable.Indices[0]))
+}

+ 0 - 3
public/app/core/components/dashboard_selector.ts

@@ -4,9 +4,6 @@ import coreModule from 'app/core/core_module';
 
 
 var template = `
 var template = `
 <select class="gf-form-input" ng-model="ctrl.model" ng-options="f.value as f.text for f in ctrl.options"></select>
 <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 {
 export class DashboardSelectorCtrl {

+ 6 - 0
public/app/core/components/grafana_app.ts

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

+ 2 - 0
public/app/core/components/info_popover.ts

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

+ 1 - 0
public/app/core/directives/tags.js

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

+ 1 - 1
public/app/core/nav_model_srv.ts

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

+ 0 - 1
public/app/features/alerting/alert_def.ts

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

+ 30 - 17
public/app/features/annotations/annotation_tooltip.ts

@@ -1,12 +1,10 @@
-///<reference path="../../headers/common.d.ts" />
-
 import _ from 'lodash';
 import _ from 'lodash';
 import $ from 'jquery';
 import $ from 'jquery';
 import coreModule from 'app/core/core_module';
 import coreModule from 'app/core/core_module';
 import alertDef from '../alerting/alert_def';
 import alertDef from '../alerting/alert_def';
 
 
 /** @ngInject **/
 /** @ngInject **/
-export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) {
+export function annotationTooltipDirective($sanitize, dashboardSrv, contextSrv, popoverSrv, $compile) {
 
 
   function sanitizeString(str) {
   function sanitizeString(str) {
     try {
     try {
@@ -21,6 +19,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) {
     restrict: 'E',
     restrict: 'E',
     scope: {
     scope: {
       "event": "=",
       "event": "=",
+      "onEdit": "&"
     },
     },
     link: function(scope, element) {
     link: function(scope, element) {
       var event = scope.event;
       var event = scope.event;
@@ -31,33 +30,46 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) {
       var tooltip = '<div class="graph-annotation">';
       var tooltip = '<div class="graph-annotation">';
       var titleStateClass = '';
       var titleStateClass = '';
 
 
-      if (event.source.name === 'panel-alert') {
+      if (event.alertId) {
         var stateModel = alertDef.getStateDisplayModel(event.newState);
         var stateModel = alertDef.getStateDisplayModel(event.newState);
         titleStateClass = stateModel.stateClass;
         titleStateClass = stateModel.stateClass;
         title = `<i class="icon-gf ${stateModel.iconClass}"></i> ${stateModel.text}`;
         title = `<i class="icon-gf ${stateModel.iconClass}"></i> ${stateModel.text}`;
         text = alertDef.getAlertAnnotationInfo(event);
         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) {
       if (text) {
-        tooltip += sanitizeString(text).replace(/\n/g, '<br>') + '<br>';
+        tooltip += '<div>' + sanitizeString(text).replace(/\n/g, '<br>') + '</div>';
       }
       }
 
 
       var tags = event.tags;
       var tags = event.tags;
-      if (_.isString(event.tags)) {
-        tags = event.tags.split(',');
-        if (tags.length === 1) {
-          tags = event.tags.split(' ');
-        }
-      }
 
 
       if (tags && tags.length) {
       if (tags && tags.length) {
         scope.tags = tags;
         scope.tags = tags;
@@ -65,6 +77,7 @@ export function annotationTooltipDirective($sanitize, dashboardSrv, $compile) {
       }
       }
 
 
       tooltip += "</div>";
       tooltip += "</div>";
+      tooltip += '</div>';
 
 
       var $tooltip = $(tooltip);
       var $tooltip = $(tooltip);
       $tooltip.appendTo(element);
       $tooltip.appendTo(element);

+ 156 - 84
public/app/features/annotations/annotations_srv.ts

@@ -1,5 +1,3 @@
-///<reference path="../../headers/common.d.ts" />
-
 import './editor_ctrl';
 import './editor_ctrl';
 
 
 import angular from 'angular';
 import angular from 'angular';
@@ -11,11 +9,7 @@ export class AnnotationsSrv {
   alertStatesPromise: any;
   alertStatesPromise: any;
 
 
   /** @ngInject */
   /** @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('refresh', this.clearCache.bind(this), $rootScope);
     $rootScope.onAppEvent('dashboard-initialized', this.clearCache.bind(this), $rootScope);
     $rootScope.onAppEvent('dashboard-initialized', this.clearCache.bind(this), $rootScope);
   }
   }
@@ -26,64 +20,40 @@ export class AnnotationsSrv {
   }
   }
 
 
   getAnnotations(options) {
   getAnnotations(options) {
-    return this.$q.all([
-      this.getGlobalAnnotations(options),
-      this.getPanelAnnotations(options),
-      this.getAlertStates(options)
-    ]).then(results => {
-
-      // 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;
+    return this.$q
+      .all([this.getGlobalAnnotations(options), this.getAlertStates(options)])
+      .then(results => {
+        // combine the annotations and flatten results
+        var annotations = _.flattenDeep(results[0]);
+
+        // 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;
-      });
-
-      // 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 true;
+        });
 
 
-      return [];
-    });
-  }
+        annotations = dedupAnnotations(annotations);
+        annotations = makeRegions(annotations, options);
 
 
-  getPanelAnnotations(options) {
-    var panel = options.panel;
-    var dashboard = options.dashboard;
+        // look for alert state for this panel
+        var alertState = _.find(results[1], {panelId: options.panel.id});
 
 
-    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 {
+          annotations: annotations,
+          alertState: alertState,
+        };
+      })
+      .catch(err => {
+        if (!err.message && err.data && err.data.message) {
+          err.message = err.data.message;
+        }
+        console.log('AnnotationSrv.query error', err);
+        this.$rootScope.appEvent('alert-error', ['Annotation Query Failed', err.message || err]);
+        return [];
       });
       });
-    }
-
-    return this.$q.when([]);
   }
   }
 
 
   getAlertStates(options) {
   getAlertStates(options) {
@@ -104,43 +74,55 @@ export class AnnotationsSrv {
       return this.alertStatesPromise;
       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;
     return this.alertStatesPromise;
   }
   }
 
 
   getGlobalAnnotations(options) {
   getGlobalAnnotations(options) {
     var dashboard = options.dashboard;
     var dashboard = options.dashboard;
 
 
-    if (dashboard.annotations.list.length === 0) {
-      return this.$q.when([]);
-    }
-
     if (this.globalAnnotationsPromise) {
     if (this.globalAnnotationsPromise) {
       return this.globalAnnotationsPromise;
       return this.globalAnnotationsPromise;
     }
     }
 
 
-    var annotations = _.filter(dashboard.annotations.list, {enable: true});
     var range = this.timeSrv.timeRange();
     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) {
       if (annotation.snapshotData) {
         return this.translateQueryResult(annotation, 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;
     return this.globalAnnotationsPromise;
   }
   }
 
 
@@ -149,6 +131,21 @@ export class AnnotationsSrv {
     return this.backendSrv.post('/api/annotations', annotation);
     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) {
   translateQueryResult(annotation, results) {
     // if annotation has snapshotData
     // if annotation has snapshotData
     // make clone and remove it
     // make clone and remove it
@@ -159,13 +156,88 @@ export class AnnotationsSrv {
 
 
     for (var item of results) {
     for (var item of results) {
       item.source = annotation;
       item.source = annotation;
-      item.min = item.time;
-      item.max = item.time;
-      item.scope = 1;
-      item.eventType = annotation.name;
     }
     }
     return results;
     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);
 coreModule.service('annotationsSrv', AnnotationsSrv);

+ 13 - 8
public/app/features/annotations/editor_ctrl.ts

@@ -1,5 +1,3 @@
-///<reference path="../../headers/common.d.ts" />
-
 import angular from 'angular';
 import angular from 'angular';
 import _ from 'lodash';
 import _ from 'lodash';
 import $ from 'jquery';
 import $ from 'jquery';
@@ -35,12 +33,6 @@ export class AnnotationsEditorCtrl {
     this.datasources = datasourceSrv.getAnnotationSources();
     this.datasources = datasourceSrv.getAnnotationSources();
     this.annotations = $scope.dashboard.annotations.list;
     this.annotations = $scope.dashboard.annotations.list;
     this.reset();
     this.reset();
-
-    $scope.$watch('mode', newVal => {
-      if (newVal === 'new') {
-        this.reset();
-      }
-    });
   }
   }
 
 
   datasourceChanged() {
   datasourceChanged() {
@@ -71,6 +63,11 @@ export class AnnotationsEditorCtrl {
     this.$scope.broadcastRefresh();
     this.$scope.broadcastRefresh();
   }
   }
 
 
+  setupNew() {
+    this.mode = 'new';
+    this.reset();
+  }
+
   add() {
   add() {
     this.annotations.push(this.currentAnnotation);
     this.annotations.push(this.currentAnnotation);
     this.reset();
     this.reset();
@@ -85,6 +82,14 @@ export class AnnotationsEditorCtrl {
     this.$scope.dashboard.updateSubmenuVisibility();
     this.$scope.dashboard.updateSubmenuVisibility();
     this.$scope.broadcastRefresh();
     this.$scope.broadcastRefresh();
   }
   }
+
+  annotationEnabledChange() {
+    this.$scope.broadcastRefresh();
+  }
+
+  annotationHiddenChanged() {
+    this.$scope.dashboard.updateSubmenuVisibility();
+  }
 }
 }
 
 
 coreModule.controller('AnnotationsEditorCtrl', AnnotationsEditorCtrl);
 coreModule.controller('AnnotationsEditorCtrl', AnnotationsEditorCtrl);

+ 3 - 1
public/app/features/annotations/event.ts

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

+ 48 - 6
public/app/features/annotations/event_editor.ts

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

+ 106 - 37
public/app/features/annotations/event_manager.ts

@@ -3,25 +3,30 @@ import moment from 'moment';
 import {MetricsPanelCtrl} from 'app/plugins/sdk';
 import {MetricsPanelCtrl} from 'app/plugins/sdk';
 import {AnnotationEvent} from './event';
 import {AnnotationEvent} from './event';
 
 
+const OK_COLOR =       "rgba(11, 237, 50, 1)",
+      ALERTING_COLOR = "rgba(237, 46, 24, 1)",
+      NO_DATA_COLOR =  "rgba(150, 150, 150, 1)";
+
+
 export class EventManager {
 export class EventManager {
   event: AnnotationEvent;
   event: AnnotationEvent;
+  editorOpen: boolean;
 
 
-  constructor(private panelCtrl: MetricsPanelCtrl, private elem, private popoverSrv) {
+  constructor(private panelCtrl: MetricsPanelCtrl) {
   }
   }
 
 
   editorClosed() {
   editorClosed() {
-    console.log('editorClosed');
     this.event = null;
     this.event = null;
+    this.editorOpen = false;
     this.panelCtrl.render();
     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 = new AnnotationEvent();
       this.event.dashboardId = this.panelCtrl.dashboard.id;
       this.event.dashboardId = this.panelCtrl.dashboard.id;
       this.event.panelId = this.panelCtrl.panel.id;
       this.event.panelId = this.panelCtrl.panel.id;
@@ -35,25 +40,11 @@ export class EventManager {
       this.event.isRegion = true;
       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();
     this.panelCtrl.render();
   }
   }
 
 
@@ -64,35 +55,54 @@ export class EventManager {
 
 
     var types = {
     var types = {
       '$__alerting': {
       '$__alerting': {
-        color: 'rgba(237, 46, 24, 1)',
+        color: ALERTING_COLOR,
         position: 'BOTTOM',
         position: 'BOTTOM',
         markerSize: 5,
         markerSize: 5,
       },
       },
       '$__ok': {
       '$__ok': {
-        color: 'rgba(11, 237, 50, 1)',
+        color: OK_COLOR,
         position: 'BOTTOM',
         position: 'BOTTOM',
         markerSize: 5,
         markerSize: 5,
       },
       },
       '$__no_data': {
       '$__no_data': {
-        color: 'rgba(150, 150, 150, 1)',
+        color: NO_DATA_COLOR,
         position: 'BOTTOM',
         position: 'BOTTOM',
         markerSize: 5,
         markerSize: 5,
       },
       },
     };
     };
 
 
     if (this.event) {
     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: '$__alerting',
+            editModel: this.event,
+          }
+        ];
+      } else {
+        annotations = [
+          {
+            min: this.event.time.valueOf(),
+            text: this.event.text,
+            editModel: this.event,
+            eventType: '$__alerting',
+          }
+        ];
+      }
     } else {
     } else {
       // annotations from query
       // annotations from query
       for (var i = 0; i < annotations.length; i++) {
       for (var i = 0; i < annotations.length; i++) {
         var item = annotations[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) {
         if (item.newState) {
           item.eventType = '$__' + item.newState;
           item.eventType = '$__' + item.newState;
           continue;
           continue;
@@ -108,10 +118,69 @@ 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 = {
     flotOptions.events = {
       levels: _.keys(types).length + 1,
       levels: _.keys(types).length + 1,
       data: annotations,
       data: annotations,
       types: types,
       types: types,
+      manager: this
     };
     };
   }
   }
 }
 }
+
+function getRegions(events) {
+  return _.filter(events, 'isRegion');
+}
+
+function addRegionMarking(regions, flotOptions) {
+  let markings = flotOptions.grid.markings;
+  let defaultColor = 'rgb(237, 46, 24)';
+  let fillColor;
+
+  _.each(regions, region => {
+    if (region.source) {
+      fillColor = region.source.iconColor || defaultColor;
+    } else {
+      fillColor = defaultColor;
+    }
+
+    // Convert #FFFFFF to rgb(255, 255, 255)
+    // because panels with alerting use this format
+    let hexPattern = /^#[\da-fA-f]{3,6}/;
+    if (hexPattern.test(fillColor)) {
+      fillColor = convertToRGB(fillColor);
+    }
+
+    fillColor = addAlphaToRGB(fillColor, 0.090);
+    markings.push({ xaxis: { from: region.min, to: region.timeEnd }, color: fillColor });
+  });
+}
+
+function addAlphaToRGB(rgb: string, alpha: number): string {
+  let rgbPattern = /^rgb\(/;
+  if (rgbPattern.test(rgb)) {
+    return rgb.replace(')', `, ${alpha})`).replace('rgb', 'rgba');
+  } else {
+    return rgb.replace(/[\d\.]+\)/, `${alpha})`);
+  }
+}
+
+function convertToRGB(hex: string): string {
+  let hexPattern = /#([\da-fA-F]{2})([\da-fA-F]{2})([\da-fA-F]{2})/g;
+  let match = hexPattern.exec(hex);
+  if (match) {
+    let rgb = _.map(match.slice(1), hex_val => {
+      return parseInt(hex_val, 16);
+    });
+    return 'rgb(' + rgb.join(',')  + ')';
+  } else {
+    return "";
+  }
+}

+ 48 - 41
public/app/features/annotations/partials/editor.html

@@ -40,10 +40,11 @@
 					Annotations provide a way to integrate event data into your graphs. They are visualized as vertical lines and icons
 					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.
 					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.
 					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>
+				<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>
 		</div>
 		</div>
 
 
@@ -53,13 +54,16 @@
 			</div>
 			</div>
 			<table class="grafana-options-table">
 			<table class="grafana-options-table">
 				<tr ng-repeat="annotation in ctrl.annotations">
 				<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}}
 						{{annotation.name}}
 					</td>
 					</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="$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%"><i ng-click="_.move(ctrl.annotations,$index,$index+1)" ng-hide="$last" class="pointer fa fa-arrow-down"></i></td>
-
 					<td style="width: 1%">
 					<td style="width: 1%">
 						<a ng-click="ctrl.edit(annotation)" class="btn btn-inverse btn-mini">
 						<a ng-click="ctrl.edit(annotation)" class="btn btn-inverse btn-mini">
 							<i class="fa fa-edit"></i>
 							<i class="fa fa-edit"></i>
@@ -67,7 +71,7 @@
 						</a>
 						</a>
 					</td>
 					</td>
 					<td style="width: 1%">
 					<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>
 							<i class="fa fa-remove"></i>
 						</a>
 						</a>
 					</td>
 					</td>
@@ -77,60 +81,63 @@
 
 
 		<div class="gf-form" ng-show="ctrl.mode === 'list'">
 		<div class="gf-form" ng-show="ctrl.mode === 'list'">
 			<div class="gf-form-button-row">
 			<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>
 		</div>
 
 
 		<div class="annotations-basic-settings" ng-if="ctrl.mode === 'edit' || ctrl.mode === 'new'">
 		<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-inline">
 						<div class="gf-form">
 						<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>
 						<div class="gf-form">
 						<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>
 								<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>
 					</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">
 						<div class="gf-form">
-							<label class="gf-form-label width-9">Color</label>
+							<label class="gf-form-label">Color</label>
 							<spectrum-picker class="gf-form-input width-3" ng-model="ctrl.currentAnnotation.iconColor"></spectrum-picker>
 							<spectrum-picker class="gf-form-input width-3" ng-model="ctrl.currentAnnotation.iconColor"></spectrum-picker>
 						</div>
 						</div>
 					</div>
 					</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>
-
 	</div>
 	</div>
 </div>
 </div>

+ 31 - 34
public/app/features/annotations/partials/event_editor.html

@@ -1,38 +1,35 @@
 
 
-<h5 class="section-heading text-center">Add annotation</h5>
+<div class="graph-annotation">
+	<div class="graph-annotation__header">
+		<div class="graph-annotation__user" bs-tooltip="'Created by {{ctrl.login}}'">
+		</div>
 
 
-<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__title">
+			<span ng-if="!ctrl.event.id">Add Annotation</span>
+			<span ng-if="ctrl.event.id">Edit Annotation</span>
 		</div>
 		</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__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>

+ 40 - 0
public/app/features/annotations/specs/annotations_srv_specs.ts

@@ -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);
+    });
+  });
+});
+

+ 25 - 0
public/app/features/dashboard/model.ts

@@ -71,10 +71,35 @@ export class DashboardModel {
       }
       }
     }
     }
 
 
+    this.addBuiltInAnnotationQuery();
     this.updateSchema(data);
     this.updateSchema(data);
     this.initMeta(meta);
     this.initMeta(meta);
   }
   }
 
 
+  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: 'rgb(0, 211, 255)',
+      enable: true,
+      hide: true,
+      builtIn: 1,
+    });
+  }
+
   private initMeta(meta) {
   private initMeta(meta) {
     meta = meta || {};
     meta = meta || {};
 
 

+ 8 - 23
public/app/features/dashboard/specs/dashboard_model_specs.ts

@@ -46,8 +46,8 @@ describe('DashboardModel', function() {
       var saveModel = model.getSaveModelClone();
       var saveModel = model.getSaveModelClone();
       var keys = _.keys(saveModel);
       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() {
   describe('Given editable false dashboard', function() {
     var model;
     var model;
 
 
@@ -339,7 +319,12 @@ describe('DashboardModel', function() {
     });
     });
 
 
     it('should add empty list', 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);
       expect(model.templating.list.length).to.be(0);
     });
     });
   });
   });

+ 5 - 1
public/app/features/dashboard/specs/exporter_specs.ts

@@ -80,6 +80,10 @@ describe('given dashboard with repeated panels', function() {
       name: 'mixed',
       name: 'mixed',
       meta: {id: "mixed", info: {version: "1.2.1"}, name: "Mixed", builtIn: true}
       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'] = {
     config.panels['graph'] = {
       id: "graph",
       id: "graph",
@@ -116,7 +120,7 @@ describe('given dashboard with repeated panels', function() {
   });
   });
 
 
   it('should replace datasource in annotation query', 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() {
   it('should add datasource as input', function() {

+ 1 - 1
public/app/features/dashboard/submenu/submenu.html

@@ -12,7 +12,7 @@
 
 
   <div ng-if="ctrl.dashboard.annotations.list.length > 0">
   <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}">
     <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>
   </div>
   </div>
 
 

+ 7 - 3
public/app/features/org/prefs_control.ts

@@ -59,9 +59,13 @@ var template = `
   </div>
   </div>
 
 
   <div class="gf-form">
   <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>
     </dashboard-selector>
   </div>
   </div>
 
 

+ 0 - 6
public/app/headers/common.d.ts

@@ -4,9 +4,3 @@ declare module 'eventemitter3' {
   var config: any;
   var config: any;
   export default config;
   export default config;
 }
 }
-
-declare module 'd3' {
-  var d3: any;
-  export default d3;
-}
-

+ 15 - 8
public/app/plugins/datasource/elasticsearch/datasource.ts

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

+ 1 - 1
public/app/plugins/datasource/elasticsearch/index_pattern.ts

@@ -18,7 +18,7 @@ export class IndexPattern {
     } else {
     } else {
       return this.pattern;
       return this.pattern;
     }
     }
-  };
+  }
 
 
   getIndexList(from, to) {
   getIndexList(from, to) {
     if (!this.interval) {
     if (!this.interval) {

+ 9 - 13
public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html

@@ -15,24 +15,20 @@
 	<h6>Field mappings</h6>
 	<h6>Field mappings</h6>
 	<div class="gf-form-inline">
 	<div class="gf-form-inline">
 		<div class="gf-form">
 		<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>
-
 		<div class="gf-form">
 		<div class="gf-form">
-			<span class="gf-form-label width-10">Title</span>
-			<input type="text" class="gf-form-input max-width-16" ng-model='ctrl.annotation.titleField' placeholder="desc"></input>
+			<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>
-	</div>
-	<div class="gf-form-inline">
 		<div class="gf-form">
 		<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>
+			<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>
-
-		<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 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>
 	</div>
 </div>
 </div>

+ 1 - 1
public/app/plugins/datasource/elasticsearch/query_builder.ts

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

+ 1 - 1
public/app/plugins/datasource/elasticsearch/query_def.ts

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

+ 45 - 27
public/app/plugins/datasource/grafana/datasource.ts

@@ -1,5 +1,3 @@
-///<reference path="../../../headers/common.d.ts" />
-
 import _ from 'lodash';
 import _ from 'lodash';
 
 
 class GrafanaDatasource {
 class GrafanaDatasource {
@@ -8,42 +6,62 @@ class GrafanaDatasource {
   constructor(private backendSrv, private $q) {}
   constructor(private backendSrv, private $q) {}
 
 
   query(options) {
   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 = [];
-
-      if (res.results) {
-        _.forEach(res.results, queryRes => {
-          for (let series of queryRes.series) {
-            data.push({
-              target: series.name,
-              datapoints: series.points
-            });
-          }
-        });
-      }
+    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,
+              });
+            }
+          });
+        }
 
 
-      return {data: data};
-    });
+        return {data: data};
+      });
   }
   }
 
 
   metricFindQuery(options) {
   metricFindQuery(options) {
     return this.$q.when({data: []});
     return this.$q.when({data: []});
   }
   }
 
 
+
   annotationQuery(options) {
   annotationQuery(options) {
-    return this.backendSrv.get('/api/annotations', {
+    const params: any = {
       from: options.range.from.valueOf(),
       from: options.range.from.valueOf(),
       to: options.range.to.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};
 export {GrafanaDatasource};

+ 6 - 5
public/app/plugins/datasource/grafana/module.ts

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

+ 21 - 4
public/app/plugins/datasource/grafana/partials/annotations.editor.html

@@ -2,14 +2,29 @@
 <div class="gf-form-group">
 <div class="gf-form-group">
 	<div class="gf-form-inline">
 	<div class="gf-form-inline">
 		<div class="gf-form">
 		<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>
 				</select>
 			</div>
 			</div>
 		</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">
 		<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">
 			<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 class="gf-form-input" ng-model="ctrl.annotation.limit" ng-options="f for f in [10,50,100,200,300,500,1000,2000]">
 				</select>
 				</select>
@@ -17,3 +32,5 @@
 		</div>
 		</div>
 	</div>
 	</div>
 </div>
 </div>
+
+

+ 20 - 3
public/app/plugins/datasource/graphite/datasource.ts

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

+ 90 - 16
public/app/plugins/datasource/graphite/specs/datasource_specs.ts

@@ -2,10 +2,12 @@
 import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common';
 import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common';
 import helpers from 'test/specs/helpers';
 import helpers from 'test/specs/helpers';
 import {GraphiteDatasource} from "../datasource";
 import {GraphiteDatasource} from "../datasource";
+import moment from 'moment';
+import _ from 'lodash';
 
 
 describe('graphiteDatasource', function() {
 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.core'));
   beforeEach(angularMocks.module('grafana.services'));
   beforeEach(angularMocks.module('grafana.services'));
@@ -22,16 +24,16 @@ describe('graphiteDatasource', function() {
     ctx.ds = ctx.$injector.instantiate(GraphiteDatasource, {instanceSettings: instanceSettings});
     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,
       panelId: 3,
       rangeRaw: { from: 'now-1h', to: 'now' },
       rangeRaw: { from: 'now-1h', to: 'now' },
       targets: [{ target: 'prod1.count' }, {target: 'prod2.count'}],
       targets: [{ target: 'prod1.count' }, {target: 'prod2.count'}],
       maxDataPoints: 500,
       maxDataPoints: 500,
     };
     };
 
 
-    var results;
-    var requestOptions;
+    let results;
+    let requestOptions;
 
 
     beforeEach(function() {
     beforeEach(function() {
       ctx.backendSrv.datasourceRequest = function(options) {
       ctx.backendSrv.datasourceRequest = function(options) {
@@ -52,7 +54,7 @@ describe('graphiteDatasource', function() {
     });
     });
 
 
     it('should query correctly', 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=prod1.count');
       expect(params).to.contain('target=prod2.count');
       expect(params).to.contain('target=prod2.count');
       expect(params).to.contain('from=-1h');
       expect(params).to.contain('from=-1h');
@@ -60,7 +62,7 @@ describe('graphiteDatasource', function() {
     });
     });
 
 
     it('should exclude undefined params', function() {
     it('should exclude undefined params', function() {
-      var params = requestOptions.data.split('&');
+      let params = requestOptions.data.split('&');
       expect(params).to.not.contain('cacheTimeout=undefined');
       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() {
   describe('building graphite params', function() {
     it('should return empty array if no targets', function() {
     it('should return empty array if no targets', function() {
-      var results = ctx.ds.buildGraphiteParams({
+      let results = ctx.ds.buildGraphiteParams({
         targets: [{}]
         targets: [{}]
       });
       });
       expect(results.length).to.be(0);
       expect(results.length).to.be(0);
     });
     });
 
 
     it('should uri escape targets', function() {
     it('should uri escape targets', function() {
-      var results = ctx.ds.buildGraphiteParams({
+      let results = ctx.ds.buildGraphiteParams({
       targets: [{target: 'prod1.{test,test2}'}, {target: 'prod2.count'}]
       targets: [{target: 'prod1.{test,test2}'}, {target: 'prod2.count'}]
       });
       });
       expect(results).to.contain('target=prod1.%7Btest%2Ctest2%7D');
       expect(results).to.contain('target=prod1.%7Btest%2Ctest2%7D');
     });
     });
 
 
     it('should replace target placeholder', function() {
     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)'}]
       targets: [{target: 'series1'}, {target: 'series2'}, {target: 'asPercent(#A,#B)'}]
       });
       });
       expect(results[2]).to.be('target=asPercent(series1%2Cseries2)');
       expect(results[2]).to.be('target=asPercent(series1%2Cseries2)');
     });
     });
 
 
     it('should replace target placeholder for hidden series', function() {
     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)'}]
       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))'));
       expect(results[0]).to.be('target=' + encodeURIComponent('asPercent(series1,sumSeries(series1))'));
     });
     });
 
 
     it('should replace target placeholder when nesting query references', function() {
     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)'}]
       targets: [{target: 'series1'}, {target: 'sumSeries(#A)'}, {target: 'asPercent(#A,#B)'}]
       });
       });
       expect(results[2]).to.be('target=' + encodeURIComponent("asPercent(series1,sumSeries(series1))"));
       expect(results[2]).to.be('target=' + encodeURIComponent("asPercent(series1,sumSeries(series1))"));
     });
     });
 
 
     it('should fix wrong minute interval parameters', function() {
     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')" }]
       targets: [{target: "summarize(prod.25m.count, '25m', 'sum')" }]
       });
       });
       expect(results[0]).to.be('target=' + encodeURIComponent("summarize(prod.25m.count, '25min', 'sum')"));
       expect(results[0]).to.be('target=' + encodeURIComponent("summarize(prod.25m.count, '25min', 'sum')"));
     });
     });
 
 
     it('should fix wrong month interval parameters', function() {
     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')" }]
       targets: [{target: "summarize(prod.5M.count, '5M', 'sum')" }]
       });
       });
       expect(results[0]).to.be('target=' + encodeURIComponent("summarize(prod.5M.count, '5mon', 'sum')"));
       expect(results[0]).to.be('target=' + encodeURIComponent("summarize(prod.5M.count, '5mon', 'sum')"));
     });
     });
 
 
     it('should ignore empty targets', function() {
     it('should ignore empty targets', function() {
-      var results = ctx.ds.buildGraphiteParams({
+      let results = ctx.ds.buildGraphiteParams({
       targets: [{target: 'series1'}, {target: ''}]
       targets: [{target: 'series1'}, {target: ''}]
       });
       });
       expect(results.length).to.be(2);
       expect(results.length).to.be(2);

+ 0 - 2
public/app/plugins/datasource/influxdb/datasource.ts

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

+ 5 - 7
public/app/plugins/datasource/influxdb/partials/annotations.editor.html

@@ -9,18 +9,16 @@
 <div class="gf-form-group">
 <div class="gf-form-group">
 	<div class="gf-form-inline">
 	<div class="gf-form-inline">
 		<div class="gf-form">
 		<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>
-
 		<div class="gf-form">
 		<div class="gf-form">
 			<span class="gf-form-label width-4">Tags</span>
 			<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>
 			<input type="text" class="gf-form-input max-width-10" ng-model='ctrl.annotation.tagsColumn' placeholder=""></input>
 		</div>
 		</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>
 	</div>
 </div>
 </div>

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

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

+ 1 - 3
public/app/plugins/datasource/mysql/response_parser.ts

@@ -106,7 +106,6 @@ export default class ResponseParser {
     const table = data.data.results[options.annotation.name].tables[0];
     const table = data.data.results[options.annotation.name].tables[0];
 
 
     let timeColumnIndex = -1;
     let timeColumnIndex = -1;
-    let titleColumnIndex = -1;
     let textColumnIndex = -1;
     let textColumnIndex = -1;
     let tagsColumnIndex = -1;
     let tagsColumnIndex = -1;
 
 
@@ -114,7 +113,7 @@ export default class ResponseParser {
       if (table.columns[i].text === 'time_sec') {
       if (table.columns[i].text === 'time_sec') {
         timeColumnIndex = i;
         timeColumnIndex = i;
       } else if (table.columns[i].text === 'title') {
       } 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') {
       } else if (table.columns[i].text === 'text') {
         textColumnIndex = i;
         textColumnIndex = i;
       } else if (table.columns[i].text === 'tags') {
       } else if (table.columns[i].text === 'tags') {
@@ -132,7 +131,6 @@ export default class ResponseParser {
       list.push({
       list.push({
         annotation: options.annotation,
         annotation: options.annotation,
         time: Math.floor(row[timeColumnIndex]) * 1000,
         time: Math.floor(row[timeColumnIndex]) * 1000,
-        title: row[titleColumnIndex],
         text: row[textColumnIndex],
         text: row[textColumnIndex],
         tags: row[tagsColumnIndex] ? row[tagsColumnIndex].trim().split(/\s*,\s*/) : []
         tags: row[tagsColumnIndex] ? row[tagsColumnIndex].trim().split(/\s*,\s*/) : []
       });
       });

+ 5 - 6
public/app/plugins/datasource/mysql/specs/datasource_specs.ts

@@ -27,7 +27,7 @@ describe('MySQLDatasource', function() {
     const options = {
     const options = {
       annotation: {
       annotation: {
         name: annotationName,
         name: annotationName,
-        rawQuery: 'select time_sec, title, text, tags from table;'
+        rawQuery: 'select time_sec, text, tags from table;'
       },
       },
       range: {
       range: {
         from: moment(1432288354),
         from: moment(1432288354),
@@ -41,11 +41,11 @@ describe('MySQLDatasource', function() {
           refId: annotationName,
           refId: annotationName,
           tables: [
           tables: [
             {
             {
-              columns: [{text: 'time_sec'}, {text: 'title'}, {text: 'text'}, {text: 'tags'}],
+              columns: [{text: 'time_sec'}, {text: 'text'}, {text: 'tags'}],
               rows: [
               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() {
     it('should return annotation list', function() {
       expect(results.length).to.be(3);
       expect(results.length).to.be(3);
 
 
-      expect(results[0].title).to.be('aTitle');
       expect(results[0].text).to.be('some text');
       expect(results[0].text).to.be('some text');
       expect(results[0].tags[0]).to.be('TagA');
       expect(results[0].tags[0]).to.be('TagA');
       expect(results[0].tags[1]).to.be('TagB');
       expect(results[0].tags[1]).to.be('TagB');

+ 1 - 2
public/app/plugins/datasource/opentsdb/datasource.js

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

+ 1 - 1
public/app/plugins/panel/alertlist/module.html

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

+ 11 - 10
public/app/plugins/panel/graph/graph.ts

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

+ 215 - 10
public/app/plugins/panel/graph/jquery.flot.events.js

@@ -7,14 +7,18 @@ define([
 function ($, _, angular, Drop) {
 function ($, _, angular, Drop) {
   'use strict';
   'use strict';
 
 
-  function createAnnotationToolip(element, event) {
+  function createAnnotationToolip(element, event, plot) {
     var injector = angular.element(document).injector();
     var injector = angular.element(document).injector();
     var content = document.createElement('div');
     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) {
     injector.invoke(["$compile", "$rootScope", function($compile, $rootScope) {
+      var eventManager = plot.getOptions().events.manager;
       var tmpScope = $rootScope.$new(true);
       var tmpScope = $rootScope.$new(true);
       tmpScope.event = event;
       tmpScope.event = event;
+      tmpScope.onEdit = function() {
+        eventManager.editEvent(event);
+      };
 
 
       $compile(content)(tmpScope);
       $compile(content)(tmpScope);
       tmpScope.$digest();
       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
    * jquery.flot.events
    *
    *
@@ -121,11 +188,20 @@ function ($, _, angular, Drop) {
      */
      */
     this.setupEvents = function(events) {
     this.setupEvents = function(events) {
       var that = this;
       var that = this;
+      var parts = _.partition(events, 'isRegion');
+      var regions = parts[0];
+      events = parts[1];
+
       $.each(events, function(index, event) {
       $.each(events, function(index, event) {
         var ve = new VisualEvent(event, that._buildDiv(event));
         var ve = new VisualEvent(event, that._buildDiv(event));
         _events.push(ve);
         _events.push(ve);
       });
       });
 
 
+      $.each(regions, function (index, event) {
+        var vre = new VisualEvent(event, that._buildRegDiv(event));
+        _events.push(vre);
+      });
+
       _events.sort(function(a, b) {
       _events.sort(function(a, b) {
         var ao = a.getOptions(), bo = b.getOptions();
         var ao = a.getOptions(), bo = b.getOptions();
         if (ao.min > bo.min) { return 1; }
         if (ao.min > bo.min) { return 1; }
@@ -232,7 +308,10 @@ function ($, _, angular, Drop) {
         lineWidth = this._types[eventTypeId].lineWidth;
         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;
       left = xaxis.p2c(event.min) + o.left;
 
 
       var line = $('<div class="events_line flot-temp-elem"></div>').css({
       var line = $('<div class="events_line flot-temp-elem"></div>').css({
@@ -241,25 +320,27 @@ function ($, _, angular, Drop) {
         "left": left + 'px',
         "left": left + 'px',
         "top": 8,
         "top": 8,
         "width": lineWidth + "px",
         "width": lineWidth + "px",
-        "height": this._plot.height(),
+        "height": this._plot.height() + topOffset * 0.8,
         "border-left-width": lineWidth + "px",
         "border-left-width": lineWidth + "px",
         "border-left-style": lineStyle,
         "border-left-style": lineStyle,
-        "border-left-color": color
+        "border-left-color": color,
+        "color": color
       })
       })
       .appendTo(container);
       .appendTo(container);
 
 
       if (markerShow) {
       if (markerShow) {
         var marker = $('<div class="events_marker"></div>').css({
         var marker = $('<div class="events_marker"></div>').css({
           "position": "absolute",
           "position": "absolute",
-          "left": (-markerSize-Math.round(lineWidth/2)) + "px",
+          "left": (-markerSize - Math.round(lineWidth / 2)) + "px",
           "font-size": 0,
           "font-size": 0,
           "line-height": 0,
           "line-height": 0,
           "width": 0,
           "width": 0,
           "height": 0,
           "height": 0,
           "border-left": markerSize+"px solid transparent",
           "border-left": markerSize+"px solid transparent",
           "border-right": 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') {
         if (this._types[eventTypeId] && this._types[eventTypeId].position && this._types[eventTypeId].position.toUpperCase() === 'BOTTOM') {
           marker.css({
           marker.css({
@@ -280,9 +361,13 @@ function ($, _, angular, Drop) {
         });
         });
 
 
         var mouseenter = function() {
         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() {
         var mouseleave = function() {
           that._plot.clearSelection();
           that._plot.clearSelection();
         };
         };
@@ -312,6 +397,127 @@ function ($, _, angular, Drop) {
       return drawableEvent;
       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
      * check if the event is inside visible range
      */
      */
@@ -395,5 +601,4 @@ function ($, _, angular, Drop) {
     name: "events",
     name: "events",
     version: "0.2.5"
     version: "0.2.5"
   });
   });
-
 });
 });

+ 83 - 0
public/app/system.conf.js

@@ -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'
+    }
+  }
+});

+ 1 - 0
public/sass/_grafana.scss

@@ -78,6 +78,7 @@
 @import "components/jsontree";
 @import "components/jsontree";
 @import "components/edit_sidemenu.scss";
 @import "components/edit_sidemenu.scss";
 @import "components/row.scss";
 @import "components/row.scss";
+@import "components/icon-picker.scss";
 @import "components/json_explorer.scss";
 @import "components/json_explorer.scss";
 @import "components/code_editor.scss";
 @import "components/code_editor.scss";
 
 

+ 2 - 1
public/sass/_variables.dark.scss

@@ -251,7 +251,8 @@ $alert-info-bg:           linear-gradient(100deg, #1a4552, #00374a);
 // popover
 // popover
 $popover-bg:              $panel-bg;
 $popover-bg:              $panel-bg;
 $popover-color:           $text-color;
 $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-bg:         $btn-secondary-bg;
 $popover-help-color:      $text-color;
 $popover-help-color:      $text-color;

+ 4 - 2
public/sass/_variables.light.scss

@@ -270,9 +270,11 @@ $alert-warning-bg:        linear-gradient(90deg, #d44939, #e0603d);
 $alert-info-bg:           $blue-dark;
 $alert-info-bg:           $blue-dark;
 
 
 // popover
 // popover
-$popover-bg:              $gray-5;
+$popover-bg:              $panel-bg;
 $popover-color:           $text-color;
 $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-bg:         $blue-dark;
 $popover-help-color:      $gray-6;
 $popover-help-color:      $gray-6;
 $popover-error-bg:        $btn-danger-bg;
 $popover-error-bg:        $btn-danger-bg;

+ 7 - 0
public/sass/components/_drop.scss

@@ -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-element.drop-popover--form {
   .drop-content {
   .drop-content {
     max-width: none;
     max-width: none;
+    padding: 0;
   }
   }
 }
 }
 
 

+ 26 - 0
public/sass/components/_icon-picker.scss

@@ -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;
+  }
+}

+ 24 - 9
public/sass/components/_panel_graph.scss

@@ -287,19 +287,27 @@
     margin-top: 8px;
     margin-top: 8px;
   }
   }
 
 
-  .graph-annotation-header {
-    background-color: $input-label-bg;
+  .graph-annotation__header {
+    background-color: $popover-border-color;
     padding: 0.40rem 0.65rem;
     padding: 0.40rem 0.65rem;
+    display: flex;
   }
   }
 
 
-  .graph-annotation-title {
+  .graph-annotation__title {
     font-weight: $font-weight-semi-bold;
     font-weight: $font-weight-semi-bold;
     padding-right: $spacer;
     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;
     color: $text-muted;
     font-style: italic;
     font-style: italic;
     font-weight: normal;
     font-weight: normal;
@@ -308,15 +316,22 @@
     top: 1px;
     top: 1px;
   }
   }
 
 
-  .graph-annotation-body {
+  .graph-annotation__body {
     padding: 0.65rem;
     padding: 0.65rem;
   }
   }
 
 
-  a {
+  .graph-annotation__user {
+    img {
+      border-radius: 50%;
+      width: 16px;
+      height: 16px;
+    }
+  }
+
+  a[href] {
     color: $blue;
     color: $blue;
     text-decoration: underline;
     text-decoration: underline;
   }
   }
-
 }
 }
 
 
 .left-yaxis-label {
 .left-yaxis-label {

+ 0 - 4
public/sass/mixins/_drop_element.scss

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

+ 130 - 0
public/test/test-main.js

@@ -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);
+  });
+
+})();

+ 6 - 1
public/vendor/flot/jquery.flot.js

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

+ 9 - 3
public/vendor/tagsinput/bootstrap-tagsinput.js

@@ -28,15 +28,14 @@
     this.$element = $(element);
     this.$element = $(element);
     this.$element.hide();
     this.$element.hide();
 
 
+    this.widthClass = options.widthClass || 'width-9';
     this.isSelect = (element.tagName === 'SELECT');
     this.isSelect = (element.tagName === 'SELECT');
     this.multiple = (this.isSelect && element.hasAttribute('multiple'));
     this.multiple = (this.isSelect && element.hasAttribute('multiple'));
     this.objectItems = options && options.itemValue;
     this.objectItems = options && options.itemValue;
     this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : '';
     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.$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);
     this.$element.after(this.$container);
 
 
@@ -292,6 +291,13 @@
         self.$input.focus();
         self.$input.focus();
       }, self));
       }, 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) {
       self.$container.on('keydown', 'input', $.proxy(function(event) {
         var $input = $(event.target),
         var $input = $(event.target),
             $inputWrapper = self.findInputWrapper();
             $inputWrapper = self.findInputWrapper();

+ 3 - 6
scripts/webpack/webpack.common.js

@@ -29,13 +29,14 @@ module.exports = {
   module: {
   module: {
     rules: [
     rules: [
       {
       {
-        test: /\.(ts|tsx)$/,
+        test: /\.tsx?$/,
         enforce: 'pre',
         enforce: 'pre',
         exclude: /node_modules/,
         exclude: /node_modules/,
         use: {
         use: {
           loader: 'tslint-loader',
           loader: 'tslint-loader',
           options: {
           options: {
-            emitErrors: true
+            emitErrors: true,
+            typeCheck: false,
           }
           }
         }
         }
       },
       },
@@ -59,10 +60,6 @@ module.exports = {
           }
           }
         ]
         ]
       },
       },
-      // {
-      //   test   : /\.(ico|png|cur|jpg|ttf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/,
-      //   loader : 'file-loader',
-      // },
       {
       {
         test: /\.html$/,
         test: /\.html$/,
         exclude: /index\.template.html/,
         exclude: /index\.template.html/,

+ 45 - 0
tasks/options/copy.js

@@ -0,0 +1,45 @@
+module.exports = function(config) {
+  return {
+    // copy source to temp, we will minify in place for the dist build
+    everything_but_less_to_temp: {
+      cwd: '<%= srcDir %>',
+      expand: true,
+      src: ['**/*', '!**/*.less'],
+      dest: '<%= tempDir %>'
+    },
+
+    public_to_gen: {
+      cwd: '<%= srcDir %>',
+      expand: true,
+      src: ['**/*', '!**/*.less'],
+      dest: '<%= genDir %>'
+    },
+
+    node_modules: {
+      cwd: './node_modules',
+      expand: true,
+      src: [
+        'ace-builds/src-noconflict/**/*',
+        'eventemitter3/*.js',
+        'systemjs/dist/*.js',
+        'es6-promise/**/*',
+        'es6-shim/*.js',
+        'reflect-metadata/*.js',
+        'reflect-metadata/*.ts',
+        'reflect-metadata/*.d.ts',
+        'rxjs/**/*',
+        'tether/**/*',
+        'tether-drop/**/*',
+        'tether-drop/**/*',
+        'remarkable/dist/*',
+        'remarkable/dist/*',
+        'virtual-scroll/**/*',
+        'mousetrap/**/*',
+        'twemoji/2/twemoji.amd*',
+        'twemoji/2/svg/*.svg',
+      ],
+      dest: '<%= srcDir %>/vendor/npm'
+    }
+
+  };
+};

+ 2 - 4
tsconfig.json

@@ -9,8 +9,8 @@
       "module": "esnext",
       "module": "esnext",
       "declaration": false,
       "declaration": false,
       "allowSyntheticDefaultImports": true,
       "allowSyntheticDefaultImports": true,
-      "inlineSourceMap": true,
-      "sourceMap": false,
+      "inlineSourceMap": false,
+      "sourceMap": true,
       "noEmitOnError": false,
       "noEmitOnError": false,
       "emitDecoratorMetadata": false,
       "emitDecoratorMetadata": false,
       "experimentalDecorators": false,
       "experimentalDecorators": false,
@@ -28,7 +28,5 @@
       "public/app/**/*.ts",
       "public/app/**/*.ts",
       "public/app/**/*.tsx",
       "public/app/**/*.tsx",
       "public/test/**/*.ts"
       "public/test/**/*.ts"
-    ],
-    "exclude": [
     ]
     ]
 }
 }

+ 1 - 2
tslint.json

@@ -6,7 +6,7 @@
 		"no-unused-variable": true,
 		"no-unused-variable": true,
     "curly": true,
     "curly": true,
     "class-name": true,
     "class-name": true,
-    "semicolon": ["always"],
+    "semicolon": [true, "always", "ignore-bound-class-methods"],
     "triple-equals": [true, "allow-null-check"],
     "triple-equals": [true, "allow-null-check"],
     "comment-format": [false, "check-space"],
     "comment-format": [false, "check-space"],
     "eofline": true,
     "eofline": true,
@@ -26,7 +26,6 @@
     ],
     ],
     "no-construct": true,
     "no-construct": true,
     "no-debugger": true,
     "no-debugger": true,
-    "no-duplicate-variable": true,
     "no-empty": false,
     "no-empty": false,
     "no-eval": true,
     "no-eval": true,
     "no-inferrable-types": true,
     "no-inferrable-types": true,