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

1
.gitignore vendored

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

@ -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"}
```

@ -87,8 +87,8 @@
"tslint-loader": "^3.5.3",
"typescript": "^2.5.2",
"webpack": "^3.6.0",
"webpack-bundle-analyzer": "^2.9.0",
"webpack-cleanup-plugin": "^0.5.1",
"webpack-bundle-analyzer": "^2.9.0",
"webpack-merge": "^4.1.0",
"zone.js": "^0.7.2"
},
@ -97,13 +97,14 @@
"watch": "./node_modules/.bin/webpack --progress --colors --watch --config scripts/webpack/webpack.dev.js",
"build": "./node_modules/.bin/grunt build",
"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"
},
"license": "Apache-2.0",
"dependencies": {
"angular": "^1.6.6",
"angular-bindonce": "^0.3.1",
"angular-mocks": "^1.6.6",
"angular-native-dragdrop": "^1.2.2",
"angular-route": "^1.6.6",
"angular-sanitize": "^1.6.6",

@ -2,6 +2,7 @@ package api
import (
"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/services/annotations"
)
@ -11,13 +12,12 @@ func GetAnnotations(c *middleware.Context) Response {
query := &annotations.ItemQuery{
From: c.QueryInt64("from") / 1000,
To: c.QueryInt64("to") / 1000,
Type: annotations.ItemType(c.Query("type")),
OrgId: c.OrgId,
AlertId: c.QueryInt64("alertId"),
DashboardId: c.QueryInt64("dashboardId"),
PanelId: c.QueryInt64("panelId"),
Limit: c.QueryInt64("limit"),
NewState: c.QueryStrings("newState"),
Tags: c.QueryStrings("tags"),
}
repo := annotations.GetRepository()
@ -27,25 +27,14 @@ func GetAnnotations(c *middleware.Context) Response {
return ApiError(500, "Failed to get annotations", err)
}
result := make([]dtos.Annotation, 0)
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 {
@ -53,14 +42,13 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response
item := annotations.Item{
OrgId: c.OrgId,
UserId: c.UserId,
DashboardId: cmd.DashboardId,
PanelId: cmd.PanelId,
Epoch: cmd.Time / 1000,
Title: cmd.Title,
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 {
@ -71,12 +59,16 @@ func PostAnnotation(c *middleware.Context, cmd dtos.PostAnnotationsCmd) Response
if cmd.IsRegion {
item.RegionId = item.Id
if item.Data == nil {
item.Data = simplejson.New()
}
if err := repo.Update(&item); err != nil {
return ApiError(500, "Failed set regionId on annotation", err)
}
item.Id = 0
item.Epoch = cmd.TimeEnd
item.Epoch = cmd.TimeEnd / 1000
if err := repo.Save(&item); err != nil {
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")
}
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 {
repo := annotations.GetRepository()
@ -101,3 +128,33 @@ func DeleteAnnotations(c *middleware.Context, cmd dtos.DeleteAnnotationsCmd) Res
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")
}

@ -289,6 +289,9 @@ func (hs *HttpServer) registerRoutes() {
apiRoute.Group("/annotations", func(annotationsRoute RouteRegister) {
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)
// error test

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

@ -2,37 +2,30 @@ package dtos
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 {
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 {
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"`
}

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

@ -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")
})
})
}

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

@ -5,7 +5,7 @@ import "github.com/grafana/grafana/pkg/components/simplejson"
type Repository interface {
Save(item *Item) error
Update(item *Item) error
Find(query *ItemQuery) ([]*Item, error)
Find(query *ItemQuery) ([]*ItemDTO, error)
Delete(params *DeleteParams) error
}
@ -13,11 +13,10 @@ type ItemQuery struct {
OrgId int64 `json:"orgId"`
From int64 `json:"from"`
To int64 `json:"to"`
Type ItemType `json:"type"`
AlertId int64 `json:"alertId"`
DashboardId int64 `json:"dashboardId"`
PanelId int64 `json:"panelId"`
NewState []string `json:"newState"`
Tags []string `json:"tags"`
Limit int64 `json:"limit"`
}
@ -28,12 +27,15 @@ type PostParams struct {
Epoch int64 `json:"epoch"`
Title string `json:"title"`
Text string `json:"text"`
Icon string `json:"icon"`
}
type DeleteParams struct {
Id int64 `json:"id"`
AlertId int64 `json:"alertId"`
DashboardId int64 `json:"dashboardId"`
PanelId int64 `json:"panelId"`
RegionId int64 `json:"regionId"`
}
var repositoryInstance Repository
@ -46,29 +48,41 @@ func SetRepository(rep Repository) {
repositoryInstance = rep
}
type ItemType string
const (
AlertType ItemType = "alert"
EventType ItemType = "event"
)
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"`
}

