From 3091aece2b5dcdbf42b129c892eb505eba0325bc Mon Sep 17 00:00:00 2001 From: Patrick Schuster Date: Wed, 28 Mar 2018 11:56:54 +0200 Subject: [PATCH 01/27] Add Google Hangouts Chat notifier. --- pkg/services/alerting/notifiers/googlechat.go | 215 ++++++++++++++++++ .../alerting/notifiers/googlechat_test.go | 53 +++++ 2 files changed, 268 insertions(+) create mode 100644 pkg/services/alerting/notifiers/googlechat.go create mode 100644 pkg/services/alerting/notifiers/googlechat_test.go diff --git a/pkg/services/alerting/notifiers/googlechat.go b/pkg/services/alerting/notifiers/googlechat.go new file mode 100644 index 00000000000..5d8fba1f8c6 --- /dev/null +++ b/pkg/services/alerting/notifiers/googlechat.go @@ -0,0 +1,215 @@ +package notifiers + +import ( + "encoding/json" + "fmt" + "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" + "time" +) + +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.Id, model.IsDefault, model.Name, model.Type, model.Settings), + Url: url, + log: log.New("alerting.notifier.googlechat"), + }, nil +} + +type GoogleChatNotifier struct { + NotifierBase + Url string + method 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") + }) + + }) + }) +} From 481dcb321d6037a28e704f15dcb2acd956558ebb Mon Sep 17 00:00:00 2001 From: Patrick Schuster Date: Fri, 16 Nov 2018 00:30:51 +0100 Subject: [PATCH 02/27] Update ReadMe. --- docs/sources/alerting/notifications.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/sources/alerting/notifications.md b/docs/sources/alerting/notifications.md index fe57fd0fa8f..40ef0a818f1 100644 --- a/docs/sources/alerting/notifications.md +++ b/docs/sources/alerting/notifications.md @@ -128,6 +128,10 @@ 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. +### Google Hangouts Chat + +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 @@ -137,6 +141,7 @@ 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 From ad33cd5c5c59bf289c60acd20362a57c61f36f54 Mon Sep 17 00:00:00 2001 From: Marcus Efraimsson Date: Mon, 3 Dec 2018 18:06:37 +0100 Subject: [PATCH 03/27] fix time regions using zero hours --- .../graph/specs/time_region_manager.test.ts | 27 +++++++++++++++++++ .../panel/graph/time_region_manager.ts | 20 +++++++------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/public/app/plugins/panel/graph/specs/time_region_manager.test.ts b/public/app/plugins/panel/graph/specs/time_region_manager.test.ts index 35e48897282..7ac9e658b83 100644 --- a/public/app/plugins/panel/graph/specs/time_region_manager.test.ts +++ b/public/app/plugins/panel/graph/specs/time_region_manager.test.ts @@ -130,6 +130,33 @@ describe('TimeRegionManager', () => { }); }); + plotOptionsScenario('for time from/to region', ctx => { + const regions = [{ from: '00:00', to: '05:00', fill: true, colorMode: 'red' }]; + const from = moment('2018-12-01T00:00+01:00'); + const to = moment('2018-12-03T23:59+01:00'); + ctx.setup(regions, from, to); + + it('should add 3 markings', () => { + expect(ctx.options.grid.markings.length).toBe(3); + }); + + it('should add one fill between 00:00 and 05:00 each day', () => { + const markings = ctx.options.grid.markings; + + expect(moment(markings[0].xaxis.from).format()).toBe(moment('2018-12-01T01:00:00+01:00').format()); + expect(moment(markings[0].xaxis.to).format()).toBe(moment('2018-12-01T06:00:00+01:00').format()); + expect(markings[0].color).toBe(colorModes.red.color.fill); + + expect(moment(markings[1].xaxis.from).format()).toBe(moment('2018-12-02T01:00:00+01:00').format()); + expect(moment(markings[1].xaxis.to).format()).toBe(moment('2018-12-02T06:00:00+01:00').format()); + expect(markings[1].color).toBe(colorModes.red.color.fill); + + expect(moment(markings[2].xaxis.from).format()).toBe(moment('2018-12-03T01:00:00+01:00').format()); + expect(moment(markings[2].xaxis.to).format()).toBe(moment('2018-12-03T06:00:00+01:00').format()); + expect(markings[2].color).toBe(colorModes.red.color.fill); + }); + }); + plotOptionsScenario('for day of week from/to region', ctx => { const regions = [{ fromDayOfWeek: 7, toDayOfWeek: 7, fill: true, colorMode: 'red' }]; const from = moment('2018-01-01T18:45:05+01:00'); diff --git a/public/app/plugins/panel/graph/time_region_manager.ts b/public/app/plugins/panel/graph/time_region_manager.ts index 95987e40dbe..0c9b94c9013 100644 --- a/public/app/plugins/panel/graph/time_region_manager.ts +++ b/public/app/plugins/panel/graph/time_region_manager.ts @@ -87,6 +87,14 @@ export class TimeRegionManager { continue; } + if (timeRegion.from && !timeRegion.to) { + timeRegion.to = timeRegion.from; + } + + if (!timeRegion.from && timeRegion.to) { + timeRegion.from = timeRegion.to; + } + hRange = { from: this.parseTimeRange(timeRegion.from), to: this.parseTimeRange(timeRegion.to), @@ -108,21 +116,13 @@ export class TimeRegionManager { hRange.to.dayOfWeek = Number(timeRegion.toDayOfWeek); } - if (!hRange.from.h && hRange.to.h) { - hRange.from = hRange.to; - } - - if (hRange.from.h && !hRange.to.h) { - hRange.to = hRange.from; - } - - if (hRange.from.dayOfWeek && !hRange.from.h && !hRange.from.m) { + if (hRange.from.dayOfWeek && hRange.from.h === null && hRange.from.m === null) { hRange.from.h = 0; hRange.from.m = 0; hRange.from.s = 0; } - if (hRange.to.dayOfWeek && !hRange.to.h && !hRange.to.m) { + if (hRange.to.dayOfWeek && hRange.to.h === null && hRange.to.m === null) { hRange.to.h = 23; hRange.to.m = 59; hRange.to.s = 59; From 80d62013b2fb1ccf40efb0f5b4efcc3fea32c0ad Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 7 Dec 2018 11:27:46 +0100 Subject: [PATCH 04/27] Remove Explore > "New tab" from sidebar - we don't support tabs yet, might as well remove the entry --- pkg/api/index.go | 3 --- 1 file changed, 3 deletions(-) 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"}, - }, }) } From a990b69baa470d30a87bc54b97ca36979f0f41a2 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 7 Dec 2018 12:41:47 +0100 Subject: [PATCH 05/27] Explore: Parse initial dates - parse dates passed from URL - keep everything local time for now --- public/app/features/explore/Explore.tsx | 4 ++-- public/app/features/explore/TimePicker.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index fb2f6759111..e281fa2ec7d 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 = { diff --git a/public/app/features/explore/TimePicker.tsx b/public/app/features/explore/TimePicker.tsx index 47c52b07292..b9484fb9d45 100644 --- a/public/app/features/explore/TimePicker.tsx +++ b/public/app/features/explore/TimePicker.tsx @@ -15,7 +15,7 @@ export const DEFAULT_RANGE = { * Return a human-editable string of either relative (inludes "now") or absolute local time (in the shape of DATE_FORMAT). * @param value Epoch or relative time */ -export function parseTime(value: string, isUtc = false): string { +export function parseTime(value: string | moment.Moment, isUtc = false): string | moment.Moment { if (moment.isMoment(value)) { return value; } From a90bba859a0993735a9e309f70ea09eb8cbb5abe Mon Sep 17 00:00:00 2001 From: bergquist Date: Fri, 7 Dec 2018 14:17:09 +0100 Subject: [PATCH 06/27] fixes merge error --- pkg/services/alerting/notifiers/googlechat.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/services/alerting/notifiers/googlechat.go b/pkg/services/alerting/notifiers/googlechat.go index 5d8fba1f8c6..5195a7d21bf 100644 --- a/pkg/services/alerting/notifiers/googlechat.go +++ b/pkg/services/alerting/notifiers/googlechat.go @@ -3,12 +3,13 @@ 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" - "time" ) func init() { @@ -35,7 +36,7 @@ func NewGoogleChatNotifier(model *m.AlertNotification) (alerting.Notifier, error } return &GoogleChatNotifier{ - NotifierBase: NewNotifierBase(model.Id, model.IsDefault, model.Name, model.Type, model.Settings), + NotifierBase: NewNotifierBase(model), Url: url, log: log.New("alerting.notifier.googlechat"), }, nil From 9eea58577309ed8d9af137f9e019d19f6507ce02 Mon Sep 17 00:00:00 2001 From: bergquist Date: Fri, 7 Dec 2018 14:39:44 +0100 Subject: [PATCH 07/27] removes unused code --- pkg/services/alerting/notifiers/googlechat.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/services/alerting/notifiers/googlechat.go b/pkg/services/alerting/notifiers/googlechat.go index 5195a7d21bf..1aba15a7928 100644 --- a/pkg/services/alerting/notifiers/googlechat.go +++ b/pkg/services/alerting/notifiers/googlechat.go @@ -44,9 +44,8 @@ func NewGoogleChatNotifier(model *m.AlertNotification) (alerting.Notifier, error type GoogleChatNotifier struct { NotifierBase - Url string - method string - log log.Logger + Url string + log log.Logger } /** From 2b5b16f5d300d189a9a05d30308c27c8ea06a3de Mon Sep 17 00:00:00 2001 From: bergquist Date: Fri, 7 Dec 2018 14:50:32 +0100 Subject: [PATCH 08/27] changelog: adds note about closing #11221 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab5263eba03..c1299304ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # 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) From 30c2cc4b5c223d688b1d00625b69e3ac5964a296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Fri, 7 Dec 2018 11:14:32 -0800 Subject: [PATCH 09/27] allow sidemenu sections without children still have a hover menu/header --- public/app/core/components/sidemenu/TopSectionItem.tsx | 2 +- .../sidemenu/__snapshots__/TopSectionItem.test.tsx.snap | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) 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`] = ` + `; From 62a5cd27ba9ba583e5bb6d25991a0472f2a904e3 Mon Sep 17 00:00:00 2001 From: Scott Glajch Date: Fri, 7 Dec 2018 16:42:29 -0500 Subject: [PATCH 10/27] Add the AWS/SES Cloudwatch metrics of BounceRate and ComplaintRate. Pull request #14399 --- pkg/tsdb/cloudwatch/metric_find_query.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"}, From 683b718e2b3416b0ae32a2e57dfa865e8d613dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Sat, 8 Dec 2018 09:45:02 -0800 Subject: [PATCH 11/27] align yellow collor with graph in logs table --- public/sass/components/_panel_logs.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/sass/components/_panel_logs.scss b/public/sass/components/_panel_logs.scss index 8220cfed878..ce7abcfb68a 100644 --- a/public/sass/components/_panel_logs.scss +++ b/public/sass/components/_panel_logs.scss @@ -131,7 +131,7 @@ $column-horizontal-spacing: 10px; &--warning, &--warn { &::after { - background-color: $warn; + background-color: $yellow; } } From 14cf846e735f838f7620b6b2833da37c294e874b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Sat, 8 Dec 2018 11:15:02 -0800 Subject: [PATCH 12/27] Fixed issue with logs graph and stacking --- public/app/core/logs_model.ts | 49 ++++++++++++++++++---------- public/app/features/explore/Logs.tsx | 21 ------------ 2 files changed, 32 insertions(+), 38 deletions(-) diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index 09f5bb3a916..e9a21831e7e 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -242,32 +242,47 @@ export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSerie // Graph time series by log level const seriesByLevel = {}; const bucketSize = intervalMs * 10; + const seriesList = []; for (const row of rows) { - if (!seriesByLevel[row.logLevel]) { - seriesByLevel[row.logLevel] = { lastTs: null, datapoints: [], alias: row.logLevel }; + 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]; - - // Bucket to nearest minute + // align time to bucket size const time = Math.round(row.timeEpochMs / bucketSize) * bucketSize; // 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; + } + + // 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 Object.keys(seriesByLevel).reduce((acc, level) => { - if (seriesByLevel[level]) { - const gs = new TimeSeries(seriesByLevel[level]); - gs.setColor(LogLevelColor[level]); - acc.push(gs); - } - 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/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 108d4f37a6e..e89424f2734 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -393,27 +393,6 @@ export default class Logs extends PureComponent { } } - // Grid options - // const cssColumnSizes = []; - // if (showDuplicates) { - // cssColumnSizes.push('max-content'); - // } - // // Log-level indicator line - // cssColumnSizes.push('3px'); - // if (showUtc) { - // cssColumnSizes.push('minmax(220px, max-content)'); - // } - // if (showLocalTime) { - // cssColumnSizes.push('minmax(140px, max-content)'); - // } - // if (showLabels) { - // cssColumnSizes.push('fit-content(20%)'); - // } - // cssColumnSizes.push('1fr'); - // const logEntriesStyle = { - // gridTemplateColumns: cssColumnSizes.join(' '), - // }; - const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...'; return ( From 2b3d366f7c29953b8e071962322881b680f760ae Mon Sep 17 00:00:00 2001 From: AJ West Date: Sat, 8 Dec 2018 16:53:24 -0500 Subject: [PATCH 13/27] Allow backslash escaping in custom variables --- public/app/features/templating/custom_variable.ts | 5 +++-- public/app/features/templating/partials/editor.html | 2 +- public/app/features/templating/specs/variable_srv.test.ts | 6 ++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/public/app/features/templating/custom_variable.ts b/public/app/features/templating/custom_variable.ts index bc946458705..2e12f77f947 100644 --- a/public/app/features/templating/custom_variable.ts +++ b/public/app/features/templating/custom_variable.ts @@ -38,8 +38,9 @@ export class CustomVariable implements Variable { } updateOptions() { - // extract options in comma separated string - this.options = _.map(this.query.split(/[,]+/), text => { + // extract options in comma separated string (use backslash to escape wanted commas) + this.options = _.map(this.query.split(/(? { + text = text.replace('\\,', ','); return { text: text.trim(), value: text.trim() }; }); diff --git a/public/app/features/templating/partials/editor.html b/public/app/features/templating/partials/editor.html index 15984eba7d6..78733b3b2be 100644 --- a/public/app/features/templating/partials/editor.html +++ b/public/app/features/templating/partials/editor.html @@ -151,7 +151,7 @@
Custom Options
Values separated by comma -
diff --git a/public/app/features/templating/specs/variable_srv.test.ts b/public/app/features/templating/specs/variable_srv.test.ts index 3df6ccb8b5b..47522073fb2 100644 --- a/public/app/features/templating/specs/variable_srv.test.ts +++ b/public/app/features/templating/specs/variable_srv.test.ts @@ -493,15 +493,17 @@ describe('VariableSrv', function(this: any) { scenario.setup(() => { scenario.variableModel = { type: 'custom', - query: 'hej, hop, asd', + query: 'hej, hop, asd, escaped\\,var', name: 'test', }; }); it('should update options array', () => { - expect(scenario.variable.options.length).toBe(3); + expect(scenario.variable.options.length).toBe(4); expect(scenario.variable.options[0].text).toBe('hej'); expect(scenario.variable.options[1].value).toBe('hop'); + expect(scenario.variable.options[2].value).toBe('asd'); + expect(scenario.variable.options[3].value).toBe('escaped,var'); }); }); From 6c95acc8379b328e3bc3c6e0b96b134c7c0afb12 Mon Sep 17 00:00:00 2001 From: AJ West Date: Sat, 8 Dec 2018 18:23:08 -0500 Subject: [PATCH 14/27] Switch to global match for full browser support of escaped custom vars --- public/app/features/templating/custom_variable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/templating/custom_variable.ts b/public/app/features/templating/custom_variable.ts index 2e12f77f947..c98048f04ca 100644 --- a/public/app/features/templating/custom_variable.ts +++ b/public/app/features/templating/custom_variable.ts @@ -39,7 +39,7 @@ export class CustomVariable implements Variable { updateOptions() { // extract options in comma separated string (use backslash to escape wanted commas) - this.options = _.map(this.query.split(/(? { + this.options = _.map(this.query.match(/(?:\\,|[^,])+/g), text => { text = text.replace('\\,', ','); return { text: text.trim(), value: text.trim() }; }); From 60ee4499e715f4dbfaaa1748df4aedf10ac353e6 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Sun, 9 Dec 2018 07:05:27 +0100 Subject: [PATCH 15/27] Explore: Fix timepicker inputs for absolute dates - Timepicker needs to keep from and to internally as strings for the fully controlled inputs - make sure from and to are strings when time is reset --- public/app/features/explore/TimePicker.tsx | 23 +++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/public/app/features/explore/TimePicker.tsx b/public/app/features/explore/TimePicker.tsx index b9484fb9d45..20dc76811e4 100644 --- a/public/app/features/explore/TimePicker.tsx +++ b/public/app/features/explore/TimePicker.tsx @@ -15,11 +15,14 @@ export const DEFAULT_RANGE = { * Return a human-editable string of either relative (inludes "now") or absolute local time (in the shape of DATE_FORMAT). * @param value Epoch or relative time */ -export function parseTime(value: string | moment.Moment, isUtc = false): string | moment.Moment { +export function parseTime(value: string | moment.Moment, isUtc = false, ensureString = false): string | moment.Moment { if (moment.isMoment(value)) { + if (ensureString) { + return value.format(DATE_FORMAT); + } return value; } - if (value.indexOf('now') !== -1) { + if ((value as string).indexOf('now') !== -1) { return value; } let time: any = value; @@ -50,6 +53,16 @@ interface TimePickerState { toRaw: string; } +/** + * TimePicker with dropdown menu for relative dates. + * + * Initialize with a range that is either based on relative time strings, + * or on Moment objects. + * Internally the component needs to keep a string representation in `fromRaw` + * and `toRaw` for the controlled inputs. + * When a time is picked, `onChangeTime` is called with the new range that + * is again based on relative time strings or Moment objects. + */ export default class TimePicker extends PureComponent { dropdownEl: any; @@ -75,9 +88,9 @@ export default class TimePicker extends PureComponent Date: Sun, 9 Dec 2018 11:42:35 +0100 Subject: [PATCH 16/27] Explore: Hide scanning again after result was found - when query result was found, stop scanning - this hides the scan status again --- public/app/features/explore/Explore.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 8a271994ab0..4a9c67a2338 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -675,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); @@ -712,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, }; From 487de2b832126060bb1001295765cd6dee3fdb34 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Sun, 9 Dec 2018 13:06:34 +0100 Subject: [PATCH 17/27] Explore: Logging dedup tooltips - use title attribute of toggle button group - add descriptions for all dedup options --- .../ToggleButtonGroup/ToggleButtonGroup.tsx | 12 ++++++++++-- public/app/core/logs_model.ts | 7 +++++++ public/app/features/explore/Logs.tsx | 2 ++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx index 1e9ae4732df..077d8772393 100644 --- a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx +++ b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx @@ -49,9 +49,17 @@ interface ToggleButtonProps { value: any; className?: string; children: ReactNode; + title?: string; } -export const ToggleButton: SFC = ({ children, selected, className = '', value, onChange }) => { +export const ToggleButton: SFC = ({ + children, + selected, + className = '', + title = null, + value, + onChange, +}) => { const handleChange = event => { event.stopPropagation(); if (onChange) { @@ -61,7 +69,7 @@ export const ToggleButton: SFC = ({ children, selected, class const btnClassName = `btn ${className} ${selected ? 'active' : ''}`; return ( - ); diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index 09f5bb3a916..934b950be72 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -88,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', diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 108d4f37a6e..7a426ef4b57 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -6,6 +6,7 @@ import classnames from 'classnames'; import * as rangeUtil from 'app/core/utils/rangeutil'; import { RawTimeRange } from 'app/types/series'; import { + LogsDedupDescription, LogsDedupStrategy, LogsModel, dedupLogRows, @@ -445,6 +446,7 @@ export default class Logs extends PureComponent { key={i} value={dedupType} onChange={onChange} + title={LogsDedupDescription[dedupType] || null} selected={selectedValue === dedupType} > {dedupType} From 61924e9130aaac2a0f7e4cce98d3354ec3cf21c2 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Sun, 9 Dec 2018 13:23:44 +0100 Subject: [PATCH 18/27] Explore: dont pass all rows to all rows, fixes profiler - react profiler seems to evaluate all props of all components down the tree - this becomes slow when 1000 rows are passed to 1000 rows and their labels - use getter function instead to ask for rows on demand --- public/app/features/explore/LogLabels.tsx | 17 +++++++++-------- public/app/features/explore/Logs.tsx | 16 ++++++++++------ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/public/app/features/explore/LogLabels.tsx b/public/app/features/explore/LogLabels.tsx index eb9c39050f6..8aa1789017e 100644 --- a/public/app/features/explore/LogLabels.tsx +++ b/public/app/features/explore/LogLabels.tsx @@ -69,7 +69,7 @@ export class Stats extends PureComponent<{ class Label extends PureComponent< { - allRows?: LogRow[]; + getRows?: () => LogRow[]; label: string; plain?: boolean; value: string; @@ -98,13 +98,14 @@ class Label extends PureComponent< if (state.showStats) { return { showStats: false, stats: null }; } - const stats = calculateLogsLabelStats(this.props.allRows, this.props.label); + const allRows = this.props.getRows(); + const stats = calculateLogsLabelStats(allRows, this.props.label); return { showStats: true, stats }; }); }; render() { - const { allRows, label, plain, value } = this.props; + const { getRows, label, plain, value } = this.props; const { showStats, stats } = this.state; const tooltip = `${label}: ${value}`; return ( @@ -115,12 +116,12 @@ class Label extends PureComponent< {!plain && ( )} - {!plain && allRows && } + {!plain && getRows && } {showStats && ( LogRow[]; labels: LogsStreamLabels; plain?: boolean; onClickLabel?: (label: string, value: string) => void; }> { render() { - const { allRows, labels, onClickLabel, plain } = this.props; + const { getRows, labels, onClickLabel, plain } = this.props; return Object.keys(labels).map(key => ( -