diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000000..3a0f40dcf59 --- /dev/null +++ b/.babelrc @@ -0,0 +1,11 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { "browsers": "last 3 versions" }, + "useBuiltIns": "entry" + } + ] + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 181ba0719cf..294c5eb1834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,32 @@ # 5.5.0 (unreleased) +### New Features +* **Alerting**: Adds support for Google Hangouts Chat notifications [#11221](https://github.com/grafana/grafana/issues/11221), thx [@PatrickSchuster](https://github.com/PatrickSchuster) + ### Minor * **Elasticsearch**: Add support for offset in date histogram aggregation [#12653](https://github.com/grafana/grafana/issues/12653), thx [@mattiarossi](https://github.com/mattiarossi) * **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire) * **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi) +* **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh) + +# 5.4.1 (2018-12-10) + +* **Stackdriver**: Fixes issue with data proxy and Authorization header [#14262](https://github.com/grafana/grafana/issues/14262) +* **Units**: fixedUnit for Flow:l/min and mL/min [#14294](https://github.com/grafana/grafana/issues/14294), thx [@flopp999](https://github.com/flopp999). +* **Logging**: Fix for issue where data proxy logged a secret when debug logging was enabled, now redacted. [#14319](https://github.com/grafana/grafana/issues/14319) +* **InfluxDB**: Add support for alerting on InfluxDB queries that use the cumulative_sum function. [#14314](https://github.com/grafana/grafana/pull/14314), thx [@nitti](https://github.com/nitti) +* **Plugins**: Panel plugins should no receive the panel-initialized event again as usual. +* **Embedded Graphs**: Iframe graph panels should now work as usual. [#14284](https://github.com/grafana/grafana/issues/14284) +* **Postgres**: Improve PostgreSQL Query Editor if using different Schemas, [#14313]( +https://github.com/grafana/grafana/pull/14313) +* **Quotas**: Fixed for updating org & user quotas. [#14347](https://github.com/grafana/grafana/pull/14347), thx [#moznion](https://github.com/moznion) +* **Cloudwatch**: Add the AWS/SES Cloudwatch metrics of BounceRate and ComplaintRate to auto complete list. [#14401](https://github.com/grafana/grafana/pull/14401), thx [@sglajchEG](https://github.com/sglajchEG) +* **Dashboard Search**: Fixed filtering by tag issues. +* **Graph**: Fixed time region issues, [#14425](https://github.com/grafana/grafana/issues/14425), [#14280](https://github.com/grafana/grafana/issues/14280) +* **Graph**: Fixed issue with series color picker popover being placed outside window. + + # 5.4.0 (2018-12-03) diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index e791492495e..5327039341f 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -157,27 +157,29 @@ There are a couple of configuration options which need to be set up in Grafana U Once these two properties are set, you can send the alerts to Kafka for further processing or throttling. -### All supported notifiers +### Google Hangouts Chat -Name | Type |Support images | Support reminders ------|------------ | ------ | ------ | -Slack | `slack` | yes | yes -Pagerduty | `pagerduty` | yes | yes -Email | `email` | yes | yes -Webhook | `webhook` | link | yes -Kafka | `kafka` | no | yes -Hipchat | `hipchat` | yes | yes -VictorOps | `victorops` | yes | yes -Sensu | `sensu` | yes | yes -OpsGenie | `opsgenie` | yes | yes -Threema | `threema` | yes | yes -Pushover | `pushover` | no | yes -Telegram | `telegram` | no | yes -Line | `line` | no | yes -Microsoft Teams | `teams` | yes | yes -Prometheus Alertmanager | `prometheus-alertmanager` | no | no +Notifications can be sent by setting up an incoming webhook in Google Hangouts chat. Configuring such a webhook is described [here](https://developers.google.com/hangouts/chat/how-tos/webhooks). +### All supported notifier +Name | Type |Support images +-----|------------ | ------ +Slack | `slack` | yes +Pagerduty | `pagerduty` | yes +Email | `email` | yes +Webhook | `webhook` | link +Kafka | `kafka` | no +Google Hangouts Chat | `googlechat` | yes +Hipchat | `hipchat` | yes +VictorOps | `victorops` | yes +Sensu | `sensu` | yes +OpsGenie | `opsgenie` | yes +Threema | `threema` | yes +Pushover | `pushover` | no +Telegram | `telegram` | no +Line | `line` | no +Prometheus Alertmanager | `prometheus-alertmanager` | no # Enable images in notifications {#external-image-store} diff --git a/package.json b/package.json index f5e6e22a8a2..d8ea4c036d4 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,12 @@ "url": "http://github.com/grafana/grafana.git" }, "devDependencies": { + "@babel/core": "^7.1.2", + "@rtsao/plugin-proposal-class-properties": "^7.0.1-patch.1", + "@babel/plugin-syntax-dynamic-import": "^7.0.0", + "@babel/preset-env": "^7.1.0", + "@babel/preset-react": "^7.0.0", + "@babel/preset-typescript": "^7.1.0", "@types/d3": "^4.10.1", "@types/enzyme": "^3.1.13", "@types/jest": "^23.3.2", @@ -21,10 +27,10 @@ "angular-mocks": "1.6.6", "autoprefixer": "^6.4.0", "axios": "^0.17.1", - "babel-core": "^6.26.0", - "babel-loader": "^7.1.4", - "babel-plugin-syntax-dynamic-import": "^6.18.0", - "babel-preset-es2015": "^6.24.1", + "babel-core": "^7.0.0-bridge", + "babel-jest": "^23.6.0", + "babel-loader": "^8.0.4", + "babel-plugin-angularjs-annotate": "^0.9.0", "clean-webpack-plugin": "^0.1.19", "css-loader": "^0.28.7", "enzyme": "^3.6.0", @@ -108,18 +114,9 @@ "precommit": "lint-staged && grunt precommit" }, "lint-staged": { - "*.{ts,tsx}": [ - "prettier --write", - "git add" - ], - "*.scss": [ - "prettier --write", - "git add" - ], - "*pkg/**/*.go": [ - "gofmt -w -s", - "git add" - ] + "*.{ts,tsx}": ["prettier --write", "git add"], + "*.scss": ["prettier --write", "git add"], + "*pkg/**/*.go": ["gofmt -w -s", "git add"] }, "prettier": { "trailingComma": "es5", @@ -128,13 +125,12 @@ }, "license": "Apache-2.0", "dependencies": { + "@babel/polyfill": "^7.0.0", "angular": "1.6.6", "angular-bindonce": "0.3.1", "angular-native-dragdrop": "1.2.2", "angular-route": "1.6.6", "angular-sanitize": "1.6.6", - "babel-jest": "^23.6.0", - "babel-polyfill": "^6.26.0", "baron": "^3.0.3", "brace": "^0.10.0", "classnames": "^2.2.5", @@ -156,7 +152,7 @@ "react-custom-scrollbars": "^4.2.1", "react-dom": "^16.5.0", "react-grid-layout": "0.16.6", - "react-highlight-words": "^0.10.0", + "react-highlight-words": "0.11.0", "react-popper": "^0.7.5", "react-redux": "^5.0.7", "react-select": "2.1.0", diff --git a/packaging/docker/build-enterprise.sh b/packaging/docker/build-enterprise.sh index e10e0d691e8..10c24784d5c 100755 --- a/packaging/docker/build-enterprise.sh +++ b/packaging/docker/build-enterprise.sh @@ -18,3 +18,8 @@ docker build \ . docker push "${_docker_repo}:${_grafana_tag}" + +if echo "$_raw_grafana_tag" | grep -q "^v" && echo "$_raw_grafana_tag" | grep -qv "beta"; then + docker tag "${_docker_repo}:${_grafana_tag}" "${_docker_repo}:latest" + docker push "${_docker_repo}:latest" +fi diff --git a/packaging/publish/publish_both.sh b/packaging/publish/publish_both.sh index cbe3918bf38..b1d480567e9 100755 --- a/packaging/publish/publish_both.sh +++ b/packaging/publish/publish_both.sh @@ -1,7 +1,7 @@ #! /usr/bin/env bash -version=5.0.2 +version=5.4.1 -wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana_${version}_amd64.deb +wget https://dl.grafana.com/oss/release/grafana_${version}_amd64.deb package_cloud push grafana/stable/debian/jessie grafana_${version}_amd64.deb package_cloud push grafana/stable/debian/wheezy grafana_${version}_amd64.deb @@ -11,7 +11,7 @@ package_cloud push grafana/testing/debian/jessie grafana_${version}_amd64.deb package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb --verbose package_cloud push grafana/testing/debian/stretch grafana_${version}_amd64.deb --verbose -wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-${version}-1.x86_64.rpm +wget https://dl.grafana.com/release/grafana-${version}-1.x86_64.rpm package_cloud push grafana/testing/el/6 grafana-${version}-1.x86_64.rpm --verbose package_cloud push grafana/testing/el/7 grafana-${version}-1.x86_64.rpm --verbose diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go index dc3d390dda9..efc760d2b51 100644 --- a/pkg/api/admin_users.go +++ b/pkg/api/admin_users.go @@ -76,6 +76,7 @@ func AdminUpdateUserPassword(c *m.ReqContext, form dtos.AdminUpdateUserPasswordF c.JsonOK("User password updated") } +// PUT /api/admin/users/:id/permissions func AdminUpdateUserPermissions(c *m.ReqContext, form dtos.AdminUpdateUserPermissionsForm) { userID := c.ParamsInt64(":id") @@ -85,6 +86,11 @@ func AdminUpdateUserPermissions(c *m.ReqContext, form dtos.AdminUpdateUserPermis } if err := bus.Dispatch(&cmd); err != nil { + if err == m.ErrLastGrafanaAdmin { + c.JsonApiErr(400, m.ErrLastGrafanaAdmin.Error(), nil) + return + } + c.JsonApiErr(500, "Failed to update user permissions", err) return } diff --git a/pkg/api/admin_users_test.go b/pkg/api/admin_users_test.go new file mode 100644 index 00000000000..0b94a64b3fb --- /dev/null +++ b/pkg/api/admin_users_test.go @@ -0,0 +1,50 @@ +package api + +import ( + "testing" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestAdminApiEndpoint(t *testing.T) { + role := m.ROLE_ADMIN + Convey("Given a server admin attempts to remove themself as an admin", t, func() { + + updateCmd := dtos.AdminUpdateUserPermissionsForm{ + IsGrafanaAdmin: false, + } + + bus.AddHandler("test", func(cmd *m.UpdateUserPermissionsCommand) error { + return m.ErrLastGrafanaAdmin + }) + + putAdminScenario("When calling PUT on", "/api/admin/users/1/permissions", "/api/admin/users/:id/permissions", role, updateCmd, func(sc *scenarioContext) { + sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec() + So(sc.resp.Code, ShouldEqual, 400) + }) + }) +} + +func putAdminScenario(desc string, url string, routePattern string, role m.RoleType, cmd dtos.AdminUpdateUserPermissionsForm, fn scenarioFunc) { + Convey(desc+" "+url, func() { + defer bus.ClearBusHandlers() + + sc := setupScenarioContext(url) + sc.defaultHandler = Wrap(func(c *m.ReqContext) { + sc.context = c + sc.context.UserId = TestUserID + sc.context.OrgId = TestOrgID + sc.context.OrgRole = role + + AdminUpdateUserPermissions(c, cmd) + }) + + sc.m.Put(routePattern, sc.defaultHandler) + + fn(sc) + }) +} diff --git a/pkg/api/index.go b/pkg/api/index.go index 253fa9c17af..2980d8a5c6b 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -147,9 +147,6 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er SubTitle: "Explore your data", Icon: "fa fa-rocket", Url: setting.AppSubUrl + "/explore", - Children: []*dtos.NavLink{ - {Text: "New tab", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/explore"}, - }, }) } diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index ffbe470cb20..66e4498c283 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -87,7 +87,7 @@ func NewApiPluginProxy(ctx *m.ReqContext, proxyPath string, route *plugins.AppPl } for key, value := range headers { - log.Trace("setting key %v value %v", key, value[0]) + log.Trace("setting key %v value ", key) req.Header.Set(key, value[0]) } } diff --git a/pkg/components/dynmap/dynmap.go b/pkg/components/dynmap/dynmap.go index 96effb24332..f247aca959f 100644 --- a/pkg/components/dynmap/dynmap.go +++ b/pkg/components/dynmap/dynmap.go @@ -1,5 +1,5 @@ // uses code from https://github.com/antonholmquist/jason/blob/master/jason.go -// MIT Licence +// MIT License package dynmap diff --git a/pkg/components/dynmap/dynmap_test.go b/pkg/components/dynmap/dynmap_test.go index 62d356bd67d..68d938214a3 100644 --- a/pkg/components/dynmap/dynmap_test.go +++ b/pkg/components/dynmap/dynmap_test.go @@ -1,5 +1,5 @@ // uses code from https://github.com/antonholmquist/jason/blob/master/jason.go -// MIT Licence +// MIT License package dynmap diff --git a/pkg/models/user.go b/pkg/models/user.go index e3c7b556d35..69031e40338 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -7,7 +7,8 @@ import ( // Typed errors var ( - ErrUserNotFound = errors.New("User not found") + ErrUserNotFound = errors.New("User not found") + ErrLastGrafanaAdmin = errors.New("Cannot remove last grafana admin") ) type Password string diff --git a/pkg/services/alerting/notifiers/googlechat.go b/pkg/services/alerting/notifiers/googlechat.go new file mode 100644 index 00000000000..1aba15a7928 --- /dev/null +++ b/pkg/services/alerting/notifiers/googlechat.go @@ -0,0 +1,215 @@ +package notifiers + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" + "github.com/grafana/grafana/pkg/setting" +) + +func init() { + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "googlechat", + Name: "Google Hangouts Chat", + Description: "Sends notifications to Google Hangouts Chat via webhooks based on the official JSON message " + + "format (https://developers.google.com/hangouts/chat/reference/message-formats/).", + Factory: NewGoogleChatNotifier, + OptionsTemplate: ` +