@ -2,9 +2,11 @@ package sqlstore
import (
"bytes"
"errors"
"fmt"
"strings"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/annotations"
)
@ -13,19 +15,94 @@ type SqlAnnotationRepo struct {
func (r *SqlAnnotationRepo) Save(item *annotations.Item) 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 {
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
})
}
// 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 {
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
}
@ -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
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)
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)
}
if query.DashboardId != 0 {
sql.WriteString(` AND dashboard_id = ?`)
sql.WriteString(` AND annotation.dashboard_id = ?`)
params = append(params, query.DashboardId)
}
if query.PanelId != 0 {
sql.WriteString(` AND panel_id = ?`)
sql.WriteString(` AND annotation.panel_id = ?`)
params = append(params, query.PanelId)
}
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)
}
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))
items := make([]*annotations.Item, 0)
items := make([]*annotations.ItemDTO, 0)
if err := x.Sql(sql.String(), params...).Find(&items); err != nil {
return nil, err
}
@ -97,11 +202,31 @@ func (r *SqlAnnotationRepo) Find(query *annotations.ItemQuery) ([]*annotations.I
func (r *SqlAnnotationRepo) Delete(params *annotations.DeleteParams) 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
}

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

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

@ -57,4 +57,37 @@ func addAnnotationMig(mg *Migrator) {
mg.AddMigration("Add column region_id to annotation table", NewAddColumnMigration(table, &Column{
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))
}

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

@ -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]))
}

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

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

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

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

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

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

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

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

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

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

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

@ -3,25 +3,30 @@ import moment from 'moment';
import {MetricsPanelCtrl} from 'app/plugins/sdk';
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 {
event: AnnotationEvent;
editorOpen: boolean;
constructor(private panelCtrl: MetricsPanelCtrl, private elem, private popoverSrv) {
constructor(private panelCtrl: MetricsPanelCtrl) {
}
editorClosed() {
console.log('editorClosed');
this.event = null;
this.editorOpen = false;
this.panelCtrl.render();
}
updateTime(range) {
let newEvent = true;
editorOpened() {
this.editorOpen = true;
}
if (this.event) {
newEvent = false;
} else {
// init new event
updateTime(range) {
if (!this.event) {
this.event = new AnnotationEvent();
this.event.dashboardId = this.panelCtrl.dashboard.id;
this.event.panelId = this.panelCtrl.panel.id;
@ -35,25 +40,11 @@ export class EventManager {
this.event.isRegion = true;
}
// newEvent means the editor is not visible
if (!newEvent) {
this.panelCtrl.render();
return;
}
this.popoverSrv.show({
element: this.elem[0],
classNames: 'drop-popover drop-popover--form',
position: 'bottom center',
openOn: null,
template: '<event-editor panel-ctrl="panelCtrl" event="event" close="dismiss()"></event-editor>',
onClose: this.editorClosed.bind(this),
model: {
event: this.event,
panelCtrl: this.panelCtrl,
},
});
this.panelCtrl.render();
}
editEvent(event, elem?) {
this.event = event;
this.panelCtrl.render();
}
@ -64,35 +55,54 @@ export class EventManager {
var types = {
'$__alerting': {
color: 'rgba(237, 46, 24, 1)',
color: ALERTING_COLOR,
position: 'BOTTOM',
markerSize: 5,
},
'$__ok': {
color: 'rgba(11, 237, 50, 1)',
color: OK_COLOR,
position: 'BOTTOM',
markerSize: 5,
},
'$__no_data': {
color: 'rgba(150, 150, 150, 1)',
color: NO_DATA_COLOR,
position: 'BOTTOM',
markerSize: 5,
},
};
if (this.event) {
annotations = [
{
min: this.event.time.valueOf(),
title: this.event.title,
text: this.event.text,
eventType: '$__alerting',
}
];
if (this.event.isRegion) {
annotations = [
{
isRegion: true,
min: this.event.time.valueOf(),
timeEnd: this.event.timeEnd.valueOf(),
text: this.event.text,
eventType: '$__alerting',
editModel: this.event,
}
];
} else {
annotations = [
{
min: this.event.time.valueOf(),
text: this.event.text,
editModel: this.event,
eventType: '$__alerting',
}
];
}
} else {
// annotations from query
for (var i = 0; i < annotations.length; i++) {
var item = annotations[i];
// add properties used by jquery flot events
item.min = item.time;
item.max = item.time;
item.eventType = item.source.name;
if (item.newState) {
item.eventType = '$__' + item.newState;
continue;
@ -108,10 +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 = {
levels: _.keys(types).length + 1,
data: annotations,
types: types,
manager: this
};
}
}
function getRegions(events) {
return _.filter(events, 'isRegion');
}
function addRegionMarking(regions, flotOptions) {
let markings = flotOptions.grid.markings;
let defaultColor = '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 "";
}
}

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

@ -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>
<!-- 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>

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

@ -71,10 +71,35 @@ export class DashboardModel {
}
}
this.addBuiltInAnnotationQuery();
this.updateSchema(data);
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) {
meta = meta || {};

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -29,13 +29,14 @@ module.exports = {
module: {
rules: [
{
test: /\.(ts|tsx)$/,
test: /\.tsx?$/,
enforce: 'pre',
exclude: /node_modules/,
use: {
loader: 'tslint-loader',
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$/,
exclude: /index\.template.html/,

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

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

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

Loading…
Cancel
Save