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/Animations/FadeIn.tsx b/public/app/core/components/Animations/FadeIn.tsx index 98eb3b682c0..e12f22486f1 100644 --- a/public/app/core/components/Animations/FadeIn.tsx +++ b/public/app/core/components/Animations/FadeIn.tsx @@ -5,6 +5,7 @@ interface Props { duration: number; children: JSX.Element; in: boolean; + unmountOnExit?: boolean; } export const FadeIn: SFC = props => { @@ -21,7 +22,7 @@ export const FadeIn: SFC = props => { }; return ( - + {state => (
({}), container: () => ({}), control: () => ({}), @@ -11,7 +11,9 @@ loadingIndicator: () => ({}), loadingMessage: () => ({}), menu: () => ({}), - menuList: () => ({}), + menuList: ({ maxHeight }: { maxHeight: number }) => ({ + maxHeight, + }), multiValue: () => ({}), multiValueLabel: () => ({}), multiValueRemove: () => ({}), diff --git a/public/app/core/components/Switch/Switch.tsx b/public/app/core/components/Switch/Switch.tsx index 46040d2307b..5cb7617c89c 100644 --- a/public/app/core/components/Switch/Switch.tsx +++ b/public/app/core/components/Switch/Switch.tsx @@ -6,6 +6,7 @@ export interface Props { checked: boolean; labelClass?: string; switchClass?: string; + transparent?: boolean; onChange: (event) => any; } @@ -24,11 +25,11 @@ export class Switch extends PureComponent { }; render() { - const { labelClass = '', switchClass = '', label, checked } = this.props; + const { labelClass = '', switchClass = '', label, checked, transparent } = this.props; const labelId = `check-${this.state.id}`; - const labelClassName = `gf-form-label ${labelClass} pointer`; - const switchClassName = `gf-form-switch ${switchClass}`; + const labelClassName = `gf-form-label ${labelClass} ${transparent ? 'gf-form-label--transparent' : ''} pointer`; + const switchClassName = `gf-form-switch ${switchClass} ${transparent ? 'gf-form-switch--transparent' : ''}`; return (
- + 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/dashboard/dashgrid/DataSourcePicker.tsx b/public/app/features/dashboard/dashgrid/DataSourcePicker.tsx index 9a3923a09f2..2c33474ee73 100644 --- a/public/app/features/dashboard/dashgrid/DataSourcePicker.tsx +++ b/public/app/features/dashboard/dashgrid/DataSourcePicker.tsx @@ -1,97 +1,124 @@ import React, { PureComponent } from 'react'; import classNames from 'classnames'; import _ from 'lodash'; - +import withKeyboardNavigation from './withKeyboardNavigation'; import { DataSourceSelectItem } from 'app/types'; -interface Props { +export interface Props { onChangeDataSource: (ds: any) => void; datasources: DataSourceSelectItem[]; + selected?: number; + onKeyDown?: (evt: any, maxSelectedIndex: number, onEnterAction: () => void) => void; + onMouseEnter?: (select: number) => void; } interface State { searchQuery: string; } -export class DataSourcePicker extends PureComponent { - searchInput: HTMLElement; +export const DataSourcePicker = withKeyboardNavigation( + class DataSourcePicker extends PureComponent { + searchInput: HTMLElement; - constructor(props) { - super(props); - this.state = { - searchQuery: '', - }; - } + constructor(props) { + super(props); + this.state = { + searchQuery: '', + }; + } - getDataSources() { - const { searchQuery } = this.state; - const regex = new RegExp(searchQuery, 'i'); - const { datasources } = this.props; + getDataSources() { + const { searchQuery } = this.state; + const regex = new RegExp(searchQuery, 'i'); + const { datasources } = this.props; - const filtered = datasources.filter(item => { - return regex.test(item.name) || regex.test(item.meta.name); - }); + const filtered = datasources.filter(item => { + return regex.test(item.name) || regex.test(item.meta.name); + }); - return filtered; - } + return filtered; + } - renderDataSource = (ds: DataSourceSelectItem, index: number) => { - const { onChangeDataSource } = this.props; - const onClick = () => onChangeDataSource(ds); - const cssClass = classNames({ - 'ds-picker-list__item': true, - }); + get maxSelectedIndex() { + const filtered = this.getDataSources(); + return filtered.length - 1; + } - return ( -
- -
{ds.name}
-
- ); - }; - - componentDidMount() { - setTimeout(() => { - this.searchInput.focus(); - }, 300); - } - - onSearchQueryChange = evt => { - const value = evt.target.value; - this.setState(prevState => ({ - ...prevState, - searchQuery: value, - })); - }; - - renderFilters() { - const { searchQuery } = this.state; - return ( - <> - - - ); - } - - render() { - return ( - <> -
- {this.renderFilters()} -
+ renderDataSource = (ds: DataSourceSelectItem, index: number) => { + const { onChangeDataSource, selected, onMouseEnter } = this.props; + const onClick = () => onChangeDataSource(ds); + const isSelected = selected === index; + const cssClass = classNames({ + 'ds-picker-list__item': true, + 'ds-picker-list__item--selected': isSelected, + }); + return ( +
onMouseEnter(index)} + > + +
{ds.name}
-
{this.getDataSources().map(this.renderDataSource)}
- - ); + ); + }; + + componentDidMount() { + setTimeout(() => { + this.searchInput.focus(); + }, 300); + } + + onSearchQueryChange = evt => { + const value = evt.target.value; + this.setState(prevState => ({ + ...prevState, + searchQuery: value, + })); + }; + + renderFilters() { + const { searchQuery } = this.state; + const { onKeyDown } = this.props; + return ( + <> + + + ); + } + + render() { + return ( + <> +
+ {this.renderFilters()} +
+
+
{this.getDataSources().map(this.renderDataSource)}
+ + ); + } } -} +); + +export default DataSourcePicker; diff --git a/public/app/features/dashboard/dashgrid/EditorTabBody.tsx b/public/app/features/dashboard/dashgrid/EditorTabBody.tsx index fcd677f92c0..d0ec87dd7bf 100644 --- a/public/app/features/dashboard/dashgrid/EditorTabBody.tsx +++ b/public/app/features/dashboard/dashgrid/EditorTabBody.tsx @@ -117,7 +117,7 @@ export class EditorTabBody extends PureComponent {
- +
{openView && this.renderOpenView(openView)}
diff --git a/public/app/features/dashboard/dashgrid/TimeRangeOptions.tsx b/public/app/features/dashboard/dashgrid/TimeRangeOptions.tsx index 8c6830cf1db..00371fa960b 100644 --- a/public/app/features/dashboard/dashgrid/TimeRangeOptions.tsx +++ b/public/app/features/dashboard/dashgrid/TimeRangeOptions.tsx @@ -1,4 +1,4 @@ -import React, { PureComponent } from 'react'; +import React, { PureComponent } from 'react'; import { Switch } from 'app/core/components/Switch/Switch'; import { Input } from 'app/core/components/Form'; import { isValidTimeSpan } from 'app/core/utils/rangeutil'; @@ -64,12 +64,7 @@ export class TimeRangeOptions extends PureComponent {
- - - - Override relative time - Last {
- - - Add time shift - Amount {
-
- - - -
diff --git a/public/app/features/dashboard/dashgrid/VizTypePicker.tsx b/public/app/features/dashboard/dashgrid/VizTypePicker.tsx index fc5e19a9d5c..eff51ada020 100644 --- a/public/app/features/dashboard/dashgrid/VizTypePicker.tsx +++ b/public/app/features/dashboard/dashgrid/VizTypePicker.tsx @@ -1,9 +1,9 @@ import React, { PureComponent } from 'react'; -import classNames from 'classnames'; import _ from 'lodash'; import config from 'app/core/config'; import { PanelPlugin } from 'app/types/plugins'; +import VizTypePickerPlugin from './VizTypePickerPlugin'; interface Props { current: PanelPlugin; @@ -12,6 +12,7 @@ interface Props { interface State { searchQuery: string; + selected: number; } export class VizTypePicker extends PureComponent { @@ -23,9 +24,50 @@ export class VizTypePicker extends PureComponent { this.state = { searchQuery: '', + selected: 0, }; } + get maxSelectedIndex() { + const filteredPluginList = this.getFilteredPluginList(); + return filteredPluginList.length - 1; + } + + goRight = () => { + const nextIndex = this.state.selected >= this.maxSelectedIndex ? 0 : this.state.selected + 1; + this.setState({ + selected: nextIndex, + }); + }; + + goLeft = () => { + const nextIndex = this.state.selected <= 0 ? this.maxSelectedIndex : this.state.selected - 1; + this.setState({ + selected: nextIndex, + }); + }; + + onKeyDown = evt => { + if (evt.key === 'ArrowDown') { + evt.preventDefault(); + this.goRight(); + } + if (evt.key === 'ArrowUp') { + evt.preventDefault(); + this.goLeft(); + } + if (evt.key === 'Enter') { + const filteredPluginList = this.getFilteredPluginList(); + this.props.onTypeChanged(filteredPluginList[this.state.selected]); + } + }; + + componentDidMount() { + setTimeout(() => { + this.searchInput.focus(); + }, 300); + } + getPanelPlugins(filter): PanelPlugin[] { const panels = _.chain(config.panels) .filter({ hideFromList: false }) @@ -36,25 +78,28 @@ export class VizTypePicker extends PureComponent { return _.sortBy(panels, 'sort'); } - renderVizPlugin = (plugin: PanelPlugin, index: number) => { - const cssClass = classNames({ - 'viz-picker__item': true, - 'viz-picker__item--selected': plugin.id === this.props.current.id, + onMouseEnter = (mouseEnterIndex: number) => { + this.setState({ + selected: mouseEnterIndex, }); - - return ( -
this.props.onTypeChanged(plugin)} title={plugin.name}> -
{plugin.name}
- -
- ); }; - componentDidMount() { - setTimeout(() => { - this.searchInput.focus(); - }, 300); - } + renderVizPlugin = (plugin: PanelPlugin, index: number) => { + const isSelected = this.state.selected === index; + const isCurrent = plugin.id === this.props.current.id; + return ( + { + this.onMouseEnter(index); + }} + onClick={() => this.props.onTypeChanged(plugin)} + /> + ); + }; getFilteredPluginList = (): PanelPlugin[] => { const { searchQuery } = this.state; @@ -73,6 +118,7 @@ export class VizTypePicker extends PureComponent { this.setState(prevState => ({ ...prevState, searchQuery: value, + selected: 0, })); }; @@ -86,6 +132,7 @@ export class VizTypePicker extends PureComponent { placeholder="" ref={elem => (this.searchInput = elem)} onChange={this.onSearchQueryChange} + onKeyDown={this.onKeyDown} /> @@ -102,7 +149,6 @@ export class VizTypePicker extends PureComponent { {this.renderFilters()}
-
{filteredPluginList.map(this.renderVizPlugin)}
); diff --git a/public/app/features/dashboard/dashgrid/VizTypePickerPlugin.tsx b/public/app/features/dashboard/dashgrid/VizTypePickerPlugin.tsx new file mode 100644 index 00000000000..d4ed96d1434 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/VizTypePickerPlugin.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import classNames from 'classnames'; +import { PanelPlugin } from 'app/types/plugins'; + +interface Props { + isSelected: boolean; + isCurrent: boolean; + plugin: PanelPlugin; + onClick: () => void; + onMouseEnter: () => void; +} + +const VizTypePickerPlugin = React.memo( + ({ isSelected, isCurrent, plugin, onClick, onMouseEnter }: Props) => { + const cssClass = classNames({ + 'viz-picker__item': true, + 'viz-picker__item--selected': isSelected, + 'viz-picker__item--current': isCurrent, + }); + + return ( +
+
{plugin.name}
+ +
+ ); + }, + (prevProps, nextProps) => { + if (prevProps.isSelected === nextProps.isSelected && prevProps.isCurrent === nextProps.isCurrent) { + return true; + } + return false; + } +); + +export default VizTypePickerPlugin; diff --git a/public/app/features/dashboard/dashgrid/withKeyboardNavigation.tsx b/public/app/features/dashboard/dashgrid/withKeyboardNavigation.tsx new file mode 100644 index 00000000000..58affdf0471 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/withKeyboardNavigation.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Props } from './DataSourcePicker'; + +interface State { + selected: number; +} + +const withKeyboardNavigation = WrappedComponent => { + return class extends React.Component { + constructor(props) { + super(props); + + this.state = { + selected: 0, + }; + } + + goToNext = (maxSelectedIndex: number) => { + const nextIndex = this.state.selected >= maxSelectedIndex ? 0 : this.state.selected + 1; + this.setState({ + selected: nextIndex, + }); + }; + + goToPrev = (maxSelectedIndex: number) => { + const nextIndex = this.state.selected <= 0 ? maxSelectedIndex : this.state.selected - 1; + this.setState({ + selected: nextIndex, + }); + }; + + onKeyDown = (evt: KeyboardEvent, maxSelectedIndex: number, onEnterAction: any) => { + if (evt.key === 'ArrowDown') { + evt.preventDefault(); + this.goToNext(maxSelectedIndex); + } + if (evt.key === 'ArrowUp') { + evt.preventDefault(); + this.goToPrev(maxSelectedIndex); + } + if (evt.key === 'Enter' && onEnterAction) { + onEnterAction(); + } + }; + + onMouseEnter = (mouseEnterIndex: number) => { + this.setState({ + selected: mouseEnterIndex, + }); + }; + + render() { + return ( + + ); + } + }; +}; + +export default withKeyboardNavigation; diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index b395468313f..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, }; @@ -913,6 +920,7 @@ export class Explore extends React.PureComponent { onChange={this.onChangeDatasource} options={exploreDatasources} styles={ResetStyles} + maxMenuHeight={500} placeholder="Select datasource" loadingMessage={() => 'Loading datasources...'} noOptionsMessage={() => 'No datasources found'} 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 => ( -