diff --git a/devenv/docker/blocks/prometheus/recording.yml b/devenv/docker/blocks/prometheus/recording.yml index f45cdbeddf2..2b8ae3e477a 100644 --- a/devenv/docker/blocks/prometheus/recording.yml +++ b/devenv/docker/blocks/prometheus/recording.yml @@ -1,4 +1,18 @@ groups: + - name: TESTING_RULES + rules: + - record: test:rate5m + expr: rate(ismail_prometheus_tsdb_reloads_total{job="prometheus"}[5m]) + labels: + identifier: special_idx + - record: agent:test:rate5m + expr: rate(prometheus_tsdb_reloads_total{job="prometheus"}[5m]) + labels: + identifier: special_idx + - record: agent:test:rate5m_agg_ + expr: rate(prometheus_tsdb_reloads_total_agg_{job="aggregated"}[5m]) + labels: + identifier: special_idx - name: RECORDING_RULES rules: - record: instance_path:requests:rate5m @@ -9,6 +23,14 @@ groups: expr: rate(prometheus_tsdb_reloads_failures_total{job="prometheus"}[5m]) - record: instance_path:reloads:rate5m expr: rate(prometheus_tsdb_reloads_total{job="prometheus"}[5m]) + - record: test:rate5m + expr: rate(prometheus_tsdb_reloads_total{job="prometheus"}[5m]) + - record: test:rate5m_agg_ + expr: rate(prometheus_tsdb_reloads_total_agg_{job="aggregated"}[5m]) + - record: agent:test:rate5m + expr: rate(prometheus_tsdb_reloads_total{job="prometheus"}[5m]) + - record: agent:test:rate5m_agg_ + expr: rate(prometheus_tsdb_reloads_total_agg_{job="aggregated"}[5m]) - record: instance_path:request_failures_per_requests:ratio_rate5m expr: |2 instance_path:reloads_failures:rate5m{job="prometheus"} diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index fecc19478b7..8dae19485ea 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -229,6 +229,7 @@ Experimental features might be changed or removed without prior notice. | `k8SFolderCounts` | Enable folder's api server counts | | `k8SFolderMove` | Enable folder's api server move | | `teamHttpHeadersMimir` | Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams | +| `templateVariablesUsesCombobox` | Use new combobox component for template variables | | `queryLibraryDashboards` | Enables Query Library feature in Dashboards | | `grafanaAdvisor` | Enables Advisor app | | `elasticsearchImprovedParsing` | Enables less memory intensive Elasticsearch result parsing | diff --git a/package.json b/package.json index 0cb29eae330..26bc506bbf6 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@emotion/eslint-plugin": "11.12.0", "@grafana/eslint-config": "8.0.0", "@grafana/eslint-plugin": "link:./packages/grafana-eslint-rules", - "@grafana/plugin-e2e": "1.17.0", + "@grafana/plugin-e2e": "1.17.1", "@grafana/tsconfig": "^2.0.0", "@manypkg/get-packages": "^2.2.0", "@playwright/test": "1.50.0", @@ -230,7 +230,7 @@ "sass-loader": "16.0.4", "smtp-tester": "^2.1.0", "style-loader": "4.0.0", - "stylelint": "16.13.2", + "stylelint": "16.14.1", "stylelint-config-sass-guidelines": "12.1.0", "terser-webpack-plugin": "5.3.11", "testing-library-selector": "0.3.1", @@ -398,7 +398,7 @@ "reselect": "5.1.1", "rxjs": "7.8.1", "selecto": "1.26.3", - "semver": "7.6.3", + "semver": "7.7.0", "slate": "0.47.9", "slate-plain-serializer": "0.7.13", "slate-react": "0.22.10", diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 6ab637d841e..f983b75f5fe 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -248,6 +248,7 @@ export interface FeatureToggles { improvedExternalSessionHandlingSAML?: boolean; teamHttpHeadersMimir?: boolean; ABTestFeatureToggleA?: boolean; + templateVariablesUsesCombobox?: boolean; ABTestFeatureToggleB?: boolean; queryLibraryDashboards?: boolean; grafanaAdvisor?: boolean; diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index 5eaf8277ad1..0c797232400 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -51,7 +51,7 @@ }, "dependencies": { "@grafana/tsconfig": "^2.0.0", - "semver": "7.6.3", + "semver": "7.7.0", "tslib": "2.8.1", "typescript": "5.7.3" } diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 48d778736d4..a27c1776ec7 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -70,7 +70,7 @@ "react-use": "17.6.0", "react-window": "1.8.11", "rxjs": "7.8.1", - "semver": "7.6.3", + "semver": "7.7.0", "tslib": "2.8.1", "uuid": "11.0.5", "whatwg-fetch": "3.6.20" diff --git a/packages/grafana-prometheus/src/datasource.test.ts b/packages/grafana-prometheus/src/datasource.test.ts index 54cdf88f8c6..1e11c3b54d8 100644 --- a/packages/grafana-prometheus/src/datasource.test.ts +++ b/packages/grafana-prometheus/src/datasource.test.ts @@ -1029,6 +1029,23 @@ describe('PrometheusDatasource', () => { expect(interval).toEqual({ text: '15s', value: '15s' }); expect(intervalMs).toEqual({ text: 15000, value: 15000 }); }); + + it('should use the default time range when no range provided in options', () => { + const prometheusDatasource = new PrometheusDatasource( + { ...instanceSettings, jsonData: { ...instanceSettings.jsonData, cacheLevel: PrometheusCacheLevel.None } }, + templateSrvStub + ); + const query = 'query_result(topk(5,rate(http_request_duration_microseconds_count[$__interval])))'; + prometheusDatasource.metricFindQuery(query); + + // Last 6h + const range = replaceMock.mock.calls[1][1].__range; + const rangeMs = replaceMock.mock.calls[1][1].__range_ms; + const rangeS = replaceMock.mock.calls[1][1].__range_s; + expect(range).toEqual({ text: '21600s', value: '21600s' }); + expect(rangeMs).toEqual({ text: 21600000, value: 21600000 }); + expect(rangeS).toEqual({ text: 21600, value: 21600 }); + }); }); }); diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index a54475cebdd..cb4c80f0afd 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -456,13 +456,15 @@ export class PrometheusDatasource return Promise.resolve([]); } + const timeRange = options?.range ?? this.languageProvider.timeRange ?? getDefaultTimeRange(); + const scopedVars = { ...this.getIntervalVars(), - ...this.getRangeScopedVars(options?.range ?? getDefaultTimeRange()), + ...this.getRangeScopedVars(timeRange), }; const interpolated = this.templateSrv.replace(query, scopedVars, this.interpolateQueryExpr); const metricFindQuery = new PrometheusMetricFindQuery(this, interpolated); - return metricFindQuery.process(options?.range ?? getDefaultTimeRange()); + return metricFindQuery.process(timeRange); } getIntervalVars() { diff --git a/packages/grafana-prometheus/src/query_hints.test.ts b/packages/grafana-prometheus/src/query_hints.test.ts index 5aabdbe7bd4..af2e9eee01a 100644 --- a/packages/grafana-prometheus/src/query_hints.test.ts +++ b/packages/grafana-prometheus/src/query_hints.test.ts @@ -8,6 +8,7 @@ import { getQueryHints, getQueryLabelsForRuleName, getRecordingRuleIdentifierIdx, + isRuleInQuery, SUM_HINT_THRESHOLD_COUNT, } from './query_hints'; import { buildVisualQueryFromString } from './querybuilder/parsing'; @@ -365,6 +366,92 @@ describe('getExpandRulesHints', () => { }, ]); }); + + it('should return expand rule hint, when given query include a non-unique rule name', () => { + const extractedMapping: RuleQueryMapping = { + 'duration:p95': [ + { + query: 'expanded_duration_p95{}', + labels: {}, + }, + { + query: 'expanded_duration_p95_aggregated{}', + labels: { + span_name: '__aggregated__', + }, + }, + ], + 'duration:p95:upper_threshold': [ + { + query: 'expanded_duration_p95_upper_threshold{}', + labels: {}, + }, + ], + }; + const query = 'sum(rate(duration:p95:upper_threshold{label="foo"}[5m])) by(bar)'; + const hints = getExpandRulesHints(query, extractedMapping); + expect(hints).toEqual([ + { + type: 'EXPAND_RULES', + label: 'Query contains recording rules.', + fix: { + label: 'Expand rules', + action: { + type: 'EXPAND_RULES', + query, + options: { + 'duration:p95:upper_threshold': { + expandedQuery: 'expanded_duration_p95_upper_threshold{}', + }, + }, + }, + }, + }, + ]); + }); + + it('should return expand rule hint, when given query include a non-unique rule name - second case', () => { + const extractedMapping: RuleQueryMapping = { + 'duration:p95': [ + { + query: 'expanded_duration_p95{}', + labels: {}, + }, + { + query: 'expanded_duration_p95_aggregated{}', + labels: { + span_name: '__aggregated__', + }, + }, + ], + 'upper_threshold:duration:p95': [ + { + query: 'expanded_duration_p95_upper_threshold{}', + labels: {}, + }, + ], + }; + const query = 'sum(rate(upper_threshold:duration:p95{label="foo"}[5m])) by(bar)'; + const hints = getExpandRulesHints(query, extractedMapping); + expect(hints).toEqual([ + { + type: 'EXPAND_RULES', + label: 'Query contains recording rules.', + fix: { + label: 'Expand rules', + action: { + type: 'EXPAND_RULES', + query, + options: { + 'upper_threshold:duration:p95': { + expandedQuery: 'expanded_duration_p95_upper_threshold{}', + }, + }, + }, + }, + }, + ]); + }); }); describe('getRecordingRuleIdentifierIdx', () => { @@ -505,3 +592,73 @@ describe('getQueryLabelsForRuleName', () => { expect(result).toEqual(expected); }); }); + +describe('ruleInQuery', () => { + it('should return true when ruleName is present in the query', () => { + expect(isRuleInQuery('rate(http_requests_total{job="api"}[5m])', 'http_requests_total')).toBe(true); + }); + + it('should return false when ruleName is not present in the query', () => { + expect(isRuleInQuery('rate(cpu_usage{instance="localhost"}[5m])', 'http_requests_total')).toBe(false); + }); + + it('should return true for ruleName at the start of the query', () => { + expect(isRuleInQuery('http_requests_total{job="api"}', 'http_requests_total')).toBe(true); + }); + + it('should return true for ruleName at the end of the query', () => { + expect(isRuleInQuery('sum by (instance) (http_requests_total)', 'http_requests_total')).toBe(true); + expect(isRuleInQuery('sum(http_requests_total)', 'http_requests_total')).toBe(true); + expect(isRuleInQuery('http_requests_total', 'http_requests_total')).toBe(true); + }); + + it('should return true when ruleName is followed by spaces', () => { + expect(isRuleInQuery('http_requests_total { job="api" }', 'http_requests_total')).toBe(true); + }); + + it('should return false when ruleName is a substring of another metric', () => { + expect(isRuleInQuery('rate(http_requests_total_new{job="api"}[5m])', 'http_requests_total')).toBe(false); + }); + + it('should return false for escaped ruleName usage', () => { + expect(isRuleInQuery('rate(\"http_requests_total\"{job="api"}[5m])', 'http_requests_total')).toBe(false); + }); + + it('should return false when query is empty', () => { + expect(isRuleInQuery('', 'http_requests_total')).toBe(false); + }); + + it('should return false when ruleName is an empty string', () => { + expect(isRuleInQuery('rate(http_requests_total{job="api"}[5m])', '')).toBe(false); + }); + + it('should return false when both query and ruleName are empty', () => { + expect(isRuleInQuery('', '')).toBe(false); + }); + + it('should return true when used with binary operations', () => { + expect(isRuleInQuery('rate(http_requests_total{job="api"}[5m]) + my:rule', 'my:rule')).toBe(true); + expect(isRuleInQuery('rate(http_requests_total{job="api"}[5m])+my:rule', 'my:rule')).toBe(true); + }); + + it('should return true when ruleName is inside nested functions', () => { + expect(isRuleInQuery('sum(rate(http_requests_total[5m]))', 'http_requests_total')).toBe(true); + }); + + it('should return false when ruleName is part of a label value', () => { + expect(isRuleInQuery('http_requests_total_bytes{rule="http_requests_total"}', 'http_requests_total')).toBe(false); + }); + + it('should return true when ruleName contains special characters like colon', () => { + expect(isRuleInQuery('rate(my_namespace:http_requests_total[5m])', 'my_namespace:http_requests_total')).toBe(true); + }); + + it('should return false when ruleName appears within string literals', () => { + expect( + isRuleInQuery( + 'label_replace(http_requests_total, "label", "value", "instance", "http_requests_total")', + 'http_requests_total' + ) + ).toBe(false); + }); +}); diff --git a/packages/grafana-prometheus/src/query_hints.ts b/packages/grafana-prometheus/src/query_hints.ts index 3fcdd66e0d2..73c3e90960e 100644 --- a/packages/grafana-prometheus/src/query_hints.ts +++ b/packages/grafana-prometheus/src/query_hints.ts @@ -175,10 +175,19 @@ export function getInitHints(datasource: PrometheusDatasource): QueryHint[] { return hints; } +export function isRuleInQuery(query: string, ruleName: string) { + if (!query || !ruleName) { + return false; + } + + const getRuleRegex = new RegExp(`(? { - if (query.search(ruleName) === -1) { + if (!isRuleInQuery(query, ruleName)) { return acc; } diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx index 9ad1b409456..f6ea9df0e1c 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx @@ -7,7 +7,6 @@ import { useStyles2 } from '@grafana/ui'; import { PromQueryField } from '../../components/PromQueryField'; import { PromQueryEditorProps } from '../../components/types'; -import { QueryEditorHints } from '../shared/QueryEditorHints'; import { PromQueryBuilderExplained } from './PromQueryBuilderExplained'; @@ -35,7 +34,6 @@ export function PromQueryCodeEditor(props: PromQueryCodeEditorProps) { app={app} /> {showExplain && } - ); } diff --git a/pkg/promlib/library.go b/pkg/promlib/library.go index 596a6f61c02..cbee4a19ffc 100644 --- a/pkg/promlib/library.go +++ b/pkg/promlib/library.go @@ -2,7 +2,6 @@ package promlib import ( "context" - "errors" "fmt" "strings" @@ -11,7 +10,6 @@ import ( sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - apiv1 "github.com/prometheus/client_golang/api/prometheus/v1" "github.com/grafana/grafana/pkg/promlib/client" "github.com/grafana/grafana/pkg/promlib/instrumentation" @@ -138,18 +136,3 @@ func (s *Service) getInstance(ctx context.Context, pluginCtx backend.PluginConte in := i.(instance) return &in, nil } - -// IsAPIError returns whether err is or wraps a Prometheus error. -func IsAPIError(err error) bool { - // Check if the right error type is in err's chain. - var e *apiv1.Error - return errors.As(err, &e) -} - -func ConvertAPIError(err error) error { - var e *apiv1.Error - if errors.As(err, &e) { - return fmt.Errorf("%s: %s", e.Msg, e.Detail) - } - return err -} diff --git a/pkg/promlib/querydata/framing_bench_test.go b/pkg/promlib/querydata/framing_bench_test.go index b769df2b47d..411c8644e71 100644 --- a/pkg/promlib/querydata/framing_bench_test.go +++ b/pkg/promlib/querydata/framing_bench_test.go @@ -43,7 +43,7 @@ func BenchmarkExemplarJson(b *testing.B) { StatusCode: 200, Body: io.NopCloser(bytes.NewReader(responseBytes)), } - tCtx.httpProvider.setResponse(&res) + tCtx.httpProvider.setResponse(&res, &res) resp, err := tCtx.queryData.Execute(context.Background(), query) require.NoError(b, err) for _, r := range resp.Responses { @@ -74,7 +74,7 @@ func BenchmarkRangeJson(b *testing.B) { StatusCode: 200, Body: io.NopCloser(bytes.NewReader(body)), } - tCtx.httpProvider.setResponse(&res) + tCtx.httpProvider.setResponse(&res, &res) r, err = tCtx.queryData.Execute(context.Background(), q) require.NoError(b, err) } diff --git a/pkg/promlib/querydata/framing_test.go b/pkg/promlib/querydata/framing_test.go index 3668438ded4..3fa48bca92b 100644 --- a/pkg/promlib/querydata/framing_test.go +++ b/pkg/promlib/querydata/framing_test.go @@ -149,6 +149,6 @@ func runQuery(response []byte, q *backend.QueryDataRequest) (*backend.QueryDataR StatusCode: 200, Body: io.NopCloser(bytes.NewReader(response)), } - tCtx.httpProvider.setResponse(res) + tCtx.httpProvider.setResponse(res, res) return tCtx.queryData.Execute(context.Background(), q) } diff --git a/pkg/promlib/querydata/request_test.go b/pkg/promlib/querydata/request_test.go index 0977b9036d3..bfbde8dfb96 100644 --- a/pkg/promlib/querydata/request_test.go +++ b/pkg/promlib/querydata/request_test.go @@ -27,7 +27,6 @@ import ( func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { t.Run("exemplars response should be sampled and parsed normally", func(t *testing.T) { - t.Skip() exemplars := []apiv1.ExemplarQueryResult{ { SeriesLabels: p.LabelSet{ @@ -60,6 +59,22 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { }, } + values := []p.SamplePair{ + {Value: 1, Timestamp: 1000}, + {Value: 4, Timestamp: 4000}, + {Value: 6, Timestamp: 7000}, + {Value: 8, Timestamp: 1100}, + } + rangeResult := queryResult{ + Type: p.ValMatrix, + Result: p.Matrix{ + &p.SampleStream{ + Metric: p.Metric{"app": "Application", "tag2": "tag2"}, + Values: values, + }, + }, + } + tctx, err := setup() require.NoError(t, err) @@ -76,20 +91,20 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { RefID: "A", JSON: b, } - res, err := execute(tctx, query, exemplars) + res, err := execute(tctx, query, exemplars, rangeResult) require.NoError(t, err) // Test fields - require.Len(t, res, 1) - // require.Equal(t, res[0].Name, "exemplar") + require.Len(t, res, 2) + require.Equal(t, res[0].Name, "exemplar") require.Equal(t, res[0].Fields[0].Name, "Time") require.Equal(t, res[0].Fields[1].Name, "Value") require.Len(t, res[0].Fields, 6) // Test correct values (sampled to 2) - require.Equal(t, res[0].Fields[1].Len(), 2) + require.Equal(t, res[0].Fields[1].Len(), 4) require.Equal(t, res[0].Fields[1].At(0), 0.009545445) - require.Equal(t, res[0].Fields[1].At(1), 0.003535405) + require.Equal(t, res[0].Fields[1].At(3), 0.003535405) }) t.Run("matrix response should be parsed normally", func(t *testing.T) { @@ -128,7 +143,7 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { } tctx, err := setup() require.NoError(t, err) - res, err := execute(tctx, query, result) + res, err := execute(tctx, query, result, nil) require.NoError(t, err) require.Len(t, res, 1) @@ -177,7 +192,7 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { } tctx, err := setup() require.NoError(t, err) - res, err := execute(tctx, query, result) + res, err := execute(tctx, query, result, nil) require.NoError(t, err) require.Len(t, res, 1) @@ -222,7 +237,7 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { } tctx, err := setup() require.NoError(t, err) - res, err := execute(tctx, query, result) + res, err := execute(tctx, query, result, nil) require.NoError(t, err) require.Len(t, res, 1) @@ -266,7 +281,7 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { tctx, err := setup() require.NoError(t, err) - res, err := execute(tctx, query, result) + res, err := execute(tctx, query, result, nil) require.NoError(t, err) require.Equal(t, `{app="Application"}`, res[0].Fields[1].Config.DisplayNameFromDS) @@ -298,7 +313,7 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { } tctx, err := setup() require.NoError(t, err) - res, err := execute(tctx, query, qr) + res, err := execute(tctx, query, qr, nil) require.NoError(t, err) require.Len(t, res, 1) @@ -317,7 +332,6 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { }) t.Run("scalar response should be parsed normally", func(t *testing.T) { - t.Skip("TODO: implement scalar responses") qr := queryResult{ Type: p.ValScalar, Result: &p.Scalar{ @@ -339,14 +353,15 @@ func TestPrometheus_parseTimeSeriesResponse(t *testing.T) { } tctx, err := setup() require.NoError(t, err) - res, err := execute(tctx, query, qr) + res, err := execute(tctx, query, qr, nil) require.NoError(t, err) require.Len(t, res, 1) require.Len(t, res[0].Fields, 2) require.Len(t, res[0].Fields[0].Labels, 0) require.Equal(t, res[0].Fields[0].Name, "Time") - require.Equal(t, "1", res[0].Fields[1].Name) + require.Equal(t, "Value", res[0].Fields[1].Name) + require.Equal(t, float64(1), res[0].Fields[1].At(0)) // Ensure the timestamps are UTC zoned testValue := res[0].Fields[0].At(0) @@ -360,22 +375,29 @@ type queryResult struct { Result any `json:"result"` } -func executeWithHeaders(tctx *testContext, query backend.DataQuery, qr any, headers map[string]string) (data.Frames, error) { +func executeWithHeaders(tctx *testContext, query backend.DataQuery, rqr any, eqr any, headers map[string]string) (data.Frames, error) { req := backend.QueryDataRequest{ Queries: []backend.DataQuery{query}, Headers: headers, } - promRes, err := toAPIResponse(qr) - defer func() { - if err := promRes.Body.Close(); err != nil { - fmt.Println(fmt.Errorf("response body close error: %v", err)) - } - }() + rangeRes, err := toAPIResponse(rqr) if err != nil { return nil, err } - tctx.httpProvider.setResponse(promRes) + exemplarRes, err := toAPIResponse(eqr) + if err != nil { + return nil, err + } + defer func() { + if err := rangeRes.Body.Close(); err != nil { + fmt.Println(fmt.Errorf("rangeRes body close error: %v", err)) + } + if err := exemplarRes.Body.Close(); err != nil { + fmt.Println(fmt.Errorf("exemplarRes body close error: %v", err)) + } + }() + tctx.httpProvider.setResponse(rangeRes, exemplarRes) res, err := tctx.queryData.Execute(context.Background(), &req) if err != nil { @@ -385,8 +407,8 @@ func executeWithHeaders(tctx *testContext, query backend.DataQuery, qr any, head return res.Responses[req.Queries[0].RefID].Frames, nil } -func execute(tctx *testContext, query backend.DataQuery, qr any) (data.Frames, error) { - return executeWithHeaders(tctx, query, qr, map[string]string{}) +func execute(tctx *testContext, query backend.DataQuery, rqr any, eqr any) (data.Frames, error) { + return executeWithHeaders(tctx, query, rqr, eqr, map[string]string{}) } type apiResponse struct { @@ -426,7 +448,11 @@ func setup() (*testContext, error) { opts: httpclient.Options{ Timeouts: &httpclient.DefaultTimeoutOptions, }, - res: &http.Response{ + rangeRes: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(`{}`))), + }, + exemplarRes: &http.Response{ StatusCode: 200, Body: io.NopCloser(bytes.NewReader([]byte(`{}`))), }, @@ -456,9 +482,10 @@ func setup() (*testContext, error) { type fakeHttpClientProvider struct { httpclient.Provider - opts httpclient.Options - req *http.Request - res *http.Response + opts httpclient.Options + req *http.Request + rangeRes *http.Response + exemplarRes *http.Response } func (p *fakeHttpClientProvider) New(opts ...httpclient.Options) (*http.Client, error) { @@ -476,11 +503,18 @@ func (p *fakeHttpClientProvider) GetTransport(opts ...httpclient.Options) (http. return http.DefaultTransport, nil } -func (p *fakeHttpClientProvider) setResponse(res *http.Response) { - p.res = res +func (p *fakeHttpClientProvider) setResponse(rangeRes *http.Response, exemplarRes *http.Response) { + p.rangeRes = rangeRes + p.exemplarRes = exemplarRes } func (p *fakeHttpClientProvider) RoundTrip(req *http.Request) (*http.Response, error) { p.req = req - return p.res, nil + switch req.URL.Path { + case "/api/v1/query_range", "/api/v1/query": + return p.rangeRes, nil + case "/api/v1/query_exemplars": + return p.exemplarRes, nil + } + return nil, fmt.Errorf("no such path: %s", req.URL.Path) } diff --git a/pkg/promlib/resource/resource.go b/pkg/promlib/resource/resource.go index 56988d10805..0673a5e0b41 100644 --- a/pkg/promlib/resource/resource.go +++ b/pkg/promlib/resource/resource.go @@ -83,15 +83,6 @@ func (r *Resource) Execute(ctx context.Context, req *backend.CallResourceRequest return callResponse, err } -func (r *Resource) DetectVersion(ctx context.Context, req *backend.CallResourceRequest) (*backend.CallResourceResponse, error) { - newReq := &backend.CallResourceRequest{ - PluginContext: req.PluginContext, - Path: "/api/v1/status/buildinfo", - } - - return r.Execute(ctx, newReq) -} - func getSelectors(expr string) ([]string, error) { parsed, err := parser.ParseExpr(expr) if err != nil { diff --git a/pkg/promlib/resource/resource_test.go b/pkg/promlib/resource/resource_test.go new file mode 100644 index 00000000000..a9b7ed21b60 --- /dev/null +++ b/pkg/promlib/resource/resource_test.go @@ -0,0 +1,109 @@ +package resource_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/promlib/resource" +) + +type mockRoundTripper struct { + Response *http.Response + Err error +} + +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return m.Response, m.Err +} + +func setup() (*http.Client, backend.DataSourceInstanceSettings, log.Logger) { + // Mock HTTP Response + mockResponse := &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(`{"message": "success"}`))), + Header: make(http.Header), + } + + // Create a mock RoundTripper + mockTransport := &mockRoundTripper{ + Response: mockResponse, + } + + // Create a mock HTTP client using the mock RoundTripper + mockClient := &http.Client{ + Transport: mockTransport, + } + + settings := backend.DataSourceInstanceSettings{ + ID: 1, + URL: "http://mock-server", + JSONData: []byte(`{}`), + } + + logger := log.DefaultLogger + + return mockClient, settings, logger +} + +func TestNewResource(t *testing.T) { + mockClient, settings, logger := setup() + res, err := resource.New(mockClient, settings, logger) + require.NoError(t, err) + assert.NotNil(t, res) +} + +func TestResource_Execute(t *testing.T) { + mockClient, settings, logger := setup() + res, err := resource.New(mockClient, settings, logger) + require.NoError(t, err) + + req := &backend.CallResourceRequest{ + URL: "/test", + } + ctx := context.Background() + + resp, err := res.Execute(ctx, req) + require.NoError(t, err) + assert.NotNil(t, resp) +} + +func TestResource_GetSuggestions(t *testing.T) { + mockClient, _, logger := setup() + settings := backend.DataSourceInstanceSettings{ + ID: 1, + URL: "http://localhost:9090", + JSONData: []byte(`{"httpMethod": "GET"}`), + } + + res, err := resource.New(mockClient, settings, logger) + require.NoError(t, err) + + suggestionReq := resource.SuggestionRequest{ + LabelName: "instance", + Queries: []string{"up"}, + Start: "1609459200", + End: "1609462800", + Limit: 10, + } + + body, err := json.Marshal(suggestionReq) + require.NoError(t, err) + + req := &backend.CallResourceRequest{ + Body: body, + } + ctx := context.Background() + + resp, err := res.GetSuggestions(ctx, req) + require.NoError(t, err) + assert.NotNil(t, resp) +} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d37b417b34e..340946621be 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1722,6 +1722,13 @@ var ( Expression: "false", HideFromDocs: true, }, + { + Name: "templateVariablesUsesCombobox", + Description: "Use new combobox component for template variables", + Stage: FeatureStageExperimental, + Owner: grafanaFrontendPlatformSquad, + FrontendOnly: true, + }, { Name: "ABTestFeatureToggleB", Description: "Test feature toggle to see how cohorts could be set up AB testing", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 6ae52ca70d5..965d4b1fbe5 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -229,6 +229,7 @@ k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false improvedExternalSessionHandlingSAML,preview,@grafana/identity-access-team,false,false,false teamHttpHeadersMimir,experimental,@grafana/identity-access-team,false,false,false ABTestFeatureToggleA,experimental,@grafana/sharing-squad,false,false,false +templateVariablesUsesCombobox,experimental,@grafana/grafana-frontend-platform,false,false,true ABTestFeatureToggleB,experimental,@grafana/sharing-squad,false,false,false queryLibraryDashboards,experimental,@grafana/grafana-frontend-platform,false,false,false grafanaAdvisor,experimental,@grafana/plugins-platform-backend,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 0a6e5c4d0b4..64e4b7b2fc3 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -927,6 +927,10 @@ const ( // Test feature toggle to see how cohorts could be set up AB testing FlagABTestFeatureToggleA = "ABTestFeatureToggleA" + // FlagTemplateVariablesUsesCombobox + // Use new combobox component for template variables + FlagTemplateVariablesUsesCombobox = "templateVariablesUsesCombobox" + // FlagABTestFeatureToggleB // Test feature toggle to see how cohorts could be set up AB testing FlagABTestFeatureToggleB = "ABTestFeatureToggleB" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 1e79164d138..5c3dcb7040e 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3716,6 +3716,19 @@ "codeowner": "@grafana/identity-access-team" } }, + { + "metadata": { + "name": "templateVariablesUsesCombobox", + "resourceVersion": "1738141787383", + "creationTimestamp": "2025-01-29T09:09:47Z" + }, + "spec": { + "description": "Use new combobox component for template variables", + "stage": "experimental", + "codeowner": "@grafana/grafana-frontend-platform", + "frontend": true + } + }, { "metadata": { "name": "timeRangeProvider", diff --git a/pkg/services/ngalert/sender/router.go b/pkg/services/ngalert/sender/router.go index cfb474f63be..637a3c77f21 100644 --- a/pkg/services/ngalert/sender/router.go +++ b/pkg/services/ngalert/sender/router.go @@ -90,9 +90,11 @@ func (d *AlertsRouter) SyncAndApplyConfigFromDatabase(ctx context.Context) error d.logger.Debug("Attempting to sync admin configs", "count", len(cfgs)) disableExternal := d.featureManager.IsEnabled(ctx, featuremgmt.FlagAlertingDisableSendAlertsExternal) - orgsFound := make(map[int64]struct{}, len(cfgs)) + + // We're holding this lock either until we return an error or right before we stop the senders. d.adminConfigMtx.Lock() + for _, cfg := range cfgs { _, isDisabledOrg := d.disabledOrgs[cfg.OrgID] if isDisabledOrg { @@ -167,6 +169,7 @@ func (d *AlertsRouter) SyncAndApplyConfigFromDatabase(ctx context.Context) error senderLogger := log.New("ngalert.sender.external-alertmanager") s, err := NewExternalAlertmanagerSender(senderLogger, prometheus.NewRegistry()) if err != nil { + d.adminConfigMtx.Unlock() return err } d.externalAlertmanagers[cfg.OrgID] = s @@ -190,9 +193,9 @@ func (d *AlertsRouter) SyncAndApplyConfigFromDatabase(ctx context.Context) error delete(d.externalAlertmanagersCfgHash, orgID) } } - d.adminConfigMtx.Unlock() - // We can now stop these external Alertmanagers w/o having to hold a lock. + // We can now stop these senders w/o having to hold a lock. + d.adminConfigMtx.Unlock() for orgID, s := range sendersToStop { d.logger.Info("Stopping sender", "org", orgID) s.Stop() @@ -318,8 +321,10 @@ func (d *AlertsRouter) Send(ctx context.Context, key models.AlertRuleKey, alerts } // Send alerts to local notifier if they need to be handled internally // or if no external AMs have been discovered yet. + d.adminConfigMtx.RLock() + defer d.adminConfigMtx.RUnlock() var localNotifierExist, externalNotifierExist bool - if d.sendAlertsTo[key.OrgID] == models.ExternalAlertmanagers && len(d.AlertmanagersFor(key.OrgID)) > 0 { + if d.sendAlertsTo[key.OrgID] == models.ExternalAlertmanagers && len(d.alertmanagersFor(key.OrgID)) > 0 { logger.Debug("All alerts for the given org should be routed to external notifiers only. skipping the internal notifier.") } else { logger.Info("Sending alerts to local notifier", "count", len(alerts.PostableAlerts)) @@ -340,8 +345,6 @@ func (d *AlertsRouter) Send(ctx context.Context, key models.AlertRuleKey, alerts // Send alerts to external Alertmanager(s) if we have a sender for this organization // and alerts are not being handled just internally. - d.adminConfigMtx.RLock() - defer d.adminConfigMtx.RUnlock() s, ok := d.externalAlertmanagers[key.OrgID] if ok && d.sendAlertsTo[key.OrgID] != models.InternalAlertmanager { logger.Info("Sending alerts to external notifier", "count", len(alerts.PostableAlerts)) @@ -358,6 +361,10 @@ func (d *AlertsRouter) Send(ctx context.Context, key models.AlertRuleKey, alerts func (d *AlertsRouter) AlertmanagersFor(orgID int64) []*url.URL { d.adminConfigMtx.RLock() defer d.adminConfigMtx.RUnlock() + return d.alertmanagersFor(orgID) +} + +func (d *AlertsRouter) alertmanagersFor(orgID int64) []*url.URL { s, ok := d.externalAlertmanagers[orgID] if !ok { return []*url.URL{} diff --git a/public/app/api/createBaseQuery.ts b/public/app/api/createBaseQuery.ts new file mode 100644 index 00000000000..29327703bf9 --- /dev/null +++ b/public/app/api/createBaseQuery.ts @@ -0,0 +1,43 @@ +import { BaseQueryFn } from '@reduxjs/toolkit/query'; +import { lastValueFrom } from 'rxjs'; + +import { BackendSrvRequest, getBackendSrv, isFetchError } from '@grafana/runtime'; + +interface RequestOptions extends BackendSrvRequest { + manageError?: (err: unknown) => { error: unknown }; + body?: BackendSrvRequest['data']; +} + +export function createBaseQuery({ baseURL }: { baseURL: string }): BaseQueryFn { + async function backendSrvBaseQuery(requestOptions: RequestOptions) { + try { + const { data: responseData, ...meta } = await lastValueFrom( + getBackendSrv().fetch({ + ...requestOptions, + url: baseURL + requestOptions.url, + showErrorAlert: requestOptions.showErrorAlert ?? false, + data: requestOptions.body, + }) + ); + return { data: responseData, meta }; + } catch (error) { + if (requestOptions.manageError) { + return requestOptions.manageError(error); + } else { + return handleRequestError(error); + } + } + } + + return backendSrvBaseQuery; +} + +export function handleRequestError(error: unknown) { + if (isFetchError(error)) { + return { error: new Error(error.data.message) }; + } else if (error instanceof Error) { + return { error }; + } else { + return { error: new Error('Unknown error') }; + } +} diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index a292b62d479..369bd1bb25e 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -1,10 +1,10 @@ -import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react'; -import { lastValueFrom } from 'rxjs'; +import { createApi } from '@reduxjs/toolkit/query/react'; import { AppEvents, isTruthy, locationUtil } from '@grafana/data'; -import { BackendSrvRequest, config, getBackendSrv, locationService } from '@grafana/runtime'; +import { config, getBackendSrv, locationService } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; +import { createBaseQuery, handleRequestError } from 'app/api/createBaseQuery'; import appEvents from 'app/core/app_events'; import { contextSrv } from 'app/core/core'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; @@ -30,11 +30,6 @@ import { DashboardTreeSelection } from '../types'; import { PAGE_SIZE } from './services'; -interface RequestOptions extends BackendSrvRequest { - manageError?: (err: unknown) => { error: unknown }; - showErrorAlert?: boolean; -} - interface DeleteItemsArgs { selectedItems: Omit; } @@ -66,28 +61,6 @@ interface HardDeleteDashboardArgs { dashboardUID: string; } -function createBackendSrvBaseQuery({ baseURL }: { baseURL: string }): BaseQueryFn { - async function backendSrvBaseQuery(requestOptions: RequestOptions) { - // Suppress error pop-up for root (aka 'general') folder - const isGeneralFolder = requestOptions.url === `/folders/general`; - requestOptions = isGeneralFolder ? { ...requestOptions, showErrorAlert: false } : requestOptions; - - try { - const { data: responseData, ...meta } = await lastValueFrom( - getBackendSrv().fetch({ - ...requestOptions, - url: baseURL + requestOptions.url, - }) - ); - return { data: responseData, meta }; - } catch (error) { - return requestOptions.manageError ? requestOptions.manageError(error) : { error }; - } - } - - return backendSrvBaseQuery; -} - export interface ListFolderQueryArgs { page: number; parentUid: string | undefined; @@ -98,7 +71,7 @@ export interface ListFolderQueryArgs { export const browseDashboardsAPI = createApi({ tagTypes: ['getFolder'], reducerPath: 'browseDashboardsAPI', - baseQuery: createBackendSrvBaseQuery({ baseURL: '/api' }), + baseQuery: createBaseQuery({ baseURL: '/api' }), endpoints: (builder) => ({ listFolders: builder.query({ providesTags: (result) => result?.map((folder) => ({ type: 'getFolder', id: folder.uid })) ?? [], @@ -121,7 +94,7 @@ export const browseDashboardsAPI = createApi({ query: ({ title, parentUid }) => ({ method: 'POST', url: '/folders', - data: { + body: { title, parentUid, }, @@ -148,7 +121,7 @@ export const browseDashboardsAPI = createApi({ query: ({ uid, title, version }) => ({ method: 'PUT', url: `/folders/${uid}`, - data: { + body: { title, version, }, @@ -171,7 +144,7 @@ export const browseDashboardsAPI = createApi({ query: ({ folder, destinationUID }) => ({ url: `/folders/${folder.uid}/move`, method: 'POST', - data: { parentUID: destinationUID }, + body: { parentUID: destinationUID }, }), onQueryStarted: ({ folder, destinationUID }, { queryFulfilled, dispatch }) => { const { parentUid } = folder; @@ -260,23 +233,34 @@ export const browseDashboardsAPI = createApi({ await baseQuery({ url: `/folders/${folderUID}/move`, method: 'POST', - data: { parentUID: destinationUID }, + body: { parentUID: destinationUID }, }); } // Move all the dashboards sequentially // TODO error handling here for (const dashboardUID of selectedDashboards) { - const fullDash: DashboardDTO = await getDashboardAPI().getDashboardDTO(dashboardUID); + if (config.featureToggles.useV2DashboardsAPI) { + const fullDash = await getDashboardAPI('v2').getDashboardDTO(dashboardUID); - await getDashboardAPI().saveDashboard({ - dashboard: fullDash.dashboard, - folderUid: destinationUID, - overwrite: false, - message: '', - }); + await getDashboardAPI('v2').saveDashboard({ + dashboard: fullDash.spec, + folderUid: destinationUID, + overwrite: false, + message: '', + k8s: fullDash.metadata, + }); + } else { + const fullDash: DashboardDTO = await getDashboardAPI().getDashboardDTO(dashboardUID); + + await getDashboardAPI().saveDashboard({ + dashboard: fullDash.dashboard, + folderUid: destinationUID, + overwrite: false, + message: '', + }); + } } - return { data: undefined }; }, onQueryStarted: ({ destinationUID, selectedItems }, { queryFulfilled, dispatch }) => { @@ -356,7 +340,7 @@ export const browseDashboardsAPI = createApi({ } throw new Error('Invalid dashboard version'); } catch (error) { - return { error }; + return handleRequestError(error); } }, @@ -378,7 +362,7 @@ export const browseDashboardsAPI = createApi({ query: ({ dashboard, overwrite, inputs, folderUid }) => ({ method: 'POST', url: '/dashboards/import', - data: { + body: { dashboard, overwrite, inputs, @@ -403,7 +387,7 @@ export const browseDashboardsAPI = createApi({ restoreDashboard: builder.mutation({ query: ({ dashboardUID, targetFolderUID }) => ({ url: `/dashboards/uid/${dashboardUID}/trash`, - data: { + body: { folderUid: targetFolderUID, }, method: 'PATCH', diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index 64d850e9047..d885e17fdda 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -154,6 +154,22 @@ describe('DashboardScenePageStateManager v1', () => { status: 500, }); }); + + it('should throw when v2 custom home dashboard is provided', async () => { + setBackendSrv({ + get: () => Promise.resolve({ dashboard: customHomeDashboardV2Spec, meta: {} }), + } as unknown as BackendSrv); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home }); + + expect(loader.state.dashboard).toBeUndefined(); + expect(loader.state.loadError).toEqual({ + message: 'v2 dashboard spec is not supported. Enable useV2DashboardsAPI feature toggle', + messageId: undefined, + status: undefined, + }); + }); }); describe('New dashboards', () => { @@ -423,13 +439,12 @@ describe('DashboardScenePageStateManager v2', () => { }); describe('Home dashboard', () => { - // TODO: Unskip when redirect is implemented in v2 API - it.skip('should handle home dashboard redirect', async () => { + it('should handle home dashboard redirect', async () => { setBackendSrv({ get: () => Promise.resolve({ redirectUri: '/d/asd' }), } as unknown as BackendSrv); - const loader = new DashboardScenePageStateManager({}); + const loader = new DashboardScenePageStateManagerV2({}); await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home }); expect(loader.state.dashboard).toBeUndefined(); @@ -455,6 +470,45 @@ describe('DashboardScenePageStateManager v2', () => { status: 500, }); }); + + it('should not transform v2 custom home dashboard spec', async () => { + setBackendSrv({ + get: () => + Promise.resolve({ + dashboard: customHomeDashboardV2Spec, + meta: { + canSave: false, + canEdit: true, + canAdmin: false, + canStar: false, + canDelete: false, + slug: '', + url: '', + expires: '0001-01-01T00:00:00Z', + created: '0001-01-01T00:00:00Z', + updated: '0001-01-01T00:00:00Z', + updatedBy: '', + createdBy: '', + version: 0, + hasAcl: false, + isFolder: false, + folderId: 0, + folderUid: '', + folderTitle: 'General', + folderUrl: '', + provisioned: false, + provisionedExternalId: '', + annotationsPermissions: null, + }, + }), + } as unknown as BackendSrv); + + const loader = new DashboardScenePageStateManagerV2({}); + await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home }); + + expect(loader.state.dashboard?.getInitialSaveModel()).toEqual(customHomeDashboardV2Spec); + expect(loader.state.loadError).toBeUndefined(); + }); }); describe('New dashboards', () => { @@ -593,3 +647,78 @@ describe('DashboardScenePageStateManager v2', () => { }); }); }); + +const customHomeDashboardV2Spec = { + title: 'Home Dashboard v2 schema', + cursorSync: 'Off', + preload: false, + editable: true, + links: [], + tags: [], + timeSettings: { + timezone: 'browser', + from: 'now-6h', + to: 'now', + autoRefresh: '', + autoRefreshIntervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'], + quickRanges: ['5m', '15m', '1h', '6h', '12h', '24h', '2d', '7d', '30d'], + hideTimepicker: false, + weekStart: '', + fiscalYearStartMonth: 0, + }, + variables: [], + elements: { + text_panel: { + kind: 'Panel', + spec: { + id: 0, + title: 'Welcome', + description: 'Welcome to the home dashboard!', + links: [], + data: { + kind: 'QueryGroup', + spec: { + queries: [], + transformations: [], + queryOptions: {}, + }, + }, + vizConfig: { + kind: 'text', + spec: { + pluginVersion: '', + options: { + mode: 'markdown', + content: '# Welcome to the home dashboard!\n\n## Example of v2 schema home dashboard', + }, + fieldConfig: { + defaults: {}, + overrides: [], + }, + }, + }, + }, + }, + }, + annotations: [], + layout: { + kind: 'GridLayout', + spec: { + items: [ + { + kind: 'GridLayoutItem', + spec: { + x: 6, + y: 0, + width: 12, + height: 6, + element: { + kind: 'ElementReference', + name: 'text_panel', + }, + }, + }, + ], + }, + }, +}; diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 22b527b5bdc..db5a64048c7 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -10,11 +10,18 @@ import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; import { AnnoKeyFolder } from 'app/features/apiserver/types'; import { ResponseTransformers } from 'app/features/dashboard/api/ResponseTransformers'; import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; +import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; import { dashboardLoaderSrv, DashboardLoaderSrvV2 } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor'; import { trackDashboardSceneLoaded } from 'app/features/dashboard/utils/tracking'; -import { DashboardDTO, DashboardRoutes } from 'app/types'; +import { + DashboardDataDTO, + DashboardDTO, + DashboardRoutes, + HomeDashboardRedirectDTO, + isRedirectResponse, +} from 'app/types'; import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; @@ -69,6 +76,10 @@ export interface LoadDashboardOptions { }; } +export type HomeDashboardDTO = DashboardDTO & { + dashboard: DashboardDataDTO | DashboardV2Spec; +}; + interface DashboardScenePageStateManagerLike { fetchDashboard(options: LoadDashboardOptions): Promise; getDashboardFromCache(cacheKey: string): T | null; @@ -168,6 +179,11 @@ abstract class DashboardScenePageStateManagerBase private async loadScene(options: LoadDashboardOptions): Promise { this.setState({ dashboard: undefined, isLoading: true }); const rsp = await this.fetchDashboard(options); + + if (!rsp) { + return null; + } + return this.transformResponseToScene(rsp, options); } @@ -236,12 +252,6 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag return scene; } - if (rsp?.redirectUri) { - const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri); - locationService.replace(newUrl); - return null; - } - throw new Error('Dashboard not found'); } @@ -273,7 +283,7 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag } } - let rsp: DashboardDTO; + let rsp: DashboardDTO | HomeDashboardRedirectDTO; try { switch (route) { @@ -282,10 +292,16 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag break; case DashboardRoutes.Home: - rsp = await getBackendSrv().get('/api/dashboards/home'); + rsp = await getBackendSrv().get('/api/dashboards/home'); - if (rsp.redirectUri) { - return rsp; + if (isRedirectResponse(rsp)) { + const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri); + locationService.replace(newUrl); + return null; + } + + if (isDashboardV2Spec(rsp.dashboard)) { + throw new Error('v2 dashboard spec is not supported. Enable useV2DashboardsAPI feature toggle'); } if (rsp?.meta) { @@ -458,13 +474,6 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan return scene; } - // TOD)[schema v2]: Figure out redirect utl - // if (rsp?.redirectUri) { - // const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri); - // locationService.replace(newUrl); - // return null; - // } - throw new Error('Dashboard not found'); } @@ -492,17 +501,25 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan rsp = await buildNewDashboardSaveModelV2(urlFolderUid); break; case DashboardRoutes.Home: - // throw new Error('Method not implemented.'); - const dto = await getBackendSrv().get('/api/dashboards/home'); + const dto = await getBackendSrv().get('/api/dashboards/home'); + + if (isRedirectResponse(dto)) { + const newUrl = locationUtil.stripBaseFromUrl(dto.redirectUri); + locationService.replace(newUrl); + return null; + } + rsp = ResponseTransformers.ensureV2Response(dto); + + // if custom home dashboard is v2 spec already, ignore the spec transformation + if (isDashboardV2Spec(dto.dashboard)) { + rsp.spec = dto.dashboard; + } + rsp.access.canSave = false; rsp.access.canShare = false; rsp.access.canStar = false; - // if (rsp.redirectUri) { - // return rsp; - // } - break; case DashboardRoutes.Public: { return await this.dashboardLoader.loadDashboard('public', '', uid); diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts index 1831507291b..535265f606d 100644 --- a/public/app/features/dashboard/api/ResponseTransformers.ts +++ b/public/app/features/dashboard/api/ResponseTransformers.ts @@ -103,13 +103,8 @@ export function ensureV2Response( if (isDashboardResource(dto)) { accessMeta = dto.access; annotationsMeta = { - [AnnoKeyCreatedBy]: dto.metadata.annotations?.[AnnoKeyCreatedBy], - [AnnoKeyUpdatedBy]: dto.metadata.annotations?.[AnnoKeyUpdatedBy], - [AnnoKeyUpdatedTimestamp]: dto.metadata.annotations?.[AnnoKeyUpdatedTimestamp], - [AnnoKeyFolder]: dto.metadata.annotations?.[AnnoKeyFolder], - [AnnoKeySlug]: dto.metadata.annotations?.[AnnoKeySlug], + ...dto.metadata.annotations, [AnnoKeyDashboardGnetId]: dashboard.gnetId ?? undefined, - [AnnoKeyDashboardIsSnapshot]: dto.metadata.annotations?.[AnnoKeyDashboardIsSnapshot], }; creationTimestamp = dto.metadata.creationTimestamp; labelsMeta = { diff --git a/public/app/features/dashboard/api/publicDashboardApi.ts b/public/app/features/dashboard/api/publicDashboardApi.ts index 063b55e4d79..40bda98f4c5 100644 --- a/public/app/features/dashboard/api/publicDashboardApi.ts +++ b/public/app/features/dashboard/api/publicDashboardApi.ts @@ -1,7 +1,7 @@ -import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react'; -import { lastValueFrom } from 'rxjs'; +import { createApi } from '@reduxjs/toolkit/query/react'; -import { BackendSrvRequest, config, FetchError, getBackendSrv, isFetchError } from '@grafana/runtime/src'; +import { config, FetchError, isFetchError } from '@grafana/runtime/src'; +import { createBaseQuery } from 'app/api/createBaseQuery'; import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; import { t } from 'app/core/internationalization'; @@ -19,39 +19,17 @@ import { PublicDashboardListWithPaginationResponse, } from 'app/features/manage-dashboards/types'; -type ReqOptions = { - manageError?: (err: unknown) => { error: unknown }; - showErrorAlert?: boolean; -}; - function isFetchBaseQueryError(error: unknown): error is { error: FetchError } { return typeof error === 'object' && error != null && 'error' in error; } -const backendSrvBaseQuery = - ({ baseUrl }: { baseUrl: string }): BaseQueryFn => - async (requestOptions) => { - try { - const { data: responseData, ...meta } = await lastValueFrom( - getBackendSrv().fetch({ - ...requestOptions, - url: baseUrl + requestOptions.url, - showErrorAlert: requestOptions.showErrorAlert, - }) - ); - return { data: responseData, meta }; - } catch (error) { - return requestOptions.manageError ? requestOptions.manageError(error) : { error }; - } - }; - -const getConfigError = (err: unknown) => ({ +export const getConfigError = (err: unknown) => ({ error: isFetchError(err) && err.data.messageId !== 'publicdashboards.notFound' ? err : null, }); export const publicDashboardApi = createApi({ reducerPath: 'publicDashboardApi', - baseQuery: backendSrvBaseQuery({ baseUrl: '/api' }), + baseQuery: createBaseQuery({ baseURL: '/api' }), tagTypes: ['PublicDashboard', 'AuditTablePublicDashboard', 'UsersWithActiveSessions', 'ActiveUserDashboards'], refetchOnMountOrArgChange: true, endpoints: (builder) => ({ @@ -81,7 +59,7 @@ export const publicDashboardApi = createApi({ return { url: `/dashboards/uid/${dashUid}/public-dashboards`, method: 'POST', - data: params.payload, + body: params.payload, }; }, async onQueryStarted({ dashboard, payload: { share } }, { dispatch, queryFulfilled }) { @@ -121,7 +99,7 @@ export const publicDashboardApi = createApi({ return { url: `/dashboards/uid/${dashUid}/public-dashboards/${payload.uid}`, method: 'PATCH', - data: payload, + body: payload, }; }, async onQueryStarted({ dashboard }, { dispatch, queryFulfilled }) { @@ -153,7 +131,7 @@ export const publicDashboardApi = createApi({ return { url: `/dashboards/uid/${dashUid}/public-dashboards/${payload.uid}`, method: 'PATCH', - data: payload, + body: payload, }; }, async onQueryStarted({ dashboard, payload: { isEnabled } }, { dispatch, queryFulfilled }) { @@ -193,7 +171,7 @@ export const publicDashboardApi = createApi({ return { url: `/dashboards/uid/${dashUid}/public-dashboards/${payload.uid}`, method: 'PATCH', - data: payload, + body: payload, }; }, async onQueryStarted({ dashboard, payload: { share } }, { dispatch, queryFulfilled }) { diff --git a/public/app/features/dashboard/api/v2.ts b/public/app/features/dashboard/api/v2.ts index 3a283071091..ec4fa199386 100644 --- a/public/app/features/dashboard/api/v2.ts +++ b/public/app/features/dashboard/api/v2.ts @@ -56,6 +56,11 @@ export class K8sDashboardV2API } catch (e) { throw new Error('Failed to load folder'); } + } else if (result.metadata.annotations && !result.metadata.annotations[AnnoKeyFolder]) { + // Set AnnoKeyFolder to empty string for top-level dashboards + // This ensures NestedFolderPicker correctly identifies it as being in the "Dashboard" root folder + // AnnoKeyFolder undefined -> top-level dashboard -> empty string + result.metadata.annotations[AnnoKeyFolder] = ''; } // Depending on the ui components readiness, we might need to convert the response to v1 @@ -117,6 +122,8 @@ export class K8sDashboardV2API } if (obj.metadata.name) { + // remove resource version when updating + delete obj.metadata.resourceVersion; return this.client.update(obj).then((v) => this.asSaveDashboardResponseDTO(v)); } return await this.client.create(obj).then((v) => this.asSaveDashboardResponseDTO(v)); diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 4ee7261cf02..23b36244519 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -24,6 +24,8 @@ import { DashboardDTO, DashboardInitPhase, DashboardRoutes, + HomeDashboardRedirectDTO, + isRedirectResponse, StoreState, ThunkDispatch, ThunkResult, @@ -69,10 +71,10 @@ async function fetchDashboard( } // load home dash - const dashDTO: DashboardDTO = await backendSrv.get('/api/dashboards/home'); + const dashDTO = await backendSrv.get('/api/dashboards/home'); // if user specified a custom home dashboard redirect to that - if (dashDTO.redirectUri) { + if (isRedirectResponse(dashDTO)) { const newUrl = locationUtil.stripBaseFromUrl(dashDTO.redirectUri); locationService.replace(newUrl); return null; diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx index 54dc5951b88..076e1664cb4 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesList.tsx @@ -53,7 +53,7 @@ export function QueryTemplatesList(props: QueryTemplatesListProps) { return uniqBy(loadQueryMetadataResult.value, 'datasourceName').map((row) => row.datasourceName); }, [loadQueryMetadataResult.value]); - if (error) { + if (error instanceof Error) { return ( {error.message} diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFiltersTags.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFiltersTags.tsx index 0aabff724a4..10fa8befd62 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFiltersTags.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFiltersTags.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { useMount } from 'react-use'; import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data'; -import { AccessoryButton } from '@grafana/experimental'; +import { AccessoryButton } from '@grafana/plugin-ui'; import { Input, Select, Stack, useStyles2 } from '@grafana/ui'; import { randomId, SearchProps, Tag } from '../../../useSearch'; diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/jsonMarkup.js b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/jsonMarkup.js index 2dcbb1ee895..7db0889e1e6 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/jsonMarkup.js +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/jsonMarkup.js @@ -67,7 +67,7 @@ function escape(str) { return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } -module.exports = function (doc, styleFile) { +export default function jsonMarkup(doc, styleFile) { let indent = ''; const style = Stylize(styleFile); @@ -130,4 +130,4 @@ module.exports = function (doc, styleFile) { } return '
' + visit(doc) + '
'; -}; +} diff --git a/public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx b/public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx index a612dee380d..11710cef528 100644 --- a/public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx +++ b/public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx @@ -7,7 +7,7 @@ import { toOption, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; -import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/plugin-ui'; import { InlineField, InlineFieldRow, Input, Select, useStyles2 } from '@grafana/ui'; export interface SpanBarOptions { diff --git a/public/app/features/migrate-to-cloud/api/baseAPI.ts b/public/app/features/migrate-to-cloud/api/baseAPI.ts index 6a140906631..60c18dfd3e3 100644 --- a/public/app/features/migrate-to-cloud/api/baseAPI.ts +++ b/public/app/features/migrate-to-cloud/api/baseAPI.ts @@ -1,38 +1,9 @@ -import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react'; -import { lastValueFrom } from 'rxjs'; +import { createApi } from '@reduxjs/toolkit/query/react'; -import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; - -interface RequestOptions extends BackendSrvRequest { - manageError?: (err: unknown) => { error: unknown }; - showErrorAlert?: boolean; - - // rtk codegen sets this - body?: BackendSrvRequest['data']; -} - -function createBackendSrvBaseQuery({ baseURL }: { baseURL: string }): BaseQueryFn { - async function backendSrvBaseQuery(requestOptions: RequestOptions) { - try { - const { data: responseData, ...meta } = await lastValueFrom( - getBackendSrv().fetch({ - ...requestOptions, - url: baseURL + requestOptions.url, - showErrorAlert: false, - data: requestOptions.body, - }) - ); - return { data: responseData, meta }; - } catch (error) { - return requestOptions.manageError ? requestOptions.manageError(error) : { error }; - } - } - - return backendSrvBaseQuery; -} +import { createBaseQuery } from 'app/api/createBaseQuery'; export const baseAPI = createApi({ reducerPath: 'migrateToCloudGeneratedAPI', - baseQuery: createBackendSrvBaseQuery({ baseURL: '/api' }), + baseQuery: createBaseQuery({ baseURL: '/api' }), endpoints: () => ({}), }); diff --git a/public/app/features/migrate-to-cloud/api/index.ts b/public/app/features/migrate-to-cloud/api/index.ts index 60a2d7a7b87..0e5c1d8b453 100644 --- a/public/app/features/migrate-to-cloud/api/index.ts +++ b/public/app/features/migrate-to-cloud/api/index.ts @@ -1,11 +1,14 @@ -export * from './endpoints.gen'; import { BaseQueryFn, EndpointDefinition } from '@reduxjs/toolkit/query'; import { getLocalPlugins } from 'app/features/plugins/admin/api'; import { LocalPlugin } from 'app/features/plugins/admin/types'; +import { handleRequestError } from '../../../api/createBaseQuery'; + import { generatedAPI } from './endpoints.gen'; +export * from './endpoints.gen'; + export const cloudMigrationAPI = generatedAPI .injectEndpoints({ endpoints: (build) => ({ @@ -16,7 +19,7 @@ export const cloudMigrationAPI = generatedAPI const list = await getLocalPlugins(); return { data: list }; } catch (error) { - return { error: error }; + return handleRequestError(error); } }, }), diff --git a/public/app/features/preferences/api/user/baseAPI.ts b/public/app/features/preferences/api/user/baseAPI.ts index b1b75e7b036..33316419f6e 100644 --- a/public/app/features/preferences/api/user/baseAPI.ts +++ b/public/app/features/preferences/api/user/baseAPI.ts @@ -1,36 +1,9 @@ -import { BaseQueryFn, createApi } from '@reduxjs/toolkit/query/react'; -import { lastValueFrom } from 'rxjs'; +import { createApi } from '@reduxjs/toolkit/query/react'; -import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime'; - -interface RequestOptions extends BackendSrvRequest { - manageError?: (err: unknown) => { error: unknown }; - showErrorAlert?: boolean; - body?: BackendSrvRequest['data']; -} - -function createBackendSrvBaseQuery({ baseURL }: { baseURL: string }): BaseQueryFn { - async function backendSrvBaseQuery(requestOptions: RequestOptions) { - try { - const { data: responseData, ...meta } = await lastValueFrom( - getBackendSrv().fetch({ - ...requestOptions, - url: baseURL + requestOptions.url, - showErrorAlert: requestOptions.showErrorAlert, - data: requestOptions.body, - }) - ); - return { data: responseData, meta }; - } catch (error) { - return requestOptions.manageError ? requestOptions.manageError(error) : { error }; - } - } - - return backendSrvBaseQuery; -} +import { createBaseQuery } from 'app/api/createBaseQuery'; export const baseAPI = createApi({ reducerPath: 'userPreferencesAPI', - baseQuery: createBackendSrvBaseQuery({ baseURL: '/api' }), + baseQuery: createBaseQuery({ baseURL: '/api' }), endpoints: () => ({}), }); diff --git a/public/app/features/query-library/api/factory.ts b/public/app/features/query-library/api/factory.ts index 00e6115db7d..d03074092d6 100644 --- a/public/app/features/query-library/api/factory.ts +++ b/public/app/features/query-library/api/factory.ts @@ -1,12 +1,14 @@ import { createApi } from '@reduxjs/toolkit/query/react'; -import { baseQuery } from './query'; +import { createBaseQuery } from '../../../api/createBaseQuery'; + +import { BASE_URL } from './query'; // Currently, we are loading all query templates // Organizations can have maximum of 1000 query templates export const QUERY_LIBRARY_GET_LIMIT = 1000; export const queryLibraryApi = createApi({ - baseQuery, + baseQuery: createBaseQuery({ baseURL: BASE_URL }), endpoints: () => ({}), }); diff --git a/public/app/features/query-library/api/query.ts b/public/app/features/query-library/api/query.ts index 610c1556387..d0cc6db0209 100644 --- a/public/app/features/query-library/api/query.ts +++ b/public/app/features/query-library/api/query.ts @@ -1,8 +1,3 @@ -import { BaseQueryFn } from '@reduxjs/toolkit/query/react'; -import { lastValueFrom } from 'rxjs'; - -import { BackendSrvRequest, getBackendSrv, isFetchError } from '@grafana/runtime/src/services/backendSrv'; - import { getAPINamespace } from '../../../api/utils'; /** @@ -23,29 +18,3 @@ export enum QueryTemplateKinds { * @alpha */ export const BASE_URL = `/apis/${API_VERSION}/namespaces/${getAPINamespace()}`; - -interface QueryLibraryBackendRequest extends BackendSrvRequest { - body?: BackendSrvRequest['data']; -} - -export const baseQuery: BaseQueryFn = async (requestOptions) => { - try { - const responseObservable = getBackendSrv().fetch({ - url: `${BASE_URL}/${requestOptions.url ?? ''}`, - showErrorAlert: true, - method: requestOptions.method || 'GET', - data: requestOptions.body, - params: requestOptions.params, - headers: { ...requestOptions.headers }, - }); - return await lastValueFrom(responseObservable); - } catch (error) { - if (isFetchError(error)) { - return { error: new Error(error.data.message) }; - } else if (error instanceof Error) { - return { error }; - } else { - return { error: new Error('Unknown error') }; - } - } -}; diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/ConfigEditor.tsx b/public/app/plugins/datasource/grafana-pyroscope-datasource/ConfigEditor.tsx index af6467d7896..3b2e3cbb5d0 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/ConfigEditor.tsx +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/ConfigEditor.tsx @@ -9,7 +9,7 @@ import { ConnectionSettings, DataSourceDescription, convertLegacyAuthProps, -} from '@grafana/experimental'; +} from '@grafana/plugin-ui'; import { config } from '@grafana/runtime'; import { Divider, diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx index 6e7ace1ca18..14134fec7ec 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/GroupByField.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { GrafanaTheme2 } from '@grafana/data'; -import { AccessoryButton } from '@grafana/experimental'; +import { AccessoryButton } from '@grafana/plugin-ui'; import { Alert, HorizontalGroup, InputActionMeta, Select, useStyles2 } from '@grafana/ui'; import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen'; diff --git a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.tsx b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.tsx index d5f69c33064..d9ecd049504 100644 --- a/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.tsx +++ b/public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { GrafanaTheme2 } from '@grafana/data'; -import { AccessoryButton } from '@grafana/experimental'; +import { AccessoryButton } from '@grafana/plugin-ui'; import { FetchError } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx index 7602cea1487..941bcaadceb 100644 --- a/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx @@ -1,6 +1,13 @@ import { css } from '@emotion/css'; import { DataSourcePluginOptionsEditorProps, GrafanaTheme2 } from '@grafana/data'; +import { + NodeGraphSection, + SpanBarSection, + TraceToLogsSection, + TraceToMetricsSection, + TraceToProfilesSection, +} from '@grafana/o11y-ds-frontend'; import { AdvancedHttpSettings, Auth, @@ -10,14 +17,7 @@ import { ConnectionSettings, convertLegacyAuthProps, DataSourceDescription, -} from '@grafana/experimental'; -import { - NodeGraphSection, - SpanBarSection, - TraceToLogsSection, - TraceToMetricsSection, - TraceToProfilesSection, -} from '@grafana/o11y-ds-frontend'; +} from '@grafana/plugin-ui'; import { config } from '@grafana/runtime'; import { SecureSocksProxySettings, useStyles2, Divider, Stack } from '@grafana/ui'; diff --git a/public/app/plugins/datasource/tempo/configuration/StreamingSection.tsx b/public/app/plugins/datasource/tempo/configuration/StreamingSection.tsx index bba18945603..e3d63e1ae26 100644 --- a/public/app/plugins/datasource/tempo/configuration/StreamingSection.tsx +++ b/public/app/plugins/datasource/tempo/configuration/StreamingSection.tsx @@ -7,7 +7,7 @@ import { GrafanaTheme2, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; -import { ConfigSection } from '@grafana/experimental'; +import { ConfigSection } from '@grafana/plugin-ui'; import { InlineFieldRow, InlineField, InlineSwitch, Alert, Stack, useStyles2 } from '@grafana/ui'; import { FeatureName, featuresToTempoVersion } from '../datasource'; diff --git a/public/app/plugins/datasource/tempo/configuration/TagLimitSettings.tsx b/public/app/plugins/datasource/tempo/configuration/TagLimitSettings.tsx index edda2b69462..33768a47b9b 100644 --- a/public/app/plugins/datasource/tempo/configuration/TagLimitSettings.tsx +++ b/public/app/plugins/datasource/tempo/configuration/TagLimitSettings.tsx @@ -6,7 +6,7 @@ import { GrafanaTheme2, updateDatasourcePluginJsonDataOption, } from '@grafana/data'; -import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/experimental'; +import { ConfigDescriptionLink, ConfigSubSection } from '@grafana/plugin-ui'; import { InlineField, InlineFieldRow, Input, useStyles2 } from '@grafana/ui'; export interface TagLimitOptions extends DataSourceJsonData { diff --git a/public/app/plugins/datasource/tempo/package.json b/public/app/plugins/datasource/tempo/package.json index 32e5bd74287..970b6048c84 100644 --- a/public/app/plugins/datasource/tempo/package.json +++ b/public/app/plugins/datasource/tempo/package.json @@ -7,11 +7,11 @@ "@emotion/css": "11.13.5", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", - "@grafana/experimental": "2.1.6", "@grafana/lezer-logql": "0.2.6", "@grafana/lezer-traceql": "0.0.20", "@grafana/monaco-logql": "^0.0.8", "@grafana/o11y-ds-frontend": "workspace:*", + "@grafana/plugin-ui": "0.10.1", "@grafana/runtime": "workspace:*", "@grafana/schema": "workspace:*", "@grafana/ui": "workspace:*", @@ -32,7 +32,7 @@ "react-select": "5.10.0", "react-use": "17.6.0", "rxjs": "7.8.1", - "semver": "7.6.3", + "semver": "7.7.0", "stream-browserify": "3.0.0", "string_decoder": "1.3.0", "tslib": "2.8.1", diff --git a/public/app/plugins/datasource/tempo/traceql/TempoQueryBuilderOptions.tsx b/public/app/plugins/datasource/tempo/traceql/TempoQueryBuilderOptions.tsx index 5834a153594..a6d0ae61fcd 100644 --- a/public/app/plugins/datasource/tempo/traceql/TempoQueryBuilderOptions.tsx +++ b/public/app/plugins/datasource/tempo/traceql/TempoQueryBuilderOptions.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { EditorField, EditorRow } from '@grafana/experimental'; +import { EditorField, EditorRow } from '@grafana/plugin-ui'; import { AutoSizeInput, RadioButtonGroup, useStyles2 } from '@grafana/ui'; import { QueryOptionGroup } from '../_importedDependencies/datasources/prometheus/QueryOptionGroup'; diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index 5c36ad21ad1..a5705803382 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -4,8 +4,11 @@ import { ObjectMeta } from 'app/features/apiserver/types'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { ProvisioningPreview } from 'app/features/provisioning/types'; +export interface HomeDashboardRedirectDTO { + redirectUri: string; +} + export interface DashboardDTO { - redirectUri?: string; dashboard: DashboardDataDTO; meta: DashboardMeta; } @@ -145,3 +148,7 @@ export interface DashboardState { } export const DASHBOARD_FROM_LS_KEY = 'DASHBOARD_FROM_LS_KEY'; + +export function isRedirectResponse(dto: DashboardDTO | HomeDashboardRedirectDTO): dto is HomeDashboardRedirectDTO { + return 'redirectUri' in dto; +} diff --git a/yarn.lock b/yarn.lock index c930abae96b..2c6b0370c1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3043,12 +3043,12 @@ __metadata: "@emotion/css": "npm:11.13.5" "@grafana/data": "workspace:*" "@grafana/e2e-selectors": "workspace:*" - "@grafana/experimental": "npm:2.1.6" "@grafana/lezer-logql": "npm:0.2.6" "@grafana/lezer-traceql": "npm:0.0.20" "@grafana/monaco-logql": "npm:^0.0.8" "@grafana/o11y-ds-frontend": "workspace:*" "@grafana/plugin-configs": "npm:11.5.0-pre" + "@grafana/plugin-ui": "npm:0.10.1" "@grafana/runtime": "workspace:*" "@grafana/schema": "workspace:*" "@grafana/ui": "workspace:*" @@ -3083,7 +3083,7 @@ __metadata: react-select-event: "npm:5.5.1" react-use: "npm:17.6.0" rxjs: "npm:7.8.1" - semver: "npm:7.6.3" + semver: "npm:7.7.0" stream-browserify: "npm:3.0.0" string_decoder: "npm:1.3.0" ts-node: "npm:10.9.2" @@ -3272,7 +3272,7 @@ __metadata: rollup-plugin-dts: "npm:^6.1.1" rollup-plugin-esbuild: "npm:6.1.1" rollup-plugin-node-externals: "npm:^8.0.0" - semver: "npm:7.6.3" + semver: "npm:7.7.0" tslib: "npm:2.8.1" typescript: "npm:5.7.3" languageName: unknown @@ -3547,17 +3547,17 @@ __metadata: languageName: unknown linkType: soft -"@grafana/plugin-e2e@npm:1.17.0": - version: 1.17.0 - resolution: "@grafana/plugin-e2e@npm:1.17.0" +"@grafana/plugin-e2e@npm:1.17.1": + version: 1.17.1 + resolution: "@grafana/plugin-e2e@npm:1.17.1" dependencies: - "@grafana/e2e-selectors": "npm:^11.5.0-220285" + "@grafana/e2e-selectors": "npm:^11.5.0-221187" semver: "npm:^7.5.4" uuid: "npm:^11.0.2" yaml: "npm:^2.3.4" peerDependencies: "@playwright/test": ^1.41.2 - checksum: 10/d24465857228cb9588777f92bd7bee8b5b7042a9375cb20a31b04e9af75b307248d3f17bd421eff0640ff9592612ef66e16f1e461af57ca7c2d680052fc02348 + checksum: 10/d98b06fa04f59e07a8385fd81e8a7b456332d431aa529a0eb748087524f8b7b1bee867e616e01e7d1842b9e3c8dd508e8122fd5fb4d5a0a25162a4657b3dd4c0 languageName: node linkType: hard @@ -3713,7 +3713,7 @@ __metadata: rxjs: "npm:7.8.1" sass: "npm:1.83.4" sass-loader: "npm:16.0.4" - semver: "npm:7.6.3" + semver: "npm:7.7.0" style-loader: "npm:4.0.0" testing-library-selector: "npm:0.3.1" ts-node: "npm:10.9.2" @@ -9374,9 +9374,9 @@ __metadata: linkType: hard "@types/diff@npm:^7.0.0": - version: 7.0.0 - resolution: "@types/diff@npm:7.0.0" - checksum: 10/11464fe70a81a0d02fdaad4ff4b0a0a5ac71825fa32dde9a98a1a82b1cac50dda302fb1621bea71db6c254e00ce002ea14543cc3502ac44f012598ca2c0fbcae + version: 7.0.1 + resolution: "@types/diff@npm:7.0.1" + checksum: 10/ef8c5fe0ea56737e8967c3db5e78518134f704e8e2971cb4ba6f2ed46ed5e13b4bbe41a6b2b78122b8f47c618ac25550f85a681633636a3a020369042523295d languageName: node linkType: hard @@ -17817,7 +17817,7 @@ __metadata: "@grafana/lezer-logql": "npm:0.2.7" "@grafana/monaco-logql": "npm:^0.0.8" "@grafana/o11y-ds-frontend": "workspace:*" - "@grafana/plugin-e2e": "npm:1.17.0" + "@grafana/plugin-e2e": "npm:1.17.1" "@grafana/plugin-ui": "npm:0.10.1" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" @@ -18095,13 +18095,13 @@ __metadata: sass: "npm:1.83.4" sass-loader: "npm:16.0.4" selecto: "npm:1.26.3" - semver: "npm:7.6.3" + semver: "npm:7.7.0" slate: "npm:0.47.9" slate-plain-serializer: "npm:0.7.13" slate-react: "npm:0.22.10" smtp-tester: "npm:^2.1.0" style-loader: "npm:4.0.0" - stylelint: "npm:16.13.2" + stylelint: "npm:16.14.1" stylelint-config-sass-guidelines: "npm:12.1.0" swagger-ui-react: "npm:5.18.3" symbol-observable: "npm:4.0.0" @@ -18949,7 +18949,7 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^7.0.1": +"ignore@npm:^7.0.3": version: 7.0.3 resolution: "ignore@npm:7.0.3" checksum: 10/ce5e812af3acd6607a3fe0a9f9b5f01d53f009a5ace8cbf5b6491d05a481b55d65186e6a7eaa13126e93f15276bcf3d1e8d6ff3ce5549c312f9bb313fff33365 @@ -24754,7 +24754,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:8.5.1, postcss@npm:^8.4.33, postcss@npm:^8.4.38, postcss@npm:^8.4.49": +"postcss@npm:8.5.1, postcss@npm:^8.4.33, postcss@npm:^8.4.38, postcss@npm:^8.5.1": version: 8.5.1 resolution: "postcss@npm:8.5.1" dependencies: @@ -26697,12 +26697,12 @@ __metadata: linkType: hard "react-zoom-pan-pinch@npm:^3.3.0": - version: 3.6.1 - resolution: "react-zoom-pan-pinch@npm:3.6.1" + version: 3.7.0 + resolution: "react-zoom-pan-pinch@npm:3.7.0" peerDependencies: react: "*" react-dom: "*" - checksum: 10/9146aa5c427dd6d0c8a4ebe3db0c720718eef6262d1b4b36033ee433bc76a9c84e30ca91311211ab95446305d3e2813d9abc576d093efbf5562be984431896cb + checksum: 10/5ae7f1ffea86fd19ae57f7b4c6818b282e13e00523200d23759db95a1518333044017583c2af3c179b2634c88c76162aa0ce8de79cd26359f07339415842ba34 languageName: node linkType: hard @@ -27855,12 +27855,12 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.6.3, semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3": - version: 7.6.3 - resolution: "semver@npm:7.6.3" +"semver@npm:7.7.0, semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3": + version: 7.7.0 + resolution: "semver@npm:7.7.0" bin: semver: bin/semver.js - checksum: 10/36b1fbe1a2b6f873559cd57b238f1094a053dbfd997ceeb8757d79d1d2089c56d1321b9f1069ce263dc64cfa922fa1d2ad566b39426fe1ac6c723c1487589e10 + checksum: 10/5d615860a54ff563955c451e467bff3aaf74c8d060489f936f25551d5ca05f5ac683eb46c9ed7ade082e1e53b313f205ed9c5df0b25ebb3517ec25c79e1f0d9c languageName: node linkType: hard @@ -29320,9 +29320,9 @@ __metadata: languageName: node linkType: hard -"stylelint@npm:16.13.2, stylelint@npm:^16.8.2": - version: 16.13.2 - resolution: "stylelint@npm:16.13.2" +"stylelint@npm:16.14.1, stylelint@npm:^16.8.2": + version: 16.14.1 + resolution: "stylelint@npm:16.14.1" dependencies: "@csstools/css-parser-algorithms": "npm:^3.0.4" "@csstools/css-tokenizer": "npm:^3.0.3" @@ -29342,7 +29342,7 @@ __metadata: globby: "npm:^11.1.0" globjoin: "npm:^0.1.4" html-tags: "npm:^3.3.1" - ignore: "npm:^7.0.1" + ignore: "npm:^7.0.3" imurmurhash: "npm:^0.1.4" is-plain-object: "npm:^5.0.0" known-css-properties: "npm:^0.35.0" @@ -29351,7 +29351,7 @@ __metadata: micromatch: "npm:^4.0.8" normalize-path: "npm:^3.0.0" picocolors: "npm:^1.1.1" - postcss: "npm:^8.4.49" + postcss: "npm:^8.5.1" postcss-resolve-nested-selector: "npm:^0.1.6" postcss-safe-parser: "npm:^7.0.1" postcss-selector-parser: "npm:^7.0.0" @@ -29364,7 +29364,7 @@ __metadata: write-file-atomic: "npm:^5.0.1" bin: stylelint: bin/stylelint.mjs - checksum: 10/98385b53d3c822b3b764fe8ff2f7212717127ab40ca9fd34a83bc6e27b5240d4ea02f959e01d4eaf91c87480a0c787b07b837883d4b3ec44133cc7ca03c79b47 + checksum: 10/2c0c95e5ee77d946efdbe58573da75ff4b12bc89c220e78b69f0c2f94251f5e5a1096f5ab583f28f56ae14f298b1335e212d52fe066719d63b8a41d6cc8083b6 languageName: node linkType: hard @@ -30361,9 +30361,9 @@ __metadata: linkType: hard "type-fest@npm:^4.18.2, type-fest@npm:^4.26.1": - version: 4.30.2 - resolution: "type-fest@npm:4.30.2" - checksum: 10/c5168b159c366e4fd5b74c7f7b786bed9248c03f67e6e07d52dd5d51354447468fa7c92b9f2142c7fe9279814031f783959370242c3520de848931b65ddb48bb + version: 4.33.0 + resolution: "type-fest@npm:4.33.0" + checksum: 10/0d179e66fa765bd0a25a785b12dc797f90f2f92bdb8c9c8a789f3fd8e5a4492444e7ef83551b3b8463aeab24fd6195761e26b03174722de636b4b75aa5726fb7 languageName: node linkType: hard @@ -31925,11 +31925,11 @@ __metadata: linkType: hard "yaml@npm:^2.0.0, yaml@npm:^2.3.4": - version: 2.6.1 - resolution: "yaml@npm:2.6.1" + version: 2.7.0 + resolution: "yaml@npm:2.7.0" bin: yaml: bin.mjs - checksum: 10/cf412f03a33886db0a3aac70bb4165588f4c5b3c6f8fc91520b71491e5537800b6c2c73ed52015617f6e191eb4644c73c92973960a1999779c62a200ee4c231d + checksum: 10/c8c314c62fbd49244a6a51b06482f6d495b37ab10fa685fcafa1bbaae7841b7233ee7d12cab087bcca5a0b28adc92868b6e437322276430c28d00f1c1732eeec languageName: node linkType: hard