diff --git a/CHANGELOG.md b/CHANGELOG.md index ab5263eba03..294c5eb1834 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) @@ -7,6 +10,24 @@ * **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) * **Cloudwatch**: Fix invalid time range causes segmentation fault [#14150](https://github.com/grafana/grafana/issues/14150) 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/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/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/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/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/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/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 index be970119dd5..872f2f28bb8 100644 --- a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx +++ b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx @@ -1,4 +1,5 @@ import React, { SFC, ReactNode, PureComponent } from 'react'; +import Tooltip from 'app/core/components/Tooltip/Tooltip'; interface ToggleButtonGroupProps { label?: string; @@ -25,9 +26,17 @@ interface ToggleButtonProps { value: any; className?: string; children: ReactNode; + tooltip?: string; } -export const ToggleButton: SFC = ({ children, selected, className = '', value, onChange }) => { +export const ToggleButton: SFC = ({ + children, + selected, + className = '', + value = null, + tooltip, + onChange, +}) => { const handleChange = event => { event.stopPropagation(); if (onChange) { @@ -36,9 +45,15 @@ export const ToggleButton: SFC = ({ children, selected, class }; const btnClassName = `btn ${className} ${selected ? 'active' : ''}`; - return ( + const button = ( ); + + if (tooltip) { + return {button}; + } else { + return button; + } }; 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/logs_model.ts b/public/app/core/logs_model.ts index 09f5bb3a916..2ce8b42927f 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', @@ -242,32 +249,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/Explore.tsx b/public/app/features/explore/Explore.tsx index 4cd8b8f91e0..0d7d824b366 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -40,8 +40,8 @@ import Graph from './Graph'; import Logs from './Logs'; import Table from './Table'; import ErrorBoundary from './ErrorBoundary'; -import TimePicker from './TimePicker'; import { Alert } from './Error'; +import TimePicker, { parseTime } from './TimePicker'; interface ExploreProps { datasourceSrv: DatasourceSrv; @@ -119,7 +119,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 = { @@ -687,7 +687,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); @@ -724,15 +725,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, }; 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 => ( -