diff --git a/.gitignore b/.gitignore index 03178388a7c..2113bb2920b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/sources/http_api/annotations.md b/docs/sources/http_api/annotations.md new file mode 100644 index 00000000000..2f148e9aded --- /dev/null +++ b/docs/sources/http_api/annotations.md @@ -0,0 +1,189 @@ ++++ +title = "Annotations HTTP API " +description = "Grafana Annotations HTTP API" +keywords = ["grafana", "http", "documentation", "api", "annotation", "annotations", "comment"] +aliases = ["/http_api/annotations/"] +type = "docs" +[menu.docs] +name = "Annotations" +identifier = "annotationshttp" +parent = "http_api" ++++ + +# Annotations resources / actions + +This is the API documentation for the new Grafana Annotations feature released in Grafana 4.6. Annotations are saved in the Grafana database (sqlite, mysql or postgres). Annotations can be global annotations that can be shown on any dashboard by configuring an annotation data source - they are filtered by tags. Or they can be tied to a panel on a dashboard and are then only shown on that panel. + +## Find Annotations + +`GET /api/annotations?from=1506676478816&to=1507281278816&tags=tag1&tags=tag2&limit=100` + +**Example Request**: + +```http +GET /api/annotations?from=1506676478816&to=1507281278816&tags=tag1&tags=tag2&limit=100 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Basic YWRtaW46YWRtaW4= +``` + + +Query Parameters: + +- `from`: epoch datetime in milliseconds. Optional. +- `to`: epoch datetime in milliseconds. Optional. +- `limit`: number. Optional - default is 10. Max limit for results returned. +- `alertId`: number. Optional. Find annotations for a specified alert. +- `dashboardId`: number. Optional. Find annotations that are scoped to a specific dashboard +- `panelId`: number. Optional. Find annotations that are scoped to a specific panel +- `tags`: string. Optional. Use this to filter global annotations. Global annotations are annotations from an annotation data source that are not connected specifically to a dashboard or panel. To do an "AND" filtering with multiple tags, specify the tags parameter multiple times e.g. `tags=tag1&tags=tag2`. + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json +[ + { + "id": 1124, + "alertId": 0, + "dashboardId": 468, + "panelId": 2, + "userId": 1, + "userName": "", + "newState": "", + "prevState": "", + "time": 1507266395000, + "text": "test", + "metric": "", + "regionId": 1123, + "type": "event", + "tags": [ + "tag1", + "tag2" + ], + "data": {} + }, + { + "id": 1123, + "alertId": 0, + "dashboardId": 468, + "panelId": 2, + "userId": 1, + "userName": "", + "newState": "", + "prevState": "", + "time": 1507265111000, + "text": "test", + "metric": "", + "regionId": 1123, + "type": "event", + "tags": [ + "tag1", + "tag2" + ], + "data": {} + } +] +``` + +## Create Annotation + +Creates an annotation in the Grafana database. The `dashboardId` and `panelId` fields are optional. If they are not specified then a global annotation is created and can be queried in any dashboard that adds the Grafana annotations data source. + +`POST /api/annotations` + +**Example Request**: + +```json +POST /api/annotations HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{ + "dashboardId":468, + "panelId":1, + "time":1507037197339, + "isRegion":true, + "timeEnd":1507180805056, + "tags":["tag1","tag2"], + "text":"Annotation Description" +} +``` + +**Example Response**: + +```json +HTTP/1.1 200 +Content-Type: application/json + +{"message":"Annotation added"} +``` + +## Update Annotation + +`PUT /api/annotations/:id` + +**Example Request**: + +```json +PUT /api/annotations/1141 HTTP/1.1 +Accept: application/json +Content-Type: application/json + +{ + "time":1507037197339, + "isRegion":true, + "timeEnd":1507180805056, + "text":"Annotation Description", + "tags":["tag3","tag4","tag5"] +} +``` + +## Delete Annotation By Id + +`DELETE /api/annotation/:id` + +Deletes the annotation that matches the specified id. + +**Example Request**: + +```http +DELETE /api/annotation/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{"message":"Annotation deleted"} +``` + +## Delete Annotation By RegionId + +`DELETE /api/annotation/region/:id` + +Deletes the annotation that matches the specified region id. A region is an annotation that covers a timerange and has a start and end time. In the Grafana database, this is a stored as two annotations connected by a region id. + +**Example Request**: + +```http +DELETE /api/annotation/region/1 HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example Response**: + +```http +HTTP/1.1 200 +Content-Type: application/json + +{"message":"Annotation region deleted"} +``` diff --git a/package.json b/package.json index 83025a3e8fc..1d8cc8bf64c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index e07c77f1c1d..300fa7f2cdc 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -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 = AND id != ... + 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") +} diff --git a/pkg/api/api.go b/pkg/api/api.go index a979363d528..802d6e2d028 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -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 diff --git a/pkg/api/avatar/avatar.go b/pkg/api/avatar/avatar.go index 7abb5da1cec..80280fd3cc9 100644 --- a/pkg/api/avatar/avatar.go +++ b/pkg/api/avatar/avatar.go @@ -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") diff --git a/pkg/api/dtos/annotations.go b/pkg/api/dtos/annotations.go index 958fdff89ca..ee5f6915b66 100644 --- a/pkg/api/dtos/annotations.go +++ b/pkg/api/dtos/annotations.go @@ -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"` + 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"` } -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"` - - 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"` } diff --git a/pkg/models/tags.go b/pkg/models/tags.go new file mode 100644 index 00000000000..1b90b7d55ba --- /dev/null +++ b/pkg/models/tags.go @@ -0,0 +1,60 @@ +package models + +import ( + "strings" +) + +type Tag struct { + Id int64 + Key string + Value string +} + +func ParseTagPairs(tagPairs []string) (tags []*Tag) { + if tagPairs == nil { + return []*Tag{} + } + + for _, tagPair := range tagPairs { + var tag Tag + + if strings.Contains(tagPair, ":") { + keyValue := strings.Split(tagPair, ":") + tag.Key = strings.Trim(keyValue[0], " ") + tag.Value = strings.Trim(keyValue[1], " ") + } else { + tag.Key = strings.Trim(tagPair, " ") + } + + if tag.Key == "" || ContainsTag(tags, &tag) { + continue + } + + tags = append(tags, &tag) + } + + return tags +} + +func ContainsTag(existingTags []*Tag, tag *Tag) bool { + for _, t := range existingTags { + if t.Key == tag.Key && t.Value == tag.Value { + return true + } + } + return false +} + +func JoinTagPairs(tags []*Tag) []string { + tagPairs := []string{} + + for _, tag := range tags { + if tag.Value != "" { + tagPairs = append(tagPairs, tag.Key+":"+tag.Value) + } else { + tagPairs = append(tagPairs, tag.Key) + } + } + + return tagPairs +} diff --git a/pkg/models/tags_test.go b/pkg/models/tags_test.go new file mode 100644 index 00000000000..7d95187d668 --- /dev/null +++ b/pkg/models/tags_test.go @@ -0,0 +1,95 @@ +package models + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestParsingTags(t *testing.T) { + Convey("Testing parsing a tag pairs into tags", t, func() { + Convey("Can parse one empty tag", func() { + tags := ParseTagPairs([]string{""}) + So(len(tags), ShouldEqual, 0) + }) + + Convey("Can parse valid tags", func() { + tags := ParseTagPairs([]string{"outage", "type:outage", "error"}) + So(len(tags), ShouldEqual, 3) + So(tags[0].Key, ShouldEqual, "outage") + So(tags[0].Value, ShouldEqual, "") + So(tags[1].Key, ShouldEqual, "type") + So(tags[1].Value, ShouldEqual, "outage") + So(tags[2].Key, ShouldEqual, "error") + So(tags[2].Value, ShouldEqual, "") + }) + + Convey("Can parse tags with spaces", func() { + tags := ParseTagPairs([]string{" outage ", " type : outage ", "error "}) + So(len(tags), ShouldEqual, 3) + So(tags[0].Key, ShouldEqual, "outage") + So(tags[0].Value, ShouldEqual, "") + So(tags[1].Key, ShouldEqual, "type") + So(tags[1].Value, ShouldEqual, "outage") + So(tags[2].Key, ShouldEqual, "error") + So(tags[2].Value, ShouldEqual, "") + }) + + Convey("Can parse empty tags", func() { + tags := ParseTagPairs([]string{" outage ", "", "", ":", "type : outage ", "error ", "", ""}) + So(len(tags), ShouldEqual, 3) + So(tags[0].Key, ShouldEqual, "outage") + So(tags[0].Value, ShouldEqual, "") + So(tags[1].Key, ShouldEqual, "type") + So(tags[1].Value, ShouldEqual, "outage") + So(tags[2].Key, ShouldEqual, "error") + So(tags[2].Value, ShouldEqual, "") + }) + + Convey("Can parse tags with extra colons", func() { + tags := ParseTagPairs([]string{" outage", "type : outage:outage2 :outage3 ", "error :"}) + So(len(tags), ShouldEqual, 3) + So(tags[0].Key, ShouldEqual, "outage") + So(tags[0].Value, ShouldEqual, "") + So(tags[1].Key, ShouldEqual, "type") + So(tags[1].Value, ShouldEqual, "outage") + So(tags[2].Key, ShouldEqual, "error") + So(tags[2].Value, ShouldEqual, "") + }) + + Convey("Can parse tags that contains key and values with spaces", func() { + tags := ParseTagPairs([]string{" outage 1", "type 1: outage 1 ", "has error "}) + So(len(tags), ShouldEqual, 3) + So(tags[0].Key, ShouldEqual, "outage 1") + So(tags[0].Value, ShouldEqual, "") + So(tags[1].Key, ShouldEqual, "type 1") + So(tags[1].Value, ShouldEqual, "outage 1") + So(tags[2].Key, ShouldEqual, "has error") + So(tags[2].Value, ShouldEqual, "") + }) + + Convey("Can filter out duplicate tags", func() { + tags := ParseTagPairs([]string{"test", "test", "key:val1", "key:val2"}) + So(len(tags), ShouldEqual, 3) + So(tags[0].Key, ShouldEqual, "test") + So(tags[0].Value, ShouldEqual, "") + So(tags[1].Key, ShouldEqual, "key") + So(tags[1].Value, ShouldEqual, "val1") + So(tags[2].Key, ShouldEqual, "key") + So(tags[2].Value, ShouldEqual, "val2") + }) + + Convey("Can join tag pairs", func() { + tagPairs := []*Tag{ + {Key: "key1", Value: "val1"}, + {Key: "key2", Value: ""}, + {Key: "key3"}, + } + tags := JoinTagPairs(tagPairs) + So(len(tags), ShouldEqual, 3) + So(tags[0], ShouldEqual, "key1:val1") + So(tags[1], ShouldEqual, "key2") + So(tags[2], ShouldEqual, "key3") + }) + }) +} diff --git a/pkg/services/alerting/result_handler.go b/pkg/services/alerting/result_handler.go index d34dbf5a632..448b4ace5bb 100644 --- a/pkg/services/alerting/result_handler.go +++ b/pkg/services/alerting/result_handler.go @@ -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(), diff --git a/pkg/services/annotations/annotations.go b/pkg/services/annotations/annotations.go index be9d3f2d4d0..2fdc824f172 100644 --- a/pkg/services/annotations/annotations.go +++ b/pkg/services/annotations/annotations.go @@ -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"` - Data *simplejson.Json `json:"data"` + // needed until we remove it from db + Type string + Title string +} + +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"` } diff --git a/pkg/services/sqlstore/annotation.go b/pkg/services/sqlstore/annotation.go index ffad5bf2cad..a2c5d80ac3a 100644 --- a/pkg/services/sqlstore/annotation.go +++ b/pkg/services/sqlstore/annotation.go @@ -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 _, err := sess.Table("annotation").Id(item.Id).Update(item); err != nil { + 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(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(` + 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 org_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{} - 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) + 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(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{} + ) - sql := "DELETE FROM annotation WHERE dashboard_id = ? AND panel_id = ?" + 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} + } - _, err := sess.Exec(sql, params.DashboardId, params.PanelId) - if err != nil { + if _, err := sess.Exec(annoTagSql, queryParams...); err != nil { + return err + } + + if _, err := sess.Exec(sql, queryParams...); err != nil { return err } diff --git a/pkg/services/sqlstore/annotation_test.go b/pkg/services/sqlstore/annotation_test.go new file mode 100644 index 00000000000..3f7415a952b --- /dev/null +++ b/pkg/services/sqlstore/annotation_test.go @@ -0,0 +1,208 @@ +package sqlstore + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/annotations" +) + +func TestSavingTags(t *testing.T) { + Convey("Testing annotation saving/loading", t, func() { + InitTestDB(t) + + repo := SqlAnnotationRepo{} + + Convey("Can save tags", func() { + tagPairs := []*models.Tag{ + {Key: "outage"}, + {Key: "type", Value: "outage"}, + {Key: "server", Value: "server-1"}, + {Key: "error"}, + } + tags, err := repo.ensureTagsExist(newSession(), tagPairs) + + So(err, ShouldBeNil) + So(len(tags), ShouldEqual, 4) + }) + }) +} + +func TestAnnotations(t *testing.T) { + Convey("Testing annotation saving/loading", t, func() { + InitTestDB(t) + + repo := SqlAnnotationRepo{} + + Convey("Can save annotation", func() { + err := repo.Save(&annotations.Item{ + OrgId: 1, + UserId: 1, + DashboardId: 1, + Text: "hello", + Epoch: 10, + Tags: []string{"outage", "error", "type:outage", "server:server-1"}, + }) + + So(err, ShouldBeNil) + + Convey("Can query for annotation", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 0, + To: 15, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 1) + + Convey("Can read tags", func() { + So(items[0].Tags, ShouldResemble, []string{"outage", "error", "type:outage", "server:server-1"}) + }) + }) + + Convey("Should not find any when item is outside time range", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 12, + To: 15, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 0) + }) + + Convey("Should not find one when tag filter does not match", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 1, + To: 15, + Tags: []string{"asd"}, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 0) + }) + + Convey("Should find one when all tag filters does match", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 1, + To: 15, + Tags: []string{"outage", "error"}, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 1) + }) + + Convey("Should find one when all key value tag filters does match", func() { + items, err := repo.Find(&annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 1, + To: 15, + Tags: []string{"type:outage", "server:server-1"}, + }) + + So(err, ShouldBeNil) + So(items, ShouldHaveLength, 1) + }) + + Convey("Can update annotation and remove all tags", func() { + query := &annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 0, + To: 15, + } + items, err := repo.Find(query) + + So(err, ShouldBeNil) + + annotationId := items[0].Id + + err = repo.Update(&annotations.Item{ + Id: annotationId, + OrgId: 1, + Text: "something new", + Tags: []string{}, + }) + + So(err, ShouldBeNil) + + items, err = repo.Find(query) + + So(err, ShouldBeNil) + + Convey("Can read tags", func() { + So(items[0].Id, ShouldEqual, annotationId) + So(len(items[0].Tags), ShouldEqual, 0) + So(items[0].Text, ShouldEqual, "something new") + }) + }) + + Convey("Can update annotation with new tags", func() { + query := &annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 0, + To: 15, + } + items, err := repo.Find(query) + + So(err, ShouldBeNil) + + annotationId := items[0].Id + + err = repo.Update(&annotations.Item{ + Id: annotationId, + OrgId: 1, + Text: "something new", + Tags: []string{"newtag1", "newtag2"}, + }) + + So(err, ShouldBeNil) + + items, err = repo.Find(query) + + So(err, ShouldBeNil) + + Convey("Can read tags", func() { + So(items[0].Id, ShouldEqual, annotationId) + So(items[0].Tags, ShouldResemble, []string{"newtag1", "newtag2"}) + So(items[0].Text, ShouldEqual, "something new") + }) + }) + + Convey("Can delete annotation", func() { + query := &annotations.ItemQuery{ + OrgId: 1, + DashboardId: 1, + From: 0, + To: 15, + } + items, err := repo.Find(query) + So(err, ShouldBeNil) + + annotationId := items[0].Id + + err = repo.Delete(&annotations.DeleteParams{Id: annotationId}) + + items, err = repo.Find(query) + So(err, ShouldBeNil) + + Convey("Should be deleted", func() { + So(len(items), ShouldEqual, 0) + }) + }) + + }) + }) +} diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 27812eef32e..d91b4a08aa6 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -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 { diff --git a/pkg/services/sqlstore/migrations/annotation_mig.go b/pkg/services/sqlstore/migrations/annotation_mig.go index a9343266863..8d2bf94bc42 100644 --- a/pkg/services/sqlstore/migrations/annotation_mig.go +++ b/pkg/services/sqlstore/migrations/annotation_mig.go @@ -57,4 +57,37 @@ func addAnnotationMig(mg *Migrator) { mg.AddMigration("Add column region_id to annotation table", NewAddColumnMigration(table, &Column{ 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)) } diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 38072fe88e4..4984ff18592 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -26,6 +26,7 @@ func AddMigrations(mg *Migrator) { addAnnotationMig(mg) addTestDataMigrations(mg) addDashboardVersionMigration(mg) + addTagMigration(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/migrations/tag_mig.go b/pkg/services/sqlstore/migrations/tag_mig.go new file mode 100644 index 00000000000..0303ddd6409 --- /dev/null +++ b/pkg/services/sqlstore/migrations/tag_mig.go @@ -0,0 +1,24 @@ +package migrations + +import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func addTagMigration(mg *Migrator) { + + tagTable := Table{ + Name: "tag", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "key", Type: DB_NVarchar, Length: 100, Nullable: false}, + {Name: "value", Type: DB_NVarchar, Length: 100, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"key", "value"}, Type: UniqueIndex}, + }, + } + + // create table + mg.AddMigration("create tag table", NewAddTableMigration(tagTable)) + + // create indices + mg.AddMigration("add index tag.key_value", NewAddIndexMigration(tagTable, tagTable.Indices[0])) +} diff --git a/public/app/core/components/dashboard_selector.ts b/public/app/core/components/dashboard_selector.ts index f68e70a17c0..7ec9f681520 100644 --- a/public/app/core/components/dashboard_selector.ts +++ b/public/app/core/components/dashboard_selector.ts @@ -4,9 +4,6 @@ import coreModule from 'app/core/core_module'; var template = ` - - Not finding dashboard you want? Star it first, then it should appear in this select box. - `; export class DashboardSelectorCtrl { diff --git a/public/app/core/components/grafana_app.ts b/public/app/core/components/grafana_app.ts index 0eee3ae43fd..8852da4a436 100644 --- a/public/app/core/components/grafana_app.ts +++ b/public/app/core/components/grafana_app.ts @@ -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 diff --git a/public/app/core/components/info_popover.ts b/public/app/core/components/info_popover.ts index a6ea853b7bb..954e84a3baa 100644 --- a/public/app/core/components/info_popover.ts +++ b/public/app/core/components/info_popover.ts @@ -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); }); diff --git a/public/app/core/directives/tags.js b/public/app/core/directives/tags.js index 90a355dea07..a322673a342 100644 --- a/public/app/core/directives/tags.js +++ b/public/app/core/directives/tags.js @@ -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]) ? diff --git a/public/app/core/nav_model_srv.ts b/public/app/core/nav_model_srv.ts index 4771f8c191c..dd61db5d346 100644 --- a/public/app/core/nav_model_srv.ts +++ b/public/app/core/nav_model_srv.ts @@ -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') }); diff --git a/public/app/features/alerting/alert_def.ts b/public/app/features/alerting/alert_def.ts index 51cbbd9691f..c86f0dee775 100644 --- a/public/app/features/alerting/alert_def.ts +++ b/public/app/features/alerting/alert_def.ts @@ -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 diff --git a/public/app/features/annotations/annotation_tooltip.ts b/public/app/features/annotations/annotation_tooltip.ts index 39c2ff84acb..c8c95b38392 100644 --- a/public/app/features/annotations/annotation_tooltip.ts +++ b/public/app/features/annotations/annotation_tooltip.ts @@ -1,12 +1,10 @@ -/// - 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 = '
'; var titleStateClass = ''; - if (event.source.name === 'panel-alert') { + if (event.alertId) { var stateModel = alertDef.getStateDisplayModel(event.newState); titleStateClass = stateModel.stateClass; title = ` ${stateModel.text}`; text = alertDef.getAlertAnnotationInfo(event); + if (event.text) { + text = text + '
' + event.text; + } + } else if (title) { + text = title + '
' + text; + title = ''; } - tooltip += ` -
- ${sanitizeString(title)} - ${dashboard.formatDate(event.min)} -
+ var header = `
`; + if (event.login) { + header += `
`; + } + header += ` + ${sanitizeString(title)} + ${dashboard.formatDate(event.min)} `; - tooltip += '
'; + // Show edit icon only for users with at least Editor role + if (event.id && contextSrv.isEditor) { + header += ` + + + + `; + } + + header += `
`; + tooltip += header; + tooltip += '
'; if (text) { - tooltip += sanitizeString(text).replace(/\n/g, '
') + '
'; + tooltip += '
' + sanitizeString(text).replace(/\n/g, '
') + '
'; } 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 += "
"; + tooltip += '
'; var $tooltip = $(tooltip); $tooltip.appendTo(element); diff --git a/public/app/features/annotations/annotations_srv.ts b/public/app/features/annotations/annotations_srv.ts index e6a4c8660ae..2863ecdd843 100644 --- a/public/app/features/annotations/annotations_srv.ts +++ b/public/app/features/annotations/annotations_srv.ts @@ -1,5 +1,3 @@ -/// - import './editor_ctrl'; import angular from 'angular'; @@ -11,11 +9,7 @@ export class AnnotationsSrv { alertStatesPromise: any; /** @ngInject */ - constructor(private $rootScope, - private $q, - private datasourceSrv, - private backendSrv, - private timeSrv) { + constructor(private $rootScope, private $q, private datasourceSrv, private backendSrv, private timeSrv) { $rootScope.onAppEvent('refresh', this.clearCache.bind(this), $rootScope); $rootScope.onAppEvent('dashboard-initialized', this.clearCache.bind(this), $rootScope); } @@ -26,64 +20,40 @@ export class AnnotationsSrv { } getAnnotations(options) { - return this.$q.all([ - this.getGlobalAnnotations(options), - this.getPanelAnnotations(options), - this.getAlertStates(options) - ]).then(results => { + return this.$q + .all([this.getGlobalAnnotations(options), this.getAlertStates(options)]) + .then(results => { + // combine the annotations and flatten results + var annotations = _.flattenDeep(results[0]); - // combine the annotations and flatten results - var annotations = _.flattenDeep([results[0], results[1]]); - - // filter out annotations that do not belong to requesting panel - annotations = _.filter(annotations, item => { - // shownIn === 1 requires annotation matching panel id - if (item.source.showIn === 1) { - if (item.panelId && options.panel.id === item.panelId) { - return true; + // filter out annotations that do not belong to requesting panel + annotations = _.filter(annotations, item => { + // if event has panel id and query is of type dashboard then panel and requesting panel id must match + if (item.panelId && item.source.type === 'dashboard') { + return item.panelId === options.panel.id; } - return false; + return true; + }); + + annotations = dedupAnnotations(annotations); + annotations = makeRegions(annotations, options); + + // look for alert state for this panel + var alertState = _.find(results[1], {panelId: options.panel.id}); + + return { + annotations: annotations, + alertState: alertState, + }; + }) + .catch(err => { + if (!err.message && err.data && err.data.message) { + err.message = err.data.message; } - return true; + console.log('AnnotationSrv.query error', err); + this.$rootScope.appEvent('alert-error', ['Annotation Query Failed', err.message || err]); + return []; }); - - // look for alert state for this panel - var alertState = _.find(results[2], {panelId: options.panel.id}); - - return { - annotations: annotations, - alertState: alertState, - }; - - }).catch(err => { - if (!err.message && err.data && err.data.message) { - err.message = err.data.message; - } - this.$rootScope.appEvent('alert-error', ['Annotation Query Failed', (err.message || err)]); - - return []; - }); - } - - getPanelAnnotations(options) { - var panel = options.panel; - var dashboard = options.dashboard; - - if (dashboard.id && panel && panel.alert) { - return this.backendSrv.get('/api/annotations', { - from: options.range.from.valueOf(), - to: options.range.to.valueOf(), - limit: 100, - panelId: panel.id, - dashboardId: dashboard.id, - }).then(results => { - // this built in annotation source name `panel-alert` is used in annotation tooltip - // to know that this annotation is from panel alert - return this.translateQueryResult({iconColor: '#AA0000', name: 'panel-alert'}, results); - }); - } - - return this.$q.when([]); } getAlertStates(options) { @@ -104,43 +74,55 @@ export class AnnotationsSrv { return this.alertStatesPromise; } - this.alertStatesPromise = this.backendSrv.get('/api/alerts/states-for-dashboard', {dashboardId: options.dashboard.id}); + this.alertStatesPromise = this.backendSrv.get('/api/alerts/states-for-dashboard', { + dashboardId: options.dashboard.id, + }); return this.alertStatesPromise; } getGlobalAnnotations(options) { var dashboard = options.dashboard; - if (dashboard.annotations.list.length === 0) { - return this.$q.when([]); - } - if (this.globalAnnotationsPromise) { return this.globalAnnotationsPromise; } - var annotations = _.filter(dashboard.annotations.list, {enable: true}); var range = this.timeSrv.timeRange(); + var promises = []; + + for (let annotation of dashboard.annotations.list) { + if (!annotation.enable) { + continue; + } - this.globalAnnotationsPromise = this.$q.all(_.map(annotations, annotation => { if (annotation.snapshotData) { return this.translateQueryResult(annotation, annotation.snapshotData); } - return this.datasourceSrv.get(annotation.datasource).then(datasource => { - // issue query against data source - return datasource.annotationQuery({range: range, rangeRaw: range.raw, annotation: annotation}); - }) - .then(results => { - // store response in annotation object if this is a snapshot call - if (dashboard.snapshot) { - annotation.snapshotData = angular.copy(results); - } - // translate result - return this.translateQueryResult(annotation, results); - }); - })); + promises.push( + this.datasourceSrv + .get(annotation.datasource) + .then(datasource => { + // issue query against data source + return datasource.annotationQuery({ + range: range, + rangeRaw: range.raw, + annotation: annotation, + dashboard: dashboard, + }); + }) + .then(results => { + // store response in annotation object if this is a snapshot call + if (dashboard.snapshot) { + annotation.snapshotData = angular.copy(results); + } + // translate result + return this.translateQueryResult(annotation, results); + }), + ); + } + this.globalAnnotationsPromise = this.$q.all(promises); return this.globalAnnotationsPromise; } @@ -149,6 +131,21 @@ export class AnnotationsSrv { return this.backendSrv.post('/api/annotations', annotation); } + updateAnnotationEvent(annotation) { + this.globalAnnotationsPromise = null; + return this.backendSrv.put(`/api/annotations/${annotation.id}`, annotation); + } + + deleteAnnotationEvent(annotation) { + this.globalAnnotationsPromise = null; + let deleteUrl = `/api/annotations/${annotation.id}`; + if (annotation.isRegion) { + deleteUrl = `/api/annotations/region/${annotation.regionId}`; + } + + return this.backendSrv.delete(deleteUrl); + } + translateQueryResult(annotation, results) { // if annotation has snapshotData // make clone and remove it @@ -159,13 +156,88 @@ export class AnnotationsSrv { for (var item of results) { item.source = annotation; - item.min = item.time; - item.max = item.time; - item.scope = 1; - item.eventType = annotation.name; } return results; } } +/** + * This function converts annotation events into set + * of single events and regions (event consist of two) + * @param annotations + * @param options + */ +function makeRegions(annotations, options) { + let [regionEvents, singleEvents] = _.partition(annotations, 'regionId'); + let regions = getRegions(regionEvents, options.range); + annotations = _.concat(regions, singleEvents); + return annotations; +} + +function getRegions(events, range) { + let region_events = _.filter(events, event => { + return event.regionId; + }); + let regions = _.groupBy(region_events, 'regionId'); + regions = _.compact( + _.map(regions, region_events => { + let region_obj = _.head(region_events); + if (region_events && region_events.length > 1) { + region_obj.timeEnd = region_events[1].time; + region_obj.isRegion = true; + return region_obj; + } else { + if (region_events && region_events.length) { + // Don't change proper region object + if (!region_obj.time || !region_obj.timeEnd) { + // This is cut region + if (isStartOfRegion(region_obj)) { + region_obj.timeEnd = range.to.valueOf() - 1; + } else { + // Start time = null + region_obj.timeEnd = region_obj.time; + region_obj.time = range.from.valueOf() + 1; + } + region_obj.isRegion = true; + } + + return region_obj; + } + } + }), + ); + + return regions; +} + +function isStartOfRegion(event): boolean { + return event.id && event.id === event.regionId; +} + +function dedupAnnotations(annotations) { + let dedup = []; + + // Split events by annotationId property existance + let events = _.partition(annotations, 'id'); + + let eventsById = _.groupBy(events[0], 'id'); + dedup = _.map(eventsById, eventGroup => { + if (eventGroup.length > 1 && !_.every(eventGroup, isPanelAlert)) { + // Get first non-panel alert + return _.find(eventGroup, event => { + return event.eventType !== 'panel-alert'; + }); + } else { + return _.head(eventGroup); + } + }); + + dedup = _.concat(dedup, events[1]); + return dedup; +} + +function isPanelAlert(event) { + return event.eventType === 'panel-alert'; +} + coreModule.service('annotationsSrv', AnnotationsSrv); diff --git a/public/app/features/annotations/editor_ctrl.ts b/public/app/features/annotations/editor_ctrl.ts index 74c4768b5ad..fafb51aea08 100644 --- a/public/app/features/annotations/editor_ctrl.ts +++ b/public/app/features/annotations/editor_ctrl.ts @@ -1,5 +1,3 @@ -/// - 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); diff --git a/public/app/features/annotations/event.ts b/public/app/features/annotations/event.ts index 53afbea5b07..24d0edbe1a2 100644 --- a/public/app/features/annotations/event.ts +++ b/public/app/features/annotations/event.ts @@ -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; } diff --git a/public/app/features/annotations/event_editor.ts b/public/app/features/annotations/event_editor.ts index e5311ef8c76..b8e0a40a7bd 100644 --- a/public/app/features/annotations/event_editor.ts +++ b/public/app/features/annotations/event_editor.ts @@ -1,6 +1,5 @@ -/// - 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; } } diff --git a/public/app/features/annotations/event_manager.ts b/public/app/features/annotations/event_manager.ts index 6b8a58f0b57..caa367e42f1 100644 --- a/public/app/features/annotations/event_manager.ts +++ b/public/app/features/annotations/event_manager.ts @@ -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: '', - 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 ""; + } +} diff --git a/public/app/features/annotations/partials/editor.html b/public/app/features/annotations/partials/editor.html index 1506e1a0dc5..e4d9c2c413f 100644 --- a/public/app/features/annotations/partials/editor.html +++ b/public/app/features/annotations/partials/editor.html @@ -40,10 +40,11 @@ Annotations provide a way to integrate event data into your graphs. They are visualized as vertical lines and icons on all graph panels. When you hover over an annotation icon you can get title, tags, and text information for the event. In the Queries tab you can add queries that return annotation events. -
-
- Checkout the Annotations documentation for more information.

+

+ 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. +

+ Checkout the Annotations documentation for more information.
@@ -53,13 +54,16 @@ - + - @@ -77,60 +81,63 @@
-
-
Options
+
+
+
General
- Name - + Name +
- Data source -
+ Data source +
-
-
- - - - - - - - -
+
+ +
+
+ + + +
- +
+
-
Query
- - - - +
Query
+ + + + -
-
- - -
+
+
+ +
+
-
diff --git a/public/app/features/annotations/partials/event_editor.html b/public/app/features/annotations/partials/event_editor.html index 6e44b6f768d..529434755f1 100644 --- a/public/app/features/annotations/partials/event_editor.html +++ b/public/app/features/annotations/partials/event_editor.html @@ -1,38 +1,35 @@ -
Add annotation
- -
-
-
- Title - +
+
+
- -
-
- Time - -
-
- -
-
- Start - -
-
- End - -
-
-
- Description - -
-
- - Cancel -
-
- +
+ Add Annotation + Edit Annotation +
+ +
{{ctrl.timeFormated}}
+
+ +
+
+
+ Description + +
+ +
+ Tags + + +
+ +
+ + + Cancel +
+
+ +
diff --git a/public/app/features/annotations/specs/annotations_srv_specs.ts b/public/app/features/annotations/specs/annotations_srv_specs.ts new file mode 100644 index 00000000000..3c0142ed87f --- /dev/null +++ b/public/app/features/annotations/specs/annotations_srv_specs.ts @@ -0,0 +1,40 @@ +import {describe, beforeEach, it, expect, angularMocks} from 'test/lib/common'; +import '../annotations_srv'; +import helpers from 'test/specs/helpers'; + +describe('AnnotationsSrv', function() { + var ctx = new helpers.ServiceTestContext(); + + beforeEach(angularMocks.module('grafana.core')); + beforeEach(angularMocks.module('grafana.services')); + beforeEach(() => { + ctx.createService('annotationsSrv'); + }); + describe('When translating the query result', () => { + const annotationSource = { + datasource: '-- Grafana --', + enable: true, + hide: false, + limit: 200, + name: 'test', + scope: 'global', + tags: [ + 'test' + ], + type: 'event', + }; + + const time = 1507039543000; + const annotations = [{id: 1, panelId: 1, text: 'text', time: time}]; + let translatedAnnotations; + + beforeEach(() => { + translatedAnnotations = ctx.service.translateQueryResult(annotationSource, annotations); + }); + + it('should set defaults', () => { + expect(translatedAnnotations[0].source).to.eql(annotationSource); + }); + }); +}); + diff --git a/public/app/features/dashboard/model.ts b/public/app/features/dashboard/model.ts index 3eb3d25f4fe..11067d3c68b 100644 --- a/public/app/features/dashboard/model.ts +++ b/public/app/features/dashboard/model.ts @@ -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 || {}; diff --git a/public/app/features/dashboard/specs/dashboard_model_specs.ts b/public/app/features/dashboard/specs/dashboard_model_specs.ts index 6ca84ba89f3..ca5482bbfc5 100644 --- a/public/app/features/dashboard/specs/dashboard_model_specs.ts +++ b/public/app/features/dashboard/specs/dashboard_model_specs.ts @@ -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); }); }); diff --git a/public/app/features/dashboard/specs/exporter_specs.ts b/public/app/features/dashboard/specs/exporter_specs.ts index 9364cea8c47..cc2b1ddaf97 100644 --- a/public/app/features/dashboard/specs/exporter_specs.ts +++ b/public/app/features/dashboard/specs/exporter_specs.ts @@ -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() { diff --git a/public/app/features/dashboard/submenu/submenu.html b/public/app/features/dashboard/submenu/submenu.html index ce3c61f1cc3..9e9d22fb495 100644 --- a/public/app/features/dashboard/submenu/submenu.html +++ b/public/app/features/dashboard/submenu/submenu.html @@ -12,7 +12,7 @@
diff --git a/public/app/features/org/prefs_control.ts b/public/app/features/org/prefs_control.ts index 07b277680ad..84cb2186f75 100644 --- a/public/app/features/org/prefs_control.ts +++ b/public/app/features/org/prefs_control.ts @@ -59,9 +59,13 @@ var template = `
- Home Dashboard - + + Home Dashboard + + Not finding dashboard you want? Star it first, then it should appear in this select box. + + +
diff --git a/public/app/headers/common.d.ts b/public/app/headers/common.d.ts index 94ae1b1abef..8b9dfa256ef 100644 --- a/public/app/headers/common.d.ts +++ b/public/app/headers/common.d.ts @@ -4,9 +4,3 @@ declare module 'eventemitter3' { var config: any; export default config; } - -declare module 'd3' { - var d3: any; - export default d3; -} - diff --git a/public/app/plugins/datasource/elasticsearch/datasource.ts b/public/app/plugins/datasource/elasticsearch/datasource.ts index 543e73d64e5..b165ad4c689 100644 --- a/public/app/plugins/datasource/elasticsearch/datasource.ts +++ b/public/app/plugins/datasource/elasticsearch/datasource.ts @@ -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) { diff --git a/public/app/plugins/datasource/elasticsearch/index_pattern.ts b/public/app/plugins/datasource/elasticsearch/index_pattern.ts index 075c05dbf3f..22f121d02de 100644 --- a/public/app/plugins/datasource/elasticsearch/index_pattern.ts +++ b/public/app/plugins/datasource/elasticsearch/index_pattern.ts @@ -18,7 +18,7 @@ export class IndexPattern { } else { return this.pattern; } - }; + } getIndexList(from, to) { if (!this.interval) { diff --git a/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html b/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html index ad68312b727..d4e1e7d1b1c 100644 --- a/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html +++ b/public/app/plugins/datasource/elasticsearch/partials/annotations.editor.html @@ -15,24 +15,20 @@
Field mappings
- Time - + Time +
-
- Title + Text + +
+
+ Tags + +
+
+ Title (depricated)
-
-
- Tags - -
- -
- Text - -
-
diff --git a/public/app/plugins/datasource/elasticsearch/query_builder.ts b/public/app/plugins/datasource/elasticsearch/query_builder.ts index ba4e1e436d4..754c541b2a8 100644 --- a/public/app/plugins/datasource/elasticsearch/query_builder.ts +++ b/public/app/plugins/datasource/elasticsearch/query_builder.ts @@ -167,7 +167,7 @@ export class ElasticQueryBuilder { break; } } - }; + } build(target, adhocFilters?, queryString?) { // make sure query has defaults; diff --git a/public/app/plugins/datasource/elasticsearch/query_def.ts b/public/app/plugins/datasource/elasticsearch/query_def.ts index dac48acae48..3cb63340b84 100644 --- a/public/app/plugins/datasource/elasticsearch/query_def.ts +++ b/public/app/plugins/datasource/elasticsearch/query_def.ts @@ -188,4 +188,4 @@ export function describeOrderBy(orderBy, target) { } else { return "metric not found"; } -}; +} diff --git a/public/app/plugins/datasource/grafana/datasource.ts b/public/app/plugins/datasource/grafana/datasource.ts index 2960af6b062..5ca3c433476 100644 --- a/public/app/plugins/datasource/grafana/datasource.ts +++ b/public/app/plugins/datasource/grafana/datasource.ts @@ -1,5 +1,3 @@ -/// - import _ from 'lodash'; class GrafanaDatasource { @@ -8,42 +6,62 @@ class GrafanaDatasource { constructor(private backendSrv, private $q) {} query(options) { - return this.backendSrv.get('/api/tsdb/testdata/random-walk', { - from: options.range.from.valueOf(), - to: options.range.to.valueOf(), - intervalMs: options.intervalMs, - maxDataPoints: options.maxDataPoints, - }).then(res => { - var data = []; + return this.backendSrv + .get('/api/tsdb/testdata/random-walk', { + from: options.range.from.valueOf(), + to: options.range.to.valueOf(), + intervalMs: options.intervalMs, + maxDataPoints: options.maxDataPoints, + }) + .then(res => { + var data = []; - if (res.results) { - _.forEach(res.results, queryRes => { - for (let series of queryRes.series) { - data.push({ - target: series.name, - datapoints: series.points - }); - } - }); - } + if (res.results) { + _.forEach(res.results, queryRes => { + for (let series of queryRes.series) { + data.push({ + target: series.name, + datapoints: series.points, + }); + } + }); + } - return {data: data}; - }); + return {data: data}; + }); } metricFindQuery(options) { return this.$q.when({data: []}); } + annotationQuery(options) { - return this.backendSrv.get('/api/annotations', { + const params: any = { from: options.range.from.valueOf(), to: options.range.to.valueOf(), - limit: options.limit, - type: options.type, - }); - } + limit: options.annotation.limit, + tags: options.annotation.tags, + }; + if (options.annotation.type === 'dashboard') { + // if no dashboard id yet return + if (!options.dashboard.id) { + return this.$q.when([]); + } + // filter by dashboard id + params.dashboardId = options.dashboard.id; + // remove tags filter if any + delete params.tags; + } else { + // require at least one tag + if (!_.isArray(options.annotation.tags) || options.annotation.tags.length === 0) { + return this.$q.when([]); + } + } + + return this.backendSrv.get('/api/annotations', params); + } } export {GrafanaDatasource}; diff --git a/public/app/plugins/datasource/grafana/module.ts b/public/app/plugins/datasource/grafana/module.ts index d03914bda19..eb0b582bfb1 100644 --- a/public/app/plugins/datasource/grafana/module.ts +++ b/public/app/plugins/datasource/grafana/module.ts @@ -1,5 +1,3 @@ -/// - 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, }; - diff --git a/public/app/plugins/datasource/grafana/partials/annotations.editor.html b/public/app/plugins/datasource/grafana/partials/annotations.editor.html index 24a06a2abd6..9803f082a23 100644 --- a/public/app/plugins/datasource/grafana/partials/annotations.editor.html +++ b/public/app/plugins/datasource/grafana/partials/annotations.editor.html @@ -2,14 +2,29 @@
- Type -
-
+ +
+ Tags + + +
+
- Max limit + Max limit
@@ -17,3 +32,5 @@
+ + diff --git a/public/app/plugins/datasource/graphite/datasource.ts b/public/app/plugins/datasource/graphite/datasource.ts index 2c846ee6be8..5114922f1f7 100644 --- a/public/app/plugins/datasource/graphite/datasource.ts +++ b/public/app/plugins/datasource/graphite/datasource.ts @@ -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) + diff --git a/public/app/plugins/datasource/graphite/specs/datasource_specs.ts b/public/app/plugins/datasource/graphite/specs/datasource_specs.ts index f25bbbd8910..182ba475331 100644 --- a/public/app/plugins/datasource/graphite/specs/datasource_specs.ts +++ b/public/app/plugins/datasource/graphite/specs/datasource_specs.ts @@ -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); diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index a29e520c159..c0aa246c093 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -1,5 +1,3 @@ -/// - import _ from 'lodash'; import * as dateMath from 'app/core/utils/datemath'; diff --git a/public/app/plugins/datasource/influxdb/partials/annotations.editor.html b/public/app/plugins/datasource/influxdb/partials/annotations.editor.html index da8f4edf881..475eaa2d4e0 100644 --- a/public/app/plugins/datasource/influxdb/partials/annotations.editor.html +++ b/public/app/plugins/datasource/influxdb/partials/annotations.editor.html @@ -9,18 +9,16 @@
- Title - + Text +
-
Tags
- -
- Text - +
+ Title (depricated) +
diff --git a/public/app/plugins/datasource/mysql/module.ts b/public/app/plugins/datasource/mysql/module.ts index c32c832d6a0..2d953d941b5 100644 --- a/public/app/plugins/datasource/mysql/module.ts +++ b/public/app/plugins/datasource/mysql/module.ts @@ -9,7 +9,6 @@ class MysqlConfigCtrl { const defaultQuery = `SELECT UNIX_TIMESTAMP() as time_sec, - as title, as text, as tags FROM
-   + +   {{annotation.name}} +   + {{annotation.name}} (Built-in) + @@ -67,7 +71,7 @@ - +
diff --git a/public/app/plugins/datasource/mysql/response_parser.ts b/public/app/plugins/datasource/mysql/response_parser.ts index 5501e4fc17a..22ea20a2851 100644 --- a/public/app/plugins/datasource/mysql/response_parser.ts +++ b/public/app/plugins/datasource/mysql/response_parser.ts @@ -106,7 +106,6 @@ export default class ResponseParser { const table = data.data.results[options.annotation.name].tables[0]; 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*/) : [] }); diff --git a/public/app/plugins/datasource/mysql/specs/datasource_specs.ts b/public/app/plugins/datasource/mysql/specs/datasource_specs.ts index 08d2f8922a5..989d3d59395 100644 --- a/public/app/plugins/datasource/mysql/specs/datasource_specs.ts +++ b/public/app/plugins/datasource/mysql/specs/datasource_specs.ts @@ -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'); diff --git a/public/app/plugins/datasource/opentsdb/datasource.js b/public/app/plugins/datasource/opentsdb/datasource.js index 5228ce26b9c..4d51b117ed4 100644 --- a/public/app/plugins/datasource/opentsdb/datasource.js +++ b/public/app/plugins/datasource/opentsdb/datasource.js @@ -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 }; diff --git a/public/app/plugins/panel/alertlist/module.html b/public/app/plugins/panel/alertlist/module.html index 39dbb5bbe26..a88c4ebadc7 100644 --- a/public/app/plugins/panel/alertlist/module.html +++ b/public/app/plugins/panel/alertlist/module.html @@ -33,7 +33,7 @@
-

{{al.title}}

+

{{al.alertName}}

{{al.stateModel.text}} {{al.info}} diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index a203648ae04..723a92ad19b 100755 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -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); } } }); diff --git a/public/app/plugins/panel/graph/jquery.flot.events.js b/public/app/plugins/panel/graph/jquery.flot.events.js index 3fc3db0d6d3..1aa79c5056f 100644 --- a/public/app/plugins/panel/graph/jquery.flot.events.js +++ b/public/app/plugins/panel/graph/jquery.flot.events.js @@ -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 = ''; + content.innerHTML = ''; 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 = ''; + + 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 = $('
').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 = $('
').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 = $('
').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 = $('
').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" }); - }); diff --git a/public/app/system.conf.js b/public/app/system.conf.js new file mode 100644 index 00000000000..88ab2670e78 --- /dev/null +++ b/public/app/system.conf.js @@ -0,0 +1,83 @@ +System.config({ + defaultJSExtenions: true, + baseURL: 'public', + paths: { + 'virtual-scroll': 'vendor/npm/virtual-scroll/src/index.js', + 'mousetrap': 'vendor/npm/mousetrap/mousetrap.js', + 'remarkable': 'vendor/npm/remarkable/dist/remarkable.js', + 'tether': 'vendor/npm/tether/dist/js/tether.js', + 'eventemitter3': 'vendor/npm/eventemitter3/index.js', + 'tether-drop': 'vendor/npm/tether-drop/dist/js/drop.js', + 'moment': 'vendor/moment.js', + "jquery": "vendor/jquery/dist/jquery.js", + 'lodash-src': 'vendor/lodash/dist/lodash.js', + "lodash": 'app/core/lodash_extended.js', + "angular": "vendor/angular/angular.js", + "bootstrap": "vendor/bootstrap/bootstrap.js", + 'angular-route': 'vendor/angular-route/angular-route.js', + 'angular-sanitize': 'vendor/angular-sanitize/angular-sanitize.js', + "angular-ui": "vendor/angular-ui/ui-bootstrap-tpls.js", + "angular-strap": "vendor/angular-other/angular-strap.js", + "angular-dragdrop": "vendor/angular-native-dragdrop/draganddrop.js", + "angular-bindonce": "vendor/angular-bindonce/bindonce.js", + "spectrum": "vendor/spectrum.js", + "bootstrap-tagsinput": "vendor/tagsinput/bootstrap-tagsinput.js", + "jquery.flot": "vendor/flot/jquery.flot", + "jquery.flot.pie": "vendor/flot/jquery.flot.pie", + "jquery.flot.selection": "vendor/flot/jquery.flot.selection", + "jquery.flot.stack": "vendor/flot/jquery.flot.stack", + "jquery.flot.stackpercent": "vendor/flot/jquery.flot.stackpercent", + "jquery.flot.time": "vendor/flot/jquery.flot.time", + "jquery.flot.crosshair": "vendor/flot/jquery.flot.crosshair", + "jquery.flot.fillbelow": "vendor/flot/jquery.flot.fillbelow", + "jquery.flot.gauge": "vendor/flot/jquery.flot.gauge", + "d3": "vendor/d3/d3.js", + "jquery.flot.dashes": "vendor/flot/jquery.flot.dashes", + "twemoji": "vendor/npm/twemoji/2/twemoji.amd.js", + "ace": "vendor/npm/ace-builds/src-noconflict/ace", + }, + + packages: { + app: { + defaultExtension: 'js', + }, + vendor: { + defaultExtension: 'js', + }, + plugins: { + defaultExtension: 'js', + }, + test: { + defaultExtension: 'js', + }, + }, + + map: { + text: 'vendor/plugin-text/text.js', + css: 'app/core/utils/css_loader.js' + }, + + meta: { + 'vendor/npm/virtual-scroll/src/indx.js': { + format: 'cjs', + exports: 'VirtualScroll', + }, + 'vendor/angular/angular.js': { + format: 'global', + deps: ['jquery'], + exports: 'angular', + }, + 'vendor/npm/eventemitter3/index.js': { + format: 'cjs', + exports: 'EventEmitter' + }, + 'vendor/npm/mousetrap/mousetrap.js': { + format: 'global', + exports: 'Mousetrap' + }, + 'vendor/npm/ace-builds/src-noconflict/ace.js': { + format: 'global', + exports: 'ace' + } + } +}); diff --git a/public/sass/_grafana.scss b/public/sass/_grafana.scss index f2abb2b8b3f..79ee1799b90 100644 --- a/public/sass/_grafana.scss +++ b/public/sass/_grafana.scss @@ -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"; diff --git a/public/sass/_variables.dark.scss b/public/sass/_variables.dark.scss index 4de87a0aaf0..0dc00cf5a9c 100644 --- a/public/sass/_variables.dark.scss +++ b/public/sass/_variables.dark.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; diff --git a/public/sass/_variables.light.scss b/public/sass/_variables.light.scss index 533daec705b..e6901e1c772 100644 --- a/public/sass/_variables.light.scss +++ b/public/sass/_variables.light.scss @@ -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; diff --git a/public/sass/components/_drop.scss b/public/sass/components/_drop.scss index 9e3c884bc68..c1441bd31cb 100644 --- a/public/sass/components/_drop.scss +++ b/public/sass/components/_drop.scss @@ -51,9 +51,16 @@ $easing: cubic-bezier(0, 0, 0.265, 1.00); } } +.drop-element.drop-popover { + .drop-content { + box-shadow: $popover-shadow; + } +} + .drop-element.drop-popover--form { .drop-content { max-width: none; + padding: 0; } } diff --git a/public/sass/components/_icon-picker.scss b/public/sass/components/_icon-picker.scss new file mode 100644 index 00000000000..796f3f95db5 --- /dev/null +++ b/public/sass/components/_icon-picker.scss @@ -0,0 +1,26 @@ +.gf-icon-picker { + width: 400px; + height: 450px; + + .icon-filter { + padding-bottom: 10px; + margin: auto; + width: 50%; + } + + .icon-container { + max-height: 350px; + overflow: auto; + + .gf-event-icon { + margin: 0.4rem; + height: 1.5rem; + } + } +} + +.gf-icon-picker-button { + .gf-event-icon { + height: 1.2rem; + } +} diff --git a/public/sass/components/_panel_graph.scss b/public/sass/components/_panel_graph.scss index 5f9178df2f7..45372f92a65 100644 --- a/public/sass/components/_panel_graph.scss +++ b/public/sass/components/_panel_graph.scss @@ -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 { diff --git a/public/sass/mixins/_drop_element.scss b/public/sass/mixins/_drop_element.scss index 7aa51fff256..f1bb69efd98 100644 --- a/public/sass/mixins/_drop_element.scss +++ b/public/sass/mixins/_drop_element.scss @@ -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; diff --git a/public/test/test-main.js b/public/test/test-main.js new file mode 100644 index 00000000000..1347c421a64 --- /dev/null +++ b/public/test/test-main.js @@ -0,0 +1,130 @@ +(function() { + "use strict"; + + // Tun on full stack traces in errors to help debugging + Error.stackTraceLimit=Infinity; + + window.__karma__.loaded = function() {}; + + System.config({ + baseURL: '/base/', + defaultJSExtensions: true, + paths: { + 'mousetrap': 'vendor/npm/mousetrap/mousetrap.js', + 'eventemitter3': 'vendor/npm/eventemitter3/index.js', + 'remarkable': 'vendor/npm/remarkable/dist/remarkable.js', + 'tether': 'vendor/npm/tether/dist/js/tether.js', + 'tether-drop': 'vendor/npm/tether-drop/dist/js/drop.js', + 'moment': 'vendor/moment.js', + "jquery": "vendor/jquery/dist/jquery.js", + 'lodash-src': 'vendor/lodash/dist/lodash.js', + "lodash": 'app/core/lodash_extended.js', + "angular": 'vendor/angular/angular.js', + 'angular-mocks': 'vendor/angular-mocks/angular-mocks.js', + "bootstrap": "vendor/bootstrap/bootstrap.js", + 'angular-route': 'vendor/angular-route/angular-route.js', + 'angular-sanitize': 'vendor/angular-sanitize/angular-sanitize.js', + "angular-ui": "vendor/angular-ui/ui-bootstrap-tpls.js", + "angular-strap": "vendor/angular-other/angular-strap.js", + "angular-dragdrop": "vendor/angular-native-dragdrop/draganddrop.js", + "angular-bindonce": "vendor/angular-bindonce/bindonce.js", + "spectrum": "vendor/spectrum.js", + "bootstrap-tagsinput": "vendor/tagsinput/bootstrap-tagsinput.js", + "jquery.flot": "vendor/flot/jquery.flot", + "jquery.flot.pie": "vendor/flot/jquery.flot.pie", + "jquery.flot.selection": "vendor/flot/jquery.flot.selection", + "jquery.flot.stack": "vendor/flot/jquery.flot.stack", + "jquery.flot.stackpercent": "vendor/flot/jquery.flot.stackpercent", + "jquery.flot.time": "vendor/flot/jquery.flot.time", + "jquery.flot.crosshair": "vendor/flot/jquery.flot.crosshair", + "jquery.flot.fillbelow": "vendor/flot/jquery.flot.fillbelow", + "jquery.flot.gauge": "vendor/flot/jquery.flot.gauge", + "d3": "vendor/d3/d3.js", + "jquery.flot.dashes": "vendor/flot/jquery.flot.dashes", + "twemoji": "vendor/npm/twemoji/2/twemoji.amd.js", + "ace": "vendor/npm/ace-builds/src-noconflict/ace", + }, + + packages: { + app: { + defaultExtension: 'js', + }, + vendor: { + defaultExtension: 'js', + }, + }, + + map: { + }, + + meta: { + 'vendor/angular/angular.js': { + format: 'global', + deps: ['jquery'], + exports: 'angular', + }, + 'vendor/angular-mocks/angular-mocks.js': { + format: 'global', + deps: ['angular'], + }, + 'vendor/npm/eventemitter3/index.js': { + format: 'cjs', + exports: 'EventEmitter' + }, + 'vendor/npm/mousetrap/mousetrap.js': { + format: 'global', + exports: 'Mousetrap' + }, + 'vendor/npm/ace-builds/src-noconflict/ace.js': { + format: 'global', + exports: 'ace' + }, + } + }); + + function file2moduleName(filePath) { + return filePath.replace(/\\/g, '/') + .replace(/^\/base\//, '') + .replace(/\.\w*$/, ''); + } + + function onlySpecFiles(path) { + return /specs.*/.test(path); + } + + window.grafanaBootData = {settings: {}}; + + var modules = ['angular', 'angular-mocks', 'app/app']; + var promises = modules.map(function(name) { + return System.import(name); + }); + + Promise.all(promises).then(function(deps) { + var angular = deps[0]; + + angular.module('grafana', ['ngRoute']); + angular.module('grafana.services', ['ngRoute', '$strap.directives']); + angular.module('grafana.panels', []); + angular.module('grafana.controllers', []); + angular.module('grafana.directives', []); + angular.module('grafana.filters', []); + angular.module('grafana.routes', ['ngRoute']); + + // load specs + return Promise.all( + Object.keys(window.__karma__.files) // All files served by Karma. + .filter(onlySpecFiles) + .map(file2moduleName) + .map(function(path) { + // console.log(path); + return System.import(path); + })); + }).then(function() { + window.__karma__.start(); + }, function(error) { + window.__karma__.error(error.stack || error); + }).catch(function(error) { + window.__karma__.error(error.stack || error); + }); + +})(); diff --git a/public/vendor/flot/jquery.flot.js b/public/vendor/flot/jquery.flot.js index 2f1b60b0830..41f5ea2fd2d 100644 --- a/public/vendor/flot/jquery.flot.js +++ b/public/vendor/flot/jquery.flot.js @@ -602,6 +602,7 @@ Licensed under the MIT license. tickColor: null, // color for the ticks, e.g. "rgba(0,0,0,0.15)" 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"; diff --git a/public/vendor/tagsinput/bootstrap-tagsinput.js b/public/vendor/tagsinput/bootstrap-tagsinput.js index 702b6416962..4bc29ecbbdb 100644 --- a/public/vendor/tagsinput/bootstrap-tagsinput.js +++ b/public/vendor/tagsinput/bootstrap-tagsinput.js @@ -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 = $('
'); - this.$input = $('').appendTo(this.$container); + this.$input = $('').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(); diff --git a/scripts/webpack/webpack.common.js b/scripts/webpack/webpack.common.js index fd1114e8bbf..736369a4845 100644 --- a/scripts/webpack/webpack.common.js +++ b/scripts/webpack/webpack.common.js @@ -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/, diff --git a/tasks/options/copy.js b/tasks/options/copy.js new file mode 100644 index 00000000000..1ef32af6951 --- /dev/null +++ b/tasks/options/copy.js @@ -0,0 +1,45 @@ +module.exports = function(config) { + return { + // copy source to temp, we will minify in place for the dist build + everything_but_less_to_temp: { + cwd: '<%= srcDir %>', + expand: true, + src: ['**/*', '!**/*.less'], + dest: '<%= tempDir %>' + }, + + public_to_gen: { + cwd: '<%= srcDir %>', + expand: true, + src: ['**/*', '!**/*.less'], + dest: '<%= genDir %>' + }, + + node_modules: { + cwd: './node_modules', + expand: true, + src: [ + 'ace-builds/src-noconflict/**/*', + 'eventemitter3/*.js', + 'systemjs/dist/*.js', + 'es6-promise/**/*', + 'es6-shim/*.js', + 'reflect-metadata/*.js', + 'reflect-metadata/*.ts', + 'reflect-metadata/*.d.ts', + 'rxjs/**/*', + 'tether/**/*', + 'tether-drop/**/*', + 'tether-drop/**/*', + 'remarkable/dist/*', + 'remarkable/dist/*', + 'virtual-scroll/**/*', + 'mousetrap/**/*', + 'twemoji/2/twemoji.amd*', + 'twemoji/2/svg/*.svg', + ], + dest: '<%= srcDir %>/vendor/npm' + } + + }; +}; diff --git a/tsconfig.json b/tsconfig.json index 3864befd0fd..bc9222ac87d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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": [ ] } diff --git a/tslint.json b/tslint.json index 1f74b95bfdd..2e93d80ad25 100644 --- a/tslint.json +++ b/tslint.json @@ -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,