Google Hangouts Chat settings

+
+ Url + +
+ `, + }) +} + +func NewGoogleChatNotifier(model *m.AlertNotification) (alerting.Notifier, error) { + url := model.Settings.Get("url").MustString() + if url == "" { + return nil, alerting.ValidationError{Reason: "Could not find url property in settings"} + } + + return &GoogleChatNotifier{ + NotifierBase: NewNotifierBase(model), + Url: url, + log: log.New("alerting.notifier.googlechat"), + }, nil +} + +type GoogleChatNotifier struct { + NotifierBase + Url string + log log.Logger +} + +/** +Structs used to build a custom Google Hangouts Chat message card. +See: https://developers.google.com/hangouts/chat/reference/message-formats/cards +*/ +type outerStruct struct { + Cards []card `json:"cards"` +} + +type card struct { + Header header `json:"header"` + Sections []section `json:"sections"` +} + +type header struct { + Title string `json:"title"` +} + +type section struct { + Widgets []widget `json:"widgets"` +} + +// "generic" widget used to add different types of widgets (buttonWidget, textParagraphWidget, imageWidget) +type widget interface { +} + +type buttonWidget struct { + Buttons []button `json:"buttons"` +} + +type textParagraphWidget struct { + Text text `json:"textParagraph"` +} + +type text struct { + Text string `json:"text"` +} + +type imageWidget struct { + Image image `json:"image"` +} + +type image struct { + ImageUrl string `json:"imageUrl"` +} + +type button struct { + TextButton textButton `json:"textButton"` +} + +type textButton struct { + Text string `json:"text"` + OnClick onClick `json:"onClick"` +} + +type onClick struct { + OpenLink openLink `json:"openLink"` +} + +type openLink struct { + Url string `json:"url"` +} + +func (this *GoogleChatNotifier) Notify(evalContext *alerting.EvalContext) error { + this.log.Info("Executing Google Chat notification") + + headers := map[string]string{ + "Content-Type": "application/json; charset=UTF-8", + } + + ruleUrl, err := evalContext.GetRuleUrl() + if err != nil { + this.log.Error("evalContext returned an invalid rule URL") + } + + // add a text paragraph widget for the message + widgets := []widget{ + textParagraphWidget{ + Text: text{ + Text: evalContext.Rule.Message, + }, + }, + } + + // add a text paragraph widget for the fields + var fields []textParagraphWidget + fieldLimitCount := 4 + for index, evt := range evalContext.EvalMatches { + fields = append(fields, + textParagraphWidget{ + Text: text{ + Text: "" + evt.Metric + ": " + fmt.Sprint(evt.Value) + "", + }, + }, + ) + if index > fieldLimitCount { + break + } + } + widgets = append(widgets, fields) + + // if an image exists, add it as an image widget + if evalContext.ImagePublicUrl != "" { + widgets = append(widgets, imageWidget{ + Image: image{ + ImageUrl: evalContext.ImagePublicUrl, + }, + }) + } else { + this.log.Info("Could not retrieve a public image URL.") + } + + // add a button widget (link to Grafana) + widgets = append(widgets, buttonWidget{ + Buttons: []button{ + { + TextButton: textButton{ + Text: "OPEN IN GRAFANA", + OnClick: onClick{ + OpenLink: openLink{ + Url: ruleUrl, + }, + }, + }, + }, + }, + }) + + // add text paragraph widget for the build version and timestamp + widgets = append(widgets, textParagraphWidget{ + Text: text{ + Text: "Grafana v" + setting.BuildVersion + " | " + (time.Now()).Format(time.RFC822), + }, + }) + + // nest the required structs + res1D := &outerStruct{ + Cards: []card{ + { + Header: header{ + Title: evalContext.GetNotificationTitle(), + }, + Sections: []section{ + { + Widgets: widgets, + }, + }, + }, + }, + } + body, _ := json.Marshal(res1D) + + cmd := &m.SendWebhookSync{ + Url: this.Url, + HttpMethod: "POST", + HttpHeader: headers, + Body: string(body), + } + + if err := bus.DispatchCtx(evalContext.Ctx, cmd); err != nil { + this.log.Error("Failed to send Google Hangouts Chat alert", "error", err, "webhook", this.Name) + return err + } + + return nil +} diff --git a/pkg/services/alerting/notifiers/googlechat_test.go b/pkg/services/alerting/notifiers/googlechat_test.go new file mode 100644 index 00000000000..1fdce878926 --- /dev/null +++ b/pkg/services/alerting/notifiers/googlechat_test.go @@ -0,0 +1,53 @@ +package notifiers + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" +) + +func TestGoogleChatNotifier(t *testing.T) { + Convey("Google Hangouts Chat notifier tests", t, func() { + + Convey("Parsing alert notification from settings", func() { + Convey("empty settings should return error", func() { + json := `{ }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "ops", + Type: "googlechat", + Settings: settingsJSON, + } + + _, err := NewGoogleChatNotifier(model) + So(err, ShouldNotBeNil) + }) + + Convey("from settings", func() { + json := ` + { + "url": "http://google.com" + }` + + settingsJSON, _ := simplejson.NewJson([]byte(json)) + model := &m.AlertNotification{ + Name: "ops", + Type: "googlechat", + Settings: settingsJSON, + } + + not, err := NewGoogleChatNotifier(model) + webhookNotifier := not.(*GoogleChatNotifier) + + So(err, ShouldBeNil) + So(webhookNotifier.Name, ShouldEqual, "ops") + So(webhookNotifier.Type, ShouldEqual, "googlechat") + So(webhookNotifier.Url, ShouldEqual, "http://google.com") + }) + + }) + }) +} diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index c02686c24ba..7e966106b96 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -187,7 +187,7 @@ func TestAccountDataAccess(t *testing.T) { err := DeleteOrg(&m.DeleteOrgCommand{Id: ac2.OrgId}) So(err, ShouldBeNil) - // remove frome ac2 from ac1 org + // remove ac2 user from ac1 org remCmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac2.Id, ShouldDeleteOrphanedUser: true} err = RemoveOrgUser(&remCmd) So(err, ShouldBeNil) diff --git a/pkg/services/sqlstore/quota.go b/pkg/services/sqlstore/quota.go index 7005b341268..e90b7fec131 100644 --- a/pkg/services/sqlstore/quota.go +++ b/pkg/services/sqlstore/quota.go @@ -99,14 +99,14 @@ func UpdateOrgQuota(cmd *m.UpdateOrgQuotaCmd) error { return inTransaction(func(sess *DBSession) error { //Check if quota is already defined in the DB quota := m.Quota{ - Target: cmd.Target, - OrgId: cmd.OrgId, - Updated: time.Now(), + Target: cmd.Target, + OrgId: cmd.OrgId, } has, err := sess.Get("a) if err != nil { return err } + quota.Updated = time.Now() quota.Limit = cmd.Limit if !has { quota.Created = time.Now() @@ -201,14 +201,14 @@ func UpdateUserQuota(cmd *m.UpdateUserQuotaCmd) error { return inTransaction(func(sess *DBSession) error { //Check if quota is already defined in the DB quota := m.Quota{ - Target: cmd.Target, - UserId: cmd.UserId, - Updated: time.Now(), + Target: cmd.Target, + UserId: cmd.UserId, } has, err := sess.Get("a) if err != nil { return err } + quota.Updated = time.Now() quota.Limit = cmd.Limit if !has { quota.Created = time.Now() diff --git a/pkg/services/sqlstore/quota_test.go b/pkg/services/sqlstore/quota_test.go index 49e028e9cd3..976d54d10e2 100644 --- a/pkg/services/sqlstore/quota_test.go +++ b/pkg/services/sqlstore/quota_test.go @@ -2,6 +2,7 @@ package sqlstore import ( "testing" + "time" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" @@ -168,5 +169,69 @@ func TestQuotaCommandsAndQueries(t *testing.T) { So(query.Result.Limit, ShouldEqual, 5) So(query.Result.Used, ShouldEqual, 1) }) + + // related: https://github.com/grafana/grafana/issues/14342 + Convey("Should org quota updating is successful even if it called multiple time", func() { + orgCmd := m.UpdateOrgQuotaCmd{ + OrgId: orgId, + Target: "org_user", + Limit: 5, + } + err := UpdateOrgQuota(&orgCmd) + So(err, ShouldBeNil) + + query := m.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} + err = GetOrgQuotaByTarget(&query) + So(err, ShouldBeNil) + So(query.Result.Limit, ShouldEqual, 5) + + // XXX: resolution of `Updated` column is 1sec, so this makes delay + time.Sleep(1 * time.Second) + + orgCmd = m.UpdateOrgQuotaCmd{ + OrgId: orgId, + Target: "org_user", + Limit: 10, + } + err = UpdateOrgQuota(&orgCmd) + So(err, ShouldBeNil) + + query = m.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} + err = GetOrgQuotaByTarget(&query) + So(err, ShouldBeNil) + So(query.Result.Limit, ShouldEqual, 10) + }) + + // related: https://github.com/grafana/grafana/issues/14342 + Convey("Should user quota updating is successful even if it called multiple time", func() { + userQuotaCmd := m.UpdateUserQuotaCmd{ + UserId: userId, + Target: "org_user", + Limit: 5, + } + err := UpdateUserQuota(&userQuotaCmd) + So(err, ShouldBeNil) + + query := m.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} + err = GetUserQuotaByTarget(&query) + So(err, ShouldBeNil) + So(query.Result.Limit, ShouldEqual, 5) + + // XXX: resolution of `Updated` column is 1sec, so this makes delay + time.Sleep(1 * time.Second) + + userQuotaCmd = m.UpdateUserQuotaCmd{ + UserId: userId, + Target: "org_user", + Limit: 10, + } + err = UpdateUserQuota(&userQuotaCmd) + So(err, ShouldBeNil) + + query = m.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} + err = GetUserQuotaByTarget(&query) + So(err, ShouldBeNil) + So(query.Result.Limit, ShouldEqual, 10) + }) }) } diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 99a77ecabc3..a3ccb93b30c 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -504,8 +504,18 @@ func UpdateUserPermissions(cmd *m.UpdateUserPermissionsCommand) error { user.IsAdmin = cmd.IsGrafanaAdmin sess.UseBool("is_admin") + _, err := sess.ID(user.Id).Update(&user) - return err + if err != nil { + return err + } + + // validate that after update there is at least one server admin + if err := validateOneAdminLeft(sess); err != nil { + return err + } + + return nil }) } @@ -522,3 +532,17 @@ func SetUserHelpFlag(cmd *m.SetUserHelpFlagCommand) error { return err }) } + +func validateOneAdminLeft(sess *DBSession) error { + // validate that there is an admin user left + count, err := sess.Where("is_admin=?", true).Count(&m.User{}) + if err != nil { + return err + } + + if count == 0 { + return m.ErrLastGrafanaAdmin + } + + return nil +} diff --git a/pkg/services/sqlstore/user_test.go b/pkg/services/sqlstore/user_test.go index b26dd235772..627f2ab1ca5 100644 --- a/pkg/services/sqlstore/user_test.go +++ b/pkg/services/sqlstore/user_test.go @@ -155,6 +155,32 @@ func TestUserDataAccess(t *testing.T) { }) }) }) + + Convey("Given one grafana admin user", func() { + var err error + createUserCmd := &m.CreateUserCommand{ + Email: fmt.Sprint("admin", "@test.com"), + Name: fmt.Sprint("admin"), + Login: fmt.Sprint("admin"), + IsAdmin: true, + } + err = CreateUser(context.Background(), createUserCmd) + So(err, ShouldBeNil) + + Convey("Cannot make themselves a non-admin", func() { + updateUserPermsCmd := m.UpdateUserPermissionsCommand{IsGrafanaAdmin: false, UserId: 1} + updatePermsError := UpdateUserPermissions(&updateUserPermsCmd) + + So(updatePermsError, ShouldEqual, m.ErrLastGrafanaAdmin) + + query := m.GetUserByIdQuery{Id: createUserCmd.Result.Id} + getUserError := GetUserById(&query) + + So(getUserError, ShouldBeNil) + + So(query.Result.IsAdmin, ShouldEqual, true) + }) + }) }) } diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index dd026bbb79e..dfa03d2dfa9 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -101,7 +101,7 @@ func init() { "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"}, "AWS/Route53": {"ChildHealthCheckHealthyCount", "HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"}, "AWS/S3": {"BucketSizeBytes", "NumberOfObjects", "AllRequests", "GetRequests", "PutRequests", "DeleteRequests", "HeadRequests", "PostRequests", "ListRequests", "BytesDownloaded", "BytesUploaded", "4xxErrors", "5xxErrors", "FirstByteLatency", "TotalRequestLatency"}, - "AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send"}, + "AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send", "Reputation.BounceRate", "Reputation.ComplaintRate"}, "AWS/SNS": {"NumberOfMessagesPublished", "PublishSize", "NumberOfNotificationsDelivered", "NumberOfNotificationsFailed"}, "AWS/SQS": {"NumberOfMessagesSent", "SentMessageSize", "NumberOfMessagesReceived", "NumberOfEmptyReceives", "NumberOfMessagesDeleted", "ApproximateAgeOfOldestMessage", "ApproximateNumberOfMessagesDelayed", "ApproximateNumberOfMessagesVisible", "ApproximateNumberOfMessagesNotVisible"}, "AWS/States": {"ExecutionTime", "ExecutionThrottled", "ExecutionsAborted", "ExecutionsFailed", "ExecutionsStarted", "ExecutionsSucceeded", "ExecutionsTimedOut", "ActivityRunTime", "ActivityScheduleTime", "ActivityTime", "ActivitiesFailed", "ActivitiesHeartbeatTimedOut", "ActivitiesScheduled", "ActivitiesScheduled", "ActivitiesSucceeded", "ActivitiesTimedOut", "LambdaFunctionRunTime", "LambdaFunctionScheduleTime", "LambdaFunctionTime", "LambdaFunctionsFailed", "LambdaFunctionsHeartbeatTimedOut", "LambdaFunctionsScheduled", "LambdaFunctionsStarted", "LambdaFunctionsSucceeded", "LambdaFunctionsTimedOut"}, diff --git a/pkg/tsdb/elasticsearch/response_parser.go b/pkg/tsdb/elasticsearch/response_parser.go index 0837c3dd9d5..b2c724a9b93 100644 --- a/pkg/tsdb/elasticsearch/response_parser.go +++ b/pkg/tsdb/elasticsearch/response_parser.go @@ -541,7 +541,7 @@ func getErrorFromElasticResponse(response *es.SearchResponse) *tsdb.QueryResult } else if reason != "" { result.ErrorString = reason } else { - result.ErrorString = "Unkown elasticsearch error response" + result.ErrorString = "Unknown elasticsearch error response" } return result diff --git a/pkg/tsdb/influxdb/query_part.go b/pkg/tsdb/influxdb/query_part.go index 77f565a8597..29a77f15617 100644 --- a/pkg/tsdb/influxdb/query_part.go +++ b/pkg/tsdb/influxdb/query_part.go @@ -32,6 +32,7 @@ func init() { renders["median"] = QueryDefinition{Renderer: functionRenderer} renders["sum"] = QueryDefinition{Renderer: functionRenderer} renders["mode"] = QueryDefinition{Renderer: functionRenderer} + renders["cumulative_sum"] = QueryDefinition{Renderer: functionRenderer} renders["holt_winters"] = QueryDefinition{ Renderer: functionRenderer, diff --git a/pkg/tsdb/influxdb/query_part_test.go b/pkg/tsdb/influxdb/query_part_test.go index 08bcff9b727..76daf6446d8 100644 --- a/pkg/tsdb/influxdb/query_part_test.go +++ b/pkg/tsdb/influxdb/query_part_test.go @@ -23,6 +23,7 @@ func TestInfluxdbQueryPart(t *testing.T) { {mode: "alias", params: []string{"test"}, input: "mean(value)", expected: `mean(value) AS "test"`}, {mode: "count", params: []string{}, input: "distinct(value)", expected: `count(distinct(value))`}, {mode: "mode", params: []string{}, input: "value", expected: `mode(value)`}, + {mode: "cumulative_sum", params: []string{}, input: "mean(value)", expected: `cumulative_sum(mean(value))`}, } queryContext := &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("5m", "now")} diff --git a/pkg/tsdb/opentsdb/opentsdb.go b/pkg/tsdb/opentsdb/opentsdb.go index 16da764de54..a810d3c7338 100644 --- a/pkg/tsdb/opentsdb/opentsdb.go +++ b/pkg/tsdb/opentsdb/opentsdb.go @@ -84,7 +84,7 @@ func (e *OpenTsdbExecutor) createRequest(dsInfo *models.DataSource, data OpenTsd postData, err := json.Marshal(data) if err != nil { - plog.Info("Failed marshalling data", "error", err) + plog.Info("Failed marshaling data", "error", err) return nil, fmt.Errorf("Failed to create request. error: %v", err) } diff --git a/public/app/app.ts b/public/app/app.ts index 9647fbe5416..2b94ec0fe33 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -1,4 +1,4 @@ -import 'babel-polyfill'; +import '@babel/polyfill'; import 'file-saver'; import 'lodash'; import 'jquery'; diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 7be28272f11..5609c058a27 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -16,7 +16,7 @@ export function registerAngularDirectives() { react2AngularDirective('searchResult', SearchResult, []); react2AngularDirective('tagFilter', TagFilter, [ 'tags', - ['onSelect', { watchDepth: 'reference' }], + ['onChange', { watchDepth: 'reference' }], ['tagOptions', { watchDepth: 'reference' }], ]); } diff --git a/public/app/core/components/PermissionList/AddPermission.tsx b/public/app/core/components/PermissionList/AddPermission.tsx index 71cc937ddfa..d6da7c68544 100644 --- a/public/app/core/components/PermissionList/AddPermission.tsx +++ b/public/app/core/components/PermissionList/AddPermission.tsx @@ -84,7 +84,7 @@ class AddPermissions extends Component { render() { const { onCancel } = this.props; const newItem = this.state; - const pickerClassName = 'width-20'; + const pickerClassName = 'min-width-20'; const isValid = this.isValid(); return (
diff --git a/public/app/core/components/Picker/UserPicker.tsx b/public/app/core/components/Picker/UserPicker.tsx index f78cf69bf5e..f80a3fc135f 100644 --- a/public/app/core/components/Picker/UserPicker.tsx +++ b/public/app/core/components/Picker/UserPicker.tsx @@ -40,7 +40,7 @@ export class UserPicker extends Component { .then(result => { return result.map(user => ({ id: user.userId, - label: `${user.login} - ${user.email}`, + label: user.login === user.email ? user.login : `${user.login} - ${user.email}`, avatarUrl: user.avatarUrl, login: user.login, })); diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx index 14b6ef4932b..6a4203f8739 100644 --- a/public/app/core/components/TagFilter/TagFilter.tsx +++ b/public/app/core/components/TagFilter/TagFilter.tsx @@ -10,7 +10,7 @@ import ResetStyles from 'app/core/components/Picker/ResetStyles'; export interface Props { tags: string[]; tagOptions: () => any; - onSelect: (tag: string) => void; + onChange: (tags: string[]) => void; } export class TagFilter extends React.Component { @@ -18,12 +18,9 @@ export class TagFilter extends React.Component { constructor(props) { super(props); - - this.searchTags = this.searchTags.bind(this); - this.onChange = this.onChange.bind(this); } - searchTags(query) { + onLoadOptions = query => { return this.props.tagOptions().then(options => { return options.map(option => ({ value: option.term, @@ -31,18 +28,20 @@ export class TagFilter extends React.Component { count: option.count, })); }); - } + }; - onChange(newTags) { - this.props.onSelect(newTags); - } + onChange = (newTags: any[]) => { + this.props.onChange(newTags.map(tag => tag.value)); + }; render() { + const tags = this.props.tags.map(tag => ({ value: tag, label: tag, count: 0 })); + const selectOptions = { classNamePrefix: 'gf-form-select-box', isMulti: true, defaultOptions: true, - loadOptions: this.searchTags, + loadOptions: this.onLoadOptions, onChange: this.onChange, className: 'gf-form-input gf-form-input--form-dropdown', placeholder: 'Tags', @@ -50,7 +49,7 @@ export class TagFilter extends React.Component { noOptionsMessage: () => 'No tags found', getOptionValue: i => i.value, getOptionLabel: i => i.label, - value: this.props.tags, + value: tags, styles: ResetStyles, components: { Option: TagOption, diff --git a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx new file mode 100644 index 00000000000..077d8772393 --- /dev/null +++ b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx @@ -0,0 +1,76 @@ +import React, { SFC, ReactNode, PureComponent, ReactElement } from 'react'; + +interface ToggleButtonGroupProps { + onChange: (value) => void; + value?: any; + label?: string; + render: (props) => void; +} + +export default class ToggleButtonGroup extends PureComponent { + getValues() { + const { children } = this.props; + return React.Children.toArray(children).map((c: ReactElement) => c.props.value); + } + + smallChildren() { + const { children } = this.props; + return React.Children.toArray(children).every((c: ReactElement) => c.props.className.includes('small')); + } + + handleToggle(toggleValue) { + const { value, onChange } = this.props; + if (value && value === toggleValue) { + return; + } + onChange(toggleValue); + } + + render() { + const { value, label } = this.props; + const values = this.getValues(); + const selectedValue = value || values[0]; + const labelClassName = `gf-form-label ${this.smallChildren() ? 'small' : ''}`; + + return ( +
+
+ {label && } + {this.props.render({ selectedValue, onChange: this.handleToggle.bind(this) })} +
+
+ ); + } +} + +interface ToggleButtonProps { + onChange?: (value) => void; + selected?: boolean; + value: any; + className?: string; + children: ReactNode; + title?: string; +} + +export const ToggleButton: SFC = ({ + children, + selected, + className = '', + title = null, + value, + onChange, +}) => { + const handleChange = event => { + event.stopPropagation(); + if (onChange) { + onChange(value); + } + }; + + const btnClassName = `btn ${className} ${selected ? 'active' : ''}`; + return ( + + ); +}; diff --git a/public/app/core/components/code_editor/theme-grafana-dark.js b/public/app/core/components/code_editor/theme-grafana-dark.js index a48715e698e..33d4a84b527 100644 --- a/public/app/core/components/code_editor/theme-grafana-dark.js +++ b/public/app/core/components/code_editor/theme-grafana-dark.js @@ -14,7 +14,7 @@ ace.define("ace/theme/grafana-dark",["require","exports","module","ace/lib/dom"] background: #555651\ }\ .gf-code-dark {\ - background-color: #111;\ + background-color: #09090b;\ color: #e0e0e0\ }\ .gf-code-dark .ace_cursor {\ diff --git a/public/app/core/components/colorpicker/SeriesColorPicker.tsx b/public/app/core/components/colorpicker/SeriesColorPicker.tsx index d6feaa31965..32b7554e38d 100644 --- a/public/app/core/components/colorpicker/SeriesColorPicker.tsx +++ b/public/app/core/components/colorpicker/SeriesColorPicker.tsx @@ -44,7 +44,7 @@ export class SeriesColorPicker extends React.Component { const drop = new Drop({ target: this.pickerElem, content: dropContentElem, - position: 'top center', + position: 'bottom center', classes: 'drop-popover', openOn: 'hover', hoverCloseDelay: 200, diff --git a/public/app/core/components/search/search.html b/public/app/core/components/search/search.html index 8723d5d0584..8a83ecbc205 100644 --- a/public/app/core/components/search/search.html +++ b/public/app/core/components/search/search.html @@ -41,7 +41,7 @@
- + diff --git a/public/app/core/components/search/search.ts b/public/app/core/components/search/search.ts index 322dd2bdf10..ff63ca5a8fe 100644 --- a/public/app/core/components/search/search.ts +++ b/public/app/core/components/search/search.ts @@ -25,8 +25,6 @@ export class SearchCtrl { appEvents.on('hide-dash-search', this.closeSearch.bind(this), $scope); this.initialFolderFilterTitle = 'All'; - this.getTags = this.getTags.bind(this); - this.onTagSelect = this.onTagSelect.bind(this); this.isEditor = contextSrv.isEditor; this.hasEditPermissionInFolders = contextSrv.hasEditPermissionInFolders; } @@ -162,7 +160,7 @@ export class SearchCtrl { const localSearchId = this.currentSearchId; const query = { ...this.query, - tag: this.query.tag.map(i => i.value), + tag: this.query.tag, }; return this.searchSrv.search(query).then(results => { @@ -195,14 +193,14 @@ export class SearchCtrl { evt.preventDefault(); } - getTags() { + getTags = () => { return this.searchSrv.getDashboardTags(); - } + }; - onTagSelect(newTags) { - this.query.tag = newTags; + onTagFiltersChanged = (tags: string[]) => { + this.query.tag = tags; this.search(); - } + }; clearSearchFilter() { this.query.tag = []; diff --git a/public/app/core/components/sidemenu/TopSectionItem.tsx b/public/app/core/components/sidemenu/TopSectionItem.tsx index 4a207cc0df9..7b3bf96dce8 100644 --- a/public/app/core/components/sidemenu/TopSectionItem.tsx +++ b/public/app/core/components/sidemenu/TopSectionItem.tsx @@ -15,7 +15,7 @@ const TopSectionItem: SFC = props => { {link.img && } - {link.children && } + ); }; diff --git a/public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap b/public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap index f7ff56bff6b..d79e9171581 100644 --- a/public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap +++ b/public/app/core/components/sidemenu/__snapshots__/TopSectionItem.test.tsx.snap @@ -13,5 +13,8 @@ exports[`Render should render component 1`] = ` + `; diff --git a/public/app/core/config.ts b/public/app/core/config.ts index 1473f8a91f8..13d84772ecf 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -54,7 +54,11 @@ export class Settings { } } -const bootData = (window as any).grafanaBootData || { settings: {} }; +const bootData = (window as any).grafanaBootData || { + settings: {}, + user: {}, +}; + const options = bootData.settings; options.bootData = bootData; diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index c05f5bab866..2ce8b42927f 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; import { TimeSeries } from 'app/core/core'; -import colors from 'app/core/utils/colors'; +import colors, { getThemeColor } from 'app/core/utils/colors'; export enum LogLevel { crit = 'critical', @@ -22,7 +22,7 @@ export const LogLevelColor = { [LogLevel.info]: colors[0], [LogLevel.debug]: colors[5], [LogLevel.trace]: colors[2], - [LogLevel.unkown]: '#ddd', + [LogLevel.unkown]: getThemeColor('#8e8e8e', '#dde4ed'), }; export interface LogSearchMatch { @@ -45,6 +45,13 @@ export interface LogRow { uniqueLabels?: LogsStreamLabels; } +export interface LogsLabelStat { + active?: boolean; + count: number; + proportion: number; + value: string; +} + export enum LogsMetaKind { Number, String, @@ -81,6 +88,13 @@ export interface LogsStreamLabels { [key: string]: string; } +export enum LogsDedupDescription { + none = 'No de-duplication', + exact = 'De-duplication of successive lines that are identical, ignoring ISO datetimes.', + numbers = 'De-duplication of successive lines that are identical when ignoring numbers, e.g., IP addresses, latencies.', + signature = 'De-duplication of successive lines that have identical punctuation and whitespace.', +} + export enum LogsDedupStrategy { none = 'none', exact = 'exact', @@ -88,6 +102,73 @@ export enum LogsDedupStrategy { signature = 'signature', } +export interface LogsParser { + /** + * Value-agnostic matcher for a field label. + * Used to filter rows, and first capture group contains the value. + */ + buildMatcher: (label: string) => RegExp; + /** + * Regex to find a field in the log line. + * First capture group contains the label value, second capture group the value. + */ + fieldRegex: RegExp; + /** + * Function to verify if this is a valid parser for the given line. + * The parser accepts the line unless it returns undefined. + */ + test: (line: string) => any; +} + +export const LogsParsers: { [name: string]: LogsParser } = { + JSON: { + buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"([^"]*)"`), + fieldRegex: /"(\w+)"\s*:\s*"([^"]*)"/, + test: line => { + try { + return JSON.parse(line); + } catch (error) {} + }, + }, + logfmt: { + buildMatcher: label => new RegExp(`(?:^|\\s)${label}=("[^"]*"|\\S+)`), + fieldRegex: /(?:^|\s)(\w+)=("[^"]*"|\S+)/, + test: line => LogsParsers.logfmt.fieldRegex.test(line), + }, +}; + +export function calculateFieldStats(rows: LogRow[], extractor: RegExp): LogsLabelStat[] { + // Consider only rows that satisfy the matcher + const rowsWithField = rows.filter(row => extractor.test(row.entry)); + const rowCount = rowsWithField.length; + + // Get field value counts for eligible rows + const countsByValue = _.countBy(rowsWithField, row => (row as LogRow).entry.match(extractor)[1]); + const sortedCounts = _.chain(countsByValue) + .map((count, value) => ({ count, value, proportion: count / rowCount })) + .sortBy('count') + .reverse() + .value(); + + return sortedCounts; +} + +export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabelStat[] { + // Consider only rows that have the given label + const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined); + const rowCount = rowsWithLabel.length; + + // Get label value counts for eligible rows + const countsByValue = _.countBy(rowsWithLabel, row => (row as LogRow).labels[label]); + const sortedCounts = _.chain(countsByValue) + .map((count, value) => ({ count, value, proportion: count / rowCount })) + .sortBy('count') + .reverse() + .value(); + + return sortedCounts; +} + const isoDateRegexp = /\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-6]\d[,\.]\d+([+-][0-2]\d:[0-5]\d|Z)/g; function isDuplicateRow(row: LogRow, other: LogRow, strategy: LogsDedupStrategy): boolean { switch (strategy) { @@ -128,6 +209,19 @@ export function dedupLogRows(logs: LogsModel, strategy: LogsDedupStrategy): Logs }; } +export function getParser(line: string): LogsParser { + let parser; + try { + if (LogsParsers.JSON.test(line)) { + parser = LogsParsers.JSON; + } + } catch (error) {} + if (!parser && LogsParsers.logfmt.test(line)) { + parser = LogsParsers.logfmt; + } + return parser; +} + export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set): LogsModel { if (hiddenLogLevels.size === 0) { return logs; @@ -147,31 +241,55 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set) } export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSeries[] { + // currently interval is rangeMs / resolution, which is too low for showing series as bars. + // need at least 10px per bucket, so we multiply interval by 10. Should be solved higher up the chain + // when executing queries & interval calculated and not here but this is a temporary fix. + // intervalMs = intervalMs * 10; + // Graph time series by log level const seriesByLevel = {}; - rows.forEach(row => { - if (!seriesByLevel[row.logLevel]) { - seriesByLevel[row.logLevel] = { lastTs: null, datapoints: [], alias: row.logLevel }; + const bucketSize = intervalMs * 10; + const seriesList = []; + + for (const row of rows) { + let series = seriesByLevel[row.logLevel]; + + if (!series) { + seriesByLevel[row.logLevel] = series = { + lastTs: null, + datapoints: [], + alias: row.logLevel, + color: LogLevelColor[row.logLevel], + }; + + seriesList.push(series); } - const levelSeries = seriesByLevel[row.logLevel]; + + // align time to bucket size + const time = Math.round(row.timeEpochMs / bucketSize) * bucketSize; - // Bucket to nearest minute - const time = Math.round(row.timeEpochMs / intervalMs / 10) * intervalMs * 10; // Entry for time - if (time === levelSeries.lastTs) { - levelSeries.datapoints[levelSeries.datapoints.length - 1][0]++; + if (time === series.lastTs) { + series.datapoints[series.datapoints.length - 1][0]++; } else { - levelSeries.datapoints.push([1, time]); - levelSeries.lastTs = time; + series.datapoints.push([1, time]); + series.lastTs = time; } - }); - return Object.keys(seriesByLevel).reduce((acc, level) => { - if (seriesByLevel[level]) { - const gs = new TimeSeries(seriesByLevel[level]); - gs.setColor(LogLevelColor[level]); - acc.push(gs); + // add zero to other levels to aid stacking so each level series has same number of points + for (const other of seriesList) { + if (other !== series && other.lastTs !== time) { + other.datapoints.push([0, time]); + other.lastTs = time; + } } - return acc; - }, []); + } + + return seriesList.map(series => { + series.datapoints.sort((a, b) => { + return a[1] - b[1]; + }); + + return new TimeSeries(series); + }); } diff --git a/public/app/core/specs/logs_model.test.ts b/public/app/core/specs/logs_model.test.ts index 5e427468339..85f75b50ed0 100644 --- a/public/app/core/specs/logs_model.test.ts +++ b/public/app/core/specs/logs_model.test.ts @@ -1,4 +1,12 @@ -import { dedupLogRows, LogsDedupStrategy, LogsModel } from '../logs_model'; +import { + calculateFieldStats, + calculateLogsLabelStats, + dedupLogRows, + getParser, + LogsDedupStrategy, + LogsModel, + LogsParsers, +} from '../logs_model'; describe('dedupLogRows()', () => { test('should return rows as is when dedup is set to none', () => { @@ -106,3 +114,167 @@ describe('dedupLogRows()', () => { ]); }); }); + +describe('calculateFieldStats()', () => { + test('should return no stats for empty rows', () => { + expect(calculateFieldStats([], /foo=(.*)/)).toEqual([]); + }); + + test('should return no stats if extractor does not match', () => { + const rows = [ + { + entry: 'foo=bar', + }, + ]; + + expect(calculateFieldStats(rows as any, /baz=(.*)/)).toEqual([]); + }); + + test('should return stats for found field', () => { + const rows = [ + { + entry: 'foo="42 + 1"', + }, + { + entry: 'foo=503 baz=foo', + }, + { + entry: 'foo="42 + 1"', + }, + { + entry: 't=2018-12-05T07:44:59+0000 foo=503', + }, + ]; + + expect(calculateFieldStats(rows as any, /foo=("[^"]*"|\S+)/)).toMatchObject([ + { + value: '"42 + 1"', + count: 2, + }, + { + value: '503', + count: 2, + }, + ]); + }); +}); + +describe('calculateLogsLabelStats()', () => { + test('should return no stats for empty rows', () => { + expect(calculateLogsLabelStats([], '')).toEqual([]); + }); + + test('should return no stats of label is not found', () => { + const rows = [ + { + entry: 'foo 1', + labels: { + foo: 'bar', + }, + }, + ]; + + expect(calculateLogsLabelStats(rows as any, 'baz')).toEqual([]); + }); + + test('should return stats for found labels', () => { + const rows = [ + { + entry: 'foo 1', + labels: { + foo: 'bar', + }, + }, + { + entry: 'foo 0', + labels: { + foo: 'xxx', + }, + }, + { + entry: 'foo 2', + labels: { + foo: 'bar', + }, + }, + ]; + + expect(calculateLogsLabelStats(rows as any, 'foo')).toMatchObject([ + { + value: 'bar', + count: 2, + }, + { + value: 'xxx', + count: 1, + }, + ]); + }); +}); + +describe('getParser()', () => { + test('should return no parser on empty line', () => { + expect(getParser('')).toBeUndefined(); + }); + + test('should return no parser on unknown line pattern', () => { + expect(getParser('To Be or not to be')).toBeUndefined(); + }); + + test('should return logfmt parser on key value patterns', () => { + expect(getParser('foo=bar baz="41 + 1')).toEqual(LogsParsers.logfmt); + }); + + test('should return JSON parser on JSON log lines', () => { + // TODO implement other JSON value types than string + expect(getParser('{"foo": "bar", "baz": "41 + 1"}')).toEqual(LogsParsers.JSON); + }); +}); + +describe('LogsParsers', () => { + describe('logfmt', () => { + const parser = LogsParsers.logfmt; + + test('should detect format', () => { + expect(parser.test('foo')).toBeFalsy(); + expect(parser.test('foo=bar')).toBeTruthy(); + }); + + test('should have a valid fieldRegex', () => { + const match = 'foo=bar'.match(parser.fieldRegex); + expect(match).toBeDefined(); + expect(match[1]).toBe('foo'); + expect(match[2]).toBe('bar'); + }); + + test('should build a valid value matcher', () => { + const matcher = parser.buildMatcher('foo'); + const match = 'foo=bar'.match(matcher); + expect(match).toBeDefined(); + expect(match[1]).toBe('bar'); + }); + }); + + describe('JSON', () => { + const parser = LogsParsers.JSON; + + test('should detect format', () => { + expect(parser.test('foo')).toBeFalsy(); + expect(parser.test('{"foo":"bar"}')).toBeTruthy(); + }); + + test('should have a valid fieldRegex', () => { + const match = '{"foo":"bar"}'.match(parser.fieldRegex); + expect(match).toBeDefined(); + expect(match[1]).toBe('foo'); + expect(match[2]).toBe('bar'); + }); + + test('should build a valid value matcher', () => { + const matcher = parser.buildMatcher('foo'); + const match = '{"foo":"bar"}'.match(matcher); + expect(match).toBeDefined(); + expect(match[1]).toBe('bar'); + }); + }); +}); diff --git a/public/app/core/utils/colors.ts b/public/app/core/utils/colors.ts index 13e02b76e30..34508e94a9f 100644 --- a/public/app/core/utils/colors.ts +++ b/public/app/core/utils/colors.ts @@ -1,5 +1,6 @@ import _ from 'lodash'; import tinycolor from 'tinycolor2'; +import config from 'app/core/config'; export const PALETTE_ROWS = 4; export const PALETTE_COLUMNS = 14; @@ -90,5 +91,9 @@ export function hslToHex(color) { return tinycolor(color).toHexString(); } +export function getThemeColor(dark: string, light: string): string { + return config.bootData.user.lightTheme ? light : dark; +} + export let sortedColors = sortColorsByHue(colors); export default colors; diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index be771c1b232..26b6a527d95 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -1,15 +1,15 @@ import _ from 'lodash'; import { renderUrl } from 'app/core/utils/url'; -import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore'; -import { DataQuery, RawTimeRange } from 'app/types/series'; - -import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; import kbn from 'app/core/utils/kbn'; -import colors from 'app/core/utils/colors'; -import TimeSeries from 'app/core/time_series2'; -import { parse as parseDate } from 'app/core/utils/datemath'; import store from 'app/core/store'; +import colors from 'app/core/utils/colors'; +import { parse as parseDate } from 'app/core/utils/datemath'; + +import TimeSeries from 'app/core/time_series2'; +import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; +import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore'; +import { DataQuery, RawTimeRange, IntervalValues, DataSourceApi } from 'app/types/series'; export const DEFAULT_RANGE = { from: 'now-6h', @@ -130,10 +130,15 @@ export function ensureQueries(queries?: DataQuery[]): DataQuery[] { } /** - * A target is non-empty when it has keys other than refId and key. + * A target is non-empty when it has keys (with non-empty values) other than refId and key. */ export function hasNonEmptyQuery(queries: DataQuery[]): boolean { - return queries.some(query => Object.keys(query).length > 2); + return queries.some( + query => + Object.keys(query) + .map(k => query[k]) + .filter(v => v).length > 2 + ); } export function calculateResultsFromQueryTransactions( @@ -165,18 +170,16 @@ export function calculateResultsFromQueryTransactions( }; } -export function getIntervals( - range: RawTimeRange, - datasource, - resolution: number -): { interval: string; intervalMs: number } { +export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, resolution: number): IntervalValues { if (!datasource || !resolution) { return { interval: '1s', intervalMs: 1000 }; } + const absoluteRange: RawTimeRange = { from: parseDate(range.from, false), to: parseDate(range.to, true), }; + return kbn.calculateInterval(absoluteRange, resolution, datasource.interval); } diff --git a/public/app/core/utils/kbn.ts b/public/app/core/utils/kbn.ts index 9caa5bf1a54..e0b98cb803c 100644 --- a/public/app/core/utils/kbn.ts +++ b/public/app/core/utils/kbn.ts @@ -590,8 +590,8 @@ kbn.valueFormats.flowcms = kbn.formatBuilders.fixedUnit('cms'); kbn.valueFormats.flowcfs = kbn.formatBuilders.fixedUnit('cfs'); kbn.valueFormats.flowcfm = kbn.formatBuilders.fixedUnit('cfm'); kbn.valueFormats.litreh = kbn.formatBuilders.fixedUnit('l/h'); -kbn.valueFormats.flowlpm = kbn.formatBuilders.decimalSIPrefix('l/min'); -kbn.valueFormats.flowmlpm = kbn.formatBuilders.decimalSIPrefix('mL/min', -1); +kbn.valueFormats.flowlpm = kbn.formatBuilders.fixedUnit('l/min'); +kbn.valueFormats.flowmlpm = kbn.formatBuilders.fixedUnit('mL/min'); // Angle kbn.valueFormats.degree = kbn.formatBuilders.fixedUnit('°'); diff --git a/public/app/core/utils/text.test.ts b/public/app/core/utils/text.test.ts index 4f9d8367218..206e8507f9d 100644 --- a/public/app/core/utils/text.test.ts +++ b/public/app/core/utils/text.test.ts @@ -16,9 +16,20 @@ describe('findMatchesInText()', () => { expect(findMatchesInText(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo', end: 4 }]); }); - expect(findMatchesInText(' foo foo bar ', 'foo|bar')).toEqual([ - { length: 3, start: 1, text: 'foo', end: 4 }, - { length: 3, start: 5, text: 'foo', end: 8 }, - { length: 3, start: 9, text: 'bar', end: 12 }, - ]); + test('should find all matches for a complete regex', () => { + expect(findMatchesInText(' foo foo bar ', 'foo|bar')).toEqual([ + { length: 3, start: 1, text: 'foo', end: 4 }, + { length: 3, start: 5, text: 'foo', end: 8 }, + { length: 3, start: 9, text: 'bar', end: 12 }, + ]); + }); + + test('not fail on incomplete regex', () => { + expect(findMatchesInText(' foo foo bar ', 'foo|')).toEqual([ + { length: 3, start: 1, text: 'foo', end: 4 }, + { length: 3, start: 5, text: 'foo', end: 8 }, + ]); + expect(findMatchesInText('foo foo bar', '(')).toEqual([]); + expect(findMatchesInText('foo foo bar', '(foo|')).toEqual([]); + }); }); diff --git a/public/app/core/utils/text.ts b/public/app/core/utils/text.ts index 5d7591a31e2..4e948116dba 100644 --- a/public/app/core/utils/text.ts +++ b/public/app/core/utils/text.ts @@ -8,6 +8,10 @@ export function findHighlightChunksInText({ searchWords, textToHighlight }) { return findMatchesInText(textToHighlight, searchWords.join(' ')); } +const cleanNeedle = (needle: string): string => { + return needle.replace(/[[{(][\w,.-?:*+]+$/, ''); +}; + /** * Returns a list of substring regexp matches. */ @@ -16,17 +20,25 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[] if (!haystack || !needle) { return []; } - const regexp = new RegExp(`(?:${needle})`, 'g'); const matches = []; - let match = regexp.exec(haystack); - while (match) { - matches.push({ - text: match[0], - start: match.index, - length: match[0].length, - end: match.index + match[0].length, - }); - match = regexp.exec(haystack); + const cleaned = cleanNeedle(needle); + let regexp; + try { + regexp = new RegExp(`(?:${cleaned})`, 'g'); + } catch (error) { + return matches; } + haystack.replace(regexp, (substring, ...rest) => { + if (substring) { + const offset = rest[rest.length - 2]; + matches.push({ + text: substring, + start: offset, + length: substring.length, + end: offset + substring.length, + }); + } + return ''; + }); return matches; } diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index 3320783ec67..18a16d5c1d4 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -223,6 +223,8 @@ export class DashboardModel { } panelInitialized(panel: PanelModel) { + panel.initialized(); + if (!this.otherPanelInFullscreen(panel)) { panel.refresh(); } diff --git a/public/app/features/dashboard/panel_model.ts b/public/app/features/dashboard/panel_model.ts index dc8a509f2eb..737841be7e8 100644 --- a/public/app/features/dashboard/panel_model.ts +++ b/public/app/features/dashboard/panel_model.ts @@ -132,7 +132,7 @@ export class PanelModel { } } - panelInitialized() { + initialized() { this.events.emit('panel-initialized'); } diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 3e35a68bf84..4a9c67a2338 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -38,7 +38,7 @@ import Graph from './Graph'; import Logs from './Logs'; import Table from './Table'; import ErrorBoundary from './ErrorBoundary'; -import TimePicker from './TimePicker'; +import TimePicker, { parseTime } from './TimePicker'; interface ExploreProps { datasourceSrv: DatasourceSrv; @@ -115,7 +115,7 @@ export class Explore extends React.PureComponent { } else { const { datasource, queries, range } = props.urlState as ExploreUrlState; initialQueries = ensureQueries(queries); - const initialRange = range || { ...DEFAULT_RANGE }; + const initialRange = { from: parseTime(range.from), to: parseTime(range.to) } || { ...DEFAULT_RANGE }; // Millies step for helper bar charts const initialGraphInterval = 15 * 1000; this.state = { @@ -253,6 +253,7 @@ export class Explore extends React.PureComponent { datasourceLoading: false, datasourceName: datasource.name, initialQueries: nextQueries, + logsHighlighterExpressions: undefined, showingStartPage: Boolean(StartPage), }, () => { @@ -291,7 +292,11 @@ export class Explore extends React.PureComponent { return qt; }); - return { initialQueries: nextQueries, queryTransactions: nextQueryTransactions }; + return { + initialQueries: nextQueries, + logsHighlighterExpressions: undefined, + queryTransactions: nextQueryTransactions, + }; }); }; @@ -337,6 +342,9 @@ export class Explore extends React.PureComponent { queryTransactions: nextQueryTransactions, }; }, this.onSubmit); + } else if (this.state.datasource.getHighlighterExpression && this.modifiedQueries.length === 1) { + // Live preview of log search matches. Can only work on single row query for now + this.updateLogsHighlights(value); } }; @@ -351,6 +359,7 @@ export class Explore extends React.PureComponent { }; onClickClear = () => { + this.onStopScanning(); this.modifiedQueries = ensureQueries(); this.setState( prevState => ({ @@ -528,6 +537,7 @@ export class Explore extends React.PureComponent { return { ...results, initialQueries: nextQueries, + logsHighlighterExpressions: undefined, queryTransactions: nextQueryTransactions, }; }, @@ -644,6 +654,7 @@ export class Explore extends React.PureComponent { ...results, queryTransactions: nextQueryTransactions, showingStartPage: false, + graphInterval: queryOptions.intervalMs, }; }); @@ -664,7 +675,8 @@ export class Explore extends React.PureComponent { } this.setState(state => { - const { history, queryTransactions, scanning } = state; + const { history, queryTransactions } = state; + let { scanning } = state; // Transaction might have been discarded const transaction = queryTransactions.find(qt => qt.id === transactionId); @@ -701,15 +713,21 @@ export class Explore extends React.PureComponent { const nextHistory = updateHistory(history, datasourceId, queries); // Keep scanning for results if this was the last scanning transaction - if (_.size(result) === 0 && scanning) { - const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done); - if (!other) { - this.scanTimer = setTimeout(this.scanPreviousRange, 1000); + if (scanning) { + if (_.size(result) === 0) { + const other = nextQueryTransactions.find(qt => qt.scanning && !qt.done); + if (!other) { + this.scanTimer = setTimeout(this.scanPreviousRange, 1000); + } + } else { + // We can stop scanning if we have a result + scanning = false; } } return { ...results, + scanning, history: nextHistory, queryTransactions: nextQueryTransactions, }; @@ -725,7 +743,7 @@ export class Explore extends React.PureComponent { console.error(response); - let error: string | JSX.Element = response; + let error: string | JSX.Element; if (response.data) { if (typeof response.data === 'string') { error = response.data; @@ -742,6 +760,12 @@ export class Explore extends React.PureComponent { } else { throw new Error('Could not handle error response'); } + } else if (response.message) { + error = response.message; + } else if (typeof response === 'string') { + error = response; + } else { + error = 'Unknown error during query transaction. Please check JS console logs.'; } this.setState(state => { @@ -771,6 +795,9 @@ export class Explore extends React.PureComponent { async runQueries(resultType: ResultType, queryOptions: any, resultGetter?: any) { const queries = [...this.modifiedQueries]; if (!hasNonEmptyQuery(queries)) { + this.setState({ + queryTransactions: [], + }); return; } const { datasource } = this.state; @@ -790,6 +817,17 @@ export class Explore extends React.PureComponent { }); } + updateLogsHighlights = _.debounce((value: DataQuery, index: number) => { + this.setState(state => { + const { datasource } = state; + if (datasource.getHighlighterExpression) { + const logsHighlighterExpressions = [state.datasource.getHighlighterExpression(value)]; + return { logsHighlighterExpressions }; + } + return null; + }); + }, 500); + cloneState(): ExploreState { // Copy state, but copy queries including modifications return { @@ -816,6 +854,7 @@ export class Explore extends React.PureComponent { graphResult, history, initialQueries, + logsHighlighterExpressions, logsResult, queryTransactions, range, @@ -960,6 +999,7 @@ export class Explore extends React.PureComponent { +
+
{value}
+
{count}
+
{percent}
+
+
+
+
+
+ ); +} + +const STATS_ROW_LIMIT = 5; +export class Stats extends PureComponent<{ + stats: LogsLabelStat[]; + label: string; + value: string; + rowCount: number; + onClickClose: () => void; +}> { + render() { + const { label, rowCount, stats, value, onClickClose } = this.props; + const topRows = stats.slice(0, STATS_ROW_LIMIT); + let activeRow = topRows.find(row => row.value === value); + let otherRows = stats.slice(STATS_ROW_LIMIT); + const insertActiveRow = !activeRow; + // Remove active row from other to show extra + if (insertActiveRow) { + activeRow = otherRows.find(row => row.value === value); + otherRows = otherRows.filter(row => row.value !== value); + } + const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0); + const topCount = topRows.reduce((sum, row) => sum + row.count, 0); + const total = topCount + otherCount; + const otherProportion = otherCount / total; + + return ( +
+
+ + {label}: {total} of {rowCount} rows have that label + + +
+
+ {topRows.map(stat => )} + {insertActiveRow && activeRow && } + {otherCount > 0 && ( + + )} +
+
+ ); + } +} + +class Label extends PureComponent< + { + getRows?: () => LogRow[]; + label: string; + plain?: boolean; + value: string; + onClickLabel?: (label: string, value: string) => void; + }, + { showStats: boolean; stats: LogsLabelStat[] } +> { + state = { + stats: null, + showStats: false, + }; + + onClickClose = () => { + this.setState({ showStats: false }); + }; + + onClickLabel = () => { + const { onClickLabel, label, value } = this.props; + if (onClickLabel) { + onClickLabel(label, value); + } + }; + + onClickStats = () => { + this.setState(state => { + if (state.showStats) { + return { showStats: false, stats: null }; + } + const allRows = this.props.getRows(); + const stats = calculateLogsLabelStats(allRows, this.props.label); + return { showStats: true, stats }; + }); + }; + + render() { + const { getRows, label, plain, value } = this.props; + const { showStats, stats } = this.state; + const tooltip = `${label}: ${value}`; + return ( + + + {value} + + {!plain && ( + + )} + {!plain && getRows && } + {showStats && ( + + + + )} + + ); + } +} + +export default class LogLabels extends PureComponent<{ + getRows?: () => LogRow[]; + labels: LogsStreamLabels; + plain?: boolean; + onClickLabel?: (label: string, value: string) => void; +}> { + render() { + const { getRows, labels, onClickLabel, plain } = this.props; + return Object.keys(labels).map(key => ( +