From 1aeafa34d1f0c87df960cf4a65c53f2fdaa97163 Mon Sep 17 00:00:00 2001 From: Dimitris Sotirakis Date: Mon, 16 Aug 2021 14:45:20 +0300 Subject: [PATCH 01/22] Elasticsearch: Fix metric names for alert queries (#37871) * Use props names as metrics names * Make aliases work --- pkg/tsdb/elasticsearch/response_parser.go | 30 +++++++++++---------- pkg/tsdb/elasticsearch/time_series_query.go | 4 +-- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/pkg/tsdb/elasticsearch/response_parser.go b/pkg/tsdb/elasticsearch/response_parser.go index 47ddbd86629..7fe796e946b 100644 --- a/pkg/tsdb/elasticsearch/response_parser.go +++ b/pkg/tsdb/elasticsearch/response_parser.go @@ -42,7 +42,7 @@ var newResponseParser = func(responses []*es.SearchResponse, targets []*Query, d } } -// nolint:staticcheck // plugins.DataResponse deprecated +// nolint:staticcheck func (rp *responseParser) getTimeSeries() (*backend.QueryDataResponse, error) { result := backend.QueryDataResponse{ Responses: backend.Responses{}, @@ -93,7 +93,7 @@ func (rp *responseParser) getTimeSeries() (*backend.QueryDataResponse, error) { return &result, nil } -// nolint:staticcheck // plugins.* deprecated +// nolint:staticcheck func (rp *responseParser) processBuckets(aggs map[string]interface{}, target *Query, queryResult *backend.DataResponse, props map[string]string, depth int) error { var err error @@ -172,7 +172,7 @@ func (rp *responseParser) processBuckets(aggs map[string]interface{}, target *Qu return nil } -// nolint:staticcheck,gocyclo // plugins.* deprecated +// nolint:staticcheck,gocyclo func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, query *backend.DataResponse, props map[string]string) error { frames := data.Frames{} @@ -203,7 +203,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, tags["metric"] = countType frames = append(frames, data.NewFrame(metric.Field, data.NewField("time", nil, timeVector), - data.NewField("value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: rp.getMetricName(tags["metric"]) + " " + metric.Field}))) + data.NewField("value", tags, values))) case percentilesType: buckets := esAggBuckets if len(buckets) == 0 { @@ -237,7 +237,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, } frames = append(frames, data.NewFrame(metric.Field, data.NewField("time", nil, timeVector), - data.NewField("value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: rp.getMetricName(tags["metric"]) + " " + metric.Field}))) + data.NewField("value", tags, values))) } case topMetricsType: buckets := esAggBuckets @@ -279,7 +279,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, frames = append(frames, data.NewFrame(metricField.(string), data.NewField("time", nil, timeVector), - data.NewField("value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: rp.getMetricName(tags["metric"]) + " " + metricField.(string)}), + data.NewField("value", tags, values), )) } @@ -326,7 +326,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, labels := tags frames = append(frames, data.NewFrame(metric.Field, data.NewField("time", nil, timeVector), - data.NewField("value", labels, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: rp.getMetricName(tags["metric"]) + " " + metric.Field}))) + data.NewField("value", labels, values))) } default: for k, v := range props { @@ -354,7 +354,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, } frames = append(frames, data.NewFrame(metric.Field, data.NewField("time", nil, timeVector), - data.NewField("value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: rp.getMetricName(tags["metric"]) + " " + metric.Field}))) + data.NewField("value", tags, values))) } } if query.Frames != nil { @@ -365,7 +365,7 @@ func (rp *responseParser) processMetrics(esAgg *simplejson.Json, target *Query, return nil } -// nolint:staticcheck // plugins.* deprecated +// nolint:staticcheck func (rp *responseParser) processAggregationDocs(esAgg *simplejson.Json, aggDef *BucketAgg, target *Query, queryResult *backend.DataResponse, props map[string]string) error { propKeys := make([]string, 0) @@ -517,8 +517,7 @@ func extractDataField(name string, v interface{}) *data.Field { } } -// TODO remove deprecations -// nolint:staticcheck // plugins.DataQueryResult deprecated +// nolint:staticcheck func (rp *responseParser) trimDatapoints(queryResult backend.DataResponse, target *Query) { var histogram *BucketAgg for _, bucketAgg := range target.BucketAggs { @@ -552,7 +551,7 @@ func (rp *responseParser) trimDatapoints(queryResult backend.DataResponse, targe } } -// nolint:staticcheck // plugins.DataQueryResult deprecated +// nolint:staticcheck func (rp *responseParser) nameFields(queryResult backend.DataResponse, target *Query) { set := make(map[string]struct{}) frames := queryResult.Frames @@ -568,12 +567,15 @@ func (rp *responseParser) nameFields(queryResult backend.DataResponse, target *Q metricTypeCount := len(set) for i := range frames { frames[i].Name = rp.getFieldName(*frames[i].Fields[1], target, metricTypeCount) + for _, field := range frames[i].Fields { + field.SetConfig(&data.FieldConfig{DisplayNameFromDS: rp.getFieldName(*frames[i].Fields[1], target, metricTypeCount)}) + } } } var aliasPatternRegex = regexp.MustCompile(`\{\{([\s\S]+?)\}\}`) -// nolint:staticcheck // plugins.* deprecated +// nolint:staticcheck func (rp *responseParser) getFieldName(dataField data.Field, target *Query, metricTypeCount int) string { metricType := dataField.Labels["metric"] metricName := rp.getMetricName(metricType) @@ -706,7 +708,7 @@ func findAgg(target *Query, aggID string) (*BucketAgg, error) { return nil, errors.New("can't found aggDef, aggID:" + aggID) } -// nolint:staticcheck // plugins.DataQueryResult deprecated +// nolint:staticcheck func getErrorFromElasticResponse(response *es.SearchResponse) string { var errorString string json := simplejson.NewFromAny(response.Error) diff --git a/pkg/tsdb/elasticsearch/time_series_query.go b/pkg/tsdb/elasticsearch/time_series_query.go index 6b7e7ea3f3e..4caf3cdde4d 100644 --- a/pkg/tsdb/elasticsearch/time_series_query.go +++ b/pkg/tsdb/elasticsearch/time_series_query.go @@ -28,7 +28,7 @@ var newTimeSeriesQuery = func(client es.Client, dataQuery []backend.DataQuery, } } -// nolint:staticcheck // plugins.DataQueryResult deprecated +// nolint:staticcheck func (e *timeSeriesQuery) execute() (*backend.QueryDataResponse, error) { tsQueryParser := newTimeSeriesQueryParser() queries, err := tsQueryParser.parse(e.dataQueries) @@ -63,7 +63,7 @@ func (e *timeSeriesQuery) execute() (*backend.QueryDataResponse, error) { return rp.getTimeSeries() } -// nolint:staticcheck // plugins.DataQueryResult deprecated +// nolint:staticcheck func (e *timeSeriesQuery) processQuery(q *Query, ms *es.MultiSearchRequestBuilder, from, to string, result backend.QueryDataResponse) error { minInterval, err := e.client.GetMinInterval(q.Interval) From d26f3cdd0330758dcc11462825fa85ad0da75d77 Mon Sep 17 00:00:00 2001 From: Olof Bourghardt Date: Mon, 16 Aug 2021 14:02:13 +0200 Subject: [PATCH 02/22] Loki: add support for resolution (#36710) * Add input to specify min step * Add stepInterval to as input to component * Add onBlur to Input component * Loki: add functionality for min step * Loki: change name on props to step to make it more clear * Loki: add resolution as a query option * Loki: Add min,max,exact as step options * Loki: add functionality for different step modes * Loki: fix bug where step function isn't working * Loki: fix bug where exact step isn't working * Loki: change width of step input field * Loki: add tests for adjustInterval function * Loki: add check for max step oprio to make sure it's not below the safe interval * Loki: fix bug with some tests * Loki: fix bug with tests * Explore: add tooltip to loki step function * Loki: remove resolution as a logs option * Loki: update snapshots * Fix failing tests * Loki: add select component for choosing resolution * Loki: add functionality for calculating correct interval with resolution applied * Loki: remove functionality for step mode * Loki: remove tests for step mode * Loki: add tooltip to line limit and resolution * Loki: add backend support for resolution * Loki: fixed backend bug where resolution was undefined * Loki: add check for resolution --- CHANGELOG.md | 5 +-- docs/sources/datasources/alertmanager.md | 2 +- docs/sources/http_api/access_control.md | 4 -- .../release-notes/release-notes-8-1-1.md | 3 +- pkg/tsdb/loki/loki.go | 8 +++- .../components/AnnotationsQueryEditor.tsx | 1 + .../components/LokiExploreQueryEditor.tsx | 1 + .../loki/components/LokiOptionFields.tsx | 37 ++++++++++++++++--- .../loki/components/LokiQueryEditor.tsx | 1 + .../LokiExploreQueryEditor.test.tsx.snap | 1 + .../LokiQueryEditor.test.tsx.snap | 2 + .../datasource/loki/datasource.test.ts | 36 ++++++++++++++---- .../app/plugins/datasource/loki/datasource.ts | 36 ++++++++++++++---- public/app/plugins/datasource/loki/types.ts | 1 + 14 files changed, 106 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9533c8acc16..673efaf4acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,11 @@ - # 8.1.1 (2021-08-09) ### Bug fixes -* **CloudWatch Logs:** Fix crash when no region is selected. [#37639](https://github.com/grafana/grafana/pull/37639), [@aocenas](https://github.com/aocenas) -* **Reporting:** Fix timezone parsing for scheduler (enterprise) +- **CloudWatch Logs:** Fix crash when no region is selected. [#37639](https://github.com/grafana/grafana/pull/37639), [@aocenas](https://github.com/aocenas) +- **Reporting:** Fix timezone parsing for scheduler (enterprise) diff --git a/docs/sources/datasources/alertmanager.md b/docs/sources/datasources/alertmanager.md index 4c181b91057..76ac065fff9 100644 --- a/docs/sources/datasources/alertmanager.md +++ b/docs/sources/datasources/alertmanager.md @@ -10,7 +10,7 @@ weight = 150 Grafana includes built-in support for Prometheus Alertmanager. It is presently in alpha and not accessible unless [alpha plugins are enabled in Grafana settings](https://grafana.com/docs/grafana/latest/administration/configuration/#enable_alpha). Once you add it as a data source, you can use the [Grafana alerting UI](https://grafana.com/docs/grafana/latest/alerting/) to manage silences, contact points as well as notification policies. A drop down option in these pages allows you to switch between Grafana and any configured Alertmanager data sources . -> **Note:** Currently, the [Cortex implementation of Prometheus Alertmanager](https://cortexmetrics.io/docs/proposals/scalable-alertmanager/) is required to edit rules. +> **Note:** Currently, the [Cortex implementation of Prometheus Alertmanager](https://cortexmetrics.io/docs/proposals/scalable-alertmanager/) is required to edit rules. ## Provision the Alertmanager data source diff --git a/docs/sources/http_api/access_control.md b/docs/sources/http_api/access_control.md index 3937fb72294..a69718203a0 100644 --- a/docs/sources/http_api/access_control.md +++ b/docs/sources/http_api/access_control.md @@ -26,7 +26,6 @@ Returns an indicator to check if fine-grained access control is enabled or not. | -------------------- | ---------------------- | | status:accesscontrol | services:accesscontrol | - #### Example request ```http @@ -256,7 +255,6 @@ Content-Type: application/json; charset=UTF-8 #### Status codes - | Code | Description | | ---- | ---------------------------------------------------------------------------------- | | 200 | Role is updated. | @@ -279,7 +277,6 @@ For example, if a user does not have required permissions for creating users, th | ----------- | -------------------- | | roles:write | permissions:delegate | - #### Example request ```http @@ -377,7 +374,6 @@ For example, if a user does not have required permissions for creating users, th | ------------ | -------------------- | | roles:delete | permissions:delegate | - #### Example request ```http diff --git a/docs/sources/release-notes/release-notes-8-1-1.md b/docs/sources/release-notes/release-notes-8-1-1.md index b5e51b3cd23..fb62caa0064 100644 --- a/docs/sources/release-notes/release-notes-8-1-1.md +++ b/docs/sources/release-notes/release-notes-8-1-1.md @@ -10,5 +10,4 @@ list = false ### Bug fixes -* **CloudWatch Logs:** Fix crash when no region is selected. [#37639](https://github.com/grafana/grafana/pull/37639), [@aocenas](https://github.com/aocenas) - +- **CloudWatch Logs:** Fix crash when no region is selected. [#37639](https://github.com/grafana/grafana/pull/37639), [@aocenas](https://github.com/aocenas) diff --git a/pkg/tsdb/loki/loki.go b/pkg/tsdb/loki/loki.go index f374d484461..a2600fd80af 100644 --- a/pkg/tsdb/loki/loki.go +++ b/pkg/tsdb/loki/loki.go @@ -56,6 +56,7 @@ type ResponseModel struct { LegendFormat string `json:"legendFormat"` Interval string `json:"interval"` IntervalMS int `json:"intervalMS"` + Resolution int64 `json:"resolution"` } func init() { @@ -210,7 +211,12 @@ func (s *Service) parseQuery(dsInfo *datasourceInfo, queryContext *backend.Query return nil, err } - step := time.Duration(int64(interval.Value)) + var resolution int64 = 1 + if model.Resolution >= 1 && model.Resolution <= 5 || model.Resolution == 10 { + resolution = model.Resolution + } + + step := time.Duration(int64(interval.Value) * resolution) qs = append(qs, &lokiQuery{ Expr: model.Expr, diff --git a/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx b/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx index 824c197b367..467d566efa3 100644 --- a/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx +++ b/public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx @@ -36,6 +36,7 @@ export const LokiAnnotationsQueryEditor = memo(function LokiAnnotationQueryEdito {}} onChange={onChange} diff --git a/public/app/plugins/datasource/loki/components/LokiExploreQueryEditor.tsx b/public/app/plugins/datasource/loki/components/LokiExploreQueryEditor.tsx index 76d98913690..640aac68b0a 100644 --- a/public/app/plugins/datasource/loki/components/LokiExploreQueryEditor.tsx +++ b/public/app/plugins/datasource/loki/components/LokiExploreQueryEditor.tsx @@ -27,6 +27,7 @@ export function LokiExploreQueryEditor(props: Props) { void; @@ -27,8 +29,20 @@ const queryTypeOptions: Array> = [ }, ]; +export const DEFAULT_RESOLUTION: SelectableValue = { + value: 1, + label: '1/1', +}; + +const RESOLUTION_OPTIONS: Array> = [DEFAULT_RESOLUTION].concat( + map([2, 3, 4, 5, 10], (value: number) => ({ + value, + label: '1/' + value, + })) +); + export function LokiOptionFields(props: LokiOptionFieldsProps) { - const { lineLimitValue, queryType, query, onRunQuery, runOnBlur, onChange } = props; + const { lineLimitValue, resolution, queryType, query, onRunQuery, runOnBlur, onChange } = props; function onChangeQueryLimit(value: string) { const nextQuery = { ...query, maxLines: preprocessMaxLines(value) }; @@ -71,6 +85,11 @@ export function LokiOptionFields(props: LokiOptionFieldsProps) { } } + function onResolutionChange(option: SelectableValue) { + const nextQuery = { ...query, resolution: option.value }; + onChange(nextQuery); + } + return (
{/*Query type field*/} @@ -108,7 +127,7 @@ export function LokiOptionFields(props: LokiOptionFieldsProps) { )} aria-label="Line limit field" > - + + + diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx index 3287560173c..2b2ed297435 100644 --- a/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.tsx @@ -1,10 +1,10 @@ import React, { memo, FC, useEffect } from 'react'; // Types -import { ExploreQueryFieldProps } from '@grafana/data'; +import { ExploreQueryFieldProps, SelectableValue } from '@grafana/data'; import { PrometheusDatasource } from '../datasource'; -import { PromQuery, PromOptions } from '../types'; +import { PromQuery, PromOptions, StepMode } from '../types'; import PromQueryField from './PromQueryField'; import { PromExploreExtraField } from './PromExploreExtraField'; @@ -26,7 +26,19 @@ export const PromExploreQueryEditor: FC = (props: Props) => { onChange(nextQuery); } - function onStepChange(e: React.SyntheticEvent) { + function onChangeStepMode(mode: StepMode) { + const { query, onChange } = props; + const nextQuery = { ...query, stepMode: mode }; + onChange(nextQuery); + } + + function onStepModeChange(option: SelectableValue) { + if (option.value) { + onChangeStepMode(option.value); + } + } + + function onStepIntervalChange(e: React.SyntheticEvent) { if (e.currentTarget.value !== query.interval) { onChangeQueryStep(e.currentTarget.value); } @@ -66,8 +78,10 @@ export const PromExploreQueryEditor: FC = (props: Props) => { // Select "both" as default option when Explore is opened. In legacy requests, range and instant can be undefined. In this case, we want to run queries with "both". queryType={query.range === query.instant ? 'both' : query.instant ? 'instant' : 'range'} stepValue={query.interval || ''} + stepMode={query.stepMode || 'min'} onQueryTypeChange={onQueryTypeChange} - onStepChange={onStepChange} + onStepModeChange={onStepModeChange} + onStepIntervalChange={onStepIntervalChange} onKeyDownFunc={onReturnKeyDown} query={query} onChange={onChange} diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx index 7acc6d59acb..9a7d4872708 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryEditor.tsx @@ -29,7 +29,7 @@ export const DEFAULT_STEP_MODE: SelectableValue = { label: 'Minimum', }; -const STEP_MODES: Array> = [ +export const STEP_MODES: Array> = [ DEFAULT_STEP_MODE, { value: 'max', diff --git a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap index 9f69a2cb974..16acb4e3cab 100644 --- a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap +++ b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap @@ -16,7 +16,8 @@ exports[`PromExploreQueryEditor should render component 1`] = ` onChange={[MockFunction]} onKeyDownFunc={[Function]} onQueryTypeChange={[Function]} - onStepChange={[Function]} + onStepIntervalChange={[Function]} + onStepModeChange={[Function]} query={ Object { "expr": "", @@ -25,6 +26,7 @@ exports[`PromExploreQueryEditor should render component 1`] = ` } } queryType="both" + stepMode="min" stepValue="1s" /> } From 483c5977406b2ceb0c7a6798ca5b67a68e2b4b24 Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Tue, 17 Aug 2021 10:14:57 +0200 Subject: [PATCH 10/22] Storybook: Fix Graph with Tooltip story (#37937) --- .../components/Graph/Graph.story.internal.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/grafana-ui/src/components/Graph/Graph.story.internal.tsx b/packages/grafana-ui/src/components/Graph/Graph.story.internal.tsx index 77369a6c3ba..e90a6cc7ee5 100644 --- a/packages/grafana-ui/src/components/Graph/Graph.story.internal.tsx +++ b/packages/grafana-ui/src/components/Graph/Graph.story.internal.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { dateTime, ArrayVector, FieldType, GraphSeriesXY, FieldColorModeId } from '@grafana/data'; +import { dateTime, ArrayVector, FieldType, GraphSeriesXY, FieldColorModeId, getDisplayProcessor } from '@grafana/data'; import { Story } from '@storybook/react'; +import { useTheme2 } from '../../themes'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { VizTooltip, TooltipDisplayMode, VizTooltipContentProps } from '../VizTooltip'; import { JSONFormatter } from '../JSONFormatter/JSONFormatter'; @@ -108,9 +109,19 @@ export default { }, }; -export const WithTooltip: Story = ({ tooltipMode, ...args }) => { +export const WithTooltip: Story = ({ + tooltipMode, + series, + ...args +}) => { + const theme = useTheme2(); + const seriesWithDisplay = series.map((data) => ({ + ...data, + valueField: { ...data.valueField, display: getDisplayProcessor({ field: data.valueField, theme }) }, + })); + return ( - + ); From 8a2f63ee063496793b0d4771c257f27b19e76d2f Mon Sep 17 00:00:00 2001 From: Will Browne Date: Tue, 17 Aug 2021 12:06:33 +0200 Subject: [PATCH 11/22] Update Makefile (#37926) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index dbfe41812e7..6f4e32ae1f0 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ build-server: ## Build Grafana server. $(GO) run build.go build-server build-cli: ## Build Grafana CLI application. - @echo "build in CI environment" + @echo "build grafana-cli" $(GO) run build.go build-cli build-js: ## Build frontend assets. From 697ac937c6f8cfaa09c31018baaa3b4c4407cb71 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Tue, 17 Aug 2021 12:16:34 +0200 Subject: [PATCH 12/22] fix with global config state (#37293) --- .../DataSourceSettings/DataSourceHttpSettings.tsx | 2 +- pkg/models/datasource_cache.go | 3 ++- pkg/models/datasource_cache_test.go | 6 ++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx index e5a89c9bb60..326305af2f9 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx @@ -268,7 +268,7 @@ export const DataSourceHttpSettings: React.FC = (props) => { )} - {dataSourceConfig.jsonData.sigV4Auth && } + {dataSourceConfig.jsonData.sigV4Auth && sigV4AuthToggleEnabled && } {(dataSourceConfig.jsonData.tlsAuth || dataSourceConfig.jsonData.tlsAuthWithCACert) && ( diff --git a/pkg/models/datasource_cache.go b/pkg/models/datasource_cache.go index c0709c4fc5c..3a793787dc1 100644 --- a/pkg/models/datasource_cache.go +++ b/pkg/models/datasource_cache.go @@ -11,6 +11,7 @@ import ( sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/httpclient" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" ) @@ -139,7 +140,7 @@ func (ds *DataSource) HTTPClientOptions() (*sdkhttpclient.Options, error) { } } - if ds.JsonData != nil && ds.JsonData.Get("sigV4Auth").MustBool(false) { + if ds.JsonData != nil && ds.JsonData.Get("sigV4Auth").MustBool(false) && setting.SigV4AuthEnabled { opts.SigV4 = &sdkhttpclient.SigV4Config{ Service: awsServiceNamespace(ds.Type), Region: ds.JsonData.Get("sigV4Region").MustString(), diff --git a/pkg/models/datasource_cache_test.go b/pkg/models/datasource_cache_test.go index fcb79e0d5fb..149b50bddf4 100644 --- a/pkg/models/datasource_cache_test.go +++ b/pkg/models/datasource_cache_test.go @@ -296,6 +296,12 @@ func TestDataSource_GetHttpTransport(t *testing.T) { }) clearDSProxyCache(t) + origSigV4Enabled := setting.SigV4AuthEnabled + setting.SigV4AuthEnabled = true + t.Cleanup(func() { + setting.SigV4AuthEnabled = origSigV4Enabled + }) + json, err := simplejson.NewJson([]byte(`{ "sigV4Auth": true }`)) require.NoError(t, err) From 368da73ac4b011f38d61cf016df6217aaa681fbd Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Tue, 17 Aug 2021 11:50:37 +0100 Subject: [PATCH 13/22] AzureMonitor: Fix crash from infinite render loop (#37924) --- .../components/MetricsQueryEditor/dataHooks.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts index 7a64ef556ad..7587ee5c4b2 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/dataHooks.ts @@ -69,10 +69,15 @@ export const useSubscriptions: DataHook = (query, datasource, onChange, setError ); useEffect(() => { - if (!subscription && defaultSubscription && hasOption(subscriptionOptions, defaultSubscription)) { - onChange(setSubscriptionID(query, defaultSubscription)); - } else if ((!subscription && subscriptionOptions.length) || subscriptionOptions.length === 1) { - onChange(setSubscriptionID(query, subscriptionOptions[0].value)); + // Return early if subscriptions havent loaded, or if the query already has a subscription + if (!subscriptionOptions.length || (subscription && hasOption(subscriptionOptions, subscription))) { + return; + } + + const defaultSub = defaultSubscription || subscriptionOptions[0].value; + + if (!subscription && defaultSub && hasOption(subscriptionOptions, defaultSub)) { + onChange(setSubscriptionID(query, defaultSub)); } }, [subscriptionOptions, query, subscription, defaultSubscription, onChange]); From 6aba5927416e0dd34d1edfb9a481c94cadd45272 Mon Sep 17 00:00:00 2001 From: sam boyer Date: Tue, 17 Aug 2021 07:11:57 -0400 Subject: [PATCH 14/22] Schema: get all devenv dashboards passing validation (#37857) * Strip nulls (again) * Add stripnulls script * Add transformations field * Close FieldConfig struct; proper plugin validating * s/graph/viz/ field in histogram dashboard * Use ui.GraphFieldConfig in histogram model * Add models for stat, gauge, barguage panel plugins Also toss necessary shared types into cue/ui/gen.cue, with TODOs to move them appropriately later. * Add required license headers * Heap of updates to cue UI components * Fix barchart types and one old devenv input * Use the GraphFieldConfig directly for timeseries * Add models.cue for a few panel plugins Barchart, state-timeline, and status-history * Enable the test validating all devenv dashboards!! * Fix effects of not checking after making comments * Update packages/grafana-ui/src/options/models.gen.ts Co-authored-by: Ryan McKinley * Realign and unalign cue with ts types * Update devenv test to sniff for null errors Best option we have right now for helping people to know they need to strip nulls from devenv dashboards. * Add speculative default for barchart stacking * Fixup some dated devenv dashboards timeline-modes needed to be regenerated with the appropriate tooltip values included, per typing requirements, and timeline-demo needed to have the `mode` field removed, as it is not intended to be persisted. * Add necessary missing options for various panels * Regenerate devenv dashboards Co-authored-by: Ryan McKinley --- cue/data/gen.cue | 17 ++- cue/ui/gen.cue | 115 +++++++++++++++--- .../panel-barchart/barchart-autosizing.json | 2 +- .../graph-ng-by-value-color-schemes.json | 36 ++---- .../panel-graph/graph-ng-nulls.json | 17 ++- .../panel-histogram/histogram_tests.json | 8 +- .../panel-timeline/timeline-demo.json | 41 +++++-- .../panel-timeline/timeline-modes.json | 23 +++- .../src/components/uPlot/models.cue | 46 ++++++- pkg/schema/load/load_test.go | 31 +++-- public/app/plugins/panel/barchart/models.cue | 47 +++++++ public/app/plugins/panel/bargauge/models.cue | 32 +++++ public/app/plugins/panel/gauge/models.cue | 32 +++++ public/app/plugins/panel/histogram/models.cue | 22 +++- public/app/plugins/panel/stat/models.cue | 34 ++++++ .../plugins/panel/state-timeline/models.cue | 47 +++++++ .../plugins/panel/status-history/models.cue | 42 +++++++ .../app/plugins/panel/timeseries/models.cue | 12 +- scripts/stripnulls.sh | 16 +++ 19 files changed, 521 insertions(+), 99 deletions(-) create mode 100644 public/app/plugins/panel/barchart/models.cue create mode 100644 public/app/plugins/panel/bargauge/models.cue create mode 100644 public/app/plugins/panel/gauge/models.cue create mode 100644 public/app/plugins/panel/stat/models.cue create mode 100644 public/app/plugins/panel/state-timeline/models.cue create mode 100644 public/app/plugins/panel/status-history/models.cue create mode 100755 scripts/stripnulls.sh diff --git a/cue/data/gen.cue b/cue/data/gen.cue index 60de8d603fa..6279bf407b6 100644 --- a/cue/data/gen.cue +++ b/cue/data/gen.cue @@ -113,6 +113,13 @@ Family: scuemata.#Family & { steps: [...#Threshold] } @cuetsy(targetType="interface") + // TODO docs + // FIXME this is extremely underspecfied; wasn't obvious which typescript types corresponded to it + #Transformation: { + id: string + options: {...} + } + // Schema for panel targets is specified by datasource // plugins. We use a placeholder definition, which the Go // schema loader either left open/as-is with the Base @@ -197,6 +204,8 @@ Family: scuemata.#Family & { // TODO docs timeRegions?: [...] + transformations: [...#Transformation] + // TODO docs // TODO tighter constraint interval?: string @@ -209,8 +218,10 @@ Family: scuemata.#Family & { // TODO tighter constraint timeShift?: string - // The values depend on panel type - options: {...} + // The allowable options are specified by the panel plugin's + // schema. + // FIXME same conundrum as with the closed validation for fieldConfig. + options: {} fieldConfig: { defaults: { @@ -282,7 +293,7 @@ Family: scuemata.#Family & { // Can always exist. Valid fields within this are // defined by the panel plugin - that's the // PanelFieldConfig that comes from the plugin. - custom?: {...} + custom?: {} } overrides: [...{ matcher: { diff --git a/cue/ui/gen.cue b/cue/ui/gen.cue index ebd849e93b9..7e1037003e7 100644 --- a/cue/ui/gen.cue +++ b/cue/ui/gen.cue @@ -1,21 +1,22 @@ package grafanaschema +// FIXME can't write enums as structs, must use disjunctions TableCellDisplayMode: { - Auto: "auto", - ColorText: "color-text", - ColorBackground: "color-background", - GradientGauge: "gradient-gauge", - LcdGauge: "lcd-gauge", - JSONView: "json-view", - BasicGauge: "basic", - Image: "image", + Auto: "auto", + ColorText: "color-text", + ColorBackground: "color-background", + GradientGauge: "gradient-gauge", + LcdGauge: "lcd-gauge", + JSONView: "json-view", + BasicGauge: "basic", + Image: "image", } @cuetsy(targetType="enum") TableFieldOptions: { - width?: number - align: FieldTextAlignment | *"auto" - displayMode: TableCellDisplayMode | *"auto" - hidden?: bool // ?? default is missing or false ?? + width?: number + align: FieldTextAlignment | *"auto" + displayMode: TableCellDisplayMode | *"auto" + hidden?: bool // ?? default is missing or false ?? } @cuetsy(targetType="interface") TableSortByFieldState: { @@ -31,6 +32,11 @@ DrawStyle: "line" | "bars" | "points" @c LineInterpolation: "linear" | "smooth" | "stepBefore" | "stepAfter" @cuetsy(targetType="enum") ScaleDistribution: "linear" | "log" @cuetsy(targetType="enum") GraphGradientMode: "none" | "opacity" | "hue" | "scheme" @cuetsy(targetType="enum") +StackingMode: "none" | "normal" | "percent" @cuetsy(targetType="enum") +BarValueVisibility: "auto" | "never" | "always" @cuetsy(targetType="enum") +BarAlignment: -1 | 0 | 1 @cuetsy(targetType="enum",memberNames="Before|Center|After") +ScaleOrientation: 0 | 1 @cuetsy(targetType="enum",memberNames="Horizontal|Vertical") +ScaleDirection: 1 | 1 | -1 | -1 @cuetsy(targetType="enum",memberNames="Up|Right|Down|Left") LineStyle: { fill?: "solid" | "dash" | "dot" | "square" dash?: [...number] @@ -42,6 +48,11 @@ LineConfig: { lineStyle?: LineStyle spanNulls?: bool | number } @cuetsy(targetType="interface") +BarConfig: { + barAlignment?: BarAlignment + barWidthFactor?: number + barMaxWidth?: number +} @cuetsy(targetType="interface") FillConfig: { fillColor?: string fillOpacity?: number @@ -70,6 +81,20 @@ HideSeriesConfig: { legend: bool viz: bool } @cuetsy(targetType="interface") +StackingConfig: { + mode?: StackingMode + group?: string +} @cuetsy(targetType="interface") +StackableFieldConfig: { + stacking?: StackingConfig +} @cuetsy(targetType="interface") +HideableFieldConfig: { + hideFrom?: HideSeriesConfig +} @cuetsy(targetType="interface") +GraphTresholdsStyleMode: "off" | "line" | "area" | "line+area" | "series" @cuetsy(targetType="enum",memberNames="Off|Line|Area|LineAndArea|Series") +GraphThresholdsStyleConfig: { + mode: GraphTresholdsStyleMode +} @cuetsy(targetType="interface") LegendPlacement: "bottom" | "right" @cuetsy(targetType="type") LegendDisplayMode: "list" | "table" | "hidden" @cuetsy(targetType="enum") TableFieldOptions: { @@ -78,10 +103,17 @@ TableFieldOptions: { displayMode: TableCellDisplayMode | *"auto" hidden?: bool } @cuetsy(targetType="interface") -GraphFieldConfig: LineConfig & FillConfig & PointsConfig & AxisConfig & { - drawStyle?: DrawStyle - gradientMode?: GraphGradientMode - hideFrom?: HideSeriesConfig +GraphFieldConfig: { + LineConfig + FillConfig + PointsConfig + AxisConfig + BarConfig + StackableFieldConfig + HideableFieldConfig + drawStyle?: DrawStyle + gradientMode?: GraphGradientMode + thresholdsStyle?: GraphThresholdsStyleConfig } @cuetsy(targetType="interface") VizLegendOptions: { displayMode: LegendDisplayMode @@ -93,3 +125,54 @@ VizLegendOptions: { VizTooltipOptions: { mode: TooltipDisplayMode } @cuetsy(targetType="interface") +// TODO copy back to appropriate place +SingleStatBaseOptions: { + OptionsWithTextFormatting + reduceOptions: ReduceDataOptions + orientation: VizOrientation +} @cuetsy(targetType="interface") +// TODO copy back to appropriate place +ReduceDataOptions: { + // If true show each row value + values?: bool + // if showing all values limit + limit?: number + // When !values, pick one value for the whole field + calcs: [...string] + // Which fields to show. By default this is only numeric fields + fields?: string +} @cuetsy(targetType="interface") +// TODO copy back to appropriate place +VizOrientation: "auto" | "vertical" | "horizontal" @cuetsy(targetType="enum") +// TODO copy back to appropriate place +OptionsWithTooltip: { + // FIXME this field is non-optional in the corresponding TS type + tooltip?: VizTooltipOptions +} @cuetsy(targetType="interface") +// TODO copy back to appropriate place +OptionsWithLegend: { + // FIXME this field is non-optional in the corresponding TS type + legend?: VizLegendOptions +} @cuetsy(targetType="interface") +// TODO copy back to appropriate place +OptionsWithTextFormatting: { + text?: VizTextDisplayOptions +} @cuetsy(targetType="interface") +// TODO copy back to appropriate place +VizTextDisplayOptions: { + // Explicit title text size + titleSize?: number + // Explicit value text size + valueSize?: number +} @cuetsy(targetType="interface") +// TODO copy back to appropriate place +BigValueColorMode: "value" | "background" | "none" @cuetsy(targetType="enum") +// TODO copy back to appropriate place +BigValueGraphMode: "none" | "line" | "area" @cuetsy(targetType="enum") +// TODO copy back to appropriate place +BigValueJustifyMode: "auto" | "center" @cuetsy(targetType="enum") +// TODO copy back to appropriate place +// TODO does cuetsy handle underscores the expected way? +BigValueTextMode: "auto" | "value" | "value_and_name" | "name" | "none" @cuetsy(targetType="enum") +// TODO copy back to appropriate place +BarGaugeDisplayMode: "basic" | "lcd" | "gradient" @cuetsy(targetType="enum") \ No newline at end of file diff --git a/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json b/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json index 8c90171d957..d65e63b22a9 100644 --- a/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json +++ b/devenv/dev-dashboards/panel-barchart/barchart-autosizing.json @@ -354,7 +354,7 @@ "orientation": "auto", "showValue": "auto", "text": { - "size": 10, + "titleSize": 10, "valueSize": 25 }, "tooltip": { diff --git a/devenv/dev-dashboards/panel-graph/graph-ng-by-value-color-schemes.json b/devenv/dev-dashboards/panel-graph/graph-ng-by-value-color-schemes.json index 65f44b9ba88..9afd53a1f27 100644 --- a/devenv/dev-dashboards/panel-graph/graph-ng-by-value-color-schemes.json +++ b/devenv/dev-dashboards/panel-graph/graph-ng-by-value-color-schemes.json @@ -19,12 +19,10 @@ ] }, "editable": true, - "gnetId": null, "graphTooltip": 0, "links": [], "panels": [ { - "datasource": null, "description": "", "fieldConfig": { "defaults": { @@ -66,8 +64,7 @@ "mode": "absolute", "steps": [ { - "color": "green", - "value": null + "color": "green" }, { "color": "orange", @@ -112,7 +109,6 @@ "type": "timeseries" }, { - "datasource": null, "description": "", "fieldConfig": { "defaults": { @@ -154,8 +150,7 @@ "mode": "absolute", "steps": [ { - "color": "green", - "value": null + "color": "green" }, { "color": "orange", @@ -200,7 +195,6 @@ "type": "timeseries" }, { - "datasource": null, "fieldConfig": { "defaults": { "color": { @@ -241,8 +235,7 @@ "mode": "absolute", "steps": [ { - "color": "green", - "value": null + "color": "green" }, { "color": "orange", @@ -328,8 +321,7 @@ "mode": "absolute", "steps": [ { - "color": "green", - "value": null + "color": "green" }, { "color": "orange", @@ -373,7 +365,6 @@ "type": "timeseries" }, { - "datasource": null, "fieldConfig": { "defaults": { "color": { @@ -414,8 +405,7 @@ "mode": "absolute", "steps": [ { - "color": "green", - "value": null + "color": "green" }, { "color": "orange", @@ -461,7 +451,6 @@ "startValue": 1 } ], - "timeFrom": null, "title": "Color bars by discrete thresholds", "type": "timeseries" }, @@ -507,8 +496,7 @@ "mode": "absolute", "steps": [ { - "color": "blue", - "value": null + "color": "blue" }, { "color": "green", @@ -597,8 +585,7 @@ "mode": "absolute", "steps": [ { - "color": "blue", - "value": null + "color": "blue" }, { "color": "green", @@ -687,8 +674,7 @@ "mode": "absolute", "steps": [ { - "color": "blue", - "value": null + "color": "blue" }, { "color": "green", @@ -736,7 +722,6 @@ "type": "timeseries" }, { - "datasource": null, "fieldConfig": { "defaults": { "color": { @@ -777,8 +762,7 @@ "mode": "absolute", "steps": [ { - "color": "blue", - "value": null + "color": "blue" }, { "color": "green", @@ -859,4 +843,4 @@ "title": "Panel Tests - Graph NG - By value color schemes", "uid": "aBXrJ0R7z", "version": 11 -} \ No newline at end of file +} diff --git a/devenv/dev-dashboards/panel-graph/graph-ng-nulls.json b/devenv/dev-dashboards/panel-graph/graph-ng-nulls.json index 8e322fbf316..e6472aa80d4 100644 --- a/devenv/dev-dashboards/panel-graph/graph-ng-nulls.json +++ b/devenv/dev-dashboards/panel-graph/graph-ng-nulls.json @@ -8,6 +8,12 @@ "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, "type": "dashboard" } ] @@ -42,7 +48,6 @@ "fillOpacity": 0, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -149,7 +154,6 @@ "fillOpacity": 0, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -265,7 +269,6 @@ "fillOpacity": 0, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -400,7 +403,6 @@ "fillOpacity": 0, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -507,7 +509,6 @@ "fillOpacity": 0, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -623,7 +624,6 @@ "fillOpacity": 0, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -758,7 +758,6 @@ "fillOpacity": 10, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -902,7 +901,6 @@ "fillOpacity": 10, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -1045,7 +1043,6 @@ "fillOpacity": 10, "gradientMode": "none", "hideFrom": { - "viz": false, "legend": false, "tooltip": false, "viz": false @@ -1232,5 +1229,5 @@ "timezone": "", "title": "Panel Tests - Graph NG - Gaps and Connected", "uid": "8mmCAF1Mz", - "version": 12 + "version": 2 } diff --git a/devenv/dev-dashboards/panel-histogram/histogram_tests.json b/devenv/dev-dashboards/panel-histogram/histogram_tests.json index 370432cce46..cf5adbcf6e0 100644 --- a/devenv/dev-dashboards/panel-histogram/histogram_tests.json +++ b/devenv/dev-dashboards/panel-histogram/histogram_tests.json @@ -27,7 +27,7 @@ "fillOpacity": 80, "gradientMode": "none", "hideFrom": { - "graph": false, + "viz": false, "legend": false, "tooltip": false }, @@ -86,7 +86,7 @@ "fillOpacity": 80, "gradientMode": "none", "hideFrom": { - "graph": false, + "viz": false, "legend": false, "tooltip": false }, @@ -144,7 +144,7 @@ "fillOpacity": 80, "gradientMode": "none", "hideFrom": { - "graph": false, + "viz": false, "legend": false, "tooltip": false }, @@ -215,7 +215,7 @@ "fillOpacity": 80, "gradientMode": "none", "hideFrom": { - "graph": false, + "viz": false, "legend": false, "tooltip": false }, diff --git a/devenv/dev-dashboards/panel-timeline/timeline-demo.json b/devenv/dev-dashboards/panel-timeline/timeline-demo.json index 0d6523350ad..6830dc069ce 100644 --- a/devenv/dev-dashboards/panel-timeline/timeline-demo.json +++ b/devenv/dev-dashboards/panel-timeline/timeline-demo.json @@ -8,6 +8,12 @@ "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, "type": "dashboard" } ] @@ -79,9 +85,12 @@ "displayMode": "list", "placement": "bottom" }, - "mode": "changes", + "mergeValues": true, "rowHeight": 0.98, - "showValue": "always" + "showValue": "always", + "tooltip": { + "mode": "single" + } }, "pluginVersion": "7.5.0-pre", "targets": [ @@ -168,9 +177,17 @@ "options": { "alignValue": "center", "colWidth": 1, + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "mergeValues": true, "mode": "changes", "rowHeight": 0.98, - "showValue": "always" + "showValue": "always", + "tooltip": { + "mode": "single" + } }, "targets": [ { @@ -261,9 +278,17 @@ "options": { "alignValue": "center", "colWidth": 1, + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "mergeValues": true, "mode": "changes", "rowHeight": 0.98, - "showValue": "always" + "showValue": "always", + "tooltip": { + "mode": "single" + } }, "targets": [ { @@ -339,9 +364,11 @@ "displayMode": "list", "placement": "bottom" }, - "mode": "samples", "rowHeight": 0.98, - "showValue": "always" + "showValue": "always", + "tooltip": { + "mode": "single" + } }, "pluginVersion": "7.5.0-pre", "targets": [ @@ -400,5 +427,5 @@ "timezone": "utc", "title": "Timeline Demo", "uid": "mIJjFy8Kz", - "version": 13 + "version": 3 } diff --git a/devenv/dev-dashboards/panel-timeline/timeline-modes.json b/devenv/dev-dashboards/panel-timeline/timeline-modes.json index cd613c34ccd..d76c7db9609 100644 --- a/devenv/dev-dashboards/panel-timeline/timeline-modes.json +++ b/devenv/dev-dashboards/panel-timeline/timeline-modes.json @@ -8,6 +8,12 @@ "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, "type": "dashboard" } ] @@ -60,7 +66,10 @@ }, "mergeValues": true, "rowHeight": 0.9, - "showValue": "always" + "showValue": "always", + "tooltip": { + "mode": "single" + } }, "pluginVersion": "7.5.0-pre", "targets": [ @@ -233,7 +242,10 @@ }, "mergeValues": true, "rowHeight": 0.9, - "showValue": "always" + "showValue": "always", + "tooltip": { + "mode": "single" + } }, "pluginVersion": "7.5.0-pre", "targets": [ @@ -305,7 +317,10 @@ "placement": "bottom" }, "rowHeight": 0.9, - "showValue": "always" + "showValue": "always", + "tooltip": { + "mode": "single" + } }, "pluginVersion": "7.5.0-pre", "targets": [ @@ -360,5 +375,5 @@ "timezone": "utc", "title": "Timeline Modes", "uid": "mIJjFy8Gz", - "version": 12 + "version": 13 } diff --git a/packages/grafana-ui/src/components/uPlot/models.cue b/packages/grafana-ui/src/components/uPlot/models.cue index e0913aa4235..5839ea927a0 100644 --- a/packages/grafana-ui/src/components/uPlot/models.cue +++ b/packages/grafana-ui/src/components/uPlot/models.cue @@ -6,6 +6,11 @@ DrawStyle: "line" | "bars" | "points" @cuetsy(targetType="enum") LineInterpolation: "linear" | "smooth" | "stepBefore" | "stepAfter" @cuetsy(targetType="enum") ScaleDistribution: "linear" | "log" | "ordinal" @cuetsy(targetType="enum") GraphGradientMode: "none" | "opacity" | "hue" | "scheme" @cuetsy(targetType="enum") +StackingMode: "none" | "normal" | "percent" @cuetsy(targetType="enum") +BarValueVisibility: "auto" | "never" | "always" @cuetsy(targetType="enum") +BarAlignment: -1 | 0 | 1 @cuetsy(targetType="enum",memberNames="Before|Center|After") +ScaleOrientation: 0 | 1 @cuetsy(targetType="enum",memberNames="Horizontal|Vertical") +ScaleDirection: 1 | 1 | -1 | -1 @cuetsy(targetType="enum",memberNames="Up|Right|Down|Left") LineStyle: { fill?: "solid" | "dash" | "dot" | "square" @@ -20,6 +25,12 @@ LineConfig: { spanNulls?: bool | number } @cuetsy(targetType="interface") +BarConfig: { + barAlignment?: BarAlignment + barWidthFactor?: number + barMaxWidth?: number +} @cuetsy(targetType="interface") + FillConfig: { fillColor?: string fillOpacity?: number @@ -53,11 +64,34 @@ HideSeriesConfig: { viz: bool } @cuetsy(targetType="interface") -// TODO This is the same composition as what's used in the timeseries panel's -// PanelFieldConfig. If that's the only place it's used, it probably shouldn't -// be assembled here, too -GraphFieldConfig: LineConfig & FillConfig & PointsConfig & AxisConfig & { - drawStyle?: DrawStyle - gradientMode?: GraphGradientMode +StackingConfig: { + mode?: StackingMode + group?: string +} @cuetsy(targetType="interface") + +StackableFieldConfig: { + stacking?: StackingConfig +} @cuetsy(targetType="interface") + +HideableFieldConfig: { hideFrom?: HideSeriesConfig } @cuetsy(targetType="interface") + +GraphTresholdsStyleMode: "off" | "line" | "area" | "line+area" | "series" @cuetsy(targetType="enum",memberNames="Off|Line|Area|LineAndArea|Series") + +GraphThresholdsStyleConfig: { + mode: GraphTresholdsStyleMode +} @cuetsy(targetType="interface") + +GraphFieldConfig: { + LineConfig + FillConfig + PointsConfig + AxisConfig + BarConfig + StackableFieldConfig + HideableFieldConfig + drawStyle?: DrawStyle + gradientMode?: GraphGradientMode + thresholdsStyle?: GraphThresholdsStyleConfig +} @cuetsy(targetType="interface") diff --git a/pkg/schema/load/load_test.go b/pkg/schema/load/load_test.go index b75acfe0331..16d57a13f7f 100644 --- a/pkg/schema/load/load_test.go +++ b/pkg/schema/load/load_test.go @@ -7,6 +7,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" "testing" "testing/fstest" @@ -49,17 +50,8 @@ func TestScuemataBasics(t *testing.T) { } func TestDevenvDashboardValidity(t *testing.T) { - // TODO un-skip when tests pass on all devenv dashboards - t.Skip() - // validdir := os.DirFS(filepath.Join("..", "..", "..", "devenv", "dev-dashboards")) validdir := filepath.Join("..", "..", "..", "devenv", "dev-dashboards") - dash, err := BaseDashboardFamily(p) - require.NoError(t, err, "error while loading base dashboard scuemata") - - ddash, err := DistDashboardFamily(p) - require.NoError(t, err, "error while loading dist dashboard scuemata") - doTest := func(sch schema.VersionedCueSchema) func(t *testing.T) { return func(t *testing.T) { t.Parallel() @@ -87,7 +79,9 @@ func TestDevenvDashboardValidity(t *testing.T) { return nil } else { if !(oldschemav.(float64) > 29) { - t.Logf("schemaVersion is %v, older than 30, skipping %s", oldschemav, path) + if testing.Verbose() { + t.Logf("schemaVersion is %v, older than 30, skipping %s", oldschemav, path) + } return nil } } @@ -96,7 +90,12 @@ func TestDevenvDashboardValidity(t *testing.T) { err := sch.Validate(schema.Resource{Value: byt, Name: path}) if err != nil { // Testify trims errors to short length. We want the full text - t.Fatal(errors.Details(err, nil)) + errstr := errors.Details(err, nil) + t.Log(errstr) + if strings.Contains(errstr, "null") { + t.Log("validation failure appears to involve nulls - see if scripts/stripnulls.sh has any effect?") + } + t.FailNow() } }) @@ -107,7 +106,15 @@ func TestDevenvDashboardValidity(t *testing.T) { // TODO will need to expand this appropriately when the scuemata contain // more than one schema - t.Run("base", doTest(dash)) + + // TODO disabled because base variant validation currently must fail in order for + // dist/instance validation to do closed validation of plugin-specified fields + // t.Run("base", doTest(dash)) + // dash, err := BaseDashboardFamily(p) + // require.NoError(t, err, "error while loading base dashboard scuemata") + + ddash, err := DistDashboardFamily(p) + require.NoError(t, err, "error while loading dist dashboard scuemata") t.Run("dist", doTest(ddash)) } diff --git a/public/app/plugins/panel/barchart/models.cue b/public/app/plugins/panel/barchart/models.cue new file mode 100644 index 00000000000..46d790d836b --- /dev/null +++ b/public/app/plugins/panel/barchart/models.cue @@ -0,0 +1,47 @@ +// Copyright 2021 Grafana Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grafanaschema + +import ( + ui "github.com/grafana/grafana/cue/ui:grafanaschema" +) + +Family: { + lineages: [ + [ + { + PanelOptions: { + ui.OptionsWithLegend + ui.OptionsWithTooltip + ui.OptionsWithTextFormatting + orientation: ui.VizOrientation + // TODO this default is a guess based on common devenv values + stacking: ui.StackingMode | *"none" + showValue: ui.BarValueVisibility + barWidth: number + groupWidth: number + } + PanelFieldConfig: { + ui.AxisConfig + ui.HideableFieldConfig + lineWidth?: number + fillOpacity?: number + gradientMode?: ui.GraphGradientMode + } + } + ] + ] + migrations: [] +} \ No newline at end of file diff --git a/public/app/plugins/panel/bargauge/models.cue b/public/app/plugins/panel/bargauge/models.cue new file mode 100644 index 00000000000..eadcf2534cc --- /dev/null +++ b/public/app/plugins/panel/bargauge/models.cue @@ -0,0 +1,32 @@ +// Copyright 2021 Grafana Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grafanaschema + +import ui "github.com/grafana/grafana/cue/ui:grafanaschema" + +Family: { + lineages: [ + [ + { + PanelOptions: { + ui.SingleStatBaseOptions + displayMode: ui.BarGaugeDisplayMode + showUnfilled: bool + } + } + ] + ] + migrations: [] +} \ No newline at end of file diff --git a/public/app/plugins/panel/gauge/models.cue b/public/app/plugins/panel/gauge/models.cue new file mode 100644 index 00000000000..8a7a3b8b37f --- /dev/null +++ b/public/app/plugins/panel/gauge/models.cue @@ -0,0 +1,32 @@ +// Copyright 2021 Grafana Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grafanaschema + +import ui "github.com/grafana/grafana/cue/ui:grafanaschema" + +Family: { + lineages: [ + [ + { + PanelOptions: { + ui.SingleStatBaseOptions + showThresholdLabels: bool + showThresholdMarkers: bool + } + } + ] + ] + migrations: [] +} \ No newline at end of file diff --git a/public/app/plugins/panel/histogram/models.cue b/public/app/plugins/panel/histogram/models.cue index ca9f4a2e1fd..4aa9f787946 100644 --- a/public/app/plugins/panel/histogram/models.cue +++ b/public/app/plugins/panel/histogram/models.cue @@ -1,16 +1,36 @@ +// Copyright 2021 Grafana Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package grafanaschema +import ui "github.com/grafana/grafana/cue/ui:grafanaschema" + Family: { lineages: [ [ { PanelOptions: { + ui.OptionsWithLegend + ui.OptionsWithTooltip bucketSize?: int bucketOffset: int | *0 combine?: bool } - // TODO: FieldConfig + PanelFieldConfig: { + ui.GraphFieldConfig + } } ] ] diff --git a/public/app/plugins/panel/stat/models.cue b/public/app/plugins/panel/stat/models.cue new file mode 100644 index 00000000000..26a2e91c5b2 --- /dev/null +++ b/public/app/plugins/panel/stat/models.cue @@ -0,0 +1,34 @@ +// Copyright 2021 Grafana Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grafanaschema + +import ui "github.com/grafana/grafana/cue/ui:grafanaschema" + +Family: { + lineages: [ + [ + { + PanelOptions: { + ui.SingleStatBaseOptions + graphMode: ui.BigValueGraphMode + colorMode: ui.BigValueColorMode + justifyMode: ui.BigValueJustifyMode + textMode: ui.BigValueTextMode + } + } + ] + ] + migrations: [] +} \ No newline at end of file diff --git a/public/app/plugins/panel/state-timeline/models.cue b/public/app/plugins/panel/state-timeline/models.cue new file mode 100644 index 00000000000..2ef3f6a6c0f --- /dev/null +++ b/public/app/plugins/panel/state-timeline/models.cue @@ -0,0 +1,47 @@ +// Copyright 2021 Grafana Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grafanaschema + +import ( + ui "github.com/grafana/grafana/cue/ui:grafanaschema" +) + +Family: { + lineages: [ + [ + { + #TimelineMode: "changes" | "samples" @cuetsy(targetType="enum") + #TimelineValueAlignment: "center" | "left" | "right" @cuetsy(targetType="type") + PanelOptions: { + // FIXME ts comments indicate this shouldn't be in the saved model, but currently is emitted + mode?: #TimelineMode + ui.OptionsWithLegend + ui.OptionsWithTooltip + showValue: ui.BarValueVisibility | *"auto" + rowHeight: number | *0.9 + colWidth?: number + mergeValues?: bool | *true + alignValue?: #TimelineValueAlignment | *"left" + } + PanelFieldConfig: { + ui.HideableFieldConfig + lineWidth?: number | *0 + fillOpacity?: number | *70 + } + } + ] + ] + migrations: [] +} \ No newline at end of file diff --git a/public/app/plugins/panel/status-history/models.cue b/public/app/plugins/panel/status-history/models.cue new file mode 100644 index 00000000000..3e703cd3350 --- /dev/null +++ b/public/app/plugins/panel/status-history/models.cue @@ -0,0 +1,42 @@ +// Copyright 2021 Grafana Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grafanaschema + +import ( + ui "github.com/grafana/grafana/cue/ui:grafanaschema" +) + +Family: { + lineages: [ + [ + { + PanelOptions: { + ui.OptionsWithLegend + ui.OptionsWithTooltip + showValue: ui.BarValueVisibility + rowHeight: number + colWidth?: number + alignValue: "center" | *"left" | "right" + } + PanelFieldConfig: { + ui.HideableFieldConfig + lineWidth?: number | *1 + fillOpacity?: number | *70 + } + } + ] + ] + migrations: [] +} \ No newline at end of file diff --git a/public/app/plugins/panel/timeseries/models.cue b/public/app/plugins/panel/timeseries/models.cue index 6fe9d4b43a3..816b21a29fe 100644 --- a/public/app/plugins/panel/timeseries/models.cue +++ b/public/app/plugins/panel/timeseries/models.cue @@ -23,18 +23,12 @@ Family: { [ { PanelOptions: { + // FIXME idk where this is coming from but various devenv dashes have it + graph?: {...} legend: ui.VizLegendOptions tooltip: ui.VizTooltipOptions } - PanelFieldConfig: { - ui.LineConfig - ui.FillConfig - ui.PointsConfig - ui.AxisConfig - drawStyle?: ui.DrawStyle - gradientMode?: ui.GraphGradientMode - hideFrom?: ui.HideSeriesConfig - } + PanelFieldConfig: ui.GraphFieldConfig } ] ] diff --git a/scripts/stripnulls.sh b/scripts/stripnulls.sh new file mode 100755 index 00000000000..cb6d2a98ff1 --- /dev/null +++ b/scripts/stripnulls.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Strip all null values from dashboards within devenv for some particular +# schema version. Must be run from Grafana root. + +# OSX users need to install GNU sed: `brew install gsed` +SED=$(command -v gsed) +SED=${SED:-"sed"} + +FILES=$(grep -rl '"schemaVersion": 3[01]' devenv) +set -e +set -x +for DASH in ${FILES}; do echo "${DASH}"; grep -v 'null,$' "${DASH}" > "${DASH}-nulless"; mv "${DASH}-nulless" "${DASH}"; done +for DASH in ${FILES}; do grep -v 'null$' "${DASH}" > "${DASH}-nulless"; mv "${DASH}-nulless" "${DASH}"; done +# shellcheck disable=SC2016,SC2002 +for DASH in ${FILES}; do cat "${DASH}" | $SED -E -n 'H; x; s:,(\s*\n\s*}):\1:; P; ${x; p}' | $SED '1 d' > "${DASH}-nulless"; mv "${DASH}-nulless" "${DASH}"; done From afabc617ed22003f8b1f260dbbbb24c6c46bc9b0 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Tue, 17 Aug 2021 13:03:18 +0100 Subject: [PATCH 15/22] AzureMonitor: Apply query migrations in QueryEditor (#37704) * move query migrations out of the angular controller * Migrate queries in QueryEditor * finish up migrations * update deprecated comment * remove comment --- package.json | 1 + .../components/QueryEditor/QueryEditor.tsx | 4 +- .../components/QueryEditor/useDefaultQuery.ts | 34 ---- .../QueryEditor/usePreparedQuery.ts | 36 ++++ .../datasource.ts | 33 +--- .../types/query.ts | 14 +- .../utils/migrateQuery.test.ts | 40 +++++ .../utils/migrateQuery.ts | 165 ++++++++++++++++++ 8 files changed, 262 insertions(+), 65 deletions(-) delete mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/useDefaultQuery.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/usePreparedQuery.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/migrateQuery.test.ts create mode 100644 public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/migrateQuery.ts diff --git a/package.json b/package.json index 1e8776c1167..e572c77d8b2 100644 --- a/package.json +++ b/package.json @@ -250,6 +250,7 @@ "dangerously-set-html-content": "1.0.6", "debounce-promise": "3.1.2", "eventemitter3": "4.0.0", + "fast-deep-equal": "^3.1.3", "fast-json-patch": "2.2.1", "fast-text-encoding": "^1.0.0", "file-saver": "2.0.2", diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx index dcbe6662d55..1063ca27703 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor.tsx @@ -18,7 +18,7 @@ import ApplicationInsightsEditor from '../ApplicationInsightsEditor'; import InsightsAnalyticsEditor from '../InsightsAnalyticsEditor'; import { Space } from '../Space'; import { debounce } from 'lodash'; -import useDefaultQuery from './useDefaultQuery'; +import usePreparedQuery from './usePreparedQuery'; export type AzureMonitorQueryEditorProps = QueryEditorProps< AzureMonitorDatasource, @@ -43,7 +43,7 @@ const QueryEditor: React.FC = ({ [onChange, onRunQuery] ); - const query = useDefaultQuery(baseQuery, onQueryChange); + const query = usePreparedQuery(baseQuery, onQueryChange); const subscriptionId = query.subscription || datasource.azureMonitorDatasource.defaultSubscriptionId; const variableOptionGroup = { diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/useDefaultQuery.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/useDefaultQuery.ts deleted file mode 100644 index 002c52068a9..00000000000 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/useDefaultQuery.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useEffect, useMemo } from 'react'; -import { AzureMonitorQuery, AzureQueryType } from '../../types'; - -const DEFAULT_QUERY_TYPE = AzureQueryType.AzureMonitor; - -const createQueryWithDefaults = (query: AzureMonitorQuery) => { - // A quick and easy way to set just the default query type. If we want to set any other defaults, - // we might want to look into something more robust - if (!query.queryType) { - return { - ...query, - queryType: query.queryType ?? DEFAULT_QUERY_TYPE, - }; - } - - return query; -}; - -/** - * Returns queries with some defaults, and calls onChange function to notify if it changes - */ -const useDefaultQuery = (query: AzureMonitorQuery, onChangeQuery: (newQuery: AzureMonitorQuery) => void) => { - const queryWithDefaults = useMemo(() => createQueryWithDefaults(query), [query]); - - useEffect(() => { - if (queryWithDefaults !== query) { - onChangeQuery(queryWithDefaults); - } - }, [queryWithDefaults, query, onChangeQuery]); - - return queryWithDefaults; -}; - -export default useDefaultQuery; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/usePreparedQuery.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/usePreparedQuery.ts new file mode 100644 index 00000000000..4a33d06a5de --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/usePreparedQuery.ts @@ -0,0 +1,36 @@ +import { useEffect, useMemo } from 'react'; +import { defaults } from 'lodash'; +import { AzureMonitorQuery, AzureQueryType } from '../../types'; +import deepEqual from 'fast-deep-equal'; +import migrateQuery from '../../utils/migrateQuery'; + +const DEFAULT_QUERY = { + queryType: AzureQueryType.AzureMonitor, +}; + +const prepareQuery = (query: AzureMonitorQuery) => { + // Note: _.defaults does not apply default values deeply. + const withDefaults = defaults({}, query, DEFAULT_QUERY); + const migratedQuery = migrateQuery(withDefaults); + + // If we didn't make any changes to the object, then return the original object to keep the + // identity the same, and not trigger any other useEffects or anything. + return deepEqual(migratedQuery, query) ? query : migratedQuery; +}; + +/** + * Returns queries with some defaults + migrations, and calls onChange function to notify if it changes + */ +const usePreparedQuery = (query: AzureMonitorQuery, onChangeQuery: (newQuery: AzureMonitorQuery) => void) => { + const preparedQuery = useMemo(() => prepareQuery(query), [query]); + + useEffect(() => { + if (preparedQuery !== query) { + onChangeQuery(preparedQuery); + } + }, [preparedQuery, query, onChangeQuery]); + + return preparedQuery; +}; + +export default usePreparedQuery; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts index 86041ce506c..64007ac8b5c 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/datasource.ts @@ -3,13 +3,7 @@ import AzureMonitorDatasource from './azure_monitor/azure_monitor_datasource'; import AppInsightsDatasource from './app_insights/app_insights_datasource'; import AzureLogAnalyticsDatasource from './azure_log_analytics/azure_log_analytics_datasource'; import ResourcePickerData from './resourcePicker/resourcePickerData'; -import { - AzureDataSourceJsonData, - AzureMonitorQuery, - AzureQueryType, - DatasourceValidationResult, - InsightsAnalyticsQuery, -} from './types'; +import { AzureDataSourceJsonData, AzureMonitorQuery, AzureQueryType, DatasourceValidationResult } from './types'; import { DataFrame, DataQueryRequest, @@ -22,7 +16,7 @@ import { import { forkJoin, Observable, of } from 'rxjs'; import { getTemplateSrv, TemplateSrv } from '@grafana/runtime'; import InsightsAnalyticsDatasource from './insights_analytics/insights_analytics_datasource'; -import { migrateMetricsDimensionFilters } from './query_ctrl'; +import { datasourceMigrations } from './utils/migrateQuery'; import { map } from 'rxjs/operators'; import AzureResourceGraphDatasource from './azure_resource_graph/azure_resource_graph_datasource'; import { getAzureCloud } from './credentials'; @@ -82,9 +76,9 @@ export default class Datasource extends DataSourceApi): Observable { const byType = new Map>(); - for (const target of options.targets) { - // Migrate old query structure - migrateQuery(target); + for (const baseTarget of options.targets) { + // Migrate old query structures + const target = datasourceMigrations(baseTarget); // Skip hidden or invalid queries or ones without properties if (!target.queryType || target.hide || !hasQueryForType(target)) { @@ -298,23 +292,6 @@ export default class Datasource extends DataSourceApi //the table to query (e.g. Usage, Heartbeat, Perf)\n| where $__timeFilter(TimeGenerated) //this is a macro used to show the full chart’s time range, choose the datetime column here\n| summarize count() by , bin(TimeGenerated, $__interval) //change “group by column” to a column in your table, such as “Computer”. The $__interval macro is used to auto-select the time grain. Can also use 1h, 5m etc.\n| order by TimeGenerated asc', + resultFormat: 'time_series', + workspace: 'e3fe4fde-ad5e-4d60-9974-e2f3562ffdf2', + }, + azureMonitor: { + aggregation: 'Average', + alias: '{{ dimensionvalue }}', + allowedTimeGrainsMs: [60000, 300000, 900000, 1800000, 3600000, 21600000, 43200000, 86400000], + dimensionFilters: [{ dimension: 'dependency/success', filter: '', operator: 'eq' }], + metricDefinition: 'microsoft.insights/components', + metricName: 'dependencies/duration', + metricNamespace: 'microsoft.insights/components', + resourceGroup: 'cloud-datasources', + resourceName: 'AppInsightsTestData', + timeGrain: 'PT5M', + top: '10', + }, + azureResourceGraph: { resultFormat: 'table' }, + insightsAnalytics: { query: '', resultFormat: 'time_series' }, + queryType: AzureQueryType.AzureMonitor, + refId: 'A', + subscription: '44693801-6ee6-49de-9b2d-9106972f9572', + subscriptions: ['44693801-6ee6-49de-9b2d-9106972f9572'], +}; + +describe('AzureMonitor: migrateQuery', () => { + it('modern queries should not change', () => { + const result = migrateQuery(modernMetricsQuery); + + // MUST use .toBe because we want to assert that the identity of unmigrated queries remains the same + expect(modernMetricsQuery).toBe(result); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/migrateQuery.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/migrateQuery.ts new file mode 100644 index 00000000000..f9f230467ca --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/migrateQuery.ts @@ -0,0 +1,165 @@ +import { AzureMonitorQuery, AzureQueryType } from '../types'; +import TimegrainConverter from '../time_grain_converter'; +import { + appendDimensionFilter, + setTimeGrain as setMetricsTimeGrain, +} from '../components/MetricsQueryEditor/setQueryValue'; +import { setKustoQuery } from '../components/LogsQueryEditor/setQueryValue'; + +const OLD_DEFAULT_DROPDOWN_VALUE = 'select'; + +export default function migrateQuery(query: AzureMonitorQuery): AzureMonitorQuery { + let workingQuery = query; + + // The old angular controller also had a `migrateApplicationInsightsKeys` migraiton that + // migrated old properties to other properties that still do not appear to be used anymore, so + // we decided to not include that migration anymore + // See https://github.com/grafana/grafana/blob/a6a09add/public/app/plugins/datasource/grafana-azure-monitor-datasource/query_ctrl.ts#L269-L288 + + workingQuery = migrateTimeGrains(workingQuery); + workingQuery = migrateLogAnalyticsToFromTimes(workingQuery); + workingQuery = migrateToDefaultNamespace(workingQuery); + workingQuery = migrateApplicationInsightsDimensions(workingQuery); + workingQuery = migrateMetricsDimensionFilters(workingQuery); + + return workingQuery; +} + +function migrateTimeGrains(query: AzureMonitorQuery): AzureMonitorQuery { + let workingQuery = query; + + if (workingQuery.azureMonitor?.timeGrainUnit && workingQuery.azureMonitor.timeGrain !== 'auto') { + const newTimeGrain = TimegrainConverter.createISO8601Duration( + workingQuery.azureMonitor.timeGrain ?? 'auto', + workingQuery.azureMonitor.timeGrainUnit + ); + workingQuery = setMetricsTimeGrain(workingQuery, newTimeGrain); + + delete workingQuery.azureMonitor?.timeGrainUnit; + } + + if (workingQuery.appInsights?.timeGrainUnit && workingQuery.appInsights.timeGrain !== 'auto') { + const appInsights = { + ...workingQuery.appInsights, + }; + + if (workingQuery.appInsights.timeGrainCount) { + appInsights.timeGrain = TimegrainConverter.createISO8601Duration( + workingQuery.appInsights.timeGrainCount, + workingQuery.appInsights.timeGrainUnit + ); + } else { + appInsights.timeGrainCount = workingQuery.appInsights.timeGrain; + + if (workingQuery.appInsights.timeGrain) { + appInsights.timeGrain = TimegrainConverter.createISO8601Duration( + workingQuery.appInsights.timeGrain, + workingQuery.appInsights.timeGrainUnit + ); + } + } + + workingQuery = { + ...workingQuery, + appInsights: appInsights, + }; + } + + return workingQuery; +} + +function migrateLogAnalyticsToFromTimes(query: AzureMonitorQuery): AzureMonitorQuery { + let workingQuery = query; + + if (workingQuery.azureLogAnalytics?.query?.match(/\$__from\s/gi)) { + workingQuery = setKustoQuery( + workingQuery, + workingQuery.azureLogAnalytics.query.replace(/\$__from\s/gi, '$__timeFrom() ') + ); + } + + if (workingQuery.azureLogAnalytics?.query?.match(/\$__to\s/gi)) { + workingQuery = setKustoQuery( + workingQuery, + workingQuery.azureLogAnalytics.query.replace(/\$__to\s/gi, '$__timeTo() ') + ); + } + + return workingQuery; +} + +function migrateToDefaultNamespace(query: AzureMonitorQuery): AzureMonitorQuery { + const haveMetricNamespace = + query.azureMonitor?.metricNamespace && query.azureMonitor.metricNamespace !== OLD_DEFAULT_DROPDOWN_VALUE; + + if (!haveMetricNamespace && query.azureMonitor?.metricDefinition) { + return { + ...query, + azureMonitor: { + ...query.azureMonitor, + metricNamespace: query.azureMonitor.metricDefinition, + }, + }; + } + + return query; +} + +function migrateApplicationInsightsDimensions(query: AzureMonitorQuery): AzureMonitorQuery { + const dimension = query?.appInsights?.dimension as unknown; + + if (dimension && typeof dimension === 'string') { + return { + ...query, + appInsights: { + ...query.appInsights, + dimension: [dimension], + }, + }; + } + + return query; +} + +// Exported because its also used directly in the datasource.ts for some reason +function migrateMetricsDimensionFilters(query: AzureMonitorQuery): AzureMonitorQuery { + let workingQuery = query; + + const oldDimension = workingQuery.azureMonitor?.dimension; + if (oldDimension && oldDimension !== 'None') { + workingQuery = appendDimensionFilter(workingQuery, oldDimension, 'eq', workingQuery.azureMonitor?.dimensionFilter); + } + + return workingQuery; +} + +// datasource.ts also contains some migrations, which have been moved to here. Unsure whether +// they should also do all the other migrations... +export function datasourceMigrations(query: AzureMonitorQuery): AzureMonitorQuery { + let workingQuery = query; + + if (workingQuery.queryType === AzureQueryType.ApplicationInsights && workingQuery.appInsights?.rawQuery) { + workingQuery = { + ...workingQuery, + queryType: AzureQueryType.InsightsAnalytics, + appInsights: undefined, + insightsAnalytics: { + query: workingQuery.appInsights.rawQuery, + resultFormat: 'time_series', + }, + }; + } + + if (!workingQuery.queryType) { + workingQuery = { + ...workingQuery, + queryType: AzureQueryType.AzureMonitor, + }; + } + + if (workingQuery.queryType === AzureQueryType.AzureMonitor && workingQuery.azureMonitor) { + workingQuery = migrateMetricsDimensionFilters(workingQuery); + } + + return workingQuery; +} From 3ca00f90b5d27a5e4939c42d97795cb9c2c4bcf7 Mon Sep 17 00:00:00 2001 From: George Robinson <85952834+gerobinson@users.noreply.github.com> Date: Tue, 17 Aug 2021 13:49:05 +0100 Subject: [PATCH 16/22] Contact point testing (#37308) This commit adds contact point testing to ngalerts via a new API endpoint. This endpoint accepts JSON containing a list of receiver configurations which are validated and then tested with a notification for a test alert. The endpoint returns JSON for each receiver with a status and error message. It accepts a configurable timeout via the Request-Timeout header (in seconds) up to a maximum of 30 seconds. --- pkg/services/ngalert/api/api.go | 5 + pkg/services/ngalert/api/api_alertmanager.go | 241 ++++++++++--- .../ngalert/api/api_alertmanager_test.go | 140 ++++++++ pkg/services/ngalert/api/forked_am.go | 9 + .../api/generated_base_api_alertmanager.go | 11 + pkg/services/ngalert/api/lotex_am.go | 4 + .../api/tooling/definitions/alertmanager.go | 116 ++++-- pkg/services/ngalert/api/tooling/spec.json | 129 ++++++- pkg/services/ngalert/notifier/alertmanager.go | 190 ++++++---- .../ngalert/notifier/channels/webhook.go | 3 + pkg/services/ngalert/notifier/receivers.go | 227 ++++++++++++ .../ngalert/notifier/receivers_test.go | 82 +++++ pkg/services/ngalert/notifier/status.go | 14 +- .../api_alertmanager_configuration_test.go | 2 +- .../alerting/api_notification_channel_test.go | 337 ++++++++++++++++++ 15 files changed, 1348 insertions(+), 162 deletions(-) create mode 100644 pkg/services/ngalert/api/api_alertmanager_test.go create mode 100644 pkg/services/ngalert/notifier/receivers.go create mode 100644 pkg/services/ngalert/notifier/receivers_test.go diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index c2240df38d5..7dbe015ec6f 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -1,6 +1,7 @@ package api import ( + "context" "net/url" "time" @@ -10,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasources" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/metrics" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/store" @@ -43,6 +45,9 @@ type Alertmanager interface { // Alerts GetAlerts(active, silenced, inhibited bool, filter []string, receiver string) (apimodels.GettableAlerts, error) GetAlertGroups(active, silenced, inhibited bool, filter []string, receiver string) (apimodels.AlertGroups, error) + + // Testing + TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigParams) (*notifier.TestReceiversResult, error) } // API handlers. diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index 14f2f9d414d..205582e963f 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -1,9 +1,13 @@ package api import ( + "context" "errors" "fmt" "net/http" + "strconv" + "strings" + "time" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" @@ -15,12 +19,79 @@ import ( "github.com/grafana/grafana/pkg/util" ) +const ( + defaultTestReceiversTimeout = 15 * time.Second + maxTestReceiversTimeout = 30 * time.Second +) + type AlertmanagerSrv struct { am Alertmanager store store.AlertingStore log log.Logger } +type UnknownReceiverError struct { + UID string +} + +func (e UnknownReceiverError) Error() string { + return fmt.Sprintf("unknown receiver: %s", e.UID) +} + +func (srv AlertmanagerSrv) loadSecureSettings(orgId int64, receivers []*apimodels.PostableApiReceiver) error { + // Get the last known working configuration + query := ngmodels.GetLatestAlertmanagerConfigurationQuery{OrgID: orgId} + if err := srv.store.GetLatestAlertmanagerConfiguration(&query); err != nil { + // If we don't have a configuration there's nothing for us to know and we should just continue saving the new one + if !errors.Is(err, store.ErrNoAlertmanagerConfiguration) { + return fmt.Errorf("failed to get latest configuration: %w", err) + } + } + + currentReceiverMap := make(map[string]*apimodels.PostableGrafanaReceiver) + if query.Result != nil { + currentConfig, err := notifier.Load([]byte(query.Result.AlertmanagerConfiguration)) + if err != nil { + return fmt.Errorf("failed to load latest configuration: %w", err) + } + currentReceiverMap = currentConfig.GetGrafanaReceiverMap() + } + + // Copy the previously known secure settings + for i, r := range receivers { + for j, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers { + if gr.UID == "" { // new receiver + continue + } + + cgmr, ok := currentReceiverMap[gr.UID] + if !ok { + // it tries to update a receiver that didn't previously exist + return UnknownReceiverError{UID: gr.UID} + } + + // frontend sends only the secure settings that have to be updated + // therefore we have to copy from the last configuration only those secure settings not included in the request + for key := range cgmr.SecureSettings { + _, ok := gr.SecureSettings[key] + if !ok { + decryptedValue, err := cgmr.GetDecryptedSecret(key) + if err != nil { + return fmt.Errorf("failed to decrypt stored secure setting: %s: %w", key, err) + } + + if receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings == nil { + receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings = make(map[string]string, len(cgmr.SecureSettings)) + } + + receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings[key] = decryptedValue + } + } + } + } + return nil +} + func (srv AlertmanagerSrv) RouteGetAMStatus(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, srv.am.GetStatus()) } @@ -210,46 +281,12 @@ func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *models.ReqContext, body ap } } - currentReceiverMap := make(map[string]*apimodels.PostableGrafanaReceiver) - if query.Result != nil { - currentConfig, err := notifier.Load([]byte(query.Result.AlertmanagerConfiguration)) - if err != nil { - return ErrResp(http.StatusInternalServerError, err, "failed to load lastest configuration") - } - currentReceiverMap = currentConfig.GetGrafanaReceiverMap() - } - - // Copy the previously known secure settings - for i, r := range body.AlertmanagerConfig.Receivers { - for j, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers { - if gr.UID == "" { // new receiver - continue - } - - cgmr, ok := currentReceiverMap[gr.UID] - if !ok { - // it tries to update a receiver that didn't previously exist - return ErrResp(http.StatusBadRequest, fmt.Errorf("unknown receiver: %s", gr.UID), "") - } - - // frontend sends only the secure settings that have to be updated - // therefore we have to copy from the last configuration only those secure settings not included in the request - for key := range cgmr.SecureSettings { - _, ok := body.AlertmanagerConfig.Receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings[key] - if !ok { - decryptedValue, err := cgmr.GetDecryptedSecret(key) - if err != nil { - return ErrResp(http.StatusInternalServerError, err, "failed to decrypt stored secure setting: %s", key) - } - - if body.AlertmanagerConfig.Receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings == nil { - body.AlertmanagerConfig.Receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings = make(map[string]string, len(cgmr.SecureSettings)) - } - - body.AlertmanagerConfig.Receivers[i].PostableGrafanaReceivers.GrafanaManagedReceivers[j].SecureSettings[key] = decryptedValue - } - } + if err := srv.loadSecureSettings(c.OrgId, body.AlertmanagerConfig.Receivers); err != nil { + var unknownReceiverError UnknownReceiverError + if errors.As(err, &unknownReceiverError) { + return ErrResp(http.StatusBadRequest, err, "") } + return ErrResp(http.StatusInternalServerError, err, "") } if err := body.ProcessConfig(); err != nil { @@ -265,6 +302,130 @@ func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *models.ReqContext, body ap } func (srv AlertmanagerSrv) RoutePostAMAlerts(c *models.ReqContext, body apimodels.PostableAlerts) response.Response { - // not implemented return NotImplementedResp } + +func (srv AlertmanagerSrv) RoutePostTestReceivers(c *models.ReqContext, body apimodels.TestReceiversConfigParams) response.Response { + if !c.HasUserRole(models.ROLE_EDITOR) { + return accessForbiddenResp() + } + + if err := srv.loadSecureSettings(c.OrgId, body.Receivers); err != nil { + var unknownReceiverError UnknownReceiverError + if errors.As(err, &unknownReceiverError) { + return ErrResp(http.StatusBadRequest, err, "") + } + return ErrResp(http.StatusInternalServerError, err, "") + } + + if err := body.ProcessConfig(); err != nil { + return ErrResp(http.StatusInternalServerError, err, "failed to post process Alertmanager configuration") + } + + ctx, cancelFunc, err := contextWithTimeoutFromRequest( + c.Req.Context(), + c.Req.Request, + defaultTestReceiversTimeout, + maxTestReceiversTimeout) + if err != nil { + return ErrResp(http.StatusBadRequest, err, "") + } + defer cancelFunc() + + result, err := srv.am.TestReceivers(ctx, body) + if err != nil { + if errors.Is(err, notifier.ErrNoReceivers) { + return response.Error(http.StatusBadRequest, "", err) + } + return response.Error(http.StatusInternalServerError, "", err) + } + + return response.JSON(statusForTestReceivers(result.Receivers), newTestReceiversResult(result)) +} + +// contextWithTimeoutFromRequest returns a context with a deadline set from the +// Request-Timeout header in the HTTP request. If the header is absent then the +// context will use the default timeout. The timeout in the Request-Timeout +// header cannot exceed the maximum timeout. +func contextWithTimeoutFromRequest(ctx context.Context, r *http.Request, defaultTimeout, maxTimeout time.Duration) (context.Context, context.CancelFunc, error) { + timeout := defaultTimeout + if s := strings.TrimSpace(r.Header.Get("Request-Timeout")); s != "" { + // the timeout is measured in seconds + v, err := strconv.ParseInt(s, 10, 16) + if err != nil { + return nil, nil, err + } + if d := time.Duration(v) * time.Second; d < maxTimeout { + timeout = d + } else { + return nil, nil, fmt.Errorf("exceeded maximum timeout of %d seconds", maxTimeout) + } + } + ctx, cancelFunc := context.WithTimeout(ctx, timeout) + return ctx, cancelFunc, nil +} + +func newTestReceiversResult(r *notifier.TestReceiversResult) apimodels.TestReceiversResult { + v := apimodels.TestReceiversResult{ + Receivers: make([]apimodels.TestReceiverResult, len(r.Receivers)), + NotifedAt: r.NotifedAt, + } + for ix, next := range r.Receivers { + configs := make([]apimodels.TestReceiverConfigResult, len(next.Configs)) + for jx, config := range next.Configs { + configs[jx].Name = config.Name + configs[jx].UID = config.UID + configs[jx].Status = config.Status + if config.Error != nil { + configs[jx].Error = config.Error.Error() + } + } + v.Receivers[ix].Configs = configs + v.Receivers[ix].Name = next.Name + } + return v +} + +// statusForTestReceivers returns the appropriate status code for the response +// for the results. +// +// It returns an HTTP 200 OK status code if notifications were sent to all receivers, +// an HTTP 400 Bad Request status code if all receivers contain invalid configuration, +// an HTTP 408 Request Timeout status code if all receivers timed out when sending +// a test notification or an HTTP 207 Multi Status. +func statusForTestReceivers(v []notifier.TestReceiverResult) int { + var ( + numBadRequests int + numTimeouts int + numUnknownErrors int + ) + for _, receiver := range v { + for _, next := range receiver.Configs { + if next.Error != nil { + var ( + invalidReceiverErr notifier.InvalidReceiverError + receiverTimeoutErr notifier.ReceiverTimeoutError + ) + if errors.As(next.Error, &invalidReceiverErr) { + numBadRequests += 1 + } else if errors.As(next.Error, &receiverTimeoutErr) { + numTimeouts += 1 + } else { + numUnknownErrors += 1 + } + } + } + } + if numBadRequests == len(v) { + // if all receivers contain invalid configuration + return http.StatusBadRequest + } else if numTimeouts == len(v) { + // if all receivers contain valid configuration but timed out + return http.StatusRequestTimeout + } else if numBadRequests+numTimeouts+numUnknownErrors > 0 { + return http.StatusMultiStatus + } else { + // all receivers were sent a notification without error + return http.StatusOK + } +} diff --git a/pkg/services/ngalert/api/api_alertmanager_test.go b/pkg/services/ngalert/api/api_alertmanager_test.go new file mode 100644 index 00000000000..525e7ddb88e --- /dev/null +++ b/pkg/services/ngalert/api/api_alertmanager_test.go @@ -0,0 +1,140 @@ +package api + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/grafana/grafana/pkg/services/ngalert/notifier" + "github.com/stretchr/testify/require" +) + +func TestContextWithTimeoutFromRequest(t *testing.T) { + t.Run("assert context has default timeout when header is absent", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) + require.NoError(t, err) + + now := time.Now() + ctx := context.Background() + ctx, cancelFunc, err := contextWithTimeoutFromRequest( + ctx, + req, + 15*time.Second, + 30*time.Second) + require.NoError(t, err) + require.NotNil(t, cancelFunc) + require.NotNil(t, ctx) + + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.True(t, deadline.After(now)) + require.Less(t, deadline.Sub(now).Seconds(), 30.0) + require.GreaterOrEqual(t, deadline.Sub(now).Seconds(), 15.0) + }) + + t.Run("assert context has timeout in request header", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) + require.NoError(t, err) + req.Header.Set("Request-Timeout", "5") + + now := time.Now() + ctx := context.Background() + ctx, cancelFunc, err := contextWithTimeoutFromRequest( + ctx, + req, + 15*time.Second, + 30*time.Second) + require.NoError(t, err) + require.NotNil(t, cancelFunc) + require.NotNil(t, ctx) + + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.True(t, deadline.After(now)) + require.Less(t, deadline.Sub(now).Seconds(), 15.0) + require.GreaterOrEqual(t, deadline.Sub(now).Seconds(), 5.0) + }) + + t.Run("assert timeout in request header cannot exceed max timeout", func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "https://grafana.net", nil) + require.NoError(t, err) + req.Header.Set("Request-Timeout", "60") + + ctx := context.Background() + ctx, cancelFunc, err := contextWithTimeoutFromRequest( + ctx, + req, + 15*time.Second, + 30*time.Second) + require.Error(t, err, "exceeded maximum timeout") + require.Nil(t, cancelFunc) + require.Nil(t, ctx) + }) +} + +func TestStatusForTestReceivers(t *testing.T) { + t.Run("assert HTTP 400 Status Bad Request for no receivers", func(t *testing.T) { + require.Equal(t, http.StatusBadRequest, statusForTestReceivers([]notifier.TestReceiverResult{})) + }) + + t.Run("assert HTTP 400 Bad Request when all invalid receivers", func(t *testing.T) { + require.Equal(t, http.StatusBadRequest, statusForTestReceivers([]notifier.TestReceiverResult{{ + Name: "test1", + Configs: []notifier.TestReceiverConfigResult{{ + Name: "test1", + UID: "uid1", + Status: "failed", + Error: notifier.InvalidReceiverError{}, + }}, + }, { + Name: "test2", + Configs: []notifier.TestReceiverConfigResult{{ + Name: "test2", + UID: "uid2", + Status: "failed", + Error: notifier.InvalidReceiverError{}, + }}, + }})) + }) + + t.Run("assert HTTP 408 Request Timeout when all receivers timed out", func(t *testing.T) { + require.Equal(t, http.StatusRequestTimeout, statusForTestReceivers([]notifier.TestReceiverResult{{ + Name: "test1", + Configs: []notifier.TestReceiverConfigResult{{ + Name: "test1", + UID: "uid1", + Status: "failed", + Error: notifier.ReceiverTimeoutError{}, + }}, + }, { + Name: "test2", + Configs: []notifier.TestReceiverConfigResult{{ + Name: "test2", + UID: "uid2", + Status: "failed", + Error: notifier.ReceiverTimeoutError{}, + }}, + }})) + }) + + t.Run("assert 207 Multi Status for different errors", func(t *testing.T) { + require.Equal(t, http.StatusMultiStatus, statusForTestReceivers([]notifier.TestReceiverResult{{ + Name: "test1", + Configs: []notifier.TestReceiverConfigResult{{ + Name: "test1", + UID: "uid1", + Status: "failed", + Error: notifier.InvalidReceiverError{}, + }}, + }, { + Name: "test2", + Configs: []notifier.TestReceiverConfigResult{{ + Name: "test2", + UID: "uid2", + Status: "failed", + Error: notifier.ReceiverTimeoutError{}, + }}, + }})) + }) +} diff --git a/pkg/services/ngalert/api/forked_am.go b/pkg/services/ngalert/api/forked_am.go index 427125317a3..9936e1dcb63 100644 --- a/pkg/services/ngalert/api/forked_am.go +++ b/pkg/services/ngalert/api/forked_am.go @@ -146,3 +146,12 @@ func (am *ForkedAMSvc) RoutePostAMAlerts(ctx *models.ReqContext, body apimodels. return s.RoutePostAMAlerts(ctx, body) } + +func (am *ForkedAMSvc) RoutePostTestReceivers(ctx *models.ReqContext, body apimodels.TestReceiversConfigParams) response.Response { + s, err := am.getService(ctx) + if err != nil { + return ErrResp(400, err, "") + } + + return s.RoutePostTestReceivers(ctx, body) +} diff --git a/pkg/services/ngalert/api/generated_base_api_alertmanager.go b/pkg/services/ngalert/api/generated_base_api_alertmanager.go index f6d19b647f9..4b5ae0db470 100644 --- a/pkg/services/ngalert/api/generated_base_api_alertmanager.go +++ b/pkg/services/ngalert/api/generated_base_api_alertmanager.go @@ -31,6 +31,7 @@ type AlertmanagerApiService interface { RouteGetSilences(*models.ReqContext) response.Response RoutePostAMAlerts(*models.ReqContext, apimodels.PostableAlerts) response.Response RoutePostAlertingConfig(*models.ReqContext, apimodels.PostableUserConfig) response.Response + RoutePostTestReceivers(*models.ReqContext, apimodels.TestReceiversConfigParams) response.Response } func (api *API) RegisterAlertmanagerApiEndpoints(srv AlertmanagerApiService, m *metrics.Metrics) { @@ -137,5 +138,15 @@ func (api *API) RegisterAlertmanagerApiEndpoints(srv AlertmanagerApiService, m * m, ), ) + group.Post( + toMacaronPath("/api/alertmanager/{Recipient}/config/api/v1/receivers/test"), + binding.Bind(apimodels.TestReceiversConfigParams{}), + metrics.Instrument( + http.MethodPost, + "/api/alertmanager/{Recipient}/config/api/v1/receivers/test", + srv.RoutePostTestReceivers, + m, + ), + ) }, middleware.ReqSignedIn) } diff --git a/pkg/services/ngalert/api/lotex_am.go b/pkg/services/ngalert/api/lotex_am.go index e8e5fac185f..86501d0f561 100644 --- a/pkg/services/ngalert/api/lotex_am.go +++ b/pkg/services/ngalert/api/lotex_am.go @@ -192,3 +192,7 @@ func (am *LotexAM) RoutePostAMAlerts(ctx *models.ReqContext, alerts apimodels.Po nil, ) } + +func (am *LotexAM) RoutePostTestReceivers(ctx *models.ReqContext, config apimodels.TestReceiversConfigParams) response.Response { + return NotImplementedResp +} diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index 60da4024a45..74fa1491263 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "reflect" + "time" "github.com/go-openapi/strfmt" "github.com/pkg/errors" @@ -73,6 +74,17 @@ import ( // 200: alertGroups // 400: ValidationError +// swagger:route POST /api/alertmanager/{Recipient}/config/api/v1/receivers/test alertmanager RoutePostTestReceivers +// +// Test Grafana managed receivers without saving them. +// +// Responses: +// +// 200: Ack +// 207: MultiStatus +// 400: ValidationError +// 408: Failure + // swagger:route GET /api/alertmanager/{Recipient}/api/v2/silences alertmanager RouteGetSilences // // get silences @@ -105,6 +117,40 @@ import ( // 200: Ack // 400: ValidationError +// swagger:model +type TestReceiversConfig struct { + Receivers []*PostableApiReceiver `yaml:"receivers,omitempty" json:"receivers,omitempty"` +} + +// swagger:parameters RoutePostTestReceivers +type TestReceiversConfigParams struct { + Receivers []*PostableApiReceiver `yaml:"receivers,omitempty" json:"receivers,omitempty"` +} + +func (c *TestReceiversConfigParams) ProcessConfig() error { + return processReceiverConfigs(c.Receivers) +} + +// swagger:model +type TestReceiversResult struct { + Receivers []TestReceiverResult `json:"receivers"` + NotifedAt time.Time `json:"notified_at"` +} + +// swagger:model +type TestReceiverResult struct { + Name string `json:"name"` + Configs []TestReceiverConfigResult `json:"grafana_managed_receiver_configs"` +} + +// swagger:model +type TestReceiverConfigResult struct { + Name string `json:"name"` + UID string `json:"uid"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + // swagger:parameters RouteCreateSilence type CreateSilenceParams struct { // in:body @@ -345,39 +391,7 @@ func (c *PostableUserConfig) GetGrafanaReceiverMap() map[string]*PostableGrafana // ProcessConfig parses grafana receivers, encrypts secrets and assigns UUIDs (if they are missing) func (c *PostableUserConfig) ProcessConfig() error { - seenUIDs := make(map[string]struct{}) - // encrypt secure settings for storing them in DB - for _, r := range c.AlertmanagerConfig.Receivers { - switch r.Type() { - case GrafanaReceiverType: - for _, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers { - for k, v := range gr.SecureSettings { - encryptedData, err := util.Encrypt([]byte(v), setting.SecretKey) - if err != nil { - return fmt.Errorf("failed to encrypt secure settings: %w", err) - } - gr.SecureSettings[k] = base64.StdEncoding.EncodeToString(encryptedData) - } - if gr.UID == "" { - retries := 5 - for i := 0; i < retries; i++ { - gen := util.GenerateShortUID() - _, ok := seenUIDs[gen] - if !ok { - gr.UID = gen - break - } - } - if gr.UID == "" { - return fmt.Errorf("all %d attempts to generate UID for receiver have failed; please retry", retries) - } - } - seenUIDs[gr.UID] = struct{}{} - } - default: - } - } - return nil + return processReceiverConfigs(c.AlertmanagerConfig.Receivers) } // MarshalYAML implements yaml.Marshaller. @@ -911,3 +925,39 @@ type GettableGrafanaReceivers struct { type PostableGrafanaReceivers struct { GrafanaManagedReceivers []*PostableGrafanaReceiver `yaml:"grafana_managed_receiver_configs,omitempty" json:"grafana_managed_receiver_configs,omitempty"` } + +func processReceiverConfigs(c []*PostableApiReceiver) error { + seenUIDs := make(map[string]struct{}) + // encrypt secure settings for storing them in DB + for _, r := range c { + switch r.Type() { + case GrafanaReceiverType: + for _, gr := range r.PostableGrafanaReceivers.GrafanaManagedReceivers { + for k, v := range gr.SecureSettings { + encryptedData, err := util.Encrypt([]byte(v), setting.SecretKey) + if err != nil { + return fmt.Errorf("failed to encrypt secure settings: %w", err) + } + gr.SecureSettings[k] = base64.StdEncoding.EncodeToString(encryptedData) + } + if gr.UID == "" { + retries := 5 + for i := 0; i < retries; i++ { + gen := util.GenerateShortUID() + _, ok := seenUIDs[gen] + if !ok { + gr.UID = gen + break + } + } + if gr.UID == "" { + return fmt.Errorf("all %d attempts to generate UID for receiver have failed; please retry", retries) + } + } + seenUIDs[gr.UID] = struct{}{} + } + default: + } + } + return nil +} diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 98cd43cc174..22596da3082 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -485,6 +485,49 @@ } } }, + "/api/alertmanager/{Recipient}/config/api/v1/receivers/test": { + "post": { + "tags": [ + "alertmanager" + ], + "summary": "Test Grafana managed receivers without saving them.", + "operationId": "RoutePostTestReceivers", + "parameters": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/PostableApiReceiver" + }, + "x-go-name": "Receivers", + "name": "receivers", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Ack", + "schema": { + "$ref": "#/definitions/Ack" + } + }, + "207": { + "$ref": "#/responses/MultiStatus" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + }, + "408": { + "description": "Failure", + "schema": { + "$ref": "#/definitions/Failure" + } + } + } + } + }, "/api/prometheus/{Recipient}/api/v1/alerts": { "get": { "description": "gets the current alerts", @@ -1707,6 +1750,7 @@ "enum": [ "Alerting" ], + "x-go-enum-desc": "Alerting AlertingErrState", "x-go-name": "ExecErrState" }, "id": { @@ -1735,6 +1779,7 @@ "NoData", "OK" ], + "x-go-enum-desc": "Alerting Alerting\nNoData NoData\nOK OK", "x-go-name": "NoDataState" }, "orgId": { @@ -2547,6 +2592,7 @@ "enum": [ "Alerting" ], + "x-go-enum-desc": "Alerting AlertingErrState", "x-go-name": "ExecErrState" }, "no_data_state": { @@ -2556,6 +2602,7 @@ "NoData", "OK" ], + "x-go-enum-desc": "Alerting Alerting\nNoData NoData\nOK OK", "x-go-name": "NoDataState" }, "title": { @@ -3229,6 +3276,76 @@ }, "x-go-package": "github.com/prometheus/common/config" }, + "TestReceiverConfigResult": { + "type": "object", + "properties": { + "error": { + "type": "string", + "x-go-name": "Error" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "status": { + "type": "string", + "x-go-name": "Status" + }, + "uid": { + "type": "string", + "x-go-name": "UID" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, + "TestReceiverResult": { + "type": "object", + "properties": { + "grafana_managed_receiver_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/TestReceiverConfigResult" + }, + "x-go-name": "Configs" + }, + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, + "TestReceiversConfig": { + "type": "object", + "properties": { + "receivers": { + "type": "array", + "items": { + "$ref": "#/definitions/PostableApiReceiver" + }, + "x-go-name": "Receivers" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, + "TestReceiversResult": { + "type": "object", + "properties": { + "notified_at": { + "type": "string", + "format": "date-time", + "x-go-name": "NotifedAt" + }, + "receivers": { + "type": "array", + "items": { + "$ref": "#/definitions/TestReceiverResult" + }, + "x-go-name": "Receivers" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, "TestRulePayload": { "type": "object", "properties": { @@ -3483,11 +3600,12 @@ "$ref": "#/definitions/alertGroup" }, "alertGroups": { - "description": "AlertGroups alert groups", "type": "array", "items": { "$ref": "#/definitions/alertGroup" }, + "x-go-name": "AlertGroups", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/alertGroups" }, "alertStatus": { @@ -3672,16 +3790,14 @@ "$ref": "#/definitions/gettableAlert" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "type": "array", "items": { "$ref": "#/definitions/gettableAlert" }, - "x-go-name": "GettableAlerts", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/gettableAlerts" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -3734,6 +3850,8 @@ "x-go-name": "UpdatedAt" } }, + "x-go-name": "GettableSilence", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/gettableSilence" }, "gettableSilences": { @@ -3872,6 +3990,7 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "postableSilence": { + "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", @@ -3912,8 +4031,6 @@ "x-go-name": "StartsAt" } }, - "x-go-name": "PostableSilence", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/postableSilence" }, "receiver": { diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index f9f9a41b40e..58e828a5dec 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -106,7 +106,8 @@ type Alertmanager struct { dispatcherMetrics *dispatch.DispatcherMetrics reloadConfigMtx sync.RWMutex - config []byte + config *apimodels.PostableUserConfig + configHash [16]byte } func New(cfg *setting.Cfg, store store.AlertingStore, m *metrics.Metrics) (*Alertmanager, error) { @@ -166,7 +167,11 @@ func (am *Alertmanager) Ready() bool { am.reloadConfigMtx.RLock() defer am.reloadConfigMtx.RUnlock() - return len(am.config) > 0 + return am.ready() +} + +func (am *Alertmanager) ready() bool { + return am.config != nil } func (am *Alertmanager) Run(ctx context.Context) error { @@ -314,6 +319,32 @@ func (am *Alertmanager) SyncAndApplyConfigFromDatabase(orgID int64) error { return nil } +func (am *Alertmanager) getTemplate() (*template.Template, error) { + am.reloadConfigMtx.RLock() + defer am.reloadConfigMtx.RUnlock() + if !am.ready() { + return nil, errors.New("alertmanager is not initialized") + } + paths := make([]string, 0, len(am.config.TemplateFiles)) + for name := range am.config.TemplateFiles { + paths = append(paths, filepath.Join(am.WorkingDirPath(), name)) + } + return am.templateFromPaths(paths...) +} + +func (am *Alertmanager) templateFromPaths(paths ...string) (*template.Template, error) { + tmpl, err := template.FromGlobs(paths...) + if err != nil { + return nil, err + } + externalURL, err := url.Parse(am.Settings.AppURL) + if err != nil { + return nil, err + } + tmpl.ExternalURL = externalURL + return tmpl, nil +} + // applyConfig applies a new configuration by re-initializing all components using the configuration provided. // It is not safe to call concurrently. func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig []byte) (err error) { @@ -328,7 +359,7 @@ func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig rawConfig = enc } - if md5.Sum(am.config) != md5.Sum(rawConfig) { + if am.configHash != md5.Sum(rawConfig) { configChanged = true } @@ -350,15 +381,10 @@ func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig } // With the templates persisted, create the template list using the paths. - tmpl, err := template.FromGlobs(paths...) + tmpl, err := am.templateFromPaths(paths...) if err != nil { return err } - externalURL, err := url.Parse(am.Settings.AppURL) - if err != nil { - return err - } - tmpl.ExternalURL = externalURL // Finally, build the integrations map using the receiver configuration and templates. integrationsMap, err := am.buildIntegrationsMap(cfg.AlertmanagerConfig.Receivers, tmpl) @@ -400,7 +426,9 @@ func (am *Alertmanager) applyConfig(cfg *apimodels.PostableUserConfig, rawConfig am.inhibitor.Run() }() - am.config = rawConfig + am.config = cfg + am.configHash = md5.Sum(rawConfig) + return nil } @@ -430,77 +458,95 @@ type NotificationChannel interface { // buildReceiverIntegrations builds a list of integration notifiers off of a receiver config. func (am *Alertmanager) buildReceiverIntegrations(receiver *apimodels.PostableApiReceiver, tmpl *template.Template) ([]notify.Integration, error) { var integrations []notify.Integration - for i, r := range receiver.GrafanaManagedReceivers { - // secure settings are already encrypted at this point - secureSettings := securejsondata.SecureJsonData(make(map[string][]byte, len(r.SecureSettings))) - - for k, v := range r.SecureSettings { - d, err := base64.StdEncoding.DecodeString(v) - if err != nil { - return nil, fmt.Errorf("failed to decode secure setting") - } - secureSettings[k] = d - } - var ( - cfg = &channels.NotificationChannelConfig{ - UID: r.UID, - Name: r.Name, - Type: r.Type, - DisableResolveMessage: r.DisableResolveMessage, - Settings: r.Settings, - SecureSettings: secureSettings, - } - n NotificationChannel - err error - ) - switch r.Type { - case "email": - n, err = channels.NewEmailNotifier(cfg, tmpl) // Email notifier already has a default template. - case "pagerduty": - n, err = channels.NewPagerdutyNotifier(cfg, tmpl) - case "pushover": - n, err = channels.NewPushoverNotifier(cfg, tmpl) - case "slack": - n, err = channels.NewSlackNotifier(cfg, tmpl) - case "telegram": - n, err = channels.NewTelegramNotifier(cfg, tmpl) - case "victorops": - n, err = channels.NewVictoropsNotifier(cfg, tmpl) - case "teams": - n, err = channels.NewTeamsNotifier(cfg, tmpl) - case "dingding": - n, err = channels.NewDingDingNotifier(cfg, tmpl) - case "kafka": - n, err = channels.NewKafkaNotifier(cfg, tmpl) - case "webhook": - n, err = channels.NewWebHookNotifier(cfg, tmpl) - case "sensugo": - n, err = channels.NewSensuGoNotifier(cfg, tmpl) - case "discord": - n, err = channels.NewDiscordNotifier(cfg, tmpl) - case "googlechat": - n, err = channels.NewGoogleChatNotifier(cfg, tmpl) - case "LINE": - n, err = channels.NewLineNotifier(cfg, tmpl) - case "threema": - n, err = channels.NewThreemaNotifier(cfg, tmpl) - case "opsgenie": - n, err = channels.NewOpsgenieNotifier(cfg, tmpl) - case "prometheus-alertmanager": - n, err = channels.NewAlertmanagerNotifier(cfg, tmpl) - default: - return nil, fmt.Errorf("notifier %s is not supported", r.Type) - } + n, err := am.buildReceiverIntegration(r, tmpl) if err != nil { return nil, err } integrations = append(integrations, notify.NewIntegration(n, n, r.Type, i)) } - return integrations, nil } +func (am *Alertmanager) buildReceiverIntegration(r *apimodels.PostableGrafanaReceiver, tmpl *template.Template) (NotificationChannel, error) { + // secure settings are already encrypted at this point + secureSettings := securejsondata.SecureJsonData(make(map[string][]byte, len(r.SecureSettings))) + + for k, v := range r.SecureSettings { + d, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return nil, InvalidReceiverError{ + Receiver: r, + Err: errors.New("failed to decode secure setting"), + } + } + secureSettings[k] = d + } + + var ( + cfg = &channels.NotificationChannelConfig{ + UID: r.UID, + Name: r.Name, + Type: r.Type, + DisableResolveMessage: r.DisableResolveMessage, + Settings: r.Settings, + SecureSettings: secureSettings, + } + n NotificationChannel + err error + ) + switch r.Type { + case "email": + n, err = channels.NewEmailNotifier(cfg, tmpl) // Email notifier already has a default template. + case "pagerduty": + n, err = channels.NewPagerdutyNotifier(cfg, tmpl) + case "pushover": + n, err = channels.NewPushoverNotifier(cfg, tmpl) + case "slack": + n, err = channels.NewSlackNotifier(cfg, tmpl) + case "telegram": + n, err = channels.NewTelegramNotifier(cfg, tmpl) + case "victorops": + n, err = channels.NewVictoropsNotifier(cfg, tmpl) + case "teams": + n, err = channels.NewTeamsNotifier(cfg, tmpl) + case "dingding": + n, err = channels.NewDingDingNotifier(cfg, tmpl) + case "kafka": + n, err = channels.NewKafkaNotifier(cfg, tmpl) + case "webhook": + n, err = channels.NewWebHookNotifier(cfg, tmpl) + case "sensugo": + n, err = channels.NewSensuGoNotifier(cfg, tmpl) + case "discord": + n, err = channels.NewDiscordNotifier(cfg, tmpl) + case "googlechat": + n, err = channels.NewGoogleChatNotifier(cfg, tmpl) + case "LINE": + n, err = channels.NewLineNotifier(cfg, tmpl) + case "threema": + n, err = channels.NewThreemaNotifier(cfg, tmpl) + case "opsgenie": + n, err = channels.NewOpsgenieNotifier(cfg, tmpl) + case "prometheus-alertmanager": + n, err = channels.NewAlertmanagerNotifier(cfg, tmpl) + default: + return nil, InvalidReceiverError{ + Receiver: r, + Err: fmt.Errorf("notifier %s is not supported", r.Type), + } + } + + if err != nil { + return nil, InvalidReceiverError{ + Receiver: r, + Err: err, + } + } + + return n, nil +} + // PutAlerts receives the alerts and then sends them through the corresponding route based on whenever the alert has a receiver embedded or not func (am *Alertmanager) PutAlerts(postableAlerts apimodels.PostableAlerts) error { now := time.Now() diff --git a/pkg/services/ngalert/notifier/channels/webhook.go b/pkg/services/ngalert/notifier/channels/webhook.go index 63d6107fdf6..13c0d1367f1 100644 --- a/pkg/services/ngalert/notifier/channels/webhook.go +++ b/pkg/services/ngalert/notifier/channels/webhook.go @@ -31,6 +31,9 @@ type WebhookNotifier struct { // NewWebHookNotifier is the constructor for // the WebHook notifier. func NewWebHookNotifier(model *NotificationChannelConfig, t *template.Template) (*WebhookNotifier, error) { + if model.Settings == nil { + return nil, receiverInitError{Cfg: *model, Reason: "could not find settings property"} + } url := model.Settings.Get("url").MustString() if url == "" { return nil, receiverInitError{Cfg: *model, Reason: "could not find url property in settings"} diff --git a/pkg/services/ngalert/notifier/receivers.go b/pkg/services/ngalert/notifier/receivers.go new file mode 100644 index 00000000000..3e6e9230651 --- /dev/null +++ b/pkg/services/ngalert/notifier/receivers.go @@ -0,0 +1,227 @@ +package notifier + +import ( + "context" + "errors" + "fmt" + "net/url" + "time" + + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/types" + "github.com/prometheus/common/model" + "golang.org/x/sync/errgroup" +) + +const ( + maxTestReceiversWorkers = 10 +) + +var ( + ErrNoReceivers = errors.New("no receivers") +) + +type TestReceiversResult struct { + Receivers []TestReceiverResult + NotifedAt time.Time +} + +type TestReceiverResult struct { + Name string + Configs []TestReceiverConfigResult +} + +type TestReceiverConfigResult struct { + Name string + UID string + Status string + Error error +} + +type InvalidReceiverError struct { + Receiver *apimodels.PostableGrafanaReceiver + Err error +} + +func (e InvalidReceiverError) Error() string { + return fmt.Sprintf("the receiver is invalid: %s", e.Err) +} + +type ReceiverTimeoutError struct { + Receiver *apimodels.PostableGrafanaReceiver + Err error +} + +func (e ReceiverTimeoutError) Error() string { + return fmt.Sprintf("the receiver timed out: %s", e.Err) +} + +func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigParams) (*TestReceiversResult, error) { + // now represents the start time of the test + now := time.Now() + testAlert := &types.Alert{ + Alert: model.Alert{ + Labels: model.LabelSet{ + model.LabelName("alertname"): "TestAlertAlwaysFiring", + model.LabelName("instance"): "Grafana", + }, + Annotations: model.LabelSet{ + model.LabelName("summary"): "TestAlertAlwaysFiring", + model.LabelName("description"): "This is a test alert from Grafana", + }, + StartsAt: now, + }, + UpdatedAt: now, + } + + // we must set a group key that is unique per test as some receivers use this key to deduplicate alerts + ctx = notify.WithGroupKey(ctx, testAlert.Labels.String()+now.String()) + + tmpl, err := am.getTemplate() + if err != nil { + return nil, fmt.Errorf("failed to get template: %w", err) + } + + // job contains all metadata required to test a receiver + type job struct { + Config *apimodels.PostableGrafanaReceiver + ReceiverName string + Notifier notify.Notifier + } + + // result contains the receiver that was tested and an error that is non-nil if the test failed + type result struct { + Config *apimodels.PostableGrafanaReceiver + ReceiverName string + Error error + } + + newTestReceiversResult := func(results []result, notifiedAt time.Time) *TestReceiversResult { + m := make(map[string]TestReceiverResult) + for _, receiver := range c.Receivers { + // set up the result for this receiver + m[receiver.Name] = TestReceiverResult{ + Name: receiver.Name, + // A Grafana receiver can have multiple nested receivers + Configs: make([]TestReceiverConfigResult, 0, len(receiver.GrafanaManagedReceivers)), + } + } + for _, next := range results { + tmp := m[next.ReceiverName] + status := "ok" + if next.Error != nil { + status = "failed" + } + tmp.Configs = append(tmp.Configs, TestReceiverConfigResult{ + Name: next.Config.Name, + UID: next.Config.UID, + Status: status, + Error: processNotifierError(next.Config, next.Error), + }) + m[next.ReceiverName] = tmp + } + v := new(TestReceiversResult) + v.Receivers = make([]TestReceiverResult, 0, len(c.Receivers)) + v.NotifedAt = notifiedAt + for _, next := range m { + v.Receivers = append(v.Receivers, next) + } + return v + } + + // invalid keeps track of all invalid receiver configurations + invalid := make([]result, 0, len(c.Receivers)) + // jobs keeps track of all receivers that need to be sent test notifications + jobs := make([]job, 0, len(c.Receivers)) + + for _, receiver := range c.Receivers { + for _, next := range receiver.GrafanaManagedReceivers { + n, err := am.buildReceiverIntegration(next, tmpl) + if err != nil { + invalid = append(invalid, result{ + Config: next, + ReceiverName: next.Name, + Error: err, + }) + } else { + jobs = append(jobs, job{ + Config: next, + ReceiverName: receiver.Name, + Notifier: n, + }) + } + } + } + + if len(invalid)+len(jobs) == 0 { + return nil, ErrNoReceivers + } + + if len(jobs) == 0 { + return newTestReceiversResult(invalid, now), nil + } + + numWorkers := maxTestReceiversWorkers + if numWorkers > len(jobs) { + numWorkers = len(jobs) + } + + resultCh := make(chan result, len(jobs)) + workCh := make(chan job, len(jobs)) + for _, job := range jobs { + workCh <- job + } + close(workCh) + + g, ctx := errgroup.WithContext(ctx) + for i := 0; i < numWorkers; i++ { + g.Go(func() error { + for next := range workCh { + v := result{ + Config: next.Config, + ReceiverName: next.ReceiverName, + } + if _, err := next.Notifier.Notify(ctx, testAlert); err != nil { + v.Error = err + } + resultCh <- v + } + return nil + }) + } + g.Wait() // nolint + close(resultCh) + + results := make([]result, 0, len(jobs)) + for next := range resultCh { + results = append(results, next) + } + + return newTestReceiversResult(append(invalid, results...), now), nil +} + +func processNotifierError(config *apimodels.PostableGrafanaReceiver, err error) error { + if err == nil { + return nil + } + + var urlError *url.Error + if errors.As(err, &urlError) { + if urlError.Timeout() { + return ReceiverTimeoutError{ + Receiver: config, + Err: err, + } + } + } + + if errors.Is(err, context.DeadlineExceeded) { + return ReceiverTimeoutError{ + Receiver: config, + Err: err, + } + } + + return err +} diff --git a/pkg/services/ngalert/notifier/receivers_test.go b/pkg/services/ngalert/notifier/receivers_test.go new file mode 100644 index 00000000000..136d3eebab0 --- /dev/null +++ b/pkg/services/ngalert/notifier/receivers_test.go @@ -0,0 +1,82 @@ +package notifier + +import ( + "context" + "errors" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" +) + +func TestInvalidReceiverError_Error(t *testing.T) { + e := InvalidReceiverError{ + Receiver: &definitions.PostableGrafanaReceiver{ + Name: "test", + UID: "uid", + }, + Err: errors.New("this is an error"), + } + require.Equal(t, "the receiver is invalid: this is an error", e.Error()) +} + +func TestReceiverTimeoutError_Error(t *testing.T) { + e := ReceiverTimeoutError{ + Receiver: &definitions.PostableGrafanaReceiver{ + Name: "test", + UID: "uid", + }, + Err: errors.New("context deadline exceeded"), + } + require.Equal(t, "the receiver timed out: context deadline exceeded", e.Error()) +} + +type timeoutError struct{} + +func (e timeoutError) Error() string { + return "the request timed out" +} + +func (e timeoutError) Timeout() bool { + return true +} + +func TestProcessNotifierError(t *testing.T) { + t.Run("assert ReceiverTimeoutError is returned for context deadline exceeded", func(t *testing.T) { + r := &definitions.PostableGrafanaReceiver{ + Name: "test", + UID: "uid", + } + require.Equal(t, ReceiverTimeoutError{ + Receiver: r, + Err: context.DeadlineExceeded, + }, processNotifierError(r, context.DeadlineExceeded)) + }) + + t.Run("assert ReceiverTimeoutError is returned for *url.Error timeout", func(t *testing.T) { + r := &definitions.PostableGrafanaReceiver{ + Name: "test", + UID: "uid", + } + urlError := &url.Error{ + Op: "Get", + URL: "https://grafana.net", + Err: timeoutError{}, + } + require.Equal(t, ReceiverTimeoutError{ + Receiver: r, + Err: urlError, + }, processNotifierError(r, urlError)) + }) + + t.Run("assert unknown error is returned unmodified", func(t *testing.T) { + r := &definitions.PostableGrafanaReceiver{ + Name: "test", + UID: "uid", + } + err := errors.New("this is an error") + require.Equal(t, err, processNotifierError(r, err)) + }) +} diff --git a/pkg/services/ngalert/notifier/status.go b/pkg/services/ngalert/notifier/status.go index 8726166dcbe..eef11ab48cf 100644 --- a/pkg/services/ngalert/notifier/status.go +++ b/pkg/services/ngalert/notifier/status.go @@ -1,8 +1,6 @@ package notifier import ( - "encoding/json" - apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" ) @@ -10,13 +8,9 @@ func (am *Alertmanager) GetStatus() apimodels.GettableStatus { am.reloadConfigMtx.RLock() defer am.reloadConfigMtx.RUnlock() - var amConfig apimodels.PostableApiAlertingConfig - if am.config != nil { - err := json.Unmarshal(am.config, &amConfig) - if err != nil { - // this should never error here, if the configuration is running it should be valid. - am.logger.Error("unable to marshal alertmanager configuration", "err", err) - } + config := apimodels.PostableApiAlertingConfig{} + if am.ready() { + config = am.config.AlertmanagerConfig } - return *apimodels.NewGettableStatus(&amConfig) + return *apimodels.NewGettableStatus(&config) } diff --git a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go index ef9dbd108b6..643cda7f0ac 100644 --- a/pkg/tests/api/alerting/api_alertmanager_configuration_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_configuration_test.go @@ -85,7 +85,7 @@ func TestAlertmanagerConfigurationIsTransactional(t *testing.T) { } ` resp := postRequest(t, alertConfigURL, payload, http.StatusBadRequest) // nolint - require.JSONEq(t, `{"message":"failed to save and apply Alertmanager configuration: failed to validate receiver \"slack.receiver\" of type \"slack\": token must be specified when using the Slack chat API"}`, getBody(t, resp.Body)) + require.JSONEq(t, `{"message":"failed to save and apply Alertmanager configuration: the receiver is invalid: failed to validate receiver \"slack.receiver\" of type \"slack\": token must be specified when using the Slack chat API"}`, getBody(t, resp.Body)) resp = getRequest(t, alertConfigURL, http.StatusOK) // nolint require.JSONEq(t, defaultAlertmanagerConfigJSON, getBody(t, resp.Body)) diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index 22e111e71d4..1b2750e8303 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -30,6 +30,328 @@ import ( "github.com/grafana/grafana/pkg/tests/testinfra" ) +func TestTestReceivers(t *testing.T) { + t.Run("assert no receivers returns 400 Bad Request", func(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + }) + store := testinfra.SetUpDatabase(t, dir) + store.Bus = bus.GetBus() + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_EDITOR), + Login: "grafana", + Password: "password", + }) + + testReceiversURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/config/api/v1/receivers/test", grafanaListedAddr) + // nolint + resp := postRequest(t, testReceiversURL, `{ + "receivers": [] +}`, http.StatusBadRequest) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.JSONEq(t, `{"error":"no receivers"}`, string(b)) + }) + + t.Run("assert working receiver returns OK", func(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + }) + store := testinfra.SetUpDatabase(t, dir) + store.Bus = bus.GetBus() + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_EDITOR), + Login: "grafana", + Password: "password", + }) + + oldEmailBus := bus.GetHandlerCtx("SendEmailCommandSync") + mockEmails := &mockEmailHandler{} + bus.AddHandlerCtx("", mockEmails.sendEmailCommandHandlerSync) + t.Cleanup(func() { + bus.AddHandlerCtx("", oldEmailBus) + }) + + testReceiversURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/config/api/v1/receivers/test", grafanaListedAddr) + // nolint + resp := postRequest(t, testReceiversURL, `{ + "receivers": [{ + "name":"receiver-1", + "grafana_managed_receiver_configs": [ + { + "uid":"", + "name":"receiver-1", + "type":"email", + "disableResolveMessage":false, + "settings":{ + "addresses":"example@email.com" + }, + "secureFields":{} + } + ] + }] +}`, http.StatusOK) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + var result apimodels.TestReceiversResult + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + + require.Len(t, result.Receivers, 1) + require.Len(t, result.Receivers[0].Configs, 1) + require.Equal(t, apimodels.TestReceiversResult{ + Receivers: []apimodels.TestReceiverResult{{ + Name: "receiver-1", + Configs: []apimodels.TestReceiverConfigResult{{ + Name: "receiver-1", + UID: result.Receivers[0].Configs[0].UID, + Status: "ok", + }}, + }}, + NotifedAt: result.NotifedAt, + }, result) + + require.Len(t, mockEmails.emails, 1) + require.Equal(t, []string{"example@email.com"}, mockEmails.emails[0].To) + }) + + t.Run("assert invalid receiver returns 400 Bad Request", func(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + }) + store := testinfra.SetUpDatabase(t, dir) + store.Bus = bus.GetBus() + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_EDITOR), + Login: "grafana", + Password: "password", + }) + + oldEmailBus := bus.GetHandlerCtx("SendEmailCommandSync") + mockEmails := &mockEmailHandler{} + bus.AddHandlerCtx("", mockEmails.sendEmailCommandHandlerSync) + t.Cleanup(func() { + bus.AddHandlerCtx("", oldEmailBus) + }) + + testReceiversURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/config/api/v1/receivers/test", grafanaListedAddr) + // nolint + resp := postRequest(t, testReceiversURL, `{ + "receivers": [{ + "name":"receiver-1", + "grafana_managed_receiver_configs": [ + { + "uid":"", + "name":"receiver-1", + "type":"email", + "disableResolveMessage":false, + "settings":{}, + "secureFields":{} + } + ] + }] +}`, http.StatusBadRequest) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, resp.Body.Close()) + }) + + var result apimodels.TestReceiversResult + require.NoError(t, json.Unmarshal(b, &result)) + require.Len(t, result.Receivers, 1) + require.Len(t, result.Receivers[0].Configs, 1) + require.Equal(t, apimodels.TestReceiversResult{ + Receivers: []apimodels.TestReceiverResult{{ + Name: "receiver-1", + Configs: []apimodels.TestReceiverConfigResult{{ + Name: "receiver-1", + UID: result.Receivers[0].Configs[0].UID, + Status: "failed", + Error: "the receiver is invalid: failed to validate receiver \"receiver-1\" of type \"email\": could not find addresses in settings", + }}, + }}, + NotifedAt: result.NotifedAt, + }, result) + }) + + t.Run("assert timed out receiver returns 408 Request Timeout", func(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + }) + store := testinfra.SetUpDatabase(t, dir) + store.Bus = bus.GetBus() + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_EDITOR), + Login: "grafana", + Password: "password", + }) + + oldEmailBus := bus.GetHandlerCtx("SendEmailCommandSync") + mockEmails := &mockEmailHandlerWithTimeout{ + timeout: 5 * time.Second, + } + bus.AddHandlerCtx("", mockEmails.sendEmailCommandHandlerSync) + t.Cleanup(func() { + bus.AddHandlerCtx("", oldEmailBus) + }) + + testReceiversURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/config/api/v1/receivers/test", grafanaListedAddr) + req, err := http.NewRequest(http.MethodPost, testReceiversURL, strings.NewReader(`{ + "receivers": [{ + "name":"receiver-1", + "grafana_managed_receiver_configs": [ + { + "uid":"", + "name":"receiver-1", + "type":"email", + "disableResolveMessage":false, + "settings":{ + "addresses":"example@email.com" + }, + "secureFields":{} + } + ] + }] +}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Request-Timeout", "1") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, resp.Body.Close()) + }) + require.Equal(t, http.StatusRequestTimeout, resp.StatusCode) + + var result apimodels.TestReceiversResult + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + + require.Len(t, result.Receivers, 1) + require.Len(t, result.Receivers[0].Configs, 1) + require.Equal(t, apimodels.TestReceiversResult{ + Receivers: []apimodels.TestReceiverResult{{ + Name: "receiver-1", + Configs: []apimodels.TestReceiverConfigResult{{ + Name: "receiver-1", + UID: result.Receivers[0].Configs[0].UID, + Status: "failed", + Error: "the receiver timed out: context deadline exceeded", + }}, + }}, + NotifedAt: result.NotifedAt, + }, result) + }) + + t.Run("assert multiple different errors returns 207 Multi Status", func(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + }) + store := testinfra.SetUpDatabase(t, dir) + store.Bus = bus.GetBus() + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_EDITOR), + Login: "grafana", + Password: "password", + }) + + oldEmailBus := bus.GetHandlerCtx("SendEmailCommandSync") + mockEmails := &mockEmailHandlerWithTimeout{ + timeout: 5 * time.Second, + } + bus.AddHandlerCtx("", mockEmails.sendEmailCommandHandlerSync) + t.Cleanup(func() { + bus.AddHandlerCtx("", oldEmailBus) + }) + + testReceiversURL := fmt.Sprintf("http://grafana:password@%s/api/alertmanager/grafana/config/api/v1/receivers/test", grafanaListedAddr) + req, err := http.NewRequest(http.MethodPost, testReceiversURL, strings.NewReader(`{ + "receivers": [{ + "name":"receiver-1", + "grafana_managed_receiver_configs": [ + { + "uid":"", + "name":"receiver-1", + "type":"email", + "disableResolveMessage":false, + "settings":{}, + "secureFields":{} + } + ] + }, { + "name":"receiver-2", + "grafana_managed_receiver_configs": [ + { + "uid":"", + "name":"receiver-2", + "type":"email", + "disableResolveMessage":false, + "settings":{ + "addresses":"example@email.com" + }, + "secureFields":{} + } + ] + }] +}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Request-Timeout", "1") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, resp.Body.Close()) + }) + require.Equal(t, http.StatusMultiStatus, resp.StatusCode) + + var result apimodels.TestReceiversResult + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + + require.Len(t, result.Receivers, 2) + require.Len(t, result.Receivers[0].Configs, 1) + require.Len(t, result.Receivers[1].Configs, 1) + require.Equal(t, apimodels.TestReceiversResult{ + Receivers: []apimodels.TestReceiverResult{{ + Name: "receiver-1", + Configs: []apimodels.TestReceiverConfigResult{{ + Name: "receiver-1", + UID: result.Receivers[0].Configs[0].UID, + Status: "failed", + Error: "the receiver is invalid: failed to validate receiver \"receiver-1\" of type \"email\": could not find addresses in settings", + }}, + }, { + Name: "receiver-2", + Configs: []apimodels.TestReceiverConfigResult{{ + Name: "receiver-2", + UID: result.Receivers[1].Configs[0].UID, + Status: "failed", + Error: "the receiver timed out: context deadline exceeded", + }}, + }}, + NotifedAt: result.NotifedAt, + }, result) + }) +} + func TestNotificationChannels(t *testing.T) { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ EnableFeatureToggles: []string{"ngalert"}, @@ -391,6 +713,21 @@ func (e *mockEmailHandler) sendEmailCommandHandlerSync(_ context.Context, cmd *m return nil } +// mockEmailHandlerWithTimeout blocks until the timeout has expired. +type mockEmailHandlerWithTimeout struct { + mockEmailHandler + timeout time.Duration +} + +func (e *mockEmailHandlerWithTimeout) sendEmailCommandHandlerSync(ctx context.Context, cmd *models.SendEmailCommandSync) error { + select { + case <-time.After(e.timeout): + return e.mockEmailHandler.sendEmailCommandHandlerSync(ctx, cmd) + case <-ctx.Done(): + return ctx.Err() + } +} + // alertmanagerConfig has the config for all the notification channels // that we want to test. It is recommended to use different URL for each // channel and have 1 route per channel. From 0d2aaed3e8f8e4393395ac1808aa435ba0dbe577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 17 Aug 2021 15:14:11 +0200 Subject: [PATCH 17/22] Annotations: Fixes so alert annotations are visible in the correct Panel (#37959) --- .../standardAnnotationSupport.test.ts | 60 +++++++++++++++++++ .../annotations/standardAnnotationSupport.ts | 1 + 2 files changed, 61 insertions(+) diff --git a/public/app/features/annotations/standardAnnotationSupport.test.ts b/public/app/features/annotations/standardAnnotationSupport.test.ts index 4be61ab3184..6be3050e792 100644 --- a/public/app/features/annotations/standardAnnotationSupport.test.ts +++ b/public/app/features/annotations/standardAnnotationSupport.test.ts @@ -94,4 +94,64 @@ describe('DataFrame to annotations', () => { ], ]); }); + + it('all valid key names should be included in the output result', async () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', values: [100] }, + { name: 'timeEnd', values: [200] }, + { name: 'title', values: ['title'] }, + { name: 'text', values: ['text'] }, + { name: 'tags', values: ['t1,t2,t3'] }, + { name: 'id', values: [1] }, + { name: 'userId', values: ['Admin'] }, + { name: 'login', values: ['admin'] }, + { name: 'email', values: ['admin@unknown.us'] }, + { name: 'prevState', values: ['normal'] }, + { name: 'newState', values: ['alerting'] }, + { name: 'data', values: [{ text: 'a', value: 'A' }] }, + { name: 'panelId', values: [4] }, + ], + }); + + const observable = getAnnotationsFromData([frame]); + + await expect(observable).toEmitValues([ + [ + { + color: 'red', + data: { text: 'a', value: 'A' }, + email: 'admin@unknown.us', + id: 1, + login: 'admin', + newState: 'alerting', + panelId: 4, + prevState: 'normal', + tags: ['t1', 't2', 't3'], + text: 'text', + time: 100, + timeEnd: 200, + title: 'title', + type: 'default', + userId: 'Admin', + }, + ], + ]); + }); + + it('key names that are not valid should be excluded in the output result', async () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', values: [100] }, + { name: 'text', values: ['text'] }, + { name: 'someData', values: [{ value: 'bar' }] }, + { name: 'panelSource', values: ['100'] }, + { name: 'timeStart', values: [100] }, + ], + }); + + const observable = getAnnotationsFromData([frame]); + + await expect(observable).toEmitValues([[{ color: 'red', text: 'text', time: 100, type: 'default' }]]); + }); }); diff --git a/public/app/features/annotations/standardAnnotationSupport.ts b/public/app/features/annotations/standardAnnotationSupport.ts index e5d87f9e5fd..b9c7d99b0a8 100644 --- a/public/app/features/annotations/standardAnnotationSupport.ts +++ b/public/app/features/annotations/standardAnnotationSupport.ts @@ -122,6 +122,7 @@ const alertEventAndAnnotationFields: AnnotationFieldInfo[] = [ { key: 'prevState' }, { key: 'newState' }, { key: 'data' as any }, + { key: 'panelId' }, ]; export function getAnnotationsFromData( From 6aa2a0dc8a73b9e6f070b6e1dae8d51e8e2dc995 Mon Sep 17 00:00:00 2001 From: cyhone Date: Tue, 17 Aug 2021 21:34:03 +0800 Subject: [PATCH 18/22] refactor: simplify serverlock code (#37451) --- pkg/infra/serverlock/serverlock.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/infra/serverlock/serverlock.go b/pkg/infra/serverlock/serverlock.go index e662f85af4f..95b2027a81f 100644 --- a/pkg/infra/serverlock/serverlock.go +++ b/pkg/infra/serverlock/serverlock.go @@ -39,7 +39,7 @@ func (sl *ServerLockService) LockAndExecute(ctx context.Context, actionName stri // avoid execution if last lock happened less than `maxInterval` ago if rowLock.LastExecution != 0 { lastExecutionTime := time.Unix(rowLock.LastExecution, 0) - if lastExecutionTime.Unix() > time.Now().Add(-maxInterval).Unix() { + if time.Since(lastExecutionTime) < maxInterval { return nil } } From 11c848f00da531914f1d783f0456cf8c909fe9ab Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Tue, 17 Aug 2021 15:48:29 +0200 Subject: [PATCH 19/22] Tempo: Service map (#37661) * Add prometheus queries * Add stats * Refactor transform * Fix stat format * Refactor transform * Hide behind feature flag * Better linking error messages * Add test * Add test for datasource * Fix lint * Make optionality checking more explicit --- packages/grafana-data/src/types/config.ts | 1 + packages/grafana-runtime/src/config.ts | 1 + .../plugins/datasource/tempo/ConfigEditor.tsx | 9 +- .../plugins/datasource/tempo/QueryField.tsx | 172 ++++++++++++++---- .../datasource/tempo/ServiceMapSettings.tsx | 64 +++++++ .../datasource/tempo/datasource.test.ts | 73 +++++++- .../plugins/datasource/tempo/datasource.ts | 86 +++++++-- .../datasource/tempo/graphTransform.test.ts | 57 +++++- .../datasource/tempo/graphTransform.ts | 159 +++++++++++++++- 9 files changed, 560 insertions(+), 62 deletions(-) create mode 100644 public/app/plugins/datasource/tempo/ServiceMapSettings.tsx diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index e28bd2455da..2fef6fd2085 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -48,6 +48,7 @@ export interface FeatureToggles { ngalert: boolean; trimDefaults: boolean; accesscontrol: boolean; + tempoServiceGraph: boolean; } /** diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 014fccb77ea..b2d48fb97ad 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -63,6 +63,7 @@ export class GrafanaBootConfig implements GrafanaConfig { ngalert: false, accesscontrol: false, trimDefaults: false, + tempoServiceGraph: false, }; licenseInfo: LicenseInfo = {} as LicenseInfo; rendererAvailable = false; diff --git a/public/app/plugins/datasource/tempo/ConfigEditor.tsx b/public/app/plugins/datasource/tempo/ConfigEditor.tsx index 31c0a99ecf5..36fb8d59bdd 100644 --- a/public/app/plugins/datasource/tempo/ConfigEditor.tsx +++ b/public/app/plugins/datasource/tempo/ConfigEditor.tsx @@ -2,6 +2,8 @@ import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { DataSourceHttpSettings } from '@grafana/ui'; import { TraceToLogsSettings } from 'app/core/components/TraceToLogsSettings'; import React from 'react'; +import { ServiceMapSettings } from './ServiceMapSettings'; +import { config } from '@grafana/runtime'; export type Props = DataSourcePluginOptionsEditorProps; @@ -15,7 +17,12 @@ export const ConfigEditor: React.FC = ({ options, onOptionsChange }) => { onChange={onOptionsChange} /> - +
+ +
+ {config.featureToggles.tempoServiceGraph && ( + + )} ); }; diff --git a/public/app/plugins/datasource/tempo/QueryField.tsx b/public/app/plugins/datasource/tempo/QueryField.tsx index 55f5c54ac05..819da54879f 100644 --- a/public/app/plugins/datasource/tempo/QueryField.tsx +++ b/public/app/plugins/datasource/tempo/QueryField.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; -import { DataQuery, DataSourceApi, ExploreQueryFieldProps } from '@grafana/data'; +import { DataSourceApi, ExploreQueryFieldProps, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { getDataSourceSrv } from '@grafana/runtime'; +import { config, getDataSourceSrv } from '@grafana/runtime'; import { FileDropzone, InlineField, @@ -16,16 +16,27 @@ import { TraceToLogsOptions } from 'app/core/components/TraceToLogsSettings'; import React from 'react'; import { LokiQueryField } from '../loki/components/LokiQueryField'; import { TempoDatasource, TempoQuery, TempoQueryType } from './datasource'; +import LokiDatasource from '../loki/datasource'; +import { LokiQuery } from '../loki/types'; +import { PrometheusDatasource } from '../prometheus/datasource'; +import useAsync from 'react-use/lib/useAsync'; interface Props extends ExploreQueryFieldProps, Themeable2 {} const DEFAULT_QUERY_TYPE: TempoQueryType = 'traceId'; + interface State { - linkedDatasource?: DataSourceApi; + linkedDatasourceUid?: string; + linkedDatasource?: LokiDatasource; + serviceMapDatasourceUid?: string; + serviceMapDatasource?: PrometheusDatasource; } class TempoQueryFieldComponent extends React.PureComponent { state = { + linkedDatasourceUid: undefined, linkedDatasource: undefined, + serviceMapDatasourceUid: undefined, + serviceMapDatasource: undefined, }; constructor(props: Props) { @@ -37,16 +48,21 @@ class TempoQueryFieldComponent extends React.PureComponent { // Find query field from linked datasource const tracesToLogsOptions: TraceToLogsOptions = datasource.tracesToLogs || {}; const linkedDatasourceUid = tracesToLogsOptions.datasourceUid; - if (linkedDatasourceUid) { - const dsSrv = getDataSourceSrv(); - const linkedDatasource = await dsSrv.get(linkedDatasourceUid); - this.setState({ - linkedDatasource, - }); - } + + const serviceMapDsUid = datasource.serviceMap?.datasourceUid; + + // Check status of linked data sources so we can show warnings if needed. + const [logsDs, serviceMapDs] = await Promise.all([getDS(linkedDatasourceUid), getDS(serviceMapDsUid)]); + + this.setState({ + linkedDatasourceUid: linkedDatasourceUid, + linkedDatasource: logsDs as LokiDatasource, + serviceMapDatasourceUid: serviceMapDsUid, + serviceMapDatasource: serviceMapDs as PrometheusDatasource, + }); } - onChangeLinkedQuery = (value: DataQuery) => { + onChangeLinkedQuery = (value: LokiQuery) => { const { query, onChange } = this.props; onChange({ ...query, @@ -59,19 +75,28 @@ class TempoQueryFieldComponent extends React.PureComponent { }; render() { - const { query, onChange } = this.props; - const { linkedDatasource } = this.state; + const { query, onChange, datasource } = this.props; + // Find query field from linked datasource + const tracesToLogsOptions: TraceToLogsOptions = datasource.tracesToLogs || {}; + const logsDatasourceUid = tracesToLogsOptions.datasourceUid; + const graphDatasourceUid = datasource.serviceMap?.datasourceUid; + + const queryTypeOptions: Array> = [ + { value: 'search', label: 'Search' }, + { value: 'traceId', label: 'TraceID' }, + { value: 'upload', label: 'JSON file' }, + ]; + + if (config.featureToggles.tempoServiceGraph) { + queryTypeOptions.push({ value: 'serviceMap', label: 'Service Map' }); + } return ( <> - options={[ - { value: 'search', label: 'Search' }, - { value: 'traceId', label: 'TraceID' }, - { value: 'upload', label: 'JSON file' }, - ]} + options={queryTypeOptions} value={query.queryType || DEFAULT_QUERY_TYPE} onChange={(v) => onChange({ @@ -83,23 +108,13 @@ class TempoQueryFieldComponent extends React.PureComponent { /> - {query.queryType === 'search' && linkedDatasource && ( - <> - - Tempo uses {((linkedDatasource as unknown) as DataSourceApi).name} to find traces. - - - - - )} - {query.queryType === 'search' && !linkedDatasource && ( -
Please set up a Traces-to-logs datasource in the datasource settings.
+ {query.queryType === 'search' && ( + )} {query.queryType === 'upload' && (
@@ -112,7 +127,7 @@ class TempoQueryFieldComponent extends React.PureComponent { />
)} - {(!query.queryType || query.queryType === 'traceId') && ( + {query.queryType === 'traceId' && ( { } /> )} + {query.queryType === 'serviceMap' && } ); } } +function ServiceMapSection({ graphDatasourceUid }: { graphDatasourceUid?: string }) { + const dsState = useAsync(() => getDS(graphDatasourceUid), [graphDatasourceUid]); + if (dsState.loading) { + return null; + } + + const ds = dsState.value as LokiDatasource; + + if (!graphDatasourceUid) { + return
Please set up a service graph datasource in the datasource settings.
; + } + + if (graphDatasourceUid && !ds) { + return ( +
+ Service graph datasource is configured but the data source no longer exists. Please configure existing data + source to use the service graph functionality. +
+ ); + } + + return null; +} + +interface SearchSectionProps { + linkedDatasourceUid?: string; + onChange: (value: LokiQuery) => void; + onRunQuery: () => void; + query: TempoQuery; +} +function SearchSection({ linkedDatasourceUid, onChange, onRunQuery, query }: SearchSectionProps) { + const dsState = useAsync(() => getDS(linkedDatasourceUid), [linkedDatasourceUid]); + if (dsState.loading) { + return null; + } + + const ds = dsState.value as LokiDatasource; + + if (ds) { + return ( + <> + Tempo uses {ds.name} to find traces. + + + + ); + } + + if (!linkedDatasourceUid) { + return
Please set up a Traces-to-logs datasource in the datasource settings.
; + } + + if (linkedDatasourceUid && !ds) { + return ( +
+ Traces-to-logs datasource is configured but the data source no longer exists. Please configure existing data + source to use the search. +
+ ); + } + + return null; +} + +async function getDS(uid?: string): Promise { + if (!uid) { + return undefined; + } + + const dsSrv = getDataSourceSrv(); + try { + return await dsSrv.get(uid); + } catch (error) { + console.error('Failed to load data source', error); + return undefined; + } +} + export const TempoQueryField = withTheme2(TempoQueryFieldComponent); diff --git a/public/app/plugins/datasource/tempo/ServiceMapSettings.tsx b/public/app/plugins/datasource/tempo/ServiceMapSettings.tsx new file mode 100644 index 00000000000..285becb76cc --- /dev/null +++ b/public/app/plugins/datasource/tempo/ServiceMapSettings.tsx @@ -0,0 +1,64 @@ +import { css } from '@emotion/css'; +import { DataSourcePluginOptionsEditorProps, GrafanaTheme, updateDatasourcePluginJsonDataOption } from '@grafana/data'; +import { DataSourcePicker } from '@grafana/runtime'; +import { Button, InlineField, InlineFieldRow, useStyles } from '@grafana/ui'; +import React from 'react'; +import { TempoJsonData } from './datasource'; + +interface Props extends DataSourcePluginOptionsEditorProps {} + +export function ServiceMapSettings({ options, onOptionsChange }: Props) { + const styles = useStyles(getStyles); + + return ( +
+

Service map

+ +
+ To allow querying service map data you have to select a Prometheus instance where the data is stored. +
+ + + + + updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'serviceMap', { + datasourceUid: ds.uid, + }) + } + /> + + + +
+ ); +} + +const getStyles = (theme: GrafanaTheme) => ({ + infoText: css` + label: infoText; + padding-bottom: ${theme.spacing.md}; + color: ${theme.colors.textSemiWeak}; + `, + + row: css` + label: row; + align-items: baseline; + `, +}); diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts index d7838b6f442..f78500de5dd 100644 --- a/public/app/plugins/datasource/tempo/datasource.test.ts +++ b/public/app/plugins/datasource/tempo/datasource.test.ts @@ -3,13 +3,15 @@ import { dataFrameToJSON, DataSourceInstanceSettings, FieldType, + getDefaultTimeRange, + LoadingState, MutableDataFrame, PluginType, } from '@grafana/data'; -import { BackendDataSourceResponse, FetchResponse, setBackendSrv } from '@grafana/runtime'; import { Observable, of } from 'rxjs'; import { createFetchResponse } from 'test/helpers/createFetchResponse'; import { TempoDatasource } from './datasource'; +import { FetchResponse, setBackendSrv, BackendDataSourceResponse, setDataSourceSrv } from '@grafana/runtime'; import mockJson from './mockJsonResponse.json'; describe('Tempo data source', () => { @@ -77,6 +79,30 @@ describe('Tempo data source', () => { ]); }); + it('runs service map queries', async () => { + const ds = new TempoDatasource({ + ...defaultSettings, + jsonData: { + serviceMap: { + datasourceUid: 'prom', + }, + }, + }); + setDataSourceSrv(backendSrvWithPrometheus as any); + const response = await ds + .query({ targets: [{ queryType: 'serviceMap' }], range: getDefaultTimeRange() } as any) + .toPromise(); + + expect(response.data).toHaveLength(2); + expect(response.data[0].name).toBe('Nodes'); + expect(response.data[0].fields[0].values.length).toBe(3); + + expect(response.data[1].name).toBe('Edges'); + expect(response.data[1].fields[0].values.length).toBe(2); + + expect(response.state).toBe(LoadingState.Done); + }); + it('should handle json file upload', async () => { const ds = new TempoDatasource(defaultSettings); ds.uploadedJson = JSON.stringify(mockJson); @@ -93,6 +119,19 @@ describe('Tempo data source', () => { }); }); +const backendSrvWithPrometheus = { + async get(uid: string) { + if (uid === 'prom') { + return { + query() { + return of({ data: [totalsPromMetric] }, { data: [secondsPromMetric] }); + }, + }; + } + throw new Error('unexpected uid'); + }, +}; + function setupBackendSrv(frame: DataFrame) { setBackendSrv({ fetch(): Observable> { @@ -113,11 +152,11 @@ const defaultSettings: DataSourceInstanceSettings = { id: 0, uid: '0', type: 'tracing', - name: 'jaeger', + name: 'tempo', access: 'proxy', meta: { - id: 'jaeger', - name: 'jaeger', + id: 'tempo', + name: 'tempo', type: PluginType.datasource, info: {} as any, module: '', @@ -125,3 +164,29 @@ const defaultSettings: DataSourceInstanceSettings = { }, jsonData: {}, }; + +const totalsPromMetric = new MutableDataFrame({ + refId: 'tempo_service_graph_request_total', + fields: [ + { name: 'Time', values: [1628169788000, 1628169788000] }, + { name: 'client', values: ['app', 'lb'] }, + { name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] }, + { name: 'job', values: ['local_scrape', 'local_scrape'] }, + { name: 'server', values: ['db', 'app'] }, + { name: 'tempo_config', values: ['default', 'default'] }, + { name: 'Value #tempo_service_graph_request_total', values: [10, 20] }, + ], +}); + +const secondsPromMetric = new MutableDataFrame({ + refId: 'tempo_service_graph_request_server_seconds_sum', + fields: [ + { name: 'Time', values: [1628169788000, 1628169788000] }, + { name: 'client', values: ['app', 'lb'] }, + { name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] }, + { name: 'job', values: ['local_scrape', 'local_scrape'] }, + { name: 'server', values: ['db', 'app'] }, + { name: 'tempo_config', values: ['default', 'default'] }, + { name: 'Value #tempo_service_graph_request_server_seconds_sum', values: [10, 40] }, + ], +}); diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index c03c6b3e1df..ff817d3a9e8 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -1,54 +1,66 @@ +import { groupBy } from 'lodash'; import { DataQuery, DataQueryRequest, DataQueryResponse, DataSourceApi, DataSourceInstanceSettings, + DataSourceJsonData, LoadingState, } from '@grafana/data'; import { DataSourceWithBackend } from '@grafana/runtime'; -import { TraceToLogsData, TraceToLogsOptions } from 'app/core/components/TraceToLogsSettings'; +import { TraceToLogsOptions } from 'app/core/components/TraceToLogsSettings'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { from, merge, Observable, of, throwError } from 'rxjs'; -import { map, mergeMap } from 'rxjs/operators'; -import { LokiOptions } from '../loki/types'; -import { transformFromOTLP as transformFromOTEL, transformTrace, transformTraceList } from './resultTransformer'; +import { map, mergeMap, toArray } from 'rxjs/operators'; +import { LokiOptions, LokiQuery } from '../loki/types'; +import { transformTrace, transformTraceList, transformFromOTLP as transformFromOTEL } from './resultTransformer'; +import { PrometheusDatasource } from '../prometheus/datasource'; +import { PromQuery } from '../prometheus/types'; +import { mapPromMetricsToServiceMap, serviceMapMetrics } from './graphTransform'; -export type TempoQueryType = 'search' | 'traceId' | 'upload'; +export type TempoQueryType = 'search' | 'traceId' | 'serviceMap' | 'upload'; + +export interface TempoJsonData extends DataSourceJsonData { + tracesToLogs?: TraceToLogsOptions; + serviceMap?: { + datasourceUid?: string; + }; +} export type TempoQuery = { query: string; // Query to find list of traces, e.g., via Loki - linkedQuery?: DataQuery; + linkedQuery?: LokiQuery; queryType: TempoQueryType; } & DataQuery; -export class TempoDatasource extends DataSourceWithBackend { +export class TempoDatasource extends DataSourceWithBackend { tracesToLogs?: TraceToLogsOptions; + serviceMap?: { + datasourceUid?: string; + }; uploadedJson?: string | ArrayBuffer | null = null; - constructor(instanceSettings: DataSourceInstanceSettings) { + constructor(instanceSettings: DataSourceInstanceSettings) { super(instanceSettings); this.tracesToLogs = instanceSettings.jsonData.tracesToLogs; + this.serviceMap = instanceSettings.jsonData.serviceMap; } query(options: DataQueryRequest): Observable { const subQueries: Array> = []; const filteredTargets = options.targets.filter((target) => !target.hide); - const searchTargets = filteredTargets.filter((target) => target.queryType === 'search'); - const uploadTargets = filteredTargets.filter((target) => target.queryType === 'upload'); - const traceTargets = filteredTargets.filter( - (target) => target.queryType === 'traceId' || target.queryType === undefined - ); + const targets: { [type: string]: TempoQuery[] } = groupBy(filteredTargets, (t) => t.queryType || 'traceId'); // Run search queries on linked datasource - if (this.tracesToLogs?.datasourceUid && searchTargets.length > 0) { + if (this.tracesToLogs?.datasourceUid && targets.search?.length > 0) { const dsSrv = getDatasourceSrv(); subQueries.push( from(dsSrv.get(this.tracesToLogs.datasourceUid)).pipe( mergeMap((linkedDatasource: DataSourceApi) => { // Wrap linked query into a data request based on original request - const linkedRequest: DataQueryRequest = { ...options, targets: searchTargets.map((t) => t.linkedQuery!) }; + const linkedRequest: DataQueryRequest = { ...options, targets: targets.search.map((t) => t.linkedQuery!) }; // Find trace matchers in derived fields of the linked datasource that's identical to this datasource const settings: DataSourceInstanceSettings = (linkedDatasource as any).instanceSettings; const traceLinkMatcher: string[] = @@ -71,7 +83,7 @@ export class TempoDatasource extends DataSourceWithBackend 0) { - const traceRequest: DataQueryRequest = { ...options, targets: traceTargets }; + if (this.serviceMap?.datasourceUid && targets.serviceMap?.length > 0) { + subQueries.push(serviceMapQuery(options, this.serviceMap.datasourceUid)); + } + + if (targets.traceId?.length > 0) { + const traceRequest: DataQueryRequest = { ...options, targets: targets.traceId }; subQueries.push( super.query(traceRequest).pipe( map((response) => { @@ -121,3 +137,37 @@ export class TempoDatasource extends DataSourceWithBackend, datasourceUid: string) { + return from(getDatasourceSrv().get(datasourceUid)).pipe( + mergeMap((ds) => { + return (ds as PrometheusDatasource).query(request); + }) + ); +} + +function serviceMapQuery(request: DataQueryRequest, datasourceUid: string) { + return queryServiceMapPrometheus(makePromServiceMapRequest(request), datasourceUid).pipe( + // Just collect all the responses first before processing into node graph data + toArray(), + map((responses: DataQueryResponse[]) => { + return { + data: mapPromMetricsToServiceMap(responses, request.range), + state: LoadingState.Done, + }; + }) + ); +} + +function makePromServiceMapRequest(options: DataQueryRequest): DataQueryRequest { + return { + ...options, + targets: serviceMapMetrics.map((metric) => { + return { + refId: metric, + expr: `delta(${metric}[$__range])`, + instant: true, + }; + }), + }; +} diff --git a/public/app/plugins/datasource/tempo/graphTransform.test.ts b/public/app/plugins/datasource/tempo/graphTransform.test.ts index 446ddd6843d..c79c975a57b 100644 --- a/public/app/plugins/datasource/tempo/graphTransform.test.ts +++ b/public/app/plugins/datasource/tempo/graphTransform.test.ts @@ -1,6 +1,6 @@ -import { createGraphFrames } from './graphTransform'; +import { createGraphFrames, mapPromMetricsToServiceMap } from './graphTransform'; import { bigResponse } from './testResponse'; -import { DataFrameView, MutableDataFrame } from '@grafana/data'; +import { ArrayVector, DataFrameView, dateTime, MutableDataFrame } from '@grafana/data'; describe('createGraphFrames', () => { it('transforms basic response into nodes and edges frame', async () => { @@ -58,6 +58,33 @@ describe('createGraphFrames', () => { }); }); +describe('mapPromMetricsToServiceMap', () => { + it('transforms prom metrics to service map', async () => { + const range = { + from: dateTime('2000-01-01T00:00:00'), + to: dateTime('2000-01-01T00:01:00'), + }; + const [nodes, edges] = mapPromMetricsToServiceMap([{ data: [totalsPromMetric] }, { data: [secondsPromMetric] }], { + ...range, + raw: range, + }); + + expect(nodes.fields).toMatchObject([ + { name: 'id', values: new ArrayVector(['db', 'app', 'lb']) }, + { name: 'title', values: new ArrayVector(['db', 'app', 'lb']) }, + { name: 'mainStat', values: new ArrayVector([1000, 2000, NaN]) }, + { name: 'secondaryStat', values: new ArrayVector([10, 20, NaN]) }, + ]); + expect(edges.fields).toMatchObject([ + { name: 'id', values: new ArrayVector(['app_db', 'lb_app']) }, + { name: 'source', values: new ArrayVector(['app', 'lb']) }, + { name: 'target', values: new ArrayVector(['db', 'app']) }, + { name: 'mainStat', values: new ArrayVector([10, 20]) }, + { name: 'secondaryStat', values: new ArrayVector([1000, 2000]) }, + ]); + }); +}); + const singleSpanResponse = new MutableDataFrame({ fields: [ { name: 'traceID', values: ['04450900759028499335'] }, @@ -81,3 +108,29 @@ const missingSpanResponse = new MutableDataFrame({ { name: 'duration', values: [14.984, 4.984] }, ], }); + +const totalsPromMetric = new MutableDataFrame({ + refId: 'tempo_service_graph_request_total', + fields: [ + { name: 'Time', values: [1628169788000, 1628169788000] }, + { name: 'client', values: ['app', 'lb'] }, + { name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] }, + { name: 'job', values: ['local_scrape', 'local_scrape'] }, + { name: 'server', values: ['db', 'app'] }, + { name: 'tempo_config', values: ['default', 'default'] }, + { name: 'Value #tempo_service_graph_request_total', values: [10, 20] }, + ], +}); + +const secondsPromMetric = new MutableDataFrame({ + refId: 'tempo_service_graph_request_server_seconds_sum', + fields: [ + { name: 'Time', values: [1628169788000, 1628169788000] }, + { name: 'client', values: ['app', 'lb'] }, + { name: 'instance', values: ['127.0.0.1:12345', '127.0.0.1:12345'] }, + { name: 'job', values: ['local_scrape', 'local_scrape'] }, + { name: 'server', values: ['db', 'app'] }, + { name: 'tempo_config', values: ['default', 'default'] }, + { name: 'Value #tempo_service_graph_request_server_seconds_sum', values: [10, 40] }, + ], +}); diff --git a/public/app/plugins/datasource/tempo/graphTransform.ts b/public/app/plugins/datasource/tempo/graphTransform.ts index dbfa118d95e..21dd28372f4 100644 --- a/public/app/plugins/datasource/tempo/graphTransform.ts +++ b/public/app/plugins/datasource/tempo/graphTransform.ts @@ -1,4 +1,13 @@ -import { DataFrame, DataFrameView, NodeGraphDataFrameFieldNames as Fields } from '@grafana/data'; +import { groupBy } from 'lodash'; +import { + DataFrame, + DataFrameView, + DataQueryResponse, + FieldDTO, + MutableDataFrame, + NodeGraphDataFrameFieldNames as Fields, + TimeRange, +} from '@grafana/data'; import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '../../../core/utils/tracing'; interface Row { @@ -117,3 +126,151 @@ function findTraceDuration(view: DataFrameView): number { return traceEndTime - traceStartTime; } + +const secondsMetric = 'tempo_service_graph_request_server_seconds_sum'; +const totalsMetric = 'tempo_service_graph_request_total'; + +export const serviceMapMetrics = [ + secondsMetric, + totalsMetric, + // We don't show histogram in node graph at the moment but we could later add that into a node context menu. + // 'tempo_service_graph_request_seconds_bucket', + // 'tempo_service_graph_request_seconds_count', + // These are used for debugging the tempo collection so probably not useful for service map right now. + // 'tempo_service_graph_unpaired_spans_total', + // 'tempo_service_graph_untagged_spans_total', +]; + +/** + * Map response from multiple prometheus metrics into a node graph data frames with nodes and edges. + * @param responses + * @param range + */ +export function mapPromMetricsToServiceMap(responses: DataQueryResponse[], range: TimeRange): [DataFrame, DataFrame] { + const [totalsDFView, secondsDFView] = getMetricFrames(responses); + + // First just collect data from the metrics into a map with nodes and edges as keys + const nodesMap: Record = {}; + const edgesMap: Record = {}; + // At this moment we don't have any error/success or other counts so we just use these 2 + collectMetricData(totalsDFView, 'total', totalsMetric, nodesMap, edgesMap); + collectMetricData(secondsDFView, 'seconds', secondsMetric, nodesMap, edgesMap); + + return convertToDataFrames(nodesMap, edgesMap, range); +} + +function createServiceMapDataFrames() { + function createDF(name: string, fields: FieldDTO[]) { + return new MutableDataFrame({ name, fields, meta: { preferredVisualisationType: 'nodeGraph' } }); + } + + const nodes = createDF('Nodes', [ + { name: Fields.id }, + { name: Fields.title }, + { name: Fields.mainStat, config: { unit: 'ms/t', displayName: 'Average response time' } }, + { + name: Fields.secondaryStat, + config: { unit: 't/min', displayName: 'Transactions per minute' }, + }, + ]); + const edges = createDF('Edges', [ + { name: Fields.id }, + { name: Fields.source }, + { name: Fields.target }, + { name: Fields.mainStat, config: { unit: 't', displayName: 'Transactions' } }, + { name: Fields.secondaryStat, config: { unit: 'ms/t', displayName: 'Average response time' } }, + ]); + + return [nodes, edges]; +} + +function getMetricFrames(responses: DataQueryResponse[]) { + const responsesMap = groupBy(responses, (r) => r.data[0].refId); + const totalsDFView = new DataFrameView(responsesMap[totalsMetric][0].data[0]); + const secondsDFView = new DataFrameView(responsesMap[secondsMetric][0].data[0]); + return [totalsDFView, secondsDFView]; +} + +/** + * Collect data from a metric into a map of nodes and edges. The metric data is modeled as counts of metric per edge + * which is a pair of client-server nodes. This means we convert each row of the metric 1-1 to edges and than we assign + * the metric also to server. We count the stats for server only as we show requests/transactions that particular node + * processed not those which it generated and other stats like average transaction time then stem from that. + * @param frame + * @param stat + * @param metric + * @param nodesMap + * @param edgesMap + */ +function collectMetricData( + frame: DataFrameView, + stat: 'total' | 'seconds', + metric: string, + nodesMap: Record, + edgesMap: Record +) { + // The name of the value column is in this format + // TODO figure out if it can be changed + const valueName = `Value #${metric}`; + + for (let i = 0; i < frame.length; i++) { + const row = frame.get(i); + const edgeId = `${row.client}_${row.server}`; + + if (!edgesMap[edgeId]) { + edgesMap[edgeId] = { + target: row.server, + source: row.client, + [stat]: row[valueName], + }; + } else { + edgesMap[edgeId][stat] = (edgesMap[edgeId][stat] || 0) + row[valueName]; + } + + if (!nodesMap[row.server]) { + nodesMap[row.server] = { + [stat]: row[valueName], + }; + } else { + nodesMap[row.server][stat] = (nodesMap[row.server][stat] || 0) + row[valueName]; + } + + if (!nodesMap[row.client]) { + nodesMap[row.client] = { + [stat]: 0, + }; + } + } +} + +function convertToDataFrames( + nodesMap: Record, + edgesMap: Record, + range: TimeRange +): [DataFrame, DataFrame] { + const rangeMs = range.to.valueOf() - range.from.valueOf(); + const [nodes, edges] = createServiceMapDataFrames(); + for (const nodeId of Object.keys(nodesMap)) { + const node = nodesMap[nodeId]; + nodes.add({ + id: nodeId, + title: nodeId, + // NaN will not be shown in the node graph. This happens for a root client node which did not process + // any requests itself. + mainStat: node.total ? (node.seconds / node.total) * 1000 : Number.NaN, + secondaryStat: node.total ? node.total / (rangeMs / (1000 * 60)) : Number.NaN, + }); + } + for (const edgeId of Object.keys(edgesMap)) { + const edge = edgesMap[edgeId]; + edges.add({ + id: edgeId, + source: edge.source, + target: edge.target, + mainStat: edge.total, + secondaryStat: edge.total ? (edge.seconds / edge.total) * 1000 : Number.NaN, + }); + } + + return [nodes, edges]; +} From 1e221b645270587ffa657d77eec34d7577e500c1 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Tue, 17 Aug 2021 16:46:15 +0100 Subject: [PATCH 20/22] AzureMonitor: Show error message when subscriptions request fails in ConfigEditor (#37837) * Fix jsdoc description for datasourceRequest * Align default subscription width with other fields * AzureMonitor: Show error message when requesting default subscriptions in ConfigEditor * update snapshots: --- .../src/services/backendSrv.ts | 3 +- .../components/AzureCredentialsForm.tsx | 2 +- .../components/ConfigEditor.tsx | 40 ++++++++++++++++--- .../AzureCredentialsForm.test.tsx.snap | 6 +-- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/packages/grafana-runtime/src/services/backendSrv.ts b/packages/grafana-runtime/src/services/backendSrv.ts index eb9249daf8a..8f39f2551c1 100644 --- a/packages/grafana-runtime/src/services/backendSrv.ts +++ b/packages/grafana-runtime/src/services/backendSrv.ts @@ -152,11 +152,12 @@ export interface BackendSrv { request(options: BackendSrvRequest): Promise; /** - * @deprecated Use the fetch function instead * Special function used to communicate with datasources that will emit core * events that the Grafana QueryInspector and QueryEditor is listening for to be able * to display datasource query information. Can be skipped by adding `option.silent` * when initializing the request. + * + * @deprecated Use the fetch function instead */ datasourceRequest(options: BackendSrvRequest): Promise>; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx index bdfa47550ef..57cc9517c9e 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx @@ -252,7 +252,7 @@ export const AzureCredentialsForm: FunctionComponent = (props: Props) =>
Default Subscription -
+
Date: Tue, 17 Aug 2021 16:53:25 +0100 Subject: [PATCH 21/22] DashboardGrid: compare window width against theme breakpoints (#37868) --- .../app/features/dashboard/dashgrid/DashboardGrid.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index bf5c66dbd4f..7edaf0a0540 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -32,6 +32,7 @@ export class DashboardGrid extends PureComponent { private panelMap: { [id: string]: PanelModel } = {}; private eventSubs = new Subscription(); private windowHeight = 1200; + private windowWidth = 1920; private gridWidth = 0; constructor(props: Props) { @@ -152,6 +153,7 @@ export class DashboardGrid extends PureComponent { // We assume here that if width change height might have changed as well if (this.gridWidth !== gridWidth) { this.windowHeight = window.innerHeight ?? 1000; + this.windowWidth = window.innerWidth; this.gridWidth = gridWidth; } @@ -170,6 +172,7 @@ export class DashboardGrid extends PureComponent { gridPos={panel.gridPos} gridWidth={gridWidth} windowHeight={this.windowHeight} + windowWidth={this.windowWidth} isViewing={panel.isViewing} > {(width: number, height: number) => { @@ -259,6 +262,7 @@ interface GrafanaGridItemProps extends Record { gridPos?: GridPos; isViewing: string; windowHeight: number; + windowWidth: number; children: any; } @@ -270,15 +274,15 @@ const GrafanaGridItem = React.forwardRef(( let width = 100; let height = 100; - const { gridWidth, gridPos, isViewing, windowHeight, ...divProps } = props; + const { gridWidth, gridPos, isViewing, windowHeight, windowWidth, ...divProps } = props; const style: CSSProperties = props.style ?? {}; if (isViewing) { - width = props.gridWidth!; + width = gridWidth!; height = windowHeight * 0.85; style.height = height; style.width = '100%'; - } else if (props.gridWidth! < theme.breakpoints.values.md) { + } else if (windowWidth < theme.breakpoints.values.md) { width = props.gridWidth!; height = props.gridPos!.h * (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN) - GRID_CELL_VMARGIN; style.height = height; From d93d989a5a4c1d51092907444329796973dbfc88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Jamr=C3=B3z?= Date: Tue, 17 Aug 2021 18:50:31 +0200 Subject: [PATCH 22/22] Graphite: Migrate to React (part 4 & 5: group all components) (#37590) * Add UMLs * Add rendered diagrams * Move QueryCtrl to flux * Remove redundant param in the reducer * Use named imports for lodash and fix typing for GraphiteTagOperator * Add missing async/await * Extract providers to a separate file * Clean up async await * Rename controller functions back to main * Simplify creating actions * Re-order controller functions * Separate helpers from actions * Rename vars * Simplify helpers * Move controller methods to state reducers * Remove docs (they are added in design doc) * Move actions.ts to state folder * Add docs * Add old methods stubs for easier review * Check how state dependencies will be mapped * Rename state to store * Rename state to store * Rewrite spec tests for Graphite Query Controller * Update docs * Update docs * Add GraphiteTextEditor * Add play button * Add AddGraphiteFunction * Use Segment to simplify AddGraphiteFunction * Memoize function defs * Fix useCallback deps * Update public/app/plugins/datasource/graphite/state/helpers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/helpers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/helpers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/providers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/providers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/providers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/providers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/providers.ts Co-authored-by: Giordano Ricci * Update public/app/plugins/datasource/graphite/state/providers.ts Co-authored-by: Giordano Ricci * Add more type definitions * Remove submitOnClickAwayOption This behavior is actually needed to remove parameters in functions * Load function definitions before parsing the target on initial load * Add button padding * Fix loading function definitions * Change targetChanged to updateQuery to avoid mutating state directly It's also needed for extra refresh/runQuery execution as handleTargetChanged doesn't handle changing the raw query * Fix updating query after adding a function * Simplify updating function params * Migrate function editor to react * Simplify setting Segment Select min width * Remove unnecessary changes to SegmentInput * Extract view logic to a helper and update types definitions * Clean up types * Update FuncDef types and add tests * Show red border for unknown functions * Autofocus on new params * Extract params mapping to a helper * Split code between params and function editor * Focus on the first param when a function is added even if it's an optional argument * Add function editor tests * Remove todo marker * Fix adding new functions * Allow empty value in selects for removing function params * Add placeholders and fix styling * Add more docs * Create basic implementation for metrics and tags * Post merge fixes These files are not .ts * Remove mapping to Angular dropdowns * Simplify mapping tag names, values and operators * Simplify mapping metrics * Fix removing tags and autocomplete * Simplify debouncing providers * Ensure options are loaded twice and segment is opened * Remove focusing new segments logic (not supported by React's segment) * Clean up * Move debouncing to components * Simplify mapping to selectable options * Add docs * Group all components * Remove unused controller methods * Create Dispatch context * Group Series and Tags Sections * Create Functions section * Create Section component * use getStyles * remove redundant async/await * Remove * remove redundant async/await * Remove console.log and silent test console output * Do not display the name of the selected dropdown option * Move Section to grafana-ui * Update storybook * Simplify SectionLabel * Fix Influx tests * Fix API Extractor warnings * Fix API Extractor warnings * Do not show hidden functions * Use block docs for better doc generation * Handle undefined values provided for autocomplete * Section -> SegmentSection * Simplify section styling * Remove redundant div * Simplify SegmentSection component * Use theme.spacing * Use empty label instead of a single space label Co-authored-by: Giordano Ricci --- .../src/components/Forms/InlineFieldRow.tsx | 1 + .../src/components/Segment/Segment.story.tsx | 9 +- .../components/Segment/SegmentAsync.story.tsx | 9 +- .../components/Segment/SegmentInput.story.tsx | 9 +- .../src/components/Segment/SegmentSection.tsx | 51 +++++ .../src/components/Segment/index.ts | 1 + packages/grafana-ui/src/components/index.ts | 2 +- public/app/core/angular_wrappers.ts | 16 +- .../components/AddGraphiteFunction.tsx | 8 +- .../{ => components}/FunctionEditor.test.tsx | 2 +- .../{ => components}/FunctionEditor.tsx | 2 +- .../FunctionEditorControls.tsx | 2 +- .../graphite/components/FunctionsSection.tsx | 21 +++ .../components/GraphiteFunctionEditor.tsx | 8 +- .../components/GraphiteQueryEditor.tsx | 26 +++ .../components/GraphiteTextEditor.tsx | 25 ++- .../graphite/components/MetricSegment.tsx | 6 +- .../MetricTankMetaInspector.tsx | 6 +- .../graphite/components/MetricsSection.tsx | 23 +-- .../graphite/components/PlayButton.tsx | 10 +- .../graphite/components/SeriesSection.tsx | 22 +-- .../graphite/components/TagEditor.tsx | 6 +- .../graphite/components/TagsSection.tsx | 18 +- .../app/plugins/datasource/graphite/module.ts | 2 +- .../graphite/partials/query.editor.html | 41 +--- .../plugins/datasource/graphite/query_ctrl.ts | 175 +----------------- .../datasource/graphite/state/context.tsx | 16 ++ .../VisualInfluxQLEditor/Editor.test.tsx | 36 ++-- .../VisualInfluxQLEditor/Editor.tsx | 64 ++++--- .../VisualInfluxQLEditor/SectionLabel.tsx | 15 -- 30 files changed, 245 insertions(+), 387 deletions(-) create mode 100644 packages/grafana-ui/src/components/Segment/SegmentSection.tsx rename public/app/plugins/datasource/graphite/{ => components}/FunctionEditor.test.tsx (96%) rename public/app/plugins/datasource/graphite/{ => components}/FunctionEditor.tsx (98%) rename public/app/plugins/datasource/graphite/{ => components}/FunctionEditorControls.tsx (97%) create mode 100644 public/app/plugins/datasource/graphite/components/FunctionsSection.tsx create mode 100644 public/app/plugins/datasource/graphite/components/GraphiteQueryEditor.tsx rename public/app/plugins/datasource/graphite/{ => components}/MetricTankMetaInspector.tsx (98%) create mode 100644 public/app/plugins/datasource/graphite/state/context.tsx delete mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/SectionLabel.tsx diff --git a/packages/grafana-ui/src/components/Forms/InlineFieldRow.tsx b/packages/grafana-ui/src/components/Forms/InlineFieldRow.tsx index 72894d2b5fe..0409ef4d326 100644 --- a/packages/grafana-ui/src/components/Forms/InlineFieldRow.tsx +++ b/packages/grafana-ui/src/components/Forms/InlineFieldRow.tsx @@ -24,6 +24,7 @@ const getStyles = (theme: GrafanaTheme) => { flex-direction: row; flex-wrap: wrap; align-content: flex-start; + row-gap: ${theme.spacing.xs}; `, }; }; diff --git a/packages/grafana-ui/src/components/Segment/Segment.story.tsx b/packages/grafana-ui/src/components/Segment/Segment.story.tsx index 826e192746b..ab629544ce8 100644 --- a/packages/grafana-ui/src/components/Segment/Segment.story.tsx +++ b/packages/grafana-ui/src/components/Segment/Segment.story.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { action } from '@storybook/addon-actions'; -import { Segment, Icon } from '@grafana/ui'; +import { Segment, Icon, SegmentSection } from '@grafana/ui'; const AddButton = ( @@ -17,13 +17,10 @@ const groupedOptions = [ const SegmentFrame = ({ options, children }: any) => ( <> -
-
- Segment Name -
+ {children} action('New value added')(value)} options={options} /> -
+ ); diff --git a/packages/grafana-ui/src/components/Segment/SegmentAsync.story.tsx b/packages/grafana-ui/src/components/Segment/SegmentAsync.story.tsx index fb3bcf10612..0c720b7350c 100644 --- a/packages/grafana-ui/src/components/Segment/SegmentAsync.story.tsx +++ b/packages/grafana-ui/src/components/Segment/SegmentAsync.story.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { AsyncState } from 'react-use/lib/useAsync'; import { action } from '@storybook/addon-actions'; import { SelectableValue } from '@grafana/data'; -import { SegmentAsync, Icon } from '@grafana/ui'; +import { SegmentAsync, Icon, SegmentSection } from '@grafana/ui'; const AddButton = (
@@ -21,17 +21,14 @@ const loadOptionsErr = (): Promise>> => const SegmentFrame = ({ loadOptions, children }: any) => ( <> -
-
- Segment Name -
+ {children} action('New value added')(value)} loadOptions={() => loadOptions(options)} /> -
+ ); diff --git a/packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx b/packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx index 7377a0f59ab..af21609e1df 100644 --- a/packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx +++ b/packages/grafana-ui/src/components/Segment/SegmentInput.story.tsx @@ -1,15 +1,10 @@ import React, { useState } from 'react'; import { action } from '@storybook/addon-actions'; -import { SegmentInput, Icon } from '@grafana/ui'; +import { SegmentInput, Icon, SegmentSection } from '@grafana/ui'; const SegmentFrame = ({ children }: any) => ( <> -
-
- Segment Name -
- {children} -
+ {children} ); diff --git a/packages/grafana-ui/src/components/Segment/SegmentSection.tsx b/packages/grafana-ui/src/components/Segment/SegmentSection.tsx new file mode 100644 index 00000000000..8cc5b7f751d --- /dev/null +++ b/packages/grafana-ui/src/components/Segment/SegmentSection.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '../../themes'; +import { InlineLabel } from '../Forms/InlineLabel'; +import { InlineFieldRow } from '../Forms/InlineFieldRow'; + +/** + * Horizontal section for editor components. + * + * @alpha + */ +export const SegmentSection = ({ + label, + children, + fill, +}: { + // Name of the section + label: string; + // List of components in the section + children: React.ReactNode; + // Fill the space at the end + fill?: boolean; +}) => { + const styles = useStyles2(getStyles); + return ( + <> + + + {label} + + {children} + {fill && ( +
+ {''} +
+ )} +
+ + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + label: css` + color: ${theme.colors.primary.text}; + `, + fill: css` + flex-grow: 1; + margin-bottom: ${theme.spacing(0.5)}; + `, +}); diff --git a/packages/grafana-ui/src/components/Segment/index.ts b/packages/grafana-ui/src/components/Segment/index.ts index 040d96f3612..95c32736f69 100644 --- a/packages/grafana-ui/src/components/Segment/index.ts +++ b/packages/grafana-ui/src/components/Segment/index.ts @@ -2,5 +2,6 @@ export { Segment } from './Segment'; export { SegmentAsync } from './SegmentAsync'; export { SegmentSelect } from './SegmentSelect'; export { SegmentInput } from './SegmentInput'; +export { SegmentSection } from './SegmentSection'; export { SegmentProps } from './types'; export { useExpandableLabel } from './useExpandableLabel'; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 7d61b2523f4..3c7458379a0 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -152,7 +152,7 @@ export { CertificationKey } from './DataSourceSettings/CertificationKey'; export { Spinner } from './Spinner/Spinner'; export { FadeTransition } from './transitions/FadeTransition'; export { SlideOutTransition } from './transitions/SlideOutTransition'; -export { Segment, SegmentAsync, SegmentInput, SegmentSelect } from './Segment/'; +export { Segment, SegmentAsync, SegmentInput, SegmentSelect, SegmentSection } from './Segment/'; export { Drawer } from './Drawer/Drawer'; export { Slider } from './Slider/Slider'; export { RangeSlider } from './Slider/RangeSlider'; diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 0b695b30911..1fb3645faad 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -12,12 +12,11 @@ import { DataSourceHttpSettings, GraphContextMenu, Icon, - Spinner, LegacyForms, SeriesColorPickerPopoverWithTheme, + Spinner, UnitPicker, } from '@grafana/ui'; -import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor'; import { LokiAnnotationsQueryEditor } from '../plugins/datasource/loki/components/AnnotationsQueryEditor'; import { HelpModal } from './components/help/HelpModal'; import { Footer } from './components/Footer/Footer'; @@ -25,11 +24,7 @@ import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { SearchField, SearchResults, SearchResultsFilter } from '../features/search'; import { TimePickerSettings } from 'app/features/dashboard/components/DashboardSettings/TimePickerSettings'; import QueryEditor from 'app/plugins/datasource/grafana-azure-monitor-datasource/components/QueryEditor/QueryEditor'; -import { GraphiteTextEditor } from '../plugins/datasource/graphite/components/GraphiteTextEditor'; -import { PlayButton } from '../plugins/datasource/graphite/components/PlayButton'; -import { AddGraphiteFunction } from '../plugins/datasource/graphite/components/AddGraphiteFunction'; -import { GraphiteFunctionEditor } from '../plugins/datasource/graphite/components/GraphiteFunctionEditor'; -import { SeriesSection } from '../plugins/datasource/graphite/components/SeriesSection'; +import { GraphiteQueryEditor } from '../plugins/datasource/graphite/components/GraphiteQueryEditor'; const { SecretFormField } = LegacyForms; @@ -207,10 +202,5 @@ export function registerAngularDirectives() { ]); // Temporal wrappers for Graphite migration - react2AngularDirective('functionEditor', FunctionEditor, ['func', 'onRemove', 'onMoveLeft', 'onMoveRight']); - react2AngularDirective('graphiteTextEditor', GraphiteTextEditor, ['rawQuery', 'dispatch']); - react2AngularDirective('playButton', PlayButton, ['dispatch']); - react2AngularDirective('addGraphiteFunction', AddGraphiteFunction, ['funcDefs', 'dispatch']); - react2AngularDirective('graphiteFunctionEditor', GraphiteFunctionEditor, ['func', 'dispatch']); - react2AngularDirective('seriesSection', SeriesSection, ['state', 'dispatch']); + react2AngularDirective('graphiteQueryEditor', GraphiteQueryEditor, ['state', 'dispatch']); } diff --git a/public/app/plugins/datasource/graphite/components/AddGraphiteFunction.tsx b/public/app/plugins/datasource/graphite/components/AddGraphiteFunction.tsx index 74160af4f69..c130bf0c5d9 100644 --- a/public/app/plugins/datasource/graphite/components/AddGraphiteFunction.tsx +++ b/public/app/plugins/datasource/graphite/components/AddGraphiteFunction.tsx @@ -5,14 +5,14 @@ import { actions } from '../state/actions'; import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { css, cx } from '@emotion/css'; import { mapFuncDefsToSelectables } from './helpers'; -import { Dispatch } from 'redux'; +import { useDispatch } from '../state/context'; type Props = { - dispatch: Dispatch; funcDefs: FuncDefs; }; -export function AddGraphiteFunction({ dispatch, funcDefs }: Props) { +export function AddGraphiteFunction({ funcDefs }: Props) { + const dispatch = useDispatch(); const [value, setValue] = useState | undefined>(undefined); const styles = useStyles2(getStyles); @@ -37,7 +37,7 @@ export function AddGraphiteFunction({ dispatch, funcDefs }: Props) { options={options} onChange={setValue} inputMinWidth={150} - > + /> ); } diff --git a/public/app/plugins/datasource/graphite/FunctionEditor.test.tsx b/public/app/plugins/datasource/graphite/components/FunctionEditor.test.tsx similarity index 96% rename from public/app/plugins/datasource/graphite/FunctionEditor.test.tsx rename to public/app/plugins/datasource/graphite/components/FunctionEditor.test.tsx index db5260fad8c..b8527aa1a4c 100644 --- a/public/app/plugins/datasource/graphite/FunctionEditor.test.tsx +++ b/public/app/plugins/datasource/graphite/components/FunctionEditor.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { FunctionEditor } from './FunctionEditor'; -import { FuncInstance } from './gfunc'; +import { FuncInstance } from '../gfunc'; function mockFunctionInstance(name: string, unknown?: boolean): FuncInstance { const def = { diff --git a/public/app/plugins/datasource/graphite/FunctionEditor.tsx b/public/app/plugins/datasource/graphite/components/FunctionEditor.tsx similarity index 98% rename from public/app/plugins/datasource/graphite/FunctionEditor.tsx rename to public/app/plugins/datasource/graphite/components/FunctionEditor.tsx index fbcab41ddca..38932d077c3 100644 --- a/public/app/plugins/datasource/graphite/FunctionEditor.tsx +++ b/public/app/plugins/datasource/graphite/components/FunctionEditor.tsx @@ -1,7 +1,7 @@ import React, { useRef } from 'react'; import { PopoverController, Popover, ClickOutsideWrapper, Icon, Tooltip, useStyles2 } from '@grafana/ui'; import { FunctionEditorControls, FunctionEditorControlsProps } from './FunctionEditorControls'; -import { FuncInstance } from './gfunc'; +import { FuncInstance } from '../gfunc'; import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; diff --git a/public/app/plugins/datasource/graphite/FunctionEditorControls.tsx b/public/app/plugins/datasource/graphite/components/FunctionEditorControls.tsx similarity index 97% rename from public/app/plugins/datasource/graphite/FunctionEditorControls.tsx rename to public/app/plugins/datasource/graphite/components/FunctionEditorControls.tsx index 731cc96bb7e..f4d1fa52fa8 100644 --- a/public/app/plugins/datasource/graphite/FunctionEditorControls.tsx +++ b/public/app/plugins/datasource/graphite/components/FunctionEditorControls.tsx @@ -1,6 +1,6 @@ import React, { Suspense } from 'react'; import { Icon, Tooltip } from '@grafana/ui'; -import { FuncInstance } from './gfunc'; +import { FuncInstance } from '../gfunc'; export interface FunctionEditorControlsProps { onMoveLeft: (func: FuncInstance) => void; diff --git a/public/app/plugins/datasource/graphite/components/FunctionsSection.tsx b/public/app/plugins/datasource/graphite/components/FunctionsSection.tsx new file mode 100644 index 00000000000..81cce8c702a --- /dev/null +++ b/public/app/plugins/datasource/graphite/components/FunctionsSection.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { FuncDefs, FuncInstance } from '../gfunc'; +import { GraphiteFunctionEditor } from './GraphiteFunctionEditor'; +import { AddGraphiteFunction } from './AddGraphiteFunction'; +import { SegmentSection } from '@grafana/ui'; + +type Props = { + functions: FuncInstance[]; + funcDefs: FuncDefs; +}; + +export function FunctionsSection({ functions = [], funcDefs }: Props) { + return ( + + {functions.map((func: FuncInstance, index: number) => { + return !func.hidden && ; + })} + + + ); +} diff --git a/public/app/plugins/datasource/graphite/components/GraphiteFunctionEditor.tsx b/public/app/plugins/datasource/graphite/components/GraphiteFunctionEditor.tsx index 9be0952e31a..fcf1b93cef3 100644 --- a/public/app/plugins/datasource/graphite/components/GraphiteFunctionEditor.tsx +++ b/public/app/plugins/datasource/graphite/components/GraphiteFunctionEditor.tsx @@ -5,18 +5,19 @@ import { css, cx } from '@emotion/css'; import { FuncInstance } from '../gfunc'; import { EditableParam, FunctionParamEditor } from './FunctionParamEditor'; import { actions } from '../state/actions'; -import { FunctionEditor } from '../FunctionEditor'; +import { FunctionEditor } from './FunctionEditor'; import { mapFuncInstanceToParams } from './helpers'; +import { useDispatch } from '../state/context'; export type FunctionEditorProps = { func: FuncInstance; - dispatch: (action: any) => void; }; /** * Allows editing function params and removing/moving a function (note: editing function name is not supported) */ -export function GraphiteFunctionEditor({ func, dispatch }: FunctionEditorProps) { +export function GraphiteFunctionEditor({ func }: FunctionEditorProps) { + const dispatch = useDispatch(); const styles = useStyles2(getStyles); // keep track of mouse over and isExpanded state to display buttons for adding optional/multiple params @@ -81,6 +82,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ borderRadius: theme.shape.borderRadius(), marginRight: theme.spacing(0.5), padding: `0 ${theme.spacing(1)}`, + height: `${theme.v1.spacing.formInputHeight}px`, }), error: css` border: 1px solid ${theme.colors.error.main}; diff --git a/public/app/plugins/datasource/graphite/components/GraphiteQueryEditor.tsx b/public/app/plugins/datasource/graphite/components/GraphiteQueryEditor.tsx new file mode 100644 index 00000000000..d5f861cb43a --- /dev/null +++ b/public/app/plugins/datasource/graphite/components/GraphiteQueryEditor.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Dispatch } from 'redux'; +import { GraphiteQueryEditorState } from '../state/store'; +import { GraphiteTextEditor } from './GraphiteTextEditor'; +import { SeriesSection } from './SeriesSection'; +import { GraphiteContext } from '../state/context'; +import { FunctionsSection } from './FunctionsSection'; + +type Props = { + state: GraphiteQueryEditorState; + dispatch: Dispatch; +}; + +export function GraphiteQueryEditor({ dispatch, state }: Props) { + return ( + + {state.target?.textEditor && } + {!state.target?.textEditor && ( + <> + + + + )} + + ); +} diff --git a/public/app/plugins/datasource/graphite/components/GraphiteTextEditor.tsx b/public/app/plugins/datasource/graphite/components/GraphiteTextEditor.tsx index 68a4d1bc591..5941a4e3953 100644 --- a/public/app/plugins/datasource/graphite/components/GraphiteTextEditor.tsx +++ b/public/app/plugins/datasource/graphite/components/GraphiteTextEditor.tsx @@ -1,14 +1,15 @@ import React, { useCallback } from 'react'; import { QueryField } from '@grafana/ui'; import { actions } from '../state/actions'; -import { Dispatch } from 'redux'; +import { useDispatch } from '../state/context'; type Props = { rawQuery: string; - dispatch: Dispatch; }; -export function GraphiteTextEditor({ rawQuery, dispatch }: Props) { +export function GraphiteTextEditor({ rawQuery }: Props) { + const dispatch = useDispatch(); + const updateQuery = useCallback( (query: string) => { dispatch(actions.updateQuery({ query })); @@ -21,15 +22,13 @@ export function GraphiteTextEditor({ rawQuery, dispatch }: Props) { }, [dispatch]); return ( - <> - - + ); } diff --git a/public/app/plugins/datasource/graphite/components/MetricSegment.tsx b/public/app/plugins/datasource/graphite/components/MetricSegment.tsx index 4afcfe4a67c..ff4717fb7cb 100644 --- a/public/app/plugins/datasource/graphite/components/MetricSegment.tsx +++ b/public/app/plugins/datasource/graphite/components/MetricSegment.tsx @@ -1,17 +1,16 @@ import React, { useCallback, useMemo } from 'react'; import { SegmentAsync } from '@grafana/ui'; import { actions } from '../state/actions'; -import { Dispatch } from 'redux'; import { GraphiteSegment } from '../types'; import { SelectableValue } from '@grafana/data'; import { getAltSegmentsSelectables } from '../state/providers'; import { debounce } from 'lodash'; import { GraphiteQueryEditorState } from '../state/store'; +import { useDispatch } from '../state/context'; type Props = { segment: GraphiteSegment; metricIndex: number; - dispatch: Dispatch; state: GraphiteQueryEditorState; }; @@ -25,7 +24,8 @@ type Props = { * getAltSegmentsSelectables() also returns list of tags for segment with index=0. Once a tag is selected the editor * enters tag-adding mode (see SeriesSection and GraphiteQueryModel.seriesByTagUsed). */ -export function MetricSegment({ dispatch, metricIndex, segment, state }: Props) { +export function MetricSegment({ metricIndex, segment, state }: Props) { + const dispatch = useDispatch(); const loadOptions = useCallback( (value: string | undefined) => { return getAltSegmentsSelectables(state, metricIndex, value || ''); diff --git a/public/app/plugins/datasource/graphite/MetricTankMetaInspector.tsx b/public/app/plugins/datasource/graphite/components/MetricTankMetaInspector.tsx similarity index 98% rename from public/app/plugins/datasource/graphite/MetricTankMetaInspector.tsx rename to public/app/plugins/datasource/graphite/components/MetricTankMetaInspector.tsx index e39aaf04295..3a7ce13b615 100644 --- a/public/app/plugins/datasource/graphite/MetricTankMetaInspector.tsx +++ b/public/app/plugins/datasource/graphite/components/MetricTankMetaInspector.tsx @@ -1,9 +1,9 @@ import { css, cx } from '@emotion/css'; import React, { PureComponent } from 'react'; import { MetadataInspectorProps, rangeUtil } from '@grafana/data'; -import { GraphiteDatasource } from './datasource'; -import { GraphiteQuery, GraphiteOptions, MetricTankSeriesMeta } from './types'; -import { parseSchemaRetentions, getRollupNotice, getRuntimeConsolidationNotice } from './meta'; +import { GraphiteDatasource } from '../datasource'; +import { GraphiteQuery, GraphiteOptions, MetricTankSeriesMeta } from '../types'; +import { parseSchemaRetentions, getRollupNotice, getRuntimeConsolidationNotice } from '../meta'; import { stylesFactory } from '@grafana/ui'; import { config } from 'app/core/config'; diff --git a/public/app/plugins/datasource/graphite/components/MetricsSection.tsx b/public/app/plugins/datasource/graphite/components/MetricsSection.tsx index 6dd3e5fea62..34fb24160d4 100644 --- a/public/app/plugins/datasource/graphite/components/MetricsSection.tsx +++ b/public/app/plugins/datasource/graphite/components/MetricsSection.tsx @@ -1,34 +1,19 @@ import React from 'react'; -import { Dispatch } from 'redux'; import { GraphiteSegment } from '../types'; import { GraphiteQueryEditorState } from '../state/store'; import { MetricSegment } from './MetricSegment'; -import { css } from '@emotion/css'; -import { useStyles2 } from '@grafana/ui'; type Props = { segments: GraphiteSegment[]; - dispatch: Dispatch; state: GraphiteQueryEditorState; }; -export function MetricsSection({ dispatch, segments = [], state }: Props) { - const styles = useStyles2(getStyles); - +export function MetricsSection({ segments = [], state }: Props) { return ( -
+ <> {segments.map((segment, index) => { - return ; + return ; })} -
+ ); } - -function getStyles() { - return { - container: css` - display: flex; - flex-direction: row; - `, - }; -} diff --git a/public/app/plugins/datasource/graphite/components/PlayButton.tsx b/public/app/plugins/datasource/graphite/components/PlayButton.tsx index c9f7035c162..cc1084cc316 100644 --- a/public/app/plugins/datasource/graphite/components/PlayButton.tsx +++ b/public/app/plugins/datasource/graphite/components/PlayButton.tsx @@ -1,14 +1,10 @@ import React, { useCallback } from 'react'; import { Button } from '@grafana/ui'; import { actions } from '../state/actions'; -import { Dispatch } from 'redux'; +import { useDispatch } from '../state/context'; -type Props = { - rawQuery: string; - dispatch: Dispatch; -}; - -export function PlayButton({ dispatch }: Props) { +export function PlayButton() { + const dispatch = useDispatch(); const onClick = useCallback(() => { dispatch(actions.unpause()); }, [dispatch]); diff --git a/public/app/plugins/datasource/graphite/components/SeriesSection.tsx b/public/app/plugins/datasource/graphite/components/SeriesSection.tsx index 647a4232fc9..7ec29a4a3d4 100644 --- a/public/app/plugins/datasource/graphite/components/SeriesSection.tsx +++ b/public/app/plugins/datasource/graphite/components/SeriesSection.tsx @@ -1,23 +1,23 @@ import React from 'react'; -import { Dispatch } from 'redux'; import { GraphiteQueryEditorState } from '../state/store'; import { TagsSection } from './TagsSection'; import { MetricsSection } from './MetricsSection'; +import { SegmentSection } from '@grafana/ui'; type Props = { - dispatch: Dispatch; state: GraphiteQueryEditorState; }; -export function SeriesSection({ dispatch, state }: Props) { - return state.queryModel?.seriesByTagUsed ? ( - +export function SeriesSection({ state }: Props) { + const sectionContent = state.queryModel?.seriesByTagUsed ? ( + ) : ( - + + ); + + return ( + + {sectionContent} + ); } diff --git a/public/app/plugins/datasource/graphite/components/TagEditor.tsx b/public/app/plugins/datasource/graphite/components/TagEditor.tsx index d22b8f6d7bd..4dfa8a1e5ad 100644 --- a/public/app/plugins/datasource/graphite/components/TagEditor.tsx +++ b/public/app/plugins/datasource/graphite/components/TagEditor.tsx @@ -1,16 +1,15 @@ import React, { useCallback, useMemo } from 'react'; -import { Dispatch } from 'redux'; import { Segment, SegmentAsync } from '@grafana/ui'; import { actions } from '../state/actions'; import { GraphiteTag, GraphiteTagOperator } from '../types'; import { getTagOperatorsSelectables, getTagsSelectables, getTagValuesSelectables } from '../state/providers'; import { GraphiteQueryEditorState } from '../state/store'; import { debounce } from 'lodash'; +import { useDispatch } from '../state/context'; type Props = { tag: GraphiteTag; tagIndex: number; - dispatch: Dispatch; state: GraphiteQueryEditorState; }; @@ -22,7 +21,8 @@ type Props = { * Options for tag names and values are reloaded while user is typing with backend taking care of auto-complete * (auto-complete cannot be implemented in front-end because backend returns only limited number of entries) */ -export function TagEditor({ dispatch, tag, tagIndex, state }: Props) { +export function TagEditor({ tag, tagIndex, state }: Props) { + const dispatch = useDispatch(); const getTagsOptions = useCallback( (inputValue: string | undefined) => { return getTagsSelectables(state, tagIndex, inputValue || ''); diff --git a/public/app/plugins/datasource/graphite/components/TagsSection.tsx b/public/app/plugins/datasource/graphite/components/TagsSection.tsx index 2a59005268d..8fdf09313c2 100644 --- a/public/app/plugins/datasource/graphite/components/TagsSection.tsx +++ b/public/app/plugins/datasource/graphite/components/TagsSection.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useMemo } from 'react'; -import { Dispatch } from 'redux'; import { GraphiteSegment } from '../types'; import { GraphiteTag } from '../graphite_query'; import { GraphiteQueryEditorState } from '../state/store'; @@ -11,9 +10,10 @@ import { css } from '@emotion/css'; import { mapSegmentsToSelectables } from './helpers'; import { TagEditor } from './TagEditor'; import { debounce } from 'lodash'; +import { useDispatch } from '../state/context'; +import { PlayButton } from './PlayButton'; type Props = { - dispatch: Dispatch; tags: GraphiteTag[]; addTagSegments: GraphiteSegment[]; state: GraphiteQueryEditorState; @@ -25,7 +25,8 @@ type Props = { * Options for tag names are reloaded while user is typing with backend taking care of auto-complete * (auto-complete cannot be implemented in front-end because backend returns only limited number of entries) */ -export function TagsSection({ dispatch, tags, state, addTagSegments }: Props) { +export function TagsSection({ tags, state, addTagSegments }: Props) { + const dispatch = useDispatch(); const styles = useStyles2(getStyles); const newTagsOptions = mapSegmentsToSelectables(addTagSegments || []); @@ -43,9 +44,9 @@ export function TagsSection({ dispatch, tags, state, addTagSegments }: Props) { ]); return ( -
+ <> {tags.map((tag, index) => { - return ; + return ; })} {newTagsOptions.length && ( @@ -58,16 +59,13 @@ export function TagsSection({ dispatch, tags, state, addTagSegments }: Props) { Component={
+ {state.paused && } + ); } function getStyles(theme: GrafanaTheme2) { return { - container: css` - display: flex; - flex-direction: row; - `, button: css` margin-right: ${theme.spacing(0.5)}; `, diff --git a/public/app/plugins/datasource/graphite/module.ts b/public/app/plugins/datasource/graphite/module.ts index 174061621b0..4d4e0f243cb 100644 --- a/public/app/plugins/datasource/graphite/module.ts +++ b/public/app/plugins/datasource/graphite/module.ts @@ -2,7 +2,7 @@ import { GraphiteDatasource } from './datasource'; import { GraphiteQueryCtrl } from './query_ctrl'; import { DataSourcePlugin } from '@grafana/data'; import { ConfigEditor } from './configuration/ConfigEditor'; -import { MetricTankMetaInspector } from './MetricTankMetaInspector'; +import { MetricTankMetaInspector } from './components/MetricTankMetaInspector'; class AnnotationsQueryCtrl { static templateUrl = 'partials/annotations.editor.html'; diff --git a/public/app/plugins/datasource/graphite/partials/query.editor.html b/public/app/plugins/datasource/graphite/partials/query.editor.html index 4e5406798d0..0d879461995 100644 --- a/public/app/plugins/datasource/graphite/partials/query.editor.html +++ b/public/app/plugins/datasource/graphite/partials/query.editor.html @@ -1,42 +1,3 @@ - -
- -
- -
-
-
- -
- - - -
- -
- -
-
-
-
- -
-
- -
- -
- -
- - - -
-
-
-
-
+
diff --git a/public/app/plugins/datasource/graphite/query_ctrl.ts b/public/app/plugins/datasource/graphite/query_ctrl.ts index f662f3405d6..f179d0387b1 100644 --- a/public/app/plugins/datasource/graphite/query_ctrl.ts +++ b/public/app/plugins/datasource/graphite/query_ctrl.ts @@ -4,13 +4,7 @@ import { auto } from 'angular'; import { TemplateSrv } from '@grafana/runtime'; import { actions } from './state/actions'; import { createStore, GraphiteQueryEditorState } from './state/store'; -import { - GraphiteActionDispatcher, - GraphiteQueryEditorAngularDependencies, - GraphiteSegment, - GraphiteTag, -} from './types'; -import { ChangeEvent } from 'react'; +import { GraphiteActionDispatcher, GraphiteQueryEditorAngularDependencies } from './types'; /** * @deprecated Moved to state/store @@ -81,174 +75,7 @@ export class GraphiteQueryCtrl extends QueryCtrl { this.dispatch(actions.init(deps as GraphiteQueryEditorAngularDependencies)); } - parseTarget() { - // WIP: moved to state/helpers (the same name) - } - async toggleEditorMode() { await this.dispatch(actions.toggleEditorMode()); } - - buildSegments(modifyLastSegment = true) { - // WIP: moved to state/helpers (the same name) - } - - addSelectMetricSegment() { - // WIP: moved to state/helpers (the same name) - } - - checkOtherSegments(fromIndex: number, modifyLastSegment = true) { - // WIP: moved to state/helpers (the same name) - } - - setSegmentFocus(segmentIndex: any) { - // WIP: removed - } - - /** - * Get list of options for an empty segment or a segment with metric when it's clicked/opened. - * - * This is used for new segments and segments with metrics selected. - */ - getAltSegments(index: number, text: string): void { - // WIP: moved to state/providers (the same name) - } - - addAltTagSegments(prefix: string, altSegments: any[]) { - // WIP: moved to state/providers (the same name) - } - - removeTaggedEntry(altSegments: any[]) { - // WIP: moved to state/providers (the same name) - } - - /** - * Apply changes to a given metric segment - */ - async segmentValueChanged(segment: GraphiteSegment, index: number) { - // WIP: moved to MetricsSegment - } - - spliceSegments(index: any) { - // WIP: moved to state/helpers (the same name) - } - - emptySegments() { - // WIP: moved to state/helpers (the same name) - } - - async targetTextChanged(event: ChangeEvent) { - // WIP: removed, handled by GraphiteTextEditor - } - - updateModelTarget() { - // WIP: moved to state/helpers as handleTargetChanged() - } - - async addFunction(name: string) { - // WIP: removed, called from AddGraphiteFunction - } - - removeFunction(func: any) { - // WIP: converted to "removeFunction" action and handled in state/store reducer - // It's now dispatched in func_editor - } - - moveFunction(func: any, offset: any) { - // WIP: converted to "moveFunction" action and handled in state/store reducer - // It's now dispatched in func_editor - } - - addSeriesByTagFunc(tag: string) { - // WIP: moved to state/helpers (the same name) - // It's now dispatched in func_editor - } - - smartlyHandleNewAliasByNode(func: { def: { name: string }; params: number[]; added: boolean }) { - // WIP: moved to state/helpers (the same name) - } - - getAllTags() { - // WIP: removed. It was not used. - } - - /** - * Get list of tags for editing exiting tag with - */ - getTags(index: number, query: string): void { - // WIP: removed, called from TagsSection - } - - /** - * Get tag list when adding a new tag with - */ - getTagsAsSegments(query: string): void { - // WIP: removed, called from TagsSection - } - - /** - * Get list of available tag operators - */ - getTagOperators(): void { - // WIP: removed, called from TagsSection - } - - getAllTagValues(tag: { key: any }) { - // WIP: removed. It was not used. - } - - /** - * Get list of available tag values - */ - getTagValues(tag: GraphiteTag, index: number, query: string): void { - // WIP: removed, called from TagsSection - } - - /** - * Apply changes when a tag is changed - */ - async tagChanged(tag: GraphiteTag, index: number) { - // WIP: removed, called from TagsSection - } - - async addNewTag(segment: GraphiteSegment) { - // WIP: removed, called from TagsSection - } - - removeTag(index: any) { - // WIP: removed. It was not used. - // Tags are removed by selecting the segment called "-- remove tag --" - } - - fixTagSegments() { - // WIP: moved to state/helpers (the same name) - } - - showDelimiter(index: number) { - // WIP: removed. It was not used because of broken syntax in the template. The logic has been moved directly to the template - } - - pause() { - // WIP: moved to state/helpers (the same name) - } - - async unpause() { - // WIP: removed, called from PlayButton - } - - getCollapsedText() { - // WIP: removed. It was not used. - } - - handleTagsAutoCompleteError(error: Error): void { - // WIP: moved to state/helpers (the same name) - } - - handleMetricsAutoCompleteError(error: Error): void { - // WIP: moved to state/helpers (the same name) - } } - -// WIP: moved to state/providers (the same names) -// function mapToDropdownOptions(results: any[]) {} -// function removeTagPrefix(value: string): string {} diff --git a/public/app/plugins/datasource/graphite/state/context.tsx b/public/app/plugins/datasource/graphite/state/context.tsx new file mode 100644 index 00000000000..78b06381366 --- /dev/null +++ b/public/app/plugins/datasource/graphite/state/context.tsx @@ -0,0 +1,16 @@ +import React, { createContext, Dispatch, PropsWithChildren, useContext } from 'react'; +import { AnyAction } from '@reduxjs/toolkit'; + +type Props = { + dispatch: Dispatch; +}; + +const DispatchContext = createContext>({} as Dispatch); + +export const useDispatch = () => { + return useContext(DispatchContext); +}; + +export const GraphiteContext = ({ children, dispatch }: PropsWithChildren) => { + return {children}; +}; diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.test.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.test.tsx index 74bdc8b1847..d88f6e329b3 100644 --- a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.test.tsx +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.test.tsx @@ -50,12 +50,12 @@ describe('InfluxDB InfluxQL Visual Editor', () => { }; assertEditor( query, - 'from[default][select measurement]where[+]' + - 'select[field]([value])[mean]()[+]' + - 'group by[time]([$__interval])[fill]([null])[+]' + - 'timezone[(optional)]order by time[ASC]' + - 'limit[(optional)]slimit[(optional)]' + - 'format as[time_series]alias[Naming pattern]' + 'FROM[default][select measurement]WHERE[+]' + + 'SELECT[field]([value])[mean]()[+]' + + 'GROUP BY[time]([$__interval])[fill]([null])[+]' + + 'TIMEZONE[(optional)]ORDER BY TIME[ASC]' + + 'LIMIT[(optional)]SLIMIT[(optional)]' + + 'FORMAT AS[time_series]ALIAS[Naming pattern]' ); }); it('should have the alias-field hidden when format-as-table', () => { @@ -66,12 +66,12 @@ describe('InfluxDB InfluxQL Visual Editor', () => { }; assertEditor( query, - 'from[default][select measurement]where[+]' + - 'select[field]([value])[mean]()[+]' + - 'group by[time]([$__interval])[fill]([null])[+]' + - 'timezone[(optional)]order by time[ASC]' + - 'limit[(optional)]slimit[(optional)]' + - 'format as[table]' + 'FROM[default][select measurement]WHERE[+]' + + 'SELECT[field]([value])[mean]()[+]' + + 'GROUP BY[time]([$__interval])[fill]([null])[+]' + + 'TIMEZONE[(optional)]ORDER BY TIME[ASC]' + + 'LIMIT[(optional)]SLIMIT[(optional)]' + + 'FORMAT AS[table]' ); }); it('should handle complex query', () => { @@ -145,13 +145,13 @@ describe('InfluxDB InfluxQL Visual Editor', () => { }; assertEditor( query, - 'from[default][cpu]where[cpu][=][cpu1][AND][cpu][<][cpu3][+]' + - 'select[field]([usage_idle])[mean]()[+]' + + 'FROM[default][cpu]WHERE[cpu][=][cpu1][AND][cpu][<][cpu3][+]' + + 'SELECT[field]([usage_idle])[mean]()[+]' + '[field]([usage_guest])[median]()[holt_winters_with_fit]([10],[2])[+]' + - 'group by[time]([$__interval])[tag]([cpu])[tag]([host])[fill]([null])[+]' + - 'timezone[UTC]order by time[DESC]' + - 'limit[4]slimit[5]' + - 'format as[logs]alias[all i as]' + 'GROUP BY[time]([$__interval])[tag]([cpu])[tag]([host])[fill]([null])[+]' + + 'TIMEZONE[UTC]ORDER BY TIME[DESC]' + + 'LIMIT[4]SLIMIT[5]' + + 'FORMAT AS[logs]ALIAS[all i as]' ); }); }); diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.tsx index 00b199b333f..5c87bcc5a5b 100644 --- a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.tsx +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.tsx @@ -24,10 +24,11 @@ import { changeGroupByPart, } from '../../queryUtils'; import { FormatAsSection } from './FormatAsSection'; -import { SectionLabel } from './SectionLabel'; -import { SectionFill } from './SectionFill'; import { DEFAULT_RESULT_FORMAT } from '../constants'; import { getNewSelectPartOptions, getNewGroupByPartOptions, makePartList } from './partListUtils'; +import { InlineLabel, SegmentSection, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { css } from '@emotion/css'; type Props = { query: InfluxQuery; @@ -51,15 +52,8 @@ function withTemplateVariableOptions(optionsPromise: Promise): Promise return optionsPromise.then((options) => [...getTemplateVariableOptions(), ...options]); } -const SectionWrap = ({ initialName, children }: { initialName: string; children: React.ReactNode }) => ( -
- - {children} - -
-); - export const Editor = (props: Props): JSX.Element => { + const styles = useStyles2(getStyles); const query = normalizeQuery(props.query); const { datasource } = props; const { measurement, policy } = query; @@ -112,7 +106,7 @@ export const Editor = (props: Props): JSX.Element => { return (
- + { } onChange={handleFromSectionChange} /> - + + WHERE + { withTemplateVariableOptions(getTagValues(key, measurement, policy, query.tags ?? [], datasource)) } /> - + {selectLists.map((sel, index) => ( - + Promise.resolve(getNewSelectPartOptions())} @@ -150,9 +146,9 @@ export const Editor = (props: Props): JSX.Element => { onAppliedChange(removeSelectPart(query, partIndex, index)); }} /> - + ))} - + getNewGroupByPartOptions(query, getTagKeys)} @@ -167,8 +163,8 @@ export const Editor = (props: Props): JSX.Element => { onAppliedChange(removeGroupByPart(query, partIndex)); }} /> - - + + { onAppliedChange({ ...query, tz }); }} /> - + + ORDER BY TIME + { onAppliedChange({ ...query, orderByTime: v }); }} /> - + {/* query.fill is ignored in the query-editor, and it is deleted whenever query-editor changes. the influx_query_model still handles it, but the new approach seem to be to handle "fill" inside query.groupBy. so, if you - have a panel where in the json you have query.fill, it will be appled, + have a panel where in the json you have query.fill, it will be applied, as long as you do not edit that query. */} - + { onAppliedChange({ ...query, limit }); }} /> - + + SLIMIT + { onAppliedChange({ ...query, slimit }); }} /> - - + + { @@ -215,7 +215,9 @@ export const Editor = (props: Props): JSX.Element => { /> {query.resultFormat !== 'table' && ( <> - + + ALIAS + { /> )} - +
); }; + +function getStyles(theme: GrafanaTheme2) { + return { + inlineLabel: css` + color: ${theme.colors.primary.text}; + `, + }; +} diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/SectionLabel.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/SectionLabel.tsx deleted file mode 100644 index 93df9933cb1..00000000000 --- a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/SectionLabel.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { cx, css } from '@emotion/css'; - -type Props = { - name: string; - isInitial?: boolean; -}; - -const uppercaseClass = css({ - textTransform: 'uppercase', -}); - -export const SectionLabel = ({ name, isInitial }: Props) => ( - -);