From 3f5dcbfdb54101df7c6153c0b7347abd026dd8de Mon Sep 17 00:00:00 2001 From: Igor Suleymanov Date: Fri, 22 Oct 2021 13:34:21 +0300 Subject: [PATCH 01/11] Refactor Prometheus response handling and reduce allocations (#40765) Signed-off-by: Igor Suleymanov --- pkg/tsdb/prometheus/prometheus.go | 174 +++++++++++++++--------------- 1 file changed, 85 insertions(+), 89 deletions(-) diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index 6043861773e..5366b1a2339 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -161,9 +161,9 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) if err != nil { plog.Error("Range query", query.Expr, "failed with", err) result.Responses[query.RefId] = backend.DataResponse{Error: err} - continue + } else { + response[RangeQueryType] = rangeResponse } - response[RangeQueryType] = rangeResponse } if query.InstantQuery { @@ -171,9 +171,9 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) if err != nil { plog.Error("Instant query", query.Expr, "failed with", err) result.Responses[query.RefId] = backend.DataResponse{Error: err} - continue + } else { + response[InstantQueryType] = instantResponse } - response[InstantQueryType] = instantResponse } if query.ExemplarQuery { @@ -334,37 +334,32 @@ func (s *Service) parseQuery(queryContext *backend.QueryDataRequest, dsInfo *Dat } func parseResponse(value map[PrometheusQueryType]interface{}, query *PrometheusQuery) (data.Frames, error) { - frames := data.Frames{} + var ( + frames = data.Frames{} + nextFrames = data.Frames{} + ) for _, value := range value { - matrix, ok := value.(model.Matrix) - if ok { - matrixFrames := matrixToDataFrames(matrix, query) - frames = append(frames, matrixFrames...) + // Zero out the slice to prevent data corruption. + nextFrames = nextFrames[:0] + + switch v := value.(type) { + case model.Matrix: + nextFrames = matrixToDataFrames(v, query, nextFrames) + case model.Vector: + nextFrames = vectorToDataFrames(v, query, nextFrames) + case *model.Scalar: + nextFrames = scalarToDataFrames(v, query, nextFrames) + case []apiv1.ExemplarQueryResult: + nextFrames = exemplarToDataFrames(v, query, nextFrames) + default: + plog.Error("Query", query.Expr, "returned unexpected result type", v) continue } - vector, ok := value.(model.Vector) - if ok { - vectorFrames := vectorToDataFrames(vector, query) - frames = append(frames, vectorFrames...) - continue - } - - scalar, ok := value.(*model.Scalar) - if ok { - scalarFrames := scalarToDataFrames(scalar, query) - frames = append(frames, scalarFrames...) - continue - } - - exemplar, ok := value.([]apiv1.ExemplarQueryResult) - if ok { - exemplarFrames := exemplarToDataFrames(exemplar, query) - frames = append(frames, exemplarFrames...) - continue - } + frames = append(frames, nextFrames...) } + return frames, nil } @@ -398,9 +393,7 @@ func calculateRateInterval(interval time.Duration, scrapeInterval string, interv return rateInterval } -func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery) data.Frames { - frames := data.Frames{} - +func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery, frames data.Frames) data.Frames { for _, v := range matrix { tags := make(map[string]string, len(v.Metric)) for k, v := range v.Metric { @@ -424,61 +417,58 @@ func matrixToDataFrames(matrix model.Matrix, query *PrometheusQuery) data.Frames valueField.Config = &data.FieldConfig{DisplayNameFromDS: name} valueField.Labels = tags - frame := data.NewFrame(name, timeField, valueField) - frame.Meta = &data.FrameMeta{ - Custom: map[string]string{ - "resultType": "matrix", - }, - } - frames = append(frames, frame) + frames = append(frames, newDataFrame(name, "matrix", timeField, valueField)) } + return frames } -func scalarToDataFrames(scalar *model.Scalar, query *PrometheusQuery) data.Frames { +func scalarToDataFrames(scalar *model.Scalar, query *PrometheusQuery, frames data.Frames) data.Frames { timeVector := []time.Time{time.Unix(scalar.Timestamp.Unix(), 0).UTC()} values := []float64{float64(scalar.Value)} name := fmt.Sprintf("%g", values[0]) - frame := data.NewFrame(name, - data.NewField("Time", nil, timeVector), - data.NewField("Value", nil, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: name})) - frame.Meta = &data.FrameMeta{ - Custom: map[string]string{ - "resultType": "scalar", - }, - } - frames := data.Frames{frame} - return frames + return append( + frames, + newDataFrame( + name, + "scalar", + data.NewField("Time", nil, timeVector), + data.NewField("Value", nil, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: name}), + ), + ) } -func vectorToDataFrames(vector model.Vector, query *PrometheusQuery) data.Frames { - frames := data.Frames{} +func vectorToDataFrames(vector model.Vector, query *PrometheusQuery, frames data.Frames) data.Frames { for _, v := range vector { name := formatLegend(v.Metric, query) tags := make(map[string]string, len(v.Metric)) timeVector := []time.Time{time.Unix(v.Timestamp.Unix(), 0).UTC()} values := []float64{float64(v.Value)} + for k, v := range v.Metric { tags[string(k)] = string(v) } - frame := data.NewFrame(name, - data.NewField("Time", nil, timeVector), - data.NewField("Value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: name})) - frame.Meta = &data.FrameMeta{ - Custom: map[string]string{ - "resultType": "vector", - }, - } - frames = append(frames, frame) + + frames = append( + frames, + newDataFrame( + name, + "vector", + data.NewField("Time", nil, timeVector), + data.NewField("Value", tags, values).SetConfig(&data.FieldConfig{DisplayNameFromDS: name}), + ), + ) } return frames } -func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *PrometheusQuery) data.Frames { - frames := data.Frames{} - events := make([]ExemplarEvent, 0) +func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *PrometheusQuery, frames data.Frames) data.Frames { + // TODO: this preallocation is very naive. + // We should figure out a better approximation here. + events := make([]ExemplarEvent, 0, len(response)*2) + for _, exemplarData := range response { for _, exemplar := range exemplarData.Exemplars { event := ExemplarEvent{} @@ -486,9 +476,11 @@ func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *Prometheu event.Time = exemplarTime event.Value = float64(exemplar.Value) event.Labels = make(map[string]string) + for label, value := range exemplar.Labels { event.Labels[string(label)] = string(value) } + for seriesLabel, seriesValue := range exemplarData.SeriesLabels { event.Labels[string(seriesLabel)] = string(seriesValue) } @@ -497,11 +489,11 @@ func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *Prometheu } } - //Sampling of exemplars + // Sampling of exemplars bucketedExemplars := make(map[string][]ExemplarEvent) - values := make([]float64, 0) + values := make([]float64, 0, len(events)) - //Create bucketed exemplars based on aligned timestamp + // Create bucketed exemplars based on aligned timestamp for _, event := range events { alignedTs := fmt.Sprintf("%.0f", math.Floor(float64(event.Time.Unix())/query.Step.Seconds())*query.Step.Seconds()) _, ok := bucketedExemplars[alignedTs] @@ -513,18 +505,18 @@ func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *Prometheu values = append(values, event.Value) } - //Calculate standard deviation + // Calculate standard deviation standardDeviation := deviation(values) - //Create slice with all of the bucketed exemplars + // Create slice with all of the bucketed exemplars sampledBuckets := make([]string, len(bucketedExemplars)) for bucketTimes := range bucketedExemplars { sampledBuckets = append(sampledBuckets, bucketTimes) } sort.Strings(sampledBuckets) - //Sample exemplars based ona value, so we are not showing too many of them - sampleExemplars := make([]ExemplarEvent, 0) + // Sample exemplars based ona value, so we are not showing too many of them + sampleExemplars := make([]ExemplarEvent, 0, len(sampledBuckets)) for _, bucket := range sampledBuckets { exemplarsInBucket := bucketedExemplars[bucket] if len(exemplarsInBucket) == 1 { @@ -561,13 +553,15 @@ func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *Prometheu } // Create DF from sampled exemplars - timeVector := make([]time.Time, 0, len(sampleExemplars)) - valuesVector := make([]float64, 0, len(sampleExemplars)) + timeField := data.NewFieldFromFieldType(data.FieldTypeTime, len(sampleExemplars)) + timeField.Name = "Time" + valueField := data.NewFieldFromFieldType(data.FieldTypeFloat64, len(sampleExemplars)) + valueField.Name = "Value" labelsVector := make(map[string][]string, len(sampleExemplars)) - for _, exemplar := range sampleExemplars { - timeVector = append(timeVector, exemplar.Time) - valuesVector = append(valuesVector, exemplar.Value) + for i, exemplar := range sampleExemplars { + timeField.Set(i, exemplar.Time) + valueField.Set(i, exemplar.Value) for label, value := range exemplar.Labels { if labelsVector[label] == nil { @@ -578,22 +572,13 @@ func exemplarToDataFrames(response []apiv1.ExemplarQueryResult, query *Prometheu } } - frame := data.NewFrame("exemplar", - data.NewField("Time", nil, timeVector), - data.NewField("Value", nil, valuesVector)) - + dataFields := make([]*data.Field, 0, len(labelsVector)+2) + dataFields = append(dataFields, timeField, valueField) for label, vector := range labelsVector { - frame.Fields = append(frame.Fields, data.NewField(label, nil, vector)) + dataFields = append(dataFields, data.NewField(label, nil, vector)) } - frame.Meta = &data.FrameMeta{ - Custom: map[string]PrometheusQueryType{ - "resultType": "exemplar", - }, - } - - frames = append(frames, frame) - return frames + return append(frames, newDataFrame("exemplar", "exemplar", dataFields...)) } func deviation(values []float64) float64 { @@ -608,3 +593,14 @@ func deviation(values []float64) float64 { } return math.Sqrt(sd / (valuesLen - 1)) } + +func newDataFrame(name string, typ string, fields ...*data.Field) *data.Frame { + frame := data.NewFrame(name, fields...) + frame.Meta = &data.FrameMeta{ + Custom: map[string]string{ + "resultType": typ, + }, + } + + return frame +} From 35e2a3a126a90e1d0aa8849b841c57f4596625ac Mon Sep 17 00:00:00 2001 From: Giordano Ricci Date: Fri, 22 Oct 2021 12:47:48 +0100 Subject: [PATCH 02/11] Explore: fix history settings tab padding (#40752) --- public/app/features/explore/RichHistory/RichHistorySettings.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/public/app/features/explore/RichHistory/RichHistorySettings.tsx b/public/app/features/explore/RichHistory/RichHistorySettings.tsx index 27cdd934a8f..f07b0c5136f 100644 --- a/public/app/features/explore/RichHistory/RichHistorySettings.tsx +++ b/public/app/features/explore/RichHistory/RichHistorySettings.tsx @@ -22,7 +22,6 @@ export interface RichHistorySettingsProps { const getStyles = stylesFactory((theme: GrafanaTheme) => { return { container: css` - padding-left: ${theme.spacing.sm}; font-size: ${theme.typography.size.sm}; .space-between { margin-bottom: ${theme.spacing.lg}; From e9df40d7d01d74b13c30d2a88db53ea1e6e8db6a Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 22 Oct 2021 15:03:47 +0100 Subject: [PATCH 03/11] Chore: Ignore copy-webpack-plugin updates in toolkit (#40812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Chore(dependencies): Ignore updates to copy-webpack-plugin in @grafana/toolkit for now * Chore(dependencies): Ignore updates to css-loader in @grafana/toolkit for now Co-authored-by: Gábor Farkas --- .github/renovate.json5 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 8886041e644..946a15c5bef 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -17,6 +17,15 @@ "minor": { "enabled": false }, + "packageRules": [ + { + "matchPaths": ["grafana-toolkit/package.json"], + "ignoreDeps": [ + "copy-webpack-plugin", // need to wait for Grafana 9 to upgrade toolkit to webpack 5 + "css-loader", // need to wait for Grafana 9 to upgrade toolkit to webpack 5 + ] + } + ], "patch": { "enabled": false }, From ca4b35e596fda02035c16e831febe2a1f9a1eff5 Mon Sep 17 00:00:00 2001 From: Kevin Minehart Date: Fri, 22 Oct 2021 09:19:55 -0500 Subject: [PATCH 04/11] Packaging: remove private users setting in systemd unit files (#40815) * Remove privateusers=true from grafana-server.service --- packaging/deb/systemd/grafana-server.service | 1 - packaging/rpm/systemd/grafana-server.service | 1 - 2 files changed, 2 deletions(-) diff --git a/packaging/deb/systemd/grafana-server.service b/packaging/deb/systemd/grafana-server.service index d8633990817..54be80c06bb 100644 --- a/packaging/deb/systemd/grafana-server.service +++ b/packaging/deb/systemd/grafana-server.service @@ -33,7 +33,6 @@ MemoryDenyWriteExecute=false NoNewPrivileges=true PrivateDevices=true PrivateTmp=true -PrivateUsers=true ProtectClock=true ProtectControlGroups=true ProtectHome=true diff --git a/packaging/rpm/systemd/grafana-server.service b/packaging/rpm/systemd/grafana-server.service index ba553ab34b0..e9af40136e4 100644 --- a/packaging/rpm/systemd/grafana-server.service +++ b/packaging/rpm/systemd/grafana-server.service @@ -32,7 +32,6 @@ MemoryDenyWriteExecute=false NoNewPrivileges=true PrivateDevices=true PrivateTmp=true -PrivateUsers=true ProtectClock=true ProtectControlGroups=true ProtectHome=true From 73e66d97a5ef045d3bcd7a794a3cbbeef1632f8c Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Fri, 22 Oct 2021 16:37:41 +0200 Subject: [PATCH 05/11] Tempo: Add error metric to service graph (#40774) * Add typings * Query error metric for service graph --- .../datasource/tempo/datasource.test.ts | 15 ++- .../plugins/datasource/tempo/datasource.ts | 3 - .../datasource/tempo/graphTransform.test.ts | 26 ++++- .../datasource/tempo/graphTransform.ts | 96 ++++++++++++++----- 4 files changed, 106 insertions(+), 34 deletions(-) diff --git a/public/app/plugins/datasource/tempo/datasource.test.ts b/public/app/plugins/datasource/tempo/datasource.test.ts index 444f5c44772..0e557d2b37b 100644 --- a/public/app/plugins/datasource/tempo/datasource.test.ts +++ b/public/app/plugins/datasource/tempo/datasource.test.ts @@ -209,7 +209,7 @@ const backendSrvWithPrometheus = { if (uid === 'prom') { return { query() { - return of({ data: [totalsPromMetric, secondsPromMetric] }); + return of({ data: [totalsPromMetric, secondsPromMetric, failedPromMetric] }); }, }; } @@ -280,6 +280,19 @@ const secondsPromMetric = new MutableDataFrame({ ], }); +const failedPromMetric = new MutableDataFrame({ + refId: 'traces_service_graph_request_failed_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 #traces_service_graph_request_failed_total', values: [2, 15] }, + ], +}); + const mockInvalidJson = { batches: [ { diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index 20359b6443c..51069ad85a0 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -292,9 +292,6 @@ function serviceMapQuery(request: DataQueryRequest, datasourceUid: s data: mapPromMetricsToServiceMap(responses, request.range), state: LoadingState.Done, }; - }), - catchError((error) => { - return of({ error: { message: error.message }, data: [] }); }) ); } diff --git a/public/app/plugins/datasource/tempo/graphTransform.test.ts b/public/app/plugins/datasource/tempo/graphTransform.test.ts index 43f595b9471..639fcd22852 100644 --- a/public/app/plugins/datasource/tempo/graphTransform.test.ts +++ b/public/app/plugins/datasource/tempo/graphTransform.test.ts @@ -64,16 +64,21 @@ describe('mapPromMetricsToServiceMap', () => { from: dateTime('2000-01-01T00:00:00'), to: dateTime('2000-01-01T00:01:00'), }; - const [nodes, edges] = mapPromMetricsToServiceMap([{ data: [totalsPromMetric, secondsPromMetric] }], { - ...range, - raw: range, - }); + const [nodes, edges] = mapPromMetricsToServiceMap( + [{ data: [totalsPromMetric, secondsPromMetric, failedPromMetric] }], + { + ...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([0.17, 0.33, NaN]) }, + { name: 'arc__success', values: new ArrayVector([0.8, 0.25, 1]) }, + { name: 'arc__failed', values: new ArrayVector([0.2, 0.75, 0]) }, ]); expect(edges.fields).toMatchObject([ { name: 'id', values: new ArrayVector(['app_db', 'lb_app']) }, @@ -134,3 +139,16 @@ const secondsPromMetric = new MutableDataFrame({ { name: 'Value #traces_service_graph_request_server_seconds_sum', values: [10, 40] }, ], }); + +const failedPromMetric = new MutableDataFrame({ + refId: 'traces_service_graph_request_failed_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 #traces_service_graph_request_failed_total', values: [2, 15] }, + ], +}); diff --git a/public/app/plugins/datasource/tempo/graphTransform.ts b/public/app/plugins/datasource/tempo/graphTransform.ts index 3de895b2cf9..5ce34e5806a 100644 --- a/public/app/plugins/datasource/tempo/graphTransform.ts +++ b/public/app/plugins/datasource/tempo/graphTransform.ts @@ -1,8 +1,8 @@ -import { groupBy } from 'lodash'; import { DataFrame, DataFrameView, DataQueryResponse, + FieldColorModeId, FieldDTO, MutableDataFrame, NodeGraphDataFrameFieldNames as Fields, @@ -10,6 +10,9 @@ import { } from '@grafana/data'; import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '../../../core/utils/tracing'; +/** + * Row in a trace dataFrame + */ interface Row { traceID: string; spanID: string; @@ -129,10 +132,12 @@ function findTraceDuration(view: DataFrameView): number { const secondsMetric = 'traces_service_graph_request_server_seconds_sum'; const totalsMetric = 'traces_service_graph_request_total'; +const failedMetric = 'traces_service_graph_request_failed_total'; export const serviceMapMetrics = [ secondsMetric, totalsMetric, + failedMetric, // We don't show histogram in node graph at the moment but we could later add that into a node context menu. // 'traces_service_graph_request_seconds_bucket', // 'traces_service_graph_request_seconds_count', @@ -147,14 +152,15 @@ export const serviceMapMetrics = [ * @param range */ export function mapPromMetricsToServiceMap(responses: DataQueryResponse[], range: TimeRange): [DataFrame, DataFrame] { - const [totalsDFView, secondsDFView] = getMetricFrames(responses); + const frames = getMetricFrames(responses); // First just collect data from the metrics into a map with nodes and edges as keys - const nodesMap: Record = {}; - const edgesMap: Record = {}; + 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); + collectMetricData(frames[totalsMetric], 'total', totalsMetric, nodesMap, edgesMap); + collectMetricData(frames[secondsMetric], 'seconds', secondsMetric, nodesMap, edgesMap); + collectMetricData(frames[failedMetric], 'failed', failedMetric, nodesMap, edgesMap); return convertToDataFrames(nodesMap, edgesMap, range); } @@ -172,6 +178,14 @@ function createServiceMapDataFrames() { name: Fields.secondaryStat, config: { unit: 'r/sec', displayName: 'Requests per second' }, }, + { + name: Fields.arc + 'success', + config: { displayName: 'Success', color: { fixedColor: 'green', mode: FieldColorModeId.Fixed } }, + }, + { + name: Fields.arc + 'failed', + config: { displayName: 'Failed', color: { fixedColor: 'red', mode: FieldColorModeId.Fixed } }, + }, ]); const edges = createDF('Edges', [ { name: Fields.id }, @@ -184,13 +198,29 @@ function createServiceMapDataFrames() { return [nodes, edges]; } -function getMetricFrames(responses: DataQueryResponse[]) { - const responsesMap = groupBy(responses[0].data, (data) => data.refId); - const totalsDFView = new DataFrameView(responsesMap[totalsMetric][0]); - const secondsDFView = new DataFrameView(responsesMap[secondsMetric][0]); - return [totalsDFView, secondsDFView]; +/** + * Group frames from response based on ref id which is set the same as the metric name so we know which metric is where + * and also put it into DataFrameView so it's easier to work with. + * @param responses + */ +function getMetricFrames(responses: DataQueryResponse[]): Record { + return responses[0].data.reduce>((acc, frame) => { + acc[frame.refId] = new DataFrameView(frame); + return acc; + }, {}); } +type ServiceMapStatistics = { + total?: number; + seconds?: number; + failed?: number; +}; + +type EdgeObject = ServiceMapStatistics & { + source: string; + target: string; +}; + /** * 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 @@ -203,12 +233,16 @@ function getMetricFrames(responses: DataQueryResponse[]) { * @param edgesMap */ function collectMetricData( - frame: DataFrameView, - stat: 'total' | 'seconds', + frame: DataFrameView | undefined, + stat: keyof ServiceMapStatistics, metric: string, - nodesMap: Record, - edgesMap: Record + nodesMap: Record, + edgesMap: Record ) { + if (!frame) { + return; + } + // The name of the value column is in this format // TODO figure out if it can be changed const valueName = `Value #${metric}`; @@ -218,24 +252,32 @@ function collectMetricData( const edgeId = `${row.client}_${row.server}`; if (!edgesMap[edgeId]) { + // Create edge as it does not exist yet edgesMap[edgeId] = { target: row.server, source: row.client, [stat]: row[valueName], }; } else { + // Add stat to edge + // We are adding the values if exists but that should not happen in general as there should be single row for + // an edge. edgesMap[edgeId][stat] = (edgesMap[edgeId][stat] || 0) + row[valueName]; } if (!nodesMap[row.server]) { + // Create node for server nodesMap[row.server] = { [stat]: row[valueName], }; } else { + // Add stat to server node. Sum up values if there are multiple edges targeting this server node. nodesMap[row.server][stat] = (nodesMap[row.server][stat] || 0) + row[valueName]; } if (!nodesMap[row.client]) { + // Create the client node but don't add the stat as edge stats are attributed to the server node. This means for + // example that the number of requests in a node show how many requests it handled not how many it generated. nodesMap[row.client] = { [stat]: 0, }; @@ -244,8 +286,8 @@ function collectMetricData( } function convertToDataFrames( - nodesMap: Record, - edgesMap: Record, + nodesMap: Record, + edgesMap: Record, range: TimeRange ): [DataFrame, DataFrame] { const rangeMs = range.to.valueOf() - range.from.valueOf(); @@ -253,22 +295,24 @@ function convertToDataFrames( for (const nodeId of Object.keys(nodesMap)) { const node = nodesMap[nodeId]; nodes.add({ - id: nodeId, - title: nodeId, + [Fields.id]: nodeId, + [Fields.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, // Average response time - secondaryStat: node.total ? Math.round((node.total / (rangeMs / 1000)) * 100) / 100 : Number.NaN, // Request per second (to 2 decimals) + [Fields.mainStat]: node.total ? (node.seconds! / node.total) * 1000 : Number.NaN, // Average response time + [Fields.secondaryStat]: node.total ? Math.round((node.total / (rangeMs / 1000)) * 100) / 100 : Number.NaN, // Request per second (to 2 decimals) + [Fields.arc + 'success']: node.total ? (node.total - (node.failed || 0)) / node.total : 1, + [Fields.arc + 'failed']: node.total ? (node.failed || 0) / node.total : 0, }); } for (const edgeId of Object.keys(edgesMap)) { const edge = edgesMap[edgeId]; edges.add({ - id: edgeId, - source: edge.source, - target: edge.target, - mainStat: edge.total, // Requests - secondaryStat: edge.total ? (edge.seconds / edge.total) * 1000 : Number.NaN, // Average response time + [Fields.id]: edgeId, + [Fields.source]: edge.source, + [Fields.target]: edge.target, + [Fields.mainStat]: edge.total, // Requests + [Fields.secondaryStat]: edge.total ? (edge.seconds! / edge.total) * 1000 : Number.NaN, // Average response time }); } From 0a97d0fff36363a3a16a040f26e8ea8d7248d78f Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Fri, 22 Oct 2021 16:41:16 +0200 Subject: [PATCH 06/11] Prometheus: Change condition for exemplars availability (#40780) --- public/app/plugins/datasource/prometheus/datasource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 8cd551e8b7f..1efb7f441d5 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -813,7 +813,7 @@ export class PrometheusDatasource extends DataSourceWithBackend Date: Fri, 22 Oct 2021 12:23:22 -0300 Subject: [PATCH 07/11] Fix slack contact point panic (code review changes) (#40770) * panic when unexpected Slack response fixed, tests added * fix linting errors, add test * Code review changes * Apply PR comments Co-authored-by: Armand Grillet --- pkg/services/alerting/notifiers/slack.go | 18 +++++++++++------- pkg/services/alerting/notifiers/slack_test.go | 8 ++++---- .../ngalert/notifier/channels/slack.go | 18 +++++++++++------- .../ngalert/notifier/channels/slack_test.go | 8 ++++---- 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/pkg/services/alerting/notifiers/slack.go b/pkg/services/alerting/notifiers/slack.go index 057227e79f7..72a7b7a2c4e 100644 --- a/pkg/services/alerting/notifiers/slack.go +++ b/pkg/services/alerting/notifiers/slack.go @@ -382,18 +382,22 @@ func (sn *SlackNotifier) sendRequest(ctx context.Context, data []byte) error { return fmt.Errorf("failed to read response body: %w", err) } - if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { // Slack responds to some requests with a JSON document, that might contain an error rslt := struct { Ok bool `json:"ok"` Err string `json:"error"` }{} - if err := json.Unmarshal(body, &rslt); err == nil { - if !rslt.Ok && rslt.Err != "" { - sn.log.Warn("Sending Slack API request failed", "url", sn.url.String(), "statusCode", resp.Status, - "err", rslt.Err) - return fmt.Errorf("failed to make Slack API request: %s", rslt.Err) - } + if err := json.Unmarshal(body, &rslt); err != nil { + sn.log.Warn("Failed to unmarshal Slack API response", "url", sn.url.String(), "statusCode", resp.Status, + "err", err) + return fmt.Errorf("failed to unmarshal Slack API response with status code %d: %s", resp.StatusCode, err) + } + + if !rslt.Ok && rslt.Err != "" { + sn.log.Warn("Sending Slack API request failed", "url", sn.url.String(), "statusCode", resp.Status, + "err", rslt.Err) + return fmt.Errorf("failed to make Slack API request: %s", rslt.Err) } sn.log.Debug("Sending Slack API request succeeded", "url", sn.url.String(), "statusCode", resp.Status) diff --git a/pkg/services/alerting/notifiers/slack_test.go b/pkg/services/alerting/notifiers/slack_test.go index 64be9af4e32..d0a15199bbd 100644 --- a/pkg/services/alerting/notifiers/slack_test.go +++ b/pkg/services/alerting/notifiers/slack_test.go @@ -273,20 +273,20 @@ func TestSendSlackRequest(t *testing.T) { expectError: false, }, { - name: "Success case, no response body", + name: "No response body", statusCode: http.StatusOK, - expectError: false, + expectError: true, }, { name: "Success case, unexpected response body", statusCode: http.StatusOK, - slackResponse: "{}", + slackResponse: `{"test": true}`, expectError: false, }, { name: "Success case, ok: true", statusCode: http.StatusOK, - slackResponse: "{\"ok\": true}", + slackResponse: `{"ok": true}`, expectError: false, }, { diff --git a/pkg/services/ngalert/notifier/channels/slack.go b/pkg/services/ngalert/notifier/channels/slack.go index 85150eee979..04e9fb60247 100644 --- a/pkg/services/ngalert/notifier/channels/slack.go +++ b/pkg/services/ngalert/notifier/channels/slack.go @@ -218,7 +218,7 @@ var sendSlackRequest = func(request *http.Request, logger log.Logger) error { return fmt.Errorf("failed to read response body: %w", err) } - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { logger.Warn("Slack API request failed", "url", request.URL.String(), "statusCode", resp.Status, "body", string(body)) return fmt.Errorf("request to Slack API failed with status code %d", resp.StatusCode) } @@ -228,12 +228,16 @@ var sendSlackRequest = func(request *http.Request, logger log.Logger) error { Ok bool `json:"ok"` Err string `json:"error"` }{} - if err := json.Unmarshal(body, &rslt); err == nil { - if !rslt.Ok && rslt.Err != "" { - logger.Warn("Sending Slack API request failed", "url", request.URL.String(), "statusCode", resp.Status, - "err", rslt.Err) - return fmt.Errorf("failed to make Slack API request: %s", rslt.Err) - } + if err := json.Unmarshal(body, &rslt); err != nil { + logger.Warn("Failed to unmarshal Slack API response", "url", request.URL.String(), "statusCode", resp.Status, + "body", string(body)) + return fmt.Errorf("failed to unmarshal Slack API response: %s", err) + } + + if !rslt.Ok && rslt.Err != "" { + logger.Warn("Sending Slack API request failed", "url", request.URL.String(), "statusCode", resp.Status, + "err", rslt.Err) + return fmt.Errorf("failed to make Slack API request: %s", rslt.Err) } logger.Debug("Sending Slack API request succeeded", "url", request.URL.String(), "statusCode", resp.Status) diff --git a/pkg/services/ngalert/notifier/channels/slack_test.go b/pkg/services/ngalert/notifier/channels/slack_test.go index cf2714dbbd5..2f137e40e8b 100644 --- a/pkg/services/ngalert/notifier/channels/slack_test.go +++ b/pkg/services/ngalert/notifier/channels/slack_test.go @@ -270,20 +270,20 @@ func TestSendSlackRequest(t *testing.T) { expectError: false, }, { - name: "Success case, no response body", + name: "No response body", statusCode: http.StatusOK, - expectError: false, + expectError: true, }, { name: "Success case, unexpected response body", statusCode: http.StatusOK, - slackResponse: "{}", + slackResponse: `{"test": true}`, expectError: false, }, { name: "Success case, ok: true", statusCode: http.StatusOK, - slackResponse: "{\"ok\": true}", + slackResponse: `{"ok": true}`, expectError: false, }, { From 4680a8454f9ca03f07a9322f7ae5753be8d851fe Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 22 Oct 2021 08:56:16 -0700 Subject: [PATCH 08/11] Live: add rule for datasource (#40738) --- pkg/api/index.go | 4 +- public/app/features/live/pages/AddNewRule.tsx | 144 +++++++++++++----- .../features/live/pages/PipelineAdminPage.tsx | 120 +++------------ .../app/features/live/pages/PipelineTable.tsx | 144 ++++++++++++++++++ public/app/features/live/pages/RuleModal.tsx | 83 +++++----- public/app/features/live/pages/RuleTest.tsx | 93 ++++------- public/app/features/live/pages/routes.ts | 6 - 7 files changed, 355 insertions(+), 239 deletions(-) create mode 100644 public/app/features/live/pages/PipelineTable.tsx diff --git a/pkg/api/index.go b/pkg/api/index.go index c6431fc5ead..9d763d10a7b 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -317,9 +317,6 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto liveNavLinks = append(liveNavLinks, &dtos.NavLink{ Text: "Cloud", Id: "live-cloud", Url: hs.Cfg.AppSubURL + "/live/cloud", Icon: "cloud-upload", }) - liveNavLinks = append(liveNavLinks, &dtos.NavLink{ - Text: "Test", Id: "live-test", Url: hs.Cfg.AppSubURL + "/live/test", Icon: "arrow", - }) navTree = append(navTree, &dtos.NavLink{ Id: "live", Text: "Live", @@ -328,6 +325,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto Url: hs.Cfg.AppSubURL + "/live", Children: liveNavLinks, HideFromMenu: true, + HideFromTabs: true, }) } diff --git a/public/app/features/live/pages/AddNewRule.tsx b/public/app/features/live/pages/AddNewRule.tsx index 78844a8ebea..0af3926fb9f 100644 --- a/public/app/features/live/pages/AddNewRule.tsx +++ b/public/app/features/live/pages/AddNewRule.tsx @@ -1,32 +1,48 @@ -import React from 'react'; -import { Input, Form, Field, Button } from '@grafana/ui'; -import { getBackendSrv } from '@grafana/runtime'; +import React, { useState } from 'react'; +import { Input, Field, Button, ValuePicker, HorizontalGroup } from '@grafana/ui'; +import { DataSourcePicker, getBackendSrv } from '@grafana/runtime'; +import { AppEvents, DatasourceRef, LiveChannelScope, SelectableValue } from '@grafana/data'; +import appEvents from 'app/core/app_events'; import { Rule } from './types'; interface Props { - onClose: (state: boolean) => void; + onRuleAdded: (rule: Rule) => void; } -export function AddNewRule({ onClose }: Props) { - const onSubmit = (formData: Rule) => { + +type PatternType = 'ds' | 'any'; + +const patternTypes: Array> = [ + { + label: 'Data source', + description: 'Configure a channel scoped to a data source instance', + value: 'ds', + }, + { + label: 'Any', + description: 'Enter an arbitray channel pattern', + value: 'any', + }, +]; + +export function AddNewRule({ onRuleAdded }: Props) { + const [patternType, setPatternType] = useState(); + const [pattern, setPattern] = useState(); + const [patternPrefix, setPatternPrefix] = useState(''); + const [datasource, setDatasource] = useState(); + + const onSubmit = () => { + if (!pattern) { + appEvents.emit(AppEvents.alertError, ['Enter path']); + return; + } + if (patternType === 'ds' && !patternPrefix.length) { + appEvents.emit(AppEvents.alertError, ['Select datasource']); + return; + } + getBackendSrv() .post(`api/live/channel-rules`, { - pattern: formData.pattern, - settings: { - output: formData.settings.frameOutputs, - converter: formData.settings.converter, - }, - }) - .then(() => { - // close modal - onClose(false); - }) - .catch((e) => console.error(e)); - }; - - return ( -
- {({ register, errors }) => ( - <> - - + }) + .then((v: any) => { + console.log('ADDED', v); + setPattern(undefined); + setPatternType(undefined); + onRuleAdded(v.rule); + }) + .catch((e) => { + appEvents.emit(AppEvents.alertError, ['Error adding rule', e]); + e.isHandled = true; + }); + }; + + if (patternType) { + return ( +
+ + {patternType === 'any' && ( + + setPattern(e.currentTarget.value)} + placeholder="scope/namespace/path" + /> + + )} + {patternType === 'ds' && ( + <> + + { + setDatasource(ds.name); + setPatternPrefix(`${LiveChannelScope.DataSource}/${ds.uid}/`); + }} + /> + + + setPattern(e.currentTarget.value)} placeholder="path" /> + + + )} + + + - - - )} - + + + + + +
+ ); + } + + return ( +
+ setPatternType(v.value)} + /> +
); } diff --git a/public/app/features/live/pages/PipelineAdminPage.tsx b/public/app/features/live/pages/PipelineAdminPage.tsx index 99bdf32b2fd..2edda347c00 100644 --- a/public/app/features/live/pages/PipelineAdminPage.tsx +++ b/public/app/features/live/pages/PipelineAdminPage.tsx @@ -1,33 +1,20 @@ import React, { useEffect, useState, ChangeEvent } from 'react'; import { getBackendSrv } from '@grafana/runtime'; -import { Input, Tag, useStyles, Button, Modal, IconButton } from '@grafana/ui'; +import { Input } from '@grafana/ui'; import Page from 'app/core/components/Page/Page'; import { useNavModel } from 'app/core/hooks/useNavModel'; -import { css } from '@emotion/css'; -import { GrafanaTheme } from '@grafana/data'; -import { Rule, Output, RuleType } from './types'; -import { RuleModal } from './RuleModal'; +import { Rule } from './types'; +import { PipelineTable } from './PipelineTable'; import { AddNewRule } from './AddNewRule'; -function renderOutputTags(key: string, output?: Output): React.ReactNode { - if (!output?.type) { - return null; - } - return ; -} - export default function PipelineAdminPage() { const [rules, setRules] = useState([]); - const [isOpen, setOpen] = useState(false); - const [selectedRule, setSelectedRule] = useState(); const [defaultRules, setDefaultRules] = useState([]); + const [newRule, setNewRule] = useState(); const navModel = useNavModel('live-pipeline'); - const [isOpenEditor, setOpenEditor] = useState(false); const [error, setError] = useState(); - const [clickColumn, setClickColumn] = useState('converter'); - const styles = useStyles(getStyles); - useEffect(() => { + const loadRules = () => { getBackendSrv() .get(`api/live/channel-rules`) .then((data) => { @@ -39,20 +26,12 @@ export default function PipelineAdminPage() { setError(JSON.stringify(e.data, null, 2)); } }); - }, [isOpenEditor, isOpen]); - - const onRowClick = (event: any) => { - const pattern = event.target.getAttribute('data-pattern'); - const column = event.target.getAttribute('data-column'); - if (column === 'pattern') { - setClickColumn('converter'); - } else { - setClickColumn(column); - } - setSelectedRule(rules.filter((rule) => rule.pattern === pattern)[0]); - setOpen(true); }; + useEffect(() => { + loadRules(); + }, []); + const onSearchQueryChange = (e: ChangeEvent) => { if (e.target.value) { setRules(rules.filter((rule) => rule.pattern.toLowerCase().includes(e.target.value.toLowerCase()))); @@ -61,11 +40,6 @@ export default function PipelineAdminPage() { } }; - const onRemoveRule = (pattern: string) => { - getBackendSrv() - .delete(`api/live/channel-rules`, JSON.stringify({ pattern: pattern })) - .catch((e) => console.error(e)); - }; return ( @@ -73,75 +47,19 @@ export default function PipelineAdminPage() {
-
-
- - - - - - - - - - - {rules.map((rule) => ( - - - - - - - - ))} - -
PatternConverterProcessorOutput
- {rule.pattern} - - {rule.settings?.converter?.type} - - {rule.settings?.frameProcessors?.map((processor) => ( - {processor.type} - ))} - - {rule.settings?.frameOutputs?.map((output) => ( - {renderOutputTags('out', output)} - ))} - - onRemoveRule(rule.pattern)}> -
-
- {isOpenEditor && ( - setOpenEditor(false)} title="Add a new rule"> - - - )} - {isOpen && selectedRule && ( - { - setOpen(false); - }} - clickColumn={clickColumn} - /> - )} + + + + { + console.log('GOT', r, 'vs', rules[0]); + setNewRule(r); + loadRules(); + }} + />
); } - -const getStyles = (theme: GrafanaTheme) => { - return { - row: css` - cursor: pointer; - `, - addNew: css` - margin-left: 10px; - `, - }; -}; diff --git a/public/app/features/live/pages/PipelineTable.tsx b/public/app/features/live/pages/PipelineTable.tsx new file mode 100644 index 00000000000..32873a40c16 --- /dev/null +++ b/public/app/features/live/pages/PipelineTable.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useState } from 'react'; +import { getBackendSrv } from '@grafana/runtime'; +import { Tag, useStyles, IconButton } from '@grafana/ui'; +import { css } from '@emotion/css'; +import { GrafanaTheme } from '@grafana/data'; +import { Rule, Output, RuleType } from './types'; +import { RuleModal } from './RuleModal'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; + +function renderOutputTags(key: string, output?: Output): React.ReactNode { + if (!output?.type) { + return null; + } + return ; +} + +interface Props { + rules: Rule[]; + onRuleChanged: () => void; + selectRule?: Rule; +} + +export const PipelineTable: React.FC = (props) => { + const { rules } = props; + const [isOpen, setOpen] = useState(false); + const [selectedRule, setSelectedRule] = useState(); + const [clickColumn, setClickColumn] = useState('converter'); + const styles = useStyles(getStyles); + + const onRowClick = (rule: Rule, event?: any) => { + if (!rule) { + return; + } + let column = event?.target?.getAttribute('data-column'); + if (!column || column === 'pattern') { + column = 'converter'; + } + setClickColumn(column); + setSelectedRule(rule); + setOpen(true); + }; + + // Supports selecting a rule from external config (after add rule) + useEffect(() => { + if (props.selectRule) { + onRowClick(props.selectRule); + } + }, [props.selectRule]); + + const onRemoveRule = (pattern: string) => { + getBackendSrv() + .delete(`api/live/channel-rules`, JSON.stringify({ pattern: pattern })) + .catch((e) => console.error(e)) + .finally(() => { + props.onRuleChanged(); + }); + }; + + const renderPattern = (pattern: string) => { + if (pattern.startsWith('ds/')) { + const idx = pattern.indexOf('/', 4); + if (idx > 3) { + const uid = pattern.substring(3, idx); + const ds = getDatasourceSrv().getInstanceSettings(uid); + if (ds) { + return ( +
+   + {pattern.substring(idx + 1)} +
+ ); + } + } + } + return pattern; + }; + + return ( +
+
+ + + + + + + + + + + + {rules.map((rule) => ( + onRowClick(rule, e)} className={styles.row}> + + + + + + + ))} + +
ChannelConverterProcessorOutput 
+ {renderPattern(rule.pattern)} + + {rule.settings?.converter?.type} + + {rule.settings?.frameProcessors?.map((processor) => ( + {processor.type} + ))} + + {rule.settings?.frameOutputs?.map((output) => ( + {renderOutputTags('out', output)} + ))} + + { + e.stopPropagation(); + onRemoveRule(rule.pattern); + }} + > +
+
+ {isOpen && selectedRule && ( + { + setOpen(false); + }} + clickColumn={clickColumn} + /> + )} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => { + return { + row: css` + cursor: pointer; + `, + }; +}; diff --git a/public/app/features/live/pages/RuleModal.tsx b/public/app/features/live/pages/RuleModal.tsx index 605a564da1f..0f2686024cb 100644 --- a/public/app/features/live/pages/RuleModal.tsx +++ b/public/app/features/live/pages/RuleModal.tsx @@ -7,6 +7,7 @@ import { GrafanaTheme } from '@grafana/data'; import { RuleSettingsEditor } from './RuleSettingsEditor'; import { getPipeLineEntities } from './utils'; import { RuleSettingsArray } from './RuleSettingsArray'; +import { RuleTest } from './RuleTest'; interface Props { rule: Rule; @@ -14,35 +15,41 @@ interface Props { onClose: () => void; clickColumn: RuleType; } -interface TabType { +interface TabInfo { label: string; - value: RuleType; + type?: RuleType; + isTest?: boolean; + isConverter?: boolean; + icon?: string; } -const tabs: TabType[] = [ - { label: 'Converter', value: 'converter' }, - { label: 'Processors', value: 'frameProcessors' }, - { label: 'Outputs', value: 'frameOutputs' }, +const tabs: TabInfo[] = [ + { label: 'Converter', type: 'converter', isConverter: true }, + { label: 'Processors', type: 'frameProcessors' }, + { label: 'Outputs', type: 'frameOutputs' }, + { label: 'Test', isTest: true, icon: 'flask' }, ]; export const RuleModal: React.FC = (props) => { const { isOpen, onClose, clickColumn } = props; const [rule, setRule] = useState(props.rule); - const [activeTab, setActiveTab] = useState(clickColumn); + const [activeTab, setActiveTab] = useState(tabs.find((t) => t.type === clickColumn)); // to show color of Save button const [hasChange, setChange] = useState(false); - const [ruleSetting, setRuleSetting] = useState(rule?.settings?.[activeTab]); + const [ruleSetting, setRuleSetting] = useState(activeTab?.type ? rule?.settings?.[activeTab.type] : undefined); const [entitiesInfo, setEntitiesInfo] = useState(); const styles = useStyles(getStyles); const onRuleSettingChange = (value: RuleSetting | RuleSetting[]) => { setChange(true); - setRule({ - ...rule, - settings: { - ...rule.settings, - [activeTab]: value, - }, - }); + if (activeTab?.type) { + setRule({ + ...rule, + settings: { + ...rule.settings, + [activeTab?.type]: value, + }, + }); + } setRuleSetting(value); }; @@ -71,32 +78,40 @@ export const RuleModal: React.FC = (props) => { { - setActiveTab(tab.value); - // to notify children of the new rule - setRuleSetting(rule?.settings?.[tab.value]); + setActiveTab(tab); + if (tab.type) { + // to notify children of the new rule + setRuleSetting(rule?.settings?.[tab.type]); + } }} /> ); })} - {entitiesInfo && rule && activeTab === 'converter' && ( - - )} - {entitiesInfo && rule && activeTab !== 'converter' && ( - + {entitiesInfo && rule && activeTab && ( + <> + {activeTab?.isTest && } + {activeTab.isConverter && ( + + )} + {!activeTab.isConverter && activeTab.type && ( + + )} + )}