From 002f872ce17fe9d93a949008223be9170983c333 Mon Sep 17 00:00:00 2001 From: Kristina Date: Tue, 24 Sep 2024 09:38:17 -0500 Subject: [PATCH 001/102] Correlations: Allow correlations to target URLs (#92442) * Pass one * Fix linter and add new betterer problem (sorry) * fix swagger * Add type to tests and update single correlations sql * Fix provisioning test and other function that needs a type * Add errors around query/external typing and add tests * increment number of correlations tested as we added one for testing v1 type placement * try merging back the swagger that is in main * try again? * Style form a little * Update public/app/features/logs/components/logParser.ts Co-authored-by: Matias Chomicki * fix bad commit, simplify logic * Demonstrating type difficulties * Fix distributed union changes * Additional type changes * Update types in form * Fix swagger * Add comment around the assertion and explicit typing --------- Co-authored-by: Matias Chomicki Co-authored-by: Andrej Ocenas --- .betterer.results | 9 + packages/grafana-data/src/types/dataLink.ts | 8 +- packages/grafana-data/src/utils/dataLinks.ts | 6 +- pkg/services/correlations/database.go | 17 +- pkg/services/correlations/models.go | 24 ++- pkg/services/correlations/models_test.go | 4 +- .../provisioning/datasources/datasources.go | 7 +- pkg/services/stats/statsimpl/stats_test.go | 2 +- pkg/tests/api/correlations/common_test.go | 5 + .../correlations/correlations_create_test.go | 2 +- .../correlations/correlations_delete_test.go | 5 + .../correlations_provisioning_api_test.go | 66 ++++++- .../correlations/correlations_read_test.go | 2 +- .../correlations/correlations_update_test.go | 5 +- public/api-merged.json | 1 + .../correlations/CorrelationsPage.test.tsx | 6 +- .../correlations/CorrelationsPage.tsx | 33 ++-- .../Forms/ConfigureCorrelationSourceForm.tsx | 54 ++++-- .../Forms/ConfigureCorrelationTargetForm.tsx | 171 ++++++++++++++---- .../correlations/Forms/QueryEditorField.tsx | 14 +- .../app/features/correlations/Forms/types.ts | 28 ++- .../app/features/correlations/Forms/utils.ts | 2 +- public/app/features/correlations/types.ts | 42 +++-- .../features/correlations/useCorrelations.ts | 51 ++++-- public/app/features/correlations/utils.ts | 38 ++-- .../TraceTimelineViewer/SpanLinks.tsx | 1 + .../TraceView/components/types/links.ts | 3 +- .../explore/TraceView/createSpanLink.test.ts | 16 +- .../explore/TraceView/createSpanLink.tsx | 1 + .../features/explore/utils/decorators.test.ts | 1 + .../app/features/explore/utils/decorators.ts | 6 +- .../app/features/explore/utils/links.test.ts | 16 +- public/app/features/explore/utils/links.ts | 98 +++++----- .../logs/components/logParser.test.ts | 1 + .../app/features/logs/components/logParser.ts | 14 +- public/locales/en-US/grafana.json | 21 ++- public/locales/pseudo-LOCALE/grafana.json | 21 ++- public/openapi3.json | 1 + 38 files changed, 574 insertions(+), 228 deletions(-) diff --git a/.betterer.results b/.betterer.results index 9aa8cdfa3ec..45abf50aa40 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2631,6 +2631,11 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], + "public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] + ], "public/app/features/correlations/components/EmptyCorrelationsCTA.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] @@ -2639,6 +2644,10 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"], [0, 0, 0, "Do not use export all (\`export * from ...\`)", "1"] ], + "public/app/features/correlations/types.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], "public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], diff --git a/packages/grafana-data/src/types/dataLink.ts b/packages/grafana-data/src/types/dataLink.ts index 47cba535029..476f2441ed7 100644 --- a/packages/grafana-data/src/types/dataLink.ts +++ b/packages/grafana-data/src/types/dataLink.ts @@ -51,6 +51,10 @@ export interface DataLink { internal?: InternalDataLink; origin?: DataLinkConfigOrigin; + meta?: { + correlationData?: ExploreCorrelationHelperData; + transformations?: DataLinkTransformationConfig[]; + }; } /** @@ -78,10 +82,6 @@ export interface InternalDataLink { datasourceUid: string; datasourceName: string; // used as a title if `DataLink.title` is empty panelsState?: ExplorePanelsState; - meta?: { - correlationData?: ExploreCorrelationHelperData; - }; - transformations?: DataLinkTransformationConfig[]; range?: TimeRange; } diff --git a/packages/grafana-data/src/utils/dataLinks.ts b/packages/grafana-data/src/utils/dataLinks.ts index 8c8084cee34..7f4a71f6aeb 100644 --- a/packages/grafana-data/src/utils/dataLinks.ts +++ b/packages/grafana-data/src/utils/dataLinks.ts @@ -40,11 +40,7 @@ export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkMod const interpolatedQuery = interpolateObject(link.internal?.query, scopedVars, replaceVariables); const interpolatedPanelsState = interpolateObject(link.internal?.panelsState, scopedVars, replaceVariables); - const interpolatedCorrelationData = interpolateObject( - link.internal?.meta?.correlationData, - scopedVars, - replaceVariables - ); + const interpolatedCorrelationData = interpolateObject(link.meta?.correlationData, scopedVars, replaceVariables); const title = link.title ? link.title : internalLink.datasourceName; return { diff --git a/pkg/services/correlations/database.go b/pkg/services/correlations/database.go index 47e54d5361f..0e8ff53999d 100644 --- a/pkg/services/correlations/database.go +++ b/pkg/services/correlations/database.go @@ -11,6 +11,8 @@ import ( "github.com/grafana/grafana/pkg/util" ) +const VALID_TYPE_FILTER = "(correlation.type = 'external' OR (correlation.type = 'query' AND dst.uid IS NOT NULL))" + // createCorrelation adds a correlation func (s CorrelationsService) createCorrelation(ctx context.Context, cmd CreateCorrelationCommand) (Correlation, error) { correlation := Correlation{ @@ -25,6 +27,12 @@ func (s CorrelationsService) createCorrelation(ctx context.Context, cmd CreateCo Type: cmd.Type, } + if correlation.Config.Type == CorrelationType("query") { + correlation.Type = CorrelationType("query") + } else if correlation.Config.Type != "" { + return correlation, ErrInvalidConfigType + } + err := s.SQLStore.WithTransactionalDbSession(ctx, func(session *db.Session) error { var err error @@ -184,7 +192,7 @@ func (s CorrelationsService) getCorrelation(ctx context.Context, cmd GetCorrelat } // Correlations created before the fix #72498 may have org_id = 0, but it's deprecated and will be removed in #72325 - found, err := session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ?", cmd.OrgId).Join("", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId).Where("correlation.uid = ? AND correlation.source_uid = ?", correlation.UID, correlation.SourceUID).Get(&correlation) + found, err := session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ?", cmd.OrgId).Join("LEFT OUTER", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId).Where("correlation.uid = ?", correlation.UID).And("correlation.source_uid = ?", correlation.SourceUID).And(VALID_TYPE_FILTER).Get(&correlation) if !found { return ErrCorrelationNotFound } @@ -235,7 +243,7 @@ func (s CorrelationsService) getCorrelationsBySourceUID(ctx context.Context, cmd return ErrSourceDataSourceDoesNotExists } // Correlations created before the fix #72498 may have org_id = 0, but it's deprecated and will be removed in #72325 - return session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ?", cmd.OrgId).Join("", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId).Where("correlation.source_uid = ?", cmd.SourceUID).Find(&correlations) + return session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ?", cmd.OrgId).Join("LEFT OUTER", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId).Where("correlation.source_uid = ?", cmd.SourceUID).And(VALID_TYPE_FILTER).Find(&correlations) }) if err != nil { @@ -256,12 +264,14 @@ func (s CorrelationsService) getCorrelations(ctx context.Context, cmd GetCorrela offset := cmd.Limit * (cmd.Page - 1) // Correlations created before the fix #72498 may have org_id = 0, but it's deprecated and will be removed in #72325 - q := session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ? ", cmd.OrgId).Join("", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId) + q := session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ? ", cmd.OrgId).Join("LEFT OUTER", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId) if len(cmd.SourceUIDs) > 0 { q.In("dss.uid", cmd.SourceUIDs) } + q.Where(VALID_TYPE_FILTER) + return q.Limit(int(cmd.Limit), int(offset)).Find(&result.Correlations) }) if err != nil { @@ -316,6 +326,7 @@ func (s CorrelationsService) createOrUpdateCorrelation(ctx context.Context, cmd Description: cmd.Description, Config: cmd.Config, Provisioned: false, + Type: cmd.Type, } found := false diff --git a/pkg/services/correlations/models.go b/pkg/services/correlations/models.go index 1b83b0b3042..4e941517a38 100644 --- a/pkg/services/correlations/models.go +++ b/pkg/services/correlations/models.go @@ -14,12 +14,13 @@ var ( ErrTargetDataSourceDoesNotExists = errors.New("target data source does not exist") ErrCorrelationNotFound = errors.New("correlation not found") ErrUpdateCorrelationEmptyParams = errors.New("not enough parameters to edit correlation") - ErrInvalidConfigType = errors.New("invalid correlation config type") + ErrInvalidType = errors.New("invalid correlation type") ErrInvalidTransformationType = errors.New("invalid transformation type") ErrTransformationNotNested = errors.New("transformations must be nested under config") ErrTransformationRegexReqExp = errors.New("regex transformations require expression") ErrCorrelationsQuotaFailed = errors.New("error getting correlations quota") ErrCorrelationsQuotaReached = errors.New("correlations quota reached") + ErrInvalidConfigType = errors.New("correlation contains non default value in config.type") ) const ( @@ -27,8 +28,15 @@ const ( QuotaTarget quota.Target = "correlations" ) +// the type of correlation, either query for containing query information, or external for containing an external URL +// +enum type CorrelationType string +const ( + query CorrelationType = "query" + external CorrelationType = "external" +) + type Transformation struct { //Enum: regex,logfmt Type string `json:"type"` @@ -37,13 +45,9 @@ type Transformation struct { MapValue string `json:"mapValue,omitempty"` } -const ( - TypeQuery CorrelationType = "query" -) - func (t CorrelationType) Validate() error { - if t != TypeQuery { - return fmt.Errorf("%s: \"%s\"", ErrInvalidConfigType, t) + if t != query && t != external { + return fmt.Errorf("%s: \"%s\"", ErrInvalidType, t) } return nil } @@ -161,7 +165,7 @@ type CreateCorrelationCommand struct { Config CorrelationConfig `json:"config" binding:"Required"` // True if correlation was created with provisioning. This makes it read-only. Provisioned bool `json:"provisioned"` - // correlation type, currently only valid value is "query" + // correlation type Type CorrelationType `json:"type" binding:"Required"` } @@ -169,8 +173,8 @@ func (c CreateCorrelationCommand) Validate() error { if err := c.Type.Validate(); err != nil { return err } - if c.TargetUID == nil && c.Type == TypeQuery { - return fmt.Errorf("correlations of type \"%s\" must have a targetUID", TypeQuery) + if c.TargetUID == nil && c.Type == query { + return fmt.Errorf("correlations of type \"%s\" must have a targetUID", query) } if err := c.Config.Transformations.Validate(); err != nil { diff --git a/pkg/services/correlations/models_test.go b/pkg/services/correlations/models_test.go index 4583339812c..16dbc8e279e 100644 --- a/pkg/services/correlations/models_test.go +++ b/pkg/services/correlations/models_test.go @@ -20,7 +20,7 @@ func TestCorrelationModels(t *testing.T) { OrgId: 1, TargetUID: &targetUid, Config: *config, - Type: TypeQuery, + Type: query, } require.NoError(t, cmd.Validate()) @@ -30,7 +30,7 @@ func TestCorrelationModels(t *testing.T) { config := &CorrelationConfig{ Field: "field", Target: map[string]any{}, - Type: TypeQuery, + Type: query, } cmd := &CreateCorrelationCommand{ SourceUID: "some-uid", diff --git a/pkg/services/provisioning/datasources/datasources.go b/pkg/services/provisioning/datasources/datasources.go index 1bbf5ff7820..55edd021d00 100644 --- a/pkg/services/provisioning/datasources/datasources.go +++ b/pkg/services/provisioning/datasources/datasources.go @@ -196,7 +196,7 @@ func makeCreateCorrelationCommand(correlation map[string]any, SourceUID string, // we ignore the legacy config.type value - the only valid value at that version was "query" var corrType = correlation["type"] if corrType == nil || corrType == "" { - corrType = correlations.TypeQuery + corrType = correlations.CorrelationType("query") } var json = jsoniter.ConfigCompatibleWithStandardLibrary @@ -229,6 +229,11 @@ func makeCreateCorrelationCommand(correlation map[string]any, SourceUID string, return correlations.CreateCorrelationCommand{}, err } + // config.type is a deprecated place for this value. We will default it to "query" for legacy purposes but non-query correlations should have type outside of config + if config.Type != correlations.CorrelationType("query") { + return correlations.CreateCorrelationCommand{}, correlations.ErrInvalidConfigType + } + createCommand.Config = config } if err := createCommand.Validate(); err != nil { diff --git a/pkg/services/stats/statsimpl/stats_test.go b/pkg/services/stats/statsimpl/stats_test.go index 019f1ab7d47..cce64977c32 100644 --- a/pkg/services/stats/statsimpl/stats_test.go +++ b/pkg/services/stats/statsimpl/stats_test.go @@ -104,7 +104,7 @@ func populateDB(t *testing.T, db db.DB, cfg *setting.Cfg) { Config: correlations.CorrelationConfig{ Field: "field", Target: map[string]any{}, - Type: correlations.TypeQuery, + Type: correlations.CorrelationType("query"), }, } correlation, err := correlationsSvc.CreateCorrelation(context.Background(), cmd) diff --git a/pkg/tests/api/correlations/common_test.go b/pkg/tests/api/correlations/common_test.go index 28525fe3854..efb707841de 100644 --- a/pkg/tests/api/correlations/common_test.go +++ b/pkg/tests/api/correlations/common_test.go @@ -193,6 +193,11 @@ func (c TestContext) createCorrelation(cmd correlations.CreateCorrelationCommand return correlation } +func (c TestContext) createCorrelationPassError(cmd correlations.CreateCorrelationCommand) (correlations.Correlation, error) { + c.t.Helper() + return c.env.Server.HTTPServer.CorrelationsService.CreateCorrelation(context.Background(), cmd) +} + func (c TestContext) createOrUpdateCorrelation(cmd correlations.CreateCorrelationCommand) { c.t.Helper() err := c.env.Server.HTTPServer.CorrelationsService.CreateOrUpdateCorrelation(context.Background(), cmd) diff --git a/pkg/tests/api/correlations/correlations_create_test.go b/pkg/tests/api/correlations/correlations_create_test.go index 1f619d75235..1186477ce27 100644 --- a/pkg/tests/api/correlations/correlations_create_test.go +++ b/pkg/tests/api/correlations/correlations_create_test.go @@ -230,7 +230,7 @@ func TestIntegrationCreateCorrelation(t *testing.T) { description := "a description" label := "a label" fieldName := "fieldName" - corrType := correlations.TypeQuery + corrType := correlations.CorrelationType("query") transformation := correlations.Transformation{Type: "logfmt"} transformation2 := correlations.Transformation{Type: "regex", Expression: "testExpression", MapValue: "testVar"} res := ctx.Post(PostParams{ diff --git a/pkg/tests/api/correlations/correlations_delete_test.go b/pkg/tests/api/correlations/correlations_delete_test.go index 16eaf29b554..76dd80edd46 100644 --- a/pkg/tests/api/correlations/correlations_delete_test.go +++ b/pkg/tests/api/correlations/correlations_delete_test.go @@ -135,6 +135,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) { TargetUID: &writableDs, OrgId: writableDsOrgId, Provisioned: true, + Type: correlations.CorrelationType("query"), }) res := ctx.Delete(DeleteParams{ @@ -160,6 +161,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) { SourceUID: writableDs, TargetUID: &writableDs, OrgId: writableDsOrgId, + Type: correlations.CorrelationType("query"), }) res := ctx.Delete(DeleteParams{ @@ -192,6 +194,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) { SourceUID: writableDs, TargetUID: &readOnlyDS, OrgId: writableDsOrgId, + Type: correlations.CorrelationType("query"), }) res := ctx.Delete(DeleteParams{ @@ -225,6 +228,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) { TargetUID: &readOnlyDS, OrgId: writableDsOrgId, Provisioned: false, + Type: correlations.CorrelationType("query"), }) ctx.createCorrelation(correlations.CreateCorrelationCommand{ @@ -232,6 +236,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) { TargetUID: &readOnlyDS, OrgId: writableDsOrgId, Provisioned: true, + Type: correlations.CorrelationType("query"), }) res := ctx.Delete(DeleteParams{ diff --git a/pkg/tests/api/correlations/correlations_provisioning_api_test.go b/pkg/tests/api/correlations/correlations_provisioning_api_test.go index 31e48becddf..0baa39d30f0 100644 --- a/pkg/tests/api/correlations/correlations_provisioning_api_test.go +++ b/pkg/tests/api/correlations/correlations_provisioning_api_test.go @@ -2,6 +2,7 @@ package correlations import ( "encoding/json" + "fmt" "io" "net/http" "testing" @@ -38,8 +39,8 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) { TargetUID: &dataSource.UID, OrgId: dataSource.OrgID, Label: "needs migration", + Type: correlations.CorrelationType("query"), Config: correlations.CorrelationConfig{ - Type: correlations.TypeQuery, Field: "foo", Target: map[string]any{}, Transformations: []correlations.Transformation{ @@ -54,8 +55,8 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) { TargetUID: &dataSource.UID, OrgId: dataSource.OrgID, Label: "existing", + Type: correlations.CorrelationType("query"), Config: correlations.CorrelationConfig{ - Type: correlations.TypeQuery, Field: "foo", Target: map[string]any{}, Transformations: []correlations.Transformation{ @@ -65,6 +66,23 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) { Provisioned: false, }) + // v1 correlation where type is in config + v1Correlation := ctx.createCorrelation(correlations.CreateCorrelationCommand{ + SourceUID: dataSource.UID, + TargetUID: &dataSource.UID, + OrgId: dataSource.OrgID, + Label: "v1 correlation", + Config: correlations.CorrelationConfig{ + Type: correlations.CorrelationType("query"), + Field: "foo", + Target: map[string]any{}, + Transformations: []correlations.Transformation{ + {Type: "logfmt"}, + }, + }, + Provisioned: true, + }) + t.Run("Correctly marks existing correlations as provisioned", func(t *testing.T) { // should be updated ctx.createOrUpdateCorrelation(correlations.CreateCorrelationCommand{ @@ -75,6 +93,7 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) { Description: needsMigration.Description, Config: needsMigration.Config, Provisioned: true, + Type: needsMigration.Type, }) // should be added @@ -86,6 +105,7 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) { Description: needsMigration.Description, Config: needsMigration.Config, Provisioned: true, + Type: needsMigration.Type, }) res := ctx.Get(GetParams{ @@ -101,7 +121,7 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) { err = json.Unmarshal(responseBody, &response) require.NoError(t, err) - require.Len(t, response.Correlations, 3) + require.Len(t, response.Correlations, 4) unordered := make(map[string]correlations.Correlation) for _, v := range response.Correlations { @@ -117,4 +137,44 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) { require.NoError(t, res.Body.Close()) }) + + t.Run("If Config.Type is query, provision without error but have value outside of config", func(t *testing.T) { + res := ctx.Get(GetParams{ + url: fmt.Sprintf("/api/datasources/uid/%s/correlations/%s", dataSource.UID, v1Correlation.UID), + user: adminUser, + }) + require.Equal(t, http.StatusOK, res.StatusCode) + responseBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + + var response correlations.Correlation + err = json.Unmarshal(responseBody, &response) + require.NoError(t, err) + + require.EqualValues(t, response.Config.Type, "") + require.EqualValues(t, v1Correlation.Config.Type, response.Type) + + require.NoError(t, res.Body.Close()) + }) + + t.Run("If Config.type is not query, throw an error", func(t *testing.T) { + _, err := ctx.createCorrelationPassError(correlations.CreateCorrelationCommand{ + SourceUID: dataSource.UID, + TargetUID: &dataSource.UID, + OrgId: dataSource.OrgID, + Label: "bad v1 correlation", + Config: correlations.CorrelationConfig{ + Type: correlations.CorrelationType("external"), + Field: "foo", + Target: map[string]any{}, + Transformations: []correlations.Transformation{ + {Type: "logfmt"}, + }, + }, + Provisioned: true, + }) + + require.Error(t, err) + require.ErrorIs(t, err, correlations.ErrInvalidConfigType) + }) } diff --git a/pkg/tests/api/correlations/correlations_read_test.go b/pkg/tests/api/correlations/correlations_read_test.go index 79cc73bcf48..2db12b826e3 100644 --- a/pkg/tests/api/correlations/correlations_read_test.go +++ b/pkg/tests/api/correlations/correlations_read_test.go @@ -77,7 +77,7 @@ func TestIntegrationReadCorrelation(t *testing.T) { SourceUID: dsWithCorrelations.UID, TargetUID: &dsWithCorrelations.UID, OrgId: dsWithCorrelations.OrgID, - Type: correlations.TypeQuery, + Type: correlations.CorrelationType("query"), Config: correlations.CorrelationConfig{ Field: "foo", Target: map[string]any{}, diff --git a/pkg/tests/api/correlations/correlations_update_test.go b/pkg/tests/api/correlations/correlations_update_test.go index 2a203542770..2c42d37585e 100644 --- a/pkg/tests/api/correlations/correlations_update_test.go +++ b/pkg/tests/api/correlations/correlations_update_test.go @@ -128,6 +128,7 @@ func TestIntegrationUpdateCorrelation(t *testing.T) { TargetUID: &writableDs, OrgId: writableDsOrgId, Provisioned: true, + Type: correlations.CorrelationType("query"), }) res := ctx.Patch(PatchParams{ @@ -155,6 +156,7 @@ func TestIntegrationUpdateCorrelation(t *testing.T) { SourceUID: writableDs, TargetUID: &writableDs, OrgId: writableDsOrgId, + Type: correlations.CorrelationType("query"), }) // no params @@ -220,6 +222,7 @@ func TestIntegrationUpdateCorrelation(t *testing.T) { TargetUID: &writableDs, OrgId: writableDsOrgId, Label: "a label", + Type: correlations.CorrelationType("query"), }) res := ctx.Patch(PatchParams{ @@ -249,9 +252,9 @@ func TestIntegrationUpdateCorrelation(t *testing.T) { OrgId: writableDsOrgId, Label: "0", Description: "0", + Type: correlations.CorrelationType("query"), Config: correlations.CorrelationConfig{ Field: "fieldName", - Type: "query", Target: map[string]any{"expr": "foo"}, }, }) diff --git a/public/api-merged.json b/public/api-merged.json index beecac17066..203197677a4 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -13793,6 +13793,7 @@ } }, "CorrelationType": { + "description": "the type of correlation, either query for containing query information, or external for containing an external URL\n+enum", "type": "string" }, "CounterResetHint": { diff --git a/public/app/features/correlations/CorrelationsPage.test.tsx b/public/app/features/correlations/CorrelationsPage.test.tsx index bd5788ef25f..6c046e76d06 100644 --- a/public/app/features/correlations/CorrelationsPage.test.tsx +++ b/public/app/features/correlations/CorrelationsPage.test.tsx @@ -23,7 +23,7 @@ import { createRemoveCorrelationResponse, createUpdateCorrelationResponse, } from './__mocks__/useCorrelations.mocks'; -import { Correlation, CreateCorrelationParams } from './types'; +import { Correlation, CreateCorrelationParams, OmitUnion } from './types'; const renderWithContext = async ( datasources: ConstructorParameters[0] = {}, @@ -43,7 +43,7 @@ const renderWithContext = async ( throw createFetchCorrelationsError(); }, - post: async (url: string, data: Omit) => { + post: async (url: string, data: OmitUnion) => { const matches = url.match(/^\/api\/datasources\/uid\/(?[a-zA-Z0-9]+)\/correlations$/); if (matches?.groups) { const { sourceUID } = matches.groups; @@ -54,7 +54,7 @@ const renderWithContext = async ( throw createFetchCorrelationsError(); }, - patch: async (url: string, data: Omit) => { + patch: async (url: string, data: OmitUnion) => { const matches = url.match( /^\/api\/datasources\/uid\/(?[a-zA-Z0-9]+)\/correlations\/(?[a-zA-Z0-9]+)$/ ); diff --git a/public/app/features/correlations/CorrelationsPage.tsx b/public/app/features/correlations/CorrelationsPage.tsx index ea85765eae5..d653f30a353 100644 --- a/public/app/features/correlations/CorrelationsPage.tsx +++ b/public/app/features/correlations/CorrelationsPage.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { negate } from 'lodash'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; import { isFetchError, reportInteraction } from '@grafana/runtime'; import { Badge, @@ -27,7 +27,7 @@ import { AccessControlAction } from 'app/types'; import { AddCorrelationForm } from './Forms/AddCorrelationForm'; import { EditCorrelationForm } from './Forms/EditCorrelationForm'; import { EmptyCorrelationsCTA } from './components/EmptyCorrelationsCTA'; -import type { RemoveCorrelationParams } from './types'; +import type { Correlation, RemoveCorrelationParams } from './types'; import { CorrelationData, useCorrelations } from './useCorrelations'; const sortDatasource: SortByFn = (a, b, column) => @@ -238,7 +238,7 @@ interface ExpandedRowProps { readOnly: boolean; onUpdated: () => void; } -function ExpendedRow({ correlation: { source, target, ...correlation }, readOnly, onUpdated }: ExpandedRowProps) { +function ExpendedRow({ correlation: { source, ...correlation }, readOnly, onUpdated }: ExpandedRowProps) { useEffect( () => reportInteraction('grafana_correlations_details_expanded'), // we only want to fire this on first render @@ -246,13 +246,12 @@ function ExpendedRow({ correlation: { source, target, ...correlation }, readOnly [] ); - return ( - - ); + let corr: Correlation = + correlation.type === 'query' + ? { ...correlation, type: 'query', sourceUID: source.uid, targetUID: correlation.target.uid } + : { ...correlation, type: 'external', sourceUID: source.uid }; + + return ; } const getDatasourceCellStyles = (theme: GrafanaTheme2) => ({ @@ -268,20 +267,22 @@ const getDatasourceCellStyles = (theme: GrafanaTheme2) => ({ }); const DataSourceCell = memo( - function DataSourceCell({ - cell: { value }, - }: CellProps) { + function DataSourceCell({ cell: { value } }: CellProps) { const styles = useStyles2(getDatasourceCellStyles); return ( - - {value.name} + {value?.name !== undefined && ( + <> + + {value.name} + + )} ); }, ({ cell: { value } }, { cell: { value: prevValue } }) => { - return value.type === prevValue.type && value.name === prevValue.name; + return value?.type === prevValue?.type && value?.name === prevValue?.name; } ); diff --git a/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx b/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx index 4c9b32067b1..b079b85d995 100644 --- a/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx +++ b/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx @@ -24,6 +24,35 @@ const getStyles = (theme: GrafanaTheme2) => ({ `, }); +const getFormText = (queryType: string, dataSourceName?: string) => { + if (queryType === 'query') { + return { + title: t( + 'correlations.source-form.query-title', + 'Configure the data source that will link to {{dataSourceName}} (Step 3 of 3)', + { dataSourceName } + ), + descriptionPre: t( + 'correlations.source-form.description-query-pre', + 'You have used following variables in the target query:' + ), + heading: t('correlations.source-form.heading-query', 'Variables used in the target query'), + }; + } else { + return { + title: t( + 'correlations.source-form.external-title', + 'Configure the data source that will use the URL (Step 3 of 3)' + ), + descriptionPre: t( + 'correlations.source-form.description-external-pre', + 'You have used following variables in the target URL:' + ), + heading: t('correlations.source-form.heading-external', 'Variables used in the target URL'), + }; + } +}; + export const ConfigureCorrelationSourceForm = () => { const { control, formState, register, getValues } = useFormContext(); const styles = useStyles2(getStyles); @@ -32,9 +61,13 @@ export const ConfigureCorrelationSourceForm = () => { const { correlation, readOnly } = useCorrelationsFormContext(); const currentTargetQuery = getValues('config.target'); + const currentType = getValues('type'); const variables = getVariableUsageInfo(currentTargetQuery, {}).variables.map( (variable) => variable.variableName + (variable.fieldPath ? `.${variable.fieldPath}` : '') ); + const dataSourceName = getDatasourceSrv().getInstanceSettings(getValues('targetUID'))?.name; + + const formText = getFormText(currentType, dataSourceName); function VariableList() { return ( @@ -49,16 +82,9 @@ export const ConfigureCorrelationSourceForm = () => { ); } - const dataSourceName = getDatasourceSrv().getInstanceSettings(getValues('targetUID'))?.name; return ( <> -
+

Define what data source will display the correlation, and what data will replace previously defined @@ -117,14 +143,14 @@ export const ConfigureCorrelationSourceForm = () => { {variables.length > 0 && ( - - Variables used in the target query - + {formText.heading} + {formText.descriptionPre} + +
- You have used following variables in the target query: -
A data point needs to provide values to all variables as fields or as transformations output to - make the correlation button appear in the visualization. + A data point needs to provide values to all variables as fields or as transformations output to make the + correlation button appear in the visualization.
Note: Not every variable needs to be explicitly defined below. A transformation such as{' '} logfmt will create variables for every key/value pair. diff --git a/public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx b/public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx index f0f36c4dcfd..84e22a5c36d 100644 --- a/public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx +++ b/public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx @@ -1,64 +1,173 @@ -import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { css } from '@emotion/css'; +import { Controller, FieldError, useFormContext, useWatch } from 'react-hook-form'; -import { DataSourceInstanceSettings } from '@grafana/data'; -import { Field, FieldSet } from '@grafana/ui'; +import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; +import { Field, FieldSet, Input, Select, useStyles2 } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; +import { CorrelationType, ExternalTypeTarget } from '../types'; + import { QueryEditorField } from './QueryEditorField'; import { useCorrelationsFormContext } from './correlationsFormContext'; -import { FormDTO } from './types'; +import { assertIsQueryTypeError, FormDTO } from './types'; + +type CorrelationTypeOptions = { + value: CorrelationType; + label: string; + description: string; +}; + +export const CORR_TYPES_SELECT: Record = { + query: { + value: 'query', + label: 'Query', + description: 'Open a query', + }, + external: { + value: 'external', + label: 'External', + description: 'Open an external URL', + }, +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + typeSelect: css` + max-width: ${theme.spacing(40)}; + `, +}); export const ConfigureCorrelationTargetForm = () => { - const { control, formState } = useFormContext(); + const { + control, + formState: { errors }, + } = useFormContext(); const withDsUID = (fn: Function) => (ds: DataSourceInstanceSettings) => fn(ds.uid); const { correlation } = useCorrelationsFormContext(); - const targetUID: string | undefined = useWatch({ name: 'targetUID' }) || correlation?.targetUID; + const targetUIDFromCorrelation = correlation && 'targetUID' in correlation ? correlation.targetUID : undefined; + const targetUID: string | undefined = useWatch({ name: 'targetUID' }) || targetUIDFromCorrelation; + const correlationType: CorrelationType | undefined = useWatch({ name: 'type' }) || correlation?.type; + const styles = useStyles2(getStyles); return ( <>

- Define what data source the correlation will link to, and what query will run when the correlation is - clicked. + Define what the correlation will link to. With the query type, a query will run when the correlation is + clicked. With the external type, clicking the correlation will open a URL.

( + render={({ field: { onChange, value, ...field } }) => ( - onChange(value.value)} + options={Object.values(CORR_TYPES_SELECT)} + aria-label="correlation type" /> )} /> - + {correlationType === 'query' && + (() => { + assertIsQueryTypeError(errors); + // the assert above will make sure the form dto, which can be either external or query, is for query + // however, the query type has config.target, an object, which doesn't get converted, so we must explicity type it below + return ( + <> + ( + + + + )} + /> + + + + ); + })()} + {correlationType === 'external' && ( + <> + { + const castVal = value as ExternalTypeTarget; // the target under "query" type can contain anything a datasource query contains + return ( + + { + onChange({ url: e.currentTarget.value }); + }} + /> + + ); + }} + /> + + )}
); diff --git a/public/app/features/correlations/Forms/QueryEditorField.tsx b/public/app/features/correlations/Forms/QueryEditorField.tsx index b2e9a55127c..34c99f892d8 100644 --- a/public/app/features/correlations/Forms/QueryEditorField.tsx +++ b/public/app/features/correlations/Forms/QueryEditorField.tsx @@ -52,12 +52,14 @@ export const QueryEditorField = ({ dsUid, invalid, error, name }: Props) => { name={name} rules={{ validate: { - hasQueryEditor: () => - QueryEditor !== undefined || - t( - 'correlations.query-editor.control-rules', - 'The selected target data source must export a query editor.' - ), + hasQueryEditor: (_, formVals) => { + return formVals.type === 'query' && QueryEditor === undefined + ? t( + 'correlations.query-editor.control-rules', + 'The selected target data source must export a query editor.' + ) + : true; + }, }, }} render={({ field: { value, onChange } }) => { diff --git a/public/app/features/correlations/Forms/types.ts b/public/app/features/correlations/Forms/types.ts index d95667fa38e..53cfc8b6e54 100644 --- a/public/app/features/correlations/Forms/types.ts +++ b/public/app/features/correlations/Forms/types.ts @@ -1,18 +1,36 @@ +import { DeepMap, FieldError, FieldErrors } from 'react-hook-form'; + import { SupportedTransformationType } from '@grafana/data'; import { t } from 'app/core/internationalization'; -import { CorrelationConfig, CorrelationType } from '../types'; +import { CorrelationConfigExternal, CorrelationConfigQuery, OmitUnion } from '../types'; -export interface FormDTO { +export interface FormExternalDTO { + sourceUID: string; + label: string; + description: string; + type: 'external'; + config: CorrelationConfigExternal; +} + +export interface FormQueryDTO { sourceUID: string; targetUID: string; label: string; description: string; - type: CorrelationType; - config: CorrelationConfig; + type: 'query'; + config: CorrelationConfigQuery; } -export type EditFormDTO = Omit; +export type FormDTO = FormExternalDTO | FormQueryDTO; + +export function assertIsQueryTypeError( + errors: FieldErrors +): asserts errors is DeepMap { + // explicitly assert the type so that TS can narrow down FormDTO to FormQueryDTO +} + +export type EditFormDTO = OmitUnion; export type TransformationDTO = { type: SupportedTransformationType; diff --git a/public/app/features/correlations/Forms/utils.ts b/public/app/features/correlations/Forms/utils.ts index be77c4f6b8b..0ca9b1ae549 100644 --- a/public/app/features/correlations/Forms/utils.ts +++ b/public/app/features/correlations/Forms/utils.ts @@ -1,6 +1,6 @@ import { Correlation } from '../types'; -type CorrelationBaseData = Pick; +type CorrelationBaseData = Pick; export const getInputId = (inputName: string, correlation?: CorrelationBaseData) => { if (!correlation) { diff --git a/public/app/features/correlations/types.ts b/public/app/features/correlations/types.ts index 4365930ce9b..ce1ff2c2213 100644 --- a/public/app/features/correlations/types.ts +++ b/public/app/features/correlations/types.ts @@ -26,30 +26,50 @@ export interface RemoveCorrelationResponse { message: string; } -export type CorrelationType = 'query'; +export type CorrelationType = 'query' | 'external'; -export interface CorrelationConfig { +export type ExternalTypeTarget = { url: string }; + +export type CorrelationConfigQuery = { field: string; - target: object; // this contains anything that would go in the query editor, so any extension off DataQuery a datasource would have, and needs to be generic + target: object; // for queries, this contains anything that would go in the query editor, so any extension off DataQuery a datasource would have, and needs to be generic. transformations?: DataLinkTransformationConfig[]; -} +}; -export interface Correlation { +export type CorrelationConfigExternal = { + field: string; + target: ExternalTypeTarget; // For external, this simply contains a URL + transformations?: DataLinkTransformationConfig[]; +}; + +type CorrelationBase = { uid: string; sourceUID: string; - targetUID: string; label?: string; description?: string; provisioned: boolean; orgId?: number; - config: CorrelationConfig; - type: CorrelationType; -} +}; + +export type CorrelationExternal = CorrelationBase & { + type: 'external'; + config: CorrelationConfigExternal; +}; + +export type CorrelationQuery = CorrelationBase & { + type: 'query'; + config: CorrelationConfigQuery; + targetUID: string; +}; + +export type Correlation = CorrelationExternal | CorrelationQuery; export type GetCorrelationsParams = { page: number; }; +export type OmitUnion = T extends any ? Omit : never; + export type RemoveCorrelationParams = Pick; -export type CreateCorrelationParams = Omit; -export type UpdateCorrelationParams = Omit; +export type CreateCorrelationParams = OmitUnion; +export type UpdateCorrelationParams = OmitUnion; diff --git a/public/app/features/correlations/useCorrelations.ts b/public/app/features/correlations/useCorrelations.ts index 7b1a9c83c8a..e187b7c7d97 100644 --- a/public/app/features/correlations/useCorrelations.ts +++ b/public/app/features/correlations/useCorrelations.ts @@ -7,6 +7,8 @@ import { useGrafana } from 'app/core/context/GrafanaContext'; import { Correlation, + CorrelationExternal, + CorrelationQuery, CreateCorrelationParams, CreateCorrelationResponse, GetCorrelationsParams, @@ -24,10 +26,14 @@ export interface CorrelationsResponse { totalCount: number; } -export interface CorrelationData extends Omit { - source: DataSourceInstanceSettings; - target: DataSourceInstanceSettings; -} +export type CorrelationData = + | (Omit & { + source: DataSourceInstanceSettings; + }) + | (Omit & { + source: DataSourceInstanceSettings; + target: DataSourceInstanceSettings; + }); export interface CorrelationsData { correlations: CorrelationData[]; @@ -36,13 +42,10 @@ export interface CorrelationsData { totalCount: number; } -const toEnrichedCorrelationData = ({ - sourceUID, - targetUID, - ...correlation -}: Correlation): CorrelationData | undefined => { +const toEnrichedCorrelationData = ({ sourceUID, ...correlation }: Correlation): CorrelationData | undefined => { const sourceDatasource = getDataSourceSrv().getInstanceSettings(sourceUID); - const targetDatasource = getDataSourceSrv().getInstanceSettings(targetUID); + const targetDatasource = + correlation.type === 'query' ? getDataSourceSrv().getInstanceSettings(correlation.targetUID) : undefined; // According to #72258 we will remove logic to handle orgId=0/null as global correlations. // This logging is to check if there are any customers who did not migrate existing correlations. @@ -54,21 +57,33 @@ const toEnrichedCorrelationData = ({ if ( sourceDatasource && sourceDatasource?.uid !== undefined && - targetDatasource && - targetDatasource.uid !== undefined + targetDatasource?.uid !== undefined && + correlation.type === 'query' ) { return { ...correlation, source: sourceDatasource, target: targetDatasource, }; - } else { - correlationsLogger.logWarning(`Invalid correlation config: Missing source or target.`, { - source: JSON.stringify(sourceDatasource), - target: JSON.stringify(targetDatasource), - }); - return undefined; } + + if ( + sourceDatasource && + sourceDatasource?.uid !== undefined && + targetDatasource?.uid === undefined && + correlation.type === 'external' + ) { + return { + ...correlation, + source: sourceDatasource, + }; + } + + correlationsLogger.logWarning(`Invalid correlation config: Missing source or target.`, { + source: JSON.stringify(sourceDatasource), + target: JSON.stringify(targetDatasource), + }); + return undefined; }; const validSourceFilter = (correlation: CorrelationData | undefined): correlation is CorrelationData => !!correlation; diff --git a/public/app/features/correlations/utils.ts b/public/app/features/correlations/utils.ts index 64d4cf386b9..d9719c77e64 100644 --- a/public/app/features/correlations/utils.ts +++ b/public/app/features/correlations/utils.ts @@ -53,19 +53,31 @@ const decorateDataFrameWithInternalDataLinks = (dataFrame: DataFrame, correlatio dataFrame.fields.forEach((field) => { field.config.links = field.config.links?.filter((link) => link.origin !== DataLinkConfigOrigin.Correlations) || []; correlations.map((correlation) => { - if (correlation.config?.field === field.name) { - const targetQuery = correlation.config?.target || {}; - field.config.links!.push({ - internal: { - query: { ...targetQuery, datasource: { uid: correlation.target.uid } }, - datasourceUid: correlation.target.uid, - datasourceName: correlation.target.name, - transformations: correlation.config?.transformations, - }, - url: '', - title: correlation.label || correlation.target.name, - origin: DataLinkConfigOrigin.Correlations, - }); + if (correlation.config.field === field.name) { + if (correlation.type === 'query') { + const targetQuery = correlation.config.target || {}; + field.config.links!.push({ + internal: { + query: { ...targetQuery, datasource: { uid: correlation.target.uid } }, + datasourceUid: correlation.target.uid, + datasourceName: correlation.target.name, + }, + url: '', + title: correlation.label || correlation.target.name, + origin: DataLinkConfigOrigin.Correlations, + meta: { + transformations: correlation.config.transformations, + }, + }); + } else if (correlation.type === 'external') { + const externalTarget = correlation.config.target; + field.config.links!.push({ + url: externalTarget.url, + title: correlation.label || 'External URL', + origin: DataLinkConfigOrigin.Correlations, + meta: { transformations: correlation.config?.transformations }, + }); + } } }); }); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx index 67fb2eab3e1..724e0f3b895 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx @@ -42,6 +42,7 @@ const renderMenuItems = ( : undefined } url={link.href} + target={link.target} className={styles.menuItem} /> )); diff --git a/public/app/features/explore/TraceView/components/types/links.ts b/public/app/features/explore/TraceView/components/types/links.ts index e01a081c43f..00f8f74b6ba 100644 --- a/public/app/features/explore/TraceView/components/types/links.ts +++ b/public/app/features/explore/TraceView/components/types/links.ts @@ -1,6 +1,6 @@ import * as React from 'react'; -import { Field } from '@grafana/data'; +import { Field, LinkTarget } from '@grafana/data'; import { TraceSpan } from './trace'; @@ -20,6 +20,7 @@ export type SpanLinkDef = { title?: string; field: Field; type: SpanLinkType; + target?: LinkTarget; }; export type SpanLinkFunc = (span: TraceSpan) => SpanLinkDef[] | undefined; diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts index f73ed1d5f68..18cf81ecb6d 100644 --- a/public/app/features/explore/TraceView/createSpanLink.test.ts +++ b/public/app/features/explore/TraceView/createSpanLink.test.ts @@ -1654,6 +1654,11 @@ function createMultiLinkDataFrame() { }, datasourceUid: 'loki1_uid', datasourceName: 'loki1', + }, + url: '', + title: 'Test', + origin: DataLinkConfigOrigin.Correlations, + meta: { transformations: [ { type: SupportedTransformationType.Regex, @@ -1662,9 +1667,6 @@ function createMultiLinkDataFrame() { }, ], }, - url: '', - title: 'Test', - origin: DataLinkConfigOrigin.Correlations, }, { internal: { @@ -1673,6 +1675,11 @@ function createMultiLinkDataFrame() { }, datasourceUid: 'loki1_uid', datasourceName: 'loki1', + }, + url: '', + title: 'Test', + origin: DataLinkConfigOrigin.Correlations, + meta: { transformations: [ { type: SupportedTransformationType.Regex, @@ -1681,9 +1688,6 @@ function createMultiLinkDataFrame() { }, ], }, - url: '', - title: 'Test2', - origin: DataLinkConfigOrigin.Correlations, }, ], }, diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx index 3b4a5b19fb2..15cb385e178 100644 --- a/public/app/features/explore/TraceView/createSpanLink.tsx +++ b/public/app/features/explore/TraceView/createSpanLink.tsx @@ -110,6 +110,7 @@ export function createSpanLinkFactory({ content: , field: link.origin, type: shouldCreatePyroscopeLink ? SpanLinkType.Profiles : SpanLinkType.Unknown, + target: link.target, }; }); diff --git a/public/app/features/explore/utils/decorators.test.ts b/public/app/features/explore/utils/decorators.test.ts index 67adfacbb73..b61a87f89ec 100644 --- a/public/app/features/explore/utils/decorators.test.ts +++ b/public/app/features/explore/utils/decorators.test.ts @@ -482,6 +482,7 @@ describe('decorateWithCorrelations', () => { source: datasourceInstance, target: datasourceInstance, provisioned: true, + type: 'query', config: { field: panelData.series[0].fields[0].name }, }, ] as CorrelationData[]; diff --git a/public/app/features/explore/utils/decorators.ts b/public/app/features/explore/utils/decorators.ts index fe8db4a38d5..b09f0b61b61 100644 --- a/public/app/features/explore/utils/decorators.ts +++ b/public/app/features/explore/utils/decorators.ts @@ -127,9 +127,9 @@ export const decorateWithCorrelations = ({ datasourceUid: defaultTargetDatasource.uid, datasourceName: defaultTargetDatasource.name, query: { datasource: { uid: defaultTargetDatasource.uid } }, - meta: { - correlationData: { resultField: field.name, vars: availableVars, origVars: availableVars }, - }, + }, + meta: { + correlationData: { resultField: field.name, vars: availableVars, origVars: availableVars }, }, }); } diff --git a/public/app/features/explore/utils/links.test.ts b/public/app/features/explore/utils/links.test.ts index 60a9c8fd5fe..9c7f12afc10 100644 --- a/public/app/features/explore/utils/links.test.ts +++ b/public/app/features/explore/utils/links.test.ts @@ -281,6 +281,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{app=${application} env=${environment}}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', + }, + meta: { transformations: [ { type: SupportedTransformationType.Logfmt }, { type: SupportedTransformationType.Regex, expression: 'host=(dev|prod)', mapValue: 'environment' }, @@ -330,6 +332,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{env=${msg}}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', + }, + meta: { transformations: [ { type: SupportedTransformationType.Regex, expression: 'fieldA=(asparagus|broccoli)' }, { type: SupportedTransformationType.Regex, expression: 'fieldB=(apple|banana)' }, @@ -372,6 +376,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{env=${msg}}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', + }, + meta: { transformations: [ { type: SupportedTransformationType.Regex, @@ -427,8 +433,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{app=${application} isOnline=${online}}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', - transformations: [{ type: SupportedTransformationType.Logfmt }], }, + meta: { transformations: [{ type: SupportedTransformationType.Logfmt }] }, }; const { field, range, dataFrame } = setup(transformationLink, true, { @@ -466,8 +472,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{app=${application}}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', - transformations: [{ type: SupportedTransformationType.Logfmt, field: 'fieldNamedInTransformation' }], }, + meta: { transformations: [{ type: SupportedTransformationType.Logfmt, field: 'fieldNamedInTransformation' }] }, }; // fieldWithLink has the transformation, but the transformation has defined fieldNamedInTransformation as its field to transform @@ -518,6 +524,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{app=${application} env=${environment}}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', + }, + meta: { transformations: [ { type: SupportedTransformationType.Regex, @@ -598,6 +606,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{app=${application} env=${diffVar}}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', + }, + meta: { transformations: [{ type: SupportedTransformationType.Logfmt }], }, }; @@ -623,6 +633,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{app=test}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', + }, + meta: { transformations: [{ type: SupportedTransformationType.Logfmt }], }, }; diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index bc530234b24..e36ba898134 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -49,7 +49,7 @@ const DATA_LINK_FILTERS: DataLinkFilter[] = [dataLinkHasRequiredPermissionsFilte * for internal links and undefined for non-internal links */ export interface ExploreFieldLinkModel extends LinkModel { - variables?: VariableInterpolation[]; + variables: VariableInterpolation[]; } const DATA_LINK_USAGE_KEY = 'grafana_data_link_clicked'; @@ -65,7 +65,7 @@ export const exploreDataLinkPostProcessorFactory = ( const { field, dataLinkScopedVars: vars, frame: dataFrame, link, linkModel } = options; const { valueRowIndex: rowIndex } = options.config; - if (!link.internal || rowIndex === undefined) { + if (rowIndex === undefined) { return linkModel; } @@ -159,57 +159,57 @@ export const getFieldLinksForExplore = (options: { }); const fieldLinks = links.map((link) => { - if (!link.internal) { - const replace: InterpolateFunction = (value, vars) => - getTemplateSrv().replace(value, { ...vars, ...scopedVars }); + let internalLinkSpecificVars: ScopedVars = {}; + if (link.meta?.transformations) { + link.meta?.transformations.forEach((transformation) => { + let fieldValue; + if (transformation.field) { + const transformField = dataFrame?.fields.find((field) => field.name === transformation.field); + fieldValue = transformField?.values[rowIndex]; + } else { + fieldValue = field.values[rowIndex]; + } - const linkModel = getLinkSrv().getDataLinkUIModel(link, replace, field); - if (!linkModel.title) { - linkModel.title = getTitleFromHref(linkModel.href); - } - return linkModel; + internalLinkSpecificVars = { + ...internalLinkSpecificVars, + ...getTransformationVars(transformation, fieldValue, field.name), + }; + }); + } + + const allVars = { ...scopedVars, ...internalLinkSpecificVars }; + const variableData = getVariableUsageInfo(link, allVars); + let variables: VariableInterpolation[] = []; + + // if the link has no variables (static link), add it with the right key but an empty value so we know what field the static link is associated with + if (variableData.variables.length === 0) { + const fieldName = field.name.toString(); + variables.push({ variableName: fieldName, value: '', match: '' }); } else { - let internalLinkSpecificVars: ScopedVars = {}; - if (link.internal?.transformations) { - link.internal?.transformations.forEach((transformation) => { - let fieldValue; - if (transformation.field) { - const transformField = dataFrame?.fields.find((field) => field.name === transformation.field); - fieldValue = transformField?.values[rowIndex]; - } else { - fieldValue = field.values[rowIndex]; - } + variables = variableData.variables; + } + if (variableData.allVariablesDefined) { + if (!link.internal) { + const replace: InterpolateFunction = (value, vars) => + getTemplateSrv().replace(value, { ...vars, ...allVars, ...scopedVars }); - internalLinkSpecificVars = { - ...internalLinkSpecificVars, - ...getTransformationVars(transformation, fieldValue, field.name), - }; - }); - } - - const allVars = { ...scopedVars, ...internalLinkSpecificVars }; - const variableData = getVariableUsageInfo(link, allVars); - let variables: VariableInterpolation[] = []; - - // if the link has no variables (static link), add it with the right key but an empty value so we know what field the static link is associated with - if (variableData.variables.length === 0) { - const fieldName = field.name.toString(); - variables.push({ variableName: fieldName, value: '', match: '' }); + const linkModel = getLinkSrv().getDataLinkUIModel(link, replace, field); + if (!linkModel.title) { + linkModel.title = getTitleFromHref(linkModel.href); + } + linkModel.target = '_blank'; + return { ...linkModel, variables: variables }; } else { - variables = variableData.variables; - } + const splitFnWithTracking = (options?: SplitOpenOptions) => { + reportInteraction(DATA_LINK_USAGE_KEY, { + origin: link.origin || DataLinkConfigOrigin.Datasource, + app: CoreApp.Explore, + internal: true, + }); - const splitFnWithTracking = (options?: SplitOpenOptions) => { - reportInteraction(DATA_LINK_USAGE_KEY, { - origin: link.origin || DataLinkConfigOrigin.Datasource, - app: CoreApp.Explore, - internal: true, - }); + splitOpenFn?.(options); + }; - splitOpenFn?.(options); - }; - - if (variableData.allVariablesDefined) { const internalLink = mapInternalLinkToExplore({ link, internalLink: link.internal, @@ -221,9 +221,9 @@ export const getFieldLinksForExplore = (options: { replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), }); return { ...internalLink, variables: variables }; - } else { - return undefined; } + } else { + return undefined; } }); return fieldLinks.filter((link): link is ExploreFieldLinkModel => !!link); diff --git a/public/app/features/logs/components/logParser.test.ts b/public/app/features/logs/components/logParser.test.ts index 68487d12690..5ddba19b5f0 100644 --- a/public/app/features/logs/components/logParser.test.ts +++ b/public/app/features/logs/components/logParser.test.ts @@ -448,6 +448,7 @@ describe('logParser', () => { }, title: 'test', target: '_self', + variables: [], }; const fieldWithVarLink: FieldDef = { diff --git a/public/app/features/logs/components/logParser.ts b/public/app/features/logs/components/logParser.ts index 6f412f070f1..b566c42b89d 100644 --- a/public/app/features/logs/components/logParser.ts +++ b/public/app/features/logs/components/logParser.ts @@ -35,8 +35,16 @@ export const getAllFields = ( export const createLogLineLinks = (hiddenFieldsWithLinks: FieldDef[]): FieldDef[] => { let fieldsWithLinksFromVariableMap: FieldDef[] = []; hiddenFieldsWithLinks.forEach((linkField) => { - linkField.links?.forEach((link: ExploreFieldLinkModel) => { - if (link.variables) { + linkField.links?.forEach((link: LinkModel | ExploreFieldLinkModel) => { + if ('variables' in link && link.variables.length > 0) { + // convert ExploreFieldLinkModel to LinkModel by omitting variables field + const fieldDefFromLink: LinkModel = { + href: link.href, + title: link.title, + origin: link.origin, + onClick: link.onClick, + target: link.target, + }; const variableKeys = link.variables.map((variable) => { const varName = variable.variableName; const fieldPath = variable.fieldPath ? `.${variable.fieldPath}` : ''; @@ -46,7 +54,7 @@ export const createLogLineLinks = (hiddenFieldsWithLinks: FieldDef[]): FieldDef[ fieldsWithLinksFromVariableMap.push({ keys: variableKeys, values: variableValues, - links: [link], + links: [fieldDefFromLink], fieldIndex: linkField.fieldIndex, }); } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 71961f341b5..b20956af416 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -462,23 +462,30 @@ }, "source-form": { "control-required": "This field is required.", - "description": "You have used following variables in the target query: <1><2>A data point needs to provide values to all variables as fields or as transformations output to make the correlation button appear in the visualization.<4>Note: Not every variable needs to be explicitly defined below. A transformation such as <7>logfmt will create variables for every key/value pair.", - "heading": "Variables used in the target query", + "description": "A data point needs to provide values to all variables as fields or as transformations output to make the correlation button appear in the visualization.<1>Note: Not every variable needs to be explicitly defined below. A transformation such as <4>logfmt will create variables for every key/value pair.", + "description-external-pre": "You have used following variables in the target URL:", + "description-query-pre": "You have used following variables in the target query:", + "external-title": "Configure the data source that will use the URL (Step 3 of 3)", + "heading-external": "Variables used in the target URL", + "heading-query": "Variables used in the target query", + "query-title": "Configure the data source that will link to {{dataSourceName}} (Step 3 of 3)", "results-description": "The link will be shown next to the value of this field", "results-label": "Results field", "results-required": "This field is required.", "source-description": "Results from selected source data source have links displayed in the panel", "source-label": "Source", - "sub-text": "<0>Define what data source will display the correlation, and what data will replace previously defined variables.", - "title": "Configure the data source that will link to {{dataSourceName}} (Step 3 of 3)" + "sub-text": "<0>Define what data source will display the correlation, and what data will replace previously defined variables." }, "sub-title": "Define how data living in different data sources relates to each other. Read more in the <2>documentation<1>", "target-form": { "control-rules": "This field is required.", - "sub-text": "<0>Define what data source the correlation will link to, and what query will run when the correlation is clicked.", - "target-description": "Specify which data source is queried when the link is clicked", + "sub-text": "<0>Define what the correlation will link to. With the query type, a query will run when the correlation is clicked. With the external type, clicking the correlation will open a URL.", + "target-description-external": "Specify the URL that will open when the link is clicked", + "target-description-query": "Specify which data source is queried when the link is clicked", "target-label": "Target", - "title": "Setup the target for the correlation (Step 2 of 3)" + "target-type-description": "Specify the type of correlation", + "title": "Setup the target for the correlation (Step 2 of 3)", + "type-label": "Type" }, "trans-details": { "logfmt-description": "Parse provided field with logfmt to get variables", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 8f2e09e0cb2..8ac07e0c62d 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -462,23 +462,30 @@ }, "source-form": { "control-required": "Ŧĥįş ƒįęľđ įş řęqūįřęđ.", - "description": "Ÿőū ĥävę ūşęđ ƒőľľőŵįʼnģ väřįäþľęş įʼn ŧĥę ŧäřģęŧ qūęřy: <1><2>Å đäŧä pőįʼnŧ ʼnęęđş ŧő přővįđę väľūęş ŧő äľľ väřįäþľęş äş ƒįęľđş őř äş ŧřäʼnşƒőřmäŧįőʼnş őūŧpūŧ ŧő mäĸę ŧĥę čőřřęľäŧįőʼn þūŧŧőʼn äppęäř įʼn ŧĥę vįşūäľįžäŧįőʼn.<4>Ńőŧę: Ńőŧ ęvęřy väřįäþľę ʼnęęđş ŧő þę ęχpľįčįŧľy đęƒįʼnęđ þęľőŵ. Å ŧřäʼnşƒőřmäŧįőʼn şūčĥ äş <7>ľőģƒmŧ ŵįľľ čřęäŧę väřįäþľęş ƒőř ęvęřy ĸęy/väľūę päįř.", - "heading": "Väřįäþľęş ūşęđ įʼn ŧĥę ŧäřģęŧ qūęřy", + "description": "Å đäŧä pőįʼnŧ ʼnęęđş ŧő přővįđę väľūęş ŧő äľľ väřįäþľęş äş ƒįęľđş őř äş ŧřäʼnşƒőřmäŧįőʼnş őūŧpūŧ ŧő mäĸę ŧĥę čőřřęľäŧįőʼn þūŧŧőʼn äppęäř įʼn ŧĥę vįşūäľįžäŧįőʼn.<1>Ńőŧę: Ńőŧ ęvęřy väřįäþľę ʼnęęđş ŧő þę ęχpľįčįŧľy đęƒįʼnęđ þęľőŵ. Å ŧřäʼnşƒőřmäŧįőʼn şūčĥ äş <4>ľőģƒmŧ ŵįľľ čřęäŧę väřįäþľęş ƒőř ęvęřy ĸęy/väľūę päįř.", + "description-external-pre": "Ÿőū ĥävę ūşęđ ƒőľľőŵįʼnģ väřįäþľęş įʼn ŧĥę ŧäřģęŧ ŮŖĿ:", + "description-query-pre": "Ÿőū ĥävę ūşęđ ƒőľľőŵįʼnģ väřįäþľęş įʼn ŧĥę ŧäřģęŧ qūęřy:", + "external-title": "Cőʼnƒįģūřę ŧĥę đäŧä şőūřčę ŧĥäŧ ŵįľľ ūşę ŧĥę ŮŖĿ (Ŝŧęp 3 őƒ 3)", + "heading-external": "Väřįäþľęş ūşęđ įʼn ŧĥę ŧäřģęŧ ŮŖĿ", + "heading-query": "Väřįäþľęş ūşęđ įʼn ŧĥę ŧäřģęŧ qūęřy", + "query-title": "Cőʼnƒįģūřę ŧĥę đäŧä şőūřčę ŧĥäŧ ŵįľľ ľįʼnĸ ŧő {{dataSourceName}} (Ŝŧęp 3 őƒ 3)", "results-description": "Ŧĥę ľįʼnĸ ŵįľľ þę şĥőŵʼn ʼnęχŧ ŧő ŧĥę väľūę őƒ ŧĥįş ƒįęľđ", "results-label": "Ŗęşūľŧş ƒįęľđ", "results-required": "Ŧĥįş ƒįęľđ įş řęqūįřęđ.", "source-description": "Ŗęşūľŧş ƒřőm şęľęčŧęđ şőūřčę đäŧä şőūřčę ĥävę ľįʼnĸş đįşpľäyęđ įʼn ŧĥę päʼnęľ", "source-label": "Ŝőūřčę", - "sub-text": "<0>Đęƒįʼnę ŵĥäŧ đäŧä şőūřčę ŵįľľ đįşpľäy ŧĥę čőřřęľäŧįőʼn, äʼnđ ŵĥäŧ đäŧä ŵįľľ řępľäčę přęvįőūşľy đęƒįʼnęđ väřįäþľęş.", - "title": "Cőʼnƒįģūřę ŧĥę đäŧä şőūřčę ŧĥäŧ ŵįľľ ľįʼnĸ ŧő {{dataSourceName}} (Ŝŧęp 3 őƒ 3)" + "sub-text": "<0>Đęƒįʼnę ŵĥäŧ đäŧä şőūřčę ŵįľľ đįşpľäy ŧĥę čőřřęľäŧįőʼn, äʼnđ ŵĥäŧ đäŧä ŵįľľ řępľäčę přęvįőūşľy đęƒįʼnęđ väřįäþľęş." }, "sub-title": "Đęƒįʼnę ĥőŵ đäŧä ľįvįʼnģ įʼn đįƒƒęřęʼnŧ đäŧä şőūřčęş řęľäŧęş ŧő ęäčĥ őŧĥęř. Ŗęäđ mőřę įʼn ŧĥę <2>đőčūmęʼnŧäŧįőʼn<1>", "target-form": { "control-rules": "Ŧĥįş ƒįęľđ įş řęqūįřęđ.", - "sub-text": "<0>Đęƒįʼnę ŵĥäŧ đäŧä şőūřčę ŧĥę čőřřęľäŧįőʼn ŵįľľ ľįʼnĸ ŧő, äʼnđ ŵĥäŧ qūęřy ŵįľľ řūʼn ŵĥęʼn ŧĥę čőřřęľäŧįőʼn įş čľįčĸęđ.", - "target-description": "Ŝpęčįƒy ŵĥįčĥ đäŧä şőūřčę įş qūęřįęđ ŵĥęʼn ŧĥę ľįʼnĸ įş čľįčĸęđ", + "sub-text": "<0>Đęƒįʼnę ŵĥäŧ ŧĥę čőřřęľäŧįőʼn ŵįľľ ľįʼnĸ ŧő. Ŵįŧĥ ŧĥę qūęřy ŧypę, ä qūęřy ŵįľľ řūʼn ŵĥęʼn ŧĥę čőřřęľäŧįőʼn įş čľįčĸęđ. Ŵįŧĥ ŧĥę ęχŧęřʼnäľ ŧypę, čľįčĸįʼnģ ŧĥę čőřřęľäŧįőʼn ŵįľľ őpęʼn ä ŮŖĿ.", + "target-description-external": "Ŝpęčįƒy ŧĥę ŮŖĿ ŧĥäŧ ŵįľľ őpęʼn ŵĥęʼn ŧĥę ľįʼnĸ įş čľįčĸęđ", + "target-description-query": "Ŝpęčįƒy ŵĥįčĥ đäŧä şőūřčę įş qūęřįęđ ŵĥęʼn ŧĥę ľįʼnĸ įş čľįčĸęđ", "target-label": "Ŧäřģęŧ", - "title": "Ŝęŧūp ŧĥę ŧäřģęŧ ƒőř ŧĥę čőřřęľäŧįőʼn (Ŝŧęp 2 őƒ 3)" + "target-type-description": "Ŝpęčįƒy ŧĥę ŧypę őƒ čőřřęľäŧįőʼn", + "title": "Ŝęŧūp ŧĥę ŧäřģęŧ ƒőř ŧĥę čőřřęľäŧįőʼn (Ŝŧęp 2 őƒ 3)", + "type-label": "Ŧypę" }, "trans-details": { "logfmt-description": "Päřşę přővįđęđ ƒįęľđ ŵįŧĥ ľőģƒmŧ ŧő ģęŧ väřįäþľęş", diff --git a/public/openapi3.json b/public/openapi3.json index 0b51415aaed..4a6e0fa3ad4 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -4026,6 +4026,7 @@ "type": "object" }, "CorrelationType": { + "description": "the type of correlation, either query for containing query information, or external for containing an external URL\n+enum", "type": "string" }, "CounterResetHint": { From db697a3e2339cc3e24ee302bc98254a2e7337864 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:43:03 +0100 Subject: [PATCH 002/102] Update dependency @rollup/plugin-node-resolve to v15.3.0 (#93677) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/grafana-data/package.json | 2 +- packages/grafana-e2e-selectors/package.json | 2 +- packages/grafana-flamegraph/package.json | 2 +- packages/grafana-prometheus/package.json | 2 +- packages/grafana-runtime/package.json | 2 +- packages/grafana-schema/package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 22 ++++++++++----------- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index c71f738a903..89123455f5d 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -62,7 +62,7 @@ }, "devDependencies": { "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.2.4", + "@rollup/plugin-node-resolve": "15.3.0", "@types/dompurify": "^3.0.0", "@types/history": "4.7.11", "@types/lodash": "4.17.9", diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index ccb6c46f1e5..104b15e7a1b 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -39,7 +39,7 @@ "postpack": "mv package.json.bak package.json" }, "devDependencies": { - "@rollup/plugin-node-resolve": "15.2.4", + "@rollup/plugin-node-resolve": "15.3.0", "@types/node": "20.16.6", "esbuild": "0.24.0", "rimraf": "6.0.1", diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index 71e97d340a0..6b3bb6ff097 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -60,7 +60,7 @@ "@babel/preset-env": "7.25.4", "@babel/preset-react": "7.24.7", "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.2.4", + "@rollup/plugin-node-resolve": "15.3.0", "@testing-library/dom": "10.0.0", "@testing-library/jest-dom": "^6.1.2", "@testing-library/react": "15.0.2", diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 418e5140555..341efa96c90 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -79,7 +79,7 @@ "@grafana/e2e-selectors": "11.3.0-pre", "@grafana/tsconfig": "^2.0.0", "@rollup/plugin-image": "3.0.3", - "@rollup/plugin-node-resolve": "15.2.4", + "@rollup/plugin-node-resolve": "15.3.0", "@swc/core": "1.4.2", "@swc/helpers": "0.5.13", "@testing-library/dom": "10.0.0", diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 631c9128195..f773eb75175 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -49,7 +49,7 @@ }, "devDependencies": { "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.2.4", + "@rollup/plugin-node-resolve": "15.3.0", "@rollup/plugin-terser": "0.4.4", "@testing-library/dom": "10.0.0", "@testing-library/react": "15.0.2", diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index 1e5338c51b5..39c8d34ab20 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -37,7 +37,7 @@ }, "devDependencies": { "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.2.4", + "@rollup/plugin-node-resolve": "15.3.0", "esbuild": "0.24.0", "glob": "^11.0.0", "rimraf": "6.0.1", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 0abc18b87d8..6dabdb778b5 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -117,7 +117,7 @@ "@babel/core": "7.25.2", "@faker-js/faker": "^8.4.1", "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.2.4", + "@rollup/plugin-node-resolve": "15.3.0", "@storybook/addon-a11y": "^8.1.6", "@storybook/addon-actions": "^8.1.6", "@storybook/addon-docs": "^8.1.6", diff --git a/yarn.lock b/yarn.lock index 7a56c866639..73c49e56649 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3583,7 +3583,7 @@ __metadata: "@braintree/sanitize-url": "npm:7.0.1" "@grafana/schema": "npm:11.3.0-pre" "@grafana/tsconfig": "npm:^2.0.0" - "@rollup/plugin-node-resolve": "npm:15.2.4" + "@rollup/plugin-node-resolve": "npm:15.3.0" "@types/d3-interpolate": "npm:^3.0.0" "@types/dompurify": "npm:^3.0.0" "@types/history": "npm:4.7.11" @@ -3634,7 +3634,7 @@ __metadata: resolution: "@grafana/e2e-selectors@workspace:packages/grafana-e2e-selectors" dependencies: "@grafana/tsconfig": "npm:^2.0.0" - "@rollup/plugin-node-resolve": "npm:15.2.4" + "@rollup/plugin-node-resolve": "npm:15.3.0" "@types/node": "npm:20.16.6" esbuild: "npm:0.24.0" rimraf: "npm:6.0.1" @@ -3796,7 +3796,7 @@ __metadata: "@grafana/tsconfig": "npm:^2.0.0" "@grafana/ui": "npm:11.3.0-pre" "@leeoniya/ufuzzy": "npm:1.0.14" - "@rollup/plugin-node-resolve": "npm:15.2.4" + "@rollup/plugin-node-resolve": "npm:15.3.0" "@testing-library/dom": "npm:10.0.0" "@testing-library/jest-dom": "npm:^6.1.2" "@testing-library/react": "npm:15.0.2" @@ -3963,7 +3963,7 @@ __metadata: "@prometheus-io/lezer-promql": "npm:0.54.1" "@reduxjs/toolkit": "npm:2.2.7" "@rollup/plugin-image": "npm:3.0.3" - "@rollup/plugin-node-resolve": "npm:15.2.4" + "@rollup/plugin-node-resolve": "npm:15.3.0" "@swc/core": "npm:1.4.2" "@swc/helpers": "npm:0.5.13" "@testing-library/dom": "npm:10.0.0" @@ -4061,7 +4061,7 @@ __metadata: "@grafana/schema": "npm:11.3.0-pre" "@grafana/tsconfig": "npm:^2.0.0" "@grafana/ui": "npm:11.3.0-pre" - "@rollup/plugin-node-resolve": "npm:15.2.4" + "@rollup/plugin-node-resolve": "npm:15.3.0" "@rollup/plugin-terser": "npm:0.4.4" "@testing-library/dom": "npm:10.0.0" "@testing-library/react": "npm:15.0.2" @@ -4156,7 +4156,7 @@ __metadata: resolution: "@grafana/schema@workspace:packages/grafana-schema" dependencies: "@grafana/tsconfig": "npm:^2.0.0" - "@rollup/plugin-node-resolve": "npm:15.2.4" + "@rollup/plugin-node-resolve": "npm:15.3.0" esbuild: "npm:0.24.0" glob: "npm:^11.0.0" rimraf: "npm:6.0.1" @@ -4251,7 +4251,7 @@ __metadata: "@react-aria/focus": "npm:3.18.2" "@react-aria/overlays": "npm:3.23.2" "@react-aria/utils": "npm:3.25.2" - "@rollup/plugin-node-resolve": "npm:15.2.4" + "@rollup/plugin-node-resolve": "npm:15.3.0" "@storybook/addon-a11y": "npm:^8.1.6" "@storybook/addon-actions": "npm:^8.1.6" "@storybook/addon-docs": "npm:^8.1.6" @@ -6944,9 +6944,9 @@ __metadata: languageName: node linkType: hard -"@rollup/plugin-node-resolve@npm:15.2.4, @rollup/plugin-node-resolve@npm:^15.2.3": - version: 15.2.4 - resolution: "@rollup/plugin-node-resolve@npm:15.2.4" +"@rollup/plugin-node-resolve@npm:15.3.0, @rollup/plugin-node-resolve@npm:^15.2.3": + version: 15.3.0 + resolution: "@rollup/plugin-node-resolve@npm:15.3.0" dependencies: "@rollup/pluginutils": "npm:^5.0.1" "@types/resolve": "npm:1.20.2" @@ -6958,7 +6958,7 @@ __metadata: peerDependenciesMeta: rollup: optional: true - checksum: 10/ad1940019344df457be88414393214fa69eaabba59cb7763bd81a9baacf0bb80efedf1bb999c40593860974b905f8d96ea8600b3b04f402f04da9c7ddc714178 + checksum: 10/214596dd0ecf0822a135e6cb604f6a4469bac4a9d6b43608d277b47c34762e800b79f5f1c18ea0f7317448165ac0cff2439b35446641e093a5bc5c372940c819 languageName: node linkType: hard From ae5a7a53a9d3f03febfea97ecbf149488d8a96b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 24 Sep 2024 17:00:15 +0200 Subject: [PATCH 003/102] TimePicker: Support strings in ISO formats (#93479) * TimePicker: Support strings in ISO formats * changed fix and added test --- .../TimeRangePicker/TimeRangeContent.test.tsx | 17 +++++++++++++++++ .../TimeRangePicker/TimeRangeContent.tsx | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx index d6a7aa1c35e..99f9df99a20 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx @@ -76,6 +76,23 @@ describe('TimeRangeForm', () => { expect(getByLabelText('To')).toHaveValue(toValue); }); + it('should parse UTC iso strings and render in current timezone', () => { + const { getByLabelText } = setup( + { + from: defaultTimeRange.from, + to: defaultTimeRange.to, + raw: { + from: defaultTimeRange.from.toISOString(), + to: defaultTimeRange.to.toISOString(), + }, + }, + 'America/New_York' + ); + + expect(getByLabelText('From')).toHaveValue('2021-06-16 20:00:00'); + expect(getByLabelText('To')).toHaveValue('2021-06-19 19:59:00'); + }); + it('should close calendar when clicking the close icon', () => { const { queryByLabelText, getAllByRole, getByRole } = setup(); const { TimePicker } = selectors.components; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx index 87f5572c586..a23da6f7a62 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx @@ -267,6 +267,12 @@ function valueAsString(value: DateTime | string, timeZone?: TimeZone): string { if (isDateTime(value)) { return dateTimeFormat(value, { timeZone }); } + + if (value.endsWith('Z')) { + const dt = dateTimeParse(value, { timeZone: 'utc' }); + return dateTimeFormat(dt, { timeZone }); + } + return value; } From 9adb7b03a79d54d780cd1f57cfa049ae812c6c33 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Tue, 24 Sep 2024 16:10:29 +0100 Subject: [PATCH 004/102] Remove buildstamp from build hash. Version and commit should be enough (#93658) Remove buildstamp from build hash, version and commit should be enough --- pkg/api/frontendsettings.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index a7a3f914a8c..6566b02acfc 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -41,7 +41,6 @@ func (hs *HTTPServer) GetFrontendAssets(c *contextmodel.ReqContext) { hash.Reset() _, _ = hash.Write([]byte(setting.BuildVersion)) _, _ = hash.Write([]byte(setting.BuildCommit)) - _, _ = hash.Write([]byte(fmt.Sprintf("%d", setting.BuildStamp))) keys["version"] = fmt.Sprintf("%x", hash.Sum(nil)) // Plugin configs From 038d9cabde98de9a21b6fd93fccb3ff35fd96670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 24 Sep 2024 17:13:32 +0200 Subject: [PATCH 005/102] PanelEdit: Edit the source panel, refactor out VizPanelManager, simplify (#93045) * Options pane, data pane queries tab and transformations tab working * Update * Discard works * Panel inspect working * Table viw works * Repeat options * Began fixing tests * More tests fixed * Progress on tests * no errors * init full width when enabling repeat * Began moving VizPanelManager tests to where the code was moved * Unlink libray panel code and unit test * Fixes and unit tests for change tracking and resetting original state and dirty flag when saving * migrating and improving unit tests * Done with VizPanelManager tests refactoring * Update * Update * remove console.log * Removed unnesssary behavior and fixed test * Update * Fix unrelated test * conditional options fix * remove * Fixing issue with editing repeated panels and scoping variable to first value * Minor fix * Fix discard query runner changes * Review comment changes * fix discard issue with new panels * Add loading state to panel edit * Fix test * Update * fix test * fix lint * lint * Fix * Fix overrides editing mutating fieldConfig --------- Co-authored-by: alexandra vargas --- .betterer.results | 7 +- .../inspect/HelpWizard/utils.ts | 14 - .../inspect/InspectJsonTab.tsx | 6 - .../inspect/PanelInspectDrawer.tsx | 4 +- .../PanelDataPane/DataProviderSharer.tsx | 48 + .../PanelDataAlertingTab.test.tsx | 3 +- .../PanelDataPane/PanelDataAlertingTab.tsx | 52 +- .../PanelDataPane/PanelDataPane.tsx | 116 +-- .../PanelDataQueriesTab.test.tsx | 556 +++++++++-- .../PanelDataPane/PanelDataQueriesTab.tsx | 270 ++++-- .../PanelDataTransformationsTab.test.tsx | 17 +- .../PanelDataTransformationsTab.tsx | 51 +- .../panel-edit/PanelDataPane/types.ts | 7 +- .../panel-edit/PanelEditControls.tsx | 9 +- .../panel-edit/PanelEditor.test.ts | 408 +++++---- .../panel-edit/PanelEditor.tsx | 289 ++++-- .../panel-edit/PanelEditorRenderer.tsx | 36 +- .../panel-edit/PanelOptions.test.tsx | 38 +- .../panel-edit/PanelOptions.tsx | 13 +- .../panel-edit/PanelOptionsPane.test.tsx | 101 ++ .../panel-edit/PanelOptionsPane.tsx | 79 +- .../panel-edit/PanelVizTypePicker.tsx | 39 +- .../panel-edit/VizPanelManager.test.tsx | 864 ------------------ .../panel-edit/VizPanelManager.tsx | 504 ---------- .../panel-edit/getPanelFrameOptions.tsx | 163 ++-- .../saving/DashboardPrompt.tsx | 17 +- .../saving/DashboardSceneChangeTracker.ts | 12 - .../getDashboardChangesFromScene.test.ts | 3 +- .../DashboardDatasourceBehaviour.test.tsx | 11 +- .../scene/DashboardGridItem.tsx | 63 +- .../scene/DashboardScene.test.tsx | 24 +- .../dashboard-scene/scene/DashboardScene.tsx | 4 +- .../scene/DashboardSceneUrlSync.ts | 49 +- .../scene/LibraryPanelBehavior.tsx | 10 + .../scene/NavToolbarActions.tsx | 45 +- .../scene/row-actions/RowOptionsForm.test.tsx | 4 +- .../scene/row-actions/RowOptionsForm.tsx | 6 +- .../scene/row-actions/RowOptionsModal.tsx | 2 +- .../transformSceneToSaveModel.test.ts | 107 +-- .../transformSceneToSaveModel.ts | 48 +- .../PanelEditor/getFieldOverrideElements.tsx | 29 +- .../RepeatRowSelect/RepeatRowSelect.tsx | 6 +- .../app/features/library-panels/state/api.ts | 5 - .../trails/DataTrailsHistory.test.tsx | 4 +- .../dashboard/DashboardQueryEditor.tsx | 4 +- 45 files changed, 1715 insertions(+), 2432 deletions(-) create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelDataPane/DataProviderSharer.tsx create mode 100644 public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx delete mode 100644 public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx delete mode 100644 public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx diff --git a/.betterer.results b/.betterer.results index 45abf50aa40..3ec225ceb44 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2665,8 +2665,7 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "10"] ], "public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/dashboard-scene/inspect/InspectDataTab.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] @@ -2793,9 +2792,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], diff --git a/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts b/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts index d3cab702aba..0808233eaad 100644 --- a/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts +++ b/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts @@ -15,7 +15,6 @@ import { VizPanel } from '@grafana/scenes'; import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { DashboardGridItem } from '../../scene/DashboardGridItem'; -import { DashboardScene } from '../../scene/DashboardScene'; import { gridItemToPanel, vizPanelToPanel } from '../../serialization/transformSceneToSaveModel'; import { getQueryRunnerFor, isLibraryPanel } from '../../utils/utils'; @@ -64,25 +63,12 @@ export function getGithubMarkdown(panel: VizPanel, snapshot: string): string { export async function getDebugDashboard(panel: VizPanel, rand: Randomize, timeRange: TimeRange) { let saveModel: ReturnType = { type: '' }; const gridItem = panel.parent as DashboardGridItem; - const scene = panel.getRoot() as DashboardScene; if (isLibraryPanel(panel)) { saveModel = { ...gridItemToPanel(gridItem), ...vizPanelToPanel(panel), }; - } else if (scene.state.editPanel) { - // If panel edit mode is open when the user chooses the "get help" panel menu option - // we want the debug dashboard to include the panel with any changes that were made while - // in panel edit mode. - const sourcePanel = scene.state.editPanel.state.vizManager.state.sourcePanel.resolve(); - const dashGridItem = sourcePanel.parent; - if (dashGridItem instanceof DashboardGridItem) { - saveModel = { - ...gridItemToPanel(dashGridItem), - ...vizPanelToPanel(scene.state.editPanel.state.vizManager.state.panel.clone()), - }; - } } else { saveModel = gridItemToPanel(gridItem); } diff --git a/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx b/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx index c1424bfeeb3..e0de482f519 100644 --- a/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx +++ b/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx @@ -25,7 +25,6 @@ import { InspectTab } from 'app/features/inspector/types'; import { getPrettyJSON } from 'app/features/inspector/utils/utils'; import { reportPanelInspectInteraction } from 'app/features/search/page/reporting'; -import { VizPanelManager } from '../panel-edit/VizPanelManager'; import { DashboardGridItem } from '../scene/DashboardGridItem'; import { buildGridItemForPanel } from '../serialization/transformSaveModelToScene'; import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; @@ -219,11 +218,6 @@ function getJsonText(show: ShowContent, panel: VizPanel): string { break; } - if (panel.parent instanceof VizPanelManager) { - objToStringify = panel.parent.getPanelSaveModel(); - break; - } - if (gridItem instanceof DashboardGridItem) { objToStringify = gridItemToPanel(gridItem); } diff --git a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx index 919c8e32cc3..59b38232d74 100644 --- a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx +++ b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx @@ -28,7 +28,7 @@ import { SceneInspectTab } from './types'; interface PanelInspectDrawerState extends SceneObjectState { tabs?: SceneInspectTab[]; - panelRef?: SceneObjectRef; + panelRef: SceneObjectRef; pluginNotLoaded?: boolean; canEdit?: boolean; } @@ -51,7 +51,7 @@ export class PanelInspectDrawer extends SceneObjectBase */ async buildTabs(retry: number) { const panelRef = this.state.panelRef; - const plugin = panelRef?.resolve()?.getPlugin(); + const plugin = panelRef.resolve()?.getPlugin(); const tabs: SceneInspectTab[] = []; if (!plugin) { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DataProviderSharer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DataProviderSharer.tsx new file mode 100644 index 00000000000..20bf2c7d267 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DataProviderSharer.tsx @@ -0,0 +1,48 @@ +import { Observable } from 'rxjs'; + +import { + SceneDataProvider, + SceneDataProviderResult, + SceneDataState, + SceneObjectBase, + SceneObjectRef, +} from '@grafana/scenes'; + +export interface DataProviderSharerState extends SceneDataState { + source: SceneObjectRef; +} + +export class DataProviderSharer extends SceneObjectBase implements SceneDataProvider { + public constructor(state: DataProviderSharerState) { + super({ + source: state.source, + data: state.source.resolve().state.data, + }); + + this.addActivationHandler(() => { + this._subs.add( + this.state.source.resolve().subscribeToState((newState, oldState) => { + if (newState.data !== oldState.data) { + this.setState({ data: newState.data }); + } + }) + ); + }); + } + + public setContainerWidth(width: number) { + this.state.source.resolve().setContainerWidth?.(width); + } + + public isDataReadyToDisplay() { + return this.state.source.resolve().isDataReadyToDisplay?.() ?? true; + } + + public cancelQuery() { + this.state.source.resolve().cancelQuery?.(); + } + + public getResultsStream(): Observable { + return this.state.source.resolve().getResultsStream(); + } +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx index c315a73bcc9..c1dab28f8ed 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx @@ -34,7 +34,6 @@ import { AlertQuery, PromRulesResponse } from 'app/types/unified-alerting-dto'; import { createDashboardSceneFromDashboardModel } from '../../serialization/transformSaveModelToScene'; import * as utils from '../../utils/utils'; import { findVizPanelByKey, getVizPanelKeyForPanelId } from '../../utils/utils'; -import { VizPanelManager } from '../VizPanelManager'; import { PanelDataAlertingTab, PanelDataAlertingTabRendered } from './PanelDataAlertingTab'; @@ -361,7 +360,7 @@ async function clickNewButton() { function createModel(dashboard: DashboardModel) { const scene = createDashboardSceneFromDashboardModel(dashboard, {} as DashboardDataDTO); const vizPanel = findVizPanelByKey(scene, getVizPanelKeyForPanelId(34))!; - const model = new PanelDataAlertingTab(VizPanelManager.createFor(vizPanel)); + const model = new PanelDataAlertingTab({ panelRef: vizPanel.getRef() }); jest.spyOn(utils, 'getDashboardSceneFor').mockReturnValue(scene); return model; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx index 3ebc9bb51a7..a4fbbc4471d 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx @@ -1,8 +1,7 @@ import { css } from '@emotion/css'; -import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; +import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState, VizPanel } from '@grafana/scenes'; import { Alert, LoadingPlaceholder, Tab, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; import { RulesTable } from 'app/features/alerting/unified/components/rules/RulesTable'; @@ -11,58 +10,46 @@ import { getRulesPermissions } from 'app/features/alerting/unified/utils/access- import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; import { getDashboardSceneFor, getPanelIdForVizPanel } from '../../utils/utils'; -import { VizPanelManager } from '../VizPanelManager'; import { ScenesNewRuleFromPanelButton } from './NewAlertRuleButton'; -import { PanelDataPaneTab, PanelDataPaneTabState, PanelDataTabHeaderProps, TabId } from './types'; +import { PanelDataPaneTab, PanelDataTabHeaderProps, TabId } from './types'; -export class PanelDataAlertingTab extends SceneObjectBase implements PanelDataPaneTab { +export interface PanelDataAlertingTabState extends SceneObjectState { + panelRef: SceneObjectRef; +} + +export class PanelDataAlertingTab extends SceneObjectBase implements PanelDataPaneTab { static Component = PanelDataAlertingTabRendered; - TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; + public tabId = TabId.Alert; - tabId = TabId.Alert; - private _panelManager: VizPanelManager; - - constructor(panelManager: VizPanelManager) { - super({}); - this.TabComponent = (props: PanelDataTabHeaderProps) => AlertingTab({ ...props, model: this }); - this._panelManager = panelManager; + public renderTab(props: PanelDataTabHeaderProps) { + return ; } - getTabLabel() { + public getTabLabel() { return 'Alert'; } - getDashboardUID() { + public getDashboardUID() { const dashboard = this.getDashboard(); return dashboard.state.uid!; } - getDashboard() { - return getDashboardSceneFor(this._panelManager); + public getDashboard() { + return getDashboardSceneFor(this); } - getLegacyPanelId() { - return getPanelIdForVizPanel(this._panelManager.state.panel); + public getLegacyPanelId() { + return getPanelIdForVizPanel(this.state.panelRef.resolve()); } - getCanCreateRules() { + public getCanCreateRules() { const rulesPermissions = getRulesPermissions('grafana'); return this.getDashboard().state.meta.canSave && contextSrv.hasPermission(rulesPermissions.create); } - - get panelManager() { - return this._panelManager; - } - - get panel() { - return this._panelManager.state.panel; - } } -export function PanelDataAlertingTabRendered(props: SceneComponentProps) { - const { model } = props; - +export function PanelDataAlertingTabRendered({ model }: SceneComponentProps) { const styles = useStyles2(getStyles); const { errors, loading, rules } = usePanelCombinedRules({ @@ -87,7 +74,7 @@ export function PanelDataAlertingTabRendered(props: SceneComponentProps; } export class PanelDataPane extends SceneObjectBase { static Component = PanelDataPaneRendered; protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['tab'] }); - private panelSubscription: Unsubscribable | undefined; - public panelManager: VizPanelManager; - getUrlState() { - return { - tab: this.state.tab, - }; + public static createFor(panel: VizPanel) { + const panelRef = panel.getRef(); + const tabs: PanelDataPaneTab[] = [ + new PanelDataQueriesTab({ panelRef }), + new PanelDataTransformationsTab({ panelRef }), + ]; + + if (shouldShowAlertingTab(panel.state.pluginId)) { + tabs.push(new PanelDataAlertingTab({ panelRef })); + } + + return new PanelDataPane({ + panelRef, + tabs, + tab: TabId.Queries, + }); } - updateFromUrl(values: SceneObjectUrlValues) { + public onChangeTab = (tab: PanelDataPaneTab) => { + this.setState({ tab: tab.tabId }); + }; + + public getUrlState() { + return { tab: this.state.tab }; + } + + public updateFromUrl(values: SceneObjectUrlValues) { if (!values.tab) { return; } @@ -48,68 +66,6 @@ export class PanelDataPane extends SceneObjectBase { this.setState({ tab: values.tab as TabId }); } } - - constructor(panelMgr: VizPanelManager) { - super({ - tab: TabId.Queries, - tabs: [], - }); - - this.panelManager = panelMgr; - this.addActivationHandler(() => this.onActivate()); - } - - private onActivate() { - this.buildTabs(); - - this._subs.add( - // Setup subscription for the case when panel type changed - this.panelManager.subscribeToState((n, p) => { - if (n.pluginId !== p.pluginId) { - this.buildTabs(); - } - }) - ); - - return () => { - if (this.panelSubscription) { - this.panelSubscription.unsubscribe(); - this.panelSubscription = undefined; - } - }; - } - - private buildTabs() { - const panelManager = this.panelManager; - const panel = panelManager.state.panel; - const pluginId = panelManager.state.pluginId; - - const runner = this.panelManager.queryRunner; - const tabs: PanelDataPaneTab[] = []; - - if (panel) { - if (config.panels[pluginId]?.skipDataQuery) { - this.setState({ tabs }); - return; - } else { - if (runner) { - tabs.push(new PanelDataQueriesTab(this.panelManager)); - } - - tabs.push(new PanelDataTransformationsTab(this.panelManager)); - - if (shouldShowAlertingTab(panelManager.state.pluginId)) { - tabs.push(new PanelDataAlertingTab(this.panelManager)); - } - } - } - - this.setState({ tabs }); - } - - onChangeTab = (tab: PanelDataPaneTab) => { - this.setState({ tab: tab.tabId }); - }; } function PanelDataPaneRendered({ model }: SceneComponentProps) { @@ -125,15 +81,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { return (
- {tabs.map((t, index) => { - return ( - model.onChangeTab(t)} - > - ); - })} + {tabs.map((t) => t.renderTab({ active: t.tabId === tab, onChangeTab: () => model.onChangeTab(t) }))} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx index 9264e85d079..5a406406282 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx @@ -6,6 +6,7 @@ import { DataQuery, DataQueryRequest, DataSourceApi, + DataSourceInstanceSettings, DataSourceJsonData, DataSourceRef, FieldType, @@ -14,29 +15,28 @@ import { TimeRange, toDataFrame, } from '@grafana/data'; +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { selectors } from '@grafana/e2e-selectors'; -import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { config, locationService, setPluginExtensionsHook } from '@grafana/runtime'; +import { InspectTab } from 'app/features/inspector/types'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; import { DashboardDataDTO } from 'app/types'; +import { PanelTimeRange, PanelTimeRangeState } from '../../scene/PanelTimeRange'; import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene'; -import { DashboardModelCompatibilityWrapper } from '../../utils/DashboardModelCompatibilityWrapper'; import { findVizPanelByKey } from '../../utils/utils'; -import { VizPanelManager } from '../VizPanelManager'; -import { testDashboard } from '../testfiles/testDashboard'; +import { buildPanelEditScene } from '../PanelEditor'; +import { testDashboard, panelWithTransformations, panelWithQueriesOnly } from '../testfiles/testDashboard'; import { PanelDataQueriesTab, PanelDataQueriesTabRendered } from './PanelDataQueriesTab'; async function createModelMock() { - const panelManager = setupVizPanelManger('panel-1'); - panelManager.activate(); - await Promise.resolve(); - const queryTabModel = new PanelDataQueriesTab(panelManager); + const { queriesTab } = await setupScene('panel-1'); // mock queryRunner data state - jest.spyOn(queryTabModel.queryRunner, 'state', 'get').mockReturnValue({ - ...queryTabModel.queryRunner.state, + jest.spyOn(queriesTab.queryRunner, 'state', 'get').mockReturnValue({ + ...queriesTab.queryRunner.state, data: { state: LoadingState.Done, series: [ @@ -52,8 +52,14 @@ async function createModelMock() { }, }); - return queryTabModel; + return queriesTab; } + +setPluginExtensionsHook(() => ({ + extensions: [], + isLoading: false, +})); + const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { const result: PanelData = { state: LoadingState.Loading, @@ -186,11 +192,17 @@ const MixedDsSettingsMock = { }, }; +const panelPlugin = getPanelPlugin({ id: 'timeseries', skipDataQuery: false }); + jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { return runRequestMock(ds, request); }, + getPluginImportUtils: () => ({ + getPanelPluginFromCache: jest.fn(() => panelPlugin), + }), + getPluginLinkExtensions: jest.fn(), getDataSourceSrv: () => ({ get: async (ref: DataSourceRef) => { // Mocking the build in Grafana data source to avoid annotations data layer errors. @@ -234,112 +246,454 @@ jest.mock('@grafana/runtime', () => ({ return instance1SettingsMock; }, }), - locationService: { - partial: jest.fn(), - getSearchObject: jest.fn().mockReturnValue({ - firstPanel: false, - }), - }, config: { ...jest.requireActual('@grafana/runtime').config, defaultDatasource: 'gdev-testdata', }, })); -describe('PanelDataQueriesModel', () => { - it('can add a new query', async () => { - const vizPanelManager = setupVizPanelManger('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - const model = new PanelDataQueriesTab(vizPanelManager); - model.addQueryClick(); - expect(model.queryRunner.state.queries).toHaveLength(2); - expect(model.queryRunner.state.queries[1].refId).toBe('B'); - expect(model.queryRunner.state.queries[1].hide).toBe(false); - expect(model.queryRunner.state.queries[1].datasource).toEqual({ - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', +jest.mock('app/core/store', () => ({ + exists: jest.fn(), + get: jest.fn(), + getObject: jest.fn((_a, b) => b), + setObject: jest.fn(), +})); + +const store = jest.requireMock('app/core/store'); +let deactivators = [] as Array<() => void>; + +describe('PanelDataQueriesTab', () => { + beforeEach(() => { + store.setObject.mockClear(); + }); + + afterEach(() => { + deactivators.forEach((deactivate) => deactivate()); + deactivators = []; + }); + + describe('Adding queries', () => { + it('can add a new query', async () => { + const { queriesTab } = await setupScene('panel-1'); + + queriesTab.addQueryClick(); + + expect(queriesTab.queryRunner.state.queries).toHaveLength(2); + expect(queriesTab.queryRunner.state.queries[1].refId).toBe('B'); + expect(queriesTab.queryRunner.state.queries[1].hide).toBe(false); + expect(queriesTab.queryRunner.state.queries[1].datasource).toEqual({ + type: 'grafana-testdata-datasource', + uid: 'gdev-testdata', + }); + }); + + it('Can add a new query when datasource is mixed', async () => { + const { queriesTab } = await setupScene('panel-7'); + + expect(queriesTab.state.datasource?.uid).toBe('-- Mixed --'); + expect(queriesTab.queryRunner.state.datasource?.uid).toBe('-- Mixed --'); + + queriesTab.addQueryClick(); + + expect(queriesTab.queryRunner.state.queries).toHaveLength(2); + expect(queriesTab.queryRunner.state.queries[1].refId).toBe('B'); + expect(queriesTab.queryRunner.state.queries[1].hide).toBe(false); + expect(queriesTab.queryRunner.state.queries[1].datasource?.uid).toBe('gdev-testdata'); }); }); - it('can add a new query when datasource is mixed', async () => { - const vizPanelManager = setupVizPanelManger('panel-7'); - vizPanelManager.activate(); - await Promise.resolve(); + describe('PanelDataQueriesTab', () => { + it('renders query group top section', async () => { + const modelMock = await createModelMock(); - const model = new PanelDataQueriesTab(vizPanelManager); - expect(vizPanelManager.state.datasource?.uid).toBe('-- Mixed --'); - expect(model.queryRunner.state.datasource?.uid).toBe('-- Mixed --'); - model.addQueryClick(); + render(); + await screen.findByTestId(selectors.components.QueryTab.queryGroupTopSection); + }); - expect(model.queryRunner.state.queries).toHaveLength(2); - expect(model.queryRunner.state.queries[1].refId).toBe('B'); - expect(model.queryRunner.state.queries[1].hide).toBe(false); - expect(model.queryRunner.state.queries[1].datasource?.uid).toBe('gdev-testdata'); + it('renders queries rows when queries are set', async () => { + const modelMock = await createModelMock(); + render(); + + await screen.findByTestId('query-editor-rows'); + expect(screen.getAllByTestId('query-editor-row')).toHaveLength(1); + }); + + it('allow to add a new query when user clicks on add new', async () => { + const modelMock = await createModelMock(); + jest.spyOn(modelMock, 'addQueryClick'); + jest.spyOn(modelMock, 'onQueriesChange'); + render(); + + await screen.findByTestId(selectors.components.QueryTab.addQuery); + await userEvent.click(screen.getByTestId(selectors.components.QueryTab.addQuery)); + + const expectedQueries = [ + { + datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, + refId: 'A', + scenarioId: 'random_walk', + seriesCount: 1, + }, + { datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, hide: false, refId: 'B' }, + ]; + + expect(modelMock.addQueryClick).toHaveBeenCalled(); + expect(modelMock.onQueriesChange).toHaveBeenCalledWith(expectedQueries); + }); + + it('allow to remove a query when user clicks on remove', async () => { + const modelMock = await createModelMock(); + jest.spyOn(modelMock, 'addQueryClick'); + jest.spyOn(modelMock, 'onQueriesChange'); + render(); + + await screen.findByTestId('data-testid Remove query'); + await userEvent.click(screen.getByTestId('data-testid Remove query')); + + expect(modelMock.onQueriesChange).toHaveBeenCalledWith([]); + }); + }); + + describe('query options', () => { + describe('activation', () => { + it('should load data source', async () => { + const { queriesTab } = await setupScene('panel-1'); + + expect(queriesTab.state.datasource).toEqual(ds1Mock); + expect(queriesTab.state.dsSettings).toEqual(instance1SettingsMock); + }); + + it('should store loaded data source in local storage', async () => { + await setupScene('panel-1'); + + expect(store.setObject).toHaveBeenCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', { + dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', + datasourceUid: 'gdev-testdata', + }); + }); + + it('should load default datasource if the datasource passed is not found', async () => { + const { queriesTab } = await setupScene('panel-6'); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'abc', + type: 'datasource', + }); + + expect(config.defaultDatasource).toBe('gdev-testdata'); + expect(queriesTab.state.datasource).toEqual(defaultDsMock); + expect(queriesTab.state.dsSettings).toEqual(instance1SettingsMock); + }); + }); + + describe('data source change', () => { + it('should load new data source', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + panel.state.$data?.activate(); + + await queriesTab.onChangeDataSource( + { type: 'grafana-prometheus-datasource', uid: 'gdev-prometheus' } as DataSourceInstanceSettings, + [] + ); + + expect(store.setObject).toHaveBeenCalledTimes(2); + expect(store.setObject).toHaveBeenLastCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', { + dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', + datasourceUid: 'gdev-prometheus', + }); + + expect(queriesTab.state.datasource).toEqual(ds2Mock); + expect(queriesTab.state.dsSettings).toEqual(instance2SettingsMock); + }); + }); + + describe('query options change', () => { + describe('time overrides', () => { + it('should create PanelTimeRange object', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + + panel.state.$data?.activate(); + + expect(panel.state.$timeRange).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + timeRange: { from: '1h' }, + }); + + expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); + }); + + it('should update PanelTimeRange object on time options update', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + + expect(panel.state.$timeRange).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + timeRange: { from: '1h' }, + }); + + expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); + expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('1h'); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + timeRange: { from: '2h' }, + }); + + expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('2h'); + }); + + it('should remove PanelTimeRange object on time options cleared', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + + expect(panel.state.$timeRange).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + timeRange: { from: '1h' }, + }); + + queriesTab.onQueryOptionsChange({ + dataSource: { + name: 'grafana-testdata', + type: 'grafana-testdata-datasource', + default: true, + }, + queries: [], + timeRange: { from: null }, + }); + + expect(panel.state.$timeRange).toBeUndefined(); + }); + }); + + describe('max data points and interval', () => { + it('should update max data points', async () => { + const { queriesTab } = await setupScene('panel-1'); + const dataObj = queriesTab.queryRunner; + + expect(dataObj.state.maxDataPoints).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + maxDataPoints: 100, + }); + + expect(dataObj.state.maxDataPoints).toBe(100); + }); + + it('should update min interval', async () => { + const { queriesTab } = await setupScene('panel-1'); + const dataObj = queriesTab.queryRunner; + + expect(dataObj.state.maxDataPoints).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + minInterval: '1s', + }); + expect(dataObj.state.minInterval).toBe('1s'); + }); + }); + + describe('query caching', () => { + it('updates cacheTimeout and queryCachingTTL', async () => { + const { queriesTab } = await setupScene('panel-1'); + const dataObj = queriesTab.queryRunner; + + queriesTab.onQueryOptionsChange({ + cacheTimeout: '60', + queryCachingTTL: 200000, + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + }); + + expect(dataObj.state.cacheTimeout).toBe('60'); + expect(dataObj.state.queryCachingTTL).toBe(200000); + }); + }); + }); + + describe('query inspection', () => { + it('allows query inspection from the tab', async () => { + const { queriesTab } = await setupScene('panel-1'); + queriesTab.onOpenInspector(); + + const params = locationService.getSearchObject(); + expect(params.inspect).toBe('1'); + expect(params.inspectTab).toBe(InspectTab.Query); + }); + }); + + describe('data source change', () => { + it('changing from one plugin to another', async () => { + const { queriesTab } = await setupScene('panel-1'); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'gdev-testdata', + type: 'grafana-testdata-datasource', + }); + + await queriesTab.onChangeDataSource({ + name: 'grafana-prometheus', + type: 'grafana-prometheus-datasource', + uid: 'gdev-prometheus', + meta: { + name: 'Prometheus', + module: 'prometheus', + id: 'grafana-prometheus-datasource', + }, + } as DataSourceInstanceSettings); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'gdev-prometheus', + type: 'grafana-prometheus-datasource', + }); + }); + + it('changing from a plugin to a dashboard data source', async () => { + const { queriesTab } = await setupScene('panel-1'); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'gdev-testdata', + type: 'grafana-testdata-datasource', + }); + + await queriesTab.onChangeDataSource({ + name: SHARED_DASHBOARD_QUERY, + type: 'datasource', + uid: SHARED_DASHBOARD_QUERY, + meta: { + name: 'Prometheus', + module: 'prometheus', + id: DASHBOARD_DATASOURCE_PLUGIN_ID, + }, + } as DataSourceInstanceSettings); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: SHARED_DASHBOARD_QUERY, + type: 'datasource', + }); + }); + + it('changing from dashboard data source to a plugin', async () => { + const { queriesTab } = await setupScene('panel-3'); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ uid: SHARED_DASHBOARD_QUERY, type: 'datasource' }); + + await queriesTab.onChangeDataSource({ + name: 'grafana-prometheus', + type: 'grafana-prometheus-datasource', + uid: 'gdev-prometheus', + meta: { + name: 'Prometheus', + module: 'prometheus', + id: 'grafana-prometheus-datasource', + }, + } as DataSourceInstanceSettings); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'gdev-prometheus', + type: 'grafana-prometheus-datasource', + }); + }); + }); + + describe('change queries', () => { + describe('plugin queries', () => { + it('should update queries', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + + panel.state.$data?.activate(); + + queriesTab.onQueriesChange([ + { + datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, + refId: 'A', + scenarioId: 'random_walk', + seriesCount: 5, + }, + ]); + + expect(queriesTab.queryRunner.state.queries).toEqual([ + { + datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, + refId: 'A', + scenarioId: 'random_walk', + seriesCount: 5, + }, + ]); + }); + }); + + describe('dashboard queries', () => { + it('should update queries', async () => { + const { queriesTab, panel } = await setupScene('panel-3'); + + panel.state.$data?.activate(); + + // Changing dashboard query to a panel with transformations + queriesTab.onQueriesChange([ + { + refId: 'A', + datasource: { type: DASHBOARD_DATASOURCE_PLUGIN_ID }, + panelId: panelWithTransformations.id, + }, + ]); + + expect(queriesTab.queryRunner.state.queries[0].panelId).toEqual(panelWithTransformations.id); + + // Changing dashboard query to a panel with queries only + queriesTab.onQueriesChange([ + { + refId: 'A', + datasource: { type: DASHBOARD_DATASOURCE_PLUGIN_ID }, + panelId: panelWithQueriesOnly.id, + }, + ]); + + expect(queriesTab.queryRunner.state.queries[0].panelId).toBe(panelWithQueriesOnly.id); + }); + + it('should load last used data source if no data source specified for a panel', async () => { + store.exists.mockReturnValue(true); + store.getObject.mockReturnValue({ + dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', + datasourceUid: 'gdev-testdata', + }); + + const { queriesTab } = await setupScene('panel-5'); + + expect(queriesTab.state.datasource).toBe(ds1Mock); + expect(queriesTab.state.dsSettings).toBe(instance1SettingsMock); + }); + }); + }); }); }); -describe('PanelDataQueriesTab', () => { - it('renders query group top section', async () => { - const modelMock = await createModelMock(); +async function setupScene(panelId: string) { + const dashboard = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} }); + const panel = findVizPanelByKey(dashboard, panelId)!; - render(); - await screen.findByTestId(selectors.components.QueryTab.queryGroupTopSection); - }); + const panelEditor = buildPanelEditScene(panel); + dashboard.setState({ editPanel: panelEditor }); - it('renders queries rows when queries are set', async () => { - const modelMock = await createModelMock(); - render(); + deactivators.push(dashboard.activate()); + deactivators.push(panelEditor.activate()); - await screen.findByTestId('query-editor-rows'); - expect(screen.getAllByTestId('query-editor-row')).toHaveLength(1); - }); + const queriesTab = panelEditor.state.dataPane!.state.tabs[0] as PanelDataQueriesTab; + deactivators.push(queriesTab.activate()); - it('allow to add a new query when user clicks on add new', async () => { - const modelMock = await createModelMock(); - jest.spyOn(modelMock, 'addQueryClick'); - jest.spyOn(modelMock, 'onQueriesChange'); - render(); + await Promise.resolve(); - await screen.findByTestId(selectors.components.QueryTab.addQuery); - await userEvent.click(screen.getByTestId(selectors.components.QueryTab.addQuery)); - - const expectedQueries = [ - { - datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, - refId: 'A', - scenarioId: 'random_walk', - seriesCount: 1, - }, - { datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, hide: false, refId: 'B' }, - ]; - - expect(modelMock.addQueryClick).toHaveBeenCalled(); - expect(modelMock.onQueriesChange).toHaveBeenCalledWith(expectedQueries); - }); - - it('allow to remove a query when user clicks on remove', async () => { - const modelMock = await createModelMock(); - jest.spyOn(modelMock, 'addQueryClick'); - jest.spyOn(modelMock, 'onQueriesChange'); - render(); - - await screen.findByTestId('data-testid Remove query'); - await userEvent.click(screen.getByTestId('data-testid Remove query')); - - expect(modelMock.onQueriesChange).toHaveBeenCalledWith([]); - }); -}); - -const setupVizPanelManger = (panelId: string) => { - const scene = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} }); - const panel = findVizPanelByKey(scene, panelId)!; - - const vizPanelManager = VizPanelManager.createFor(panel); - - // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it - // @ts-expect-error - getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); - - return vizPanelManager; -}; + return { panel, scene: dashboard, queriesTab }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx index 94d59e73eb4..23f663ca9f5 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx @@ -1,60 +1,134 @@ -import * as React from 'react'; - -import { CoreApp, DataSourceApi, DataSourceInstanceSettings, IconName, getDataSourceRef } from '@grafana/data'; +import { CoreApp, DataSourceApi, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { config, getDataSourceSrv } from '@grafana/runtime'; -import { SceneObjectBase, SceneComponentProps, sceneGraph, SceneQueryRunner } from '@grafana/scenes'; +import { config, getDataSourceSrv, locationService } from '@grafana/runtime'; +import { + SceneObjectBase, + SceneComponentProps, + sceneGraph, + SceneQueryRunner, + SceneObjectRef, + VizPanel, + SceneObjectState, + SceneDataQuery, +} from '@grafana/scenes'; import { DataQuery } from '@grafana/schema'; import { Button, Stack, Tab } from '@grafana/ui'; import { addQuery } from 'app/core/utils/query'; +import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard'; +import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils'; import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; import { GroupActionComponents } from 'app/features/query/components/QueryActionComponent'; import { QueryEditorRows } from 'app/features/query/components/QueryEditorRows'; import { QueryGroupTopSection } from 'app/features/query/components/QueryGroup'; +import { updateQueries } from 'app/features/query/state/updateQueries'; import { isSharedDashboardQuery } from 'app/plugins/datasource/dashboard'; -import { GrafanaQuery } from 'app/plugins/datasource/grafana/types'; import { QueryGroupOptions } from 'app/types'; -import { PanelTimeRange } from '../../scene/PanelTimeRange'; -import { VizPanelManager } from '../VizPanelManager'; +import { PanelTimeRange, PanelTimeRangeState } from '../../scene/PanelTimeRange'; +import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../../utils/utils'; -import { PanelDataPaneTabState, PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; +import { PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; -interface PanelDataQueriesTabState extends PanelDataPaneTabState { +interface PanelDataQueriesTabState extends SceneObjectState { datasource?: DataSourceApi; dsSettings?: DataSourceInstanceSettings; + panelRef: SceneObjectRef; } export class PanelDataQueriesTab extends SceneObjectBase implements PanelDataPaneTab { static Component = PanelDataQueriesTabRendered; - TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; - tabId = TabId.Queries; - icon: IconName = 'database'; - private _panelManager: VizPanelManager; - getTabLabel() { + public constructor(state: PanelDataQueriesTabState) { + super(state); + this.addActivationHandler(() => this.onActivate()); + } + + public getTabLabel() { return 'Queries'; } - getItemsCount() { + public getItemsCount() { return this.getQueries().length; } - constructor(panelManager: VizPanelManager) { - super({}); - - this.TabComponent = (props: PanelDataTabHeaderProps) => { - return QueriesTab({ ...props, model: this }); - }; - - this._panelManager = panelManager; + public renderTab(props: PanelDataTabHeaderProps) { + return ; } - buildQueryOptions(): QueryGroupOptions { - const panelManager = this._panelManager; - const panelObj = this._panelManager.state.panel; - const queryRunner = this._panelManager.queryRunner; - const timeRangeObj = sceneGraph.getTimeRange(panelObj); + private onActivate() { + this.loadDataSource(); + } + + private async loadDataSource() { + const panel = this.state.panelRef.resolve(); + const dataObj = panel.state.$data; + + if (!dataObj) { + return; + } + + let datasourceToLoad = this.queryRunner.state.datasource; + + try { + let datasource: DataSourceApi | undefined; + let dsSettings: DataSourceInstanceSettings | undefined; + + if (!datasourceToLoad) { + const dashboardScene = getDashboardSceneFor(this); + const dashboardUid = dashboardScene.state.uid ?? ''; + const lastUsedDatasource = getLastUsedDatasourceFromStorage(dashboardUid!); + + // do we have a last used datasource for this dashboard + if (lastUsedDatasource?.datasourceUid !== null) { + // get datasource from dashbopard uid + dsSettings = getDataSourceSrv().getInstanceSettings({ uid: lastUsedDatasource?.datasourceUid }); + if (dsSettings) { + datasource = await getDataSourceSrv().get({ + uid: lastUsedDatasource?.datasourceUid, + type: dsSettings.type, + }); + + this.queryRunner.setState({ + datasource: { + ...getDataSourceRef(dsSettings), + uid: lastUsedDatasource?.datasourceUid, + }, + }); + } + } + } else { + datasource = await getDataSourceSrv().get(datasourceToLoad); + dsSettings = getDataSourceSrv().getInstanceSettings(datasourceToLoad); + } + + if (datasource && dsSettings) { + this.setState({ datasource, dsSettings }); + storeLastUsedDataSourceInLocalStorage(getDataSourceRef(dsSettings) || { default: true }); + } + } catch (err) { + //set default datasource if we fail to load the datasource + const datasource = await getDataSourceSrv().get(config.defaultDatasource); + const dsSettings = getDataSourceSrv().getInstanceSettings(config.defaultDatasource); + + if (datasource && dsSettings) { + this.setState({ + datasource, + dsSettings, + }); + + this.queryRunner.setState({ + datasource: getDataSourceRef(dsSettings), + }); + } + + console.error(err); + } + } + + public buildQueryOptions(): QueryGroupOptions { + const panel = this.state.panelRef.resolve(); + const queryRunner = getQueryRunnerFor(panel)!; + const timeRangeObj = sceneGraph.getTimeRange(panel); let timeRangeOpts: QueryGroupOptions['timeRange'] = { from: undefined, @@ -71,19 +145,14 @@ export class PanelDataQueriesTab extends SceneObjectBase { - this._panelManager.inspectPanel(); + public onOpenInspector = () => { + const panel = this.state.panelRef.resolve(); + const panelId = getPanelIdForVizPanel(panel); + + locationService.partial({ inspect: panelId, inspectTab: 'query' }); }; - onChangeDataSource = async ( - newSettings: DataSourceInstanceSettings, - defaultQueries?: DataQuery[] | GrafanaQuery[] - ) => { - this._panelManager.changePanelDataSource(newSettings, defaultQueries); + public onChangeDataSource = async (newSettings: DataSourceInstanceSettings, defaultQueries?: SceneDataQuery[]) => { + const { dsSettings } = this.state; + const queryRunner = this.queryRunner; + + const currentDS = dsSettings ? await getDataSourceSrv().get({ uid: dsSettings.uid }) : undefined; + const nextDS = await getDataSourceSrv().get({ uid: newSettings.uid }); + + const currentQueries = queryRunner.state.queries; + + // We need to pass in newSettings.uid as well here as that can be a variable expression and we want to store that in the query model not the current ds variable value + const queries = defaultQueries || (await updateQueries(nextDS, newSettings.uid, currentQueries, currentDS)); + + queryRunner.setState({ datasource: getDataSourceRef(newSettings), queries }); + + if (defaultQueries) { + queryRunner.runQueries(); + } + + this.loadDataSource(); }; - onQueryOptionsChange = (options: QueryGroupOptions) => { - this._panelManager.changeQueryOptions(options); + public onQueryOptionsChange = (options: QueryGroupOptions) => { + const panelObj = this.state.panelRef.resolve(); + const dataObj = this.queryRunner; + const timeRangeObj = panelObj.state.$timeRange; + + const dataObjStateUpdate: Partial = {}; + const timeRangeObjStateUpdate: Partial = {}; + + if (options.maxDataPoints !== dataObj.state.maxDataPoints) { + dataObjStateUpdate.maxDataPoints = options.maxDataPoints ?? undefined; + } + + if (options.minInterval !== dataObj.state.minInterval && options.minInterval !== null) { + dataObjStateUpdate.minInterval = options.minInterval; + } + + if (options.timeRange) { + timeRangeObjStateUpdate.timeFrom = options.timeRange.from ?? undefined; + timeRangeObjStateUpdate.timeShift = options.timeRange.shift ?? undefined; + timeRangeObjStateUpdate.hideTimeOverride = options.timeRange.hide; + } + + if (timeRangeObj instanceof PanelTimeRange) { + if (timeRangeObjStateUpdate.timeFrom !== undefined || timeRangeObjStateUpdate.timeShift !== undefined) { + // update time override + timeRangeObj.setState(timeRangeObjStateUpdate); + } else { + // remove time override + panelObj.setState({ $timeRange: undefined }); + } + } else { + // no time override present on the panel, let's create one first + panelObj.setState({ $timeRange: new PanelTimeRange(timeRangeObjStateUpdate) }); + } + + if (options.cacheTimeout !== dataObj?.state.cacheTimeout) { + dataObjStateUpdate.cacheTimeout = options.cacheTimeout; + } + + if (options.queryCachingTTL !== dataObj?.state.queryCachingTTL) { + dataObjStateUpdate.queryCachingTTL = options.queryCachingTTL; + } + + dataObj.setState(dataObjStateUpdate); + dataObj.runQueries(); }; - onQueriesChange = (queries: DataQuery[]) => { - this._panelManager.changeQueries(queries); + public onQueriesChange = (queries: SceneDataQuery[]) => { + const runner = this.queryRunner; + runner.setState({ queries }); }; - onRunQueries = () => { - this._panelManager.queryRunner.runQueries(); + public onRunQueries = () => { + this.queryRunner.runQueries(); }; - getQueries() { - return this._panelManager.queryRunner.state.queries; + public getQueries() { + return this.queryRunner.state.queries; } - newQuery(): Partial { - const { dsSettings, datasource } = this._panelManager.state; - + public newQuery(): Partial { + const { dsSettings, datasource } = this.state; let ds; + if (!dsSettings?.meta.mixed) { ds = dsSettings; // Use dsSettings if it is not mixed } else if (!datasource?.meta.mixed) { @@ -138,29 +268,30 @@ export class PanelDataQueriesTab extends SceneObjectBase { + public addQueryClick = () => { const queries = this.getQueries(); this.onQueriesChange(addQuery(queries, this.newQuery())); }; - onAddQuery = (query: Partial) => { + public onAddQuery = (query: Partial) => { const queries = this.getQueries(); - const dsSettings = this._panelManager.state.dsSettings; + const dsSettings = this.state.dsSettings; + this.onQueriesChange( addQuery(queries, query, dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined }) ); }; - isExpressionsSupported(dsSettings: DataSourceInstanceSettings): boolean { + public isExpressionsSupported(dsSettings: DataSourceInstanceSettings): boolean { return (dsSettings.meta.alerting || dsSettings.meta.mixed) === true; } - onAddExpressionClick = () => { + public onAddExpressionClick = () => { const queries = this.getQueries(); this.onQueriesChange(addQuery(queries, expressionDatasource.newQuery())); }; - renderExtraActions() { + public renderExtraActions() { return GroupActionComponents.getAllExtraRenderAction() .map((action, index) => action({ @@ -172,18 +303,14 @@ export class PanelDataQueriesTab extends SceneObjectBase) { - const { datasource, dsSettings } = model.panelManager.useState(); - const { data, queries } = model.panelManager.queryRunner.useState(); + const { datasource, dsSettings } = model.useState(); + const { data, queries } = model.queryRunner.useState(); if (!datasource || !dsSettings || !data) { return null; @@ -250,7 +377,6 @@ function QueriesTab(props: QueriesTabProps) { return ( { it('can change transformations', () => { - const vizPanelManager = setupVizPanelManger('panel-1'); - const model = new PanelDataTransformationsTab(vizPanelManager); - model.onChangeTransformations([{ id: 'calculateField', options: {} }]); - expect(model.getDataTransformer().state.transformations).toEqual([{ id: 'calculateField', options: {} }]); + const { transformsTab } = setupTabScene('panel-1'); + transformsTab.onChangeTransformations([{ id: 'calculateField', options: {} }]); + expect(transformsTab.getDataTransformer().state.transformations).toEqual([{ id: 'calculateField', options: {} }]); }); }); @@ -169,15 +167,16 @@ describe('PanelDataTransformationsTab', () => { }); }); -const setupVizPanelManger = (panelId: string) => { +function setupTabScene(panelId: string) { const scene = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} }); const panel = findVizPanelByKey(scene, panelId)!; - const vizPanelManager = VizPanelManager.createFor(panel); + const transformsTab = new PanelDataTransformationsTab({ panelRef: panel.getRef() }); + transformsTab.activate(); // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it // @ts-expect-error getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); - return vizPanelManager; -}; + return { transformsTab }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx index a90022f66d2..5f0d7d4ad05 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx @@ -2,56 +2,62 @@ import { css } from '@emotion/css'; import { DragDropContext, DropResult, Droppable } from '@hello-pangea/dnd'; import { useState } from 'react'; -import { DataTransformerConfig, GrafanaTheme2, IconName, PanelData } from '@grafana/data'; +import { DataTransformerConfig, GrafanaTheme2, PanelData } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { SceneObjectBase, SceneComponentProps, SceneDataTransformer, SceneQueryRunner } from '@grafana/scenes'; +import { + SceneObjectBase, + SceneComponentProps, + SceneDataTransformer, + SceneQueryRunner, + SceneObjectRef, + VizPanel, + SceneObjectState, +} from '@grafana/scenes'; import { Button, ButtonGroup, ConfirmModal, Tab, useStyles2 } from '@grafana/ui'; import { TransformationOperationRows } from 'app/features/dashboard/components/TransformationsEditor/TransformationOperationRows'; -import { VizPanelManager } from '../VizPanelManager'; +import { getQueryRunnerFor } from '../../utils/utils'; import { EmptyTransformationsMessage } from './EmptyTransformationsMessage'; import { TransformationsDrawer } from './TransformationsDrawer'; -import { PanelDataPaneTabState, PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; +import { PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; -interface PanelDataTransformationsTabState extends PanelDataPaneTabState {} +interface PanelDataTransformationsTabState extends SceneObjectState { + panelRef: SceneObjectRef; +} export class PanelDataTransformationsTab extends SceneObjectBase implements PanelDataPaneTab { static Component = PanelDataTransformationsTabRendered; - TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; - tabId = TabId.Transformations; - icon: IconName = 'process'; - private _panelManager: VizPanelManager; getTabLabel() { return 'Transformations'; } - constructor(panelManager: VizPanelManager) { - super({}); - this.TabComponent = (props: PanelDataTabHeaderProps) => TransformationsTab({ ...props, model: this }); - - this._panelManager = panelManager; + public renderTab(props: PanelDataTabHeaderProps) { + return ; } public getQueryRunner(): SceneQueryRunner { - return this._panelManager.queryRunner; + return getQueryRunnerFor(this.state.panelRef.resolve())!; } public getDataTransformer(): SceneDataTransformer { - return this._panelManager.dataTransformer; + const provider = this.state.panelRef.resolve().state.$data; + + if (!provider || !(provider instanceof SceneDataTransformer)) { + throw new Error('Could not find SceneDataTransformer for panel'); + } + return provider; } public onChangeTransformations(transformations: DataTransformerConfig[]) { - this._panelManager.changeTransformations(transformations); - } - - get panelManager() { - return this._panelManager; + const transformer = this.getDataTransformer(); + transformer.setState({ transformations }); + transformer.reprocessTransformations(); } } @@ -200,11 +206,10 @@ interface TransformationsTabProps extends PanelDataTabHeaderProps { function TransformationsTab(props: TransformationsTabProps) { const { model } = props; - const transformerState = model.getDataTransformer().useState(); + return ( ) => void; } export interface PanelDataPaneTab extends SceneObject { - TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; + renderTab: (props: PanelDataTabHeaderProps) => React.JSX.Element; getTabLabel(): string; tabId: TabId; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx index cf37a7888ea..66c1263e302 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx @@ -1,5 +1,4 @@ import { selectors } from '@grafana/e2e-selectors'; -import { config } from '@grafana/runtime'; import { InlineSwitch } from '@grafana/ui'; import { PanelEditor } from './PanelEditor'; @@ -9,19 +8,17 @@ export interface Props { } export function PanelEditControls({ panelEditor }: Props) { - const vizManager = panelEditor.state.vizManager; - const { panel, tableView } = vizManager.useState(); - const skipDataQuery = config.panels[panel.state.pluginId]?.skipDataQuery; + const { tableView, dataPane } = panelEditor.useState(); return ( <> - {!skipDataQuery && ( + {dataPane && ( vizManager.toggleTableView()} + onClick={panelEditor.onToggleTableView} aria-label="toggle-table-view" data-testid={selectors.components.PanelEditor.toggleTableView} /> diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts index 1c18e13fa0d..8117146a4fc 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts @@ -1,5 +1,21 @@ -import { PanelPlugin, PanelPluginMeta, PluginType } from '@grafana/data'; -import { SceneGridLayout, VizPanel } from '@grafana/scenes'; +import { of } from 'rxjs'; + +import { DataQueryRequest, DataSourceApi, LoadingState, PanelPlugin } from '@grafana/data'; +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { setDataSourceSrv } from '@grafana/runtime'; +import { + CancelActivationHandler, + CustomVariable, + SceneDataTransformer, + sceneGraph, + SceneGridLayout, + SceneQueryRunner, + SceneTimeRange, + SceneVariableSet, + VizPanel, +} from '@grafana/scenes'; +import { mockDataSource, MockDataSourceSrv } from 'app/features/alerting/unified/mocks'; +import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; import * as libAPI from 'app/features/library-panels/state/api'; import { DashboardGridItem } from '../scene/DashboardGridItem'; @@ -7,14 +23,28 @@ import { DashboardScene } from '../scene/DashboardScene'; import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { activateFullSceneTree } from '../utils/test-utils'; +import { findVizPanelByKey, getQueryRunnerFor } from '../utils/utils'; import { buildPanelEditScene } from './PanelEditor'; -let pluginToLoad: PanelPlugin | undefined; +const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { + return of({ + state: LoadingState.Loading, + series: [], + timeRange: request.range, + }); +}); + +let pluginPromise: Promise | undefined; + jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), + getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { + return runRequestMock(ds, request); + }, getPluginImportUtils: () => ({ - getPanelPluginFromCache: jest.fn(() => pluginToLoad), + getPanelPluginFromCache: jest.fn(() => undefined), + importPanelPlugin: () => pluginPromise, }), config: { ...jest.requireActual('@grafana/runtime').config, @@ -29,103 +59,134 @@ jest.mock('@grafana/runtime', () => ({ }, })); +const dataSources = { + ds1: mockDataSource({ + uid: 'ds1', + type: DataSourceType.Prometheus, + }), +}; + +setDataSourceSrv(new MockDataSourceSrv(dataSources)); + +let deactivate: CancelActivationHandler | undefined; + describe('PanelEditor', () => { - describe('When closing editor', () => { - it('should apply changes automatically', () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); - - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); - - const gridItem = new DashboardGridItem({ body: panel }); - - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [gridItem], - }), - }); - - const deactivate = activateFullSceneTree(scene); - - editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); - + afterEach(() => { + if (deactivate) { deactivate(); + deactivate = undefined; + } + }); - const updatedPanel = gridItem.state.body as VizPanel; - expect(updatedPanel?.state.title).toBe('changed title'); + describe('When initializing', () => { + it('should wait for panel plugin to load', async () => { + const { panelEditor, panel, pluginResolve, dashboard } = await setup({ skipWait: true }); + + expect(panel.state.options).toEqual({}); + expect(panelEditor.state.isInitializing).toBe(true); + + const pluginToLoad = getPanelPlugin({ id: 'text' }).setPanelOptions((build) => { + build.addBooleanSwitch({ + path: 'showHeader', + name: 'Show header', + defaultValue: true, + }); + }); + + pluginResolve(pluginToLoad); + + await new Promise((r) => setTimeout(r, 1)); + + expect(panelEditor.state.isInitializing).toBe(false); + expect(panel.state.options).toEqual({ showHeader: true }); + + panel.onOptionsChange({ showHeader: false }); + panelEditor.onDiscard(); + + const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!; + expect(discardedPanel.state.options).toEqual({ showHeader: true }); + }); + }); + + describe('When discarding', () => { + it('should discard changes revert all changes', async () => { + const { panelEditor, panel, dashboard } = await setup(); + + panel.setState({ title: 'changed title' }); + panelEditor.onDiscard(); + + const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!; + + expect(discardedPanel.state.title).toBe('original title'); }); - it('should discard changes when unmounted and discard changes is marked as true', () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); + it('should discard a newly added panel', async () => { + const { panelEditor, dashboard } = await setup({ isNewPanel: true }); + panelEditor.onDiscard(); - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); - - const gridItem = new DashboardGridItem({ body: panel }); - - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [gridItem], - }), - }); - - const deactivate = activateFullSceneTree(scene); - - editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); - - editScene.onDiscard(); - deactivate(); - - const updatedPanel = gridItem.state.body as VizPanel; - expect(updatedPanel?.state.title).toBe(panel.state.title); + expect((dashboard.state.body as SceneGridLayout).state.children.length).toBe(0); }); - it('should discard a newly added panel', () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); + it('should discard query runner changes', async () => { + const { panelEditor, panel, dashboard } = await setup({}); - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); + const queryRunner = getQueryRunnerFor(panel); + queryRunner?.setState({ maxDataPoints: 123, queries: [{ refId: 'A' }, { refId: 'B' }] }); - const gridItem = new DashboardGridItem({ body: panel }); + panelEditor.onDiscard(); - const editScene = buildPanelEditScene(panel, true); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [gridItem], - }), - }); + const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!; + const restoredQueryRunner = getQueryRunnerFor(discardedPanel); + expect(restoredQueryRunner?.state.maxDataPoints).toBe(500); + expect(restoredQueryRunner?.state.queries.length).toBe(1); + }); + }); - editScene.onDiscard(); - const deactivate = activateFullSceneTree(scene); + describe('When changes are made', () => { + it('Should set state to dirty', async () => { + const { panelEditor, panel } = await setup({}); - deactivate(); + expect(panelEditor.state.isDirty).toBe(undefined); - expect((scene.state.body as SceneGridLayout).state.children.length).toBe(0); + panel.setState({ title: 'changed title' }); + + expect(panelEditor.state.isDirty).toBe(true); + }); + + it('Should reset dirty and orginal state when dashboard is saved', async () => { + const { panelEditor, panel } = await setup({}); + + expect(panelEditor.state.isDirty).toBe(undefined); + + panel.setState({ title: 'changed title' }); + + panelEditor.dashboardSaved(); + + expect(panelEditor.state.isDirty).toBe(false); + + panel.setState({ title: 'changed title 2' }); + + expect(panelEditor.state.isDirty).toBe(true); + + // Change back to already saved state + panel.setState({ title: 'changed title' }); + expect(panelEditor.state.isDirty).toBe(false); + }); + }); + + describe('When opening a repeated panel', () => { + it('Should default to the first variable value if panel is repeated', async () => { + const { panel } = await setup({ repeatByVariable: 'server' }); + const variable = sceneGraph.lookupVariable('server', panel); + expect(variable?.getValue()).toBe('A'); }); }); describe('Handling library panels', () => { it('should call the api with the updated panel', async () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); + pluginPromise = Promise.resolve(getPanelPlugin({ id: 'text', skipDataQuery: true })); + const panel = new VizPanel({ key: 'panel-1', pluginId: 'text' }); const libraryPanelModel = { title: 'title', uid: 'uid', @@ -143,15 +204,13 @@ describe('PanelEditor', () => { _loadedPanel: libraryPanelModel, }); - panel.setState({ - $behaviors: [libPanelBehavior], - }); + panel.setState({ $behaviors: [libPanelBehavior] }); const gridItem = new DashboardGridItem({ body: panel }); - const editScene = buildPanelEditScene(panel); const scene = new DashboardScene({ editPanel: editScene, + $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }), isEditing: true, body: new SceneGridLayout({ children: [gridItem], @@ -160,96 +219,133 @@ describe('PanelEditor', () => { activateFullSceneTree(scene); - editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); - (editScene.state.vizManager.state.panel.state.$behaviors![0] as LibraryPanelBehavior).setState({ - name: 'changed name', - }); + await new Promise((r) => setTimeout(r, 1)); + + panel.setState({ title: 'changed title' }); + libPanelBehavior.setState({ name: 'changed name' }); jest.spyOn(libAPI, 'saveLibPanel').mockImplementation(async (panel) => { const updatedPanel = { ...libAPI.libraryVizPanelToSaveModel(panel), version: 2 }; - libPanelBehavior.setPanelFromLibPanel(updatedPanel); }); - editScene.state.vizManager.commitChanges(); + editScene.onConfirmSaveLibraryPanel(); + await new Promise(process.nextTick); - await new Promise(process.nextTick); // Wait for mock api to return and update the library panel + // Wait for mock api to return and update the library panel expect(libPanelBehavior.state._loadedPanel?.version).toBe(2); expect(libPanelBehavior.state.name).toBe('changed name'); expect(libPanelBehavior.state.title).toBe('changed title'); expect((gridItem.state.body as VizPanel).state.title).toBe('changed title'); }); + + it('unlinks library panel', () => { + const libraryPanelModel = { + title: 'title', + uid: 'uid', + name: 'libraryPanelName', + model: { + title: 'title', + type: 'text', + }, + type: 'panel', + version: 1, + }; + + const libPanelBehavior = new LibraryPanelBehavior({ + isLoaded: true, + title: libraryPanelModel.title, + uid: libraryPanelModel.uid, + name: libraryPanelModel.name, + _loadedPanel: libraryPanelModel, + }); + + // Just adding an extra stateless behavior to verify unlinking does not remvoe it + const otherBehavior = jest.fn(); + const panel = new VizPanel({ key: 'panel-1', pluginId: 'text', $behaviors: [libPanelBehavior, otherBehavior] }); + const editScene = buildPanelEditScene(panel); + editScene.onConfirmUnlinkLibraryPanel(); + + expect(panel.state.$behaviors?.length).toBe(1); + expect(panel.state.$behaviors![0]).toBe(otherBehavior); + }); }); describe('PanelDataPane', () => { - it('should not exist if panel is skipDataQuery', () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); - - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); - new DashboardGridItem({ - body: panel, - }); - - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - }); - - activateFullSceneTree(scene); - - expect(editScene.state.dataPane).toBeUndefined(); + it('should not exist if panel is skipDataQuery', async () => { + const { panelEditor } = await setup({ pluginSkipDataQuery: true }); + expect(panelEditor.state.dataPane).toBeUndefined(); }); - it('should exist if panel is supporting querying', () => { - pluginToLoad = getTestPanelPlugin({ id: 'timeseries' }); - - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'timeseries', - }); - - new DashboardGridItem({ - body: panel, - }); - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - }); - - activateFullSceneTree(scene); - expect(editScene.state.dataPane).toBeDefined(); + it('should exist if panel is supporting querying', async () => { + const { panelEditor } = await setup({ pluginSkipDataQuery: false }); + expect(panelEditor.state.dataPane).toBeDefined(); }); }); }); -export function getTestPanelPlugin(options: Partial): PanelPlugin { - const plugin = new PanelPlugin(() => null); - plugin.meta = { - id: options.id!, - type: PluginType.panel, - name: options.id!, - sort: options.sort || 1, - info: { - author: { - name: options.id + 'name', - }, - description: '', - links: [], - logos: { - large: '', - small: '', - }, - screenshots: [], - updated: '', - version: '1.0.', - }, - hideFromList: options.hideFromList === true, - module: options.module ?? '', - baseUrl: '', - skipDataQuery: options.skipDataQuery ?? false, - }; - return plugin; +interface SetupOptions { + isNewPanel?: boolean; + pluginSkipDataQuery?: boolean; + repeatByVariable?: string; + skipWait?: boolean; + pluginLoadTime?: number; +} + +async function setup(options: SetupOptions = {}) { + const pluginToLoad = getPanelPlugin({ id: 'text', skipDataQuery: options.pluginSkipDataQuery }); + let pluginResolve = (plugin: PanelPlugin) => {}; + + pluginPromise = new Promise((resolve) => { + pluginResolve = resolve; + }); + + const panel = new VizPanel({ + key: 'panel-1', + pluginId: 'text', + title: 'original title', + $data: new SceneDataTransformer({ + transformations: [], + $data: new SceneQueryRunner({ + queries: [{ refId: 'A' }], + maxDataPoints: 500, + datasource: { uid: 'ds1' }, + }), + }), + }); + + const gridItem = new DashboardGridItem({ body: panel, variableName: options.repeatByVariable }); + + const panelEditor = buildPanelEditScene(panel, options.isNewPanel); + const dashboard = new DashboardScene({ + editPanel: panelEditor, + isEditing: true, + $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }), + $variables: new SceneVariableSet({ + variables: [ + new CustomVariable({ + name: 'server', + query: 'A,B,C', + isMulti: true, + value: ['A', 'B', 'C'], + text: ['A', 'B', 'C'], + }), + ], + }), + body: new SceneGridLayout({ + children: [gridItem], + }), + }); + + panelEditor.debounceSaveModelDiff = false; + + deactivate = activateFullSceneTree(dashboard); + + if (!options.skipWait) { + //console.log('pluginResolve(pluginToLoad)'); + pluginResolve(pluginToLoad); + await new Promise((r) => setTimeout(r, 1)); + } + + return { dashboard, panel, gridItem, panelEditor, pluginResolve }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index 40546061ae5..3a696e97749 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -1,72 +1,186 @@ import * as H from 'history'; +import { debounce } from 'lodash'; -import { NavIndex } from '@grafana/data'; -import { config, locationService } from '@grafana/runtime'; -import { SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; +import { NavIndex, PanelPlugin } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { + PanelBuilders, + SceneObjectBase, + SceneObjectRef, + SceneObjectState, + SceneObjectStateChangedEvent, + sceneUtils, + VizPanel, +} from '@grafana/scenes'; +import { Panel } from '@grafana/schema/dist/esm/index.gen'; +import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; +import { saveLibPanel } from 'app/features/library-panels/state/api'; -import { DashboardGridItem } from '../scene/DashboardGridItem'; -import { getDashboardSceneFor, getPanelIdForVizPanel } from '../utils/utils'; +import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; +import { getPanelChanges } from '../saving/getDashboardChanges'; +import { DashboardGridItem, DashboardGridItemState } from '../scene/DashboardGridItem'; +import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; +import { + activateInActiveParents, + getDashboardSceneFor, + getLibraryPanelBehavior, + getPanelIdForVizPanel, +} from '../utils/utils'; +import { DataProviderSharer } from './PanelDataPane/DataProviderSharer'; import { PanelDataPane } from './PanelDataPane/PanelDataPane'; import { PanelEditorRenderer } from './PanelEditorRenderer'; import { PanelOptionsPane } from './PanelOptionsPane'; -import { VizPanelManager, VizPanelManagerState } from './VizPanelManager'; export interface PanelEditorState extends SceneObjectState { isNewPanel: boolean; isDirty?: boolean; - panelId: number; - optionsPane: PanelOptionsPane; + optionsPane?: PanelOptionsPane; dataPane?: PanelDataPane; - vizManager: VizPanelManager; + panelRef: SceneObjectRef; showLibraryPanelSaveModal?: boolean; showLibraryPanelUnlinkModal?: boolean; + tableView?: VizPanel; + pluginLoadErrror?: string; + /** + * Waiting for library panel or panel plugin to load + */ + isInitializing?: boolean; } export class PanelEditor extends SceneObjectBase { - private _initialRepeatOptions: Pick = {}; static Component = PanelEditorRenderer; - private _discardChanges = false; + private _originalLayoutElementState!: DashboardGridItemState; + private _layoutElement!: DashboardGridItem; + private _originalSaveModel!: Panel; public constructor(state: PanelEditorState) { super(state); - const { repeat, repeatDirection, maxPerRow } = state.vizManager.state; - this._initialRepeatOptions = { - repeat, - repeatDirection, - maxPerRow, - }; - + this.setOriginalState(this.state.panelRef); this.addActivationHandler(this._activationHandler.bind(this)); } private _activationHandler() { - const panelManager = this.state.vizManager; - const panel = panelManager.state.panel; + const panel = this.state.panelRef.resolve(); + const deactivateParents = activateInActiveParents(panel); + const layoutElement = panel.parent; - this._subs.add( - panelManager.subscribeToState((n, p) => { - if (n.pluginId !== p.pluginId) { - this._initDataPane(n.pluginId); - } - }) - ); - - this._initDataPane(panel.state.pluginId); + this.waitForPlugin(); return () => { - if (!this._discardChanges) { - this.commitChanges(); - } else if (this.state.isNewPanel) { - getDashboardSceneFor(this).removePanel(panelManager.state.sourcePanel.resolve()!); + if (layoutElement instanceof DashboardGridItem) { + layoutElement.editingCompleted(); + } + if (deactivateParents) { + deactivateParents(); } }; } + private waitForPlugin(retry = 0) { + const panel = this.getPanel(); + const plugin = panel.getPlugin(); - private _initDataPane(pluginId: string) { - const skipDataQuery = config.panels[pluginId]?.skipDataQuery; + if (!plugin || plugin.meta.id !== panel.state.pluginId) { + if (retry < 100) { + setTimeout(() => this.waitForPlugin(retry + 1), retry * 10); + } else { + this.setState({ pluginLoadErrror: 'Failed to load panel plugin' }); + } + return; + } + + this.gotPanelPlugin(plugin); + } + + private setOriginalState(panelRef: SceneObjectRef) { + const panel = panelRef.resolve(); + + this._originalSaveModel = vizPanelToPanel(panel); + + if (panel.parent instanceof DashboardGridItem) { + this._originalLayoutElementState = sceneUtils.cloneSceneObjectState(panel.parent.state); + this._layoutElement = panel.parent; + } + } + + /** + * Useful for testing to turn on debounce + */ + public debounceSaveModelDiff = true; + + /** + * Subscribe to state changes and check if the save model has changed + */ + private _setupChangeDetection() { + const panel = this.state.panelRef.resolve(); + const performSaveModelDiff = () => { + const { hasChanges } = getPanelChanges(this._originalSaveModel, vizPanelToPanel(panel)); + this.setState({ isDirty: hasChanges }); + }; + + const performSaveModelDiffDebounced = this.debounceSaveModelDiff + ? debounce(performSaveModelDiff, 250) + : performSaveModelDiff; + + const handleStateChange = (event: SceneObjectStateChangedEvent) => { + if (DashboardSceneChangeTracker.isUpdatingPersistedState(event)) { + performSaveModelDiffDebounced(); + } + }; + + this._subs.add(panel.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); + // Repeat options live on the layout element (DashboardGridItem) + this._subs.add(this._layoutElement.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); + } + + public getPanel(): VizPanel { + return this.state.panelRef?.resolve(); + } + + private gotPanelPlugin(plugin: PanelPlugin) { + const panel = this.getPanel(); + const layoutElement = panel.parent; + + // First time initialization + if (this.state.isInitializing) { + this.setOriginalState(this.state.panelRef); + + if (layoutElement instanceof DashboardGridItem) { + layoutElement.editingStarted(); + } + + this._setupChangeDetection(); + this._updateDataPane(plugin); + + // Listen for panel plugin changes + this._subs.add( + panel.subscribeToState((n, p) => { + if (n.pluginId !== p.pluginId) { + this.waitForPlugin(); + } + }) + ); + + // Setup options pane + this.setState({ + optionsPane: new PanelOptionsPane({ + panelRef: this.state.panelRef, + searchQuery: '', + listMode: OptionFilter.All, + }), + isInitializing: false, + }); + } else { + // plugin changed after first time initialization + // Just update data pane + this._updateDataPane(plugin); + } + } + + private _updateDataPane(plugin: PanelPlugin) { + const skipDataQuery = plugin.meta.skipDataQuery; if (skipDataQuery && this.state.dataPane) { locationService.partial({ tab: null }, true); @@ -74,12 +188,16 @@ export class PanelEditor extends SceneObjectBase { } if (!skipDataQuery && !this.state.dataPane) { - this.setState({ dataPane: new PanelDataPane(this.state.vizManager) }); + this.setState({ dataPane: PanelDataPane.createFor(this.getPanel()) }); } } public getUrlKey() { - return this.state.panelId.toString(); + return this.getPanelId().toString(); + } + + public getPanelId() { + return getPanelIdForVizPanel(this.state.panelRef.resolve()); } public getPageNav(location: H.Location, navIndex: NavIndex) { @@ -92,53 +210,23 @@ export class PanelEditor extends SceneObjectBase { } public onDiscard = () => { - this.state.vizManager.setState({ isDirty: false }); - this._discardChanges = true; + this.setState({ isDirty: false }); + + const panel = this.state.panelRef.resolve(); + + if (this.state.isNewPanel) { + getDashboardSceneFor(this).removePanel(panel); + } else { + // Revert any layout element changes + this._layoutElement.setState(this._originalLayoutElementState!); + } + locationService.partial({ editPanel: null }); }; - public commitChanges() { - const dashboard = getDashboardSceneFor(this); - - if (!dashboard.state.isEditing) { - dashboard.onEnterEditMode(); - } - - const panelManager = this.state.vizManager; - const sourcePanel = panelManager.state.sourcePanel.resolve(); - const gridItem = sourcePanel!.parent; - - if (!(gridItem instanceof DashboardGridItem)) { - console.error('Unsupported scene object type'); - return; - } - - this.commitChangesToSource(gridItem); - } - - private commitChangesToSource(gridItem: DashboardGridItem) { - let width = gridItem.state.width ?? 1; - let height = gridItem.state.height; - - const panelManager = this.state.vizManager; - const horizontalToVertical = - this._initialRepeatOptions.repeatDirection === 'h' && panelManager.state.repeatDirection === 'v'; - const verticalToHorizontal = - this._initialRepeatOptions.repeatDirection === 'v' && panelManager.state.repeatDirection === 'h'; - if (horizontalToVertical) { - width = Math.floor(width / (gridItem.state.maxPerRow ?? 1)); - } else if (verticalToHorizontal) { - width = 24; - } - - gridItem.setState({ - body: panelManager.state.panel.clone(), - repeatDirection: panelManager.state.repeatDirection, - variableName: panelManager.state.repeat, - maxPerRow: panelManager.state.maxPerRow, - width, - height, - }); + public dashboardSaved() { + this.setOriginalState(this.state.panelRef); + this.setState({ isDirty: false }); } public onSaveLibraryPanel = () => { @@ -146,8 +234,8 @@ export class PanelEditor extends SceneObjectBase { }; public onConfirmSaveLibraryPanel = () => { - this.state.vizManager.commitChanges(); - this.state.vizManager.setState({ isDirty: false }); + saveLibPanel(this.state.panelRef.resolve()); + this.setState({ isDirty: false }); locationService.partial({ editPanel: null }); }; @@ -164,16 +252,43 @@ export class PanelEditor extends SceneObjectBase { }; public onConfirmUnlinkLibraryPanel = () => { - this.state.vizManager.unlinkLibraryPanel(); + const libPanelBehavior = getLibraryPanelBehavior(this.getPanel()); + if (!libPanelBehavior) { + return; + } + + libPanelBehavior.unlink(); + this.setState({ showLibraryPanelUnlinkModal: false }); }; + + public onToggleTableView = () => { + if (this.state.tableView) { + this.setState({ tableView: undefined }); + return; + } + + const panel = this.state.panelRef.resolve(); + const dataProvider = panel.state.$data; + if (!dataProvider) { + return; + } + + this.setState({ + tableView: PanelBuilders.table() + .setTitle('') + .setOption('showTypeIcons', true) + .setOption('showHeader', true) + .setData(new DataProviderSharer({ source: dataProvider.getRef() })) + .build(), + }); + }; } export function buildPanelEditScene(panel: VizPanel, isNewPanel = false): PanelEditor { return new PanelEditor({ - panelId: getPanelIdForVizPanel(panel), - optionsPane: new PanelOptionsPane({}), - vizManager: VizPanelManager.createFor(panel), + isInitializing: true, + panelRef: panel.getRef(), isNewPanel, }); } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx index 50f259d12ff..ae136dd08b7 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx @@ -2,8 +2,8 @@ import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { SceneComponentProps } from '@grafana/scenes'; -import { Button, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { SceneComponentProps, VizPanel } from '@grafana/scenes'; +import { Button, Spinner, ToolbarButton, useStyles2 } from '@grafana/ui'; import { NavToolbarActions } from '../scene/NavToolbarActions'; import { UnlinkModal } from '../scene/UnlinkModal'; @@ -54,7 +54,8 @@ export function PanelEditorRenderer({ model }: SceneComponentProps) />
)} - {!splitterState.collapsed && } + {!splitterState.collapsed && optionsPane && } + {!splitterState.collapsed && !optionsPane && } @@ -63,9 +64,9 @@ export function PanelEditorRenderer({ model }: SceneComponentProps) function VizAndDataPane({ model }: SceneComponentProps) { const dashboard = getDashboardSceneFor(model); - const { vizManager, dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal } = model.useState(); - const { sourcePanel } = vizManager.useState(); - const libraryPanel = getLibraryPanelBehavior(sourcePanel.resolve()); + const { dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal, tableView } = model.useState(); + const panel = model.getPanel(); + const libraryPanel = getLibraryPanelBehavior(panel); const { controls } = dashboard.useState(); const styles = useStyles2(getStyles); @@ -94,7 +95,7 @@ function VizAndDataPane({ model }: SceneComponentProps) { )}
- +
{showLibraryPanelSaveModal && libraryPanel && ( ) { ); } +interface VizWrapperProps { + panel: VizPanel; + tableView?: VizPanel; +} + +function VizWrapper({ panel, tableView }: VizWrapperProps) { + const styles = useStyles2(getStyles); + const panelToShow = tableView ?? panel; + + return ( +
+ +
+ ); +} + function getStyles(theme: GrafanaTheme2) { return { pageContainer: css({ @@ -215,5 +232,10 @@ function getStyles(theme: GrafanaTheme2) { rotate: '-90deg', }, }), + vizWrapper: css({ + height: '100%', + width: '100%', + paddingLeft: theme.spacing(2), + }), }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx index 619183a9dd2..cfba5a9c57f 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx @@ -18,7 +18,7 @@ import { activateFullSceneTree } from '../utils/test-utils'; import * as utils from '../utils/utils'; import { PanelOptions } from './PanelOptions'; -import { VizPanelManager } from './VizPanelManager'; +import { PanelOptionsPane } from './PanelOptionsPane'; const OptionsPaneSelector = selectors.components.PanelEditor.OptionsPane; @@ -92,43 +92,47 @@ function setup(options: SetupOptions = {}) { } // need to wait for plugin to load - const vizManager = VizPanelManager.createFor(panel); + const panelOptionsScene = new PanelOptionsPane({ + panelRef: panel.getRef(), + searchQuery: '', + listMode: OptionFilter.All, + }); - activateFullSceneTree(vizManager); - - const panelOptions = ; + activateFullSceneTree(panelOptionsScene); + panel.activate(); + const panelOptions = ; const renderResult = render(panelOptions); - return { renderResult, vizManager }; + return { renderResult, panelOptionsScene, panel }; } describe('PanelOptions', () => { describe('Can render and edit panel frame options', () => { it('Can edit title', async () => { - const { vizManager } = setup(); + const { panel } = setup(); expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('Panel options Title'))).toBeInTheDocument(); const input = screen.getByTestId(selectors.components.PanelEditor.OptionsPane.fieldInput('Title')); fireEvent.change(input, { target: { value: 'New title' } }); - expect(vizManager.state.panel.state.title).toBe('New title'); + expect(panel.state.title).toBe('New title'); }); it('Clearing title should set hoverHeader to true', async () => { - const { vizManager } = setup(); + const { panel } = setup(); expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('Panel options Title'))).toBeInTheDocument(); const input = screen.getByTestId(selectors.components.PanelEditor.OptionsPane.fieldInput('Title')); fireEvent.change(input, { target: { value: '' } }); - expect(vizManager.state.panel.state.title).toBe(''); - expect(vizManager.state.panel.state.hoverHeader).toBe(true); + expect(panel.state.title).toBe(''); + expect(panel.state.hoverHeader).toBe(true); fireEvent.change(input, { target: { value: 'Muu' } }); - expect(vizManager.state.panel.state.hoverHeader).toBe(false); + expect(panel.state.hoverHeader).toBe(false); }); }); @@ -179,13 +183,11 @@ describe('PanelOptions', () => { _loadedPanel: libraryPanelModel, }); - panel.setState({ - $behaviors: [libraryPanel], - }); + panel.setState({ $behaviors: [libraryPanel] }); new DashboardGridItem({ body: panel }); - const { renderResult, vizManager } = setup({ panel: panel }); + const { renderResult } = setup({ panel: panel }); const input = await renderResult.findByTestId('library panel name input'); @@ -193,8 +195,6 @@ describe('PanelOptions', () => { fireEvent.blur(input, { target: { value: 'new library panel name' } }); }); - expect((vizManager.state.panel.state.$behaviors![0] as LibraryPanelBehavior).state.name).toBe( - 'new library panel name' - ); + expect((panel.state.$behaviors![0] as LibraryPanelBehavior).state.name).toBe('new library panel name'); }); }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx index 01e4655d832..3428dbe707b 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx @@ -13,24 +13,23 @@ import { import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils'; -import { VizPanelManager } from './VizPanelManager'; import { getPanelFrameCategory2 } from './getPanelFrameOptions'; interface Props { - vizManager: VizPanelManager; + panel: VizPanel; searchQuery: string; listMode: OptionFilter; data?: PanelData; } -export const PanelOptions = React.memo(({ vizManager, searchQuery, listMode, data }) => { - const { panel, repeat } = vizManager.useState(); +export const PanelOptions = React.memo(({ panel, searchQuery, listMode, data }) => { const { options, fieldConfig, _pluginInstanceState } = panel.useState(); + const layoutElement = panel.parent!; + const layoutElementState = layoutElement.useState(); - // eslint-disable-next-line react-hooks/exhaustive-deps const panelFrameOptions = useMemo( - () => getPanelFrameCategory2(vizManager, panel, repeat), - [vizManager, panel, repeat] + () => getPanelFrameCategory2(panel, layoutElementState), + [panel, layoutElementState] ); const visualizationOptions = useMemo(() => { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx new file mode 100644 index 00000000000..a63717aa9f7 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx @@ -0,0 +1,101 @@ +import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; + +import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; +import { findVizPanelByKey } from '../utils/utils'; + +import { PanelOptionsPane } from './PanelOptionsPane'; +import { testDashboard } from './testfiles/testDashboard'; + +describe('PanelOptionsPane', () => { + describe('When changing plugin', () => { + it('Should set the cache', () => { + const { optionsPane, panel } = setupTest('panel-1'); + panel.changePluginType = jest.fn(); + + expect(panel.state.pluginId).toBe('timeseries'); + + optionsPane.onChangePanelPlugin({ pluginId: 'table' }); + + expect(optionsPane['_cachedPluginOptions']['timeseries']?.options).toBe(panel.state.options); + expect(optionsPane['_cachedPluginOptions']['timeseries']?.fieldConfig).toBe(panel.state.fieldConfig); + }); + + it('Should preserve correct field config', () => { + const { optionsPane, panel } = setupTest('panel-1'); + + const mockFn = jest.fn(); + panel.changePluginType = mockFn; + + const fieldConfig = panel.state.fieldConfig; + + fieldConfig.defaults = { + ...fieldConfig.defaults, + unit: 'flop', + decimals: 2, + }; + + fieldConfig.overrides = [ + { + matcher: { + id: 'byName', + options: 'A-series', + }, + properties: [ + { + id: 'displayName', + value: 'test', + }, + ], + }, + { + matcher: { id: 'byName', options: 'D-series' }, + //should be removed because it's custom + properties: [ + { + id: 'custom.customPropNoExist', + value: 'google', + }, + ], + }, + ]; + + panel.setState({ fieldConfig: fieldConfig }); + + expect(panel.state.fieldConfig.defaults.color?.mode).toBe('palette-classic'); + expect(panel.state.fieldConfig.defaults.thresholds?.mode).toBe('absolute'); + expect(panel.state.fieldConfig.defaults.unit).toBe('flop'); + expect(panel.state.fieldConfig.defaults.decimals).toBe(2); + expect(panel.state.fieldConfig.overrides).toHaveLength(2); + expect(panel.state.fieldConfig.overrides[1].properties).toHaveLength(1); + expect(panel.state.fieldConfig.defaults.custom).toHaveProperty('axisBorderShow'); + + optionsPane.onChangePanelPlugin({ pluginId: 'table' }); + + expect(mockFn).toHaveBeenCalled(); + expect(mockFn.mock.calls[0][2].defaults.color?.mode).toBe('palette-classic'); + expect(mockFn.mock.calls[0][2].defaults.thresholds?.mode).toBe('absolute'); + expect(mockFn.mock.calls[0][2].defaults.unit).toBe('flop'); + expect(mockFn.mock.calls[0][2].defaults.decimals).toBe(2); + expect(mockFn.mock.calls[0][2].overrides).toHaveLength(2); + //removed custom property + expect(mockFn.mock.calls[0][2].overrides[1].properties).toHaveLength(0); + //removed fieldConfig custom values as well + expect(mockFn.mock.calls[0][2].defaults.custom).toStrictEqual({}); + }); + }); +}); + +function setupTest(panelId: string) { + const scene = transformSaveModelToScene({ dashboard: testDashboard, meta: {} }); + const panel = findVizPanelByKey(scene, panelId)!; + + const optionsPane = new PanelOptionsPane({ panelRef: panel.getRef(), listMode: OptionFilter.All, searchQuery: '' }); + + // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it + // @ts-expect-error + getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); + + return { optionsPane, scene, panel }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx index 5effa56b8f8..3b100b15b83 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx @@ -1,15 +1,30 @@ import { css } from '@emotion/css'; import { useMemo } from 'react'; -import { GrafanaTheme2, PanelPluginMeta } from '@grafana/data'; +import { + FieldConfigSource, + filterFieldConfigOverrides, + GrafanaTheme2, + isStandardFieldProp, + PanelPluginMeta, + restoreCustomOverrideRules, +} from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState, sceneGraph } from '@grafana/scenes'; +import { + DeepPartial, + SceneComponentProps, + SceneObjectBase, + SceneObjectRef, + SceneObjectState, + VizPanel, + sceneGraph, +} from '@grafana/scenes'; import { FilterInput, Stack, ToolbarButton, useStyles2 } from '@grafana/ui'; import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; import { getPanelPluginNotFound } from 'app/features/panel/components/PanelPluginError'; +import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicker/types'; import { getAllPanelPluginMeta } from 'app/features/panel/state/util'; -import { PanelEditor } from './PanelEditor'; import { PanelOptions } from './PanelOptions'; import { PanelVizTypePicker } from './PanelVizTypePicker'; @@ -17,21 +32,48 @@ export interface PanelOptionsPaneState extends SceneObjectState { isVizPickerOpen?: boolean; searchQuery: string; listMode: OptionFilter; + panelRef: SceneObjectRef; +} + +interface PluginOptionsCache { + options: DeepPartial<{}>; + fieldConfig: FieldConfigSource>; } export class PanelOptionsPane extends SceneObjectBase { - public constructor(state: Partial) { - super({ - searchQuery: '', - listMode: OptionFilter.All, - ...state, - }); - } + private _cachedPluginOptions: Record = {}; onToggleVizPicker = () => { this.setState({ isVizPickerOpen: !this.state.isVizPickerOpen }); }; + onChangePanelPlugin = (options: VizTypeChangeDetails) => { + const panel = this.state.panelRef.resolve(); + const { options: prevOptions, fieldConfig: prevFieldConfig, pluginId: prevPluginId } = panel.state; + const pluginId = options.pluginId; + + // clear custom options + let newFieldConfig: FieldConfigSource = { + defaults: { + ...prevFieldConfig.defaults, + custom: {}, + }, + overrides: filterFieldConfigOverrides(prevFieldConfig.overrides, isStandardFieldProp), + }; + + this._cachedPluginOptions[prevPluginId] = { options: prevOptions, fieldConfig: prevFieldConfig }; + + const cachedOptions = this._cachedPluginOptions[pluginId]?.options; + const cachedFieldConfig = this._cachedPluginOptions[pluginId]?.fieldConfig; + + if (cachedFieldConfig) { + newFieldConfig = restoreCustomOverrideRules(newFieldConfig, cachedFieldConfig); + } + + panel.changePluginType(pluginId, cachedOptions, newFieldConfig); + this.onToggleVizPicker(); + }; + onSetSearchQuery = (searchQuery: string) => { this.setState({ searchQuery }); }; @@ -41,10 +83,10 @@ export class PanelOptionsPane extends SceneObjectBase { }; static Component = ({ model }: SceneComponentProps) => { - const { isVizPickerOpen, searchQuery, listMode } = model.useState(); - const vizManager = sceneGraph.getAncestor(model, PanelEditor).state.vizManager; - const { pluginId } = vizManager.useState(); - const { data } = sceneGraph.getData(vizManager.state.panel).useState(); + const { isVizPickerOpen, searchQuery, listMode, panelRef } = model.useState(); + const panel = panelRef.resolve(); + const { pluginId } = panel.useState(); + const { data } = sceneGraph.getData(panel).useState(); const styles = useStyles2(getStyles); return ( @@ -61,12 +103,17 @@ export class PanelOptionsPane extends SceneObjectBase { />
- +
)} {isVizPickerOpen && ( - + )} ); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx index 0da983abab6..2e180642bc9 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx @@ -4,6 +4,7 @@ import { useLocalStorage } from 'react-use'; import { GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { VizPanel } from '@grafana/scenes'; import { Button, CustomScrollbar, Field, FilterInput, RadioButtonGroup, useStyles2 } from '@grafana/ui'; import { LS_VISUALIZATION_SELECT_TAB_KEY, LS_WIDGET_SELECT_TAB_KEY } from 'app/core/constants'; import { VisualizationSelectPaneTab } from 'app/features/dashboard/components/PanelEditor/types'; @@ -13,16 +14,14 @@ import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicke import { PanelModelCompatibilityWrapper } from '../utils/PanelModelCompatibilityWrapper'; -import { VizPanelManager } from './VizPanelManager'; - export interface Props { data?: PanelData; - vizManager: VizPanelManager; - onChange: () => void; + panel: VizPanel; + onChange: (options: VizTypeChangeDetails) => void; + onClose: () => void; } -export function PanelVizTypePicker({ vizManager, data, onChange }: Props) { - const { panel } = vizManager.useState(); +export function PanelVizTypePicker({ panel, data, onChange, onClose }: Props) { const styles = useStyles2(getStyles); const [searchQuery, setSearchQuery] = useState(''); @@ -50,22 +49,8 @@ export function PanelVizTypePicker({ vizManager, data, onChange }: Props) { const radioOptions: Array> = [ { label: 'Visualizations', value: VisualizationSelectPaneTab.Visualizations }, { label: 'Suggestions', value: VisualizationSelectPaneTab.Suggestions }, - // { - // label: 'Library panels', - // value: VisualizationSelectPaneTab.LibraryPanels, - // description: 'Reusable panels you can share between multiple dashboards.', - // }, ]; - const onVizTypeChange = (options: VizTypeChangeDetails) => { - vizManager.changePluginType(options.pluginId); - onChange(); - }; - - const onCloseVizPicker = () => { - onChange(); - }; - return (
@@ -82,7 +67,7 @@ export function PanelVizTypePicker({ vizManager, data, onChange }: Props) { icon="angle-up" className={styles.closeButton} data-testid={selectors.components.PanelEditor.toggleVizPicker} - onClick={onCloseVizPicker} + onClick={onClose} />
@@ -90,18 +75,10 @@ export function PanelVizTypePicker({ vizManager, data, onChange }: Props) { {listMode === VisualizationSelectPaneTab.Visualizations && ( - + )} - {/* {listMode === VisualizationSelectPaneTab.Widgets && ( - - )} */} {listMode === VisualizationSelectPaneTab.Suggestions && ( - + )}
diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx deleted file mode 100644 index 887c876bf70..00000000000 --- a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx +++ /dev/null @@ -1,864 +0,0 @@ -import { map, of } from 'rxjs'; - -import { DataQueryRequest, DataSourceApi, DataSourceInstanceSettings, LoadingState, PanelData } from '@grafana/data'; -import { calculateFieldTransformer } from '@grafana/data/src/transformations/transformers/calculateField'; -import { mockTransformationsRegistry } from '@grafana/data/src/utils/tests/mockTransformationsRegistry'; -import { config, locationService } from '@grafana/runtime'; -import { - CustomVariable, - LocalValueVariable, - SceneGridRow, - SceneVariableSet, - VizPanel, - sceneGraph, -} from '@grafana/scenes'; -import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema'; -import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; -import { InspectTab } from 'app/features/inspector/types'; -import * as libAPI from 'app/features/library-panels/state/api'; -import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; -import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; - -import { DashboardGridItem } from '../scene/DashboardGridItem'; -import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; -import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange'; -import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; -import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; -import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; -import { findVizPanelByKey } from '../utils/utils'; - -import { buildPanelEditScene } from './PanelEditor'; -import { VizPanelManager } from './VizPanelManager'; -import { panelWithQueriesOnly, panelWithTransformations, testDashboard } from './testfiles/testDashboard'; - -const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { - const result: PanelData = { - state: LoadingState.Loading, - series: [], - timeRange: request.range, - }; - - return of([]).pipe( - map(() => { - result.state = LoadingState.Done; - result.series = []; - - return result; - }) - ); -}); - -const ds1Mock: DataSourceApi = { - meta: { - id: 'grafana-testdata-datasource', - }, - name: 'grafana-testdata-datasource', - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', - getRef: () => { - return { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }; - }, -} as DataSourceApi; - -const ds2Mock: DataSourceApi = { - meta: { - id: 'grafana-prometheus-datasource', - }, - name: 'grafana-prometheus-datasource', - type: 'grafana-prometheus-datasource', - uid: 'gdev-prometheus', - getRef: () => { - return { type: 'grafana-prometheus-datasource', uid: 'gdev-prometheus' }; - }, -} as DataSourceApi; - -const ds3Mock: DataSourceApi = { - meta: { - id: DASHBOARD_DATASOURCE_PLUGIN_ID, - }, - name: SHARED_DASHBOARD_QUERY, - type: SHARED_DASHBOARD_QUERY, - uid: SHARED_DASHBOARD_QUERY, - getRef: () => { - return { type: SHARED_DASHBOARD_QUERY, uid: SHARED_DASHBOARD_QUERY }; - }, -} as DataSourceApi; - -const defaultDsMock: DataSourceApi = { - meta: { - id: 'grafana-testdata-datasource', - }, - name: 'grafana-testdata-datasource', - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', - getRef: () => { - return { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }; - }, -} as DataSourceApi; - -const instance1SettingsMock = { - id: 1, - uid: 'gdev-testdata', - name: 'testDs1', - type: 'grafana-testdata-datasource', - meta: { - id: 'grafana-testdata-datasource', - }, -}; - -const instance2SettingsMock = { - id: 1, - uid: 'gdev-prometheus', - name: 'testDs2', - type: 'grafana-prometheus-datasource', - meta: { - id: 'grafana-prometheus-datasource', - }, -}; - -// Mocking the build in Grafana data source to avoid annotations data layer errors. -const grafanaDs = { - id: 1, - uid: '-- Grafana --', - name: 'grafana', - type: 'grafana', - meta: { - id: 'grafana', - }, -}; - -// Mock the store module -jest.mock('app/core/store', () => ({ - exists: jest.fn(), - get: jest.fn(), - getObject: jest.fn((_a, b) => b), - setObject: jest.fn(), -})); - -const store = jest.requireMock('app/core/store'); - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { - return runRequestMock(ds, request); - }, - getDataSourceSrv: () => ({ - get: async (ref: DataSourceRef) => { - // Mocking the build in Grafana data source to avoid annotations data layer errors. - - if (ref.uid === '-- Grafana --') { - return grafanaDs; - } - - if (ref.uid === 'gdev-testdata') { - return ds1Mock; - } - - if (ref.uid === 'gdev-prometheus') { - return ds2Mock; - } - - if (ref.uid === SHARED_DASHBOARD_QUERY) { - return ds3Mock; - } - - // if datasource is not found, return default datasource - return defaultDsMock; - }, - getInstanceSettings: (ref: DataSourceRef) => { - if (ref.uid === 'gdev-testdata') { - return instance1SettingsMock; - } - - if (ref.uid === 'gdev-prometheus') { - return instance2SettingsMock; - } - - // if datasource is not found, return default instance settings - return instance1SettingsMock; - }, - }), - locationService: { - partial: jest.fn(), - }, - config: { - ...jest.requireActual('@grafana/runtime').config, - defaultDatasource: 'gdev-testdata', - }, -})); - -mockTransformationsRegistry([calculateFieldTransformer]); - -jest.useFakeTimers(); - -describe('VizPanelManager', () => { - describe('When changing plugin', () => { - it('Should set the cache', () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.state.panel.changePluginType = jest.fn(); - - expect(vizPanelManager.state.panel.state.pluginId).toBe('timeseries'); - - vizPanelManager.changePluginType('table'); - - expect(vizPanelManager['_cachedPluginOptions']['timeseries']?.options).toBe( - vizPanelManager.state.panel.state.options - ); - expect(vizPanelManager['_cachedPluginOptions']['timeseries']?.fieldConfig).toBe( - vizPanelManager.state.panel.state.fieldConfig - ); - }); - - it('Should preserve correct field config', () => { - const { vizPanelManager } = setupTest('panel-1'); - const mockFn = jest.fn(); - vizPanelManager.state.panel.changePluginType = mockFn; - const fieldConfig = vizPanelManager.state.panel.state.fieldConfig; - fieldConfig.defaults = { - ...fieldConfig.defaults, - unit: 'flop', - decimals: 2, - }; - fieldConfig.overrides = [ - { - matcher: { - id: 'byName', - options: 'A-series', - }, - properties: [ - { - id: 'displayName', - value: 'test', - }, - ], - }, - { - matcher: { id: 'byName', options: 'D-series' }, - //should be removed because it's custom - properties: [ - { - id: 'custom.customPropNoExist', - value: 'google', - }, - ], - }, - ]; - vizPanelManager.state.panel.setState({ - fieldConfig: fieldConfig, - }); - - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.color?.mode).toBe('palette-classic'); - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.thresholds?.mode).toBe('absolute'); - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.unit).toBe('flop'); - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.decimals).toBe(2); - expect(vizPanelManager.state.panel.state.fieldConfig.overrides).toHaveLength(2); - expect(vizPanelManager.state.panel.state.fieldConfig.overrides[1].properties).toHaveLength(1); - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.custom).toHaveProperty('axisBorderShow'); - - vizPanelManager.changePluginType('table'); - - expect(mockFn).toHaveBeenCalled(); - expect(mockFn.mock.calls[0][2].defaults.color?.mode).toBe('palette-classic'); - expect(mockFn.mock.calls[0][2].defaults.thresholds?.mode).toBe('absolute'); - expect(mockFn.mock.calls[0][2].defaults.unit).toBe('flop'); - expect(mockFn.mock.calls[0][2].defaults.decimals).toBe(2); - expect(mockFn.mock.calls[0][2].overrides).toHaveLength(2); - //removed custom property - expect(mockFn.mock.calls[0][2].overrides[1].properties).toHaveLength(0); - //removed fieldConfig custom values as well - expect(mockFn.mock.calls[0][2].defaults.custom).toStrictEqual({}); - }); - }); - - describe('library panels', () => { - it('saves library panels on commit', () => { - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); - - const libraryPanelModel = { - title: 'title', - uid: 'uid', - name: 'libraryPanelName', - model: vizPanelToPanel(panel), - type: 'panel', - version: 1, - }; - - const libPanelBehavior = new LibraryPanelBehavior({ - isLoaded: true, - title: libraryPanelModel.title, - uid: libraryPanelModel.uid, - name: libraryPanelModel.name, - _loadedPanel: libraryPanelModel, - }); - - panel.setState({ - $behaviors: [libPanelBehavior], - }); - - new DashboardGridItem({ body: panel }); - - const panelManager = VizPanelManager.createFor(panel); - - const apiCall = jest.spyOn(libAPI, 'saveLibPanel'); - - panelManager.state.panel.setState({ title: 'new title' }); - panelManager.commitChanges(); - - expect(apiCall.mock.calls[0][0].state.title).toBe('new title'); - }); - - it('unlinks library panel', () => { - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); - - const libraryPanelModel = { - title: 'title', - uid: 'uid', - name: 'libraryPanelName', - model: vizPanelToPanel(panel), - type: 'panel', - version: 1, - }; - - const libPanelBehavior = new LibraryPanelBehavior({ - isLoaded: true, - title: libraryPanelModel.title, - uid: libraryPanelModel.uid, - name: libraryPanelModel.name, - _loadedPanel: libraryPanelModel, - }); - - panel.setState({ - $behaviors: [libPanelBehavior], - }); - - new DashboardGridItem({ body: panel }); - - const panelManager = VizPanelManager.createFor(panel); - panelManager.unlinkLibraryPanel(); - - const sourcePanel = panelManager.state.sourcePanel.resolve(); - expect(sourcePanel.state.$behaviors).toBe(undefined); - }); - }); - - describe('query options', () => { - beforeEach(() => { - store.setObject.mockClear(); - }); - - describe('activation', () => { - it('should load data source', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.state.datasource).toEqual(ds1Mock); - expect(vizPanelManager.state.dsSettings).toEqual(instance1SettingsMock); - }); - - it('should store loaded data source in local storage', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(store.setObject).toHaveBeenCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', { - dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', - datasourceUid: 'gdev-testdata', - }); - }); - - it('should load default datasource if the datasource passed is not found', async () => { - const { vizPanelManager } = setupTest('panel-6'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'abc', - type: 'datasource', - }); - - expect(config.defaultDatasource).toBe('gdev-testdata'); - expect(vizPanelManager.state.datasource).toEqual(defaultDsMock); - expect(vizPanelManager.state.dsSettings).toEqual(instance1SettingsMock); - }); - }); - - describe('data source change', () => { - it('should load new data source', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - - await Promise.resolve(); - - await vizPanelManager.changePanelDataSource( - { type: 'grafana-prometheus-datasource', uid: 'gdev-prometheus' } as DataSourceInstanceSettings, - [] - ); - - expect(store.setObject).toHaveBeenCalledTimes(2); - expect(store.setObject).toHaveBeenLastCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', { - dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', - datasourceUid: 'gdev-prometheus', - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.state.datasource).toEqual(ds2Mock); - expect(vizPanelManager.state.dsSettings).toEqual(instance2SettingsMock); - }); - }); - - describe('query options change', () => { - describe('time overrides', () => { - it('should create PanelTimeRange object', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - await Promise.resolve(); - - const panel = vizPanelManager.state.panel; - - expect(panel.state.$timeRange).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: '1h', - }, - }); - - expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); - }); - it('should update PanelTimeRange object on time options update', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const panel = vizPanelManager.state.panel; - - expect(panel.state.$timeRange).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: '1h', - }, - }); - - expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); - expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('1h'); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: '2h', - }, - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('2h'); - }); - - it('should remove PanelTimeRange object on time options cleared', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const panel = vizPanelManager.state.panel; - - expect(panel.state.$timeRange).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: '1h', - }, - }); - - expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: null, - }, - }); - - expect(panel.state.$timeRange).toBeUndefined(); - }); - }); - - describe('max data points and interval', () => { - it('should update max data points', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const dataObj = vizPanelManager.queryRunner; - - expect(dataObj.state.maxDataPoints).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - maxDataPoints: 100, - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(dataObj.state.maxDataPoints).toBe(100); - }); - - it('should update min interval', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const dataObj = vizPanelManager.queryRunner; - - expect(dataObj.state.maxDataPoints).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - minInterval: '1s', - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(dataObj.state.minInterval).toBe('1s'); - }); - }); - - describe('query caching', () => { - it('updates cacheTimeout and queryCachingTTL', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const dataObj = vizPanelManager.queryRunner; - - vizPanelManager.changeQueryOptions({ - cacheTimeout: '60', - queryCachingTTL: 200000, - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(dataObj.state.cacheTimeout).toBe('60'); - expect(dataObj.state.queryCachingTTL).toBe(200000); - }); - }); - }); - - describe('query inspection', () => { - it('allows query inspection from the tab', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.inspectPanel(); - - expect(locationService.partial).toHaveBeenCalledWith({ inspect: 1, inspectTab: InspectTab.Query }); - }); - }); - - describe('data source change', () => { - it('changing from one plugin to another', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'gdev-testdata', - type: 'grafana-testdata-datasource', - }); - - await vizPanelManager.changePanelDataSource({ - name: 'grafana-prometheus', - type: 'grafana-prometheus-datasource', - uid: 'gdev-prometheus', - meta: { - name: 'Prometheus', - module: 'prometheus', - id: 'grafana-prometheus-datasource', - }, - } as DataSourceInstanceSettings); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'gdev-prometheus', - type: 'grafana-prometheus-datasource', - }); - }); - - it('changing from a plugin to a dashboard data source', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'gdev-testdata', - type: 'grafana-testdata-datasource', - }); - - await vizPanelManager.changePanelDataSource({ - name: SHARED_DASHBOARD_QUERY, - type: 'datasource', - uid: SHARED_DASHBOARD_QUERY, - meta: { - name: 'Prometheus', - module: 'prometheus', - id: DASHBOARD_DATASOURCE_PLUGIN_ID, - }, - } as DataSourceInstanceSettings); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: SHARED_DASHBOARD_QUERY, - type: 'datasource', - }); - }); - - it('changing from dashboard data source to a plugin', async () => { - const { vizPanelManager } = setupTest('panel-3'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: SHARED_DASHBOARD_QUERY, - type: 'datasource', - }); - - await vizPanelManager.changePanelDataSource({ - name: 'grafana-prometheus', - type: 'grafana-prometheus-datasource', - uid: 'gdev-prometheus', - meta: { - name: 'Prometheus', - module: 'prometheus', - id: 'grafana-prometheus-datasource', - }, - } as DataSourceInstanceSettings); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'gdev-prometheus', - type: 'grafana-prometheus-datasource', - }); - }); - }); - }); - - describe('change transformations', () => { - it('should update and reprocess transformations', () => { - const { scene, panel } = setupTest('panel-3'); - scene.setState({ editPanel: buildPanelEditScene(panel) }); - - const vizPanelManager = scene.state.editPanel!.state.vizManager; - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - - const reprocessMock = jest.fn(); - vizPanelManager.dataTransformer.reprocessTransformations = reprocessMock; - vizPanelManager.changeTransformations([{ id: 'calculateField', options: {} }]); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(reprocessMock).toHaveBeenCalledTimes(1); - expect(vizPanelManager.dataTransformer.state.transformations).toEqual([{ id: 'calculateField', options: {} }]); - }); - }); - - describe('change queries', () => { - describe('plugin queries', () => { - it('should update queries', () => { - const { vizPanelManager } = setupTest('panel-1'); - - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - - vizPanelManager.changeQueries([ - { - datasource: { - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', - }, - refId: 'A', - scenarioId: 'random_walk', - seriesCount: 5, - }, - ]); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.queries).toEqual([ - { - datasource: { - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', - }, - refId: 'A', - scenarioId: 'random_walk', - seriesCount: 5, - }, - ]); - }); - }); - - describe('dashboard queries', () => { - it('should update queries', () => { - const { scene, panel } = setupTest('panel-3'); - scene.setState({ editPanel: buildPanelEditScene(panel) }); - - const vizPanelManager = scene.state.editPanel!.state.vizManager; - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - - // Changing dashboard query to a panel with transformations - vizPanelManager.changeQueries([ - { - refId: 'A', - datasource: { - type: DASHBOARD_DATASOURCE_PLUGIN_ID, - }, - panelId: panelWithTransformations.id, - }, - ]); - - expect(vizPanelManager.queryRunner.state.queries[0].panelId).toEqual(panelWithTransformations.id); - - // Changing dashboard query to a panel with queries only - vizPanelManager.changeQueries([ - { - refId: 'A', - datasource: { - type: DASHBOARD_DATASOURCE_PLUGIN_ID, - }, - panelId: panelWithQueriesOnly.id, - }, - ]); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.queries[0].panelId).toBe(panelWithQueriesOnly.id); - }); - }); - }); - - it('should load last used data source if no data source specified for a panel', async () => { - store.exists.mockReturnValue(true); - store.getObject.mockReturnValue({ - dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', - datasourceUid: 'gdev-testdata', - }); - const { scene, panel } = setupTest('panel-5'); - scene.setState({ editPanel: buildPanelEditScene(panel) }); - - const vizPanelManager = scene.state.editPanel!.state.vizManager; - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.state.datasource).toEqual(ds1Mock); - expect(vizPanelManager.state.dsSettings).toEqual(instance1SettingsMock); - }); - - it('Should default to the first variable value if panel is repeated', async () => { - const { scene, panel } = setupTest('panel-10'); - - scene.setState({ - $variables: new SceneVariableSet({ - variables: [ - new CustomVariable({ name: 'custom', query: 'A,B,C', value: ['A', 'B', 'C'], text: ['A', 'B', 'C'] }), - ], - }), - }); - - scene.setState({ editPanel: buildPanelEditScene(panel) }); - - const vizPanelManager = scene.state.editPanel!.state.vizManager; - vizPanelManager.activate(); - - const variable = sceneGraph.lookupVariable('custom', vizPanelManager); - expect(variable?.getValue()).toBe('A'); - }); - - describe('Given a panel inside repeated row', () => { - it('Should include row variable scope', () => { - const { panel } = setupTest('panel-9'); - - const row = panel.parent?.parent; - if (!(row instanceof SceneGridRow)) { - throw new Error('Did not find parent row'); - } - - row.setState({ - $variables: new SceneVariableSet({ variables: [new LocalValueVariable({ name: 'hello', value: 'A' })] }), - }); - - const editor = buildPanelEditScene(panel); - const variable = sceneGraph.lookupVariable('hello', editor.state.vizManager); - expect(variable?.getValue()).toBe('A'); - }); - }); -}); - -const setupTest = (panelId: string) => { - const scene = transformSaveModelToScene({ dashboard: testDashboard, meta: {} }); - - const panel = findVizPanelByKey(scene, panelId)!; - - const vizPanelManager = VizPanelManager.createFor(panel); - // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it - // @ts-expect-error - getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); - - return { vizPanelManager, scene, panel }; -}; diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx deleted file mode 100644 index c27ed98146c..00000000000 --- a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx +++ /dev/null @@ -1,504 +0,0 @@ -import { css } from '@emotion/css'; -import { debounce } from 'lodash'; -import { useEffect } from 'react'; - -import { - DataSourceApi, - DataSourceInstanceSettings, - FieldConfigSource, - GrafanaTheme2, - filterFieldConfigOverrides, - getDataSourceRef, - isStandardFieldProp, - restoreCustomOverrideRules, -} from '@grafana/data'; -import { config, getDataSourceSrv, locationService } from '@grafana/runtime'; -import { - DeepPartial, - LocalValueVariable, - MultiValueVariable, - PanelBuilders, - SceneComponentProps, - SceneDataTransformer, - SceneObjectBase, - SceneObjectRef, - SceneObjectState, - SceneObjectStateChangedEvent, - SceneQueryRunner, - SceneVariableSet, - SceneVariables, - VizPanel, - sceneGraph, -} from '@grafana/scenes'; -import { DataQuery, DataTransformerConfig, Panel } from '@grafana/schema'; -import { useStyles2 } from '@grafana/ui'; -import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard'; -import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils'; -import { saveLibPanel } from 'app/features/library-panels/state/api'; -import { updateQueries } from 'app/features/query/state/updateQueries'; -import { GrafanaQuery } from 'app/plugins/datasource/grafana/types'; -import { QueryGroupOptions } from 'app/types'; - -import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; -import { getPanelChanges } from '../saving/getDashboardChanges'; -import { DashboardGridItem, RepeatDirection } from '../scene/DashboardGridItem'; -import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange'; -import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; -import { - getDashboardSceneFor, - getMultiVariableValues, - getPanelIdForVizPanel, - getQueryRunnerFor, - isLibraryPanel, -} from '../utils/utils'; - -export interface VizPanelManagerState extends SceneObjectState { - panel: VizPanel; - sourcePanel: SceneObjectRef; - pluginId: string; - datasource?: DataSourceApi; - dsSettings?: DataSourceInstanceSettings; - tableView?: VizPanel; - repeat?: string; - repeatDirection?: RepeatDirection; - maxPerRow?: number; - isDirty?: boolean; -} - -export enum DisplayMode { - Fill = 0, - Fit = 1, - Exact = 2, -} - -// VizPanelManager serves as an API to manipulate VizPanel state from the outside. It allows panel type, options and data manipulation. -export class VizPanelManager extends SceneObjectBase { - private _cachedPluginOptions: Record< - string, - { options: DeepPartial<{}>; fieldConfig: FieldConfigSource> } | undefined - > = {}; - - public constructor(state: VizPanelManagerState) { - super(state); - this.addActivationHandler(() => this._onActivate()); - } - - /** - * Will clone the source panel and move the data provider to - * live on the VizPanelManager level instead of the VizPanel level - */ - public static createFor(sourcePanel: VizPanel) { - let repeatOptions: Pick = {}; - - const gridItem = sourcePanel.parent; - - if (!(gridItem instanceof DashboardGridItem)) { - console.error('VizPanel is not a child of a dashboard grid item'); - throw new Error('VizPanel is not a child of a dashboard grid item'); - } - - const { variableName: repeat, repeatDirection, maxPerRow } = gridItem.state; - repeatOptions = { repeat, repeatDirection, maxPerRow }; - - let variables: SceneVariables | undefined; - - if (gridItem.parent?.state.$variables) { - variables = gridItem.parent.state.$variables.clone(); - } - - if (repeatOptions.repeat) { - const variable = sceneGraph.lookupVariable(repeatOptions.repeat, gridItem); - - if (variable instanceof MultiValueVariable && variable.state.value.length) { - const { values, texts } = getMultiVariableValues(variable); - - const varWithDefaultValue = new LocalValueVariable({ - name: variable.state.name, - value: values[0], - text: String(texts[0]), - }); - - if (!variables) { - variables = new SceneVariableSet({ - variables: [varWithDefaultValue], - }); - } else { - variables.setState({ variables: [varWithDefaultValue] }); - } - } - } - - return new VizPanelManager({ - $variables: variables, - panel: sourcePanel.clone(), - sourcePanel: sourcePanel.getRef(), - pluginId: sourcePanel.state.pluginId, - ...repeatOptions, - }); - } - - private _onActivate() { - this.loadDataSource(); - const changesSub = this.subscribeToEvent(SceneObjectStateChangedEvent, this._handleStateChange); - - return () => { - changesSub.unsubscribe(); - }; - } - - private _detectPanelModelChanges = debounce(() => { - const { hasChanges } = getPanelChanges( - vizPanelToPanel(this.state.sourcePanel.resolve().clone({ $behaviors: undefined })), - vizPanelToPanel(this.state.panel.clone({ $behaviors: undefined })) - ); - this.setState({ isDirty: hasChanges }); - }, 250); - - private _handleStateChange = (event: SceneObjectStateChangedEvent) => { - if (DashboardSceneChangeTracker.isUpdatingPersistedState(event)) { - this._detectPanelModelChanges(); - } - }; - - private async loadDataSource() { - const dataObj = this.state.panel.state.$data; - - if (!dataObj) { - return; - } - - let datasourceToLoad = this.queryRunner.state.datasource; - - try { - let datasource: DataSourceApi | undefined; - let dsSettings: DataSourceInstanceSettings | undefined; - - if (!datasourceToLoad) { - const dashboardScene = getDashboardSceneFor(this); - const dashboardUid = dashboardScene.state.uid ?? ''; - const lastUsedDatasource = getLastUsedDatasourceFromStorage(dashboardUid!); - - // do we have a last used datasource for this dashboard - if (lastUsedDatasource?.datasourceUid !== null) { - // get datasource from dashbopard uid - dsSettings = getDataSourceSrv().getInstanceSettings({ uid: lastUsedDatasource?.datasourceUid }); - if (dsSettings) { - datasource = await getDataSourceSrv().get({ - uid: lastUsedDatasource?.datasourceUid, - type: dsSettings.type, - }); - - this.queryRunner.setState({ - datasource: { - ...getDataSourceRef(dsSettings), - uid: lastUsedDatasource?.datasourceUid, - }, - }); - } - } - } else { - datasource = await getDataSourceSrv().get(datasourceToLoad); - dsSettings = getDataSourceSrv().getInstanceSettings(datasourceToLoad); - } - - if (datasource && dsSettings) { - this.setState({ datasource, dsSettings }); - - storeLastUsedDataSourceInLocalStorage(getDataSourceRef(dsSettings) || { default: true }); - } - } catch (err) { - //set default datasource if we fail to load the datasource - const datasource = await getDataSourceSrv().get(config.defaultDatasource); - const dsSettings = getDataSourceSrv().getInstanceSettings(config.defaultDatasource); - - if (datasource && dsSettings) { - this.setState({ - datasource, - dsSettings, - }); - - this.queryRunner.setState({ - datasource: getDataSourceRef(dsSettings), - }); - } - - console.error(err); - } - } - - public changePluginType(pluginId: string) { - const { options: prevOptions, fieldConfig: prevFieldConfig, pluginId: prevPluginId } = this.state.panel.state; - - // clear custom options - let newFieldConfig: FieldConfigSource = { - defaults: { - ...prevFieldConfig.defaults, - custom: {}, - }, - overrides: filterFieldConfigOverrides(prevFieldConfig.overrides, isStandardFieldProp), - }; - - this._cachedPluginOptions[prevPluginId] = { options: prevOptions, fieldConfig: prevFieldConfig }; - - const cachedOptions = this._cachedPluginOptions[pluginId]?.options; - const cachedFieldConfig = this._cachedPluginOptions[pluginId]?.fieldConfig; - - if (cachedFieldConfig) { - newFieldConfig = restoreCustomOverrideRules(newFieldConfig, cachedFieldConfig); - } - - // When changing from non-data to data panel, we need to add a new data provider - if (!this.state.panel.state.$data && !config.panels[pluginId].skipDataQuery) { - let ds = getLastUsedDatasourceFromStorage(getDashboardSceneFor(this).state.uid!)?.datasourceUid; - - if (!ds) { - ds = config.defaultDatasource; - } - - this.state.panel.setState({ - $data: new SceneDataTransformer({ - $data: new SceneQueryRunner({ - datasource: { - uid: ds, - }, - queries: [{ refId: 'A' }], - }), - transformations: [], - }), - }); - } - - this.setState({ pluginId }); - this.state.panel.changePluginType(pluginId, cachedOptions, newFieldConfig); - - this.loadDataSource(); - } - - public async changePanelDataSource( - newSettings: DataSourceInstanceSettings, - defaultQueries?: DataQuery[] | GrafanaQuery[] - ) { - const { dsSettings } = this.state; - const queryRunner = this.queryRunner; - - const currentDS = dsSettings ? await getDataSourceSrv().get({ uid: dsSettings.uid }) : undefined; - const nextDS = await getDataSourceSrv().get({ uid: newSettings.uid }); - - const currentQueries = queryRunner.state.queries; - - // We need to pass in newSettings.uid as well here as that can be a variable expression and we want to store that in the query model not the current ds variable value - const queries = defaultQueries || (await updateQueries(nextDS, newSettings.uid, currentQueries, currentDS)); - - queryRunner.setState({ - datasource: getDataSourceRef(newSettings), - queries, - }); - if (defaultQueries) { - queryRunner.runQueries(); - } - - this.loadDataSource(); - } - - public changeQueryOptions(options: QueryGroupOptions) { - const panelObj = this.state.panel; - const dataObj = this.queryRunner; - const timeRangeObj = panelObj.state.$timeRange; - - const dataObjStateUpdate: Partial = {}; - const timeRangeObjStateUpdate: Partial = {}; - - if (options.maxDataPoints !== dataObj.state.maxDataPoints) { - dataObjStateUpdate.maxDataPoints = options.maxDataPoints ?? undefined; - } - - if (options.minInterval !== dataObj.state.minInterval && options.minInterval !== null) { - dataObjStateUpdate.minInterval = options.minInterval; - } - - if (options.timeRange) { - timeRangeObjStateUpdate.timeFrom = options.timeRange.from ?? undefined; - timeRangeObjStateUpdate.timeShift = options.timeRange.shift ?? undefined; - timeRangeObjStateUpdate.hideTimeOverride = options.timeRange.hide; - } - - if (timeRangeObj instanceof PanelTimeRange) { - if (timeRangeObjStateUpdate.timeFrom !== undefined || timeRangeObjStateUpdate.timeShift !== undefined) { - // update time override - timeRangeObj.setState(timeRangeObjStateUpdate); - } else { - // remove time override - panelObj.setState({ $timeRange: undefined }); - } - } else { - // no time override present on the panel, let's create one first - panelObj.setState({ $timeRange: new PanelTimeRange(timeRangeObjStateUpdate) }); - } - - if (options.cacheTimeout !== dataObj?.state.cacheTimeout) { - dataObjStateUpdate.cacheTimeout = options.cacheTimeout; - } - - if (options.queryCachingTTL !== dataObj?.state.queryCachingTTL) { - dataObjStateUpdate.queryCachingTTL = options.queryCachingTTL; - } - - dataObj.setState(dataObjStateUpdate); - dataObj.runQueries(); - } - - public changeQueries(queries: T[]) { - const runner = this.queryRunner; - runner.setState({ queries }); - } - - public changeTransformations(transformations: DataTransformerConfig[]) { - const dataprovider = this.dataTransformer; - dataprovider.setState({ transformations }); - dataprovider.reprocessTransformations(); - } - - public inspectPanel() { - const panel = this.state.panel; - const panelId = getPanelIdForVizPanel(panel); - - locationService.partial({ - inspect: panelId, - inspectTab: 'query', - }); - } - - get queryRunner(): SceneQueryRunner { - // Panel data object is always SceneQueryRunner wrapped in a SceneDataTransformer - const runner = getQueryRunnerFor(this.state.panel); - - if (!runner) { - throw new Error('Query runner not found'); - } - - return runner; - } - - get dataTransformer(): SceneDataTransformer { - const provider = this.state.panel.state.$data; - if (!provider || !(provider instanceof SceneDataTransformer)) { - throw new Error('Could not find SceneDataTransformer for panel'); - } - return provider; - } - - public toggleTableView() { - if (this.state.tableView) { - this.setState({ tableView: undefined }); - return; - } - - this.setState({ - tableView: PanelBuilders.table() - .setTitle('') - .setOption('showTypeIcons', true) - .setOption('showHeader', true) - // Here we are breaking a scene rule and changing the parent of the main panel data provider - // But we need to share this same instance as the queries tab is subscribing to it - .setData(this.dataTransformer) - .build(), - }); - } - - public unlinkLibraryPanel() { - const sourcePanel = this.state.sourcePanel.resolve(); - if (!isLibraryPanel(sourcePanel)) { - throw new Error('VizPanel is not a library panel'); - } - - const gridItem = sourcePanel.parent; - - if (!(gridItem instanceof DashboardGridItem)) { - throw new Error('Library panel not a child of a grid item'); - } - - const newSourcePanel = this.state.panel.clone({ $data: sourcePanel.state.$data?.clone(), $behaviors: undefined }); - gridItem.setState({ - body: newSourcePanel, - }); - - this.state.panel.setState({ $behaviors: undefined }); - this.setState({ sourcePanel: newSourcePanel.getRef() }); - } - - public commitChanges() { - const sourcePanel = this.state.sourcePanel.resolve(); - this.commitChangesTo(sourcePanel); - } - - public commitChangesTo(sourcePanel: VizPanel) { - const repeatUpdate = { - variableName: this.state.repeat, - repeatDirection: this.state.repeatDirection, - maxPerRow: this.state.maxPerRow, - }; - - const vizPanel = this.state.panel.clone(); - - if (sourcePanel.parent instanceof DashboardGridItem) { - sourcePanel.parent.setState({ - ...repeatUpdate, - body: vizPanel, - }); - } - - if (isLibraryPanel(vizPanel)) { - saveLibPanel(vizPanel); - } - } - - /** - * Used from inspect json tab to view the current persisted model - */ - public getPanelSaveModel(): Panel | object { - const sourcePanel = this.state.sourcePanel.resolve(); - const gridItem = sourcePanel.parent; - - if (!(gridItem instanceof DashboardGridItem)) { - return { error: 'Unsupported panel parent' }; - } - - const parentClone = gridItem.clone({ - body: this.state.panel.clone(), - }); - - return gridItemToPanel(parentClone); - } - - public setPanelTitle(newTitle: string) { - this.state.panel.setState({ title: newTitle, hoverHeader: newTitle === '' }); - } - - public static Component = ({ model }: SceneComponentProps) => { - const { panel, tableView } = model.useState(); - const styles = useStyles2(getStyles); - const panelToShow = tableView ?? panel; - const dataProvider = panelToShow.state.$data; - - // This is to preserve SceneQueryRunner stays alive when switching between visualizations and table view - useEffect(() => { - return dataProvider?.activate(); - }, [dataProvider]); - - return ( - <> -
{}
- - ); - }; -} - -function getStyles(theme: GrafanaTheme2) { - return { - wrapper: css({ - height: '100%', - width: '100%', - paddingLeft: theme.spacing(2), - }), - }; -} diff --git a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx index 7762bdc2965..0f2a8961278 100644 --- a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx @@ -1,8 +1,8 @@ import { SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; -import { VizPanel } from '@grafana/scenes'; -import { RadioButtonGroup, Select, DataLinksInlineEditor, Input, TextArea, Switch } from '@grafana/ui'; +import { SceneObjectState, VizPanel } from '@grafana/scenes'; +import { DataLinksInlineEditor, Input, TextArea, Switch, RadioButtonGroup, Select } from '@grafana/ui'; import { GenAIPanelDescriptionButton } from 'app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton'; import { GenAIPanelTitleButton } from 'app/features/dashboard/components/GenAI/GenAIPanelTitleButton'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; @@ -10,17 +10,15 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; +import { DashboardGridItem } from '../scene/DashboardGridItem'; import { VizPanelLinks } from '../scene/PanelLinks'; import { vizPanelToPanel, transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { getDashboardSceneFor } from '../utils/utils'; -import { VizPanelManager, VizPanelManagerState } from './VizPanelManager'; - export function getPanelFrameCategory2( - vizManager: VizPanelManager, panel: VizPanel, - repeat?: string + layoutElementState: SceneObjectState ): OptionsPaneCategoryDescriptor { const descriptor = new OptionsPaneCategoryDescriptor({ title: 'Panel options', @@ -31,19 +29,20 @@ export function getPanelFrameCategory2( const panelLinksObject = dashboardSceneGraph.getPanelLinks(panel); const links = panelLinksObject?.state.rawLinks ?? []; const dashboard = getDashboardSceneFor(panel); + const layoutElement = panel.parent; - return descriptor + descriptor .addItem( new OptionsPaneItemDescriptor({ title: 'Title', value: panel.state.title, popularRank: 1, render: function renderTitle() { - return ; + return ; }, addon: config.featureToggles.dashgpt && ( vizManager.setPanelTitle(title)} + onGenerate={(title) => setPanelTitle(panel, title)} panel={vizPanelToPanel(panel)} dashboard={transformSceneToSaveModel(dashboard)} /> @@ -95,73 +94,77 @@ export function getPanelFrameCategory2( render: () => , }) ) - ) - .addCategory( - new OptionsPaneCategoryDescriptor({ - title: 'Repeat options', - id: 'Repeat options', - isOpenDefault: false, - }) - .addItem( - new OptionsPaneItemDescriptor({ - title: 'Repeat by variable', - description: - 'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.', - render: function renderRepeatOptions() { - return ( - { - const stateUpdate: Partial = { repeat: value }; - if (value && !vizManager.state.repeatDirection) { - stateUpdate.repeatDirection = 'h'; - } - vizManager.setState(stateUpdate); - }} - /> - ); - }, - }) - ) - .addItem( - new OptionsPaneItemDescriptor({ - title: 'Repeat direction', - showIf: () => !!vizManager.state.repeat, - render: function renderRepeatOptions() { - const directionOptions: Array> = [ - { label: 'Horizontal', value: 'h' }, - { label: 'Vertical', value: 'v' }, - ]; - - return ( - vizManager.setState({ repeatDirection: value })} - /> - ); - }, - }) - ) - .addItem( - new OptionsPaneItemDescriptor({ - title: 'Max per row', - showIf: () => Boolean(vizManager.state.repeat && vizManager.state.repeatDirection === 'h'), - render: function renderOption() { - const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value })); - return ( - gridItem.setState({ maxPerRow: value.value })} + /> + ); + }, + }) + ); + + descriptor.addCategory(category); + } + + return descriptor; } interface ScenePanelLinksEditorProps { @@ -181,14 +184,14 @@ function ScenePanelLinksEditor({ panelLinks }: ScenePanelLinksEditorProps) { ); } -function PanelFrameTitle({ vizManager }: { vizManager: VizPanelManager }) { - const { title } = vizManager.state.panel.useState(); +function PanelFrameTitle({ panel }: { panel: VizPanel }) { + const { title } = panel.useState(); return ( vizManager.setPanelTitle(e.currentTarget.value)} + onChange={(e) => setPanelTitle(panel, e.currentTarget.value)} /> ); } @@ -204,3 +207,7 @@ function DescriptionTextArea({ panel }: { panel: VizPanel }) { /> ); } + +function setPanelTitle(panel: VizPanel, title: string) { + panel.setState({ title: title, hoverHeader: title === '' }); +} diff --git a/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx b/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx index e841f7a47ba..94d1655686c 100644 --- a/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx +++ b/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx @@ -40,19 +40,12 @@ export const DashboardPrompt = memo(({ dashboard }: DashboardPromptProps) => { }, [dashboard]); const onHistoryBlock = (location: H.Location) => { - const panelInEdit = dashboard.state.editPanel; - const vizPanelManager = panelInEdit?.state.vizManager; - const vizPanel = vizPanelManager?.state.panel; + const panelEditor = dashboard.state.editPanel; + const vizPanel = panelEditor?.getPanel(); const search = new URLSearchParams(location.search); // Are we leaving panel edit & library panel? - if ( - panelInEdit && - vizPanel && - isLibraryPanel(vizPanel) && - vizPanelManager.state.isDirty && - !search.has('editPanel') - ) { + if (panelEditor && vizPanel && isLibraryPanel(vizPanel) && panelEditor.state.isDirty && !search.has('editPanel')) { const libPanelBehavior = getLibraryPanelBehavior(vizPanel); showModal(SaveLibraryVizPanelModal, { @@ -60,12 +53,12 @@ export const DashboardPrompt = memo(({ dashboard }: DashboardPromptProps) => { isUnsavedPrompt: true, libraryPanel: libPanelBehavior!, onConfirm: () => { - panelInEdit.onConfirmSaveLibraryPanel(); + panelEditor.onConfirmSaveLibraryPanel(); hideModal(); moveToBlockedLocationAfterReactStateUpdate(location); }, onDiscard: () => { - panelInEdit.onDiscard(); + panelEditor.onDiscard(); hideModal(); moveToBlockedLocationAfterReactStateUpdate(location); }, diff --git a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts index d99c4132687..d7b5cf74ff8 100644 --- a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts +++ b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts @@ -14,7 +14,6 @@ import { } from '@grafana/scenes'; import { createWorker } from 'app/features/dashboard-scene/saving/createDetectChangesWorker'; -import { VizPanelManager } from '../panel-edit/VizPanelManager'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; import { DashboardGridItem } from '../scene/DashboardGridItem'; @@ -43,7 +42,6 @@ export class DashboardSceneChangeTracker { } // Any change in the panel should trigger a change detection - // The VizPanelManager includes configuration for the panel like repeat // The PanelTimeRange includes the overrides configuration if ( payload.changedObject instanceof VizPanel || @@ -52,16 +50,6 @@ export class DashboardSceneChangeTracker { ) { return true; } - // VizPanelManager includes the repeat configuration - if (payload.changedObject instanceof VizPanelManager) { - if ( - Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'repeat') || - Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'repeatDirection') || - Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'maxPerRow') - ) { - return true; - } - } // SceneQueryRunner includes the DS configuration if (payload.changedObject instanceof SceneQueryRunner) { if (!Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'data')) { diff --git a/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts index d14ae73ed3a..3a62712b70d 100644 --- a/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts +++ b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts @@ -237,8 +237,7 @@ describe('getDashboardChangesFromScene', () => { dashboard.onEnterEditMode(); dashboard.setState({ editPanel: editScene }); - editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); - editScene.commitChanges(); + editScene.state.panelRef.resolve().setState({ title: 'changed title' }); const result = getDashboardChangesFromScene(dashboard, false, true); const panelSaveModel = result.changedSaveModel.panels![0]; diff --git a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx index ce5d1f04606..0be50bd2ecc 100644 --- a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx @@ -11,11 +11,10 @@ import { } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { setPluginImportUtils } from '@grafana/runtime'; -import { SceneDataTransformer, SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes'; +import { SceneDataTransformer, SceneFlexLayout, SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; -import { VizPanelManager } from '../panel-edit/VizPanelManager'; import { activateFullSceneTree } from '../utils/test-utils'; import { DashboardDatasourceBehaviour } from './DashboardDatasourceBehaviour'; @@ -275,14 +274,12 @@ describe('DashboardDatasourceBehaviour', () => { // spy on runQueries const spy = jest.spyOn(dashboardDSPanel.state.$data!.state.$data as SceneQueryRunner, 'runQueries'); - const vizPanelManager = new VizPanelManager({ - panel: dashboardDSPanel.clone(), + const scene = new SceneFlexLayout({ $data: dashboardDSPanel.state.$data?.clone(), - sourcePanel: dashboardDSPanel.getRef(), - pluginId: dashboardDSPanel.state.pluginId, + children: [], }); - vizPanelManager.activate(); + scene.activate(); expect(spy).not.toHaveBeenCalled(); }); diff --git a/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx index 97e44cef66b..2dad7ee9618 100644 --- a/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx @@ -21,7 +21,7 @@ import { SceneVariable, SceneVariableDependencyConfigLike, } from '@grafana/scenes'; -import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; +import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { getMultiVariableValues, getQueryRunnerFor } from '../utils/utils'; @@ -41,7 +41,8 @@ export type RepeatDirection = 'v' | 'h'; export class DashboardGridItem extends SceneObjectBase implements SceneGridItemLike { private _prevRepeatValues?: VariableValueSingle[]; - private _oldBody?: VizPanel; + private _prevPanelState: VizPanelState | undefined; + private _prevGridItemState: DashboardGridItemState | undefined; protected _variableDependency = new DashboardGridItemVariableDependencyHandler(this); @@ -54,14 +55,17 @@ export class DashboardGridItem extends SceneObjectBase i private _activationHandler() { if (this.state.variableName) { this._subs.add(this.subscribeToState((newState, prevState) => this._handleGridResize(newState, prevState))); - if (this._oldBody !== this.state.body) { - this._prevRepeatValues = undefined; - } - + this.clearCachedStateIfBodyOrOptionsChanged(); this.performRepeat(); } } + private clearCachedStateIfBodyOrOptionsChanged() { + if (this._prevGridItemState !== this.state || this._prevPanelState !== this.state.body.state) { + this._prevRepeatValues = undefined; + } + } + /** * Uses the current repeat item count to calculate the user intended desired itemHeight */ @@ -116,9 +120,6 @@ export class DashboardGridItem extends SceneObjectBase i return; } - this._oldBody = this.state.body; - this._prevRepeatValues = values; - const panelToRepeat = this.state.body; const repeatedPanels: VizPanel[] = []; @@ -178,10 +179,54 @@ export class DashboardGridItem extends SceneObjectBase i } } + this._prevGridItemState = this.state; + this._prevPanelState = this.state.body.state; + this._prevRepeatValues = values; + // Used from dashboard url sync this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); } + public setRepeatByVariable(variableName: string | undefined) { + const stateUpdate: Partial = { variableName }; + + if (variableName && !this.state.repeatDirection) { + stateUpdate.repeatDirection = 'h'; + } + + if (this.state.body.state.$variables) { + this.state.body.setState({ $variables: undefined }); + } + + this.setState(stateUpdate); + } + + /** + * Logic to prep panel for panel edit + */ + public editingStarted() { + if (!this.state.variableName) { + return; + } + + if (this.state.repeatedPanels?.length ?? 0 > 1) { + this.state.body.setState({ + $variables: this.state.repeatedPanels![0].state.$variables?.clone(), + $data: this.state.repeatedPanels![0].state.$data?.clone(), + }); + this._prevPanelState = this.state.body.state; + } + } + + /** + * Going back to dashboards logic + */ + public editingCompleted() { + if (this.state.variableName && this.state.repeatDirection === 'h' && this.state.width !== GRID_COLUMN_COUNT) { + this.setState({ width: GRID_COLUMN_COUNT }); + } + } + public notifyRepeatedPanelsWaitingForVariables(variable: SceneVariable) { for (const panel of this.state.repeatedPanels ?? []) { const queryRunner = getQueryRunnerFor(panel); diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index c2a2ba61966..3df3a8f73e6 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -18,7 +18,7 @@ import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { VariablesChanged } from 'app/features/variables/types'; -import { PanelEditor, buildPanelEditScene } from '../panel-edit/PanelEditor'; +import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createWorker } from '../saving/createDetectChangesWorker'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; @@ -197,18 +197,16 @@ describe('DashboardScene', () => { expect(resoredLayout.state.children.map((c) => c.state.key)).toEqual(originalPanelOrder); }); - it('Should exit edit mode and discard panel changes if leaving the dashboard while in panel edit', () => { - const panel = findVizPanelByKey(scene, 'panel-1'); + it('Should exit edit mode and discard panel changes if leaving the dashboard while in panel edit', async () => { + const panel = findVizPanelByKey(scene, 'panel-1')!; const editPanel = buildPanelEditScene(panel!); - scene.setState({ - editPanel, - }); - - expect(scene.state.editPanel!['_discardChanges']).toBe(false); + scene.setState({ editPanel }); + panel.setState({ title: 'new title' }); scene.exitEditMode({ skipConfirm: true }); - expect(scene.state.editPanel!['_discardChanges']).toBe(true); + const discardPanel = findVizPanelByKey(scene, panel.state.key)!; + expect(discardPanel.state.title).toBe('Panel A'); }); it.each` @@ -1023,14 +1021,14 @@ describe('DashboardScene', () => { panelPluginId: 'table', }); }); + test('when editing', () => { const panel = findVizPanelByKey(scene, 'panel-1'); const editPanel = buildPanelEditScene(panel!); - scene.setState({ - editPanel, - }); + scene.setState({ editPanel }); + + const queryRunner = editPanel.getPanel().state.$data!; - const queryRunner = (scene.state.editPanel as PanelEditor).state.vizManager.queryRunner; expect(scene.enrichDataRequest(queryRunner)).toEqual({ app: CoreApp.Dashboard, dashboardUID: 'dash-1', diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 48424ea2b1a..6c1bffe482a 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -252,6 +252,7 @@ export class DashboardScene extends SceneObjectBase { }; this._changeTracker.stopTrackingChanges(); + this.setState({ version: result.version, isDirty: false, @@ -267,6 +268,7 @@ export class DashboardScene extends SceneObjectBase { }, }); + this.state.editPanel?.dashboardSaved(); this._changeTracker.startTrackingChanges(); } @@ -801,7 +803,7 @@ export class DashboardScene extends SceneObjectBase { let panel = getClosestVizPanel(sceneObject); if (dashboard.state.isEditing && dashboard.state.editPanel) { - panel = dashboard.state.editPanel.state.vizManager.state.panel; + panel = dashboard.state.editPanel.state.panelRef.resolve(); } let panelId = 0; diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index 53067b92a34..21335a1904d 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -2,14 +2,7 @@ import { Unsubscribable } from 'rxjs'; import { AppEvents } from '@grafana/data'; import { config, locationService } from '@grafana/runtime'; -import { - SceneGridLayout, - SceneObjectBase, - SceneObjectState, - SceneObjectUrlSyncHandler, - SceneObjectUrlValues, - VizPanel, -} from '@grafana/scenes'; +import { SceneGridLayout, SceneObjectUrlSyncHandler, SceneObjectUrlValues, VizPanel } from '@grafana/scenes'; import appEvents from 'app/core/app_events'; import { KioskMode } from 'app/types'; @@ -18,7 +11,7 @@ import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createDashboardEditViewFor } from '../settings/utils'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; -import { findVizPanelByKey, getDashboardSceneFor, getLibraryPanelBehavior, isPanelClone } from '../utils/utils'; +import { findVizPanelByKey, getLibraryPanelBehavior, isPanelClone } from '../utils/utils'; import { DashboardScene, DashboardSceneState } from './DashboardScene'; import { LibraryPanelBehavior } from './LibraryPanelBehavior'; @@ -78,9 +71,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { } update.inspectPanelKey = values.inspect; - update.overlay = new PanelInspectDrawer({ - $behaviors: [new ResolveInspectPanelByKey({ panelKey: values.inspect })], - }); + update.overlay = new PanelInspectDrawer({ panelRef: panel.getRef() }); } else if (inspectPanelKey) { update.inspectPanelKey = undefined; update.overlay = undefined; @@ -196,37 +187,3 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { }); } } - -interface ResolveInspectPanelByKeyState extends SceneObjectState { - panelKey: string; -} - -class ResolveInspectPanelByKey extends SceneObjectBase { - constructor(state: ResolveInspectPanelByKeyState) { - super(state); - this.addActivationHandler(this._onActivate); - } - - private _onActivate = () => { - const parent = this.parent; - - if (!parent || !(parent instanceof PanelInspectDrawer)) { - throw new Error('ResolveInspectPanelByKey must be attached to a PanelInspectDrawer'); - } - - const dashboard = getDashboardSceneFor(parent); - if (!dashboard) { - return; - } - const panelId = this.state.panelKey; - let panel = findVizPanelByKey(dashboard, panelId); - - if (dashboard.state.editPanel) { - panel = dashboard.state.editPanel.state.vizManager.state.panel; - } - - if (panel) { - parent.setState({ panelRef: panel.getRef() }); - } - }; -} diff --git a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx index d3848d1fccf..ba2551e57ec 100644 --- a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx @@ -77,6 +77,16 @@ export class LibraryPanelBehavior extends SceneObjectBase b !== this) }); + } + } + private async loadLibraryPanelFromPanelModel() { let vizPanel = this.parent; diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index d062d96a7f0..4b00fa431ba 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -61,8 +61,8 @@ export function ToolbarActions({ dashboard }: Props) { const styles = useStyles2(getStyles); const isEditingPanel = Boolean(editPanel); const isViewingPanel = Boolean(viewPanelScene); - const isEditedPanelDirty = useVizManagerDirty(editPanel); - const isEditingLibraryPanel = useEditingLibraryPanel(editPanel); + const isEditedPanelDirty = usePanelEditDirty(editPanel); + const isEditingLibraryPanel = editPanel && isLibraryPanel(editPanel.state.panelRef.resolve()); const hasCopiedPanel = store.exists(LS_PANEL_COPY_KEY); // Means we are not in settings view, fullscreen panel or edit panel const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel; @@ -422,7 +422,7 @@ export function ToolbarActions({ dashboard }: Props) { onClick={editPanel?.onDiscard} tooltip={editPanel?.state.isNewPanel ? 'Discard panel' : 'Discard panel changes'} size="sm" - disabled={!isEditedPanelDirty || !isDirty} + disabled={!isEditedPanelDirty} key="discard" fill="outline" variant="destructive" @@ -613,41 +613,22 @@ function addDynamicActions( } } -function useEditingLibraryPanel(panelEditor?: PanelEditor) { - const [isEditingLibraryPanel, setEditingLibraryPanel] = useState(false); - - useEffect(() => { - if (panelEditor) { - const unsub = panelEditor.state.vizManager.subscribeToState((vizManagerState) => - setEditingLibraryPanel(isLibraryPanel(vizManagerState.sourcePanel.resolve())) - ); - return () => { - unsub.unsubscribe(); - }; - } - setEditingLibraryPanel(false); - return; - }, [panelEditor]); - - return isEditingLibraryPanel; -} - // This hook handles when panelEditor is not defined to avoid conditionally hook usage -function useVizManagerDirty(panelEditor?: PanelEditor) { - const [isDirty, setIsDirty] = useState(false); +function usePanelEditDirty(panelEditor?: PanelEditor) { + const [isDirty, setIsDirty] = useState(); useEffect(() => { if (panelEditor) { - const unsub = panelEditor.state.vizManager.subscribeToState((vizManagerState) => - setIsDirty(vizManagerState.isDirty || false) - ); - return () => { - unsub.unsubscribe(); - }; + const unsub = panelEditor.subscribeToState((state) => { + if (state.isDirty !== isDirty) { + setIsDirty(state.isDirty); + } + }); + + return () => unsub.unsubscribe(); } - setIsDirty(false); return; - }, [panelEditor]); + }, [panelEditor, isDirty]); return isDirty; } diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx index f112fb3d465..7b019aa5405 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx +++ b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx @@ -24,7 +24,7 @@ describe('DashboardRow', () => { { it('Should not show warning component when does not have warningMessage prop', () => { render( - + ); expect( diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx index 270f7a7f5ea..9977d96b93e 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx +++ b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx @@ -12,13 +12,13 @@ export type OnRowOptionsUpdate = (title: string, repeat?: string | null) => void export interface Props { title: string; repeat?: string; - parent: SceneObject; + sceneContext: SceneObject; onUpdate: OnRowOptionsUpdate; onCancel: () => void; warning?: React.ReactNode; } -export const RowOptionsForm = ({ repeat, title, parent, warning, onUpdate, onCancel }: Props) => { +export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate, onCancel }: Props) => { const [newRepeat, setNewRepeat] = useState(repeat); const onChangeRepeat = useCallback((name?: string) => setNewRepeat(name), [setNewRepeat]); @@ -38,7 +38,7 @@ export const RowOptionsForm = ({ repeat, title, parent, warning, onUpdate, onCan
- + {warning && ( { activateFullSceneTree(scene); expect(repeater.state.repeatedPanels?.length).toBe(2); - const result = panelRepeaterToPanels(repeater, undefined, true); + const result = panelRepeaterToPanels(repeater, true); expect(result).toHaveLength(2); @@ -861,7 +852,7 @@ describe('transformSceneToSaveModel', () => { ); activateFullSceneTree(scene); - const result = panelRepeaterToPanels(repeater, undefined, true); + const result = panelRepeaterToPanels(repeater, true); expect(result).toHaveLength(1); @@ -886,7 +877,7 @@ describe('transformSceneToSaveModel', () => { activateFullSceneTree(scene); let panels: Panel[] = []; - gridRowToSaveModel(row, panels, undefined, true); + gridRowToSaveModel(row, panels, true); expect(panels).toHaveLength(2); expect(panels[0].repeat).toBe('handler'); @@ -914,7 +905,7 @@ describe('transformSceneToSaveModel', () => { activateFullSceneTree(scene); let panels: Panel[] = []; - gridRowToSaveModel(row, panels, undefined, true); + gridRowToSaveModel(row, panels, true); expect(panels[0].repeat).toBe('handler'); @@ -1024,94 +1015,6 @@ describe('transformSceneToSaveModel', () => { }); }); - describe('Given a scene with an open panel editor', () => { - it('should persist changes to panel model', async () => { - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); - - const gridItem = new DashboardGridItem({ body: panel }); - - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [gridItem], - }), - $timeRange: new SceneTimeRange({ - from: 'now-6h', - to: 'now', - timeZone: '', - }), - }); - - editScene!.state.vizManager.state.panel.setState({ - options: { - mode: 'markdown', - code: { - language: 'plaintext', - showLineNumbers: false, - showMiniMap: false, - }, - content: 'new content', - }, - }); - activateFullSceneTree(scene); - const saveModel = transformSceneToSaveModel(scene); - expect((saveModel.panels![0] as any).options.content).toBe('new content'); - }); - - it('should persist changes to panel model in row', async () => { - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - options: { - content: 'old content', - }, - }); - - const gridItem = new DashboardGridItem({ body: panel }); - - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [ - new SceneGridRow({ - key: '23', - isCollapsed: false, - children: [gridItem], - }), - ], - }), - $timeRange: new SceneTimeRange({ - from: 'now-6h', - to: 'now', - timeZone: '', - }), - }); - activateFullSceneTree(scene); - - editScene!.state.vizManager.state.panel.setState({ - options: { - mode: 'markdown', - code: { - language: 'plaintext', - showLineNumbers: false, - showMiniMap: false, - }, - content: 'new content', - }, - }); - - const saveModel = transformSceneToSaveModel(scene); - expect((saveModel.panels![1] as any).options.content).toBe('new content'); - }); - }); - describe('Given a scene with repeated panels and non-repeated panels', () => { it('should save repeated panels itemHeight as height', () => { const scene = transformSaveModelToScene({ diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index 45db9cf1fdb..01605ed28ab 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -33,7 +33,7 @@ import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { DashboardGridItem } from '../scene/DashboardGridItem'; -import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; +import { DashboardScene } from '../scene/DashboardScene'; import { PanelTimeRange } from '../scene/PanelTimeRange'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; @@ -58,9 +58,9 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa if (child instanceof DashboardGridItem) { // handle panel repeater scenario if (child.state.variableName) { - panels = panels.concat(panelRepeaterToPanels(child, state, isSnapshot)); + panels = panels.concat(panelRepeaterToPanels(child, isSnapshot)); } else { - panels.push(gridItemToPanel(child, state, isSnapshot)); + panels.push(gridItemToPanel(child, isSnapshot)); } } @@ -69,7 +69,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa if (child.state.key!.indexOf('-clone-') > 0 && !isSnapshot) { continue; } - gridRowToSaveModel(child, panels, state, isSnapshot); + gridRowToSaveModel(child, panels, isSnapshot); } } } @@ -139,11 +139,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa return sortedDeepCloneWithoutNulls(dashboard); } -export function gridItemToPanel( - gridItem: DashboardGridItem, - sceneState?: DashboardSceneState, - isSnapshot = false -): Panel { +export function gridItemToPanel(gridItem: DashboardGridItem, isSnapshot = false): Panel { let vizPanel: VizPanel | undefined; let x = 0, y = 0, @@ -152,19 +148,6 @@ export function gridItemToPanel( let gridItem_ = gridItem; - // If we're saving while the panel editor is open, we need to persist those changes in the panel model - if ( - sceneState && - sceneState.editPanel?.state.vizManager && - sceneState.editPanel.state.vizManager.state.sourcePanel.resolve() === gridItem.state.body - ) { - const gridItemClone = gridItem.clone(); - if (gridItemClone.state.body instanceof VizPanel && !isLibraryPanel(gridItemClone.state.body)) { - sceneState.editPanel.state.vizManager.commitChangesTo(gridItemClone.state.body); - gridItem_ = gridItemClone; - } - } - if (!(gridItem_.state.body instanceof VizPanel)) { throw new Error('DashboardGridItem body expected to be VizPanel'); } @@ -325,13 +308,9 @@ function vizPanelDataToPanel( return panel; } -export function panelRepeaterToPanels( - repeater: DashboardGridItem, - sceneState?: DashboardSceneState, - isSnapshot = false -): Panel[] { +export function panelRepeaterToPanels(repeater: DashboardGridItem, isSnapshot = false): Panel[] { if (!isSnapshot) { - return [gridItemToPanel(repeater, sceneState)]; + return [gridItemToPanel(repeater)]; } else { // return early if the repeated panel is a library panel if (repeater.state.body instanceof VizPanel && isLibraryPanel(repeater.state.body)) { @@ -388,12 +367,7 @@ export function panelRepeaterToPanels( } } -export function gridRowToSaveModel( - gridRow: SceneGridRow, - panelsArray: Array, - sceneState?: DashboardSceneState, - isSnapshot = false -) { +export function gridRowToSaveModel(gridRow: SceneGridRow, panelsArray: Array, isSnapshot = false) { const collapsed = Boolean(gridRow.state.isCollapsed); const rowPanel: RowPanel = { type: 'row', @@ -443,10 +417,10 @@ export function gridRowToSaveModel( if (c instanceof DashboardGridItem) { if (c.state.variableName) { // Perform snapshot only for uncollapsed rows - panelsInsideRow = panelsInsideRow.concat(panelRepeaterToPanels(c, sceneState, !collapsed)); + panelsInsideRow = panelsInsideRow.concat(panelRepeaterToPanels(c, !collapsed)); } else { // Perform snapshot only for uncollapsed panels - panelsInsideRow.push(gridItemToPanel(c, sceneState, !collapsed)); + panelsInsideRow.push(gridItemToPanel(c, !collapsed)); } } }); @@ -455,7 +429,7 @@ export function gridRowToSaveModel( if (!(c instanceof DashboardGridItem)) { throw new Error('Row child expected to be DashboardGridItem'); } - return gridItemToPanel(c, sceneState); + return gridItemToPanel(c); }); } diff --git a/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx b/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx index 5ed6672b923..918d9fc6906 100644 --- a/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx @@ -110,20 +110,17 @@ export function getFieldOverrideCategories( onOverrideChange(idx, override); }; - const onDynamicConfigValueAdd = (o: ConfigOverrideRule, value: SelectableValue) => { + const onDynamicConfigValueAdd = (override: ConfigOverrideRule, value: SelectableValue) => { const registryItem = registry.get(value.value!); const propertyConfig: DynamicConfigValue = { id: registryItem.id, value: registryItem.defaultValue, }; - if (override.properties) { - o.properties.push(propertyConfig); - } else { - o.properties = [propertyConfig]; - } + const properties = override.properties ?? []; + properties.push(propertyConfig); - onOverrideChange(idx, o); + onOverrideChange(idx, { ...override, properties }); }; /** @@ -158,13 +155,23 @@ export function getFieldOverrideCategories( } const onPropertyChange = (value: DynamicConfigValue) => { - override.properties[propIdx].value = value; - onOverrideChange(idx, override); + onOverrideChange(idx, { + ...override, + properties: override.properties.map((prop, i) => { + if (i === propIdx) { + return { ...prop, value: value }; + } + + return prop; + }), + }); }; const onPropertyRemove = () => { - override.properties.splice(propIdx, 1); - onOverrideChange(idx, override); + onOverrideChange(idx, { + ...override, + properties: override.properties.filter((_, i) => i !== propIdx), + }); }; /** diff --git a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx index c35a286a4ea..67e9fcd45b4 100644 --- a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx +++ b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx @@ -44,14 +44,14 @@ export const RepeatRowSelect = ({ repeat, onChange, id }: Props) => { }; interface Props2 { - parent: SceneObject; + sceneContext: SceneObject; repeat: string | undefined; id?: string; onChange: (name?: string) => void; } -export const RepeatRowSelect2 = ({ parent, repeat, id, onChange }: Props2) => { - const sceneVars = useMemo(() => sceneGraph.getVariables(parent), [parent]); +export const RepeatRowSelect2 = ({ sceneContext, repeat, id, onChange }: Props2) => { + const sceneVars = useMemo(() => sceneGraph.getVariables(sceneContext.getRoot()), [sceneContext]); const variables = sceneVars.useState().variables; const variableOptions = useMemo(() => { diff --git a/public/app/features/library-panels/state/api.ts b/public/app/features/library-panels/state/api.ts index c92d295b3d6..8356e8e3ec9 100644 --- a/public/app/features/library-panels/state/api.ts +++ b/public/app/features/library-panels/state/api.ts @@ -3,7 +3,6 @@ import { lastValueFrom } from 'rxjs'; import { VizPanel } from '@grafana/scenes'; import { LibraryPanel, defaultDashboard } from '@grafana/schema'; import { DashboardModel } from 'app/features/dashboard/state'; -import { VizPanelManager } from 'app/features/dashboard-scene/panel-edit/VizPanelManager'; import { DashboardGridItem } from 'app/features/dashboard-scene/scene/DashboardGridItem'; import { vizPanelToPanel } from 'app/features/dashboard-scene/serialization/transformSceneToSaveModel'; import { getLibraryPanelBehavior } from 'app/features/dashboard-scene/utils/utils'; @@ -146,10 +145,6 @@ export function libraryVizPanelToSaveModel(vizPanel: VizPanel) { let gridItem = vizPanel.parent; - if (gridItem instanceof VizPanelManager) { - gridItem = gridItem.state.sourcePanel.resolve().parent; - } - if (!gridItem || !(gridItem instanceof DashboardGridItem)) { throw new Error('Trying to save a library panel that does not have a DashboardGridItem parent'); } diff --git a/public/app/features/trails/DataTrailsHistory.test.tsx b/public/app/features/trails/DataTrailsHistory.test.tsx index 13e65c6adce..24e27dadc23 100644 --- a/public/app/features/trails/DataTrailsHistory.test.tsx +++ b/public/app/features/trails/DataTrailsHistory.test.tsx @@ -24,7 +24,7 @@ describe('DataTrailsHistory', () => { { name: 'from history', input: { from: '2024-07-22T18:30:00.000Z', to: '2024-07-22T19:30:00.000Z' }, - expected: '2024-07-22 12:30:00 - 2024-07-22 13:30:00', + expected: '2024-07-22 13:30:00 - 2024-07-22 14:30:00', }, { name: 'time change event with timezone', @@ -33,7 +33,7 @@ describe('DataTrailsHistory', () => { }, ])('$name', ({ input, expected }) => { const result = parseTimeTooltip(input); - expect(result).toBe(expected); + expect(result).toEqual(expected); }); }); diff --git a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx index 073cb173dd8..47f70640b07 100644 --- a/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx +++ b/public/app/plugins/datasource/dashboard/DashboardQueryEditor.tsx @@ -23,9 +23,11 @@ function getQueryDisplayText(query: DataQuery): string { function isPanelInEdit(panelId: number, panelInEditId?: number) { let idToCompareWith = panelInEditId; + if (window.__grafanaSceneContext && window.__grafanaSceneContext instanceof DashboardScene) { - idToCompareWith = window.__grafanaSceneContext.state.editPanel?.state.panelId; + idToCompareWith = window.__grafanaSceneContext.state.editPanel?.getPanelId(); } + return panelId === idToCompareWith; } From 05dbba4c8594d0c20293366efe4705b33883d99b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 24 Sep 2024 17:59:32 +0200 Subject: [PATCH 006/102] DashboardScene: Fix broken error handling and error rendering (#93680) --- .../pages/DashboardScenePage.tsx | 13 +++- .../DashboardScenePageStateManager.test.ts | 5 +- .../pages/DashboardScenePageStateManager.ts | 69 +------------------ 3 files changed, 14 insertions(+), 73 deletions(-) diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index 9d004d346f1..f74e53c0d89 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo } from 'react'; import { PageLayoutType } from '@grafana/data'; import { UrlSyncContextProvider } from '@grafana/scenes'; +import { Alert, Box } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; @@ -68,9 +69,15 @@ export function DashboardScenePage({ match, route, queryParams, history }: Props if (!dashboard) { return ( - - {isLoading && } - {loadError &&

{loadError}

} + + + {isLoading && } + {loadError && ( + + {loadError} + + )} + ); } diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index b67870e1b90..3c0c7b5a2ed 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -35,7 +35,7 @@ describe('DashboardScenePageStateManager', () => { const loader = new DashboardScenePageStateManager({}); await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); - expect(loader.state.dashboard).toBeDefined(); + expect(loader.state.dashboard).toBeUndefined(); expect(loader.state.isLoading).toBe(false); expect(loader.state.loadError).toBe('Dashboard not found'); }); @@ -107,8 +107,7 @@ describe('DashboardScenePageStateManager', () => { const loader = new DashboardScenePageStateManager({}); await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home }); - expect(loader.state.dashboard).toBeDefined(); - expect(loader.state.dashboard?.state.title).toEqual('Failed to load home dashboard'); + expect(loader.state.dashboard).toBeUndefined(); expect(loader.state.loadError).toEqual('Failed to load home dashboard'); }); }); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 40cd0b07fc1..089cfa0f417 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -1,13 +1,11 @@ import { locationUtil } from '@grafana/data'; import { config, getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; -import { defaultDashboard } from '@grafana/schema'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; import { default as localStorageStore } from 'app/core/store'; import { getMessageFromError } from 'app/core/utils/errors'; import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; -import { DashboardModel } from 'app/features/dashboard/state'; import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor'; import { DASHBOARD_FROM_LS_KEY, @@ -20,10 +18,7 @@ import { DashboardDTO, DashboardRoutes } from 'app/types'; import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; import { buildNewDashboardSaveModel } from '../serialization/buildNewDashboardSaveModel'; -import { - createDashboardSceneFromDashboardModel, - transformSaveModelToScene, -} from '../serialization/transformSaveModelToScene'; +import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState'; import { updateNavModel } from './utils'; @@ -203,11 +198,7 @@ export class DashboardScenePageStateManager extends StateManagerBase

${msg}

`, - mode: 'html', - }, - title: '', - transparent: true, - type: 'text', - }, - ], - }, - meta: { canSave: false, canEdit: false }, - }; - return createDashboardSceneFromDashboardModel(new DashboardModel(dto.dashboard, dto.meta), dto.dashboard); -} From 18f8f38418df2a744d68b10bc778b455095d28f6 Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Tue, 24 Sep 2024 18:03:16 +0200 Subject: [PATCH 007/102] Prometheus: Fix query interval string interpolation in query cache (#93678) fix query interval string interpolation in query cache --- packages/grafana-prometheus/src/datasource.ts | 1 + .../src/querycache/QueryCache.test.ts | 13 ++++++++++++- .../src/querycache/QueryCache.ts | 19 ++++++++++++++----- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index f75aa985b1a..cb416fa8f0c 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -141,6 +141,7 @@ export class PrometheusDatasource this.cache = new QueryCache({ getTargetSignature: this.getPrometheusTargetSignature.bind(this), overlapString: instanceSettings.jsonData.incrementalQueryOverlapWindow ?? defaultPrometheusQueryOverlapWindow, + applyInterpolation: this.interpolateString.bind(this), }); // This needs to be here and cannot be static because of how annotations typing affects casting of data source diff --git a/packages/grafana-prometheus/src/querycache/QueryCache.test.ts b/packages/grafana-prometheus/src/querycache/QueryCache.test.ts index 8d6fd349a84..01cd25fd14f 100644 --- a/packages/grafana-prometheus/src/querycache/QueryCache.test.ts +++ b/packages/grafana-prometheus/src/querycache/QueryCache.test.ts @@ -6,7 +6,7 @@ import { DataFrame, DataQueryRequest, DateTime, dateTime, TimeRange } from '@gra import { QueryEditorMode } from '../querybuilder/shared/types'; import { PromQuery } from '../types'; -import { CacheRequestInfo, QueryCache } from './QueryCache'; +import { CacheRequestInfo, findDatapointStep, QueryCache } from './QueryCache'; import { IncrementalStorageDataFrameScenarios, trimmedFirstPointInPromFrames } from './QueryCacheTestData'; // Will not interpolate vars! @@ -559,3 +559,14 @@ describe('QueryCache: Prometheus', function () { expect(cacheRequest.shouldCache).toBe(true); }); }); + +describe('findDataPointStep', () => { + it('should interpolate custom interval', () => { + const mockApplyInterpolation = jest.fn().mockImplementation(() => '1m'); + const req = mockPromRequest(); + req.targets[0].interval = '$interval'; + const respFrames = trimmedFirstPointInPromFrames as unknown as DataFrame[]; + findDatapointStep(req, respFrames, mockApplyInterpolation); + expect(mockApplyInterpolation).toBeCalledTimes(1); + }); +}); diff --git a/packages/grafana-prometheus/src/querycache/QueryCache.ts b/packages/grafana-prometheus/src/querycache/QueryCache.ts index 9b9c308e022..9b06746b7cb 100644 --- a/packages/grafana-prometheus/src/querycache/QueryCache.ts +++ b/packages/grafana-prometheus/src/querycache/QueryCache.ts @@ -10,6 +10,7 @@ import { isValidDuration, parseDuration, rangeUtil, + ScopedVars, Table, trimTable, } from '@grafana/data'; @@ -23,10 +24,9 @@ type TargetIdent = string; // query + template variables + interval + raw time range // used for full target cache busting -> full range re-query type TargetSig = string; - type TimestampMs = number; - type SupportedQueryTypes = PromQuery; +type ApplyInterpolation = (str: string, scopedVars?: ScopedVars) => string; // string matching requirements defined in durationutil.ts export const defaultPrometheusQueryOverlapWindow = '10m'; @@ -59,12 +59,14 @@ export const getFieldIdent = (field: Field) => `${field.type}|${field.name}|${JS export class QueryCache { private overlapWindowMs: number; private getTargetSignature: (request: DataQueryRequest, target: T) => string; + private applyInterpolation = (str: string, scopedVars?: ScopedVars) => str; cache = new Map(); constructor(options: { getTargetSignature: (request: DataQueryRequest, target: T) => string; overlapString: string; + applyInterpolation?: ApplyInterpolation; }) { const unverifiedOverlap = options.overlapString; if (isValidDuration(unverifiedOverlap)) { @@ -76,6 +78,9 @@ export class QueryCache { } this.getTargetSignature = options.getTargetSignature; + if (options.applyInterpolation) { + this.applyInterpolation = options.applyInterpolation; + } } // can be used to change full range request to partial, split into multiple requests @@ -221,7 +226,7 @@ export class QueryCache { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions let table: Table = frame.fields.map((field) => field.values) as Table; - const dataPointStep = findDatapointStep(request, respFrames); + const dataPointStep = findDatapointStep(request, respFrames, this.applyInterpolation); // query interval is greater than request.intervalMs, use query interval to make sure we've always got one datapoint outside the panel viewport let trimmed = trimTable(table, newFrom - dataPointStep, newTo); @@ -260,7 +265,11 @@ export class QueryCache { } } -function findDatapointStep(request: DataQueryRequest, respFrames: DataFrame[]): number { +export function findDatapointStep( + request: DataQueryRequest, + respFrames: DataFrame[], + applyInterpolation: ApplyInterpolation +): number { // Prometheus specific logic below if (request.targets[0].datasource?.type !== 'prometheus') { return 0; @@ -270,7 +279,7 @@ function findDatapointStep(request: DataQueryRequest, respFrames: Dat let dataPointStep = request.intervalMs; if (target?.interval) { - const minStepMs = rangeUtil.intervalToMs(target.interval); + const minStepMs = rangeUtil.intervalToMs(applyInterpolation(target.interval)); if (minStepMs > request.intervalMs) { dataPointStep = minStepMs; } From d411ce266401c49d821a19dc0dbe0281a0731969 Mon Sep 17 00:00:00 2001 From: Misi Date: Tue, 24 Sep 2024 18:38:09 +0200 Subject: [PATCH 008/102] Auth: Use sessionStorage instead of cookie for automatic redirection (#92759) * WIP: working as expected, has to be tested * Rename query param, small changes * Remove unused code * Address feedback * Cleanup * Use the feature toggle to control the behaviour * Use the toggle on the FE too * Prevent the extra redirect/reload Co-authored-by: Josh Hunt * Return to login if user is not authenticated * Add tracking issue * Align BE redirect constructor to locationSvc --- pkg/api/common_test.go | 1 + pkg/api/login.go | 9 +++- pkg/api/login_oauth.go | 2 +- pkg/api/user_token.go | 6 ++- pkg/middleware/auth.go | 42 +++++++++++++-- pkg/middleware/auth_test.go | 2 +- pkg/middleware/middleware_test.go | 3 +- pkg/services/accesscontrol/middleware.go | 51 ++++++++++++++++--- pkg/services/authn/authn.go | 22 +++++--- pkg/services/contexthandler/contexthandler.go | 14 +++-- .../contexthandler/contexthandler_test.go | 7 +++ pkg/services/contexthandler/model/model.go | 5 ++ public/app/app.ts | 32 +++++++++++- .../app/core/components/Login/LoginCtrl.tsx | 6 ++- public/app/core/services/context_srv.ts | 7 +++ 15 files changed, 176 insertions(+), 33 deletions(-) diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index f94fb4dd29d..6d0dc92df4a 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -191,6 +191,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa cfg, tracing.InitializeTracerForTest(), &authntest.FakeService{ExpectedIdentity: &authn.Identity{ID: "0", Type: claims.TypeAnonymous, SessionToken: &usertoken.UserToken{}}}, + featuremgmt.WithFeatures(), ) } diff --git a/pkg/api/login.go b/pkg/api/login.go index d28ccfb8377..de3bacdf7dc 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -137,7 +137,12 @@ func (hs *HTTPServer) LoginView(c *contextmodel.ReqContext) { } } - c.Redirect(hs.GetRedirectURL(c)) + if !c.UseSessionStorageRedirect { + c.Redirect(hs.GetRedirectURL(c)) + return + } + + c.Redirect(hs.Cfg.AppSubURL + "/") return } @@ -211,7 +216,7 @@ func (hs *HTTPServer) LoginPost(c *contextmodel.ReqContext) response.Response { } metrics.MApiLoginPost.Inc() - return authn.HandleLoginResponse(c.Req, c.Resp, hs.Cfg, identity, hs.ValidateRedirectTo) + return authn.HandleLoginResponse(c.Req, c.Resp, hs.Cfg, identity, hs.ValidateRedirectTo, hs.Features) } func (hs *HTTPServer) loginUserWithUser(user *user.User, c *contextmodel.ReqContext) error { diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 61cfa7d99e1..1fb9aaa3ce3 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -57,5 +57,5 @@ func (hs *HTTPServer) OAuthLogin(reqCtx *contextmodel.ReqContext) { } metrics.MApiLoginOAuth.Inc() - authn.HandleLoginRedirect(reqCtx.Req, reqCtx.Resp, hs.Cfg, identity, hs.ValidateRedirectTo) + authn.HandleLoginRedirect(reqCtx.Req, reqCtx.Resp, hs.Cfg, identity, hs.ValidateRedirectTo, hs.Features) } diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go index 166412292fe..7aedc5f0d5e 100644 --- a/pkg/api/user_token.go +++ b/pkg/api/user_token.go @@ -84,7 +84,11 @@ func (hs *HTTPServer) RotateUserAuthTokenRedirect(c *contextmodel.ReqContext) re return response.Redirect(hs.Cfg.AppSubURL + "/login") } - return response.Redirect(hs.GetRedirectURL(c)) + if !c.UseSessionStorageRedirect { + return response.Redirect(hs.GetRedirectURL(c)) + } + + return response.Redirect(hs.Cfg.AppSubURL + "/") } // swagger:route POST /user/auth-tokens/rotate diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 7ca1b5ebf25..338ceb77f25 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -44,14 +44,26 @@ func notAuthorized(c *contextmodel.ReqContext) { return } - writeRedirectCookie(c) + if !c.UseSessionStorageRedirect { + writeRedirectCookie(c) + } if errors.Is(c.LookupTokenErr, authn.ErrTokenNeedsRotation) { - c.Redirect(setting.AppSubUrl + "/user/auth-tokens/rotate") + if !c.UseSessionStorageRedirect { + c.Redirect(setting.AppSubUrl + "/user/auth-tokens/rotate") + return + } + + c.Redirect(setting.AppSubUrl + "/user/auth-tokens/rotate" + getRedirectToQueryParam(c)) return } - c.Redirect(setting.AppSubUrl + "/login") + if !c.UseSessionStorageRedirect { + c.Redirect(setting.AppSubUrl + "/login") + return + } + + c.Redirect(setting.AppSubUrl + "/login" + getRedirectToQueryParam(c)) } func tokenRevoked(c *contextmodel.ReqContext, err *auth.TokenRevokedError) { @@ -66,8 +78,13 @@ func tokenRevoked(c *contextmodel.ReqContext, err *auth.TokenRevokedError) { return } - writeRedirectCookie(c) - c.Redirect(setting.AppSubUrl + "/login") + if !c.UseSessionStorageRedirect { + writeRedirectCookie(c) + c.Redirect(setting.AppSubUrl + "/login") + return + } + + c.Redirect(setting.AppSubUrl + "/login" + getRedirectToQueryParam(c)) } func writeRedirectCookie(c *contextmodel.ReqContext) { @@ -85,6 +102,21 @@ func writeRedirectCookie(c *contextmodel.ReqContext) { cookies.WriteCookie(c.Resp, "redirect_to", url.QueryEscape(redirectTo), 0, nil) } +func getRedirectToQueryParam(c *contextmodel.ReqContext) string { + redirectTo := c.Req.RequestURI + if setting.AppSubUrl != "" && strings.HasPrefix(redirectTo, setting.AppSubUrl) { + redirectTo = strings.TrimPrefix(redirectTo, setting.AppSubUrl) + } + + if redirectTo == "/" { + return "" + } + + // remove any forceLogin=true params + redirectTo = removeForceLoginParams(redirectTo) + return "?redirectTo=" + url.QueryEscape(redirectTo) +} + var forceLoginParamsRegexp = regexp.MustCompile(`&?forceLogin=true`) func removeForceLoginParams(str string) string { diff --git a/pkg/middleware/auth_test.go b/pkg/middleware/auth_test.go index dd63f6cfaa3..3530c90f211 100644 --- a/pkg/middleware/auth_test.go +++ b/pkg/middleware/auth_test.go @@ -33,7 +33,7 @@ func setupAuthMiddlewareTest(t *testing.T, identity *authn.Identity, authErr err return contexthandler.ProvideService(setting.NewCfg(), tracing.InitializeTracerForTest(), &authntest.FakeService{ ExpectedErr: authErr, ExpectedIdentity: identity, - }) + }, featuremgmt.WithFeatures()) } func TestAuth_Middleware(t *testing.T) { diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 1510e6caab1..bd447ab5779 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/grafana/grafana/pkg/services/contexthandler" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/navtree" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" @@ -290,5 +291,5 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg, authnService authn.Servic t.Helper() tracer := tracing.InitializeTracerForTest() - return contexthandler.ProvideService(cfg, tracer, authnService) + return contexthandler.ProvideService(cfg, tracer, authnService, featuremgmt.WithFeatures()) } diff --git a/pkg/services/accesscontrol/middleware.go b/pkg/services/accesscontrol/middleware.go index 3b3b05b0d5a..9a5c3f89a47 100644 --- a/pkg/services/accesscontrol/middleware.go +++ b/pkg/services/accesscontrol/middleware.go @@ -98,8 +98,13 @@ func deny(c *contextmodel.ReqContext, evaluator Evaluator, err error) { if !c.IsApiRequest() { // TODO(emil): I'd like to show a message after this redirect, not sure how that can be done? - writeRedirectCookie(c) - c.Redirect(setting.AppSubUrl + "/") + if !c.UseSessionStorageRedirect { + writeRedirectCookie(c) + c.Redirect(setting.AppSubUrl + "/") + return + } + + c.Redirect(setting.AppSubUrl + "/" + getRedirectToQueryParam(c)) return } @@ -125,13 +130,25 @@ func unauthorized(c *contextmodel.ReqContext) { return } - writeRedirectCookie(c) + if !c.UseSessionStorageRedirect { + writeRedirectCookie(c) + } + if errors.Is(c.LookupTokenErr, authn.ErrTokenNeedsRotation) { - c.Redirect(setting.AppSubUrl + "/user/auth-tokens/rotate") + if !c.UseSessionStorageRedirect { + c.Redirect(setting.AppSubUrl + "/user/auth-tokens/rotate") + return + } + c.Redirect(setting.AppSubUrl + "/user/auth-tokens/rotate" + getRedirectToQueryParam(c)) return } - c.Redirect(setting.AppSubUrl + "/login") + if !c.UseSessionStorageRedirect { + c.Redirect(setting.AppSubUrl + "/login") + return + } + + c.Redirect(setting.AppSubUrl + "/login" + getRedirectToQueryParam(c)) } func tokenRevoked(c *contextmodel.ReqContext, err *usertoken.TokenRevokedError) { @@ -146,8 +163,13 @@ func tokenRevoked(c *contextmodel.ReqContext, err *usertoken.TokenRevokedError) return } - writeRedirectCookie(c) - c.Redirect(setting.AppSubUrl + "/login") + if !c.UseSessionStorageRedirect { + writeRedirectCookie(c) + c.Redirect(setting.AppSubUrl + "/login") + return + } + + c.Redirect(setting.AppSubUrl + "/login" + getRedirectToQueryParam(c)) } func writeRedirectCookie(c *contextmodel.ReqContext) { @@ -162,6 +184,21 @@ func writeRedirectCookie(c *contextmodel.ReqContext) { cookies.WriteCookie(c.Resp, "redirect_to", url.QueryEscape(redirectTo), 0, nil) } +func getRedirectToQueryParam(c *contextmodel.ReqContext) string { + redirectTo := c.Req.RequestURI + if setting.AppSubUrl != "" && strings.HasPrefix(redirectTo, setting.AppSubUrl) { + redirectTo = strings.TrimPrefix(redirectTo, setting.AppSubUrl) + } + + if redirectTo == "/" { + return "" + } + + // remove any forceLogin=true params + redirectTo = removeForceLoginParams(redirectTo) + return "?redirectTo=" + url.QueryEscape(redirectTo) +} + var forceLoginParamsRegexp = regexp.MustCompile(`&?forceLogin=true`) func removeForceLoginParams(str string) string { diff --git a/pkg/services/authn/authn.go b/pkg/services/authn/authn.go index 985858a7135..3c43822e9e8 100644 --- a/pkg/services/authn/authn.go +++ b/pkg/services/authn/authn.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/middleware/cookies" "github.com/grafana/grafana/pkg/models/usertoken" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/setting" ) @@ -230,24 +231,30 @@ func ClientWithPrefix(name string) string { type RedirectValidator func(url string) error // HandleLoginResponse is a utility function to perform common operations after a successful login and returns response.NormalResponse -func HandleLoginResponse(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator) *response.NormalResponse { +func HandleLoginResponse(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles) *response.NormalResponse { result := map[string]any{"message": "Logged in"} - result["redirectUrl"] = handleLogin(r, w, cfg, identity, validator) + result["redirectUrl"] = handleLogin(r, w, cfg, identity, validator, features) return response.JSON(http.StatusOK, result) } // HandleLoginRedirect is a utility function to perform common operations after a successful login and redirects -func HandleLoginRedirect(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator) { - redirectURL := handleLogin(r, w, cfg, identity, validator) +func HandleLoginRedirect(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles) { + redirectURL := handleLogin(r, w, cfg, identity, validator, features) http.Redirect(w, r, redirectURL, http.StatusFound) } // HandleLoginRedirectResponse is a utility function to perform common operations after a successful login and return a response.RedirectResponse -func HandleLoginRedirectResponse(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator) *response.RedirectResponse { - return response.Redirect(handleLogin(r, w, cfg, identity, validator)) +func HandleLoginRedirectResponse(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles) *response.RedirectResponse { + return response.Redirect(handleLogin(r, w, cfg, identity, validator, features)) } -func handleLogin(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator) string { +func handleLogin(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles) string { + WriteSessionCookie(w, cfg, identity.SessionToken) + + if features.IsEnabledGlobally(featuremgmt.FlagUseSessionStorageForRedirection) { + return cfg.AppSubURL + "/" + } + redirectURL := cfg.AppSubURL + "/" if redirectTo := getRedirectURL(r); len(redirectTo) > 0 { if validator(redirectTo) == nil { @@ -256,7 +263,6 @@ func handleLogin(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, ident cookies.DeleteCookie(w, "redirect_to", cookieOptions(cfg)) } - WriteSessionCookie(w, cfg, identity.SessionToken) return redirectURL } diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index b82bd0c3ed8..6e18a1405c9 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/authlib/claims" authnClients "github.com/grafana/grafana/pkg/services/authn/clients" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -25,12 +26,13 @@ import ( "github.com/grafana/grafana/pkg/web" ) -func ProvideService(cfg *setting.Cfg, tracer tracing.Tracer, authenticator authn.Authenticator, +func ProvideService(cfg *setting.Cfg, tracer tracing.Tracer, authenticator authn.Authenticator, features featuremgmt.FeatureToggles, ) *ContextHandler { return &ContextHandler{ Cfg: cfg, tracer: tracer, authenticator: authenticator, + features: features, } } @@ -39,6 +41,7 @@ type ContextHandler struct { Cfg *setting.Cfg tracer tracing.Tracer authenticator authn.Authenticator + features featuremgmt.FeatureToggles } type reqContextKey = ctxkey.Key @@ -92,10 +95,11 @@ func (h *ContextHandler) Middleware(next http.Handler) http.Handler { SignedInUser: &user.SignedInUser{ Permissions: map[int64]map[string][]string{}, }, - IsSignedIn: false, - AllowAnonymous: false, - SkipDSCache: false, - Logger: log.New("context"), + IsSignedIn: false, + AllowAnonymous: false, + SkipDSCache: false, + Logger: log.New("context"), + UseSessionStorageRedirect: h.features.IsEnabledGlobally(featuremgmt.FlagUseSessionStorageForRedirection), } // inject ReqContext in the context diff --git a/pkg/services/contexthandler/contexthandler_test.go b/pkg/services/contexthandler/contexthandler_test.go index 1a0458a48fe..0810bb04442 100644 --- a/pkg/services/contexthandler/contexthandler_test.go +++ b/pkg/services/contexthandler/contexthandler_test.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/grafana/grafana/pkg/services/contexthandler" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -28,6 +29,7 @@ func TestContextHandler(t *testing.T) { setting.NewCfg(), tracing.InitializeTracerForTest(), &authntest.FakeService{ExpectedErr: errors.New("some error")}, + featuremgmt.WithFeatures(), ) server := webtest.NewServer(t, routing.NewRouteRegister()) @@ -49,6 +51,7 @@ func TestContextHandler(t *testing.T) { setting.NewCfg(), tracing.InitializeTracerForTest(), &authntest.FakeService{ExpectedIdentity: id}, + featuremgmt.WithFeatures(), ) server := webtest.NewServer(t, routing.NewRouteRegister()) @@ -74,6 +77,7 @@ func TestContextHandler(t *testing.T) { setting.NewCfg(), tracing.InitializeTracerForTest(), &authntest.FakeService{ExpectedIdentity: identity}, + featuremgmt.WithFeatures(), ) server := webtest.NewServer(t, routing.NewRouteRegister()) @@ -95,6 +99,7 @@ func TestContextHandler(t *testing.T) { setting.NewCfg(), tracing.InitializeTracerForTest(), &authntest.FakeService{ExpectedIdentity: identity}, + featuremgmt.WithFeatures(), ) server := webtest.NewServer(t, routing.NewRouteRegister()) @@ -125,6 +130,7 @@ func TestContextHandler(t *testing.T) { cfg, tracing.InitializeTracerForTest(), &authntest.FakeService{ExpectedIdentity: &authn.Identity{}}, + featuremgmt.WithFeatures(), ) server := webtest.NewServer(t, routing.NewRouteRegister()) @@ -153,6 +159,7 @@ func TestContextHandler(t *testing.T) { cfg, tracing.InitializeTracerForTest(), &authntest.FakeService{ExpectedIdentity: &authn.Identity{ID: i, Type: typ}}, + featuremgmt.WithFeatures(), ) server := webtest.NewServer(t, routing.NewRouteRegister()) diff --git a/pkg/services/contexthandler/model/model.go b/pkg/services/contexthandler/model/model.go index e24375ef80e..32b524eca05 100644 --- a/pkg/services/contexthandler/model/model.go +++ b/pkg/services/contexthandler/model/model.go @@ -35,6 +35,11 @@ type ReqContext struct { PerfmonTimer prometheus.Summary LookupTokenErr error + + // FIXME: Remove this temporary flag after the rollout of FlagUseSessionStorageForRedirection feature flag + // Tracking issue for cleaning up this flag: https://github.com/grafana/identity-access-team/issues/908 + // UseSessionStorageRedirect is introduced to simplify the rollout of the new redirect logic + UseSessionStorageRedirect bool } // Handle handles and logs error by given status. diff --git a/public/app/app.ts b/public/app/app.ts index 64d22d7a504..97e50653ca5 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -62,7 +62,7 @@ import { setMonacoEnv } from './core/monacoEnv'; import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks'; import { NewFrontendAssetsChecker } from './core/services/NewFrontendAssetsChecker'; import { backendSrv } from './core/services/backend_srv'; -import { contextSrv } from './core/services/context_srv'; +import { contextSrv, RedirectToUrlKey } from './core/services/context_srv'; import { Echo } from './core/services/echo/Echo'; import { reportPerformance } from './core/services/echo/EchoSrv'; import { PerformanceBackend } from './core/services/echo/backends/PerformanceBackend'; @@ -196,6 +196,10 @@ export class GrafanaApp { getPanelPluginFromCache: syncGetPanelPlugin, }); + if (config.featureToggles.useSessionStorageForRedirection) { + handleRedirectTo(); + } + locationUtil.initialize({ config, getTimeRangeForUrl: getTimeSrv().timeRangeForUrl, @@ -382,4 +386,30 @@ function reportMetricPerformanceMark(metricName: string, prefix = '', suffix = ' } } +function handleRedirectTo(): void { + const queryParams = locationService.getSearch(); + const redirectToParamKey = 'redirectTo'; + + if (queryParams.has(redirectToParamKey) && window.location.pathname !== '/') { + const rawRedirectTo = queryParams.get(redirectToParamKey)!; + window.sessionStorage.setItem(RedirectToUrlKey, encodeURIComponent(rawRedirectTo)); + queryParams.delete(redirectToParamKey); + window.history.replaceState({}, '', `${window.location.pathname}${queryParams.size > 0 ? `?${queryParams}` : ''}`); + return; + } + + if (!contextSrv.user.isSignedIn) { + locationService.replace('/login'); + return; + } + + const redirectTo = window.sessionStorage.getItem(RedirectToUrlKey); + if (!redirectTo) { + return; + } + + window.sessionStorage.removeItem(RedirectToUrlKey); + locationService.replace(decodeURIComponent(redirectTo)); +} + export default new GrafanaApp(); diff --git a/public/app/core/components/Login/LoginCtrl.tsx b/public/app/core/components/Login/LoginCtrl.tsx index 580c372ceab..a8612613173 100644 --- a/public/app/core/components/Login/LoginCtrl.tsx +++ b/public/app/core/components/Login/LoginCtrl.tsx @@ -118,7 +118,11 @@ export class LoginCtrl extends PureComponent { }; toGrafana = () => { - // Use window.location.href to force page reload + if (config.featureToggles.useSessionStorageForRedirection) { + window.location.assign(config.appSubUrl + '/'); + return; + } + if (this.result?.redirectUrl) { if (config.appSubUrl !== '' && !this.result.redirectUrl.startsWith(config.appSubUrl)) { window.location.assign(config.appSubUrl + this.result.redirectUrl); diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index aeb1371a855..54e5c27594b 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -19,6 +19,7 @@ import config from '../../core/config'; // When set to auto, the interval will be based on the query range // NOTE: this is defined here rather than TimeSrv so we avoid circular dependencies export const AutoRefreshInterval = 'auto'; +export const RedirectToUrlKey = 'redirectTo'; export class User implements Omit { isSignedIn: boolean; @@ -119,6 +120,12 @@ export class ContextSrv { * Indicate the user has been logged out */ setLoggedOut() { + if (config.featureToggles.useSessionStorageForRedirection) { + window.sessionStorage.setItem( + RedirectToUrlKey, + encodeURIComponent(window.location.href.substring(window.location.origin.length)) + ); + } this.cancelTokenRotationJob(); this.user.isSignedIn = false; this.isSignedIn = false; From 090958489c51c4487fd1f79d733ab90925d92964 Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Tue, 24 Sep 2024 17:45:22 +0100 Subject: [PATCH 009/102] MSSQL: Update dependencies (#93692) Use appropriate deps and bumpy TS to match root --- public/app/plugins/datasource/mssql/package.json | 8 ++++---- yarn.lock | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/public/app/plugins/datasource/mssql/package.json b/public/app/plugins/datasource/mssql/package.json index 545313fae78..89bf3961f2e 100644 --- a/public/app/plugins/datasource/mssql/package.json +++ b/public/app/plugins/datasource/mssql/package.json @@ -5,7 +5,7 @@ "version": "11.3.0-pre", "dependencies": { "@emotion/css": "11.11.2", - "@grafana/data": "workspace:*", + "@grafana/data": "11.3.0-pre", "@grafana/experimental": "1.8.0", "@grafana/runtime": "11.3.0-pre", "@grafana/sql": "11.3.0-pre", @@ -16,8 +16,8 @@ "tslib": "2.7.0" }, "devDependencies": { - "@grafana/e2e-selectors": "workspace:*", - "@grafana/plugin-configs": "workspace:*", + "@grafana/e2e-selectors": "11.3.0-pre", + "@grafana/plugin-configs": "11.3.0-pre", "@testing-library/react": "15.0.2", "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.13", @@ -26,7 +26,7 @@ "@types/react": "18.3.3", "@types/testing-library__jest-dom": "5.14.9", "ts-node": "10.9.2", - "typescript": "5.4.5", + "typescript": "5.5.4", "webpack": "5.94.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 73c49e56649..50f6e29e069 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3317,10 +3317,10 @@ __metadata: resolution: "@grafana-plugins/mssql@workspace:public/app/plugins/datasource/mssql" dependencies: "@emotion/css": "npm:11.11.2" - "@grafana/data": "workspace:*" - "@grafana/e2e-selectors": "workspace:*" + "@grafana/data": "npm:11.3.0-pre" + "@grafana/e2e-selectors": "npm:11.3.0-pre" "@grafana/experimental": "npm:1.8.0" - "@grafana/plugin-configs": "workspace:*" + "@grafana/plugin-configs": "npm:11.3.0-pre" "@grafana/runtime": "npm:11.3.0-pre" "@grafana/sql": "npm:11.3.0-pre" "@grafana/ui": "npm:11.3.0-pre" @@ -3336,7 +3336,7 @@ __metadata: rxjs: "npm:7.8.1" ts-node: "npm:10.9.2" tslib: "npm:2.7.0" - typescript: "npm:5.4.5" + typescript: "npm:5.5.4" webpack: "npm:5.94.0" peerDependencies: "@grafana/runtime": "*" From 7e61d4f80de76ad396997a060293d8962ce8d468 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 17:46:50 +0100 Subject: [PATCH 010/102] Update dependency babel-loader to v9.2.1 (#93681) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 6a8e3ecbebc..810c15e574f 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,7 @@ "@typescript-eslint/eslint-plugin": "6.21.0", "@typescript-eslint/parser": "6.21.0", "autoprefixer": "10.4.20", - "babel-loader": "9.1.3", + "babel-loader": "9.2.1", "blob-polyfill": "9.0.20240710", "browserslist": "^4.21.4", "chance": "^1.0.10", diff --git a/yarn.lock b/yarn.lock index 50f6e29e069..900ad043de8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12425,16 +12425,16 @@ __metadata: languageName: node linkType: hard -"babel-loader@npm:9.1.3": - version: 9.1.3 - resolution: "babel-loader@npm:9.1.3" +"babel-loader@npm:9.2.1": + version: 9.2.1 + resolution: "babel-loader@npm:9.2.1" dependencies: find-cache-dir: "npm:^4.0.0" schema-utils: "npm:^4.0.0" peerDependencies: "@babel/core": ^7.12.0 webpack: ">=5" - checksum: 10/7086e678273b5d1261141dca84ed784caab9f7921c8c24d7278c8ee3088235a9a9fd85caac9f0fa687336cb3c27248ca22dbf431469769b1b995d55aec606992 + checksum: 10/f1f24ae3c22d488630629240b0eba9c935545f82ff843c214e8f8df66e266492b7a3d4cb34ef9c9721fb174ca222e900799951c3fd82199473bc6bac52ec03a3 languageName: node linkType: hard @@ -18937,7 +18937,7 @@ __metadata: angular-sanitize: "npm:1.8.3" ansicolor: "npm:1.1.100" autoprefixer: "npm:10.4.20" - babel-loader: "npm:9.1.3" + babel-loader: "npm:9.2.1" baron: "npm:3.0.3" blob-polyfill: "npm:9.0.20240710" brace: "npm:0.11.1" From 488e71226b34845fa0efa9980e942fa10f506979 Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Tue, 24 Sep 2024 18:36:07 +0100 Subject: [PATCH 011/102] InfluxDB: Add support for LIST arrow type (#93227) Add support for LIST arrow type --- pkg/tsdb/influxdb/fsql/arrow.go | 19 ++++++++++++++++++- pkg/tsdb/influxdb/fsql/arrow_test.go | 14 +++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/pkg/tsdb/influxdb/fsql/arrow.go b/pkg/tsdb/influxdb/fsql/arrow.go index 965e17f8357..e8db01c730d 100644 --- a/pkg/tsdb/influxdb/fsql/arrow.go +++ b/pkg/tsdb/influxdb/fsql/arrow.go @@ -151,6 +151,9 @@ func newField(f arrow.Field) *data.Field { return newDataField[time.Time](f) case arrow.DURATION: return newDataField[int64](f) + case arrow.LIST: + nestedType := f.Type.(*arrow.ListType).ElemField() + return newField(nestedType) default: return newDataField[json.RawMessage](f) } @@ -166,6 +169,8 @@ func newDataField[T any](f arrow.Field) *data.Field { } // copyData copies the contents of an Arrow column into a Data Frame field. +// +//nolint:gocyclo func copyData(field *data.Field, col arrow.Array) error { defer func() { if r := recover(); r != nil { @@ -174,7 +179,6 @@ func copyData(field *data.Field, col arrow.Array) error { }() colData := col.Data() - switch col.DataType().ID() { case arrow.TIMESTAMP: v := array.NewTimestampData(colData) @@ -221,6 +225,19 @@ func copyData(field *data.Field, col arrow.Array) error { } field.Append(json.RawMessage(b)) } + case arrow.LIST: + v := array.NewListData(colData) + for i := 0; i < v.Len(); i++ { + sc, err := scalar.GetScalar(v, i) + if err != nil { + return err + } + + err = copyData(field, sc.(*scalar.List).Value) + if err != nil { + return err + } + } case arrow.STRING: copyBasic[string](field, array.NewStringData(colData)) case arrow.UINT8: diff --git a/pkg/tsdb/influxdb/fsql/arrow_test.go b/pkg/tsdb/influxdb/fsql/arrow_test.go index 32d260a1152..1bbdc51e919 100644 --- a/pkg/tsdb/influxdb/fsql/arrow_test.go +++ b/pkg/tsdb/influxdb/fsql/arrow_test.go @@ -37,6 +37,8 @@ func TestNewQueryDataResponse(t *testing.T) { {Name: "utf8", Type: &arrow.StringType{}}, {Name: "duration", Type: &arrow.DurationType{}}, {Name: "timestamp", Type: &arrow.TimestampType{}}, + + {Name: "item", Type: arrow.ListOf(&arrow.StringType{})}, }, nil, ) @@ -58,6 +60,8 @@ func TestNewQueryDataResponse(t *testing.T) { newJSONArray(`["foo", "bar", "baz"]`, &arrow.StringType{}), newJSONArray(`[0, 1, -2]`, &arrow.DurationType{}), newJSONArray(`[0, 1, 2]`, &arrow.TimestampType{}), + + newJSONArray(`[["test", "test1", "test2"],[],[]]`, arrow.ListOf(&arrow.StringType{})), } arr := make([]arrow.Array, 0, len(strValues)) @@ -82,7 +86,7 @@ func TestNewQueryDataResponse(t *testing.T) { resp := newQueryDataResponse(errReader{RecordReader: reader}, query, metadata.MD{}) assert.NoError(t, resp.Error) assert.Len(t, resp.Frames, 1) - assert.Len(t, resp.Frames[0].Fields, 13) + assert.Len(t, resp.Frames[0].Fields, 14) frame := resp.Frames[0] f0 := frame.Fields[0] @@ -156,6 +160,14 @@ func TestNewQueryDataResponse(t *testing.T) { }, extractFieldValues[time.Time](t, f12), ) + + s1 := "test" + s2 := "test1" + s3 := "test2" + f13 := frame.Fields[13] + assert.Equal(t, f13.Name, "item") + assert.Equal(t, f13.Type(), data.FieldTypeNullableString) + assert.Equal(t, []*string{&s1, &s2, &s3}, extractFieldValues[*string](t, f13)) } type jsonArray struct { From 76c8975b4f25a62a71e631642dfb2c8d0bac6d5f Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Tue, 24 Sep 2024 18:45:08 +0100 Subject: [PATCH 012/102] AzureMonitor: Deduplicate resource picker rows (#93129) * Use URI for querying resources in RG - Ensure resource group names are correctly slash separated * Update test --- .../resourcePicker/resourcePickerData.test.ts | 4 ++-- .../azuremonitor/resourcePicker/resourcePickerData.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.test.ts b/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.test.ts index 8764d34a017..85cdc304c37 100644 --- a/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.test.ts +++ b/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.test.ts @@ -225,14 +225,14 @@ describe('AzureMonitor resourcePickerData', () => { it('makes 1 call to ARG with the correct path and query arguments', async () => { const mockResponse = createARGResourcesResponse(); const { resourcePickerData, postResource } = createResourcePickerData([mockResponse]); - await resourcePickerData.getResourcesForResourceGroup('dev', 'logs'); + await resourcePickerData.getResourcesForResourceGroup('/subscription/sub1/resourceGroups/dev', 'logs'); expect(postResource).toBeCalledTimes(1); const firstCall = postResource.mock.calls[0]; const [path, postBody] = firstCall; expect(path).toEqual('resourcegraph/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01'); expect(postBody.query).toContain('resources'); - expect(postBody.query).toContain('where id hasprefix "dev"'); + expect(postBody.query).toContain('where id hasprefix "/subscription/sub1/resourceGroups/dev/"'); }); it('returns formatted resources', async () => { diff --git a/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts b/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts index abd86180a94..80282cb71a7 100644 --- a/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts +++ b/public/app/plugins/datasource/azuremonitor/resourcePicker/resourcePickerData.ts @@ -91,7 +91,7 @@ export default class ResourcePickerData extends DataSourceWithBackend { + // We can use subscription ID for the filtering here as they're unique const query = ` resources | join kind=inner ( @@ -230,12 +231,15 @@ export default class ResourcePickerData extends DataSourceWithBackend { + // We use resource group URI for the filtering here because resource group names are not unique across subscriptions + // We also add a slash at the end of the resource group URI to ensure we do not pull resources from a resource group + // that has a similar naming prefix e.g. resourceGroup1 and resourceGroup10 const { data: response } = await this.makeResourceGraphRequest(` resources - | where id hasprefix "${resourceGroupId}" + | where id hasprefix "${resourceGroupUri}/" ${await this.filterByType(type)} `); From 564ee32b04e2e1c55c4fb1e6d999981c7fbdc992 Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Tue, 24 Sep 2024 18:45:44 +0100 Subject: [PATCH 013/102] AzureMonitor: Improve errorsource (#93533) * Improve errorsource * Fix imports --- .../azure-log-analytics-datasource.go | 18 +++++------------- pkg/tsdb/azuremonitor/loganalytics/traces.go | 6 ++++-- .../azure-resource-graph-datasource.go | 1 - pkg/tsdb/azuremonitor/utils/utils.go | 14 ++++++++++++-- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go index 44c22852513..f06ef71f098 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go @@ -6,7 +6,6 @@ import ( "context" "encoding/base64" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -26,6 +25,7 @@ import ( "github.com/grafana/grafana/pkg/tsdb/azuremonitor/kinds/dataquery" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/macros" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/utils" ) func (e *AzureLogAnalyticsDatasource) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) (http.ResponseWriter, error) { @@ -243,11 +243,7 @@ func (e *AzureLogAnalyticsDatasource) buildQuery(ctx context.Context, query back azureLogAnalyticsQuery, err = buildLogAnalyticsQuery(query, dsInfo, appInsightsRegExp, fromAlert) if err != nil { errorMessage := fmt.Errorf("failed to build azure log analytics query: %w", err) - var sourceError errorsource.Error - if errors.As(err, &sourceError) { - return nil, errorsource.SourceError(sourceError.Source(), errorMessage, false) - } - return nil, errorMessage + return nil, utils.ApplySourceFromError(errorMessage, err) } } @@ -262,11 +258,7 @@ func (e *AzureLogAnalyticsDatasource) buildQuery(ctx context.Context, query back azureAppInsightsQuery, err := buildAppInsightsQuery(ctx, query, dsInfo, appInsightsRegExp, e.Logger) if err != nil { errorMessage := fmt.Errorf("failed to build azure application insights query: %w", err) - var sourceError errorsource.Error - if errors.As(err, &sourceError) { - return nil, errorsource.SourceError(sourceError.Source(), errorMessage, false) - } - return nil, errorMessage + return nil, utils.ApplySourceFromError(errorMessage, err) } azureLogAnalyticsQuery = azureAppInsightsQuery } @@ -612,11 +604,11 @@ func getCorrelationWorkspaces(ctx context.Context, baseResource string, resource res, err := azMonService.HTTPClient.Do(req) if err != nil { - return AzureCorrelationAPIResponse{}, err + return AzureCorrelationAPIResponse{}, errorsource.DownstreamError(err, false) } body, err := io.ReadAll(res.Body) if err != nil { - return AzureCorrelationAPIResponse{}, err + return AzureCorrelationAPIResponse{}, errorsource.DownstreamError(err, false) } defer func() { diff --git a/pkg/tsdb/azuremonitor/loganalytics/traces.go b/pkg/tsdb/azuremonitor/loganalytics/traces.go index fbc8e961799..e5c2824ca07 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/traces.go +++ b/pkg/tsdb/azuremonitor/loganalytics/traces.go @@ -201,7 +201,8 @@ func buildAppInsightsQuery(ctx context.Context, query backend.DataQuery, dsInfo if query.QueryType == string(dataquery.AzureQueryTypeTraceql) { subscription, err := utils.GetFirstSubscriptionOrDefault(ctx, dsInfo, logger) if err != nil { - return nil, fmt.Errorf("failed to retrieve subscription for trace exemplars query: %w", err) + errorMessage := fmt.Errorf("failed to retrieve subscription for trace exemplars query: %w", err) + return nil, utils.ApplySourceFromError(errorMessage, err) } resources = []string{fmt.Sprintf("/subscriptions/%s", subscription)} } @@ -222,7 +223,8 @@ func buildAppInsightsQuery(ctx context.Context, query backend.DataQuery, dsInfo operationId = *queryJSONModel.AzureTraces.OperationId resourcesMap, err = getCorrelationWorkspaces(ctx, resourceOrWorkspace, resourcesMap, dsInfo, operationId) if err != nil { - return nil, fmt.Errorf("failed to retrieve correlation resources for operation ID - %s: %s", operationId, err) + errorMessage := fmt.Errorf("failed to retrieve correlation resources for operation ID - %s: %s", operationId, err) + return nil, utils.ApplySourceFromError(errorMessage, err) } } diff --git a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go index d1252ed167c..22667b74968 100644 --- a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go +++ b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go @@ -137,7 +137,6 @@ func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query * } req, err := e.createRequest(ctx, reqBody, dsURL) - if err != nil { return nil, err } diff --git a/pkg/tsdb/azuremonitor/utils/utils.go b/pkg/tsdb/azuremonitor/utils/utils.go index d6149c8e21b..bcf9026f533 100644 --- a/pkg/tsdb/azuremonitor/utils/utils.go +++ b/pkg/tsdb/azuremonitor/utils/utils.go @@ -3,10 +3,12 @@ package utils import ( "context" "encoding/json" + "errors" "fmt" "net/http" "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) @@ -25,7 +27,7 @@ func GetFirstSubscriptionOrDefault(ctx context.Context, dsInfo types.DatasourceI res, err := dsInfo.Services["Azure Monitor"].HTTPClient.Do(request) if err != nil { - return "", fmt.Errorf("failed to retrieve subscriptions: %v", err) + return "", errorsource.DownstreamError(fmt.Errorf("failed to retrieve subscriptions: %v", err), false) } defer func() { if err := res.Body.Close(); err != nil { @@ -39,7 +41,7 @@ func GetFirstSubscriptionOrDefault(ctx context.Context, dsInfo types.DatasourceI } if len(subscriptions) == 0 { - return "", fmt.Errorf("no subscriptions found: %v", err) + return "", errorsource.DownstreamError(fmt.Errorf("no subscriptions found: %v", err), false) } return subscriptions[0], nil @@ -68,3 +70,11 @@ func ParseSubscriptions(res *http.Response, logger log.Logger) ([]string, error) return result, nil } + +func ApplySourceFromError(errorMessage error, err error) error { + var sourceError errorsource.Error + if errors.As(err, &sourceError) { + return errorsource.SourceError(sourceError.Source(), errorMessage, false) + } + return errorMessage +} From 59af06e36af96acab86aec27fbdd4a001a1bfe34 Mon Sep 17 00:00:00 2001 From: Misi Date: Tue, 24 Sep 2024 19:53:29 +0200 Subject: [PATCH 014/102] Devenv: Add serve_from_sub_path scenario to nginx devenv (#93691) Setup serve_from_sub_path scenario with nginx --- .../nginx_serve_from_sub_path.conf | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 devenv/docker/blocks/auth/nginx_proxy/nginx_serve_from_sub_path.conf diff --git a/devenv/docker/blocks/auth/nginx_proxy/nginx_serve_from_sub_path.conf b/devenv/docker/blocks/auth/nginx_proxy/nginx_serve_from_sub_path.conf new file mode 100644 index 00000000000..5da5207657b --- /dev/null +++ b/devenv/docker/blocks/auth/nginx_proxy/nginx_serve_from_sub_path.conf @@ -0,0 +1,39 @@ +events { worker_connections 1024; } + +http { + # This is required to proxy Grafana Live WebSocket connections. + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + upstream grafana { + server host.docker.internal:3000; + } + + server { + listen 8090; + + location / { + root /var/www/html; + } + + # Set the followings in grafana.ini: + # [server] + # root_url = http://localhost:8090/grafana/ + # serve_from_sub_path = true + location /grafana/ { + proxy_set_header Host $host; + proxy_pass http://grafana; + } + + # Proxy Grafana Live WebSocket connections. + location /grafana/api/live/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_pass http://grafana; + } + } +} \ No newline at end of file From 377079d8b320cc8d7bd61c1ba4154c56e9043754 Mon Sep 17 00:00:00 2001 From: maicon Date: Tue, 24 Sep 2024 16:03:15 -0300 Subject: [PATCH 015/102] UniStore: Add config to enable periodic DualWriter DataSyncer (#93555) Signed-off-by: Maicon Costa --- pkg/services/apiserver/README.md | 1 + pkg/services/apiserver/builder/helper.go | 6 +++++- pkg/services/apiserver/config.go | 5 ----- pkg/services/apiserver/options/storage.go | 3 --- pkg/setting/setting.go | 3 ++- pkg/setting/setting_unified_storage.go | 9 ++++++++- pkg/setting/setting_unified_storage_test.go | 8 +++++++- 7 files changed, 23 insertions(+), 12 deletions(-) diff --git a/pkg/services/apiserver/README.md b/pkg/services/apiserver/README.md index f85e39fe880..9bbfd8fe01b 100644 --- a/pkg/services/apiserver/README.md +++ b/pkg/services/apiserver/README.md @@ -67,6 +67,7 @@ kubernetesPlaylists = true [unified_storage.playlists.playlist.grafana.app] dualWriterMode = 2 +dualWriterPeriodicDataSyncJobEnabled = true ``` This will create a development kubeconfig and start a parallel ssl listener. It can be registered by diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index a088be374db..4b633de7e4b 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -170,9 +170,13 @@ func InstallAPIs( // Get the option from custom.ini/command line // when missing this will default to mode zero (legacy only) var mode = grafanarest.DualWriterMode(0) + + var dualWriterPeriodicDataSyncJobEnabled bool + resourceConfig, resourceExists := storageOpts.UnifiedStorageConfig[key] if resourceExists { mode = resourceConfig.DualWriterMode + dualWriterPeriodicDataSyncJobEnabled = resourceConfig.DualWriterPeriodicDataSyncJobEnabled } // Force using storage only -- regardless of internal synchronization state @@ -198,7 +202,7 @@ func InstallAPIs( default: } - if storageOpts.DualWriterDataSyncJobEnabled[key] { + if dualWriterPeriodicDataSyncJobEnabled { grafanarest.StartPeriodicDataSyncer(ctx, currentMode, legacy, storage, key, reg, serverLock, requestInfo) } diff --git a/pkg/services/apiserver/config.go b/pkg/services/apiserver/config.go index 79ecd961fb1..adde28a322a 100644 --- a/pkg/services/apiserver/config.go +++ b/pkg/services/apiserver/config.go @@ -60,11 +60,6 @@ func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o unifiedStorageCfg := cfg.UnifiedStorage o.StorageOptions.UnifiedStorageConfig = unifiedStorageCfg - o.StorageOptions.DualWriterDataSyncJobEnabled = map[string]bool{ - // TODO: This will be enabled later, when we get a dedicated config section for unified_storage - // playlist.RESOURCE + "." + playlist.GROUP: true, - } - o.ExtraOptions.DevMode = features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerEnsureKubectlAccess) o.ExtraOptions.ExternalAddress = host o.ExtraOptions.APIURL = apiURL diff --git a/pkg/services/apiserver/options/storage.go b/pkg/services/apiserver/options/storage.go index a031b06007a..512619baa9e 100644 --- a/pkg/services/apiserver/options/storage.go +++ b/pkg/services/apiserver/options/storage.go @@ -32,9 +32,6 @@ type StorageOptions struct { // The desired storage type // {resource}.{group} = 1|2|3|4 UnifiedStorageConfig map[string]setting.UnifiedStorageConfig - - // TODO... this will be moved to UnifiedStorageConfig - DualWriterDataSyncJobEnabled map[string]bool } func NewStorageOptions() *StorageOptions { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 805f36a160a..4c5f3bf5a48 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -531,7 +531,8 @@ type Cfg struct { } type UnifiedStorageConfig struct { - DualWriterMode rest.DualWriterMode + DualWriterMode rest.DualWriterMode + DualWriterPeriodicDataSyncJobEnabled bool } type InstallPlugin struct { diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index d26dc6c3c06..c004ea29b45 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -25,7 +25,14 @@ func (cfg *Cfg) setUnifiedStorageConfig() { // parse dualWriter modes from the section dualWriterMode := section.Key("dualWriterMode").MustInt(0) - storageConfig[resourceName] = UnifiedStorageConfig{DualWriterMode: rest.DualWriterMode(dualWriterMode)} + + // parse dualWriter periodic data syncer config + dualWriterPeriodicDataSyncJobEnabled := section.Key("dualWriterPeriodicDataSyncJobEnabled").MustBool(false) + + storageConfig[resourceName] = UnifiedStorageConfig{ + DualWriterMode: rest.DualWriterMode(dualWriterMode), + DualWriterPeriodicDataSyncJobEnabled: dualWriterPeriodicDataSyncJobEnabled, + } } cfg.UnifiedStorage = storageConfig } diff --git a/pkg/setting/setting_unified_storage_test.go b/pkg/setting/setting_unified_storage_test.go index bb46ad9a18b..b9227e3a3ee 100644 --- a/pkg/setting/setting_unified_storage_test.go +++ b/pkg/setting/setting_unified_storage_test.go @@ -18,11 +18,17 @@ func TestCfg_setUnifiedStorageConfig(t *testing.T) { _, err = s.NewKey("dualWriterMode", "2") assert.NoError(t, err) + _, err = s.NewKey("dualWriterPeriodicDataSyncJobEnabled", "true") + assert.NoError(t, err) + cfg.setUnifiedStorageConfig() value, exists := cfg.UnifiedStorage["playlists.playlist.grafana.app"] assert.Equal(t, exists, true) - assert.Equal(t, value, UnifiedStorageConfig{DualWriterMode: 2}) + assert.Equal(t, value, UnifiedStorageConfig{ + DualWriterMode: 2, + DualWriterPeriodicDataSyncJobEnabled: true, + }) }) } From a0122187b0400c7ee42d5ae87e97356645b29be8 Mon Sep 17 00:00:00 2001 From: Tokoro Date: Wed, 25 Sep 2024 04:18:15 +0900 Subject: [PATCH 016/102] Dashboards: Check if Grafana Javascript agent is enabled before using faro.api (#90509) Check if Grafana Javascript agent is enabled before using faro.api --- public/app/features/dashboard/dashgrid/panelOptionsLogger.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts b/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts index 21748075306..0e75d7fdad1 100644 --- a/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts +++ b/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts @@ -1,5 +1,6 @@ import { FieldConfigSource } from '@grafana/data'; import { faro } from '@grafana/faro-web-sdk'; +import { config } from 'app/core/config'; import { FIELD_CONFIG_CUSTOM_KEY, FIELD_CONFIG_OVERRIDES_KEY, PanelLogEvents } from 'app/core/log_events'; interface PanelLogInfo { @@ -29,6 +30,9 @@ export class PanelOptionsLogger { }; logPanelEvent = (eventName: string, newKey: string, newVal: string, oldVal?: string) => { + if (!config.grafanaJavascriptAgent.enabled) { + return; + } const logObj = { key: newKey, newValue: newVal, From 1cbe8772c2b3ac3356782e5f4feee29f741bbd60 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Tue, 24 Sep 2024 16:12:08 -0400 Subject: [PATCH 017/102] Docs: Restructure Search content (#93636) --- .../assess-dashboard-usage/index.md | 2 +- .../dashboards/build-dashboards/_index.md | 2 +- .../manage-dashboard-links/index.md | 2 +- .../create-manage-playlists/index.md | 2 +- .../dashboards/create-reports/index.md | 2 +- .../dashboards/dashboard-public/index.md | 2 +- .../dashboards/manage-dashboards/index.md | 2 +- .../dashboards/search-dashboards/index.md | 109 ++++++++++++++++++ .../share-dashboards-panels/index.md | 2 +- .../troubleshoot-dashboards/index.md | 2 +- .../dashboards/use-dashboards/index.md | 2 +- docs/sources/dashboards/variables/_index.md | 2 +- docs/sources/search/_index.md | 109 ++---------------- 13 files changed, 132 insertions(+), 108 deletions(-) create mode 100644 docs/sources/dashboards/search-dashboards/index.md diff --git a/docs/sources/dashboards/assess-dashboard-usage/index.md b/docs/sources/dashboards/assess-dashboard-usage/index.md index 260ed8baaf0..b74d9d6090b 100644 --- a/docs/sources/dashboards/assess-dashboard-usage/index.md +++ b/docs/sources/dashboards/assess-dashboard-usage/index.md @@ -17,7 +17,7 @@ labels: - cloud - enterprise title: Assess dashboard usage -weight: 200 +weight: 900 refs: grafana-enterprise: - pattern: /docs/grafana/ diff --git a/docs/sources/dashboards/build-dashboards/_index.md b/docs/sources/dashboards/build-dashboards/_index.md index 7077ff30bdd..4c228c3458c 100644 --- a/docs/sources/dashboards/build-dashboards/_index.md +++ b/docs/sources/dashboards/build-dashboards/_index.md @@ -14,7 +14,7 @@ labels: menuTitle: Build dashboards title: Build dashboards description: Build dashboards including managing settings, links, and version history -weight: 2 +weight: 200 refs: variables: - pattern: /docs/grafana/ diff --git a/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md b/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md index d0917600909..77c697944d1 100644 --- a/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md +++ b/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md @@ -22,7 +22,7 @@ labels: - oss menuTitle: Manage dashboard links title: Manage dashboard links -weight: 500 +weight: 200 refs: data-links: - pattern: /docs/grafana/ diff --git a/docs/sources/dashboards/create-manage-playlists/index.md b/docs/sources/dashboards/create-manage-playlists/index.md index 07e51ee44b7..cae7a433784 100644 --- a/docs/sources/dashboards/create-manage-playlists/index.md +++ b/docs/sources/dashboards/create-manage-playlists/index.md @@ -15,7 +15,7 @@ labels: menuTitle: Manage playlists title: Manage playlists description: Create and manage dashboard playlists -weight: 9 +weight: 500 --- # Manage playlists diff --git a/docs/sources/dashboards/create-reports/index.md b/docs/sources/dashboards/create-reports/index.md index ae06d3becc9..5ce55c6b50e 100644 --- a/docs/sources/dashboards/create-reports/index.md +++ b/docs/sources/dashboards/create-reports/index.md @@ -17,7 +17,7 @@ labels: menuTitle: Reporting title: Create and manage reports description: Generate and share PDF reports from your Grafana dashboards -weight: 85 +weight: 600 refs: repeat-panels-or-rows: - pattern: /docs/grafana/ diff --git a/docs/sources/dashboards/dashboard-public/index.md b/docs/sources/dashboards/dashboard-public/index.md index d6cbde2c78a..3e1f935478e 100644 --- a/docs/sources/dashboards/dashboard-public/index.md +++ b/docs/sources/dashboards/dashboard-public/index.md @@ -6,7 +6,7 @@ labels: - oss title: Public dashboards description: Make your Grafana dashboards public and share them with anyone -weight: 8 +weight: 350 refs: dashboard-sharing: - pattern: /docs/grafana/ diff --git a/docs/sources/dashboards/manage-dashboards/index.md b/docs/sources/dashboards/manage-dashboards/index.md index 6853bcb02a7..2cb660a2642 100644 --- a/docs/sources/dashboards/manage-dashboards/index.md +++ b/docs/sources/dashboards/manage-dashboards/index.md @@ -19,7 +19,7 @@ labels: menuTitle: Manage dashboards title: Manage dashboards description: Learn about dashboard management and generative AI features for dashboards -weight: 8 +weight: 300 refs: build-dashboards: - pattern: /docs/grafana/ diff --git a/docs/sources/dashboards/search-dashboards/index.md b/docs/sources/dashboards/search-dashboards/index.md new file mode 100644 index 00000000000..2524de95867 --- /dev/null +++ b/docs/sources/dashboards/search-dashboards/index.md @@ -0,0 +1,109 @@ +--- +description: Learn how to search for Grafana dashboards and folders +keywords: + - search + - dashboard + - folder +labels: + products: + - cloud + - enterprise + - oss +menutitle: Search dashboards +title: Search dashboards and folders +weight: 400 +--- + +# Search dashboards and folders + +You can search for dashboards and dashboard folders by name. + +When you search for dashboards, you can also do it by panel title. Whether you search by name or panel title, the system returns all dashboards available within the Grafana instance, even if you do not have permission to view the contents of the dashboard. + +## Search by name + +Begin typing any part of the dashboard or folder name in the search bar. The search returns results for any partial string match in real-time, as you type. + +The search is: + +- Real-time +- _Not_ case sensitive +- Functional across stored _and_ file based dashboards and folders. + +{{% admonition type="note" %}} +You can use your keyboard arrow keys to navigate the results and press `Enter` to open the selected dashboard or folder. +{{% /admonition %}} + +The following images show: + +Searching by dashboard name from the **Dashboards** page. + +{{< figure src="/media/docs/grafana/dashboards/search-for-dashboard.png" width="700px" >}} + +Searching by folder name from the **Dashboards** page. + +{{< figure src="/media/docs/grafana/dashboards/search-folder.png" width="700px" >}} + +Searching by dashboard name inside a folder. + +{{< figure src="/media/docs/grafana/dashboards/search-in-folder.png" width="700px" >}} + +{{% admonition type="note" %}} +When you search within a folder, its subfolders are not part of the results returned. You need to be on the **Dashboards** page (or the root level) to search for subfolders by name. +{{% /admonition %}} + +## Search dashboards using panel title + +You can search for a dashboard by the title of a panel that appears in a dashboard. +If a panel's title matches your search query, the dashboard appears in the search results. + +This feature is available by default in Grafana Cloud and in Grafana OSS v9.1 and higher, you access this feature by enabling the `panelTitleSearch` feature toggle. +For more information about enabling panel title search, refer to [Enable the panelTitleSearch feature toggle.](#enable-panelTitleSearch-feature-toggle) + +The following image shows the search results when you search using panel title. + +{{< figure src="/static/img/docs/v91/dashboard-features/search-by-panel-title.png" width="700px" >}} + +### Enable the panelTitleSearch feature toggle + +Complete the following steps to enable the `panelTitleSearch` feature toggle. + +**Before you begin:** + +- If you are running Grafana Enterprise with RBAC, enable [service accounts]({{< relref "../administration/service-accounts/" >}}). + +**To enable the panelTitleSearch feature toggle:** + +1. Open the Grafana [configuration file]({{< relref "../setup-grafana/configure-grafana/#configuration-file-location" >}}). + +1. Locate the [feature_toggles]({{< relref "../setup-grafana/configure-grafana/#feature_toggles" >}}) section. + +1. Add the following parameter to the `feature_toggles` section: + + ``` + [feature_toggles] + # enable features, separated by spaces + enable = panelTitleSearch + ``` + +1. Save your changes and restart the Grafana server. + +## Filter dashboard search results by tag(s) + +Tags are a great way to organize your dashboards, especially as the number of dashboards grow. You can add and manage tags in dashboard `Settings`. + +When you select multiple tags, Grafana shows dashboards that include all selected tags. + +To filter dashboard search result by a tag, complete one of the following steps: + +- To filter dashboard search results by tag, click a tag that appears in the right column of the search results. + + You can continue filtering by clicking additional tags. + +- To see a list of all available tags, click the **Filter by tags** dropdown menu and select a tag. + + All tags will be shown, and when you select a tag, the dashboard search will be instantly filtered. + +{{% admonition type="note" %}} +When using only a keyboard, press the `tab` key and navigate to the **Filter by tag** drop-down menu, press the down arrow key `▼` to activate the menu and locate a tag, and press `Enter` to select the tag. +{{% /admonition %}} diff --git a/docs/sources/dashboards/share-dashboards-panels/index.md b/docs/sources/dashboards/share-dashboards-panels/index.md index 01ef05c1923..713ae2e4c41 100644 --- a/docs/sources/dashboards/share-dashboards-panels/index.md +++ b/docs/sources/dashboards/share-dashboards-panels/index.md @@ -32,7 +32,7 @@ labels: menuTitle: Sharing title: Share dashboards and panels description: Share Grafana dashboards and panels within your organization and publicly -weight: 85 +weight: 700 refs: image-rendering: - pattern: /docs/grafana/ diff --git a/docs/sources/dashboards/troubleshoot-dashboards/index.md b/docs/sources/dashboards/troubleshoot-dashboards/index.md index 817f035fb2d..bb6530b7952 100644 --- a/docs/sources/dashboards/troubleshoot-dashboards/index.md +++ b/docs/sources/dashboards/troubleshoot-dashboards/index.md @@ -16,7 +16,7 @@ labels: menuTitle: Troubleshoot dashboards title: Troubleshoot dashboards description: Learn how to troubleshoot common dashboard issues -weight: 300 +weight: 1000 --- # Troubleshoot dashboards diff --git a/docs/sources/dashboards/use-dashboards/index.md b/docs/sources/dashboards/use-dashboards/index.md index c9771019310..035aac2e2c2 100644 --- a/docs/sources/dashboards/use-dashboards/index.md +++ b/docs/sources/dashboards/use-dashboards/index.md @@ -19,7 +19,7 @@ labels: menuTitle: Use dashboards title: Use dashboards description: Learn about the features of a Grafana dashboard -weight: 1 +weight: 100 refs: dashboard-analytics: - pattern: /docs/grafana/ diff --git a/docs/sources/dashboards/variables/_index.md b/docs/sources/dashboards/variables/_index.md index ad8eae57c86..6ec5d4916ed 100644 --- a/docs/sources/dashboards/variables/_index.md +++ b/docs/sources/dashboards/variables/_index.md @@ -10,7 +10,7 @@ labels: - oss title: Variables description: Add variables to metric queries and panel titles to create interactive and dynamic dashboards -weight: 130 +weight: 800 --- # Variables diff --git a/docs/sources/search/_index.md b/docs/sources/search/_index.md index 832a359ba41..9fb729a3c07 100644 --- a/docs/sources/search/_index.md +++ b/docs/sources/search/_index.md @@ -1,9 +1,11 @@ --- -description: Learn how to search for Grafana dashboards and folders +description: Use the command palette to search all of Grafana and initiate key actions keywords: - search - - dashboard - - folder + - dashboards + - alerts + - preferences + - sift labels: products: - cloud @@ -14,111 +16,24 @@ title: Search weight: 80 --- -# Search dashboards and folders +# Grafana search -You can search for dashboards and dashboard folders by name. - -When you search for dashboards, you can also do it by panel title. Whether you search by name or panel title, the system returns all dashboards available within the Grafana instance, even if you do not have permission to view the contents of the dashboard. - -## Search by name - -Begin typing any part of the dashboard or folder name in the search bar. The search returns results for any partial string match in real-time, as you type. - -The search is: - -- Real-time -- _Not_ case sensitive -- Functional across stored _and_ file based dashboards and folders. - -{{% admonition type="note" %}} -You can use your keyboard arrow keys to navigate the results and press `Enter` to open the selected dashboard or folder. -{{% /admonition %}} - -The following images show: - -Searching by dashboard name from the **Dashboards** page. - -{{< figure src="/media/docs/grafana/dashboards/search-for-dashboard.png" width="700px" >}} - -Searching by folder name from the **Dashboards** page. - -{{< figure src="/media/docs/grafana/dashboards/search-folder.png" width="700px" >}} - -Searching by dashboard name inside a folder. - -{{< figure src="/media/docs/grafana/dashboards/search-in-folder.png" width="700px" >}} - -{{% admonition type="note" %}} -When you search within a folder, its subfolders are not part of the results returned. You need to be on the **Dashboards** page (or the root level) to search for subfolders by name. -{{% /admonition %}} - -## Search dashboards using panel title - -You can search for a dashboard by the title of a panel that appears in a dashboard. -If a panel's title matches your search query, the dashboard appears in the search results. - -This feature is available by default in Grafana Cloud and in Grafana OSS v9.1 and higher, you access this feature by enabling the `panelTitleSearch` feature toggle. -For more information about enabling panel title search, refer to [Enable the panelTitleSearch feature toggle.](#enable-panelTitleSearch-feature-toggle) - -The following image shows the search results when you search using panel title. - -{{< figure src="/static/img/docs/v91/dashboard-features/search-by-panel-title.png" width="700px" >}} - -### Enable the panelTitleSearch feature toggle - -Complete the following steps to enable the `panelTitleSearch` feature toggle. - -**Before you begin:** - -- If you are running Grafana Enterprise with RBAC, enable [service accounts]({{< relref "../administration/service-accounts/" >}}). - -**To enable the panelTitleSearch feature toggle:** - -1. Open the Grafana [configuration file]({{< relref "../setup-grafana/configure-grafana/#configuration-file-location" >}}). - -1. Locate the [feature_toggles]({{< relref "../setup-grafana/configure-grafana/#feature_toggles" >}}) section. - -1. Add the following parameter to the `feature_toggles` section: - - ``` - [feature_toggles] - # enable features, separated by spaces - enable = panelTitleSearch - ``` - -1. Save your changes and restart the Grafana server. - -## Filter dashboard search results by tag(s) - -Tags are a great way to organize your dashboards, especially as the number of dashboards grow. You can add and manage tags in dashboard `Settings`. - -When you select multiple tags, Grafana shows dashboards that include all selected tags. - -To filter dashboard search result by a tag, complete one of the following steps: - -- To filter dashboard search results by tag, click a tag that appears in the right column of the search results. - - You can continue filtering by clicking additional tags. - -- To see a list of all available tags, click the **Filter by tags** dropdown menu and select a tag. - - All tags will be shown, and when you select a tag, the dashboard search will be instantly filtered. - -{{% admonition type="note" %}} -When using only a keyboard, press the `tab` key and navigate to the **Filter by tag** drop-down menu, press the down arrow key `▼` to activate the menu and locate a tag, and press `Enter` to select the tag. -{{% /admonition %}} +Use the command palette to search all of Grafana and to initiate key actions from any page in the application. ## Command palette -The command palette enables you to: +The command palette allows you to: - Search for and open dashboards and folders - Create dashboards and alert rules - Locate pages within Grafana - Change the theme to dark or light +- Run Sift investigations ![Command Palette screenshot](/media/docs/grafana/CommandPalette_doc_1.png) To open the command palette, press `cmd+K` in macOS or `ctrl+k` in Linux/Windows. You can also click on the input located in the navigation bar. -> **Note:** To go to the previous step, press `backspace` with the command palette input empty. +{{< admonition type="note">}} +To go to the previous step, press `backspace` with the command palette input empty. +{{< /admonition >}} From 2b94a82baaf54977d25e0c84135a80948ce82e10 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:42:59 -0600 Subject: [PATCH 018/102] AngularMigration: Migrate series Graph to BarGauge (#92609) --- .../dev-dashboards/migrations/migrations.json | 145 +++++++++++++++++- .../SingleStatBaseOptions.test.ts | 26 ++++ .../SingleStatShared/SingleStatBaseOptions.ts | 119 +++++++++++++- .../state/getPanelPluginToMigrateTo.ts | 4 + .../plugins/panel/barchart/migrations.test.ts | 35 ++++- .../app/plugins/panel/barchart/migrations.ts | 72 +++++++-- .../plugins/panel/bargauge/BarGaugeLegend.tsx | 4 +- 7 files changed, 381 insertions(+), 24 deletions(-) diff --git a/devenv/dev-dashboards/migrations/migrations.json b/devenv/dev-dashboards/migrations/migrations.json index 3d13fe327f6..5abfe8be420 100644 --- a/devenv/dev-dashboards/migrations/migrations.json +++ b/devenv/dev-dashboards/migrations/migrations.json @@ -389,9 +389,9 @@ "type": "testdata", "uid": "PD8C576611E62080A" }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"meta\": {\n \"typeVersion\": [\n 0,\n 0\n ],\n \"custom\": {\n \"customStat\": 10\n }\n },\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {\n \"interval\": 3600000\n }\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"labels\": {\n \"pod\": \"A-pod\"\n },\n \"config\": {}\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1727107111901,\n 1727110711901,\n 1727114311901,\n 1727117911901,\n 1727121511901,\n 1727125111901\n ],\n [\n 1.907286825122581,\n 2.260951647569786,\n 1.887442338051216,\n 2.1526144400893514,\n 1.7287721375237766,\n 1.7262902137793208\n ]\n ]\n }\n },\n {\n \"schema\": {\n \"refId\": \"A\",\n \"meta\": {\n \"typeVersion\": [\n 0,\n 0\n ],\n \"custom\": {\n \"customStat\": 10\n }\n },\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {\n \"interval\": 3600000\n }\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"labels\": {\n \"pod\": \"A-pod1\"\n },\n \"config\": {}\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1727107111901,\n 1727110711901,\n 1727114311901,\n 1727117911901,\n 1727121511901,\n 1727125111901\n ],\n [\n 1.907286825122581,\n 1.589539045095202,\n 1.5914283506847613,\n 1.8976990616650726,\n 1.758223085999124,\n 2.2294649594813816\n ]\n ]\n }\n },\n {\n \"schema\": {\n \"refId\": \"A\",\n \"meta\": {\n \"typeVersion\": [\n 0,\n 0\n ],\n \"custom\": {\n \"customStat\": 10\n }\n },\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {\n \"interval\": 3600000\n }\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"labels\": {\n \"pod\": \"A-pod2\"\n },\n \"config\": {}\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1727107111901,\n 1727110711901,\n 1727114311901,\n 1727117911901,\n 1727121511901,\n 1727125111901\n ],\n [\n 1.907286825122581,\n 2.0914263380328766,\n 1.8164545521094575,\n 1.621111084665713,\n 1.3902653996444705,\n 1.482803315949775\n ]\n ]\n }\n }\n]", "refId": "A", - "scenarioId": "random_walk", - "seriesCount": 3 + "scenarioId": "raw_frame" } ], "thresholds": [], @@ -462,6 +462,145 @@ "title": "Status + Notes", "type": "text" }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": { + "default": false, + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 16, + "x": 0, + "y": 22 + }, + "hiddenSeries": false, + "id": 32, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": false, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "percentage": false, + "pluginVersion": "11.3.0-pre", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 28, + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Flot graph - x axis series mode (with legend calcs)", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "series", + "show": true, + "values": [ + "total" + ] + }, + "yaxes": [ + { + "$$hashKey": "object:88", + "format": "short", + "logBase": 1, + "show": true + }, + { + "$$hashKey": "object:89", + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "gridPos": { + "h": 11, + "w": 8, + "x": 16, + "y": 22 + }, + "id": 33, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "# Graph panel >> Bar gauge panel\n", + "mode": "markdown" + }, + "pluginVersion": "11.3.0-pre", + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A" + } + ], + "title": "Status + Notes", + "type": "text" + }, { "aliasColors": {}, "bars": true, @@ -1183,4 +1322,4 @@ "uid": "cdd412c4", "version": 68, "weekStart": "" -} \ No newline at end of file +} diff --git a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts index 0fc3a03ab5b..490945ed908 100644 --- a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts +++ b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts @@ -249,3 +249,29 @@ describe('sharedSingleStatMigrationHandler', () => { expect(panel.fieldConfig.defaults.max).toBe(1); }); }); + +describe('BarGauge migrations', () => { + it('Should migrate from old graph', () => { + const old = { + angular: { + xaxis: { + mode: 'series', + }, + legend: { + show: true, + values: true, + min: false, + max: true, + current: true, + total: false, + avg: false, + }, + }, + }; + + const panel = {} as PanelModel; + panel.options = sharedSingleStatPanelChangedHandler(panel, 'graph', old); + expect(panel.options.legend.showLegend).toBe(true); + expect(panel.options.legend.calcs).toHaveLength(2); + }); +}); diff --git a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts index 49ce29f6818..37f4fe00e04 100644 --- a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts +++ b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts @@ -1,4 +1,4 @@ -import { cloneDeep, isNumber, omit } from 'lodash'; +import { cloneDeep, identity, isNumber, omit, pickBy } from 'lodash'; import { convertOldAngularValueMappings, @@ -16,7 +16,7 @@ import { ValueMapping, VizOrientation, } from '@grafana/data'; -import { OptionsWithTextFormatting } from '@grafana/schema'; +import { LegendDisplayMode, OptionsWithLegend, OptionsWithTextFormatting } from '@grafana/schema'; export interface SingleStatBaseOptions extends OptionsWithTextFormatting { reduceOptions: ReduceDataOptions; @@ -25,6 +25,7 @@ export interface SingleStatBaseOptions extends OptionsWithTextFormatting { const optionsToKeep = ['reduceOptions', 'orientation']; +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function sharedSingleStatPanelChangedHandler( panel: PanelModel> | any, prevPluginId: string, @@ -40,6 +41,9 @@ export function sharedSingleStatPanelChangedHandler( // Migrating from angular singlestat if (prevPluginId === 'singlestat' && prevOptions.angular) { return migrateFromAngularSinglestat(panel, prevOptions); + } else if (prevPluginId === 'graph') { + // Migrating from Graph panel + return migrateFromGraphPanel(panel, prevOptions); } for (const k of optionsToKeep) { @@ -51,6 +55,65 @@ export function sharedSingleStatPanelChangedHandler( return options; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function migrateFromGraphPanel(panel: PanelModel> | any, prevOptions: any) { + const graphOptions: GraphOptions = prevOptions.angular; + + const options: SingleStatBaseOptions & OptionsWithLegend = { + orientation: VizOrientation.Auto, + reduceOptions: { + values: false, + calcs: [], + }, + legend: { + displayMode: LegendDisplayMode.List, + showLegend: true, + placement: 'bottom', + calcs: [], + }, + }; + + if (graphOptions.xaxis?.mode === 'series') { + panel.fieldConfig = { + ...panel.fieldConfig, + defaults: { + ...panel.fieldConfig.defaults, + color: { mode: 'palette-classic' }, + }, + }; + + // Value options calculation migration + if (graphOptions.xaxis.values) { + options.reduceOptions.calcs = getReducerForMigration(graphOptions.xaxis.values); + } + + // Legend migration + const legendConfig = graphOptions.legend; + if (legendConfig) { + if (legendConfig.show) { + options.legend.displayMode = legendConfig.alignAsTable ? LegendDisplayMode.Table : LegendDisplayMode.List; + } else { + options.legend.showLegend = false; + } + + if (legendConfig.rightSide) { + options.legend.placement = 'right'; + } + + if (legendConfig.values) { + const enabledLegendValues = pickBy(legendConfig, identity); + options.legend.calcs = getReducersFromLegend(enabledLegendValues); + } + + if (legendConfig.sideWidth) { + options.legend.width = legendConfig.sideWidth; + } + } + } + + return options; +} + function migrateFromAngularSinglestat(panel: PanelModel> | any, prevOptions: any) { const prevPanel = prevOptions.angular; const reducer = fieldReducers.getIfExists(prevPanel.valueName); @@ -331,3 +394,55 @@ export function migrateOldThresholds(thresholds?: any[]): Threshold[] | undefine export function convertOldAngularValueMapping(panel: any): ValueMapping[] { return convertOldAngularValueMappings(panel); } + +interface GraphOptions { + xaxis: { + mode: 'series' | 'time' | 'histogram'; + values?: string[]; + }; + legend: { + show: boolean; + alignAsTable: boolean; + rightSide: boolean; + values: boolean; + min?: boolean; + max?: boolean; + avg?: boolean; + current?: boolean; + total?: boolean; + sideWidth?: number; + }; +} + +function getReducersFromLegend(obj: Record): string[] { + const ids: string[] = []; + for (const key in obj) { + const reducer = fieldReducers.getIfExists(key); + if (reducer) { + ids.push(reducer.id); + } + } + return ids; +} + +// same as public/app/plugins/panel/barchart/migrations.ts +function getReducerForMigration(reducers: string[] | undefined) { + const transformReducers: string[] = []; + + reducers?.forEach((reducer) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + if (!Object.values(ReducerID).includes(reducer as ReducerID)) { + if (reducer === 'current') { + transformReducers.push(ReducerID.lastNotNull); + } else if (reducer === 'total') { + transformReducers.push(ReducerID.sum); + } else if (reducer === 'avg') { + transformReducers.push(ReducerID.mean); + } + } else { + transformReducers.push(reducer); + } + }); + + return reducers ? transformReducers : [ReducerID.sum]; +} diff --git a/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts b/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts index 5eb7c201ee4..e4cc5b6dac2 100644 --- a/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts +++ b/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts @@ -32,6 +32,10 @@ export function getPanelPluginToMigrateTo(panel: any, forceMigration?: boolean): isUrlFeatureFlagEnabled('autoMigrateGraphPanel')) ) { if (panel.xaxis?.mode === 'series') { + if (panel.legend?.values) { + return 'bargauge'; + } + return 'barchart'; } diff --git a/public/app/plugins/panel/barchart/migrations.test.ts b/public/app/plugins/panel/barchart/migrations.test.ts index 00af18aec7b..5f172cdf380 100644 --- a/public/app/plugins/panel/barchart/migrations.test.ts +++ b/public/app/plugins/panel/barchart/migrations.test.ts @@ -1,4 +1,4 @@ -import { FieldConfigSource, PanelModel } from '@grafana/data'; +import { FieldConfigSource, FieldMatcherID, PanelModel } from '@grafana/data'; import { changeToBarChartPanelMigrationHandler } from './migrations'; @@ -17,16 +17,41 @@ describe('Bar chart Migrations', () => { angular: { xaxis: { mode: 'series', - values: 'avg', + values: ['avg'], }, }, }; const panel = {} as PanelModel; panel.options = changeToBarChartPanelMigrationHandler(panel, 'graph', old, prevFieldConfig); + const transformations = panel.transformations || []; + expect(transformations).toHaveLength(2); - const transform = panel.transformations![0]; - expect(transform.id).toBe('reduce'); - expect(transform.options.reducers).toBe('avg'); + const reduceTransform = transformations[0]; + expect(reduceTransform.id).toBe('reduce'); + expect(reduceTransform.options.reducers).toHaveLength(1); + expect(reduceTransform.options.reducers[0]).toBe('mean'); + + const transposeTransform = transformations[1]; + expect(transposeTransform.id).toBe('transpose'); + + expect(panel.fieldConfig.overrides).toHaveLength(1); + expect(panel.fieldConfig.overrides[0].matcher.id).toBe(FieldMatcherID.byName); + expect(panel.fieldConfig.overrides).toMatchInlineSnapshot(` + [ + { + "matcher": { + "id": "byName", + "options": "Field", + }, + "properties": [ + { + "id": "custom.axisPlacement", + "value": "hidden", + }, + ], + }, + ] + `); }); }); diff --git a/public/app/plugins/panel/barchart/migrations.ts b/public/app/plugins/panel/barchart/migrations.ts index 4dba05de840..100b1a138ec 100644 --- a/public/app/plugins/panel/barchart/migrations.ts +++ b/public/app/plugins/panel/barchart/migrations.ts @@ -1,33 +1,79 @@ -import { PanelTypeChangedHandler } from '@grafana/data'; +import { FieldMatcherID, PanelTypeChangedHandler, ReducerID } from '@grafana/data'; +import { AxisPlacement } from '@grafana/ui'; /* * This is called when the panel changes from another panel */ -export const changeToBarChartPanelMigrationHandler: PanelTypeChangedHandler = ( - panel, - prevPluginId, - prevOptions, - prevFieldConfig -) => { +export const changeToBarChartPanelMigrationHandler: PanelTypeChangedHandler = (panel, prevPluginId, prevOptions) => { if (prevPluginId === 'graph') { const graphOptions: GraphOptions = prevOptions.angular; + const fieldConfig = panel.fieldConfig ?? { defaults: {}, overrides: [] }; + if (graphOptions.xaxis?.mode === 'series') { - const tranformations = panel.transformations || []; - tranformations.push({ - id: 'reduce', - options: { - reducers: graphOptions.xaxis?.values ?? ['sum'], + const transformations = panel.transformations || []; + transformations.push( + { + id: 'reduce', + options: { + reducers: getReducer(graphOptions.xaxis?.values), + }, }, + { + id: 'transpose', + options: {}, + } + ); + + panel.transformations = transformations; + + // temporary, until we have a bar chart with per bar labels + fieldConfig.overrides.push({ + matcher: { + id: FieldMatcherID.byName, + options: 'Field', + }, + properties: [ + { + id: 'custom.axisPlacement', + value: AxisPlacement.Hidden, + }, + ], }); - panel.transformations = tranformations; + panel.fieldConfig = fieldConfig; + panel.options = { + ...panel.options, + groupWidth: 1, + }; } } return {}; }; +// same as grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts +const getReducer = (reducers: string[] | undefined) => { + const transformReducers: string[] = []; + + reducers?.forEach((reducer) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + if (!Object.values(ReducerID).includes(reducer as ReducerID)) { + if (reducer === 'current') { + transformReducers.push(ReducerID.lastNotNull); + } else if (reducer === 'total') { + transformReducers.push(ReducerID.sum); + } else if (reducer === 'avg') { + transformReducers.push(ReducerID.mean); + } + } else { + transformReducers.push(reducer); + } + }); + + return reducers ? transformReducers : [ReducerID.sum]; +}; + interface GraphOptions { xaxis: { mode: 'series' | 'time' | 'histogram'; diff --git a/public/app/plugins/panel/bargauge/BarGaugeLegend.tsx b/public/app/plugins/panel/bargauge/BarGaugeLegend.tsx index 6aa9609656d..8d88eac3ee3 100644 --- a/public/app/plugins/panel/bargauge/BarGaugeLegend.tsx +++ b/public/app/plugins/panel/bargauge/BarGaugeLegend.tsx @@ -1,6 +1,6 @@ import { memo } from 'react'; -import { DataFrame, FieldType, getFieldSeriesColor } from '@grafana/data'; +import { cacheFieldDisplayNames, DataFrame, FieldType, getFieldSeriesColor } from '@grafana/data'; import { Field } from '@grafana/data/'; import { AxisPlacement, VizLegendOptions } from '@grafana/schema'; import { useTheme2, VizLayout, VizLayoutLegendProps, VizLegend, VizLegendItem } from '@grafana/ui'; @@ -16,6 +16,8 @@ export const BarGaugeLegend = memo( const theme = useTheme2(); let legendItems: VizLegendItem[] = []; + cacheFieldDisplayNames(data); + data.forEach((series, frameIndex) => { series.fields.forEach((field, i) => { const fieldIndex = i + 1; From 1726567fcfe734bb3ecb7b61ff2196faf158efcf Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Tue, 24 Sep 2024 20:25:39 -0600 Subject: [PATCH 019/102] Canvas: Restore support for field-level data links (#93708) Co-authored-by: Leon Sorokin --- .../panel/canvas/components/CanvasTooltip.tsx | 17 +++- .../plugins/panel/canvas/migrations.test.ts | 82 ++++--------------- public/app/plugins/panel/canvas/migrations.ts | 65 +-------------- public/app/plugins/panel/canvas/utils.ts | 37 ++++++++- .../app/plugins/panel/status-history/utils.ts | 4 +- 5 files changed, 69 insertions(+), 136 deletions(-) diff --git a/public/app/plugins/panel/canvas/components/CanvasTooltip.tsx b/public/app/plugins/panel/canvas/components/CanvasTooltip.tsx index 2478d8adf4d..955656b7af7 100644 --- a/public/app/plugins/panel/canvas/components/CanvasTooltip.tsx +++ b/public/app/plugins/panel/canvas/components/CanvasTooltip.tsx @@ -23,7 +23,8 @@ import { CloseButton } from '@grafana/ui/src/components/uPlot/plugins/CloseButto import { getActions, getActionsDefaultField } from 'app/features/actions/utils'; import { Scene } from 'app/features/canvas/runtime/scene'; -import { getRowIndex } from '../utils'; +import { getDataLinks } from '../../status-history/utils'; +import { getElementFields, getRowIndex } from '../utils'; interface Props { scene: Scene; @@ -73,11 +74,12 @@ export const CanvasTooltip = ({ scene }: Props) => { : []), ]; + // NOTE: almost identical to getDataLinks() helper const links: Array> = []; - const linkLookup = new Set(); - const elementHasLinks = (element.options.links?.length ?? 0) > 0; - if (elementHasLinks && element.getLinks) { + if ((element.options.links?.length ?? 0) > 0 && element.getLinks) { + const linkLookup = new Set(); + element.getLinks({ valueRowIndex: getRowIndex(element.data.field, scene) }).forEach((link) => { const key = `${link.title}/${link.href}`; if (!linkLookup.has(key)) { @@ -86,6 +88,13 @@ export const CanvasTooltip = ({ scene }: Props) => { } }); } + // --------- + + if (scene.data?.series) { + getElementFields(scene.data?.series, element.options).forEach((field) => { + links.push(...getDataLinks(field, getRowIndex(element.data.field, scene))); + }); + } const actions: Array> = []; const actionLookup = new Set(); diff --git a/public/app/plugins/panel/canvas/migrations.test.ts b/public/app/plugins/panel/canvas/migrations.test.ts index e22e98b7dd5..366b27d1717 100644 --- a/public/app/plugins/panel/canvas/migrations.test.ts +++ b/public/app/plugins/panel/canvas/migrations.test.ts @@ -1,32 +1,11 @@ -import { FieldConfigSource, PanelModel } from '@grafana/data'; +import { FieldConfigSource, OneClickMode, PanelModel } from '@grafana/data'; import { canvasMigrationHandler } from './migrations'; -describe('Canvas data links migration', () => { +describe('Canvas migration', () => { let prevFieldConfig: FieldConfigSource; - beforeEach(() => { - prevFieldConfig = { - defaults: {}, - overrides: [ - { - matcher: { id: 'byName', options: 'B-series' }, - properties: [ - { - id: 'links', - value: [ - { title: 'Test B-series override', url: '${__series.name}' }, - { title: 'Test B-series override 2', url: '${__field.name}' }, - { title: 'Test B-series override 3', url: '${__field.labels.foo}' }, - ], - }, - ], - }, - ], - }; - }); - - it('should migrate data links', () => { + it('should migrate renamed options', () => { const panel = { type: 'canvas', fieldConfig: prevFieldConfig, @@ -34,55 +13,26 @@ describe('Canvas data links migration', () => { root: { elements: [ { - type: 'metric-value', - config: { - text: { - mode: 'field', - field: 'B-series', - fixed: '', - }, - size: 20, - color: { - fixed: '#000000', - }, - align: 'center', - valign: 'middle', - }, - background: { - color: { - field: 'time', - fixed: '#D9D9D9', - }, - }, - border: { - color: { - fixed: 'dark-green', - }, - }, - placement: { - top: 100, - left: 100, - width: 260, - height: 50, - }, name: 'Element 1', - constraint: { - vertical: 'top', - horizontal: 'left', - }, - links: [], + type: 'ellipse', + oneClickLinks: true, + actions: [ + { + options: { + url: 'http://test.com', + }, + }, + ], }, ], }, }, - pluginVersion: '11.1.0', + pluginVersion: '11.2', } as unknown as PanelModel; + panel.options = canvasMigrationHandler(panel); - const links = panel.options.root.elements[0].links; - expect(links).toHaveLength(3); - expect(links[0].url).toBe('${__data.fields["B-series"]}'); - expect(links[1].url).toBe('${__data.fields["B-series"]}'); - expect(links[2].url).toBe('${__data.fields["B-series"].labels.foo}'); + expect(panel.options.root.elements[0].oneClickMode).toBe(OneClickMode.Link); + expect(panel.options.root.elements[0].actions[0].fetch.url).toBe('http://test.com'); }); }); diff --git a/public/app/plugins/panel/canvas/migrations.ts b/public/app/plugins/panel/canvas/migrations.ts index 13af6799c11..f287556a2f7 100644 --- a/public/app/plugins/panel/canvas/migrations.ts +++ b/public/app/plugins/panel/canvas/migrations.ts @@ -1,5 +1,4 @@ -import { DataLink, DynamicConfigValue, FieldMatcherID, PanelModel, OneClickMode } from '@grafana/data'; -import { CanvasElementOptions } from 'app/features/canvas/element'; +import { PanelModel, OneClickMode } from '@grafana/data'; import { Options } from './panelcfg.gen'; @@ -44,30 +43,6 @@ export const canvasMigrationHandler = (panel: PanelModel): Partial => { } if (parseFloat(pluginVersion) <= 11.3) { - // migrate links from field name overrides to elements - for (let idx = 0; idx < panel.fieldConfig.overrides.length; idx++) { - const override = panel.fieldConfig.overrides[idx]; - - if (override.matcher.id === FieldMatcherID.byName) { - let props: DynamicConfigValue[] = []; - - // append override links to elements with dimensions mapped to same field name - for (const prop of override.properties) { - if (prop.id === 'links') { - addLinks(panel.options.root.elements, prop.value ?? [], override.matcher.options); - } else { - props.push(prop); - } - } - - if (props.length > 0) { - override.properties = props; - } else { - panel.fieldConfig.overrides.splice(idx, 1); - } - } - } - const root = panel.options?.root; if (root?.elements) { for (const element of root.elements) { @@ -92,41 +67,3 @@ export const canvasMigrationHandler = (panel: PanelModel): Partial => { return panel.options; }; - -function addLinks(elements: CanvasElementOptions[], links: DataLink[], fieldName?: string) { - const varsNamesRegex = /(\${__field.name})|(\${__field.labels.*?})|(\${__series.name})/g; - - const linksCopy = [...links]; - linksCopy.forEach((link) => { - const isFieldOrSeries = varsNamesRegex.test(link.url); - if (isFieldOrSeries) { - link.url = link.url.replace(varsNamesRegex, (match, fieldName1, fieldLabels1, seriesName1) => { - if (fieldName1 || seriesName1) { - return '${__data.fields["' + fieldName + '"]}'; - } - - if (fieldLabels1) { - const labels = fieldLabels1.match(new RegExp('.labels' + '(.*)' + '}')); - return '${__data.fields["' + fieldName + '"].labels' + labels[1] + '}'; - } - - return match; - }); - } - }); - - elements.forEach((element) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let cfg: Record = element.config; - - for (let k in cfg) { - let dim = cfg[k]; - - // todo: getFieldDisplayName? - if (dim.field === fieldName) { - element.links ??= []; - element.links.push(...linksCopy); - } - } - }); -} diff --git a/public/app/plugins/panel/canvas/utils.ts b/public/app/plugins/panel/canvas/utils.ts index f2addf63e4b..bf5fb30b15f 100644 --- a/public/app/plugins/panel/canvas/utils.ts +++ b/public/app/plugins/panel/canvas/utils.ts @@ -1,6 +1,7 @@ import { isNumber, isString } from 'lodash'; -import { AppEvents, PluginState, SelectableValue } from '@grafana/data'; +import { AppEvents, getFieldDisplayName, PluginState, SelectableValue } from '@grafana/data'; +import { DataFrame, Field } from '@grafana/data/'; import appEvents from 'app/core/app_events'; import { hasAlphaPanels, config } from 'app/core/config'; import { @@ -293,3 +294,37 @@ export const getParent = (scene: Scene) => { } return scene.div; }; + +export function getElementFields(frames: DataFrame[], opts: CanvasElementOptions) { + const fields = new Set(); + const cfg = opts.config ?? {}; + + frames.forEach((frame) => { + frame.fields.forEach((field) => { + const name = getFieldDisplayName(field, frame, frames); + + // (intentional fall-through) + switch (name) { + // General element config + case opts.background?.color?.field: + case opts.background?.image?.field: + case opts.border?.color?.field: + // Text config + case cfg.text?.field: + case cfg.color?.field: + // Icon config + case cfg.path?.field: + case cfg.fill?.field: + // Server config + case cfg.blinkRate?.field: + case cfg.statusColor?.field: + case cfg.bulbColor?.field: + // Wind turbine config (maybe remove / not support this?) + case cfg.rpm?.field: + fields.add(field); + } + }); + }); + + return [...fields]; +} diff --git a/public/app/plugins/panel/status-history/utils.ts b/public/app/plugins/panel/status-history/utils.ts index 946424eb2ec..f4775386ce1 100644 --- a/public/app/plugins/panel/status-history/utils.ts +++ b/public/app/plugins/panel/status-history/utils.ts @@ -2,11 +2,13 @@ import { Field, LinkModel } from '@grafana/data'; export const getDataLinks = (field: Field, rowIdx: number) => { const links: Array> = []; - const linkLookup = new Set(); if ((field.config.links?.length ?? 0) > 0 && field.getLinks != null) { const v = field.values[rowIdx]; const disp = field.display ? field.display(v) : { text: `${v}`, numeric: +v }; + + const linkLookup = new Set(); + field.getLinks({ calculatedValue: disp, valueRowIndex: rowIdx }).forEach((link) => { const key = `${link.title}/${link.href}`; if (!linkLookup.has(key)) { From 8c5dfa33d422f04de5463ef8ff12c962c3b68a57 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:38:23 +0300 Subject: [PATCH 020/102] I18n: Download translations from Crowdin (#93683) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/de-DE/grafana.json | 21 ++++++++++++++------- public/locales/es-ES/grafana.json | 21 ++++++++++++++------- public/locales/fr-FR/grafana.json | 21 ++++++++++++++------- public/locales/pt-BR/grafana.json | 21 ++++++++++++++------- public/locales/zh-Hans/grafana.json | 21 ++++++++++++++------- 5 files changed, 70 insertions(+), 35 deletions(-) diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 3cfc3aac780..039bc88a41f 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -462,23 +462,30 @@ }, "source-form": { "control-required": "Dieses Feld ist erforderlich.", - "description": "Sie haben folgende Variablen in der Zielabfrage verwendet: <1><2>Ein Datenpunkt muss Werte für alle Variablen als Felder oder als Transformationsausgabe bereitstellen, um die Korrelationsschaltfläche in der Visualisierung erscheinen zu lassen.<4>Hinweis: Nicht jede Variable muss unten explizit definiert werden. Eine Transformation wie <7>logfmt erzeugt Variablen für jedes Schlüssel-/Wert-Paar.", - "heading": "In der Zielabfrage verwendete Variablen", + "description": "", + "description-external-pre": "", + "description-query-pre": "", + "external-title": "", + "heading-external": "", + "heading-query": "", + "query-title": "", "results-description": "Der Link wird neben dem Wert dieses Feldes angezeigt", "results-label": "Ergebnisfeld", "results-required": "Dieses Feld ist erforderlich.", "source-description": "Die Ergebnisse aus der ausgewählten Quelldatenquelle verfügen über Links, die im Fenster angezeigt werden", "source-label": "Quelle", - "sub-text": "<0>Definieren, welche Datenquelle die Korrelation anzeigt und welche Daten die zuvor definierten Variablen ersetzen sollen. ", - "title": "Konfigurieren Sie die Datenquelle, die auf {{dataSourceName}} verlinkt wird (Schritt 3 von 3)" + "sub-text": "<0>Definieren, welche Datenquelle die Korrelation anzeigt und welche Daten die zuvor definierten Variablen ersetzen sollen. " }, "sub-title": "Definieren, wie sich Daten in verschiedenen Datenquellen miteinander verbinden. Lesen Sie mehr in der <2>Dokumentation<1>", "target-form": { "control-rules": "Dieses Feld ist erforderlich.", - "sub-text": "<0>Definieren Sie, mit welcher Datenquelle die Korrelation verknüpft wird und welche Abfrage ausgeführt wird, wenn die Korrelation angeklickt wird.", - "target-description": "Geben Sie an, welche Datenquelle beim Anklicken des Links abgefragt wird", + "sub-text": "", + "target-description-external": "", + "target-description-query": "", "target-label": "Ziel", - "title": "Ziel für die Korrelation einrichten (Schritt 2 von 3)" + "target-type-description": "", + "title": "Ziel für die Korrelation einrichten (Schritt 2 von 3)", + "type-label": "" }, "trans-details": { "logfmt-description": "Das angegebene Feld mit logfmt analysieren, um Variablen zu erhalten", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index a091811c869..38ecdacc400 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -462,23 +462,30 @@ }, "source-form": { "control-required": "Este campo es obligatorio.", - "description": "Ha utilizado las siguientes variables en la consulta de destino: <1><2>Un punto de datos necesita proporcionar valores a todas las variables como campos o como salida de transformaciones para hacer que el botón de correlación aparezca en la visualización. 4>Nota: No todas las variables deben definirse explícitamente a continuación. Una transformación como <7>logfmt creará variables para cada par clave/valor.", - "heading": "Variables utilizadas en la consulta de destino", + "description": "", + "description-external-pre": "", + "description-query-pre": "", + "external-title": "", + "heading-external": "", + "heading-query": "", + "query-title": "", "results-description": "El enlace se mostrará junto al valor de este campo", "results-label": "Campo de resultados", "results-required": "Este campo es obligatorio.", "source-description": "Los resultados de la fuente de datos seleccionada tienen enlaces que se muestran en el panel", "source-label": "Fuente", - "sub-text": "<0>Defina qué fuente de datos mostrará la correlación y qué datos reemplazarán a las variables previamente definidas.", - "title": "Configurar la fuente de datos que se vinculará a {{dataSourceName}} (paso 3 de 3)" + "sub-text": "<0>Defina qué fuente de datos mostrará la correlación y qué datos reemplazarán a las variables previamente definidas." }, "sub-title": "Defina cómo se relacionan los datos que se encuentran en diferentes fuentes de datos. Más información en la <2>documentación<1>", "target-form": { "control-rules": "Este campo es obligatorio.", - "sub-text": "<0>Defina a qué fuente de datos enlazará la correlación y qué consulta se ejecutará cuando se haga clic en la correlación.", - "target-description": "Especifique qué fuente de datos se consulta cuando se hace clic en el enlace", + "sub-text": "", + "target-description-external": "", + "target-description-query": "", "target-label": "Destino", - "title": "Configurar el destino de la correlación (paso 2 de 3)" + "target-type-description": "", + "title": "Configurar el destino de la correlación (paso 2 de 3)", + "type-label": "" }, "trans-details": { "logfmt-description": "Analice el campo proporcionado con logfmt para obtener variables", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 72b3987e65c..44377b1b2d6 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -462,23 +462,30 @@ }, "source-form": { "control-required": "Ce champ est obligatoire.", - "description": "Vous avez utilisé les variables suivantes dans la requête cible: : <1><2>Un point de données doit fournir des valeurs à toutes les variables en tant que champs ou en tant que résultat de transformations pour faire apparaître le bouton de corrélation dans la visualisation. <4>Remarque : toutes les variables ne doivent pas être explicitement définies ci-dessous. Une transformation telle que <7>logfmt créera des variables pour chaque paire clé/valeur.", - "heading": "Variables utilisées dans la requête cible", + "description": "", + "description-external-pre": "", + "description-query-pre": "", + "external-title": "", + "heading-external": "", + "heading-query": "", + "query-title": "", "results-description": "Le lien apparaîtra à côté de la valeur de ce champ", "results-label": "Champ résultats", "results-required": "Ce champ est obligatoire.", "source-description": "Les résultats de la source de données source sélectionnée ont des liens affichés dans le panneau", "source-label": "Source", - "sub-text": "<0>Définissez quelle source de données affichera la corrélation et quelles données remplaceront les variables précédemment définies.", - "title": "Configurer la source de données qui sera liée à {{dataSourceName}} (Étape 3 sur 3)" + "sub-text": "<0>Définissez quelle source de données affichera la corrélation et quelles données remplaceront les variables précédemment définies." }, "sub-title": "Définissez comment les données provenant de différentes sources de données se rapportent l’une à l’autre. En savoir plus dans la <2>documentation<1>", "target-form": { "control-rules": "Ce champ est obligatoire.", - "sub-text": "<0>Définissez la source de données vers laquelle la corrélation renverra, et la requête qui sera exécutée en cliquant sur la corrélation.", - "target-description": "Spécifiez quelle source de données est interrogée lorsqu'on clique sur le lien", + "sub-text": "", + "target-description-external": "", + "target-description-query": "", "target-label": "Cible", - "title": "Configurez la cible pour la corrélation (Étape 2 sur 3)" + "target-type-description": "", + "title": "Configurez la cible pour la corrélation (Étape 2 sur 3)", + "type-label": "" }, "trans-details": { "logfmt-description": "Analyser le champ fourni avec logfmt pour récupérer les variables", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index b286ebd5f5c..23d4453a4fb 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -462,23 +462,30 @@ }, "source-form": { "control-required": "Este campo é obrigatório.", - "description": "Você usou as seguintes variáveis na consulta alvo: <1><2>Um ponto de dados precisa fornecer valores para todas as variáveis como campos ou saída de transformações para fazer com que o botão de correlação apareça na visualização. <4>Observação: nem toda variável precisa ser explicitamente definida abaixo. Uma transformação como <7>logfmt criará variáveis para cada par chave/valor.", - "heading": "Variáveis usadas na consulta alvo", + "description": "", + "description-external-pre": "", + "description-query-pre": "", + "external-title": "", + "heading-external": "", + "heading-query": "", + "query-title": "", "results-description": "O link será exibido ao lado do valor deste campo", "results-label": "Campo de resultados", "results-required": "Este campo é obrigatório.", "source-description": "Os resultados da fonte de dados selecionada têm links exibidos no painel", "source-label": "Fonte", - "sub-text": "<0>Defina qual fonte de dados exibirá a correlação e quais dados substituirão variáveis predefinidas. ", - "title": "Configurar a fonte de dados que vinculará a {{dataSourceName}} (Passo 3 de 3)" + "sub-text": "<0>Defina qual fonte de dados exibirá a correlação e quais dados substituirão variáveis predefinidas. " }, "sub-title": "Defina como os dados que vivem em fontes de dados diferentes se relacionam entre si. Leia mais na <2>documentação<1>", "target-form": { "control-rules": "Este campo é obrigatório.", - "sub-text": "<0>Defina para qual fonte de dados a correlação será vinculada e qual consulta será executada quando a correlação for clicada.", - "target-description": "Especifique qual fonte de dados é consultada quando o link é clicado", + "sub-text": "", + "target-description-external": "", + "target-description-query": "", "target-label": "Alvo", - "title": "Configurar o alvo para a correlação (Passo 2 de 3)" + "target-type-description": "", + "title": "Configurar o alvo para a correlação (Passo 2 de 3)", + "type-label": "" }, "trans-details": { "logfmt-description": "Analisa o campo fornecido com logfmt para obter variáveis", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index c3686556f64..45cc6f1b0e2 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -454,23 +454,30 @@ }, "source-form": { "control-required": "此字段是必填字段。", - "description": "您在目标查询中使用了以下变量:<1><2>数据点需要为所有变量提供作为字段或转换输出的值,以使关联按钮出现在可视化中。 <4>注意:并非每个变量都需要在下面明确定义。<7>logfmt 等变换将为每个键/值对创建变量。", - "heading": "目标查询中使用的变量", + "description": "", + "description-external-pre": "", + "description-query-pre": "", + "external-title": "", + "heading-external": "", + "heading-query": "", + "query-title": "", "results-description": "此链接将显示在此字段的值旁边", "results-label": "结果字段", "results-required": "此字段是必填字段。", "source-description": "所选源数据源得到的结果有在面板中显示的链接", "source-label": "源", - "sub-text": "<0>定义哪些数据源将显示关联,以及哪些数据将取代先前定义的变量。", - "title": "配置将链接到 {{dataSourceName}} 的数据源(第 3 步,共 3 步)。" + "sub-text": "<0>定义哪些数据源将显示关联,以及哪些数据将取代先前定义的变量。" }, "sub-title": "定义不同数据源中存在的数据如何相互关联。详细信息请参阅<2>文档<1>。", "target-form": { "control-rules": "此字段是必填字段。", - "sub-text": "<0>定义关联将链接到哪个数据源,以及点击关联时将会运行什么查询。", - "target-description": "指定点击链接时查询哪个数据源", + "sub-text": "", + "target-description-external": "", + "target-description-query": "", "target-label": "目标", - "title": "设置关联的目标(第 2 步,共 3 步)" + "target-type-description": "", + "title": "设置关联的目标(第 2 步,共 3 步)", + "type-label": "" }, "trans-details": { "logfmt-description": "使用 logfmt 解析提供的字段以获取变量", From 2c26053be832e42a9232b2489cb8629a5c8e163d Mon Sep 17 00:00:00 2001 From: "Arati R." <33031346+suntala@users.noreply.github.com> Date: Wed, 25 Sep 2024 08:56:15 +0200 Subject: [PATCH 021/102] K8s/Folders: Convert additional fields when creating k8s resources (#93395) * Add separate folder registration function * Convert to k8s resource directly after legacy create * Use create command when creating folders * Set additional fields when converting to k8s resource * Add created/updated timestamps during conversion * Refactor UnstructuredToLegacyFolderDTO * Return errors when doing k8s conversions --- pkg/api/api.go | 34 +---- pkg/api/folder.go | 71 +++++++++- pkg/registry/apis/folders/conversions.go | 148 +++++++++++++++++--- pkg/registry/apis/folders/legacy_storage.go | 23 ++- 4 files changed, 213 insertions(+), 63 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index e79d338a912..a774627ba5d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -443,39 +443,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Any("/datasources/uid/:uid/health", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(datasources.ActionQuery)), routing.Wrap(hs.CheckDatasourceHealthWithUID)) // Folders - // #TODO kubernetes folders: move this to its own function, add back auth part, add other routes - apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { - if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesFolders) { - // Use k8s client to implement legacy API - handler := newFolderK8sHandler(hs) - folderRoute.Get("/", handler.searchFolders) - folderRoute.Post("/", handler.createFolder) - folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { - folderUidRoute.Get("/", handler.getFolder) - folderUidRoute.Delete("/", handler.deleteFolder) - folderUidRoute.Put("/:uid", handler.updateFolder) - }) - } else { - idScope := dashboards.ScopeFoldersProvider.GetResourceScope(ac.Parameter(":id")) - uidScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":uid")) - folderRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders)) - folderRoute.Get("/id/:id", authorize(ac.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID)) - folderRoute.Post("/", authorize(ac.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder)) - - folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { - folderUidRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID)) - folderUidRoute.Put("/", authorize(ac.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder)) - folderUidRoute.Post("/move", authorize(ac.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder)) - folderUidRoute.Delete("/", authorize(ac.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder)) - folderUidRoute.Get("/counts", authorize(ac.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts)) - - folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) { - folderPermissionRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionFoldersPermissionsRead, uidScope)), routing.Wrap(hs.GetFolderPermissionList)) - folderPermissionRoute.Post("/", authorize(ac.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions)) - }) - }) - } - }) + hs.registerFolderAPI(apiRoute, authorize) // Dashboard apiRoute.Group("/dashboards", func(dashboardRoute routing.RouteRegister) { diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 1433e12422b..5456100cd0f 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/api/apierrors" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" folderalpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/infra/metrics" @@ -39,6 +40,42 @@ import ( const REDACTED = "redacted" +func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authorize func(accesscontrol.Evaluator) web.Handler) { + // #TODO add back auth part + apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { + if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesFolders) { + // Use k8s client to implement legacy API + handler := newFolderK8sHandler(hs) + folderRoute.Get("/", handler.searchFolders) + folderRoute.Post("/", handler.createFolder) + folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { + folderUidRoute.Get("/", handler.getFolder) + folderUidRoute.Delete("/", handler.deleteFolder) + folderUidRoute.Put("/:uid", handler.updateFolder) + }) + } else { + idScope := dashboards.ScopeFoldersProvider.GetResourceScope(accesscontrol.Parameter(":id")) + uidScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.Parameter(":uid")) + folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders)) + folderRoute.Get("/id/:id", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID)) + folderRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder)) + + folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { + folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID)) + folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder)) + folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder)) + folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder)) + folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts)) + + folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) { + folderPermissionRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsRead, uidScope)), routing.Wrap(hs.GetFolderPermissionList)) + folderPermissionRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions)) + }) + }) + } + }) +} + // swagger:route GET /folders folders getFolders // // Get all folders. @@ -680,18 +717,28 @@ func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) { if !ok { return // error is already sent } - cmd := folder.UpdateFolderCommand{} + cmd := folder.CreateFolderCommand{} if err := web.Bind(c.Req, &cmd); err != nil { c.JsonApiErr(http.StatusBadRequest, "bad request data", err) return } - obj := internalfolders.LegacyUpdateCommandToUnstructured(cmd) + obj, err := internalfolders.LegacyCreateCommandToUnstructured(cmd) + if err != nil { + fk8s.writeError(c, err) + return + } out, err := client.Create(c.Req.Context(), &obj, v1.CreateOptions{}) if err != nil { fk8s.writeError(c, err) return } - c.JSON(http.StatusOK, internalfolders.UnstructuredToLegacyFolderDTO(*out)) + + f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) + if err != nil { + fk8s.writeError(c, err) + return + } + c.JSON(http.StatusOK, f) } func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) { @@ -705,7 +752,14 @@ func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) { fk8s.writeError(c, err) return } - c.JSON(http.StatusOK, internalfolders.UnstructuredToLegacyFolderDTO(*out)) + + f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) + if err != nil { + fk8s.writeError(c, err) + return + } + + c.JSON(http.StatusOK, f) } func (fk8s *folderK8sHandler) deleteFolder(c *contextmodel.ReqContext) { @@ -740,7 +794,14 @@ func (fk8s *folderK8sHandler) updateFolder(c *contextmodel.ReqContext) { fk8s.writeError(c, err) return } - c.JSON(http.StatusOK, internalfolders.UnstructuredToLegacyFolderDTO(*out)) + + f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) + if err != nil { + fk8s.writeError(c, err) + return + } + + c.JSON(http.StatusOK, f) } //----------------------------------------------------------------------------------------- diff --git a/pkg/registry/apis/folders/conversions.go b/pkg/registry/apis/folders/conversions.go index 75c13777af1..e70a5dff7d0 100644 --- a/pkg/registry/apis/folders/conversions.go +++ b/pkg/registry/apis/folders/conversions.go @@ -2,6 +2,8 @@ package folders import ( "fmt" + "strconv" + "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -9,11 +11,32 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" ) +func LegacyCreateCommandToUnstructured(cmd folder.CreateFolderCommand) (unstructured.Unstructured, error) { + obj := unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "title": cmd.Title, + "description": cmd.Description, + }, + }, + } + // #TODO: let's see if we need to set the json field to "-" + obj.SetName(cmd.UID) + + if err := setParentUID(&obj, cmd.ParentUID); err != nil { + return unstructured.Unstructured{}, err + } + + return obj, nil +} + func LegacyUpdateCommandToUnstructured(cmd folder.UpdateFolderCommand) unstructured.Unstructured { // #TODO add other fields obj := unstructured.Unstructured{ @@ -36,17 +59,55 @@ func UnstructuredToLegacyFolder(item unstructured.Unstructured) *folder.Folder { } } -func UnstructuredToLegacyFolderDTO(item unstructured.Unstructured) *dtos.Folder { +func UnstructuredToLegacyFolderDTO(item unstructured.Unstructured) (*dtos.Folder, error) { spec := item.Object["spec"].(map[string]any) - dto := &dtos.Folder{ - UID: item.GetName(), - Title: spec["title"].(string), - // #TODO add other fields + uid := item.GetName() + title := spec["title"].(string) + + meta, err := utils.MetaAccessor(&item) + if err != nil { + return nil, err } - return dto + + id, err := getLegacyID(meta) + if err != nil { + return nil, err + } + + created, err := getCreated(meta) + if err != nil { + return nil, err + } + + dto := &dtos.Folder{ + UID: uid, + Title: title, + ID: id, + ParentUID: meta.GetFolder(), + // #TODO add back CreatedBy, UpdatedBy once we figure out how to access userService + // to translate user ID into user login. meta.GetCreatedBy() only stores user ID + // Could convert meta.GetCreatedBy() return value to a struct--id and name + // CreatedBy: meta.GetCreatedBy(), + // UpdatedBy: meta.GetCreatedBy(), + URL: getURL(meta, title), + // #TODO get Created in format "2024-09-12T15:37:41.09466+02:00" + Created: *created, + // #TODO figure out whether we want to set "updated" and "updated by". Could replace with + // meta.GetUpdatedTimestamp() but it currently gets overwritten in prepareObjectForStorage(). + Updated: *created, + // #TODO figure out how to set these properly + CanSave: true, + CanEdit: true, + CanAdmin: true, + CanDelete: true, + HasACL: false, + + // #TODO figure out about adding version, parents, orgID fields + } + return dto, nil } -func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) *v0alpha1.Folder { +func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) (*v0alpha1.Folder, error) { f := &v0alpha1.Folder{ TypeMeta: v0alpha1.FolderResourceInfo.TypeMeta(), ObjectMeta: metav1.ObjectMeta{ @@ -62,24 +123,67 @@ func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) } meta, err := utils.MetaAccessor(f) - if err == nil { - meta.SetUpdatedTimestamp(&v.Updated) - if v.ID > 0 { // nolint:staticcheck - meta.SetOriginInfo(&utils.ResourceOriginInfo{ - Name: "SQL", - Path: fmt.Sprintf("%d", v.ID), // nolint:staticcheck - }) - } - if v.CreatedBy > 0 { - meta.SetCreatedBy(fmt.Sprintf("user:%d", v.CreatedBy)) - } - if v.UpdatedBy > 0 { - meta.SetUpdatedBy(fmt.Sprintf("user:%d", v.UpdatedBy)) - } + if err != nil { + return nil, err + } + + meta.SetUpdatedTimestamp(&v.Updated) + if v.ID > 0 { // nolint:staticcheck + meta.SetOriginInfo(&utils.ResourceOriginInfo{ + Name: "SQL", + Path: fmt.Sprintf("%d", v.ID), // nolint:staticcheck + Timestamp: &v.Created, + }) + } + if v.CreatedBy > 0 { + meta.SetCreatedBy(fmt.Sprintf("user:%d", v.CreatedBy)) + } + if v.UpdatedBy > 0 { + meta.SetUpdatedBy(fmt.Sprintf("user:%d", v.UpdatedBy)) } if v.ParentUID != "" { meta.SetFolder(v.ParentUID) } f.UID = gapiutil.CalculateClusterWideUID(f) - return f + return f, nil +} + +func setParentUID(u *unstructured.Unstructured, parentUid string) error { + meta, err := utils.MetaAccessor(u) + if err != nil { + return err + } + meta.SetFolder(parentUid) + return nil +} + +func getLegacyID(meta utils.GrafanaMetaAccessor) (int64, error) { + var i int64 + + info, err := meta.GetOriginInfo() + if err != nil { + return i, err + } + + if info != nil && info.Name == "SQL" { + i, err = strconv.ParseInt(info.Path, 10, 64) + if err != nil { + return i, err + } + } + return i, nil +} + +func getURL(meta utils.GrafanaMetaAccessor, title string) string { + slug := slugify.Slugify(title) + uid := meta.GetName() + return dashboards.GetFolderURL(uid, slug) +} + +func getCreated(meta utils.GrafanaMetaAccessor) (*time.Time, error) { + created, err := meta.GetOriginTimestamp() + if err != nil { + return nil, err + } + return created, nil } diff --git a/pkg/registry/apis/folders/legacy_storage.go b/pkg/registry/apis/folders/legacy_storage.go index e6986d51c75..283c5d47887 100644 --- a/pkg/registry/apis/folders/legacy_storage.go +++ b/pkg/registry/apis/folders/legacy_storage.go @@ -101,7 +101,11 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO list := &v0alpha1.FolderList{} for _, v := range hits { - list.Items = append(list.Items, *convertToK8sResource(v, s.namespacer)) + r, err := convertToK8sResource(v, s.namespacer) + if err != nil { + return nil, err + } + list.Items = append(list.Items, *r) } if len(list.Items) >= int(paging.limit) { list.Continue = paging.GetNextPageToken() @@ -132,7 +136,12 @@ func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.Ge return nil, err } - return convertToK8sResource(dto, s.namespacer), nil + r, err := convertToK8sResource(dto, s.namespacer) + if err != nil { + return nil, err + } + + return r, nil } func (s *legacyStorage) Create(ctx context.Context, @@ -178,7 +187,15 @@ func (s *legacyStorage) Create(ctx context.Context, if err != nil { return nil, err } - return s.Get(ctx, out.UID, nil) + // #TODO can we directly convert instead of doing a Get? the result of the Create + // has more data than the one of Get so there is more we can include in the k8s resource + // this way + + r, err := convertToK8sResource(out, s.namespacer) + if err != nil { + return nil, err + } + return r, nil } func (s *legacyStorage) Update(ctx context.Context, From 14a5d605ae59fd23873e6e9a16a89df39ecdb0ec Mon Sep 17 00:00:00 2001 From: Joe Blubaugh Date: Wed, 25 Sep 2024 15:11:29 +0800 Subject: [PATCH 022/102] Docs: Remove some duplication in alerting rules docs. (#93376) --- docs/sources/alerting/fundamentals/alert-rules/_index.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/sources/alerting/fundamentals/alert-rules/_index.md b/docs/sources/alerting/fundamentals/alert-rules/_index.md index b948591475f..5d9a3219f64 100644 --- a/docs/sources/alerting/fundamentals/alert-rules/_index.md +++ b/docs/sources/alerting/fundamentals/alert-rules/_index.md @@ -69,9 +69,7 @@ Grafana supports two different alert rule types: Grafana-managed alert rules and ## Grafana-managed alert rules -Grafana-managed alert rules are the most flexible alert rule type. They allow you to create alert rules that can act on data from any of the [supported data sources](#supported-data-sources), and use multiple data sources in a single alert rule. You can also add expressions to transform your data and set alert conditions. Using images in alert notifications is also supported. - -Additionally, you can also add [expressions to transform your data](ref:expression-queries), set custom alert conditions, and include [images in alert notifications](ref:notification-images). +Grafana-managed alert rules are the most flexible alert rule type. They allow you to create alert rules that can act on data from any of the [supported data sources](#supported-data-sources), and use multiple data sources in a single alert rule. You can add [expressions to transform your data](ref:expression-queries) and set custom alert conditions. Using [images in alert notifications](ref:notification-images). is also supported. {{< figure src="/media/docs/alerting/grafana-managed-alerting-architecture.png" max-width="750px" caption="How Grafana-managed alerting works by default" >}} From d371fae3c81f073746ad23cc2a4ca0deae20d150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 25 Sep 2024 11:04:20 +0200 Subject: [PATCH 023/102] DashboardScene: Fixes issue with updating repeated panels after coming back from panel edit (#93716) --- .../panel-edit/PanelEditor.tsx | 6 +++++- .../scene/DashboardGridItem.test.tsx | 9 +++++---- .../scene/DashboardGridItem.tsx | 19 ++++++------------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index 3a696e97749..ed0b3f624ae 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -54,6 +54,7 @@ export class PanelEditor extends SceneObjectBase { private _originalLayoutElementState!: DashboardGridItemState; private _layoutElement!: DashboardGridItem; private _originalSaveModel!: Panel; + private _changesHaveBeenMade = false; public constructor(state: PanelEditorState) { super(state); @@ -71,7 +72,7 @@ export class PanelEditor extends SceneObjectBase { return () => { if (layoutElement instanceof DashboardGridItem) { - layoutElement.editingCompleted(); + layoutElement.editingCompleted(this.state.isDirty || this._changesHaveBeenMade); } if (deactivateParents) { deactivateParents(); @@ -227,6 +228,9 @@ export class PanelEditor extends SceneObjectBase { public dashboardSaved() { this.setOriginalState(this.state.panelRef); this.setState({ isDirty: false }); + + // Remember that we have done changes + this._changesHaveBeenMade = false; } public onSaveLibraryPanel = () => { diff --git a/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx b/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx index e6d202b0801..de74e9934a7 100644 --- a/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx @@ -143,8 +143,8 @@ describe('PanelRepeaterGridItem', () => { await new Promise((r) => setTimeout(r, 10)); vizPanel.setState({ title: 'Changed' }); - //mimic returning to dashboard from panel edit cloning panel - panel.setState({ body: vizPanel.clone() }); + + panel.editingCompleted(true); // mimic returning to dashboard activateFullSceneTree(scene); @@ -214,10 +214,11 @@ describe('PanelRepeaterGridItem', () => { await new Promise((r) => setTimeout(r, 10)); vizPanel.setState({ title: 'Changed' }); - //mimic returning to dashboard from panel edit cloning panel - panel.setState({ body: vizPanel.clone() }); + + panel.editingCompleted(true); const performRepeatMock = jest.spyOn(panel, 'performRepeat'); + // mimic returning to dashboard activateFullSceneTree(scene); diff --git a/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx index 2dad7ee9618..5845f40e03d 100644 --- a/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx @@ -41,8 +41,6 @@ export type RepeatDirection = 'v' | 'h'; export class DashboardGridItem extends SceneObjectBase implements SceneGridItemLike { private _prevRepeatValues?: VariableValueSingle[]; - private _prevPanelState: VizPanelState | undefined; - private _prevGridItemState: DashboardGridItemState | undefined; protected _variableDependency = new DashboardGridItemVariableDependencyHandler(this); @@ -55,17 +53,10 @@ export class DashboardGridItem extends SceneObjectBase i private _activationHandler() { if (this.state.variableName) { this._subs.add(this.subscribeToState((newState, prevState) => this._handleGridResize(newState, prevState))); - this.clearCachedStateIfBodyOrOptionsChanged(); this.performRepeat(); } } - private clearCachedStateIfBodyOrOptionsChanged() { - if (this._prevGridItemState !== this.state || this._prevPanelState !== this.state.body.state) { - this._prevRepeatValues = undefined; - } - } - /** * Uses the current repeat item count to calculate the user intended desired itemHeight */ @@ -179,8 +170,6 @@ export class DashboardGridItem extends SceneObjectBase i } } - this._prevGridItemState = this.state; - this._prevPanelState = this.state.body.state; this._prevRepeatValues = values; // Used from dashboard url sync @@ -214,14 +203,18 @@ export class DashboardGridItem extends SceneObjectBase i $variables: this.state.repeatedPanels![0].state.$variables?.clone(), $data: this.state.repeatedPanels![0].state.$data?.clone(), }); - this._prevPanelState = this.state.body.state; } } /** * Going back to dashboards logic + * withChanges true if there where changes made while in panel edit */ - public editingCompleted() { + public editingCompleted(withChanges: boolean) { + if (withChanges) { + this._prevRepeatValues = undefined; + } + if (this.state.variableName && this.state.repeatDirection === 'h' && this.state.width !== GRID_COLUMN_COUNT) { this.setState({ width: GRID_COLUMN_COUNT }); } From d447fc3a82abfce84c12246ce1e4f38d34d8913a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 10:05:54 +0100 Subject: [PATCH 024/102] Update dependency re-resizable to v6.10.0 (#93695) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 810c15e574f..964ba79a453 100644 --- a/package.json +++ b/package.json @@ -357,7 +357,7 @@ "rc-slider": "11.1.6", "rc-time-picker": "3.7.3", "rc-tree": "5.9.0", - "re-resizable": "6.9.18", + "re-resizable": "6.10.0", "react": "18.2.0", "react-diff-viewer": "^3.1.1", "react-dom": "18.2.0", diff --git a/yarn.lock b/yarn.lock index 900ad043de8..d60a21fd181 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19049,7 +19049,7 @@ __metadata: rc-slider: "npm:11.1.6" rc-time-picker: "npm:3.7.3" rc-tree: "npm:5.9.0" - re-resizable: "npm:6.9.18" + re-resizable: "npm:6.10.0" react: "npm:18.2.0" react-diff-viewer: "npm:^3.1.1" react-dom: "npm:18.2.0" @@ -27090,13 +27090,13 @@ __metadata: languageName: node linkType: hard -"re-resizable@npm:6.9.18": - version: 6.9.18 - resolution: "re-resizable@npm:6.9.18" +"re-resizable@npm:6.10.0": + version: 6.10.0 + resolution: "re-resizable@npm:6.10.0" peerDependencies: react: ^16.13.1 || ^17.0.0 || ^18.0.0 react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 - checksum: 10/cdb2ee8544a2b30053af5cc74ccf56e8d3e470e8e0ed8572424049297cf234d002870dd56c13ce352f222d0dbbc53830c6d07d2cabc83b7aa0311a21361160bf + checksum: 10/303e582feffdfd3e491e2b51dc75c8641c726882e2d8e3ec249b2bc1d23bfffa73bd4a982ed5456bcab9ec25f52b5430e8c632f32647295ed773679691a961a2 languageName: node linkType: hard From 54d52d0bf91be2a6184e7bd8d5189fe7b4e81759 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Wed, 25 Sep 2024 10:23:31 +0100 Subject: [PATCH 025/102] E2C: Add rudderstack reporting for cloud-side token generation (#93334) --- .../cloud/MigrationTokenPane/MigrationTokenPane.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/public/app/features/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx b/public/app/features/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx index 16c7fa004f5..609d9e654e7 100644 --- a/public/app/features/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx +++ b/public/app/features/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; -import { isFetchError } from '@grafana/runtime'; +import { isFetchError, reportInteraction } from '@grafana/runtime'; import { Box, Button, Text } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; @@ -51,6 +51,8 @@ export const MigrationTokenPane = () => { const isLoading = getTokenQuery.isFetching || createTokenResponse.isLoading; const handleGenerateToken = useCallback(async () => { + reportInteraction('grafana_e2c_generate_token_clicked'); + const resp = await createTokenMutation(); if (!('error' in resp)) { @@ -63,6 +65,7 @@ export const MigrationTokenPane = () => { return; } + reportInteraction('grafana_e2c_delete_token_clicked'); const resp = await deleteTokenMutation({ uid: getTokenQuery.data.id }); if (!('error' in resp)) { setShowDeleteModal(false); @@ -98,7 +101,10 @@ export const MigrationTokenPane = () => { setShowCreateModal(false)} + hideModal={() => { + reportInteraction('grafana_e2c_generated_token_modal_dismissed'); + setShowCreateModal(false); + }} migrationToken={createTokenResponse.data?.token} /> From 75900139ae6c81adf604412d7699fdc4c39c5f63 Mon Sep 17 00:00:00 2001 From: Pepe Cano <825430+ppcano@users.noreply.github.com> Date: Wed, 25 Sep 2024 12:44:17 +0200 Subject: [PATCH 026/102] Alerting docs: update `Configure Alertmanagers` (#93712) * Alerting docs: update `Configure Alertmanagers` - clarify alertmanager types - specify that each alertmanager manages its own alerting resources * Update docs/sources/alerting/set-up/configure-alertmanager/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/configure-alertmanager/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * Update docs/sources/alerting/set-up/configure-alertmanager/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> * use `Alertmanager` - capitalize the first letter * Small copy changes * Minor cosmetic updates to `Add an Alertmanager` section * Update docs/sources/alerting/set-up/configure-alertmanager/index.md Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> --------- Co-authored-by: brendamuir <100768211+brendamuir@users.noreply.github.com> --- .../set-up/configure-alertmanager/index.md | 55 +++++++++++-------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/docs/sources/alerting/set-up/configure-alertmanager/index.md b/docs/sources/alerting/set-up/configure-alertmanager/index.md index 86956ad6837..52ef493b7c4 100644 --- a/docs/sources/alerting/set-up/configure-alertmanager/index.md +++ b/docs/sources/alerting/set-up/configure-alertmanager/index.md @@ -34,17 +34,32 @@ Grafana Alerting is based on the architecture of the Prometheus alerting system. {{< figure src="/media/docs/alerting/alerting-alertmanager-architecture.png" max-width="750px" alt="A diagram with the alert generator and alert manager architecture" >}} -**Grafana Alertmanager** +This architecture decouples alert rule evaluation from notification handling, allowing alerts to be forwarded to other Alertmanagers. -Grafana has its own built-in Alertmanager, referred to as "Grafana" in the user interface. It is the default Alertmanager and can only handle Grafana-managed alerts. +Grafana can use different Alertmanagers. It’s important to note that each Alertmanager manages its own independent alerting resources, such as: -**Cloud Alertmanager** +- Contact points and notification templates +- Notification policies and mute timings +- Silences +- Active notifications -Each Grafana Cloud instance comes preconfigured with an additional Alertmanager (`grafanacloud-STACK_NAME-ngalertmanager`) from the Mimir (Prometheus) instance running in the Grafana Cloud Stack. The Cloud Alertmanager can handle both Grafana-managed and data source-managed alerts. +Use the `Choose Alertmanager` on these pages to switch between Alertmanagers. -**Other Alertmanagers** +{{< figure src="/media/docs/alerting/alerting-choose-alertmanager.png" max-width="750px" alt="A screenshot choosing an Alertmanager in the notification policies UI" >}} -Grafana Alerting also supports sending alerts to other alertmanagers, such as the [Prometheus Alertmanager](https://prometheus.io/docs/alerting/latest/alertmanager/), which can handle Grafana-managed alerts and data sources-managed alerts such as alerts from Loki, Mimir, and Prometheus. +## Types of Alertmanagers in Grafana + +Grafana can be configured to manage both Grafana-managed and data source-managed alerts using various Alertmanagers, depending on your infrastructure and alerting requirements. + +- **Grafana Alertmanager**: Grafana has its own built-in Alertmanager, referred to as "Grafana" in the user interface. It is the default Alertmanager and can only handle Grafana-managed alerts. + +- **Cloud Alertmanager**: Each Grafana Cloud instance comes preconfigured with an additional Alertmanager (`grafanacloud-STACK_NAME-ngalertmanager`) from the Mimir (Prometheus) instance running in the Grafana Cloud Stack. + + The Cloud Alertmanager is available exclusively in Grafana Cloud and can handle both Grafana-managed and data source-managed alerts. + + Some Grafana Cloud services, such as **Kubernetes Monitoring** and **Synthetic Monitoring** use the Cloud Alertmanager to create and manage alerts. + +- **Other Alertmanagers**: Grafana Alerting also supports sending alerts to other Alertmanagers, such as the [Prometheus Alertmanager](https://prometheus.io/docs/alerting/latest/alertmanager/), which can handle Grafana-managed alerts and data sources-managed alerts such as alerts from Loki, Mimir, and Prometheus. You can use a combination of Alertmanagers. The decision often depends on your alerting setup and where your alerts are being generated. Here are two examples of when you may want to add an Alertmanager and send your alerts there instead of using the built-in Grafana Alertmanager. @@ -56,36 +71,32 @@ You can use a combination of Alertmanagers. The decision often depends on your a From Grafana, you can configure and administer your own Alertmanager to receive Grafana alerts. -{{% admonition type="note" %}} -Grafana Alerting does not support sending alerts to the AWS Managed Service for Prometheus due to the lack of sigv4 support in Prometheus. -{{% /admonition %}} +After adding an Alertmanager, you can use the Grafana Alerting UI to manage notification policies, contact points, and other alerting resources from within Grafana, with support for HTTP basic authentication credentials. -After you have added the Alertmanager, you can use the Grafana Alerting UI to manage silences, contact points, and notification policies. A drop-down option in these pages allows you to switch between alertmanagers. - -{{< figure src="/media/docs/alerting/alerting-choose-alertmanager.png" max-width="750px" alt="A screenshot choosing an Alertmanager in the notification policies UI" >}} - -Alertmanagers should now be configured as data sources using Grafana Configuration from the main Grafana navigation menu. This enables you to manage the contact points and notification policies of external alertmanagers from within Grafana and also encrypts HTTP basic authentication credentials. - -To add an Alertmanager, complete the following steps. +Alertmanagers should be configured as data sources using Grafana Configuration from the main Grafana navigation menu. To add an Alertmanager, complete the following steps. 1. Click **Connections** in the left-side menu. -2. On the Connections page, search for `Alertmanager`. -3. Click the **Create a new data source** button. +1. On the Connections page, search for `Alertmanager`. +1. Click the **Create a new data source** button. If you don't see this button, you may need to install the plugin, relaunch your Cloud instance, and then repeat steps 1 and 2. -4. Fill out the fields on the page, as required. +1. Fill out the fields on the page, as required. If you are provisioning your data source, set the flag `handleGrafanaManagedAlerts` in the `jsonData` field to `true` to send Grafana-managed alerts to this Alertmanager. **Note:** Prometheus, Grafana Mimir, and Cortex implementations of Alertmanager are supported. For Prometheus, contact points and notification policies are read-only in the Grafana Alerting UI. -5. Click **Save & test**. +1. Click **Save & test**. + +{{% admonition type="note" %}} +Grafana Alerting does not support sending alerts to the AWS Managed Service for Prometheus due to the lack of sigv4 support in Prometheus. +{{% /admonition %}} + +## Manage Alertmanager configurations -{{< admonition type="note" >}} On the Settings page, you can manage your Alertmanager configurations and configure where Grafana-managed alert instances are forwarded. - Manage which Alertmanagers receive alert instances from Grafana-managed rules without navigating and editing data sources. - Manage version snapshots for the built-in Alertmanager, which allows administrators to roll back unintentional changes or mistakes in the Alertmanager configuration. - Compare the historical snapshot with the latest configuration to see which changes were made. - {{< /admonition >}} From a0de3ef86749a100d3d8502cdce077b08250c250 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:08:00 +0000 Subject: [PATCH 027/102] Update dependency @types/node to v20.16.7 --- package.json | 2 +- packages/grafana-data/package.json | 2 +- packages/grafana-e2e-selectors/package.json | 2 +- packages/grafana-flamegraph/package.json | 2 +- packages/grafana-icons/package.json | 2 +- .../grafana-o11y-ds-frontend/package.json | 2 +- packages/grafana-prometheus/package.json | 2 +- packages/grafana-sql/package.json | 2 +- packages/grafana-ui/package.json | 2 +- .../datasource/azuremonitor/package.json | 2 +- .../datasource/cloud-monitoring/package.json | 2 +- .../package.json | 2 +- .../grafana-pyroscope-datasource/package.json | 2 +- .../grafana-testdata-datasource/package.json | 2 +- .../plugins/datasource/jaeger/package.json | 2 +- .../app/plugins/datasource/mssql/package.json | 2 +- .../app/plugins/datasource/mysql/package.json | 2 +- .../app/plugins/datasource/parca/package.json | 2 +- .../app/plugins/datasource/tempo/package.json | 2 +- .../plugins/datasource/zipkin/package.json | 2 +- yarn.lock | 48 +++++++++---------- 21 files changed, 44 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index 964ba79a453..2a431b1029e 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "@types/lodash": "4.17.9", "@types/logfmt": "^1.2.3", "@types/lucene": "^2", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/node-forge": "^1", "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.4", "@types/pluralize": "^0.0.33", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index 89123455f5d..ba35fda5483 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -66,7 +66,7 @@ "@types/dompurify": "^3.0.0", "@types/history": "4.7.11", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/papaparse": "5.3.14", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index 104b15e7a1b..4d93e2c1f64 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@rollup/plugin-node-resolve": "15.3.0", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "esbuild": "0.24.0", "rimraf": "6.0.1", "rollup": "2.79.1", diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index 6b3bb6ff097..695a9742194 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -68,7 +68,7 @@ "@types/d3": "^7", "@types/jest": "^29.5.4", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/react": "18.3.3", "@types/react-virtualized-auto-sizer": "1.0.4", "@types/tinycolor2": "1.4.6", diff --git a/packages/grafana-icons/package.json b/packages/grafana-icons/package.json index 639d00f6d22..9f60063a63f 100644 --- a/packages/grafana-icons/package.json +++ b/packages/grafana-icons/package.json @@ -45,7 +45,7 @@ "@svgr/plugin-prettier": "^8.1.0", "@svgr/plugin-svgo": "^8.1.0", "@types/babel__core": "^7", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", "esbuild": "0.24.0", diff --git a/packages/grafana-o11y-ds-frontend/package.json b/packages/grafana-o11y-ds-frontend/package.json index d5daba87c91..248bfe0af62 100644 --- a/packages/grafana-o11y-ds-frontend/package.json +++ b/packages/grafana-o11y-ds-frontend/package.json @@ -36,7 +36,7 @@ "@testing-library/react": "15.0.2", "@testing-library/user-event": "14.5.2", "@types/jest": "^29.5.4", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/react": "18.3.3", "@types/systemjs": "6.15.1", "@types/testing-library__jest-dom": "5.14.9", diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 341efa96c90..d9652d00f9c 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -92,7 +92,7 @@ "@types/jest": "29.5.13", "@types/jquery": "3.5.30", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/pluralize": "^0.0.33", "@types/prismjs": "1.26.4", "@types/react": "18.3.3", diff --git a/packages/grafana-sql/package.json b/packages/grafana-sql/package.json index 51c6ff2ad75..b070d13be19 100644 --- a/packages/grafana-sql/package.json +++ b/packages/grafana-sql/package.json @@ -42,7 +42,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "^29.5.4", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", "@types/react-virtualized-auto-sizer": "1.0.4", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 6dabdb778b5..b9ca81674aa 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -145,7 +145,7 @@ "@types/is-hotkey": "0.1.10", "@types/jest": "29.5.13", "@types/mock-raf": "1.0.6", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/prismjs": "1.26.4", "@types/react": "18.3.3", "@types/react-color": "3.0.12", diff --git a/public/app/plugins/datasource/azuremonitor/package.json b/public/app/plugins/datasource/azuremonitor/package.json index 1d7cf8b1924..598566ab3a0 100644 --- a/public/app/plugins/datasource/azuremonitor/package.json +++ b/public/app/plugins/datasource/azuremonitor/package.json @@ -32,7 +32,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.13", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/prismjs": "1.26.4", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", diff --git a/public/app/plugins/datasource/cloud-monitoring/package.json b/public/app/plugins/datasource/cloud-monitoring/package.json index d7ad46a0ea3..2d45f479e39 100644 --- a/public/app/plugins/datasource/cloud-monitoring/package.json +++ b/public/app/plugins/datasource/cloud-monitoring/package.json @@ -34,7 +34,7 @@ "@types/debounce-promise": "3.1.9", "@types/jest": "29.5.13", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/prismjs": "1.26.4", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json index fa9da94656e..a6f0a203728 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json @@ -22,7 +22,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.13", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/react": "18.3.3", "@types/testing-library__jest-dom": "5.14.9", "ts-node": "10.9.2", diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json index 44f4aa3d76f..7a43cc547a1 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json @@ -27,7 +27,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.13", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/prismjs": "1.26.4", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/package.json b/public/app/plugins/datasource/grafana-testdata-datasource/package.json index c336cd246e0..3aa1709fe77 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/package.json +++ b/public/app/plugins/datasource/grafana-testdata-datasource/package.json @@ -30,7 +30,7 @@ "@types/d3-random": "^3.0.2", "@types/jest": "29.5.13", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", "@types/testing-library__jest-dom": "5.14.9", diff --git a/public/app/plugins/datasource/jaeger/package.json b/public/app/plugins/datasource/jaeger/package.json index 4e7316cef9d..c5bf8138976 100644 --- a/public/app/plugins/datasource/jaeger/package.json +++ b/public/app/plugins/datasource/jaeger/package.json @@ -31,7 +31,7 @@ "@types/jest": "29.5.13", "@types/lodash": "4.17.9", "@types/logfmt": "^1.2.3", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", "@types/react-window": "1.8.8", diff --git a/public/app/plugins/datasource/mssql/package.json b/public/app/plugins/datasource/mssql/package.json index 89bf3961f2e..ebe610883ba 100644 --- a/public/app/plugins/datasource/mssql/package.json +++ b/public/app/plugins/datasource/mssql/package.json @@ -22,7 +22,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.13", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/react": "18.3.3", "@types/testing-library__jest-dom": "5.14.9", "ts-node": "10.9.2", diff --git a/public/app/plugins/datasource/mysql/package.json b/public/app/plugins/datasource/mysql/package.json index d995d4e9ed5..ada5f5be5f8 100644 --- a/public/app/plugins/datasource/mysql/package.json +++ b/public/app/plugins/datasource/mysql/package.json @@ -22,7 +22,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.13", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/react": "18.3.3", "@types/testing-library__jest-dom": "5.14.9", "ts-node": "10.9.2", diff --git a/public/app/plugins/datasource/parca/package.json b/public/app/plugins/datasource/parca/package.json index fdbb1a8e8a2..b03bf9c48b1 100644 --- a/public/app/plugins/datasource/parca/package.json +++ b/public/app/plugins/datasource/parca/package.json @@ -23,7 +23,7 @@ "@testing-library/react": "15.0.2", "@testing-library/user-event": "14.5.2", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", "ts-node": "10.9.2", diff --git a/public/app/plugins/datasource/tempo/package.json b/public/app/plugins/datasource/tempo/package.json index cee23a355dd..4dc0e403968 100644 --- a/public/app/plugins/datasource/tempo/package.json +++ b/public/app/plugins/datasource/tempo/package.json @@ -46,7 +46,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "29.5.13", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/prismjs": "1.26.4", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", diff --git a/public/app/plugins/datasource/zipkin/package.json b/public/app/plugins/datasource/zipkin/package.json index 1a18b53d8f8..8c9fb495a4e 100644 --- a/public/app/plugins/datasource/zipkin/package.json +++ b/public/app/plugins/datasource/zipkin/package.json @@ -25,7 +25,7 @@ "@testing-library/react": "15.0.2", "@types/jest": "29.5.13", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.7", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", "ts-node": "10.9.2", diff --git a/yarn.lock b/yarn.lock index d60a21fd181..0c53bcb392b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3131,7 +3131,7 @@ __metadata: "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.13" "@types/lodash": "npm:4.17.9" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/prismjs": "npm:1.26.4" "@types/react": "npm:18.3.3" "@types/react-dom": "npm:18.2.25" @@ -3173,7 +3173,7 @@ __metadata: "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.13" "@types/lodash": "npm:4.17.9" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/react": "npm:18.3.3" "@types/testing-library__jest-dom": "npm:5.14.9" lodash: "npm:4.17.21" @@ -3204,7 +3204,7 @@ __metadata: "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.13" "@types/lodash": "npm:4.17.9" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/prismjs": "npm:1.26.4" "@types/react": "npm:18.3.3" "@types/react-dom": "npm:18.2.25" @@ -3247,7 +3247,7 @@ __metadata: "@types/d3-random": "npm:^3.0.2" "@types/jest": "npm:29.5.13" "@types/lodash": "npm:4.17.9" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/react": "npm:18.3.3" "@types/react-dom": "npm:18.2.25" "@types/testing-library__jest-dom": "npm:5.14.9" @@ -3289,7 +3289,7 @@ __metadata: "@types/jest": "npm:29.5.13" "@types/lodash": "npm:4.17.9" "@types/logfmt": "npm:^1.2.3" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/react": "npm:18.3.3" "@types/react-dom": "npm:18.2.25" "@types/react-window": "npm:1.8.8" @@ -3328,7 +3328,7 @@ __metadata: "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.13" "@types/lodash": "npm:4.17.9" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/react": "npm:18.3.3" "@types/testing-library__jest-dom": "npm:5.14.9" lodash: "npm:4.17.21" @@ -3359,7 +3359,7 @@ __metadata: "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.13" "@types/lodash": "npm:4.17.9" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/react": "npm:18.3.3" "@types/testing-library__jest-dom": "npm:5.14.9" lodash: "npm:4.17.21" @@ -3388,7 +3388,7 @@ __metadata: "@testing-library/react": "npm:15.0.2" "@testing-library/user-event": "npm:14.5.2" "@types/lodash": "npm:4.17.9" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/react": "npm:18.3.3" "@types/react-dom": "npm:18.2.25" lodash: "npm:4.17.21" @@ -3425,7 +3425,7 @@ __metadata: "@types/debounce-promise": "npm:3.1.9" "@types/jest": "npm:29.5.13" "@types/lodash": "npm:4.17.9" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/prismjs": "npm:1.26.4" "@types/react": "npm:18.3.3" "@types/react-dom": "npm:18.2.25" @@ -3481,7 +3481,7 @@ __metadata: "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:29.5.13" "@types/lodash": "npm:4.17.9" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/prismjs": "npm:1.26.4" "@types/react": "npm:18.3.3" "@types/react-dom": "npm:18.2.25" @@ -3530,7 +3530,7 @@ __metadata: "@testing-library/react": "npm:15.0.2" "@types/jest": "npm:29.5.13" "@types/lodash": "npm:4.17.9" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/react": "npm:18.3.3" "@types/react-dom": "npm:18.2.25" lodash: "npm:4.17.21" @@ -3588,7 +3588,7 @@ __metadata: "@types/dompurify": "npm:^3.0.0" "@types/history": "npm:4.7.11" "@types/lodash": "npm:4.17.9" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/papaparse": "npm:5.3.14" "@types/react": "npm:18.3.3" "@types/react-dom": "npm:18.2.25" @@ -3635,7 +3635,7 @@ __metadata: dependencies: "@grafana/tsconfig": "npm:^2.0.0" "@rollup/plugin-node-resolve": "npm:15.3.0" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" esbuild: "npm:0.24.0" rimraf: "npm:6.0.1" rollup: "npm:2.79.1" @@ -3804,7 +3804,7 @@ __metadata: "@types/d3": "npm:^7" "@types/jest": "npm:^29.5.4" "@types/lodash": "npm:4.17.9" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/react": "npm:18.3.3" "@types/react-virtualized-auto-sizer": "npm:1.0.4" "@types/tinycolor2": "npm:1.4.6" @@ -3888,7 +3888,7 @@ __metadata: "@testing-library/react": "npm:15.0.2" "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:^29.5.4" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/react": "npm:18.3.3" "@types/systemjs": "npm:6.15.1" "@types/testing-library__jest-dom": "npm:5.14.9" @@ -3976,7 +3976,7 @@ __metadata: "@types/jest": "npm:29.5.13" "@types/jquery": "npm:3.5.30" "@types/lodash": "npm:4.17.9" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/pluralize": "npm:^0.0.33" "@types/prismjs": "npm:1.26.4" "@types/react": "npm:18.3.3" @@ -4108,7 +4108,7 @@ __metadata: "@svgr/plugin-prettier": "npm:^8.1.0" "@svgr/plugin-svgo": "npm:^8.1.0" "@types/babel__core": "npm:^7" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/react": "npm:18.3.3" "@types/react-dom": "npm:18.2.25" esbuild: "npm:0.24.0" @@ -4188,7 +4188,7 @@ __metadata: "@testing-library/user-event": "npm:14.5.2" "@types/jest": "npm:^29.5.4" "@types/lodash": "npm:4.17.9" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/react": "npm:18.3.3" "@types/react-dom": "npm:18.2.25" "@types/react-virtualized-auto-sizer": "npm:1.0.4" @@ -4282,7 +4282,7 @@ __metadata: "@types/jquery": "npm:3.5.30" "@types/lodash": "npm:4.17.9" "@types/mock-raf": "npm:1.0.6" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/prismjs": "npm:1.26.4" "@types/react": "npm:18.3.3" "@types/react-color": "npm:3.0.12" @@ -10189,12 +10189,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:20.16.6, @types/node@npm:>=13.7.0, @types/node@npm:^20.11.16": - version: 20.16.6 - resolution: "@types/node@npm:20.16.6" +"@types/node@npm:*, @types/node@npm:20.16.7, @types/node@npm:>=13.7.0, @types/node@npm:^20.11.16": + version: 20.16.7 + resolution: "@types/node@npm:20.16.7" dependencies: undici-types: "npm:~6.19.2" - checksum: 10/f8a07b113901fa2b5bfbea8dedd15321126238023586f047576805cb1a53abd7f717f9e490d8e71d4c7b627885659f862584fa844fa71aab9c86295d1f006771 + checksum: 10/931d6689af8d02b578a1490475df025063ca1f1c7c064ce891f7ac4da38f28bfda949e79569ac49cb5cf492b2caf91d31aecda6a4efc099e4af4be42ae7315e5 languageName: node linkType: hard @@ -18892,7 +18892,7 @@ __metadata: "@types/lodash": "npm:4.17.9" "@types/logfmt": "npm:^1.2.3" "@types/lucene": "npm:^2" - "@types/node": "npm:20.16.6" + "@types/node": "npm:20.16.7" "@types/node-forge": "npm:^1" "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.4" "@types/pluralize": "npm:^0.0.33" From 4791d914089fc6e8e499a0e3e5504ed07a29724f Mon Sep 17 00:00:00 2001 From: Ben Sully Date: Wed, 25 Sep 2024 12:04:45 +0100 Subject: [PATCH 028/102] fix(datasources): add option to avoid adding '-- Grafana --' DS (#90175) * fix(datasources): add option to avoid adding '-- Grafana --' DS Currently the `getList` method of `DatasourceSrv` adds the '-- Grafana --' datasource in the majority of situations, unless a few of the other filters are set, all of which affect the results in other ways. This is the case even if the `filter` function is passed. This causes the `DataSourcePicker` component to include the '-- Grafana --' datasource in cases it's unsupported, such as in Grafana ML where we only support specific datasource types. This commit adds a new optional `grafana` field to the filter interface. If explicitly set to `false`, the '-- Grafana --' datasource will not be added to the list of datasources returned. This should be backwards compatible and should allow developers to prevent that datasource from appearing in the `DataSourcePicker`. Relates to https://github.com/grafana/machine-learning/issues/4578. * Use filter func to see if we should add '-- Grafana --', instead --- public/app/features/plugins/datasource_srv.ts | 2 +- public/app/features/plugins/tests/datasource_srv.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index 4ac58e3adfd..a71f46e5f9b 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -299,7 +299,7 @@ export class DatasourceSrv implements DataSourceService { if (!filters.tracing) { const grafanaInstanceSettings = this.getInstanceSettings('-- Grafana --'); - if (grafanaInstanceSettings) { + if (grafanaInstanceSettings && filters.filter?.(grafanaInstanceSettings) !== false) { base.push(grafanaInstanceSettings); } } diff --git a/public/app/features/plugins/tests/datasource_srv.test.ts b/public/app/features/plugins/tests/datasource_srv.test.ts index f9ec6eba97e..9d9dac7b553 100644 --- a/public/app/features/plugins/tests/datasource_srv.test.ts +++ b/public/app/features/plugins/tests/datasource_srv.test.ts @@ -297,6 +297,11 @@ describe('datasource_srv', () => { expect(list[2].name).toBe('${datasource}'); }); + it('Should filter out the -- Grafana -- datasource', () => { + const list = dataSourceSrv.getList({ filter: (x) => x.name !== '-- Grafana --' }); + expect(list.find((x) => x.name === '-- Grafana --')).toBeUndefined(); + }); + it('Can get list of data sources with tracing: true', () => { const list = dataSourceSrv.getList({ tracing: true }); expect(list[0].name).toBe('Jaeger'); From d2d1de39a7847edb0c0d9ad5a4ca6ae83fd0e3cc Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Wed, 25 Sep 2024 12:05:20 +0100 Subject: [PATCH 029/102] GrafanaUI: Make Drawer extend to full height (#93725) --- .../src/components/Drawer/Drawer.tsx | 25 ++----------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx index a929cb59c22..df030a19d28 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx @@ -256,17 +256,7 @@ const getStyles = (theme: GrafanaTheme2) => { position: 'relative', }), drawer: css({ - '.main-view &': { - top: 80, - }, - - '.main-view--search-bar-hidden &': { - top: 40, - }, - - '.main-view--chrome-hidden &': { - top: 0, - }, + top: 0, '.rc-drawer-content-wrapper': { boxShadow: theme.shadows.z3, @@ -307,18 +297,7 @@ const getStyles = (theme: GrafanaTheme2) => { left: 0, position: 'fixed', right: 0, - - '.main-view &': { - top: 80, - }, - - '.main-view--search-bar-hidden &': { - top: 40, - }, - - '.main-view--chrome-hidden &': { - top: 0, - }, + top: 0, }, }), maskMotion: css({ From 9a67cd614d092f44dcec62f0e5ac579f10ccd92d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 10:48:12 +0000 Subject: [PATCH 030/102] Update dependency @grafana/plugin-e2e to v1.8.1 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 2a431b1029e..7fbd3d9940e 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@emotion/eslint-plugin": "11.11.0", "@grafana/eslint-config": "7.0.0", "@grafana/eslint-plugin": "link:./packages/grafana-eslint-rules", - "@grafana/plugin-e2e": "1.8.0", + "@grafana/plugin-e2e": "1.8.1", "@grafana/tsconfig": "^2.0.0", "@manypkg/get-packages": "^2.2.0", "@playwright/test": "1.47.2", diff --git a/yarn.lock b/yarn.lock index 0c53bcb392b..03cab457eac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3927,16 +3927,16 @@ __metadata: languageName: unknown linkType: soft -"@grafana/plugin-e2e@npm:1.8.0": - version: 1.8.0 - resolution: "@grafana/plugin-e2e@npm:1.8.0" +"@grafana/plugin-e2e@npm:1.8.1": + version: 1.8.1 + resolution: "@grafana/plugin-e2e@npm:1.8.1" dependencies: semver: "npm:^7.5.4" uuid: "npm:^10.0.0" yaml: "npm:^2.3.4" peerDependencies: "@playwright/test": ^1.41.2 - checksum: 10/e9fb5fd6e65d9a0c67406f0d15923c7d8bb3756957c71743dacec601138be520bdb066dd505b7a3aa7632f9170d2f0ceb494cfd3d5656c8c2394f5180a468a85 + checksum: 10/eca47ddc1b3a2cfbeea3f8c1a5f438b631a8cc689947e9a1c2f7171d3896fe319492e4a3e10c6a27ee34ab4140cffc9dad3bd723e0eab837a8acd4c57e8ed477 languageName: node linkType: hard @@ -18820,7 +18820,7 @@ __metadata: "@grafana/lezer-logql": "npm:0.2.6" "@grafana/monaco-logql": "npm:^0.0.7" "@grafana/o11y-ds-frontend": "workspace:*" - "@grafana/plugin-e2e": "npm:1.8.0" + "@grafana/plugin-e2e": "npm:1.8.1" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" "@grafana/saga-icons": "workspace:*" From 8e5459791bd7814f55c0bee9385af88c105195a5 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Wed, 25 Sep 2024 12:27:30 +0100 Subject: [PATCH 031/102] SingleTopNav: Initial changes (#93694) initial changes for single top nav --- .../ToolbarButton/ToolbarButton.tsx | 1 + .../core/components/AppChrome/AppChrome.tsx | 42 ++++-- .../components/AppChrome/AppChromeMenu.tsx | 4 +- .../components/AppChrome/AppChromeService.tsx | 2 +- .../AppChrome/TopBar/SingleTopBar.tsx | 131 ++++++++++++++++++ public/app/core/context/GrafanaContext.ts | 4 +- 6 files changed, 166 insertions(+), 18 deletions(-) create mode 100644 public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx diff --git a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx index b99e176a142..05a3c5c5bdb 100644 --- a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx +++ b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx @@ -224,6 +224,7 @@ const getStyles = (theme: GrafanaTheme2) => { flexGrow: 1, }), content: css({ + display: 'flex', flexGrow: 1, }), contentWithIcon: css({ diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index 64d9b0bb058..7e5cfd0d365 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import { PropsWithChildren, useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { locationSearchToObject, locationService } from '@grafana/runtime'; +import { config, locationSearchToObject, locationService } from '@grafana/runtime'; import { useStyles2, LinkButton, useTheme2 } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; @@ -17,6 +17,7 @@ import { DOCKED_LOCAL_STORAGE_KEY, DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY } from './ import { MegaMenu, MENU_WIDTH } from './MegaMenu/MegaMenu'; import { NavToolbar } from './NavToolbar/NavToolbar'; import { ReturnToPrevious } from './ReturnToPrevious/ReturnToPrevious'; +import { SingleTopBar } from './TopBar/SingleTopBar'; import { TopSearchBar } from './TopBar/TopSearchBar'; import { TOP_BAR_LEVEL_HEIGHT } from './types'; @@ -34,6 +35,7 @@ export function AppChrome({ children }: Props) { const menuDockedAndOpen = !state.chromeless && state.megaMenuDocked && state.megaMenuOpen; const scopesDashboardsState = useScopesDashboardsState(); const isScopesDashboardsOpen = Boolean(scopesDashboardsState?.isEnabled && scopesDashboardsState?.isPanelOpened); + const isSingleTopNav = config.featureToggles.singleTopNav; useMediaQueryChange({ breakpoint: dockedMenuBreakpoint, @@ -92,16 +94,27 @@ export function AppChrome({ children }: Props) { Skip to main content
- {!searchBarHidden && } - + {isSingleTopNav ? ( + + ) : ( + <> + {!searchBarHidden && } + + + )}
)} @@ -140,11 +153,12 @@ export function AppChrome({ children }: Props) { } const getStyles = (theme: GrafanaTheme2, searchBarHidden: boolean) => { + const isSingleTopNav = config.featureToggles.singleTopNav; return { content: css({ display: 'flex', flexDirection: 'column', - paddingTop: TOP_BAR_LEVEL_HEIGHT * 2, + paddingTop: isSingleTopNav ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2, flexGrow: 1, height: 'auto', }), @@ -167,13 +181,13 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden: boolean) => { }, { position: 'fixed', - height: `calc(100% - ${searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2}px)`, + height: `calc(100% - ${searchBarHidden || isSingleTopNav ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2}px)`, zIndex: 2, } ), scopesDashboardsContainer: css({ position: 'fixed', - height: `calc(100% - ${searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2}px)`, + height: `calc(100% - ${searchBarHidden || isSingleTopNav ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2}px)`, zIndex: 1, }), scopesDashboardsContainerDocked: css({ diff --git a/public/app/core/components/AppChrome/AppChromeMenu.tsx b/public/app/core/components/AppChrome/AppChromeMenu.tsx index fb670aff321..bf5022ec01c 100644 --- a/public/app/core/components/AppChrome/AppChromeMenu.tsx +++ b/public/app/core/components/AppChrome/AppChromeMenu.tsx @@ -6,6 +6,7 @@ import { useRef } from 'react'; import CSSTransition from 'react-transition-group/CSSTransition'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { useStyles2, useTheme2 } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { KioskMode } from 'app/types'; @@ -76,7 +77,8 @@ export function AppChromeMenu({}: Props) { } const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => { - const topPosition = searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2; + const topPosition = + searchBarHidden || config.featureToggles.singleTopNav ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2; return { backdrop: css({ diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index 5c1d88ebb41..c041574aff9 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -213,7 +213,7 @@ export class AppChromeService { private getNextKioskMode() { const { kioskMode, searchBarHidden } = this.state.getValue(); - if (searchBarHidden || kioskMode === KioskMode.TV) { + if (searchBarHidden || kioskMode === KioskMode.TV || config.featureToggles.singleTopNav) { appEvents.emit(AppEvents.alertInfo, [t('navigation.kiosk.tv-alert', 'Press ESC to exit kiosk mode')]); return KioskMode.Full; } diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx new file mode 100644 index 00000000000..1499882fdf9 --- /dev/null +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx @@ -0,0 +1,131 @@ +import { css } from '@emotion/css'; +import { cloneDeep } from 'lodash'; +import { memo } from 'react'; + +import { GrafanaTheme2, NavModelItem } from '@grafana/data'; +import { Dropdown, Icon, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { config } from 'app/core/config'; +import { contextSrv } from 'app/core/core'; +import { HOME_NAV_ID } from 'app/core/reducers/navModel'; +import { ScopesSelector } from 'app/features/scopes'; +import { useSelector } from 'app/types'; + +import { Branding } from '../../Branding/Branding'; +import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs'; +import { buildBreadcrumbs } from '../../Breadcrumbs/utils'; +import { enrichHelpItem } from '../MegaMenu/utils'; +import { NewsContainer } from '../News/NewsContainer'; +import { OrganizationSwitcher } from '../OrganizationSwitcher/OrganizationSwitcher'; +import { QuickAdd } from '../QuickAdd/QuickAdd'; +import { TOP_BAR_LEVEL_HEIGHT } from '../types'; + +import { SignInLink } from './SignInLink'; +import { TopNavBarMenu } from './TopNavBarMenu'; +import { TopSearchBarCommandPaletteTrigger } from './TopSearchBarCommandPaletteTrigger'; +import { TopSearchBarSection } from './TopSearchBarSection'; + +interface Props { + sectionNav: NavModelItem; + pageNav?: NavModelItem; + onToggleMegaMenu(): void; + onToggleKioskMode(): void; +} + +export const SingleTopBar = memo(function SingleTopBar({ + onToggleMegaMenu, + onToggleKioskMode, + pageNav, + sectionNav, +}: Props) { + const styles = useStyles2(getStyles); + const navIndex = useSelector((state) => state.navIndex); + + const helpNode = cloneDeep(navIndex['help']); + const enrichedHelpNode = helpNode ? enrichHelpItem(helpNode) : undefined; + const profileNode = navIndex['profile']; + const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID]; + const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav); + + return ( +
+ + + + + + + + + + + + + {enrichedHelpNode && ( + } placement="bottom-end"> + + + )} + {config.newsFeedEnabled && } + {!contextSrv.user.isSignedIn && } + {profileNode && ( + } placement="bottom-end"> + + + )} + + + + +
+ ); +}); + +const getStyles = (theme: GrafanaTheme2) => ({ + layout: css({ + height: TOP_BAR_LEVEL_HEIGHT, + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', + padding: theme.spacing(0, 1), + borderBottom: `1px solid ${theme.colors.border.weak}`, + justifyContent: 'space-between', + + [theme.breakpoints.up('sm')]: { + gridTemplateColumns: '2fr minmax(240px, 1fr)', // TODO probably change these values + display: 'grid', + + justifyContent: 'flex-start', + }, + }), + breadcrumbsWrapper: css({ + display: 'flex', + overflow: 'hidden', + [theme.breakpoints.down('sm')]: { + minWidth: '50%', + }, + }), + img: css({ + alignSelf: 'center', + height: theme.spacing(3), + width: theme.spacing(3), + }), + profileButton: css({ + padding: theme.spacing(0, 0.25), + img: { + borderRadius: theme.shape.radius.circle, + height: '24px', + marginRight: 0, + width: '24px', + }, + }), + kioskToggle: css({ + [theme.breakpoints.down('lg')]: { + display: 'none', + }, + }), +}); diff --git a/public/app/core/context/GrafanaContext.ts b/public/app/core/context/GrafanaContext.ts index 068f55b48e0..424bd71ee68 100644 --- a/public/app/core/context/GrafanaContext.ts +++ b/public/app/core/context/GrafanaContext.ts @@ -1,7 +1,7 @@ import { createContext, useCallback, useContext } from 'react'; import { GrafanaConfig } from '@grafana/data'; -import { LocationService, locationService, BackendSrv } from '@grafana/runtime'; +import { LocationService, locationService, BackendSrv, config } from '@grafana/runtime'; import { AppChromeService } from '../components/AppChrome/AppChromeService'; import { NewFrontendAssetsChecker } from '../services/NewFrontendAssetsChecker'; @@ -50,7 +50,7 @@ export function useChromeHeaderHeight() { if (kioskMode || chromeless) { return 0; - } else if (searchBarHidden) { + } else if (searchBarHidden || config.featureToggles.singleTopNav) { return SINGLE_HEADER_BAR_HEIGHT; } else { return SINGLE_HEADER_BAR_HEIGHT * 2; From 618e4014a70f4de4a8243632fbebe5a699249675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Wed, 25 Sep 2024 13:30:36 +0200 Subject: [PATCH 032/102] Dashboards: `SaveButton` not available after changing the folder (#93216) --- .../dashboard-scene/saving/SaveDashboardAsForm.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx index 9232b5e9c68..84012e2f694 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx @@ -48,6 +48,7 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) { const { state, onSaveDashboard } = useSaveDashboard(false); const [contentSent, setContentSent] = useState<{ title?: string; folderUid?: string }>({}); + const [hasFolderChanged, setHasFolderChanged] = useState(false); const onSave = async (overwrite: boolean) => { const data = getValues(); @@ -70,10 +71,10 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) { ); - const saveButton = (overwrite: boolean) => ( - - ); - + const saveButton = (overwrite: boolean) => { + const showSaveButton = !isValid && hasFolderChanged ? true : isValid; + return ; + }; function renderFooter(error?: Error) { const formValuesMatchContentSent = formValues.title.trim() === contentSent.title && formValues.folder.uid === contentSent.folderUid; @@ -127,6 +128,8 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) { { setValue('folder', { uid, title }); + const folderUid = dashboard.state.meta.folderUid; + setHasFolderChanged(uid !== folderUid); }} // Old folder picker fields value={formValues.folder?.uid} From 177965704dc6df68fdf76e38f56dd5fbe743291d Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Wed, 25 Sep 2024 08:29:17 -0400 Subject: [PATCH 033/102] Storage: Test mode 5 (#93714) --- pkg/api/playlist.go | 6 ++++ pkg/registry/apis/playlist/conversions.go | 4 +++ pkg/tests/apis/folder/folders_test.go | 4 --- pkg/tests/apis/helper.go | 20 +++--------- pkg/tests/apis/iam/iam_test.go | 4 --- pkg/tests/apis/playlist/playlist_test.go | 37 ++++++++++++++++++++--- 6 files changed, 47 insertions(+), 28 deletions(-) diff --git a/pkg/api/playlist.go b/pkg/api/playlist.go index 2f9f5fb1d96..e7fce8598d2 100644 --- a/pkg/api/playlist.go +++ b/pkg/api/playlist.go @@ -423,6 +423,12 @@ func (pk8s *playlistK8sHandler) updatePlaylist(c *contextmodel.ReqContext) { } obj := internalplaylist.LegacyUpdateCommandToUnstructured(cmd) obj.SetName(uid) + existing, err := client.Get(c.Req.Context(), uid, v1.GetOptions{}) + if err != nil { + pk8s.writeError(c, err) + return + } + obj.SetResourceVersion(existing.GetResourceVersion()) out, err := client.Update(c.Req.Context(), &obj, v1.UpdateOptions{}) if err != nil { pk8s.writeError(c, err) diff --git a/pkg/registry/apis/playlist/conversions.go b/pkg/registry/apis/playlist/conversions.go index 9444840d07a..ec5f2179103 100644 --- a/pkg/registry/apis/playlist/conversions.go +++ b/pkg/registry/apis/playlist/conversions.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" playlistsvc "github.com/grafana/grafana/pkg/services/playlist" + "github.com/grafana/grafana/pkg/util" ) func LegacyUpdateCommandToUnstructured(cmd playlistsvc.UpdatePlaylistCommand) unstructured.Unstructured { @@ -34,6 +35,9 @@ func LegacyUpdateCommandToUnstructured(cmd playlistsvc.UpdatePlaylistCommand) un }, }, } + if cmd.UID == "" { + cmd.UID = util.GenerateShortUID() + } obj.SetName(cmd.UID) return obj } diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index 6cb319b9b03..edfd10d6ed7 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -296,10 +296,6 @@ func doFolderTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelper "apiVersion": "folder.grafana.app/v0alpha1", "kind": "Folder", "metadata": { - "annotations": { - "grafana.app/originPath": "${originPath}", - "grafana.app/originName": "SQL" - }, "creationTimestamp": "${creationTimestamp}", "name": "` + uid + `", "namespace": "default", diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index 27869465c28..eaa24a5b228 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -25,7 +25,6 @@ import ( "k8s.io/client-go/rest" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/server" @@ -187,7 +186,7 @@ func (c *K8sResourceClient) SpecJSON(v *unstructured.UnstructuredList) string { // remove the meta keys that are expected to change each time func (c *K8sResourceClient) SanitizeJSON(v *unstructured.Unstructured, replaceMeta ...string) string { c.t.Helper() - copy := c.sanitizeObject(v) + copy := c.sanitizeObject(v, replaceMeta...) out, err := json.MarshalIndent(copy, "", " ") // fmt.Printf("%s", out) @@ -200,20 +199,8 @@ func (c *K8sResourceClient) sanitizeObject(v *unstructured.Unstructured, replace c.t.Helper() deep := v.DeepCopy() - anno := deep.GetAnnotations() - if anno["grafana.app/originPath"] != "" { - anno["grafana.app/originPath"] = "${originPath}" - } - if anno["grafana.app/originHash"] != "" { - anno["grafana.app/originHash"] = "${originHash}" - } - // Remove annotations that are not added by legacy storage - delete(anno, utils.AnnoKeyOriginTimestamp) - delete(anno, utils.AnnoKeyCreatedBy) - delete(anno, utils.AnnoKeyUpdatedBy) - delete(anno, utils.AnnoKeyUpdatedTimestamp) - - deep.SetAnnotations(anno) + deep.SetAnnotations(nil) + deep.SetManagedFields(nil) copy := deep.Object meta, ok := copy["metadata"].(map[string]any) require.True(c.t, ok) @@ -226,6 +213,7 @@ func (c *K8sResourceClient) sanitizeObject(v *unstructured.Unstructured, replace meta[key] = fmt.Sprintf("${%s}", key) } } + deep.Object["metadata"] = meta return deep } diff --git a/pkg/tests/apis/iam/iam_test.go b/pkg/tests/apis/iam/iam_test.go index 2d836827317..39deafaccb3 100644 --- a/pkg/tests/apis/iam/iam_test.go +++ b/pkg/tests/apis/iam/iam_test.go @@ -58,10 +58,6 @@ func TestIntegrationIdentity(t *testing.T) { "apiVersion": "iam.grafana.app/v0alpha1", "kind": "Team", "metadata": { - "annotations": { - "grafana.app/originName": "SQL", - "grafana.app/originPath": "${originPath}" - }, "creationTimestamp": "${creationTimestamp}", "name": "${name}", "namespace": "default", diff --git a/pkg/tests/apis/playlist/playlist_test.go b/pkg/tests/apis/playlist/playlist_test.go index ec828fb317a..ca5ba3dac1e 100644 --- a/pkg/tests/apis/playlist/playlist_test.go +++ b/pkg/tests/apis/playlist/playlist_test.go @@ -154,6 +154,22 @@ func TestIntegrationPlaylist(t *testing.T) { })) }) + t.Run("with dual write (file, mode 5)", func(t *testing.T) { + doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "file", // write the files to disk + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + RESOURCEGROUP: { + DualWriterMode: grafanarest.Mode5, + }, + }, + EnableFeatureToggles: []string{ + featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written + }, + })) + }) + t.Run("with dual write (unified storage, mode 0)", func(t *testing.T) { doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ AppModeProduction: false, // required for unified storage @@ -211,6 +227,22 @@ func TestIntegrationPlaylist(t *testing.T) { })) }) + t.Run("with dual write (unified storage, mode 5)", func(t *testing.T) { + doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, // required for unified storage + DisableAnonymous: true, + APIServerStorageType: "unified", // use the entity api tables + EnableFeatureToggles: []string{ + featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written + }, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + RESOURCEGROUP: { + DualWriterMode: grafanarest.Mode5, + }, + }, + })) + }) + t.Run("with dual write (etcd, mode 0)", func(t *testing.T) { // NOTE: running local etcd, that will be wiped clean! t.Skip("local etcd testing") @@ -431,10 +463,6 @@ func doPlaylistTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelp "apiVersion": "playlist.grafana.app/v0alpha1", "kind": "Playlist", "metadata": { - "annotations": { - "grafana.app/originPath": "${originPath}", - "grafana.app/originName": "SQL" - }, "creationTimestamp": "${creationTimestamp}", "name": "` + uid + `", "namespace": "default", @@ -504,6 +532,7 @@ func doPlaylistTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelp }) t.Run("Do CRUD via k8s (and check that legacy api still works)", func(t *testing.T) { + t.Skip() client := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Editor, GVR: gvr, From 225600a08b707f074abfbb7785cb4664023b92c1 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Wed, 25 Sep 2024 15:10:19 +0200 Subject: [PATCH 034/102] Expose queryconvert endpoint (#93656) --- pkg/registry/apis/datasource/queryconvert.go | 137 ++++++++++++++++++ .../apis/datasource/queryconvert_test.go | 90 ++++++++++++ pkg/registry/apis/datasource/register.go | 6 + .../apis/datasource/sub_query_test.go | 4 + pkg/services/apiserver/builder/helper.go | 6 + pkg/tests/apis/datasource/testdata_test.go | 18 ++- 6 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 pkg/registry/apis/datasource/queryconvert.go create mode 100644 pkg/registry/apis/datasource/queryconvert_test.go diff --git a/pkg/registry/apis/datasource/queryconvert.go b/pkg/registry/apis/datasource/queryconvert.go new file mode 100644 index 00000000000..4381dc13484 --- /dev/null +++ b/pkg/registry/apis/datasource/queryconvert.go @@ -0,0 +1,137 @@ +package datasource + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/registry/rest" + + query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + "github.com/grafana/grafana/pkg/web" +) + +type pluginClientConversion interface { + backend.ConversionHandler +} + +type queryConvertREST struct { + client pluginClientConversion + contextProvider PluginContextWrapper +} + +var ( + _ rest.Storage = (*queryConvertREST)(nil) + _ rest.Connecter = (*queryConvertREST)(nil) + _ rest.Scoper = (*queryConvertREST)(nil) + _ rest.SingularNameProvider = (*queryConvertREST)(nil) +) + +func registerQueryConvert(client pluginClientConversion, contextProvider PluginContextWrapper, storage map[string]rest.Storage) { + store := &queryConvertREST{ + client: client, + contextProvider: contextProvider, + } + storage["queryconvert"] = store +} + +func (r *queryConvertREST) New() runtime.Object { + return &query.QueryDataRequest{} +} + +func (r *queryConvertREST) Destroy() {} + +func (r *queryConvertREST) NamespaceScoped() bool { + return true +} + +func (r *queryConvertREST) GetSingularName() string { + return "queryconvert" +} + +func (r *queryConvertREST) ConnectMethods() []string { + return []string{"POST"} +} + +func (r *queryConvertREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" // true means you can use the trailing path as a variable +} + +func (r *queryConvertREST) convertQueryDataRequest(ctx context.Context, req *http.Request) (*query.QueryDataRequest, error) { + dqr := data.QueryDataRequest{} + err := web.Bind(req, &dqr) + if err != nil { + return nil, err + } + + ds := dqr.Queries[0].Datasource + pluginCtx, err := r.contextProvider.PluginContextForDataSource(ctx, &backend.DataSourceInstanceSettings{ + Type: ds.Type, + UID: ds.UID, + APIVersion: ds.APIVersion, + }) + if err != nil { + return nil, err + } + + ctx = backend.WithGrafanaConfig(ctx, pluginCtx.GrafanaConfig) + raw, err := json.Marshal(dqr) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + convertRequest := &backend.ConversionRequest{ + PluginContext: pluginCtx, + Objects: []backend.RawObject{ + { + Raw: raw, + ContentType: "application/json", + }, + }, + } + + convertResponse, err := r.client.ConvertObjects(ctx, convertRequest) + if err != nil { + if convertResponse != nil && convertResponse.Result != nil { + return nil, fmt.Errorf("conversion failed. Err: %w. Result: %s", err, convertResponse.Result.Message) + } + return nil, err + } + + qr := &query.QueryDataRequest{} + for _, obj := range convertResponse.Objects { + if obj.ContentType != "application/json" { + return nil, fmt.Errorf("unexpected content type: %s", obj.ContentType) + } + q := &data.DataQuery{} + err = json.Unmarshal(obj.Raw, q) + if err != nil { + return nil, fmt.Errorf("unmarshal: %w", err) + } + qr.Queries = append(qr.Queries, *q) + } + + return qr, nil +} + +func (r *queryConvertREST) Connect(ctx context.Context, name string, _ runtime.Object, responder rest.Responder) (http.Handler, error) { + // See: /pkg/services/apiserver/builder/helper.go#L34 + // The name is set with a rewriter hack + if name != "name" { + return nil, errors.NewNotFound(schema.GroupResource{}, name) + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + r, err := r.convertQueryDataRequest(ctx, req) + if err != nil { + responder.Error(err) + return + } + responder.Object(http.StatusOK, r) + }), nil +} diff --git a/pkg/registry/apis/datasource/queryconvert_test.go b/pkg/registry/apis/datasource/queryconvert_test.go new file mode 100644 index 00000000000..41707104ad7 --- /dev/null +++ b/pkg/registry/apis/datasource/queryconvert_test.go @@ -0,0 +1,90 @@ +package datasource + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestSubQueryConvertConnect(t *testing.T) { + originReq := `{"from":"","to":"","queries":[{"refId":"A","datasource":{"type":"","uid":"dsuid"},"rawSql":"SELECT * FROM table"}]}` + converted := `{"refId":"A","datasource":{"type":"","uid":"dsuid"},"SQL":"SELECT * FROM table"}` + convertedReq := `{"from":"","to":"","queries":[` + converted + `]}` + + sqr := queryConvertREST{ + client: mockConvertClient{ + t: t, + expectedInput: backend.RawObject{Raw: []byte(originReq), ContentType: "application/json"}, + convertObject: backend.RawObject{Raw: []byte(converted), ContentType: "application/json"}, + }, + contextProvider: mockContextProvider{}, + } + rr := httptest.NewRecorder() + mr := &mockResponderConvert{ + writer: rr, + } + handler, err := sqr.Connect(context.Background(), "name", nil, mr) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/", bytes.NewReader([]byte(originReq))) + req.Header.Set("Content-Type", "application/json") + handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), convertedReq) +} + +type mockConvertClient struct { + t *testing.T + expectedInput backend.RawObject + convertObject backend.RawObject +} + +func (m mockConvertClient) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return nil, nil +} + +func (m mockConvertClient) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + return nil +} + +func (m mockConvertClient) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + return nil, nil +} + +func (m mockConvertClient) ConvertObjects(ctx context.Context, req *backend.ConversionRequest) (*backend.ConversionResponse, error) { + require.Equal(m.t, string(m.expectedInput.Raw), string(req.Objects[0].Raw)) + return &backend.ConversionResponse{ + Objects: []backend.RawObject{m.convertObject}, + }, nil +} + +type mockResponderConvert struct { + writer http.ResponseWriter +} + +// Object writes the provided object to the response. Invoking this method multiple times is undefined. +func (m mockResponderConvert) Object(statusCode int, obj runtime.Object) { + m.writer.WriteHeader(statusCode) + err := json.NewEncoder(m.writer).Encode(obj) + if err != nil { + panic(err) + } +} + +// Error writes the provided error to the response. This method may only be invoked once. +func (m mockResponderConvert) Error(err error) { + m.writer.WriteHeader(http.StatusInternalServerError) + errStr := err.Error() + _, err = m.writer.Write([]byte(errStr)) + if err != nil { + panic(err) + } +} diff --git a/pkg/registry/apis/datasource/register.go b/pkg/registry/apis/datasource/register.go index e45c403a4d8..d7a3fd963ea 100644 --- a/pkg/registry/apis/datasource/register.go +++ b/pkg/registry/apis/datasource/register.go @@ -104,6 +104,7 @@ type PluginClient interface { backend.QueryDataHandler backend.CheckHealthHandler backend.CallResourceHandler + backend.ConversionHandler } func NewDataSourceAPIBuilder( @@ -225,6 +226,11 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver // Register hardcoded query schemas err := queryschema.RegisterQueryTypes(b.queryTypes, storage) + if err != nil { + return err + } + + registerQueryConvert(b.client, b.contextProvider, storage) apiGroupInfo.VersionedResourcesStorageMap[conn.GroupVersion().Version] = storage return err diff --git a/pkg/registry/apis/datasource/sub_query_test.go b/pkg/registry/apis/datasource/sub_query_test.go index dc08c74afe4..7657d45b719 100644 --- a/pkg/registry/apis/datasource/sub_query_test.go +++ b/pkg/registry/apis/datasource/sub_query_test.go @@ -71,6 +71,10 @@ func (m mockClient) CheckHealth(ctx context.Context, req *backend.CheckHealthReq return nil, nil } +func (m mockClient) ConvertObjects(ctx context.Context, req *backend.ConversionRequest) (*backend.ConversionResponse, error) { + return nil, nil +} + type mockResponder struct { } diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index 4b633de7e4b..1b7358cdd71 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -52,6 +52,12 @@ var PathRewriters = []filters.PathRewriter{ return matches[1] + "/name" // connector requires a name }, }, + { + Pattern: regexp.MustCompile(`(/apis/.*/v0alpha1/namespaces/.*/queryconvert$)`), + ReplaceFunc: func(matches []string) string { + return matches[1] + "/name" // connector requires a name + }, + }, } func getDefaultBuildHandlerChainFunc(builders []APIGroupBuilder) BuildHandlerChainFunc { diff --git a/pkg/tests/apis/datasource/testdata_test.go b/pkg/tests/apis/datasource/testdata_test.go index da3283ec8e3..3ed3b620c16 100644 --- a/pkg/tests/apis/datasource/testdata_test.go +++ b/pkg/tests/apis/datasource/testdata_test.go @@ -2,6 +2,7 @@ package dashboards import ( "context" + "fmt" "testing" "github.com/stretchr/testify/require" @@ -42,7 +43,7 @@ func TestIntegrationTestDatasource(t *testing.T) { t.Run("Check discovery client", func(t *testing.T) { disco := helper.GetGroupVersionInfoJSON("testdata.datasource.grafana.app") - // fmt.Printf("%s", disco) + fmt.Printf("%s", disco) require.JSONEq(t, `[ { @@ -103,7 +104,20 @@ func TestIntegrationTestDatasource(t *testing.T) { "get", "list" ] - } + }, + { + "resource": "queryconvert", + "responseKind": { + "group": "", + "kind": "QueryDataRequest", + "version": "" + }, + "scope": "Namespaced", + "singularResource": "queryconvert", + "verbs": [ + "create" + ] + } ], "version": "v0alpha1" } From 85bffcf78738c0783f31db948e5a3bc6be4fc6c5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 14:20:12 +0100 Subject: [PATCH 035/102] Update dependency browserslist to v4.24.0 (#93733) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/yarn.lock b/yarn.lock index 03cab457eac..96fc8532fb3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12913,16 +12913,16 @@ __metadata: linkType: hard "browserslist@npm:^4.0.0, browserslist@npm:^4.21.10, browserslist@npm:^4.21.4, browserslist@npm:^4.22.2, browserslist@npm:^4.23.1, browserslist@npm:^4.23.3": - version: 4.23.3 - resolution: "browserslist@npm:4.23.3" + version: 4.24.0 + resolution: "browserslist@npm:4.24.0" dependencies: - caniuse-lite: "npm:^1.0.30001646" - electron-to-chromium: "npm:^1.5.4" + caniuse-lite: "npm:^1.0.30001663" + electron-to-chromium: "npm:^1.5.28" node-releases: "npm:^2.0.18" update-browserslist-db: "npm:^1.1.0" bin: browserslist: cli.js - checksum: 10/e266d18c6c6c5becf9a1a7aa264477677b9796387972e8fce34854bb33dc1666194dc28389780e5dc6566e68a95e87ece2ce222e1c4ca93c2b75b61dfebd5f1c + checksum: 10/26c1b8ba257a0b51b102080ba9d42945af2abaa8c4cf6da21cd47b3f123fc1e81640203b293214356c2c17d9d265bb3a5ed428b6d302f383576dd6ce8fd5036c languageName: node linkType: hard @@ -13165,10 +13165,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001646": - version: 1.0.30001646 - resolution: "caniuse-lite@npm:1.0.30001646" - checksum: 10/6c66a5677b58988c2ee86905b05705b00be552a3e4f768bd0d9a10098cc4ec471de5b204e2c2ab534f34b5f216c059321a9e5cb1395928cf29ded0a4aae4535f +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001646, caniuse-lite@npm:^1.0.30001663": + version: 1.0.30001663 + resolution: "caniuse-lite@npm:1.0.30001663" + checksum: 10/a67aba45e10bf56f584f82ab414ff21f5d23ddbd71936839b79c305710b332d8b91df37948a525fe1c9cede81ab56a3d831ee6e3f1fa11c4f299651ea49a8067 languageName: node linkType: hard @@ -15995,10 +15995,10 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.4": - version: 1.5.4 - resolution: "electron-to-chromium@npm:1.5.4" - checksum: 10/ce64db25c399d33830e74e58bbc5ab7c06948669e204b6508e98c278ddaead1da1cbb356d15b55eb659f89d4d7bcf00944f08f96e886f1d3d065ba11744c5633 +"electron-to-chromium@npm:^1.5.28": + version: 1.5.28 + resolution: "electron-to-chromium@npm:1.5.28" + checksum: 10/dfe890eaeec6f48d245895060edbfd6a0ff8b1a102b22324b2b33cd4aaabe39748a4a9f687be6a5e08f1d8fb1fac0d3bcd96da00d47771a3665d8641111a5f07 languageName: node linkType: hard From 5472478ee838876db8a91aa046237dbc1636a902 Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Wed, 25 Sep 2024 15:29:19 +0200 Subject: [PATCH 036/102] Prometheus: Fix e2e selector by using "data-test-id" instead of "id" (#93736) * use data-test-id instead of id * don't skip --- e2e/various-suite/prometheus-editor.spec.ts | 6 ++---- .../src/querybuilder/components/PromQueryBuilderOptions.tsx | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/e2e/various-suite/prometheus-editor.spec.ts b/e2e/various-suite/prometheus-editor.spec.ts index a35ca282e50..d04d502a706 100644 --- a/e2e/various-suite/prometheus-editor.spec.ts +++ b/e2e/various-suite/prometheus-editor.spec.ts @@ -1,5 +1,3 @@ -import { selectors } from '@grafana/e2e-selectors'; - import { e2e } from '../utils'; import { getResources } from './helpers/prometheus-helpers'; @@ -67,9 +65,9 @@ describe('Prometheus query editor', () => { // check options e2e.components.DataSource.Prometheus.queryEditor.legend().scrollIntoView().should('exist'); e2e.components.DataSource.Prometheus.queryEditor.format().scrollIntoView().should('exist'); - cy.get(`#${selectors.components.DataSource.Prometheus.queryEditor.step}`).scrollIntoView().should('exist'); + cy.get(`[data-test-id="prometheus-step"]`).scrollIntoView().should('exist'); e2e.components.DataSource.Prometheus.queryEditor.type().scrollIntoView().should('exist'); - cy.get(`#${selectors.components.DataSource.Prometheus.queryEditor.exemplars}`).scrollIntoView().should('exist'); + cy.get(`[data-test-id="prometheus-exemplars"]`).scrollIntoView().should('exist'); }); describe('Code editor', () => { diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx index 60c73709c68..d79bb1716e0 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx @@ -92,7 +92,7 @@ export const PromQueryBuilderOptions = React.memo( minWidth={10} onCommitChange={onChangeStep} defaultValue={query.interval} - id={selectors.components.DataSource.Prometheus.queryEditor.step} + data-test-id="prometheus-step" /> @@ -112,7 +112,7 @@ export const PromQueryBuilderOptions = React.memo( )} From 10582e48f7b20db1045901a6b5bad203cfcabdff Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Wed, 25 Sep 2024 09:31:57 -0400 Subject: [PATCH 037/102] Alerting: Notifications Templates API (#91349) --- apps/alerting/notifications/template.cue | 25 + .../v0alpha1/register.go | 47 ++ .../v0alpha1/templategroup_spec.go | 8 + .../alerting_notifications/v0alpha1/types.go | 91 ++- .../v0alpha1/types_ext.go | 21 + .../v0alpha1/zz_generated.deepcopy.go | 76 +++ .../v0alpha1/zz_generated.openapi.go | 138 +++- .../v0alpha1/templategroup.go | 202 ++++++ .../v0alpha1/templategroupspec.go | 34 + pkg/generated/applyconfiguration/utils.go | 4 + .../v0alpha1/alerting_notifications_client.go | 5 + .../fake_alerting_notifications_client.go | 4 + .../v0alpha1/fake/fake_templategroup.go | 146 ++++ .../v0alpha1/generated_expansion.go | 2 + .../v0alpha1/templategroup.go | 55 ++ .../v0alpha1/interface.go | 7 + .../v0alpha1/templategroup.go | 76 +++ .../informers/externalversions/generic.go | 2 + .../v0alpha1/expansion_generated.go | 8 + .../v0alpha1/templategroup.go | 56 ++ .../apis/alerting/notifications/register.go | 14 +- .../notifications/template_group/authorize.go | 54 ++ .../template_group/conversions.go | 52 ++ .../template_group/legacy_storage.go | 187 ++++++ .../notifications/template_group/storage.go | 81 +++ pkg/services/accesscontrol/models.go | 7 +- pkg/services/ngalert/accesscontrol.go | 31 +- .../template_group/templates_group_test.go | 629 ++++++++++++++++++ 28 files changed, 2046 insertions(+), 16 deletions(-) create mode 100644 apps/alerting/notifications/template.cue create mode 100644 pkg/apis/alerting_notifications/v0alpha1/templategroup_spec.go create mode 100644 pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/templategroup.go create mode 100644 pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/templategroupspec.go create mode 100644 pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_templategroup.go create mode 100644 pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/templategroup.go create mode 100644 pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/templategroup.go create mode 100644 pkg/generated/listers/alerting_notifications/v0alpha1/templategroup.go create mode 100644 pkg/registry/apis/alerting/notifications/template_group/authorize.go create mode 100644 pkg/registry/apis/alerting/notifications/template_group/conversions.go create mode 100644 pkg/registry/apis/alerting/notifications/template_group/legacy_storage.go create mode 100644 pkg/registry/apis/alerting/notifications/template_group/storage.go create mode 100644 pkg/tests/apis/alerting/notifications/template_group/templates_group_test.go diff --git a/apps/alerting/notifications/template.cue b/apps/alerting/notifications/template.cue new file mode 100644 index 00000000000..56c2507cd1e --- /dev/null +++ b/apps/alerting/notifications/template.cue @@ -0,0 +1,25 @@ +package core + +templateGroup: { + kind: "TemplateGroup" + group: "notifications" + apiResource: { + groupOverride: "notifications.alerting.grafana.app" + } + codegen: { + frontend: false + backend: true + } + pluralName: "TemplatesGroups" + current: "v0alpha1" + versions: { + "v0alpha1": { + schema: { + spec: { + title: string + content: string + } + } + } + } +} diff --git a/pkg/apis/alerting_notifications/v0alpha1/register.go b/pkg/apis/alerting_notifications/v0alpha1/register.go index d59b6fdd1da..05eab2f7ba3 100644 --- a/pkg/apis/alerting_notifications/v0alpha1/register.go +++ b/pkg/apis/alerting_notifications/v0alpha1/register.go @@ -67,6 +67,25 @@ var ( }, }, ) + TemplateGroupResourceInfo = utils.NewResourceInfo(GROUP, VERSION, + "templategroups", "templategroup", "TemplateGroup", + func() runtime.Object { return &TemplateGroup{} }, + func() runtime.Object { return &TemplateGroupList{} }, + utils.TableColumns{ + Definition: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + }, + Reader: func(obj any) ([]interface{}, error) { + r, ok := obj.(*TemplateGroup) + if !ok { + return nil, fmt.Errorf("expected resource or info") + } + return []interface{}{ + r.Name, + }, nil + }, + }, + ) // SchemeGroupVersion is group version used to register these objects SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} // SchemaBuilder is used by standard codegen @@ -87,6 +106,8 @@ func AddKnownTypesGroup(scheme *runtime.Scheme, g schema.GroupVersion) error { &TimeIntervalList{}, &Receiver{}, &ReceiverList{}, + &TemplateGroup{}, + &TemplateGroupList{}, ) metav1.AddToGroupVersion(scheme, g) @@ -122,6 +143,22 @@ func AddKnownTypesGroup(scheme *runtime.Scheme, g schema.GroupVersion) error { return err } + err = scheme.AddFieldLabelConversionFunc( + TemplateGroupResourceInfo.GroupVersionKind(), + func(label, value string) (string, string, error) { + fieldSet := SelectableTemplateGroupFields(&TemplateGroup{}) + for key := range fieldSet { + if label == key { + return label, value, nil + } + } + return "", "", fmt.Errorf("field label not supported for %s: %s", scope.ScopeNodeResourceInfo.GroupVersionKind(), label) + }, + ) + if err != nil { + return err + } + return nil } @@ -145,6 +182,16 @@ func SelectableReceiverFields(obj *Receiver) fields.Set { }) } +func SelectableTemplateGroupFields(obj *TemplateGroup) fields.Set { + if obj == nil { + return nil + } + return generic.MergeFieldsSets(generic.ObjectMetaFieldsSet(&obj.ObjectMeta, false), fields.Set{ + "metadata.provenance": obj.GetProvenanceStatus(), + "spec.title": obj.Spec.Title, + }) +} + // Resource takes an unqualified resource and returns a Group qualified GroupResource func Resource(resource string) schema.GroupResource { return SchemeGroupVersion.WithResource(resource).GroupResource() diff --git a/pkg/apis/alerting_notifications/v0alpha1/templategroup_spec.go b/pkg/apis/alerting_notifications/v0alpha1/templategroup_spec.go new file mode 100644 index 00000000000..0731380f602 --- /dev/null +++ b/pkg/apis/alerting_notifications/v0alpha1/templategroup_spec.go @@ -0,0 +1,8 @@ +package v0alpha1 + +// TemplateGroupSpec defines model for TemplateGroupSpec. +// +k8s:openapi-gen=true +type TemplateGroupSpec struct { + Title string `json:"title"` + Content string `json:"content"` +} diff --git a/pkg/apis/alerting_notifications/v0alpha1/types.go b/pkg/apis/alerting_notifications/v0alpha1/types.go index ec5edaf6e6e..e674f58ebd7 100644 --- a/pkg/apis/alerting_notifications/v0alpha1/types.go +++ b/pkg/apis/alerting_notifications/v0alpha1/types.go @@ -7,6 +7,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// region TimeInterval + // +genclient // +k8s:openapi-gen=true // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object @@ -86,7 +88,9 @@ type TimeIntervalList struct { Items []TimeInterval `json:"items"` } -// Receivers --------------------------------- +// endregion + +// region Receivers // +genclient // +k8s:openapi-gen=true @@ -166,3 +170,88 @@ type ReceiverList struct { metav1.ListMeta `json:"metadata"` Items []Receiver `json:"items"` } + +// endregion + +// region Templates + +// +genclient +// +k8s:openapi-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type TemplateGroup struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + Spec TemplateGroupSpec `json:"spec"` +} + +func (o *TemplateGroup) GetSpec() any { + return o.Spec +} + +func (o *TemplateGroup) SetSpec(spec any) error { + cast, ok := spec.(TemplateGroupSpec) + if !ok { + return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) + } + o.Spec = cast + return nil +} + +func (o *TemplateGroup) GetCreatedBy() string { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + return o.ObjectMeta.Annotations["grafana.com/createdBy"] +} + +func (o *TemplateGroup) SetCreatedBy(createdBy string) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy +} + +func (o *TemplateGroup) GetUpdateTimestamp() time.Time { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + parsed, _ := time.Parse(o.ObjectMeta.Annotations["grafana.com/updateTimestamp"], time.RFC3339) + return parsed +} + +func (o *TemplateGroup) SetUpdateTimestamp(updateTimestamp time.Time) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) +} + +func (o *TemplateGroup) GetUpdatedBy() string { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + return o.ObjectMeta.Annotations["grafana.com/updatedBy"] +} + +func (o *TemplateGroup) SetUpdatedBy(updatedBy string) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy +} + +// +k8s:openapi-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type TemplateGroupList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []TemplateGroup `json:"items"` +} + +// endregion diff --git a/pkg/apis/alerting_notifications/v0alpha1/types_ext.go b/pkg/apis/alerting_notifications/v0alpha1/types_ext.go index 70a3e9a8b1d..d3b97edcd19 100644 --- a/pkg/apis/alerting_notifications/v0alpha1/types_ext.go +++ b/pkg/apis/alerting_notifications/v0alpha1/types_ext.go @@ -76,3 +76,24 @@ func (o *Receiver) SetInUse(routesCnt int, rules []string) { func InUseAnnotation(resource string) string { return fmt.Sprintf("%s%s/%s", InternalPrefix, "inUse", resource) } + +func (o *TemplateGroup) GetProvenanceStatus() string { + if o == nil || o.Annotations == nil { + return "" + } + s, ok := o.Annotations[ProvenanceStatusAnnotationKey] + if !ok || s == "" { + return ProvenanceStatusNone + } + return s +} + +func (o *TemplateGroup) SetProvenanceStatus(status string) { + if o.Annotations == nil { + o.Annotations = make(map[string]string, 1) + } + if status == "" { + status = ProvenanceStatusNone + } + o.Annotations[ProvenanceStatusAnnotationKey] = status +} diff --git a/pkg/apis/alerting_notifications/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.deepcopy.go index ca7b6aa8a6d..b92ad6d360d 100644 --- a/pkg/apis/alerting_notifications/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.deepcopy.go @@ -174,6 +174,82 @@ func (in *ReceiverSpec) DeepCopy() *ReceiverSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemplateGroup) DeepCopyInto(out *TemplateGroup) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemplateGroup. +func (in *TemplateGroup) DeepCopy() *TemplateGroup { + if in == nil { + return nil + } + out := new(TemplateGroup) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TemplateGroup) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemplateGroupList) DeepCopyInto(out *TemplateGroupList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]TemplateGroup, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemplateGroupList. +func (in *TemplateGroupList) DeepCopy() *TemplateGroupList { + if in == nil { + return nil + } + out := new(TemplateGroupList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TemplateGroupList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemplateGroupSpec) DeepCopyInto(out *TemplateGroupSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemplateGroupSpec. +func (in *TemplateGroupSpec) DeepCopy() *TemplateGroupSpec { + if in == nil { + return nil + } + out := new(TemplateGroupSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TimeInterval) DeepCopyInto(out *TimeInterval) { *out = *in diff --git a/pkg/apis/alerting_notifications/v0alpha1/zz_generated.openapi.go b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.openapi.go index c2ce1fbc925..6703e64406c 100644 --- a/pkg/apis/alerting_notifications/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.openapi.go @@ -14,15 +14,18 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Integration": schema_pkg_apis_alerting_notifications_v0alpha1_Integration(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Interval": schema_pkg_apis_alerting_notifications_v0alpha1_Interval(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Receiver": schema_pkg_apis_alerting_notifications_v0alpha1_Receiver(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.ReceiverList": schema_pkg_apis_alerting_notifications_v0alpha1_ReceiverList(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.ReceiverSpec": schema_pkg_apis_alerting_notifications_v0alpha1_ReceiverSpec(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeInterval": schema_pkg_apis_alerting_notifications_v0alpha1_TimeInterval(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeIntervalList": schema_pkg_apis_alerting_notifications_v0alpha1_TimeIntervalList(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeIntervalSpec": schema_pkg_apis_alerting_notifications_v0alpha1_TimeIntervalSpec(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeRange": schema_pkg_apis_alerting_notifications_v0alpha1_TimeRange(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Integration": schema_pkg_apis_alerting_notifications_v0alpha1_Integration(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Interval": schema_pkg_apis_alerting_notifications_v0alpha1_Interval(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Receiver": schema_pkg_apis_alerting_notifications_v0alpha1_Receiver(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.ReceiverList": schema_pkg_apis_alerting_notifications_v0alpha1_ReceiverList(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.ReceiverSpec": schema_pkg_apis_alerting_notifications_v0alpha1_ReceiverSpec(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TemplateGroup": schema_pkg_apis_alerting_notifications_v0alpha1_TemplateGroup(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TemplateGroupList": schema_pkg_apis_alerting_notifications_v0alpha1_TemplateGroupList(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TemplateGroupSpec": schema_pkg_apis_alerting_notifications_v0alpha1_TemplateGroupSpec(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeInterval": schema_pkg_apis_alerting_notifications_v0alpha1_TimeInterval(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeIntervalList": schema_pkg_apis_alerting_notifications_v0alpha1_TimeIntervalList(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeIntervalSpec": schema_pkg_apis_alerting_notifications_v0alpha1_TimeIntervalSpec(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeRange": schema_pkg_apis_alerting_notifications_v0alpha1_TimeRange(ref), } } @@ -336,6 +339,123 @@ func schema_pkg_apis_alerting_notifications_v0alpha1_ReceiverSpec(ref common.Ref } } +func schema_pkg_apis_alerting_notifications_v0alpha1_TemplateGroup(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TemplateGroupSpec"), + }, + }, + }, + Required: []string{"metadata", "spec"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TemplateGroupSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_alerting_notifications_v0alpha1_TemplateGroupList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TemplateGroup"), + }, + }, + }, + }, + }, + }, + Required: []string{"metadata", "items"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TemplateGroup", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_alerting_notifications_v0alpha1_TemplateGroupSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "TemplateGroupSpec defines model for TemplateGroupSpec.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "title": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "content": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"title", "content"}, + }, + }, + } +} + func schema_pkg_apis_alerting_notifications_v0alpha1_TimeInterval(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/templategroup.go b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/templategroup.go new file mode 100644 index 00000000000..b4340dcde50 --- /dev/null +++ b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/templategroup.go @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// TemplateGroupApplyConfiguration represents a declarative configuration of the TemplateGroup type for use +// with apply. +type TemplateGroupApplyConfiguration struct { + v1.TypeMetaApplyConfiguration `json:",inline"` + *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` + Spec *TemplateGroupSpecApplyConfiguration `json:"spec,omitempty"` +} + +// TemplateGroup constructs a declarative configuration of the TemplateGroup type for use with +// apply. +func TemplateGroup(name, namespace string) *TemplateGroupApplyConfiguration { + b := &TemplateGroupApplyConfiguration{} + b.WithName(name) + b.WithNamespace(namespace) + b.WithKind("TemplateGroup") + b.WithAPIVersion("notifications.alerting.grafana.app/v0alpha1") + return b +} + +// WithKind sets the Kind field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Kind field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithKind(value string) *TemplateGroupApplyConfiguration { + b.Kind = &value + return b +} + +// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the APIVersion field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithAPIVersion(value string) *TemplateGroupApplyConfiguration { + b.APIVersion = &value + return b +} + +// WithName sets the Name field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Name field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithName(value string) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.Name = &value + return b +} + +// WithGenerateName sets the GenerateName field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the GenerateName field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithGenerateName(value string) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.GenerateName = &value + return b +} + +// WithNamespace sets the Namespace field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Namespace field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithNamespace(value string) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.Namespace = &value + return b +} + +// WithUID sets the UID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the UID field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithUID(value types.UID) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.UID = &value + return b +} + +// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ResourceVersion field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithResourceVersion(value string) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ResourceVersion = &value + return b +} + +// WithGeneration sets the Generation field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Generation field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithGeneration(value int64) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.Generation = &value + return b +} + +// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the CreationTimestamp field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithCreationTimestamp(value metav1.Time) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.CreationTimestamp = &value + return b +} + +// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionTimestamp field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.DeletionTimestamp = &value + return b +} + +// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.DeletionGracePeriodSeconds = &value + return b +} + +// WithLabels puts the entries into the Labels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Labels field, +// overwriting an existing map entries in Labels field with the same key. +func (b *TemplateGroupApplyConfiguration) WithLabels(entries map[string]string) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.Labels == nil && len(entries) > 0 { + b.Labels = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.Labels[k] = v + } + return b +} + +// WithAnnotations puts the entries into the Annotations field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Annotations field, +// overwriting an existing map entries in Annotations field with the same key. +func (b *TemplateGroupApplyConfiguration) WithAnnotations(entries map[string]string) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.Annotations == nil && len(entries) > 0 { + b.Annotations = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.Annotations[k] = v + } + return b +} + +// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the OwnerReferences field. +func (b *TemplateGroupApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + if values[i] == nil { + panic("nil value passed to WithOwnerReferences") + } + b.OwnerReferences = append(b.OwnerReferences, *values[i]) + } + return b +} + +// WithFinalizers adds the given value to the Finalizers field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Finalizers field. +func (b *TemplateGroupApplyConfiguration) WithFinalizers(values ...string) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + b.Finalizers = append(b.Finalizers, values[i]) + } + return b +} + +func (b *TemplateGroupApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { + if b.ObjectMetaApplyConfiguration == nil { + b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} + } +} + +// WithSpec sets the Spec field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Spec field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithSpec(value *TemplateGroupSpecApplyConfiguration) *TemplateGroupApplyConfiguration { + b.Spec = value + return b +} + +// GetName retrieves the value of the Name field in the declarative configuration. +func (b *TemplateGroupApplyConfiguration) GetName() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.Name +} diff --git a/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/templategroupspec.go b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/templategroupspec.go new file mode 100644 index 00000000000..42fcad447be --- /dev/null +++ b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/templategroupspec.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +// TemplateGroupSpecApplyConfiguration represents a declarative configuration of the TemplateGroupSpec type for use +// with apply. +type TemplateGroupSpecApplyConfiguration struct { + Title *string `json:"title,omitempty"` + Content *string `json:"content,omitempty"` +} + +// TemplateGroupSpecApplyConfiguration constructs a declarative configuration of the TemplateGroupSpec type for use with +// apply. +func TemplateGroupSpec() *TemplateGroupSpecApplyConfiguration { + return &TemplateGroupSpecApplyConfiguration{} +} + +// WithTitle sets the Title field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Title field is set to the value of the last call. +func (b *TemplateGroupSpecApplyConfiguration) WithTitle(value string) *TemplateGroupSpecApplyConfiguration { + b.Title = &value + return b +} + +// WithContent sets the Content field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Content field is set to the value of the last call. +func (b *TemplateGroupSpecApplyConfiguration) WithContent(value string) *TemplateGroupSpecApplyConfiguration { + b.Content = &value + return b +} diff --git a/pkg/generated/applyconfiguration/utils.go b/pkg/generated/applyconfiguration/utils.go index 55ac136e4fc..8221f95b1da 100644 --- a/pkg/generated/applyconfiguration/utils.go +++ b/pkg/generated/applyconfiguration/utils.go @@ -28,6 +28,10 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &alertingnotificationsv0alpha1.ReceiverApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("ReceiverSpec"): return &alertingnotificationsv0alpha1.ReceiverSpecApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("TemplateGroup"): + return &alertingnotificationsv0alpha1.TemplateGroupApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("TemplateGroupSpec"): + return &alertingnotificationsv0alpha1.TemplateGroupSpecApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("TimeInterval"): return &alertingnotificationsv0alpha1.TimeIntervalApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("TimeIntervalSpec"): diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/alerting_notifications_client.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/alerting_notifications_client.go index 53dfb5ad0d8..f77cbc0c51a 100644 --- a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/alerting_notifications_client.go +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/alerting_notifications_client.go @@ -15,6 +15,7 @@ import ( type NotificationsV0alpha1Interface interface { RESTClient() rest.Interface ReceiversGetter + TemplateGroupsGetter TimeIntervalsGetter } @@ -27,6 +28,10 @@ func (c *NotificationsV0alpha1Client) Receivers(namespace string) ReceiverInterf return newReceivers(c, namespace) } +func (c *NotificationsV0alpha1Client) TemplateGroups(namespace string) TemplateGroupInterface { + return newTemplateGroups(c, namespace) +} + func (c *NotificationsV0alpha1Client) TimeIntervals(namespace string) TimeIntervalInterface { return newTimeIntervals(c, namespace) } diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_alerting_notifications_client.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_alerting_notifications_client.go index 84bcadcd8e2..f2695e6e8a3 100644 --- a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_alerting_notifications_client.go +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_alerting_notifications_client.go @@ -18,6 +18,10 @@ func (c *FakeNotificationsV0alpha1) Receivers(namespace string) v0alpha1.Receive return &FakeReceivers{c, namespace} } +func (c *FakeNotificationsV0alpha1) TemplateGroups(namespace string) v0alpha1.TemplateGroupInterface { + return &FakeTemplateGroups{c, namespace} +} + func (c *FakeNotificationsV0alpha1) TimeIntervals(namespace string) v0alpha1.TimeIntervalInterface { return &FakeTimeIntervals{c, namespace} } diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_templategroup.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_templategroup.go new file mode 100644 index 00000000000..ffbd0668e4b --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_templategroup.go @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + "context" + json "encoding/json" + "fmt" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + alertingnotificationsv0alpha1 "github.com/grafana/grafana/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeTemplateGroups implements TemplateGroupInterface +type FakeTemplateGroups struct { + Fake *FakeNotificationsV0alpha1 + ns string +} + +var templategroupsResource = v0alpha1.SchemeGroupVersion.WithResource("templategroups") + +var templategroupsKind = v0alpha1.SchemeGroupVersion.WithKind("TemplateGroup") + +// Get takes name of the templateGroup, and returns the corresponding templateGroup object, and an error if there is any. +func (c *FakeTemplateGroups) Get(ctx context.Context, name string, options v1.GetOptions) (result *v0alpha1.TemplateGroup, err error) { + emptyResult := &v0alpha1.TemplateGroup{} + obj, err := c.Fake. + Invokes(testing.NewGetActionWithOptions(templategroupsResource, c.ns, name, options), emptyResult) + + if obj == nil { + return emptyResult, err + } + return obj.(*v0alpha1.TemplateGroup), err +} + +// List takes label and field selectors, and returns the list of TemplateGroups that match those selectors. +func (c *FakeTemplateGroups) List(ctx context.Context, opts v1.ListOptions) (result *v0alpha1.TemplateGroupList, err error) { + emptyResult := &v0alpha1.TemplateGroupList{} + obj, err := c.Fake. + Invokes(testing.NewListActionWithOptions(templategroupsResource, templategroupsKind, c.ns, opts), emptyResult) + + if obj == nil { + return emptyResult, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v0alpha1.TemplateGroupList{ListMeta: obj.(*v0alpha1.TemplateGroupList).ListMeta} + for _, item := range obj.(*v0alpha1.TemplateGroupList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested templateGroups. +func (c *FakeTemplateGroups) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchActionWithOptions(templategroupsResource, c.ns, opts)) + +} + +// Create takes the representation of a templateGroup and creates it. Returns the server's representation of the templateGroup, and an error, if there is any. +func (c *FakeTemplateGroups) Create(ctx context.Context, templateGroup *v0alpha1.TemplateGroup, opts v1.CreateOptions) (result *v0alpha1.TemplateGroup, err error) { + emptyResult := &v0alpha1.TemplateGroup{} + obj, err := c.Fake. + Invokes(testing.NewCreateActionWithOptions(templategroupsResource, c.ns, templateGroup, opts), emptyResult) + + if obj == nil { + return emptyResult, err + } + return obj.(*v0alpha1.TemplateGroup), err +} + +// Update takes the representation of a templateGroup and updates it. Returns the server's representation of the templateGroup, and an error, if there is any. +func (c *FakeTemplateGroups) Update(ctx context.Context, templateGroup *v0alpha1.TemplateGroup, opts v1.UpdateOptions) (result *v0alpha1.TemplateGroup, err error) { + emptyResult := &v0alpha1.TemplateGroup{} + obj, err := c.Fake. + Invokes(testing.NewUpdateActionWithOptions(templategroupsResource, c.ns, templateGroup, opts), emptyResult) + + if obj == nil { + return emptyResult, err + } + return obj.(*v0alpha1.TemplateGroup), err +} + +// Delete takes name of the templateGroup and deletes it. Returns an error if one occurs. +func (c *FakeTemplateGroups) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteActionWithOptions(templategroupsResource, c.ns, name, opts), &v0alpha1.TemplateGroup{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeTemplateGroups) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + action := testing.NewDeleteCollectionActionWithOptions(templategroupsResource, c.ns, opts, listOpts) + + _, err := c.Fake.Invokes(action, &v0alpha1.TemplateGroupList{}) + return err +} + +// Patch applies the patch and returns the patched templateGroup. +func (c *FakeTemplateGroups) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v0alpha1.TemplateGroup, err error) { + emptyResult := &v0alpha1.TemplateGroup{} + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceActionWithOptions(templategroupsResource, c.ns, name, pt, data, opts, subresources...), emptyResult) + + if obj == nil { + return emptyResult, err + } + return obj.(*v0alpha1.TemplateGroup), err +} + +// Apply takes the given apply declarative configuration, applies it and returns the applied templateGroup. +func (c *FakeTemplateGroups) Apply(ctx context.Context, templateGroup *alertingnotificationsv0alpha1.TemplateGroupApplyConfiguration, opts v1.ApplyOptions) (result *v0alpha1.TemplateGroup, err error) { + if templateGroup == nil { + return nil, fmt.Errorf("templateGroup provided to Apply must not be nil") + } + data, err := json.Marshal(templateGroup) + if err != nil { + return nil, err + } + name := templateGroup.Name + if name == nil { + return nil, fmt.Errorf("templateGroup.Name must be provided to Apply") + } + emptyResult := &v0alpha1.TemplateGroup{} + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceActionWithOptions(templategroupsResource, c.ns, *name, types.ApplyPatchType, data, opts.ToPatchOptions()), emptyResult) + + if obj == nil { + return emptyResult, err + } + return obj.(*v0alpha1.TemplateGroup), err +} diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/generated_expansion.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/generated_expansion.go index c30d1403330..f76e1bfa65d 100644 --- a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/generated_expansion.go +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/generated_expansion.go @@ -6,4 +6,6 @@ package v0alpha1 type ReceiverExpansion interface{} +type TemplateGroupExpansion interface{} + type TimeIntervalExpansion interface{} diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/templategroup.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/templategroup.go new file mode 100644 index 00000000000..5eb06224934 --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/templategroup.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + "context" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + alertingnotificationsv0alpha1 "github.com/grafana/grafana/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1" + scheme "github.com/grafana/grafana/pkg/generated/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// TemplateGroupsGetter has a method to return a TemplateGroupInterface. +// A group's client should implement this interface. +type TemplateGroupsGetter interface { + TemplateGroups(namespace string) TemplateGroupInterface +} + +// TemplateGroupInterface has methods to work with TemplateGroup resources. +type TemplateGroupInterface interface { + Create(ctx context.Context, templateGroup *v0alpha1.TemplateGroup, opts v1.CreateOptions) (*v0alpha1.TemplateGroup, error) + Update(ctx context.Context, templateGroup *v0alpha1.TemplateGroup, opts v1.UpdateOptions) (*v0alpha1.TemplateGroup, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*v0alpha1.TemplateGroup, error) + List(ctx context.Context, opts v1.ListOptions) (*v0alpha1.TemplateGroupList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v0alpha1.TemplateGroup, err error) + Apply(ctx context.Context, templateGroup *alertingnotificationsv0alpha1.TemplateGroupApplyConfiguration, opts v1.ApplyOptions) (result *v0alpha1.TemplateGroup, err error) + TemplateGroupExpansion +} + +// templateGroups implements TemplateGroupInterface +type templateGroups struct { + *gentype.ClientWithListAndApply[*v0alpha1.TemplateGroup, *v0alpha1.TemplateGroupList, *alertingnotificationsv0alpha1.TemplateGroupApplyConfiguration] +} + +// newTemplateGroups returns a TemplateGroups +func newTemplateGroups(c *NotificationsV0alpha1Client, namespace string) *templateGroups { + return &templateGroups{ + gentype.NewClientWithListAndApply[*v0alpha1.TemplateGroup, *v0alpha1.TemplateGroupList, *alertingnotificationsv0alpha1.TemplateGroupApplyConfiguration]( + "templategroups", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *v0alpha1.TemplateGroup { return &v0alpha1.TemplateGroup{} }, + func() *v0alpha1.TemplateGroupList { return &v0alpha1.TemplateGroupList{} }), + } +} diff --git a/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/interface.go b/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/interface.go index 64c33c7fc5e..a84b5042189 100644 --- a/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/interface.go +++ b/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/interface.go @@ -12,6 +12,8 @@ import ( type Interface interface { // Receivers returns a ReceiverInformer. Receivers() ReceiverInformer + // TemplateGroups returns a TemplateGroupInformer. + TemplateGroups() TemplateGroupInformer // TimeIntervals returns a TimeIntervalInformer. TimeIntervals() TimeIntervalInformer } @@ -32,6 +34,11 @@ func (v *version) Receivers() ReceiverInformer { return &receiverInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} } +// TemplateGroups returns a TemplateGroupInformer. +func (v *version) TemplateGroups() TemplateGroupInformer { + return &templateGroupInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // TimeIntervals returns a TimeIntervalInformer. func (v *version) TimeIntervals() TimeIntervalInformer { return &timeIntervalInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/templategroup.go b/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/templategroup.go new file mode 100644 index 00000000000..2a04ec9ea4e --- /dev/null +++ b/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/templategroup.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by informer-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + "context" + time "time" + + alertingnotificationsv0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + versioned "github.com/grafana/grafana/pkg/generated/clientset/versioned" + internalinterfaces "github.com/grafana/grafana/pkg/generated/informers/externalversions/internalinterfaces" + v0alpha1 "github.com/grafana/grafana/pkg/generated/listers/alerting_notifications/v0alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// TemplateGroupInformer provides access to a shared informer and lister for +// TemplateGroups. +type TemplateGroupInformer interface { + Informer() cache.SharedIndexInformer + Lister() v0alpha1.TemplateGroupLister +} + +type templateGroupInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewTemplateGroupInformer constructs a new informer for TemplateGroup type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewTemplateGroupInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredTemplateGroupInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredTemplateGroupInformer constructs a new informer for TemplateGroup type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredTemplateGroupInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.NotificationsV0alpha1().TemplateGroups(namespace).List(context.TODO(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.NotificationsV0alpha1().TemplateGroups(namespace).Watch(context.TODO(), options) + }, + }, + &alertingnotificationsv0alpha1.TemplateGroup{}, + resyncPeriod, + indexers, + ) +} + +func (f *templateGroupInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredTemplateGroupInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *templateGroupInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&alertingnotificationsv0alpha1.TemplateGroup{}, f.defaultInformer) +} + +func (f *templateGroupInformer) Lister() v0alpha1.TemplateGroupLister { + return v0alpha1.NewTemplateGroupLister(f.Informer().GetIndexer()) +} diff --git a/pkg/generated/informers/externalversions/generic.go b/pkg/generated/informers/externalversions/generic.go index 36b920d2c47..9c16b6eab97 100644 --- a/pkg/generated/informers/externalversions/generic.go +++ b/pkg/generated/informers/externalversions/generic.go @@ -42,6 +42,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource // Group=notifications.alerting.grafana.app, Version=v0alpha1 case v0alpha1.SchemeGroupVersion.WithResource("receivers"): return &genericInformer{resource: resource.GroupResource(), informer: f.Notifications().V0alpha1().Receivers().Informer()}, nil + case v0alpha1.SchemeGroupVersion.WithResource("templategroups"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Notifications().V0alpha1().TemplateGroups().Informer()}, nil case v0alpha1.SchemeGroupVersion.WithResource("timeintervals"): return &genericInformer{resource: resource.GroupResource(), informer: f.Notifications().V0alpha1().TimeIntervals().Informer()}, nil diff --git a/pkg/generated/listers/alerting_notifications/v0alpha1/expansion_generated.go b/pkg/generated/listers/alerting_notifications/v0alpha1/expansion_generated.go index ed4d4f239ae..8f4585658eb 100644 --- a/pkg/generated/listers/alerting_notifications/v0alpha1/expansion_generated.go +++ b/pkg/generated/listers/alerting_notifications/v0alpha1/expansion_generated.go @@ -12,6 +12,14 @@ type ReceiverListerExpansion interface{} // ReceiverNamespaceLister. type ReceiverNamespaceListerExpansion interface{} +// TemplateGroupListerExpansion allows custom methods to be added to +// TemplateGroupLister. +type TemplateGroupListerExpansion interface{} + +// TemplateGroupNamespaceListerExpansion allows custom methods to be added to +// TemplateGroupNamespaceLister. +type TemplateGroupNamespaceListerExpansion interface{} + // TimeIntervalListerExpansion allows custom methods to be added to // TimeIntervalLister. type TimeIntervalListerExpansion interface{} diff --git a/pkg/generated/listers/alerting_notifications/v0alpha1/templategroup.go b/pkg/generated/listers/alerting_notifications/v0alpha1/templategroup.go new file mode 100644 index 00000000000..67d7df50f96 --- /dev/null +++ b/pkg/generated/listers/alerting_notifications/v0alpha1/templategroup.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by lister-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + v0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/listers" + "k8s.io/client-go/tools/cache" +) + +// TemplateGroupLister helps list TemplateGroups. +// All objects returned here must be treated as read-only. +type TemplateGroupLister interface { + // List lists all TemplateGroups in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v0alpha1.TemplateGroup, err error) + // TemplateGroups returns an object that can list and get TemplateGroups. + TemplateGroups(namespace string) TemplateGroupNamespaceLister + TemplateGroupListerExpansion +} + +// templateGroupLister implements the TemplateGroupLister interface. +type templateGroupLister struct { + listers.ResourceIndexer[*v0alpha1.TemplateGroup] +} + +// NewTemplateGroupLister returns a new TemplateGroupLister. +func NewTemplateGroupLister(indexer cache.Indexer) TemplateGroupLister { + return &templateGroupLister{listers.New[*v0alpha1.TemplateGroup](indexer, v0alpha1.Resource("templategroup"))} +} + +// TemplateGroups returns an object that can list and get TemplateGroups. +func (s *templateGroupLister) TemplateGroups(namespace string) TemplateGroupNamespaceLister { + return templateGroupNamespaceLister{listers.NewNamespaced[*v0alpha1.TemplateGroup](s.ResourceIndexer, namespace)} +} + +// TemplateGroupNamespaceLister helps list and get TemplateGroups. +// All objects returned here must be treated as read-only. +type TemplateGroupNamespaceLister interface { + // List lists all TemplateGroups in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v0alpha1.TemplateGroup, err error) + // Get retrieves the TemplateGroup from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*v0alpha1.TemplateGroup, error) + TemplateGroupNamespaceListerExpansion +} + +// templateGroupNamespaceLister implements the TemplateGroupNamespaceLister +// interface. +type templateGroupNamespaceLister struct { + listers.ResourceIndexer[*v0alpha1.TemplateGroup] +} diff --git a/pkg/registry/apis/alerting/notifications/register.go b/pkg/registry/apis/alerting/notifications/register.go index be565852adc..f073a3c7d93 100644 --- a/pkg/registry/apis/alerting/notifications/register.go +++ b/pkg/registry/apis/alerting/notifications/register.go @@ -16,6 +16,7 @@ import ( notificationsModels "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" receiver "github.com/grafana/grafana/pkg/registry/apis/alerting/notifications/receiver" + "github.com/grafana/grafana/pkg/registry/apis/alerting/notifications/template_group" timeInterval "github.com/grafana/grafana/pkg/registry/apis/alerting/notifications/timeinterval" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/builder" @@ -81,9 +82,15 @@ func (t *NotificationsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiser return fmt.Errorf("failed to initialize receiver storage: %w", err) } + templ, err := template_group.NewStorage(t.ng.Api.Templates, t.namespacer, scheme, optsGetter, dualWriteBuilder) + if err != nil { + return fmt.Errorf("failed to initialize templates group storage: %w", err) + } + apiGroupInfo.VersionedResourcesStorageMap[notificationsModels.VERSION] = map[string]rest.Storage{ - notificationsModels.TimeIntervalResourceInfo.StoragePath(): intervals, - notificationsModels.ReceiverResourceInfo.StoragePath(): recvStorage, + notificationsModels.TimeIntervalResourceInfo.StoragePath(): intervals, + notificationsModels.ReceiverResourceInfo.StoragePath(): recvStorage, + notificationsModels.TemplateGroupResourceInfo.StoragePath(): templ, } return nil } @@ -107,6 +114,7 @@ func (t *NotificationsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3 // Hide the ability to list or watch across all tenants delete(oas.Paths.Paths, root+notificationsModels.ReceiverResourceInfo.GroupResource().Resource) delete(oas.Paths.Paths, root+notificationsModels.TimeIntervalResourceInfo.GroupResource().Resource) + delete(oas.Paths.Paths, root+notificationsModels.TemplateGroupResourceInfo.GroupResource().Resource) // The root API discovery list sub := oas.Paths.Paths[root] @@ -120,6 +128,8 @@ func (t *NotificationsAPIBuilder) GetAuthorizer() authorizer.Authorizer { return authorizer.AuthorizerFunc( func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { switch a.GetResource() { + case notificationsModels.TemplateGroupResourceInfo.GroupResource().Resource: + return template_group.Authorize(ctx, t.authz, a) case notificationsModels.TimeIntervalResourceInfo.GroupResource().Resource: return timeInterval.Authorize(ctx, t.authz, a) case notificationsModels.ReceiverResourceInfo.GroupResource().Resource: diff --git a/pkg/registry/apis/alerting/notifications/template_group/authorize.go b/pkg/registry/apis/alerting/notifications/template_group/authorize.go new file mode 100644 index 00000000000..b9a3f92224a --- /dev/null +++ b/pkg/registry/apis/alerting/notifications/template_group/authorize.go @@ -0,0 +1,54 @@ +package template_group + +import ( + "context" + + "k8s.io/apiserver/pkg/authorization/authorizer" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +func Authorize(ctx context.Context, ac accesscontrol.AccessControl, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + if attr.GetResource() != resourceInfo.GroupResource().Resource { + return authorizer.DecisionNoOpinion, "", nil + } + user, err := identity.GetRequester(ctx) + if err != nil { + return authorizer.DecisionDeny, "valid user is required", err + } + + var action accesscontrol.Evaluator + switch attr.GetVerb() { + case "patch": + fallthrough + case "create": + fallthrough + case "update": + action = accesscontrol.EvalAny( + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTemplatesWrite), + ) + case "deletecollection": + fallthrough + case "delete": + action = accesscontrol.EvalAny( + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTemplatesDelete), + ) + } + + eval := accesscontrol.EvalAny( + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsRead), + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTemplatesRead), + ) + if action != nil { + eval = accesscontrol.EvalAll(eval, action) + } + + ok, err := ac.Evaluate(ctx, user, eval) + if ok { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "", err +} diff --git a/pkg/registry/apis/alerting/notifications/template_group/conversions.go b/pkg/registry/apis/alerting/notifications/template_group/conversions.go new file mode 100644 index 00000000000..4988f3187f5 --- /dev/null +++ b/pkg/registry/apis/alerting/notifications/template_group/conversions.go @@ -0,0 +1,52 @@ +package template_group + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" + + model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +func convertToK8sResources(orgID int64, list []definitions.NotificationTemplate, namespacer request.NamespaceMapper, selector fields.Selector) (*model.TemplateGroupList, error) { + result := &model.TemplateGroupList{} + for _, t := range list { + item := convertToK8sResource(orgID, t, namespacer) + if selector != nil && !selector.Empty() && !selector.Matches(model.SelectableTemplateGroupFields(item)) { + continue + } + result.Items = append(result.Items, *item) + } + return result, nil +} + +func convertToK8sResource(orgID int64, template definitions.NotificationTemplate, namespacer request.NamespaceMapper) *model.TemplateGroup { + result := &model.TemplateGroup{ + TypeMeta: resourceInfo.TypeMeta(), + ObjectMeta: metav1.ObjectMeta{ + UID: types.UID(template.UID), + Name: template.UID, + Namespace: namespacer(orgID), + ResourceVersion: template.ResourceVersion, + }, + Spec: model.TemplateGroupSpec{ + Title: template.Name, + Content: template.Template, + }, + } + result.SetProvenanceStatus(string(template.Provenance)) + return result +} + +func convertToDomainModel(template *model.TemplateGroup) definitions.NotificationTemplate { + return definitions.NotificationTemplate{ + UID: template.ObjectMeta.Name, + Name: template.Spec.Title, + Template: template.Spec.Content, + ResourceVersion: template.ResourceVersion, + Provenance: definitions.Provenance(ngmodels.ProvenanceNone), + } +} diff --git a/pkg/registry/apis/alerting/notifications/template_group/legacy_storage.go b/pkg/registry/apis/alerting/notifications/template_group/legacy_storage.go new file mode 100644 index 00000000000..09a783e2af1 --- /dev/null +++ b/pkg/registry/apis/alerting/notifications/template_group/legacy_storage.go @@ -0,0 +1,187 @@ +package template_group + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + notifications "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +var ( + _ grafanarest.LegacyStorage = (*legacyStorage)(nil) +) + +type TemplateService interface { + GetTemplate(ctx context.Context, orgID int64, nameOrUid string) (definitions.NotificationTemplate, error) + GetTemplates(ctx context.Context, orgID int64) ([]definitions.NotificationTemplate, error) + CreateTemplate(ctx context.Context, orgID int64, tmpl definitions.NotificationTemplate) (definitions.NotificationTemplate, error) + UpdateTemplate(ctx context.Context, orgID int64, tmpl definitions.NotificationTemplate) (definitions.NotificationTemplate, error) + DeleteTemplate(ctx context.Context, orgID int64, nameOrUid string, provenance definitions.Provenance, version string) error +} + +var resourceInfo = notifications.TemplateGroupResourceInfo + +type legacyStorage struct { + service TemplateService + namespacer request.NamespaceMapper + tableConverter rest.TableConvertor +} + +func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { + return nil, errors.NewMethodNotSupported(resourceInfo.GroupResource(), "deleteCollection") +} + +func (s *legacyStorage) New() runtime.Object { + return resourceInfo.NewFunc() +} + +func (s *legacyStorage) Destroy() {} + +func (s *legacyStorage) NamespaceScoped() bool { + return true // namespace == org +} + +func (s *legacyStorage) GetSingularName() string { + return resourceInfo.GetSingularName() +} + +func (s *legacyStorage) NewList() runtime.Object { + return resourceInfo.NewListFunc() +} + +func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.tableConverter.ConvertToTable(ctx, object, tableOptions) +} + +func (s *legacyStorage) List(ctx context.Context, opts *internalversion.ListOptions) (runtime.Object, error) { + orgId, err := request.OrgIDForList(ctx) + if err != nil { + return nil, err + } + + res, err := s.service.GetTemplates(ctx, orgId) + if err != nil { + return nil, err + } + + return convertToK8sResources(orgId, res, s.namespacer, opts.FieldSelector) +} + +func (s *legacyStorage) Get(ctx context.Context, name string, _ *metav1.GetOptions) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + dto, err := s.service.GetTemplate(ctx, info.OrgID, name) + if err != nil { + return nil, err + } + return convertToK8sResource(info.OrgID, dto, s.namespacer), nil +} + +func (s *legacyStorage) Create(ctx context.Context, + obj runtime.Object, + createValidation rest.ValidateObjectFunc, + _ *metav1.CreateOptions, +) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + if createValidation != nil { + if err := createValidation(ctx, obj.DeepCopyObject()); err != nil { + return nil, err + } + } + p, ok := obj.(*notifications.TemplateGroup) + if !ok { + return nil, fmt.Errorf("expected template but got %s", obj.GetObjectKind().GroupVersionKind()) + } + if p.ObjectMeta.Name != "" { // TODO remove when metadata.name can be defined by user + return nil, errors.NewBadRequest("object's metadata.name should be empty") + } + out, err := s.service.CreateTemplate(ctx, info.OrgID, convertToDomainModel(p)) + if err != nil { + return nil, err + } + return convertToK8sResource(info.OrgID, out, s.namespacer), nil +} + +func (s *legacyStorage) Update(ctx context.Context, + name string, + objInfo rest.UpdatedObjectInfo, + createValidation rest.ValidateObjectFunc, + updateValidation rest.ValidateObjectUpdateFunc, + _ bool, + _ *metav1.UpdateOptions, +) (runtime.Object, bool, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, false, err + } + + dto, err := s.service.GetTemplate(ctx, info.OrgID, name) + if err != nil { + return nil, false, err + } + old := convertToK8sResource(info.OrgID, dto, s.namespacer) + + obj, err := objInfo.UpdatedObject(ctx, old) + if err != nil { + return old, false, err + } + + if updateValidation != nil { + if err := updateValidation(ctx, obj, old); err != nil { + return nil, false, err + } + } + + p, ok := obj.(*notifications.TemplateGroup) + if !ok { + return nil, false, fmt.Errorf("expected template but got %s", obj.GetObjectKind().GroupVersionKind()) + } + + domainModel := convertToDomainModel(p) + updated, err := s.service.UpdateTemplate(ctx, info.OrgID, domainModel) + if err != nil { + return nil, false, err + } + + r := convertToK8sResource(info.OrgID, updated, s.namespacer) + return r, false, nil +} + +// GracefulDeleter +func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, false, err + } + old, err := s.Get(ctx, name, nil) + if err != nil { + return old, false, err + } + version := "" + if options.Preconditions != nil && options.Preconditions.ResourceVersion != nil { + version = *options.Preconditions.ResourceVersion + } + if deleteValidation != nil { + if err = deleteValidation(ctx, old); err != nil { + return nil, false, err + } + } + err = s.service.DeleteTemplate(ctx, info.OrgID, name, definitions.Provenance(models.ProvenanceNone), version) // TODO add support for dry-run option + return old, false, err // false - will be deleted async +} diff --git a/pkg/registry/apis/alerting/notifications/template_group/storage.go b/pkg/registry/apis/alerting/notifications/template_group/storage.go new file mode 100644 index 00000000000..e1f6a9f6886 --- /dev/null +++ b/pkg/registry/apis/alerting/notifications/template_group/storage.go @@ -0,0 +1,81 @@ +package template_group + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/apiserver/pkg/registry/rest" + apistore "k8s.io/apiserver/pkg/storage" + + model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" +) + +var _ grafanarest.Storage = (*storage)(nil) + +type storage struct { + *genericregistry.Store +} + +func (s storage) Compare(storageObj, legacyObj runtime.Object) bool { + // TODO implement when supported dual write mode is not Mode0 + return false +} + +func NewStorage( + legacySvc TemplateService, + namespacer request.NamespaceMapper, + scheme *runtime.Scheme, + optsGetter generic.RESTOptionsGetter, + dualWriteBuilder grafanarest.DualWriteBuilder, +) (rest.Storage, error) { + legacyStore := &legacyStorage{ + service: legacySvc, + namespacer: namespacer, + tableConverter: resourceInfo.TableConverter(), + } + if optsGetter != nil && dualWriteBuilder != nil { + strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) + s := &genericregistry.Store{ + NewFunc: resourceInfo.NewFunc, + NewListFunc: resourceInfo.NewListFunc, + KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), + KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), + PredicateFunc: Matcher, + DefaultQualifiedResource: resourceInfo.GroupResource(), + SingularQualifiedResource: resourceInfo.SingularGroupResource(), + TableConvertor: legacyStore.tableConverter, + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + } + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs} + if err := s.CompleteWithOptions(options); err != nil { + return nil, err + } + return dualWriteBuilder(resourceInfo.GroupResource(), legacyStore, storage{Store: s}) + } + return legacyStore, nil +} + +func GetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + if s, ok := obj.(*model.TemplateGroup); ok { + return s.Labels, model.SelectableTemplateGroupFields(s), nil + } + return nil, nil, fmt.Errorf("object of type %T is not supported", obj) +} + +// Matcher returns a generic.SelectionPredicate that matches on label and field selectors. +func Matcher(label labels.Selector, field fields.Selector) apistore.SelectionPredicate { + return apistore.SelectionPredicate{ + Label: label, + Field: field, + GetAttrs: GetAttrs, + } +} diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 4ab28eba0b3..83e7b390f73 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -434,10 +434,15 @@ const ( ActionAlertingSilencesCreate = "alert.silences:create" ActionAlertingSilencesWrite = "alert.silences:write" - // Alerting Notification policies actions + // Alerting Notification actions (legacy) ActionAlertingNotificationsRead = "alert.notifications:read" ActionAlertingNotificationsWrite = "alert.notifications:write" + // Alerting notifications template actions + ActionAlertingNotificationsTemplatesRead = "alert.notifications.templates:read" + ActionAlertingNotificationsTemplatesWrite = "alert.notifications.templates:write" + ActionAlertingNotificationsTemplatesDelete = "alert.notifications.templates:delete" + // Alerting notifications time interval actions ActionAlertingNotificationsTimeIntervalsRead = "alert.notifications.time-intervals:read" ActionAlertingNotificationsTimeIntervalsWrite = "alert.notifications.time-intervals:write" diff --git a/pkg/services/ngalert/accesscontrol.go b/pkg/services/ngalert/accesscontrol.go index 53c4ba79c5f..1aa3eb8f4c5 100644 --- a/pkg/services/ngalert/accesscontrol.go +++ b/pkg/services/ngalert/accesscontrol.go @@ -153,13 +153,38 @@ var ( }, } + templatesReaderRole = accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: accesscontrol.FixedRolePrefix + "alerting.templates:reader", + DisplayName: "Templates Reader", + Description: "Read all templates in Grafana alerting", + Group: AlertRolesGroup, + Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionAlertingNotificationsTemplatesRead}, + }, + }, + } + + templatesWriterRole = accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: accesscontrol.FixedRolePrefix + "alerting.templates:writer", + DisplayName: "Templates Writer", + Description: "Create, update, and delete all templates in Grafana alerting", + Group: AlertRolesGroup, + Permissions: accesscontrol.ConcatPermissions(templatesReaderRole.Role.Permissions, []accesscontrol.Permission{ + {Action: accesscontrol.ActionAlertingNotificationsTemplatesWrite}, + {Action: accesscontrol.ActionAlertingNotificationsTemplatesDelete}, + }), + }, + } + notificationsReaderRole = accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ Name: accesscontrol.FixedRolePrefix + "alerting.notifications:reader", DisplayName: "Notifications Reader", Description: "Read notification policies and contact points in Grafana and external providers", Group: AlertRolesGroup, - Permissions: accesscontrol.ConcatPermissions(receiversReaderRole.Role.Permissions, []accesscontrol.Permission{ + Permissions: accesscontrol.ConcatPermissions(receiversReaderRole.Role.Permissions, templatesReaderRole.Role.Permissions, []accesscontrol.Permission{ { Action: accesscontrol.ActionAlertingNotificationsRead, }, @@ -180,7 +205,7 @@ var ( DisplayName: "Notifications Writer", Description: "Add, update, and delete contact points and notification policies in Grafana and external providers", Group: AlertRolesGroup, - Permissions: accesscontrol.ConcatPermissions(notificationsReaderRole.Role.Permissions, receiversWriterRole.Role.Permissions, []accesscontrol.Permission{ + Permissions: accesscontrol.ConcatPermissions(notificationsReaderRole.Role.Permissions, receiversWriterRole.Role.Permissions, templatesWriterRole.Role.Permissions, []accesscontrol.Permission{ { Action: accesscontrol.ActionAlertingNotificationsWrite, }, @@ -312,7 +337,7 @@ func DeclareFixedRoles(service accesscontrol.Service, features featuremgmt.Featu } if features.IsEnabledGlobally(featuremgmt.FlagAlertingApiServer) { - fixedRoles = append(fixedRoles, receiversReaderRole, receiversCreatorRole, receiversWriterRole) + fixedRoles = append(fixedRoles, receiversReaderRole, receiversCreatorRole, receiversWriterRole, templatesReaderRole, templatesWriterRole) } return service.DeclareFixedRoles(fixedRoles...) diff --git a/pkg/tests/apis/alerting/notifications/template_group/templates_group_test.go b/pkg/tests/apis/alerting/notifications/template_group/templates_group_test.go new file mode 100644 index 00000000000..6a942075285 --- /dev/null +++ b/pkg/tests/apis/alerting/notifications/template_group/templates_group_test.go @@ -0,0 +1,629 @@ +package templateGroup + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + "github.com/grafana/grafana/pkg/generated/clientset/versioned" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/authz/zanzana" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder/foldertest" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func getTestHelper(t *testing.T) *apis.K8sTestHelper { + return apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{ + featuremgmt.FlagAlertingApiServer, + }, + }) +} + +func TestIntegrationResourceIdentifier(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + adminK8sClient, err := versioned.NewForConfig(helper.Org1.Admin.NewRestConfig()) + require.NoError(t, err) + client := adminK8sClient.NotificationsV0alpha1().TemplateGroups("default") + + newTemplate := &v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: "templateGroup", + Content: `{{ define "test" }} test {{ end }}`, + }, + } + + t.Run("create should fail if object name is specified", func(t *testing.T) { + template := newTemplate.DeepCopy() + template.Name = "new-templateGroup" + _, err := client.Create(ctx, template, v1.CreateOptions{}) + assert.Error(t, err) + require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest but got %s", err) + }) + + var resourceID string + t.Run("create should succeed and provide resource name", func(t *testing.T) { + actual, err := client.Create(ctx, newTemplate, v1.CreateOptions{}) + require.NoError(t, err) + require.NotEmptyf(t, actual.Name, "Resource name should not be empty") + require.NotEmptyf(t, actual.UID, "Resource UID should not be empty") + resourceID = actual.Name + }) + + var existingTemplateGroup *v0alpha1.TemplateGroup + t.Run("resource should be available by the identifier", func(t *testing.T) { + actual, err := client.Get(ctx, resourceID, v1.GetOptions{}) + require.NoError(t, err) + require.NotEmptyf(t, actual.Name, "Resource name should not be empty") + require.Equal(t, newTemplate.Spec, actual.Spec) + existingTemplateGroup = actual + }) + + t.Run("update should rename template if name in the specification changes", func(t *testing.T) { + if existingTemplateGroup == nil { + t.Skip() + } + updated := existingTemplateGroup.DeepCopy() + updated.Spec.Title = "another-templateGroup" + actual, err := client.Update(ctx, updated, v1.UpdateOptions{}) + require.NoError(t, err) + require.Equal(t, updated.Spec, actual.Spec) + require.NotEqualf(t, updated.Name, actual.Name, "Update should change the resource name but it didn't") + + resource, err := client.Get(ctx, actual.Name, v1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, actual, resource) + }) +} + +func TestIntegrationAccessControl(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + org1 := helper.Org1 + + type testCase struct { + user apis.User + canRead bool + canUpdate bool + canCreate bool + canDelete bool + } + + reader := helper.CreateUser("TemplatesReader", apis.Org1, org.RoleNone, []resourcepermissions.SetResourcePermissionCommand{ + { + Actions: []string{ + accesscontrol.ActionAlertingNotificationsTemplatesRead, + }, + }, + }) + writer := helper.CreateUser("TemplatesWriter", "Org1", org.RoleNone, []resourcepermissions.SetResourcePermissionCommand{ + { + Actions: []string{ + accesscontrol.ActionAlertingNotificationsTemplatesRead, + accesscontrol.ActionAlertingNotificationsTemplatesWrite, + }, + }, + }) + + deleter := helper.CreateUser("TemplatesDeleter", apis.Org1, org.RoleNone, []resourcepermissions.SetResourcePermissionCommand{ + { + Actions: []string{ + accesscontrol.ActionAlertingNotificationsTemplatesRead, + accesscontrol.ActionAlertingNotificationsTemplatesDelete, + }, + }, + }) + + testCases := []testCase{ + { + user: org1.Admin, + canRead: true, + canUpdate: true, + canCreate: true, + canDelete: true, + }, + { + user: org1.Editor, + canRead: true, + canUpdate: true, + canCreate: true, + canDelete: true, + }, + { + user: org1.Viewer, + canRead: true, + }, + { + user: reader, + canRead: true, + }, + { + user: writer, + canRead: true, + canCreate: true, + canUpdate: true, + }, + { + user: deleter, + canRead: true, + canDelete: true, + }, + } + + admin := org1.Admin + adminK8sClient, err := versioned.NewForConfig(admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().TemplateGroups("default") + + for _, tc := range testCases { + t.Run(fmt.Sprintf("user '%s'", tc.user.Identity.GetLogin()), func(t *testing.T) { + k8sClient, err := versioned.NewForConfig(tc.user.NewRestConfig()) + require.NoError(t, err) + client := k8sClient.NotificationsV0alpha1().TemplateGroups("default") + + var expected = &v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: fmt.Sprintf("template-group-1-%s", tc.user.Identity.GetLogin()), + Content: `{{ define "test" }} test {{ end }}`, + }, + } + expected.SetProvenanceStatus("") + d, err := json.Marshal(expected) + require.NoError(t, err) + + if tc.canCreate { + t.Run("should be able to create template group", func(t *testing.T) { + actual, err := client.Create(ctx, expected, v1.CreateOptions{}) + require.NoErrorf(t, err, "Payload %s", string(d)) + require.Equal(t, expected.Spec, actual.Spec) + + t.Run("should fail if already exists", func(t *testing.T) { + _, err := client.Create(ctx, actual, v1.CreateOptions{}) + require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err) + }) + + expected = actual + }) + } else { + t.Run("should be forbidden to create", func(t *testing.T) { + _, err := client.Create(ctx, expected, v1.CreateOptions{}) + require.Truef(t, errors.IsForbidden(err), "Payload %s", string(d)) + }) + + // create resource to proceed with other tests + expected, err = adminClient.Create(ctx, expected, v1.CreateOptions{}) + require.NoErrorf(t, err, "Payload %s", string(d)) + require.NotNil(t, expected) + } + + if tc.canRead { + t.Run("should be able to list template groups", func(t *testing.T) { + list, err := client.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + }) + + t.Run("should be able to read template group by resource identifier", func(t *testing.T) { + got, err := client.Get(ctx, expected.Name, v1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, expected, got) + + t.Run("should get NotFound if resource does not exist", func(t *testing.T) { + _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) + require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) + }) + }) + } else { + t.Run("should be forbidden to list template groups", func(t *testing.T) { + _, err := client.List(ctx, v1.ListOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + + t.Run("should be forbidden to read template group by name", func(t *testing.T) { + _, err := client.Get(ctx, expected.Name, v1.GetOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + + t.Run("should get forbidden even if name does not exist", func(t *testing.T) { + _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + }) + } + + updatedExpected := expected.DeepCopy() + updatedExpected.Spec.Content = `{{ define "another-test" }} test {{ end }}` + + d, err = json.Marshal(updatedExpected) + require.NoError(t, err) + + if tc.canUpdate { + t.Run("should be able to update template group", func(t *testing.T) { + updated, err := client.Update(ctx, updatedExpected, v1.UpdateOptions{}) + require.NoErrorf(t, err, "Payload %s", string(d)) + + expected = updated + + t.Run("should get NotFound if name does not exist", func(t *testing.T) { + up := updatedExpected.DeepCopy() + up.Name = "notFound" + _, err := client.Update(ctx, up, v1.UpdateOptions{}) + require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) + }) + }) + } else { + t.Run("should be forbidden to update template group", func(t *testing.T) { + _, err := client.Update(ctx, updatedExpected, v1.UpdateOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + + t.Run("should get forbidden even if resource does not exist", func(t *testing.T) { + up := updatedExpected.DeepCopy() + up.Name = "notFound" + _, err := client.Update(ctx, up, v1.UpdateOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + }) + } + + deleteOptions := v1.DeleteOptions{Preconditions: &v1.Preconditions{ResourceVersion: util.Pointer(expected.ResourceVersion)}} + + if tc.canDelete { + t.Run("should be able to delete template group", func(t *testing.T) { + err := client.Delete(ctx, expected.Name, deleteOptions) + require.NoError(t, err) + + t.Run("should get NotFound if name does not exist", func(t *testing.T) { + err := client.Delete(ctx, "notfound", v1.DeleteOptions{}) + require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) + }) + }) + } else { + t.Run("should be forbidden to delete template group", func(t *testing.T) { + err := client.Delete(ctx, expected.Name, deleteOptions) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + + t.Run("should be forbidden even if resource does not exist", func(t *testing.T) { + err := client.Delete(ctx, "notfound", v1.DeleteOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + }) + require.NoError(t, adminClient.Delete(ctx, expected.Name, v1.DeleteOptions{})) + } + + if tc.canRead { + t.Run("should get empty list if no mute timings", func(t *testing.T) { + list, err := client.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + require.Len(t, list.Items, 0) + }) + } + }) + } +} + +func TestIntegrationProvisioning(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + org := helper.Org1 + + admin := org.Admin + adminK8sClient, err := versioned.NewForConfig(admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().TemplateGroups("default") + + env := helper.GetEnv() + ac := acimpl.ProvideAccessControl(env.FeatureToggles, zanzana.NewNoopClient()) + db, err := store.ProvideDBStore(env.Cfg, env.FeatureToggles, env.SQLStore, &foldertest.FakeService{}, &dashboards.FakeDashboardService{}, ac) + require.NoError(t, err) + + created, err := adminClient.Create(ctx, &v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: "template-group-1", + Content: `{{ define "test" }} test {{ end }}`, + }, + }, v1.CreateOptions{}) + require.NoError(t, err) + require.Equal(t, "none", created.GetProvenanceStatus()) + + t.Run("should provide provenance status", func(t *testing.T) { + require.NoError(t, db.SetProvenance(ctx, &definitions.NotificationTemplate{ + Name: created.Spec.Title, + }, admin.Identity.GetOrgID(), "API")) + + got, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, "API", got.GetProvenanceStatus()) + }) + t.Run("should not let update if provisioned", func(t *testing.T) { + updated := created.DeepCopy() + updated.Spec.Content = `{{ define "another-test" }} test {{ end }}` + + _, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + + t.Run("should not let delete if provisioned", func(t *testing.T) { + err := adminClient.Delete(ctx, created.Name, v1.DeleteOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) +} + +func TestIntegrationOptimisticConcurrency(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + adminK8sClient, err := versioned.NewForConfig(helper.Org1.Admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().TemplateGroups("default") + + template := v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: "template-group-1", + Content: `{{ define "test" }} test {{ end }}`, + }, + } + + created, err := adminClient.Create(ctx, &template, v1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, created) + require.NotEmpty(t, created.ResourceVersion) + + t.Run("should forbid if version does not match", func(t *testing.T) { + updated := created.DeepCopy() + updated.ResourceVersion = "test" + _, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + require.Truef(t, errors.IsConflict(err), "should get Forbidden error but got %s", err) + }) + t.Run("should update if version matches", func(t *testing.T) { + updated := created.DeepCopy() + updated.Spec.Content = `{{ define "test-another" }} test {{ end }}` + actualUpdated, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + require.NoError(t, err) + require.EqualValues(t, updated.Spec, actualUpdated.Spec) + require.NotEqual(t, updated.ResourceVersion, actualUpdated.ResourceVersion) + }) + t.Run("should update if version is empty", func(t *testing.T) { + updated := created.DeepCopy() + updated.ResourceVersion = "" + updated.Spec.Content = `{{ define "test-another-2" }} test {{ end }}` + + actualUpdated, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + require.NoError(t, err) + require.EqualValues(t, updated.Spec, actualUpdated.Spec) + require.NotEqual(t, created.ResourceVersion, actualUpdated.ResourceVersion) + }) + t.Run("should fail to delete if version does not match", func(t *testing.T) { + actual, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + require.NoError(t, err) + + err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + Preconditions: &v1.Preconditions{ + ResourceVersion: util.Pointer("something"), + }, + }) + require.Truef(t, errors.IsConflict(err), "should get Forbidden error but got %s", err) + }) + t.Run("should succeed if version matches", func(t *testing.T) { + actual, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + require.NoError(t, err) + + err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + Preconditions: &v1.Preconditions{ + ResourceVersion: util.Pointer(actual.ResourceVersion), + }, + }) + require.NoError(t, err) + }) + t.Run("should succeed if version is empty", func(t *testing.T) { + actual, err := adminClient.Create(ctx, &template, v1.CreateOptions{}) + require.NoError(t, err) + + err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + Preconditions: &v1.Preconditions{ + ResourceVersion: util.Pointer(actual.ResourceVersion), + }, + }) + require.NoError(t, err) + }) +} + +func TestIntegrationPatch(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + adminK8sClient, err := versioned.NewForConfig(helper.Org1.Admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().TemplateGroups("default") + + template := v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: "template-group", + Content: `{{ define "test" }} test {{ end }}`, + }, + } + + current, err := adminClient.Create(ctx, &template, v1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, current) + require.NotEmpty(t, current.ResourceVersion) + + t.Run("should patch with merge patch", func(t *testing.T) { + patch := `{ + "spec": { + "content" : "{{ define \"test-another\" }} test {{ end }}" + } + }` + + result, err := adminClient.Patch(ctx, current.Name, types.MergePatchType, []byte(patch), v1.PatchOptions{}) + require.NoError(t, err) + require.Equal(t, `{{ define "test-another" }} test {{ end }}`, result.Spec.Content) + current = result + }) + + t.Run("should patch with json patch", func(t *testing.T) { + expected := `{{ define "test-json-patch" }} test {{ end }}` + + patch := []map[string]interface{}{ + { + "op": "replace", + "path": "/spec/content", + "value": expected, + }, + } + + patchData, err := json.Marshal(patch) + require.NoError(t, err) + + result, err := adminClient.Patch(ctx, current.Name, types.JSONPatchType, patchData, v1.PatchOptions{}) + require.NoError(t, err) + expectedSpec := *current.Spec.DeepCopy() + expectedSpec.Content = expected + require.EqualValues(t, expectedSpec, result.Spec) + current = result + }) +} + +func TestIntegrationListSelector(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + adminK8sClient, err := versioned.NewForConfig(helper.Org1.Admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().TemplateGroups("default") + + template1 := &v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: "test1", + Content: `{{ define "test1" }} test {{ end }}`, + }, + } + template1, err = adminClient.Create(ctx, template1, v1.CreateOptions{}) + require.NoError(t, err) + + template2 := &v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: "test2", + Content: `{{ define "test2" }} test {{ end }}`, + }, + } + template2, err = adminClient.Create(ctx, template2, v1.CreateOptions{}) + require.NoError(t, err) + env := helper.GetEnv() + ac := acimpl.ProvideAccessControl(env.FeatureToggles, zanzana.NewNoopClient()) + db, err := store.ProvideDBStore(env.Cfg, env.FeatureToggles, env.SQLStore, &foldertest.FakeService{}, &dashboards.FakeDashboardService{}, ac) + require.NoError(t, err) + require.NoError(t, db.SetProvenance(ctx, &definitions.NotificationTemplate{ + Name: template2.Spec.Title, + }, helper.Org1.Admin.Identity.GetOrgID(), "API")) + template2, err = adminClient.Get(ctx, template2.Name, v1.GetOptions{}) + + require.NoError(t, err) + + templates, err := adminClient.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + require.Len(t, templates.Items, 2) + + t.Run("should filter by template name", func(t *testing.T) { + list, err := adminClient.List(ctx, v1.ListOptions{ + FieldSelector: "spec.title=" + template1.Spec.Title, + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + require.Equal(t, template1.Name, list.Items[0].Name) + }) + + t.Run("should filter by template metadata name", func(t *testing.T) { + list, err := adminClient.List(ctx, v1.ListOptions{ + FieldSelector: "metadata.name=" + template2.Name, + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + require.Equal(t, template2.Name, list.Items[0].Name) + }) + + t.Run("should filter by multiple filters", func(t *testing.T) { + list, err := adminClient.List(ctx, v1.ListOptions{ + FieldSelector: fmt.Sprintf("metadata.name=%s,metadata.provenance=%s", template2.Name, "API"), + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + require.Equal(t, template2.Name, list.Items[0].Name) + }) + + t.Run("should be empty when filter does not match", func(t *testing.T) { + list, err := adminClient.List(ctx, v1.ListOptions{ + FieldSelector: fmt.Sprintf("metadata.name=%s,metadata.provenance=%s", template2.Name, "unknown"), + }) + require.NoError(t, err) + require.Empty(t, list.Items) + }) +} From e86929eb0abc640dd2f3da2acda76d406046b1bb Mon Sep 17 00:00:00 2001 From: Matthew Jacobson Date: Wed, 25 Sep 2024 09:39:36 -0400 Subject: [PATCH 038/102] Alerting: Managed receiver resource permission in config api (#93632) * Alerting: Managed receiver resource permission in config api --- .../ngalert/api/api_alertmanager_test.go | 15 ++- pkg/services/ngalert/ngalert.go | 16 ++- .../ngalert/notifier/alertmanager_config.go | 108 +++++++++++++++++- .../ngalert/notifier/multiorg_alertmanager.go | 27 +++-- .../multiorg_alertmanager_remote_test.go | 1 + .../notifier/multiorg_alertmanager_test.go | 15 ++- pkg/services/ngalert/sender/router_test.go | 15 ++- 7 files changed, 181 insertions(+), 16 deletions(-) diff --git a/pkg/services/ngalert/api/api_alertmanager_test.go b/pkg/services/ngalert/api/api_alertmanager_test.go index ed064df3ff5..1787dcfa5e9 100644 --- a/pkg/services/ngalert/api/api_alertmanager_test.go +++ b/pkg/services/ngalert/api/api_alertmanager_test.go @@ -609,7 +609,20 @@ func createMultiOrgAlertmanager(t *testing.T, configs map[int64]*ngmodels.AlertC }, // do not poll in tests. } - mam, err := notifier.NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService, featuremgmt.WithManager(featuremgmt.FlagAlertingSimplifiedRouting)) + mam, err := notifier.NewMultiOrgAlertmanager( + cfg, + configStore, + orgStore, + kvStore, + provStore, + decryptFn, + m.GetMultiOrgAlertmanagerMetrics(), + nil, + ngfakes.NewFakeReceiverPermissionsService(), + log.New("testlogger"), + secretsService, + featuremgmt.WithManager(featuremgmt.FlagAlertingSimplifiedRouting), + ) require.NoError(t, err) err = mam.LoadAndSyncAlertmanagersForOrgs(context.Background()) require.NoError(t, err) diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 74ca6331bcd..b2628d6ae08 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -311,7 +311,21 @@ func (ng *AlertNG) init() error { decryptFn := ng.SecretsService.GetDecryptedValue multiOrgMetrics := ng.Metrics.GetMultiOrgAlertmanagerMetrics() - moa, err := notifier.NewMultiOrgAlertmanager(ng.Cfg, ng.store, ng.store, ng.KVStore, ng.store, decryptFn, multiOrgMetrics, ng.NotificationService, moaLogger, ng.SecretsService, ng.FeatureToggles, overrides...) + moa, err := notifier.NewMultiOrgAlertmanager( + ng.Cfg, + ng.store, + ng.store, + ng.KVStore, + ng.store, + decryptFn, + multiOrgMetrics, + ng.NotificationService, + ng.ResourcePermissions, + moaLogger, + ng.SecretsService, + ng.FeatureToggles, + overrides..., + ) if err != nil { return err } diff --git a/pkg/services/ngalert/notifier/alertmanager_config.go b/pkg/services/ngalert/notifier/alertmanager_config.go index 7d71de2c343..c2248fe91c6 100644 --- a/pkg/services/ngalert/notifier/alertmanager_config.go +++ b/pkg/services/ngalert/notifier/alertmanager_config.go @@ -2,16 +2,19 @@ package notifier import ( "context" + "encoding/json" "errors" "fmt" "time" "github.com/go-openapi/strfmt" + "k8s.io/apimachinery/pkg/util/sets" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/util" ) @@ -58,10 +61,32 @@ func (moa *MultiOrgAlertmanager) SaveAndApplyDefaultConfig(ctx context.Context, return err } + previousConfig, cleanPermissionsErr := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, orgId) + err = orgAM.SaveAndApplyDefaultConfig(ctx) if err != nil { return err } + + // Attempt to cleanup permissions for receivers that are no longer defined and add defaults for new receivers. + // Failure should not prevent the default config from being applied. + if cleanPermissionsErr == nil { + cleanPermissionsErr = func() error { + defaultedConfig, err := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, orgId) + if err != nil { + return err + } + newReceiverNames, err := extractReceiverNames(defaultedConfig.AlertmanagerConfiguration) + if err != nil { + return err + } + return moa.cleanPermissions(ctx, orgId, previousConfig, newReceiverNames) + }() + } + if cleanPermissionsErr != nil { + moa.logger.Error("Failed to clean permissions for receivers", "error", cleanPermissionsErr) + } + return nil } @@ -130,12 +155,29 @@ func (moa *MultiOrgAlertmanager) ActivateHistoricalConfiguration(ctx context.Con } } + previousConfig, cleanPermissionsErr := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, orgId) + if err := am.SaveAndApplyConfig(ctx, cfg); err != nil { moa.logger.Error("Unable to save and apply historical alertmanager configuration", "error", err, "org", orgId, "id", id) return AlertmanagerConfigRejectedError{err} } moa.logger.Info("Applied historical alertmanager configuration", "org", orgId, "id", id) + // Attempt to cleanup permissions for receivers that are no longer defined and add defaults for new receivers. + // Failure should not prevent the default config from being applied. + if cleanPermissionsErr == nil { + cleanPermissionsErr = func() error { + newReceiverNames, err := extractReceiverNames(config.AlertmanagerConfiguration) + if err != nil { + return err + } + return moa.cleanPermissions(ctx, orgId, previousConfig, newReceiverNames) + }() + } + if cleanPermissionsErr != nil { + moa.logger.Error("Failed to clean permissions for receivers", "error", cleanPermissionsErr) + } + return nil } @@ -231,13 +273,14 @@ func (moa *MultiOrgAlertmanager) SaveAndApplyAlertmanagerConfiguration(ctx conte } // Get the last known working configuration - _, err := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, org) + previousConfig, err := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, org) if err != nil { // If we don't have a configuration there's nothing for us to know and we should just continue saving the new one if !errors.Is(err, store.ErrNoAlertmanagerConfiguration) { return fmt.Errorf("failed to get latest configuration %w", err) } } + cleanPermissionsErr := err if err := moa.Crypto.ProcessSecureSettings(ctx, org, config.AlertmanagerConfig.Receivers); err != nil { return fmt.Errorf("failed to post process Alertmanager configuration: %w", err) @@ -268,6 +311,21 @@ func (moa *MultiOrgAlertmanager) SaveAndApplyAlertmanagerConfiguration(ctx conte return AlertmanagerConfigRejectedError{err} } + // Attempt to cleanup permissions for receivers that are no longer defined and add defaults for new receivers. + // Failure should not prevent the default config from being applied. + if cleanPermissionsErr == nil { + cleanPermissionsErr = func() error { + newReceiverNames := make(sets.Set[string], len(config.AlertmanagerConfig.Receivers)) + for _, r := range config.AlertmanagerConfig.Receivers { + newReceiverNames.Insert(r.Name) + } + return moa.cleanPermissions(ctx, org, previousConfig, newReceiverNames) + }() + } + if cleanPermissionsErr != nil { + moa.logger.Error("Failed to clean permissions for receivers", "error", cleanPermissionsErr) + } + return nil } @@ -352,3 +410,51 @@ func (moa *MultiOrgAlertmanager) mergeProvenance(ctx context.Context, config def return config, nil } + +// cleanPermissions will remove permissions for receivers that are no longer defined in the new configuration and +// set default permissions for new receivers. +func (moa *MultiOrgAlertmanager) cleanPermissions(ctx context.Context, orgID int64, previousConfig *models.AlertConfiguration, newReceiverNames sets.Set[string]) error { + previousReceiverNames, err := extractReceiverNames(previousConfig.AlertmanagerConfiguration) + if err != nil { + return fmt.Errorf("failed to extract receiver names from previous configuration: %w", err) + } + + var errs []error + for receiverName := range previousReceiverNames.Difference(newReceiverNames) { // Deleted receivers. + if err := moa.receiverResourcePermissions.DeleteResourcePermissions(ctx, orgID, legacy_storage.NameToUid(receiverName)); err != nil { + errs = append(errs, fmt.Errorf("failed to delete permissions for receiver %s: %w", receiverName, err)) + } + } + + for receiverName := range newReceiverNames.Difference(previousReceiverNames) { // Added receivers. + moa.receiverResourcePermissions.SetDefaultPermissions(ctx, orgID, nil, legacy_storage.NameToUid(receiverName)) + } + + return errors.Join(errs...) +} + +// extractReceiverNames extracts receiver names from the raw Alertmanager configuration. Unmarshalling ignores fields +// unrelated to receiver names, making it more resilient to invalid configurations. +func extractReceiverNames(rawConfig string) (sets.Set[string], error) { + // Slimmed down version of the Alertmanager configuration to extract receiver names. This is more resilient to + // invalid configurations when all we are interested in is the receiver names. + type receiverUserConfig struct { + AlertmanagerConfig struct { + Receivers []struct { + Name string `yaml:"name" json:"name"` + } `yaml:"receivers,omitempty" json:"receivers,omitempty"` + } `yaml:"alertmanager_config" json:"alertmanager_config"` + } + + cfg := &receiverUserConfig{} + if err := json.Unmarshal([]byte(rawConfig), cfg); err != nil { + return nil, fmt.Errorf("unable to parse Alertmanager configuration: %w", err) + } + + receiverNames := make(sets.Set[string], len(cfg.AlertmanagerConfig.Receivers)) + for _, r := range cfg.AlertmanagerConfig.Receivers { + receiverNames[r.Name] = struct{}{} + } + + return receiverNames, nil +} diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager.go b/pkg/services/ngalert/notifier/multiorg_alertmanager.go index 464c1036296..71140200558 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager.go @@ -12,6 +12,7 @@ import ( alertingCluster "github.com/grafana/alerting/cluster" "github.com/grafana/grafana/pkg/apimachinery/errutil" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" alertingNotify "github.com/grafana/alerting/notify" @@ -100,6 +101,8 @@ type MultiOrgAlertmanager struct { metrics *metrics.MultiOrgAlertmanager ns notifications.Service + + receiverResourcePermissions ac.ReceiverPermissionsService } type OrgAlertmanagerFactory func(ctx context.Context, orgID int64) (Alertmanager, error) @@ -121,6 +124,7 @@ func NewMultiOrgAlertmanager( decryptFn alertingNotify.GetDecryptedValueFn, m *metrics.MultiOrgAlertmanager, ns notifications.Service, + receiverResourcePermissions ac.ReceiverPermissionsService, l log.Logger, s secrets.Service, featureManager featuremgmt.FeatureToggles, @@ -130,17 +134,18 @@ func NewMultiOrgAlertmanager( Crypto: NewCrypto(s, configStore, l), ProvStore: provStore, - logger: l, - settings: cfg, - featureManager: featureManager, - alertmanagers: map[int64]Alertmanager{}, - configStore: configStore, - orgStore: orgStore, - kvStore: kvStore, - decryptFn: decryptFn, - metrics: m, - ns: ns, - peer: &NilPeer{}, + logger: l, + settings: cfg, + featureManager: featureManager, + alertmanagers: map[int64]Alertmanager{}, + configStore: configStore, + orgStore: orgStore, + kvStore: kvStore, + decryptFn: decryptFn, + receiverResourcePermissions: receiverResourcePermissions, + metrics: m, + ns: ns, + peer: &NilPeer{}, } if cfg.UnifiedAlerting.SkipClustering { diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go b/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go index 81573c42061..5301b37bf9a 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go @@ -98,6 +98,7 @@ func TestMultiorgAlertmanager_RemoteSecondaryMode(t *testing.T) { secretsService.GetDecryptedValue, m.GetMultiOrgAlertmanagerMetrics(), nil, + ngfakes.NewFakeReceiverPermissionsService(), nopLogger, secretsService, featuremgmt.WithFeatures(), diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go b/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go index a2fb2647ace..e53da1fa451 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go @@ -383,7 +383,20 @@ func setupMam(t *testing.T, cfg *setting.Cfg) *MultiOrgAlertmanager { decryptFn := secretsService.GetDecryptedValue reg := prometheus.NewPedanticRegistry() m := metrics.NewNGAlert(reg) - mam, err := NewMultiOrgAlertmanager(cfg, cs, orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService, featuremgmt.WithFeatures()) + mam, err := NewMultiOrgAlertmanager( + cfg, + cs, + orgStore, + kvStore, + provStore, + decryptFn, + m.GetMultiOrgAlertmanagerMetrics(), + nil, + ngfakes.NewFakeReceiverPermissionsService(), + log.New("testlogger"), + secretsService, + featuremgmt.WithFeatures(), + ) require.NoError(t, err) return mam } diff --git a/pkg/services/ngalert/sender/router_test.go b/pkg/services/ngalert/sender/router_test.go index c10f88e6001..25f0c7566aa 100644 --- a/pkg/services/ngalert/sender/router_test.go +++ b/pkg/services/ngalert/sender/router_test.go @@ -491,7 +491,20 @@ func createMultiOrgAlertmanager(t *testing.T, orgs []int64) *notifier.MultiOrgAl m := metrics.NewNGAlert(registry) secretsService := secretsManager.SetupTestService(t, fake_secrets.NewFakeSecretsStore()) decryptFn := secretsService.GetDecryptedValue - moa, err := notifier.NewMultiOrgAlertmanager(cfg, cfgStore, orgStore, kvStore, fakes.NewFakeProvisioningStore(), decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService, featuremgmt.WithFeatures()) + moa, err := notifier.NewMultiOrgAlertmanager( + cfg, + cfgStore, + orgStore, + kvStore, + fakes.NewFakeProvisioningStore(), + decryptFn, + m.GetMultiOrgAlertmanagerMetrics(), + nil, + fakes.NewFakeReceiverPermissionsService(), + log.New("testlogger"), + secretsService, + featuremgmt.WithFeatures(), + ) require.NoError(t, err) require.NoError(t, moa.LoadAndSyncAlertmanagersForOrgs(context.Background())) require.Eventually(t, func() bool { From 5cd9ef562d2236ee084a1df826367eb4cda381f2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 16:41:18 +0300 Subject: [PATCH 039/102] Update dependency react-loading-skeleton to v3.5.0 (#93740) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-ui/package.json | 2 +- yarn.lock | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 7fbd3d9940e..255ecb0877f 100644 --- a/package.json +++ b/package.json @@ -368,7 +368,7 @@ "react-hook-form": "^7.49.2", "react-i18next": "^14.0.0", "react-inlinesvg": "3.0.2", - "react-loading-skeleton": "3.4.0", + "react-loading-skeleton": "3.5.0", "react-moveable": "0.56.0", "react-redux": "9.1.2", "react-resizable": "3.0.5", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index b9ca81674aa..e8c5928a837 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -97,7 +97,7 @@ "react-hook-form": "^7.49.2", "react-i18next": "^14.0.0", "react-inlinesvg": "3.0.2", - "react-loading-skeleton": "3.4.0", + "react-loading-skeleton": "3.5.0", "react-router-dom-v5-compat": "^6.26.1", "react-select": "5.8.1", "react-table": "7.8.0", diff --git a/yarn.lock b/yarn.lock index 96fc8532fb3..b1aabb6caf8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4340,7 +4340,7 @@ __metadata: react-hook-form: "npm:^7.49.2" react-i18next: "npm:^14.0.0" react-inlinesvg: "npm:3.0.2" - react-loading-skeleton: "npm:3.4.0" + react-loading-skeleton: "npm:3.5.0" react-router-dom-v5-compat: "npm:^6.26.1" react-select: "npm:5.8.1" react-select-event: "npm:^5.1.0" @@ -19060,7 +19060,7 @@ __metadata: react-hook-form: "npm:^7.49.2" react-i18next: "npm:^14.0.0" react-inlinesvg: "npm:3.0.2" - react-loading-skeleton: "npm:3.4.0" + react-loading-skeleton: "npm:3.5.0" react-moveable: "npm:0.56.0" react-redux: "npm:9.1.2" react-refresh: "npm:0.14.0" @@ -27500,12 +27500,12 @@ __metadata: languageName: node linkType: hard -"react-loading-skeleton@npm:3.4.0": - version: 3.4.0 - resolution: "react-loading-skeleton@npm:3.4.0" +"react-loading-skeleton@npm:3.5.0": + version: 3.5.0 + resolution: "react-loading-skeleton@npm:3.5.0" peerDependencies: react: ">=16.8.0" - checksum: 10/f504c55f2b448e05abfe480950d89a4a7301773590e3c9fc42025370bcfd1c177466bb0a9cee6291d975772218c63c83656ac8b158a75de7a2e9ad75d440925b + checksum: 10/e6a1385d07cbded44d82fa26ad9581274858a682ce728eb81a5c07816e9c1b74092ccb01dc0e79584a8d92d2f571dc6cd0481122d3817d19802e1af4f9961df5 languageName: node linkType: hard From f80d3d687dee248aa40ee9d4fe31b3f2e3ccc0e1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 14:41:48 +0100 Subject: [PATCH 040/102] Update dependency @grafana/scenes to v5.15.0 (#93730) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 255ecb0877f..1eb4d557005 100644 --- a/package.json +++ b/package.json @@ -268,7 +268,7 @@ "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", "@grafana/saga-icons": "workspace:*", - "@grafana/scenes": "5.14.7", + "@grafana/scenes": "5.15.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/yarn.lock b/yarn.lock index b1aabb6caf8..ee4492170e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4128,9 +4128,9 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes@npm:5.14.7": - version: 5.14.7 - resolution: "@grafana/scenes@npm:5.14.7" +"@grafana/scenes@npm:5.15.0": + version: 5.15.0 + resolution: "@grafana/scenes@npm:5.15.0" dependencies: "@floating-ui/react": "npm:0.26.16" "@grafana/e2e-selectors": "npm:^11.0.0" @@ -4147,7 +4147,7 @@ __metadata: "@grafana/ui": ">=10.4" react: ^18.0.0 react-dom: ^18.0.0 - checksum: 10/35f256271a56f8ddd08d49db4de0fd4d232162a686ac82ad88c7ea5527c13e0615c0ff633a9e3e575a0d40504de43f9f2986f7f53b585bc326e2fde774f50c4f + checksum: 10/da545f038df708faff29714aabf08c57a204b0754a7411d2654613b855279642d00bb6239951394c89bccfc60f67bbd578b8264d5177c55a427e7984c4fe889a languageName: node linkType: hard @@ -18824,7 +18824,7 @@ __metadata: "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" "@grafana/saga-icons": "workspace:*" - "@grafana/scenes": "npm:5.14.7" + "@grafana/scenes": "npm:5.15.0" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/tsconfig": "npm:^2.0.0" From dbf0949ca70cfbf1fe21e59b9139392ef6893f98 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:42:58 -0400 Subject: [PATCH 041/102] Docs: update docs to reflect new sharing UI (#90630) Co-authored-by: Juan Cabanas Co-authored-by: Jack Baldry --- .../breaking-changes-v11-0.md | 2 +- docs/sources/dashboards/_index.md | 10 +- .../assess-dashboard-usage/index.md | 6 +- .../manage-dashboard-links/index.md | 2 +- .../manage-library-panels/index.md | 40 +- .../dashboards/dashboard-public/index.md | 273 -------------- .../share-dashboards-panels/_index.md | 348 ++++++++++++++++++ .../share-dashboards-panels/index.md | 250 ------------- .../shared-dashboards/index.md | 331 +++++++++++++++++ .../dashboards/use-dashboards/index.md | 9 +- docs/sources/developers/http_api/_index.md | 2 +- .../developers/http_api/dashboard_public.md | 54 +-- .../introduction/grafana-enterprise.md | 2 +- .../setup-grafana/configure-grafana/_index.md | 4 +- .../configure-custom-branding/index.md | 10 +- .../configure-authentication/jwt/index.md | 2 +- .../setup-grafana/image-rendering/_index.md | 2 +- docs/sources/whatsnew/whats-new-in-v10-0.md | 4 +- docs/sources/whatsnew/whats-new-in-v10-2.md | 2 +- docs/sources/whatsnew/whats-new-in-v11-0.md | 2 +- docs/sources/whatsnew/whats-new-in-v9-1.md | 4 +- docs/sources/whatsnew/whats-new-in-v9-2.md | 2 +- docs/sources/whatsnew/whats-new-in-v9-3.md | 2 +- 23 files changed, 765 insertions(+), 598 deletions(-) delete mode 100644 docs/sources/dashboards/dashboard-public/index.md create mode 100644 docs/sources/dashboards/share-dashboards-panels/_index.md delete mode 100644 docs/sources/dashboards/share-dashboards-panels/index.md create mode 100644 docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md diff --git a/docs/sources/breaking-changes/breaking-changes-v11-0.md b/docs/sources/breaking-changes/breaking-changes-v11-0.md index f223375f276..883c062ae04 100644 --- a/docs/sources/breaking-changes/breaking-changes-v11-0.md +++ b/docs/sources/breaking-changes/breaking-changes-v11-0.md @@ -118,7 +118,7 @@ Ensure you have a public dashboard footer logo or footer text set if you don't w #### Learn more -[Configure custom branding documentation](https://grafana.com/docs/grafana//setup-grafana/configure-grafana/configure-custom-branding/#custom-branding-for-public-dashboards) for public dashboards +[Configure custom branding documentation](https://grafana.com/docs/grafana//setup-grafana/configure-grafana/configure-custom-branding/#custom-branding-for-shared-dashboards) for public dashboards ### Subfolders cause very rare issues with folders that have forward slashes in their names diff --git a/docs/sources/dashboards/_index.md b/docs/sources/dashboards/_index.md index 0ebbf8d3334..bd2fc73180e 100644 --- a/docs/sources/dashboards/_index.md +++ b/docs/sources/dashboards/_index.md @@ -31,17 +31,17 @@ cards: href: ./variables/ description: Add variables to metric queries and panel titles to create interactive and dynamic dashboards. height: 24 - - title: Public dashboards - href: ./dashboard-public/ - description: Make your Grafana dashboards public and share them with anyone without requiring access to your Grafana organization. - height: 24 - title: Reporting href: ./create-reports/ description: Automatically generate and share PDF reports from your Grafana dashboards. height: 24 - title: Sharing href: ./share-dashboards-panels/ - description: Share Grafana dashboards and panels within your organization using links, snapshots, and JSON exports. + description: Share Grafana dashboards and panels using links, snapshots, embeds, and exports. + height: 24 + - title: Shared dashboards + href: ./share-dashboards-panels/shared-dashboards/ + description: Share your dashboards with anyone without requiring access to your Grafana organization. height: 24 refs: panels: diff --git a/docs/sources/dashboards/assess-dashboard-usage/index.md b/docs/sources/dashboards/assess-dashboard-usage/index.md index b74d9d6090b..5c0c46a2e8a 100644 --- a/docs/sources/dashboards/assess-dashboard-usage/index.md +++ b/docs/sources/dashboards/assess-dashboard-usage/index.md @@ -34,7 +34,7 @@ refs: destination: /docs/grafana//setup-grafana/configure-security/export-logs/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana//setup-grafana/configure-security/export-logs/ - enabled: + dashboard-sharing: - pattern: /docs/grafana/ destination: /docs/grafana//setup-grafana/configure-grafana/#public_dashboards - pattern: /docs/grafana-cloud/ @@ -79,7 +79,7 @@ For every dashboard and data source, you can access usage information. To see dashboard usage information, click the dashboard insights icon in the header. -{{< figure src="/media/docs/grafana/dashboards/screenshot-dashboard-insights-11.2.png" alt="Dashboard insights icon" >}} +![Dashboard insights icon](/media/docs/grafana/dashboards/screenshot-dashboard-insights-icon-11.2.png) Dashboard insights show the following information: @@ -88,7 +88,7 @@ Dashboard insights show the following information: {{< figure src="/static/img/docs/enterprise/dashboard_insights_stats.png" max-width="400px" class="docs-image--no-shadow" alt="Stats tab" >}}{{< figure src="/static/img/docs/enterprise/dashboard_insights_users.png" max-width="400px" class="docs-image--no-shadow" alt="Users and activity tab" >}} -If public dashboards are [enabled](ref:enabled), you'll also see a **Public dashboards** tab in your analytics. +If [dashboard sharing](ref:dashboard-sharing) is enabled, you'll also see a **Shared dashboards** tab in your analytics. ### Data source insights diff --git a/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md b/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md index 77c697944d1..3dd373f4c31 100644 --- a/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md +++ b/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md @@ -148,7 +148,7 @@ Each panel can have its own set of links that are shown in the upper left of the Click the icon next to the panel title to see available panel links. -{{< figure src="/media/docs/grafana/screenshot-panel-links.png" width="200px" alt="List of panel links displayed" >}} +{{< figure src="/media/docs/grafana/dashboards/screenshot-panel-links-v11.3.png" max-width="550px" alt="List of panel links displayed" >}} ### Add a panel link diff --git a/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md b/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md index 838c194956c..facbdfa92a2 100644 --- a/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md +++ b/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md @@ -37,15 +37,20 @@ You can control permissions for library panels using [role-based access control ## Create a library panel -When you create a library panel, the panel on the source dashboard is converted to a library panel as well. You need to save the original dashboard once a panel is converted. +Library panels can be reused in different dashboards throughout Grafana. When you create a library panel, the panel on the source dashboard is converted to a library panel as well. You need to save the original dashboard once a panel is converted. -1. Click **Edit** in the top-right corner of the dashboard. -1. On the panel you want to update, hover over any part of the panel to display the menu icon on the top-right corner. -1. Click the menu icon and select **More > Create library panel**. -1. In **Library panel name**, enter the name. -1. In **Save in folder**, select the folder to save the library panel. -1. Click **Create library panel**. -1. Click **Save dashboard** and **Exit edit**. +To create a library panel, follow these steps: + +1. In the top-right corner of the dashboard, click **Edit**. +1. Hover over any part of the panel you want to share to display the actions menu on the top right corner. +1. Click **More > New library panel**. +1. In the **Library panel name** field, enter the name. +1. In the **Save in folder** drop-down list, select the folder in which to save the library panel. By default, the root level is selected. +1. Click **Create library panel** to save your changes. +1. Click **Save dashboard**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. Once created, you can modify the library panel using any dashboard on which it appears. After you save the changes, all instances of the library panel reflect these modifications. @@ -53,11 +58,13 @@ Once created, you can modify the library panel using any dashboard on which it a Add a Grafana library panel to a dashboard when you want to provide visualizations to other dashboard users. +To add a library panel, follow these steps: + 1. Click **Dashboards** in the main menu. -1. Click **New** and select **New Dashboard** in the dropdown. +1. Click **New** and select **New Dashboard** in the drop-down list. 1. On the empty dashboard, click **+ Add library panel**. - You'll see a list of your library panels. + The **Add panel from panel library** drawer opens. 1. Filter the list or search to find the panel you want to add. 1. Click a panel to add it to the dashboard. @@ -69,6 +76,8 @@ Add a Grafana library panel to a dashboard when you want to provide visualizatio Unlink a library panel when you want to make a change to the panel and not affect other instances of the library panel. +To unlink a library panel, follow these steps: + 1. Click **Dashboards** in the main menu. 1. Click **Library panels**. 1. Select a library panel that is being used in dashboards. @@ -107,8 +116,9 @@ Alternatively, if you know where the library panel that you want to replace is b You can view a list of available library panels and see where those panels are being used. -1. Click **Dashboards** in the main menu. -1. Click **Library panels**. +To view and manage library panels, follow these steps: + +1. Click **Dashboards > Library panels** in the main menu. You can see a list of previously defined library panels. {{< figure src="/media/docs/grafana/panels-visualizations/screenshot-library-panel-list-9-5.png" class="docs-image--no-shadow" max-width= "900px" alt="Library panels page with list of library panels" >}} @@ -123,8 +133,8 @@ You can view a list of available library panels and see where those panels are b ## Delete a library panel -Delete a library panel when you no longer need it. +To delete a library panel that you no longer need, follow these steps: -1. Click **Dashboards** in the main menu. -1. Click **Library panels**. +1. Click **Dashboards > Library panels** in the main menu. 1. Click the delete icon next to the library panel name. +1. Click **Delete**. diff --git a/docs/sources/dashboards/dashboard-public/index.md b/docs/sources/dashboards/dashboard-public/index.md deleted file mode 100644 index 3e1f935478e..00000000000 --- a/docs/sources/dashboards/dashboard-public/index.md +++ /dev/null @@ -1,273 +0,0 @@ ---- -labels: - products: - - cloud - - enterprise - - oss -title: Public dashboards -description: Make your Grafana dashboards public and share them with anyone -weight: 350 -refs: - dashboard-sharing: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/share-dashboards-panels/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels/ - custom-branding: - - pattern: /docs/grafana/ - destination: /docs/grafana//setup-grafana/configure-grafana/configure-custom-branding/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//setup-grafana/configure-grafana/configure-custom-branding/ - dashboard-insights-documentation: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/assess-dashboard-usage/#dashboard-insights - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/assess-dashboard-usage/ - caching: - - pattern: /docs/grafana/ - destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching - grafana-enterprise: - - pattern: /docs/grafana/ - destination: /docs/grafana//introduction/grafana-enterprise/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//introduction/grafana-enterprise/ ---- - -# Public dashboards - -> **Warning:** Making your dashboard public could result in a large number of queries to the data sources used by your dashboard. -> This can be mitigated by utilizing the enterprise [caching](ref:caching) and/or rate limiting features. - -Public dashboards allow you to share your Grafana dashboard with anyone. This is useful when you want to make your dashboard available to the world without requiring access to your Grafana organization. This differs from [dashboard sharing](ref:dashboard-sharing), which either requires recipients to be users in the same Grafana organization or provides limited information, as with a snapshot. - -You can see a list of all your public dashboards in one place by navigating to **Dashboards > Public dashboards**. For each dashboard in the list, the page displays the status, a link to view the dashboard, a link to the public dashboard configuration, and the option to revoke the public URL. - -## Security implications of making your dashboard public - -- Anyone with the URL can access the dashboard. -- Public dashboards are read-only. -- Arbitrary queries **cannot** be run against your data sources through public dashboards. Public dashboards can only execute the - queries stored on the original dashboard. - -## Make a dashboard public - -1. Click **Share** in the top-right corner of the dashboard. -1. Click the **Public dashboard** tab. -1. Acknowledge the implications of making the dashboard public by selecting all the checkboxes. -1. Click **Generate public URL** to make the dashboard public and make your link live. -1. Copy the public dashboard link if you'd like to share it. You can always come back later for it. - -Once you've made the dashboard public, a **Public** tag is displayed in the header of the dashboard. - -## Pause access - -1. Click **Share** in the top-right corner of the dashboard. -1. Click the **Public dashboard** tab. -1. Enable the **Pause sharing dashboard** toggle. - -The dashboard is no longer accessible, even with the link, until you make it shareable again. - -## Revoke access - -1. Click **Share** in the top-right corner of the dashboard. -1. Click the **Public dashboard** tab. -1. Click **Revoke public URL** to delete the public dashboard. - -The link no longer works. You must create a new public URL, as in [Make a dashboard public](#make-a-dashboard-public). - -## Email sharing - -{{% admonition type="note" %}} - -Available in [private preview](/docs/release-life-cycle/) in [Grafana Cloud](/docs/grafana-cloud). This feature will have a cost by active users after being promoted into general availability. - -Please contact support to have the feature enabled. - -{{% /admonition %}} - -Email sharing allows you to share your public dashboard with only specific people by email, instead of having it accessible to anyone with the URL. When you use email sharing, recipients receive a one-time use link that's valid for **one hour**. Once the link is used, the viewer has access to the public dashboard for **30 days**. - -### Invite a viewer - -1. Click **Share** in the top-right corner of the dashboard. -1. Click the **Public dashboard** tab. -1. Acknowledge the implications of making the dashboard public by selecting all the checkboxes. -1. Click **Generate public URL** to make the dashboard public and make your link live. -1. Under Can view dashboard, click **Only specified people**. -1. Enter the email you want to share the public dashboard with. -1. Click **Invite**. -1. The recipient will receive an email with a one-time use link. - -### Viewers requesting access - -If a viewer without access tries to navigate to the public dashboard, they'll be asked to request access by providing their email. They will receive an email with a new one-time use link if the email they provided has already been invited to view the public dashboard and has not been revoked. - -If the viewer doesn't have an invitation or it's been revoked, you won't be notified and no link is sent. - -### Revoke access for a viewer - -1. Click **Share** in the top-right corner of the dashboard. -1. Click the **Public dashboard** tab. -1. Click **Revoke** on the viewer you'd like to revoke access for. - -Immediately, the viewer no longer has access to the public dashboard, nor can they use any existing one-time use links they may have. - -### Reinvite a viewer - -1. Click **Share** in the top-right corner of the dashboard. -1. Click the **Public dashboard** tab. -1. Click **Resend** on the viewer you'd like to re-share the public dashboard with. - -The viewer will receive an email with a new one-time use link. This will invalidate all previously issued links for that viewer. - -### View public dashboard users - -To see a list of users who have accessed your dashboard by way of email sharing, take the following steps: - -1. In the main sidebar navigation, click **Administration**. -1. Click **Users**. -1. Click the **Public dashboard users** tab. - -From here, you can see the earliest time a user has been active in a dashboard, which public dashboards they have access to, and their role. - -### Access limitations - -One-time use links use browser cookies, so when a viewer is granted access through one of these links, they will only have access on the browser they used to claim the link. - -A single viewer cannot generate multiple valid one-time use links. When a new one-time use link is issued for a viewer, all previous ones are invalidated. - -If a Grafana user has read access to the parent dashboard, they can view the public dashboard without needing to have access granted. - -## Assess public dashboard usage - -> **Note:** Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud). - -You can check usage analytics about your public dashboard by clicking the insights icon in the dashboard header: - -{{< figure src="/media/docs/grafana/dashboards/screenshot-dashboard-insights-11.2.png" max-width="400px" class="docs-image--no-shadow" alt="Dashboard insights icon" >}} - -Learn more about the kind of information provided in the [dashboard insights documentation](ref:dashboard-insights-documentation). - -## Supported data sources - -Public dashboards _should_ work with any data source that has the properties `backend` and `alerting` both set to true in its `plugin.json`. However, this can't always be -guaranteed because plugin developers can override this functionality. The following lists include data sources confirmed to work with public dashboards and data sources that should work, but have not been confirmed as compatible. - -### Confirmed: - - - - - - -
-
    -
  • ClickHouse
  • -
  • CloudWatch
  • -
  • Elasticsearch
  • -
  • Infinity
  • -
  • InfluxDB
  • -
  • Loki
  • -
  • Microsoft SQL Server
  • -
-
-
    -
  • MongoDB
  • -
  • MySQL
  • -
  • Oracle Database
  • -
  • PostgreSQL
  • -
  • Prometheus
  • -
  • Redis
  • -
  • SQLite
  • -
-
- -### Unsupported: - - - - - -
-
    -
  • Graphite
  • -
-
- -### Unconfirmed: - - - - - - - -
-
    -
  • Altinity plugin for ClickHouse
  • -
  • Amazon Athena
  • -
  • Amazon Redshift
  • -
  • Amazon Timestream
  • -
  • Apache Cassandra
  • -
  • AppDynamics
  • -
  • Azure Data Explorer Datasource
  • -
  • Azure Monitor
  • -
  • CSV
  • -
  • DB2 Datasource
  • -
  • Databricks
  • -
  • Datadog
  • -
  • Dataset
  • -
  • Druid
  • -
-
-
    -
  • Dynatrace
  • -
  • GitHub
  • -
  • Google BigQuery
  • -
  • Grafana for YNAB
  • -
  • Honeycomb
  • -
  • Jira
  • -
  • Mock
  • -
  • Neo4j Datasource
  • -
  • New Relic
  • -
  • OPC UA (Unified Architecture)
  • -
  • Open Distro for Elasticsearch
  • -
  • OpenSearch
  • -
  • OpenTSDB
  • -
-
-
    -
  • Orbit
  • -
  • SAP HANA®
  • -
  • Salesforce
  • -
  • Sentry
  • -
  • ServiceNow
  • -
  • Snowflake
  • -
  • Splunk
  • -
  • Splunk Infrastructure Monitoring
  • -
  • Sqlyze data source
  • -
  • TDengine
  • -
  • Vertica
  • -
  • Wavefront
  • -
  • X-Ray
  • -
  • kdb+
  • -
  • simple grpc data source
  • -
-
- -## Limitations - -- Panels that use frontend data sources will fail to fetch data. -- Template variables are not supported. -- Exemplars will be omitted from the panel. -- Only annotations that query the `-- Grafana --` data source are supported. -- Organization annotations are not supported. -- Grafana Live and real-time event streams are not supported. -- Library panels are not supported. -- Data sources using Reverse Proxy functionality are not supported. - -## Custom branding - -If you're a Grafana Enterprise customer, you can use custom branding to change the appearance of a public dashboard footer. For more information, refer to [Custom branding](ref:custom-branding). diff --git a/docs/sources/dashboards/share-dashboards-panels/_index.md b/docs/sources/dashboards/share-dashboards-panels/_index.md new file mode 100644 index 00000000000..6043351cc4f --- /dev/null +++ b/docs/sources/dashboards/share-dashboards-panels/_index.md @@ -0,0 +1,348 @@ +--- +aliases: + - ../administration/reports/ + - ../enterprise/export-pdf/ + - ../enterprise/reporting/ + - ../reference/share_dashboard/ + - ../reference/share_panel/ + - ../share-dashboards-panels/ + - ../sharing/ + - ../sharing/playlists/ + - ../sharing/share-dashboard/ + - ../sharing/share-panel/ + - ./ + - reporting/ + - share-dashboard/ +keywords: + - grafana + - dashboard + - documentation + - share + - panel + - reporting + - export + - pdf +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Sharing +title: Share dashboards and panels +description: Share Grafana dashboards and panels within your organization and publicly +weight: 85 +refs: + image-rendering: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/image-rendering/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/image-rendering/ + grafana-enterprise: + - pattern: /docs/grafana/ + destination: /docs/grafana//introduction/grafana-enterprise/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//introduction/grafana-enterprise/ + shared-dashboards: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana/grafana-cloud/visualizations/dashboards/share-dashboards-panels/shared-dashboards/ + configure-report: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/create-reports/#create-or-update-a-report + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/create-reports/#create-or-update-a-report + image-rendering-config: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/image-rendering/#configuration + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/image-rendering/#configuration +--- + +# Share dashboards and panels + +Grafana enables you to share dashboards and panels with other users within your organization and in certain situations, publicly on the web. You can share using: + +- Direct links with users in and outside of your organization +- Snapshots +- Embeds +- PDFs +- JSON files +- Reports +- Library panels + +You must have an authorized viewer permission to see an image rendered by a direct link. + +The same permission is also required to view embedded links unless you have anonymous access permission enabled for your Grafana instance. + +{{< admonition type="note" >}} +As of Grafana 8.0, anonymous access permission is not available in Grafana Cloud. +{{< /admonition >}} + +## Share dashboards {#share-a-dashboard} + +You can share dashboards in the following ways: + +- [Internally with a link](#share-an-internal-link) +- [Externally with anyone or specific people](#share-an-external-link) +- [As a report](#schedule-a-report) +- [As a snapshot](#share-a-snapshot) +- [As a PDF export](#export-a-dashboard-as-pdf) +- [As a JSON file export](#export-a-dashboard-as-json) + +When you share a dashboard externally as a link or by email, those dashboards are included in a list of your shared dashboards. To view the list and manage these dashboards, navigate to **Dashboards > Shared dashboards**. + +{{< admonition type="note" >}} +If you change a dashboard, ensure that you save the changes before sharing. +{{< /admonition >}} + +### Share an internal link + +To share a customized, direct link to your dashboard within your organization, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Share internally**. +1. (Optional) In the **Share internally** drawer that opens, set the following options: + - **Lock time range** - Change the current relative time range to an absolute time range. This option is enabled by default. + - **Shorten link** - Shorten the dashboard link. This option is enabled by default. +1. Select the theme for the dashboard. Choose from **Current**, **Dark**, or **Light**. +1. Click **Copy link**. +1. Send the copied link to a Grafana user with authorization to view the link. +1. Click the **X** at the top-right corner to close the share drawer. + +#### Quick-share an internal link + +Once you've customized an internal link, you can share it quickly by following these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** button, not the drop-down list icon, to copy a shortened link. + +This link has any customizations, like time range locking or theme, that you've previously set. These are stored in the browser scope. + +### Share an external link + +Externally shared dashboards allow you to share your Grafana dashboard with anyone. This is useful when you want to make your dashboard available to the world without requiring access to your Grafana organization. + +Learn how to configure and manage externally shared dashboards in [Externally shared dashboards](ref:shared-dashboards). + +### Schedule a report + +{{< admonition type="note" >}} +This feature is only available in Grafana Enterprise. +{{< /admonition >}} + +To share your dashboard as a report, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Schedule a report**. +1. [Configure the report](ref:configure-report). +1. Depending on your schedule settings, you'll have different options at this step. Click either **Schedule send** or **Send now**. + +You can also save the report as a draft. + +To manage your reports, navigate to **Dashboards > Reporting > Reports**. + +### Share a snapshot + +A dashboard snapshot publicly shares a dashboard while removing sensitive data such as queries and panel links, leaving only visible metrics and series names. Anyone with the link can access the snapshot. + +You can publish snapshots to your local instance or to [snapshots.raintank.io](http://snapshots.raintank.io). The latter is a free service provided by Grafana Labs that enables you to publish dashboard snapshots to an external Grafana instance. Anyone with the link can view it. You can set an expiration time if you want the snapshot removed after a certain time period. + +{{< admonition type=note >}} +The snapshots.raintank.io option is disabled by default in Grafana Cloud. You can update [your config file](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#external_enabled) to enable this functionality. +{{< /admonition >}} + +To see the other snapshots shared from your organization, navigate to **Dashboards > Snapshots** in the main menu. + +To share your dashboard with anyone as a snapshot, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Share snapshot**. +1. In the **Share snapshot** drawer that opens, enter a descriptive title for the snapshot in the **Snapshot name** field. +1. Select one of the following expiration options for the snapshot: + - **1 Hour** + - **1 Day** + - **1 Week** + - **Never** +1. Click **Publish snapshot** or **Publish to snapshots.raintank.io**. + + Grafana generates the link of the snapshot. Note that you can't publish dashboard snapshots containing custom panels to snapshot.raintank.io. + +1. Click **Copy link**, and share it either within your organization or publicly on the web. +1. Click the **X** at the top-right corner to close the share drawer. + +#### Delete a snapshot + +To delete existing snapshots, follow these steps: + +1. Navigate to **Dashboards > Snapshots** in the main menu. +1. To confirm which snapshot you're about to delete, click **View** on the snapshot row. + + The URLs for panel and dashboard snapshots from the same dashboard look similar and viewing them first can help you distinguish them. + +1. Click the red **x** next to the snapshot that you want to delete. + +The snapshot is immediately deleted. You might need to clear your browser cache or use a private or incognito browser to confirm this. + +## Export dashboards + +In addition to sharing dashboards as links, reports, and snapshots, you can export them as PDFs or JSON files. + +### Export a dashboard as PDF + +To export a dashboard in its current state as a PDF, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Open the dashboard you want to export. +1. Click the **Export** drop-down in the top-right corner and select **Export as PDF**. +1. In the **Export dashboard PDF** drawer that opens, select either **Landscape** or **Portrait** for the PDF orientation. +1. Select either **Grid** or **Simple** for the PDF layout. +1. Set the **Zoom** level; zoom in to enlarge text, or zoom out to see more data (like table columns) per panel. +1. Click **Generate PDF**. + + The PDF opens in another tab where you can download it. + +1. Click the **X** at the top-right corner to close the share drawer. + +### Export a dashboard as JSON + +Export a Grafana JSON file that contains everything you need, including layout, variables, styles, data sources, queries, and so on, so that you can later import the dashboard. To export a JSON file, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Open the dashboard you want to export. +1. Click the **Export** drop-down list in the top-right corner and select **Export as JSON**. + + The **Export dashboard JSON** drawer opens. + +1. Toggle the **Export the dashboard to use in another instance** switch to generate the JSON with a different data source UID. +1. Click **Download file** or **Copy to clipboard**. +1. Click the **X** at the top-right corner to close the share drawer. + +## Share panels {#share-a-panel} + +You can share a panels in the following ways: + +- [Internally with a link](#share-an-internal-link) +- [As an embed](#share-an-embed) +- [As a snapshot](#panel-snapshot) + +{{< admonition type="note" >}} +If you change a panel, ensure that you save the changes before sharing. +{{< /admonition >}} + +### Share an internal link + +To share a personalized, direct link to your panel within your organization, follow these steps: + +1. Hover over any part of the panel you want to share to display the actions menu on the top right corner. +1. Click the menu and select **Share link**. +1. (Optional) In the **Link settings** drawer that opens, set the following options: + - **Lock time range** - Change the current relative time range to an absolute time range. This option is enabled by default. + - **Shorten link** - Shorten the panel link. This option is disabled by default. +1. Select the theme for the dashboard. Choose from **Current**, **Dark**, or **Light**. +1. Do one or both of the following: + - Click **Copy link**. + - Click **Render image**, which [renders the panel as a PNG image](ref:image-rendering). +1. Send the copied link or image URL to a Grafana user with authorization to view it. +1. Click the **X** at the top-right corner to close the share drawer. + +#### Query string parameters for server-side rendered images + +When you click **Render image** in the panel link settings, Grafana generates a PNG image of the panel with the following default parameters: + +| Parameter | Description | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| width | Width in pixels. Default is 800. | +| height | Height in pixels. Default is 400. | +| tz | Timezone in the format `UTC%2BHH%3AMM` where HH and MM are offset in hours and minutes after UTC. | +| timeout | Number of seconds. The timeout can be increased if the query for the panel needs more than the default 30 seconds. | +| scale | Numeric value to configure device scale factor. Default is 1. Use a higher value to produce more detailed images (higher DPI). Supported in Grafana v7.0+. | + +You can also update these parameters in the [image rendering configuration](ref:image-rendering-config). + +The following example shows a link to a server-side rendered PNG: + +```bash +https://play.grafana.org/render/d-solo/ktMs4D6Mk?from=2024-09-03T11:55:44.442Z&to=2024-09-03T17:55:44.442Z&panelId=panel-13&__feature.dashboardSceneSolo&width=1000&height=500&tz=UTC +``` + +### Share an embed + +You can share a panel by embedding it on another website using an iframe. Users must be signed into Grafana to view the panel. + +{{< admonition type="note" >}} +As of Grafana 8.0, anonymous access permission is no longer available for Grafana Cloud. +{{< /admonition >}} + +To create a panel that can be embedded, follow these steps: + +1. Hover over any part of the panel you want to share to display the actions menu on the top-right corner. +1. Click the menu and select **Share embed**. + + The **Share embed** drawer opens. + +1. (Optional) Toggle the **Lock time range** switch to set whether the panel uses the current relative time range or an absolute time range. This option is enabled by default. +1. Select the theme for the dashboard. Choose from **Current**, **Dark**, or **Light**. +1. (Optional) Make any changes to the HTML that you need. +1. Click **Copy to clipboard**. +1. Paste the HTML code into your website code. +1. Click the **X** at the top-right corner to close the share drawer. + +Here's an example of what the HTML code might look like: + +```html + +``` + +The result is an interactive Grafana visualization embedded in an iframe. + +### Share a snapshot {#panel-snapshot} + +A panel snapshot shares an interactive panel publicly while removing sensitive data such as queries and panel links, leaving only visible metrics and series names. Anyone with the link can access the snapshot. + +You can publish snapshots to your local instance or to [snapshots.raintank.io](http://snapshots.raintank.io). The latter is a free service provided by Grafana Labs that enables you to publish dashboard snapshots to an external Grafana instance. Anyone with the link can view it. You can set an expiration time if you want the snapshot removed after a certain time period. + +{{< admonition type=note >}} +The snapshots.raintank.io option is disabled by default in Grafana Cloud. You can update [your config file](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#external_enabled) to enable this functionality. +{{< /admonition >}} + +To see the other snapshots shared from your organization, navigate to **Dashboards > Snapshots** in the main menu. + +To share your panel with anyone as a snapshot, follow these steps: + +1. Hover over any part of the panel you want to share to display the actions menu on the top-right corner. +1. Click the menu and select **Share snapshot**. +1. In the **Share snapshot** drawer that opens, enter a descriptive title for the snapshot in the **Snapshot name** field. +1. Select one of the following expiration options for the snapshot: + - **1 Hour** + - **1 Day** + - **1 Week** + - **Never** +1. Click **Publish snapshot** or **Publish to snapshots.raintank.io**. + + Grafana generates the link of the snapshot. Note that you can't publish snapshots that include custom panels to snapshot.raintank.io. + +1. Click **Copy link**, and share it either within your organization or publicly on the web. +1. Click the **X** at the top-right corner to close the share drawer. + +#### Delete a snapshot + +To delete existing snapshots, follow these steps: + +1. Navigate to **Dashboards > Snapshots** in the main menu. +1. To confirm which snapshot you're about to delete, click **View** on the snapshot row. + + The URLs for panel and dashboard snapshots from the same dashboard look similar and viewing them first can help you distinguish them. + +1. Click the red **x** next to the snapshot URL that you want to delete. + +The snapshot is immediately deleted. You may need to clear your browser cache or use a private or incognito browser to confirm this. diff --git a/docs/sources/dashboards/share-dashboards-panels/index.md b/docs/sources/dashboards/share-dashboards-panels/index.md deleted file mode 100644 index 713ae2e4c41..00000000000 --- a/docs/sources/dashboards/share-dashboards-panels/index.md +++ /dev/null @@ -1,250 +0,0 @@ ---- -aliases: - - ../administration/reports/ - - ../enterprise/export-pdf/ - - ../enterprise/reporting/ - - ../reference/share_dashboard/ - - ../reference/share_panel/ - - ../share-dashboards-panels/ - - ../sharing/ - - ../sharing/playlists/ - - ../sharing/share-dashboard/ - - ../sharing/share-panel/ - - ./ - - reporting/ - - share-dashboard/ -keywords: - - grafana - - dashboard - - documentation - - share - - panel - - library panel - - playlist - - reporting - - export - - pdf -labels: - products: - - cloud - - enterprise - - oss -menuTitle: Sharing -title: Share dashboards and panels -description: Share Grafana dashboards and panels within your organization and publicly -weight: 700 -refs: - image-rendering: - - pattern: /docs/grafana/ - destination: /docs/grafana//setup-grafana/image-rendering/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//setup-grafana/image-rendering/ - grafana-enterprise: - - pattern: /docs/grafana/ - destination: /docs/grafana//introduction/grafana-enterprise/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//introduction/grafana-enterprise/ ---- - -# Share dashboards and panels - -Grafana enables you to share dashboards and panels with other users within an organization and in certain situations, publicly on the Web. You can share using: - -- A direct link -- A Snapshot -- An embedded link (for panels only) -- An export link (for dashboards only) - -You must have an authorized viewer permission to see an image rendered by a direct link. - -The same permission is also required to view embedded links unless you have anonymous access permission enabled for your Grafana instance. - -{{< admonition type="note" >}} -As of Grafana 8.0, anonymous access permission is not available in Grafana Cloud. -{{< /admonition >}} - -When you share a panel or dashboard as a snapshot, a snapshot (which is a panel or dashboard at the moment you take the snapshot) is publicly available on the web. Anyone with a link to it can access it. Because snapshots do not require any authorization to view, Grafana removes information related to the account it came from, as well as any sensitive data from the snapshot. - -## Share a dashboard - -You can share a dashboard as a direct link or as a snapshot. You can also export a dashboard. - -{{< admonition type="note" >}} -If you change a dashboard, ensure that you save the changes before sharing. -{{< /admonition >}} - -1. Click **Dashboards** in the main menu. -1. Click the dashboard you want to share. -1. Click **Share** in the top-right corner. - - The share dialog opens and shows the Link tab. - -### Share a direct link - -The **Link** tab shows the current time range, template variables, and the default theme. You can also share a shortened URL. - -1. Click **Copy**. - - This action copies the default or the shortened URL to the clipboard. - -1. Send the copied URL to a Grafana user with authorization to view the link. - -### Publish a snapshot - -A dashboard snapshot shares an interactive dashboard publicly. Grafana strips sensitive data such as queries (metric, template and annotation) and panel links, leaving only the visible metric data and series names embedded in the dashboard. Dashboard snapshots can be accessed by anyone with the link. - -You can publish snapshots to your local instance or to [snapshots.raintank.io](http://snapshots.raintank.io). The latter is a free service provided by Grafana Labs that enables you to publish dashboard snapshots to an external Grafana instance. Anyone with the link can view it. You can set an expiration time if you want the snapshot removed after a certain time period. - -1. Click the **Snapshot** tab. -1. Click **Publish to snapshots.raintank.io** or **Publish Snapshot**. - - Grafana generates a link of the snapshot. - -1. Copy the snapshot link, and share it either within your organization or publicly on the web. - -If you created a snapshot by mistake, click **Delete snapshot** in the dialog box to remove the snapshot from your Grafana instance. - -#### Delete a snapshot - -To delete existing snapshots, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Click **Snapshots** to go to the snapshots management page. -1. Click the red **x** next to the snapshot URL that you want to delete. - -The snapshot is immediately deleted. You may need to clear your browser cache or use a private or incognito browser to confirm this. - -### Export a dashboard as JSON - -The dashboard export action creates a Grafana JSON file that contains everything you need, including layout, variables, styles, data sources, queries, and so on, so that you can later import the dashboard. - -1. Click **Dashboards** in the main menu. -1. Open the dashboard you want to export. -1. Click **Share** in the top-right corner. -1. Click **Export**. - - If you're exporting the dashboard to use in another instance, with different data source UIDs, enable the **Export for sharing externally** switch. - -1. Click **Save to file**. - -Grafana downloads a JSON file to your local machine. - -#### Make a dashboard portable - -If you want to export a dashboard for others to use, you can add template variables for things like a metric prefix (use a constant variable) and server name. - -A template variable of the type `Constant` is automatically hidden in the dashboard, and is also added as a required input when the dashboard is imported. - -## Export dashboard as PDF - -You can generate and save PDF files of any dashboard. - -{{< admonition type="note" >}} -Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). -{{< /admonition >}} - -1. Click **Dashboards** in the main menu. -1. Click the dashboard you want to share. -1. Click **Share** in the top-right corner. -1. On the **PDF** tab, select a layout option for the exported dashboard: **Portrait** or **Landscape**. -1. Click **Save as PDF** to render the dashboard as a PDF file. - - Grafana opens the PDF file in a new window or browser tab. - -## Share a panel - -You can share a panel as a direct link, as a snapshot, or as an embedded link. You can also create library panels using the **Share** option on any panel. - -1. Hover over any part of the panel to display the actions menu on the top right corner. -1. Click the menu and select **Share**. - - The share dialog opens and shows the **Link** tab. - -### Use direct link - -The **Link** tab shows the current time range, template variables, and the default theme. You can optionally enable a shortened URL to share. - -1. Click **Copy**. - - This action copies the default or the shortened URL to the clipboard. - -1. Send the copied URL to a Grafana user with authorization to view the link. -1. You also optionally click **Direct link rendered image** to share an image of the panel. - -For more information, refer to [Image rendering](ref:image-rendering). - -The following example shows a link to a server-side rendered PNG: - -```bash -https://play.grafana.org/d/000000012/grafana-play-home?orgId=1&from=1568719680173&to=1568726880174&panelId=4&fullscreen -``` - -#### Query string parameters for server-side rendered images - -- **width:** Width in pixels. Default is 800. -- **height:** Height in pixels. Default is 400. -- **tz:** Timezone in the format `UTC%2BHH%3AMM` where HH and MM are offset in hours and minutes after UTC -- **timeout:** Number of seconds. The timeout can be increased if the query for the panel needs more than the default 30 seconds. -- **scale:** Numeric value to configure device scale factor. Default is 1. Use a higher value to produce more detailed images (higher DPI). Supported in Grafana v7.0+. - -### Publish a snapshot - -A panel snapshot shares an interactive panel publicly. Grafana strips sensitive data leaving only the visible metric data and series names embedded in the dashboard. Panel snapshots can be accessed by anyone with the link. - -You can publish snapshots to your local instance or to [snapshots.raintank.io](http://snapshots.raintank.io). The latter is a free service provided by [Grafana Labs](https://grafana.com), that enables you to publish dashboard snapshots to an external Grafana instance. - -{{< admonition type="note" >}} -As of Grafana 11, the option to publish to [snapshots.raintank.io](http://snapshots.raintank.io) is no longer available for Grafana Cloud. -{{< /admonition >}} - -You can optionally set an expiration time if you want the snapshot to be removed after a certain time period. - -1. In the **Share Panel** dialog, click **Snapshot** to go to the tab. -1. Click **Publish to snapshots.raintank.io** or **Publish Snapshot**. - - Grafana generates the link of the snapshot. - -1. Copy the snapshot link, and share it either within your organization or publicly on the web. - -If you created a snapshot by mistake, click **Delete snapshot** in the dialog box to remove the snapshot from your Grafana instance. - -#### Delete a snapshot - -To delete existing snapshots, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Click **Snapshots** to go to the snapshots management page. -1. Click the red **x** next to the snapshot URL that you want to delete. - -The snapshot is immediately deleted. You may need to clear your browser cache or use a private or incognito browser to confirm this. - -### Embed panel - -You can embed a panel using an iframe on another web site. A viewer must be signed into Grafana to view the graph. - -{{< admonition type="note" >}} -As of Grafana 8.0, anonymous access permission is no longer available for Grafana Cloud. -{{< /admonition >}} - -Here is an example of the HTML code: - -```html - -``` - -The result is an interactive Grafana graph embedded in an iframe. - -### Library panel - -To create a library panel from the **Share Panel** dialog: - -1. Click **Library panel**. -1. In **Library panel name**, enter the name. -1. In **Save in folder**, select the folder in which to save the library panel. By default, the root level is selected. -1. Click **Create library panel** to save your changes. -1. Click **Save dashboard**. diff --git a/docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md b/docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md new file mode 100644 index 00000000000..1c92c0cee32 --- /dev/null +++ b/docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md @@ -0,0 +1,331 @@ +--- +aliases: + - ../dashboard-public/ # /docs/grafana/latest/dashboards/dashboard-public/ +labels: + products: + - cloud + - enterprise + - oss +title: Externally shared dashboards +menuTitle: Shared dashboards +description: Make your Grafana dashboards externally shared and share them with anyone +weight: 8 +refs: + dashboard-sharing: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/share-dashboards-panels/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels/ + custom-branding: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/configure-custom-branding/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/configure-custom-branding/ + dashboard-insights-documentation: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/assess-dashboard-usage/#dashboard-insights + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/assess-dashboard-usage/ + caching: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching + grafana-enterprise: + - pattern: /docs/grafana/ + destination: /docs/grafana//introduction/grafana-enterprise/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//introduction/grafana-enterprise/ +--- + +# Externally shared dashboards + +{{< admonition type="note" >}} +This feature was previously called **Public dashboards**. +{{< /admonition >}} + +Externally shared dashboards allow you to share your Grafana dashboard with anyone. This is useful when you want to make your dashboard available to the world without requiring access to your Grafana organization. + +If you change a dashboard, ensure that you save the changes before sharing. + +{{< admonition type="warning" >}} +Sharing your dashboard externally could result in a large number of queries to the data sources used by your dashboard. +This can be mitigated by using the Enterprise [caching](ref:caching) and/or rate limiting features. +{{< /admonition >}} + +## Shared dashboards list + +You can see a list of all your externally shared dashboards in one place by navigating to **Dashboards > Shared dashboards**. For each dashboard in the list, the page displays: + +- Link to view the externally shared version of the dashboard +- Link to the shared dashboard configuration +- Options to pause or revoke access to the external dashboard + +You can also click the name of the dashboard to navigate to the dashboard internally. + +## Important notes about sharing your dashboard externally + +- Anyone with the URL can access the dashboard. +- Externally shared dashboards are read-only. +- Arbitrary queries **cannot** be run against your data sources through externally shared dashboards. Externally shared dashboards can only execute the queries stored on the original dashboard. + +## Share externally with specific people + +{{< admonition type="note">}} +This feature was previously called **email sharing**. + +Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud). +{{< /admonition >}} + +To share a dashboard with specific external users, you can send them a link by email. Use this option when you only want to share your dashboard with specific people. When you share dashboards by email, recipients receive a one-time use link that's valid for **one hour**. Once the link is used, the viewer has access to the shared dashboard for **30 days**. + +When you share a dashboard with an email link, your organization is billed per user, regardless of how many dashboards are shared. Grafana bills monthly per user until access is revoked. + +To share a dashboard with specific people, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Share externally**. + + The **Share externally** drawer opens. + +1. In the **Link access** drop-down list, select **Only specific people**. +1. Click the checkbox confirming that you understand payment is required to add users. +1. Click **Accept**. +1. In the **Invite** field, enter the email address of the person you want to invite and click **Invite** and repeat this process for all the people you want to invite. + + You can only invite one person at a time. + +1. (Optional) Set the following options: + - **Enable time range** - Allow people accessing the link to change the time range. This configuration screen shows the default time range of the dashboard. + - **Display annotations** - Allow people accessing the link to view the dashboard annotations. +1. (Optional) Click **Copy external link** and send the copied URL to any external user. +1. Click the **X** at the top-right corner to close the share drawer. + +Once you've shared a dashboard externally, a **Public** label is displayed in the header of the dashboard. + +### Viewers requesting access + +If a viewer without access tries to navigate to the shared dashboard, they'll be asked to request access by providing their email. They'll receive an email with a new one-time use link if the email they provided has already been invited to view the shared dashboard and hasn't been revoked. + +### Revoke access for a viewer + +You can revoke access to the entire dashboard using the steps in [Update access to an external dashboard link](#update-access-to-an-external-dashboard-link), but you can also revoke access to the dashboard for specific people. + +To revoke access for a viewer, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Share externally**. +1. In the **Share externally** drawer that opens, click the menu icon (three dots) next to the email address of the viewer for whom you'd like to revoke access. +1. Click **Revoke access**. +1. Click the **X** at the top-right corner to close the share drawer. + +The viewer immediately no longer has access to the dashboard, nor can they use any existing one-time use links they may have. + +### Re-invite a viewer + +To re-invite a viewer, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Share externally**. +1. In the **Share externally** drawer that opens, click the menu icon (three dots) next to the email address of the viewer you'd like to invite again. +1. Click **Resend invite**. +1. Click the **X** at the top-right corner to close the share drawer. + +The viewer receives an email with a new one-time use link. This invalidates all previously issued links for that viewer. + +### View shared dashboard users + +To see a list of users who have accessed your externally shared dashboard by way of an emailed link, follow these steps: + +1. Click **Administration** in in the main menu. +1. Select **Users and access** > **Users**. +1. On the **Users** page, click the **Shared dashboard users** tab. + +On this screen, you can see: + +- The earliest time a user has been active in a dashboard +- When they last accessed a shared dashboard +- The dashboards to they have access +- Their role + +You can also revoke a user's access to all shared dashboards on from this tab. + +### Access limitations + +One-time use links use browser cookies, so when a viewer is granted access through one of these links, they'll only have access on the browser they used to claim the link. + +A single viewer can't generate multiple valid one-time use links for a dashboard. When a new one-time use link is issued for a viewer, all previous ones are invalidated. + +If a Grafana user has read access to the parent dashboard, they can view the externally shared dashboard without needing to have access granted. + +## Share externally to anyone with a link + +To share your dashboard so that anyone with the link can access it, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Share externally**. + + The **Share externally** drawer opens. + +1. In the **Link access** drop-down list, select **Anyone with the link**. +1. Click the checkbox confirming that you understand the entire dashboard will be public. +1. Click **Accept**. +1. (Optional) Set the following options: + - **Enable time range** - Allow people accessing the link to change the time range. This configuration screen shows the default time range of the dashboard. + - **Display annotations** - Allow people accessing the link to view the dashboard annotations. +1. Click the **X** at the top-right corner to close the share drawer. + +Now anyone with the link can access the dashboard until you pause or revoke access to it. + +Once you've shared a dashboard externally, a **Public** label is displayed in the header of the dashboard. + +### Update access to an external dashboard link + +You can update the access to externally shared dashboard links by following these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Share externally**. +1. In the **Share externally** drawer that opens, do one of the following: + - Click **Pause access** so that people can't access the dashboard, but the link is maintained. + - Click **Resume access** so that people can access the dashboard again. + - Click **Revoke access** so that people can't access the dashboard unless a new external link is generated. Confirm that you want to revoke the link. +1. Click the **X** at the top-right corner to close the share drawer. + +## Assess shared dashboard usage + +{{< admonition type="note" >}} +Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud). +{{< /admonition >}} + +You can check usage analytics about your externally shared dashboard by clicking the insights icon in the dashboard header: + +![Dashboard insights icon](/media/docs/grafana/dashboards/screenshot-dashboard-insights-icon-11.2.png) + +Learn more about the kind of information provided in the [dashboard insights documentation](ref:dashboard-insights-documentation). + +## Supported data sources + +Externally shared dashboards _should_ work with any data source that has the properties `backend` and `alerting` both set to true in its `plugin.json`. However, this can't always be +guaranteed because plugin developers can override this functionality. The following lists include data sources confirmed to work with externally shared dashboards and data sources that should work, but have not been confirmed as compatible. + +### Confirmed: + + + + + + +
+
    +
  • ClickHouse
  • +
  • CloudWatch
  • +
  • Elasticsearch
  • +
  • Infinity
  • +
  • InfluxDB
  • +
  • Loki
  • +
  • Microsoft SQL Server
  • +
+
+
    +
  • MongoDB
  • +
  • MySQL
  • +
  • Oracle Database
  • +
  • PostgreSQL
  • +
  • Prometheus
  • +
  • Redis
  • +
  • SQLite
  • +
+
+ +### Unsupported: + + + + + +
+
    +
  • Graphite
  • +
+
+ +### Unconfirmed: + + + + + + + +
+
    +
  • Altinity plugin for ClickHouse
  • +
  • Amazon Athena
  • +
  • Amazon Redshift
  • +
  • Amazon Timestream
  • +
  • Apache Cassandra
  • +
  • AppDynamics
  • +
  • Azure Data Explorer Datasource
  • +
  • Azure Monitor
  • +
  • CSV
  • +
  • DB2 Datasource
  • +
  • Databricks
  • +
  • Datadog
  • +
  • Dataset
  • +
  • Druid
  • +
+
+
    +
  • Dynatrace
  • +
  • GitHub
  • +
  • Google BigQuery
  • +
  • Grafana for YNAB
  • +
  • Honeycomb
  • +
  • Jira
  • +
  • Mock
  • +
  • Neo4j Datasource
  • +
  • New Relic
  • +
  • OPC UA (Unified Architecture)
  • +
  • Open Distro for Elasticsearch
  • +
  • OpenSearch
  • +
  • OpenTSDB
  • +
+
+
    +
  • Orbit
  • +
  • SAP HANA®
  • +
  • Salesforce
  • +
  • Sentry
  • +
  • ServiceNow
  • +
  • Snowflake
  • +
  • Splunk
  • +
  • Splunk Infrastructure Monitoring
  • +
  • Sqlyze data source
  • +
  • TDengine
  • +
  • Vertica
  • +
  • Wavefront
  • +
  • X-Ray
  • +
  • kdb+
  • +
  • simple grpc data source
  • +
+
+ +## Limitations + +- Panels that use frontend data sources will fail to fetch data. +- Template variables are not supported. +- Exemplars will be omitted from the panel. +- Only annotations that query the `-- Grafana --` data source are supported. +- Organization annotations are not supported. +- Grafana Live and real-time event streams are not supported. +- Library panels are not supported. +- Data sources using Reverse Proxy functionality are not supported. + +## Custom branding + +If you're a Grafana Enterprise customer, you can use custom branding to change the appearance of an externally shared dashboard footer. For more information, refer to [Custom branding](ref:custom-branding). diff --git a/docs/sources/dashboards/use-dashboards/index.md b/docs/sources/dashboards/use-dashboards/index.md index 035aac2e2c2..f11d1f4df60 100644 --- a/docs/sources/dashboards/use-dashboards/index.md +++ b/docs/sources/dashboards/use-dashboards/index.md @@ -123,10 +123,11 @@ By hovering over a panel with the mouse you can use some shortcuts that will tar - `e`: Toggle panel edit view - `v`: Toggle panel fullscreen view -- `ps`: Open Panel Share Modal -- `pd`: Duplicate Panel -- `pr`: Remove Panel -- `pl`: Toggle panel legend +- `pu`: Open share panel link configuration +- `pe`: Open share panel embed configuration +- `ps`: Open share panel snapshot configuration +- `pd`: Duplicate panel +- `pr`: Remove panel ## Set dashboard time range diff --git a/docs/sources/developers/http_api/_index.md b/docs/sources/developers/http_api/_index.md index a37489074de..51b5d2cab61 100644 --- a/docs/sources/developers/http_api/_index.md +++ b/docs/sources/developers/http_api/_index.md @@ -66,7 +66,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - [Other API]({{< relref "other/" >}}) - [Playlists API]({{< relref "playlist/" >}}) - [Preferences API]({{< relref "preferences/" >}}) -- [Public dashboard API]({{< relref "dashboard_public/" >}}) +- [Shared dashboards API]({{< relref "dashboard_public/" >}}) - [Query history API]({{< relref "query_history/" >}}) - [Service account API]({{< relref "serviceaccount/" >}}) - [Short URL API]({{< relref "short_url/" >}}) diff --git a/docs/sources/developers/http_api/dashboard_public.md b/docs/sources/developers/http_api/dashboard_public.md index 1604022fd66..f96a4c4e4f4 100644 --- a/docs/sources/developers/http_api/dashboard_public.md +++ b/docs/sources/developers/http_api/dashboard_public.md @@ -2,7 +2,7 @@ aliases: - ../../http_api/dashboard_public/ canonical: /docs/grafana/latest/developers/http_api/dashboard_public/ -description: Grafana Public Dashboard HTTP API +description: Grafana Shared Dashboards HTTP API keywords: - grafana - http @@ -13,7 +13,7 @@ labels: products: - enterprise - oss -title: Public Dashboard HTTP API +title: Shared Dashboards HTTP API refs: role-based-access-control-permissions: - pattern: /docs/grafana/ @@ -22,7 +22,7 @@ refs: destination: /docs/grafana//administration/roles-and-permissions/access-control/custom-role-actions-scopes/ --- -# Public Dashboard API +# Shared Dashboards API {{% admonition type="note" %}} @@ -30,21 +30,21 @@ If you're running Grafana Enterprise, you'll need to have specific permissions f {{% /admonition %}} -## Create a public dashboard +## Create a shared dashboard `POST /api/dashboards/uid/:uid/public-dashboards/` -Creates a new public dashboard. +Creates a new shared dashboard. **Required permissions** -See note in the [introduction](#public-dashboard-api) for an explanation. +See note in the [introduction](#shared-dashboards-api) for an explanation. | Action | Scope | | ------------------------- | -------------------------------- | | `dashboards.public:write` | `dashboards:uid:` | -**Example Request for new public dashboard**: +**Example Request for new shared dashboard**: ```http POST /api/dashboards/uid/xCpsVuc4z/public-dashboards/ HTTP/1.1 @@ -64,10 +64,10 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk JSON Body schema: -- **uid** – Optional. Unique identifier when creating a public dashboard. If it's null, it will generate a new uid. +- **uid** – Optional. Unique identifier when creating a shared dashboard. If it's null, it will generate a new uid. - **accessToken** – Optional. Unique access token. If it's null, it will generate a new access token. -- **timeSelectionEnabled** – Optional. Set to `true` to enable the time picker in the public dashboard. The default value is `false`. -- **isEnabled** – Optional. Set to `true` to enable the public dashboard. The default value is `false`. +- **timeSelectionEnabled** – Optional. Set to `true` to enable the time picker in the shared dashboard. The default value is `false`. +- **isEnabled** – Optional. Set to `true` to enable the shared dashboard. The default value is `false`. - **annotationsEnabled** – Optional. Set to `true` to show annotations. The default value is `false`. - **share** – Optional. Set the share mode. The default value is `public`. @@ -96,7 +96,7 @@ Content-Length: 78 Status Codes: - **200** – Created -- **400** – Errors (such as invalid json, missing or invalid fields, or dashboard is public) +- **400** – Errors (such as invalid json, missing or invalid fields, or dashboard is shared) - **401** – Unauthorized - **403** – Access denied - **404** – Dashboard not found @@ -115,21 +115,21 @@ Content-Length: 107 } ``` -## Update a public dashboard +## Update a shared dashboard `PATCH /api/dashboards/uid/:uid/public-dashboards/:publicDashboardUid` -Will update the public dashboard given the specified unique identifier (uid). +Will update the shared dashboard given the specified unique identifier (uid). **Required permissions** -See note in the [introduction](#public-dashboard-api) for an explanation. +See note in the [introduction](#shared-dashboard-api) for an explanation. | Action | Scope | | ------------------------- | -------------------------------- | | `dashboards.public:write` | `dashboards:uid:` | -**Example Request for updating a public dashboard**: +**Example Request for updating a shared dashboard**: ```http PATCH /api/dashboards/uid/xCpsVuc4z/public-dashboards/cd56d9fd-f3d4-486d-afba-a21760e2acbe HTTP/1.1 @@ -147,8 +147,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk JSON Body schema: -- **timeSelectionEnabled** – Optional. Set to `true` to enable the time picker in the public dashboard. The default value is `false`. -- **isEnabled** – Optional. Set to `true` to enable the public dashboard. The default value is `false`. +- **timeSelectionEnabled** – Optional. Set to `true` to enable the time picker in the shared dashboard. The default value is `false`. +- **isEnabled** – Optional. Set to `true` to enable the shared dashboard. The default value is `false`. - **annotationsEnabled** – Optional. Set to `true` to show annotations. The default value is `false`. - **share** – Optional. Set the share mode. The default value is `public`. @@ -180,7 +180,7 @@ Status Codes: - **400** – Errors (such as invalid json, missing or invalid fields) - **401** – Unauthorized - **403** – Access denied -- **404** – Public dashboard not found +- **404** – Dashboard not found The error response body will have the following properties: @@ -196,15 +196,15 @@ Content-Length: 107 } ``` -## Get public dashboard by dashboard uid +## Get shared dashboard by dashboard uid `GET /api/dashboards/uid/:uid/public-dashboards/` -Will return the public dashboard given the dashboard unique identifier (uid). +Will return the shared dashboard given the dashboard unique identifier (uid). **Required permissions** -See note in the [introduction](#public-dashboard-api) for an explanation. +See note in the [introduction](#shared-dashboard-api) for an explanation. | Action | Scope | | ----------------- | -------------------------------- | @@ -245,17 +245,17 @@ Status Codes: - **200** – Found - **401** – Unauthorized - **403** – Access denied -- **404** – Not found +- **404** – Dashboard not found -## Delete public dashboard by dashboard uid and public dashboard uid +## Delete shared dashboard by dashboard uid and shared dashboard uid `DELETE /api/dashboards/uid/:uid/public-dashboards/:publicDashboardUid` -Will delete the public dashboard given the specified unique identifier (uid). +Will delete the shared dashboard given the specified unique identifier (uid). **Required permissions** -See note in the [introduction](#public-dashboard-api) for an explanation. +See note in the [introduction](#shared-dashboard-api) for an explanation. | Action | Scope | | ------------------------- | -------------------------------- | @@ -276,13 +276,13 @@ Status Codes: - **401** – Unauthorized - **403** – Access denied -## Get a list of all public dashboards with pagination +## Get a list of all shared dashboards with pagination `GET /api/dashboards/public-dashboards` **Required permissions** -See note in the [introduction](#public-dashboard-api) for an explanation. +See note in the [introduction](#shared-dashboard-api) for an explanation. | Action | Scope | | ----------------- | -------------------------------- | diff --git a/docs/sources/introduction/grafana-enterprise.md b/docs/sources/introduction/grafana-enterprise.md index f2d96227778..da08521b3ed 100644 --- a/docs/sources/introduction/grafana-enterprise.md +++ b/docs/sources/introduction/grafana-enterprise.md @@ -61,7 +61,7 @@ Grafana Enterprise adds the following features: - [Data source permissions]({{< relref "../administration/data-source-management#data-source-permissions" >}}) to restrict query access to specific teams and users. - [Data source query and resource caching]({{< relref "../administration/data-source-management#query-and-resource-caching" >}}) to temporarily store query results in Grafana to reduce data source load and rate limiting. - [Reporting]({{< relref "../dashboards/create-reports" >}}) to generate a PDF report from any dashboard and set up a schedule to have it emailed to whomever you choose. -- [Export dashboard as PDF]({{< relref "../dashboards/share-dashboards-panels#export-dashboard-as-pdf" >}}) +- [Export dashboard as PDF]({{< relref "../dashboards/share-dashboards-panels#export-a-dashboard-as-pdf" >}}) - [Custom branding]({{< relref "../setup-grafana/configure-grafana/configure-custom-branding" >}}) to customize Grafana from the brand and logo to the footer links. - [Usage insights]({{< relref "../dashboards/assess-dashboard-usage" >}}) to understand how your Grafana instance is used. - [Recorded queries]({{< relref "../administration/recorded-queries" >}}) to see trends over time for your data sources. diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 505b3b141a5..8e74e21186f 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -2613,8 +2613,8 @@ Format: ` = ` ## [public_dashboards] -This section configures the [public dashboards]({{< relref "../../dashboards/dashboard-public" >}}) feature. +This section configures the [shared dashboards](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/) feature. ### enabled -Set this to `false` to disable the public dashboards feature. This prevents users from creating new public dashboards and disables existing ones. +Set this to `false` to disable the shared dashboards feature. This prevents users from creating new shared dashboards and disables existing ones. diff --git a/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md b/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md index f38a142e429..4d39ca9b09e 100644 --- a/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md +++ b/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md @@ -112,22 +112,22 @@ The following two links are always present in the footer: If you specify `footer_links` or `GF_WHITE_LABELING_FOOTER_LINKS`, then all other default links are removed from the footer, and only what is specified is included. -## Custom branding for public dashboards +## Custom branding for shared dashboards -In addition to the customizations described below, you can customize the footer of your public dashboards. -To customize the footer of a public dashboard, add the following section to the `grafana.ini` file. +In addition to the customizations described below, you can customize the footer of your shared dashboards. +To customize the footer of a shared dashboard, add the following section to the `grafana.ini` file. ```ini [white_labeling.public_dashboards] -# Hides the footer for the public dashboards if set to `true`. +# Hides the footer for the shared dashboards if set to `true`. # example: footer_hide = "true" ;footer_hide = # Set to text shown in the footer ;footer_text = -# Set to complete url to override public dashboard footer logo. Default is `grafana-logo` and will display the Grafana logo. +# Set to complete url to override shared dashboard footer logo. Default is `grafana-logo` and will display the Grafana logo. # An empty value will hide the footer logo. ;footer_logo = diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md index eecb143e524..c8ed2b24a89 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md @@ -94,7 +94,7 @@ you can use JWT authentication to authenticate the iframe. {{% admonition type="note" %}} For Grafana Cloud, or scenarios where verifying viewer identity is not required, -embed [public dashboards]({{< relref "../../../../dashboards/dashboard-public" >}}). +embed [shared dashboards](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/). {{% /admonition %}} In this scenario, you will need to configure Grafana to accept a JWT diff --git a/docs/sources/setup-grafana/image-rendering/_index.md b/docs/sources/setup-grafana/image-rendering/_index.md index ed04d5cc64e..f77ccbdedfd 100644 --- a/docs/sources/setup-grafana/image-rendering/_index.md +++ b/docs/sources/setup-grafana/image-rendering/_index.md @@ -26,7 +26,7 @@ While an image is being rendered, the PNG image is temporarily written to the fi A background job runs every 10 minutes and removes temporary images. You can configure how long an image should be stored before being removed by configuring the [temp_data_lifetime]({{< relref "../configure-grafana#temp_data_lifetime" >}}) setting. -You can also render a PNG by hovering over the panel to display the actions menu in the top right corner, and then clicking **Share > Direct link rendered image** in the Link tab. +You can also render a PNG by hovering over the panel to display the actions menu in the top-right corner, and then clicking **Share > Share link**. The **Render image** option is displayed in the link settings. ## Alerting and render limits diff --git a/docs/sources/whatsnew/whats-new-in-v10-0.md b/docs/sources/whatsnew/whats-new-in-v10-0.md index df622eac402..9d5b08c7c44 100644 --- a/docs/sources/whatsnew/whats-new-in-v10-0.md +++ b/docs/sources/whatsnew/whats-new-in-v10-0.md @@ -225,7 +225,7 @@ With this update, we've made the following improvements: To try it out, enable the `publicDashboards` feature toggle. If you’re using Grafana Cloud, and would like to enable this feature, please contact customer support. -To learn more, refer to our [public dashboards documentation]({{< relref "../dashboards/dashboard-public" >}}). +To learn more, refer to our [public dashboards documentation](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/). ### Public dashboards insights @@ -255,7 +255,7 @@ This feature will have a cost by active users after being promoted into general {{% /admonition %}} -To learn more, refer to our [public dashboards documentation]({{< relref "../dashboards/dashboard-public" >}}). +To learn more, refer to our [public dashboards documentation](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/). ## Authentication and authorization diff --git a/docs/sources/whatsnew/whats-new-in-v10-2.md b/docs/sources/whatsnew/whats-new-in-v10-2.md index 1db3a3d2505..3b0425ec2bb 100644 --- a/docs/sources/whatsnew/whats-new-in-v10-2.md +++ b/docs/sources/whatsnew/whats-new-in-v10-2.md @@ -46,7 +46,7 @@ Learn how to upload images here: https://grafana.com/docs/writers-toolkit/write/ _Generally available in all editions of Grafana_ -Public dashboards allow you to share your visualizations and insights with a broader audience without the requirement of a login. You can effortlessly use our current sharing model and create a public dashboard URL to share with anyone using the generated public URL link. To learn more, refer to the [Public dashboards documentation](https://grafana.com/docs/grafana//dashboards/dashboard-public/), as well as the following video demo: +Public dashboards allow you to share your visualizations and insights with a broader audience without the requirement of a login. You can effortlessly use our current sharing model and create a public dashboard URL to share with anyone using the generated public URL link. To learn more, refer to the [Public dashboards documentation](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards), as well as the following video demo: {{< youtube id="XHwwRCdxHMg?rel=0" >}} diff --git a/docs/sources/whatsnew/whats-new-in-v11-0.md b/docs/sources/whatsnew/whats-new-in-v11-0.md index 024e7a44c69..361d52a37ae 100644 --- a/docs/sources/whatsnew/whats-new-in-v11-0.md +++ b/docs/sources/whatsnew/whats-new-in-v11-0.md @@ -384,7 +384,7 @@ To learn more about Grafana's strong password policy, refer to the [documentatio _Generally available in Grafana Enterprise_ -We are announcing a license change to the anonymous access feature in Grafana 11. As you may already be aware, anonymous access allows users access to Grafana without login credentials. Anonymous access was an early feature of Grafana to share dashboards; however, we recently introduced [Public Dashboards](https://grafana.com/docs/grafana//dashboards/dashboard-public/) which allows you to share dashboards in a more secure manner. We also noticed that anonymous access inadvertently resulted in user licensing issues. After careful consideration, we have decided to charge for the continued use of anonymous access starting in Grafana 11. +We are announcing a license change to the anonymous access feature in Grafana 11. As you may already be aware, anonymous access allows users access to Grafana without login credentials. Anonymous access was an early feature of Grafana to share dashboards; however, we recently introduced [Public Dashboards](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards) which allows you to share dashboards in a more secure manner. We also noticed that anonymous access inadvertently resulted in user licensing issues. After careful consideration, we have decided to charge for the continued use of anonymous access starting in Grafana 11. **Affected Grafana versions** diff --git a/docs/sources/whatsnew/whats-new-in-v9-1.md b/docs/sources/whatsnew/whats-new-in-v9-1.md index cab0a2696d4..7a6aeb9f44a 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-1.md +++ b/docs/sources/whatsnew/whats-new-in-v9-1.md @@ -50,7 +50,7 @@ You can now easily embed Grafana in other applications by adding a JWT token dir When the JWT token is passed through the request URL to Grafana, Grafana validates and authenticates the token linked to a specific user, allowing access to dashboards which that user can view. To see JWT URL embedding in action, see the [sample project](https://github.com/grafana/grafana-iframe-oauth-sample). -> **Note:** JWT URL Embedding and `allow_embedding` are not available to Grafana Cloud users. For Grafana Cloud, use the [Public Dashboards]({{< relref "../dashboards/dashboard-public" >}}) feature. To enable that, [open a ticket with our Support team](/docs/grafana-cloud/account-management/support/). +> **Note:** JWT URL Embedding and `allow_embedding` are not available to Grafana Cloud users. For Grafana Cloud, use [public dashboards](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/). To enable that, [open a ticket with our Support team](/docs/grafana-cloud/account-management/support/). {{< figure src="/static/img/docs/dashboards/jwt-url-embedding-9-1.png" max-width="750px" caption="A JWT token used to embed Grafana" >}} @@ -156,7 +156,7 @@ The public view of a dashboard has a few restrictions: - The public dashboard is displayed in a read-only kiosk view. - The time range is fixed to the dashboard default time range. -To learn more, see the [documentation]({{< relref "../dashboards/dashboard-public" >}}). +To learn more, see the [documentation](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/). #### Provisioning improvements for Grafana Alerting diff --git a/docs/sources/whatsnew/whats-new-in-v9-2.md b/docs/sources/whatsnew/whats-new-in-v9-2.md index 4deeb3a386a..deb8c3f836d 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-2.md +++ b/docs/sources/whatsnew/whats-new-in-v9-2.md @@ -95,7 +95,7 @@ External alertmanagers should now be configured as data sources using Grafana Co _Available in Experimental in Grafana Open Source, Enterprise, and Cloud._ _To enable public dashboards, you must enable a feature flag or request this feature from support._ -[Public dashboards](/docs/grafana/latest/dashboards/dashboard-public/) launched as an experimental feature in Grafana v9.1. +[Public dashboards](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards) launched as an experimental feature in Grafana v9.1. We've received lots of great feedback on this much-anticipated feature and thank everyone who has helped us improve it. The team has been hard at work polishing public dashboards, and we've closed quite a few bugs related to community reports. If you're trying out public dashboards, let us know how it is going in the [open discussion in GitHub](https://github.com/grafana/grafana/discussions/49253). diff --git a/docs/sources/whatsnew/whats-new-in-v9-3.md b/docs/sources/whatsnew/whats-new-in-v9-3.md index f780b4890af..929381d679b 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-3.md +++ b/docs/sources/whatsnew/whats-new-in-v9-3.md @@ -87,7 +87,7 @@ Annotations are now supported in public dashboards, with the exception of query Note that because Public Dashboards is an experimental feature, you need to enable it in Grafana using the `publicDashboards` [feature toggle]({{< relref "../setup-grafana/configure-grafana#feature_toggles" >}}), or open a support ticket requesting public dashboards if you are a Cloud Advanced customer. -To learn more about public dashboards, refer to [Public dashboards]({{< relref "../dashboards/dashboard-public" >}}). +To learn more about public dashboards, refer to [Public dashboards](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/). ## New transformation: Partition by values From 10a1bee86dc85dfe329f37dad20f13e9682f2672 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:43:47 -0400 Subject: [PATCH 042/102] Docs: Update use dashboard image for sharing redesign (#92957) --- docs/sources/dashboards/use-dashboards/index.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/sources/dashboards/use-dashboards/index.md b/docs/sources/dashboards/use-dashboards/index.md index f11d1f4df60..a2be205471a 100644 --- a/docs/sources/dashboards/use-dashboards/index.md +++ b/docs/sources/dashboards/use-dashboards/index.md @@ -66,6 +66,11 @@ refs: destination: /docs/grafana//panels-visualizations/panel-overview/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/visualizations/panels-visualizations/panel-overview/ + export-dashboards: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/share-dashboards-panels/#export-dashboards + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels/#export-dashboards --- # Use dashboards @@ -80,15 +85,17 @@ The dashboard user interface provides a number of features that you can use to c The following image and descriptions highlight all dashboard features. -![An annotated image of a dashboard](/media/docs/grafana/dashboards/screenshot-dashboard-annotated-11.2.png) +![An annotated image of a dashboard](/media/docs/grafana/dashboards/screenshot-dashboard-no-toggle-annotated-11.3.png) 1. **Grafana home** - Click **Home** in the breadcrumb to go to the home page configured in the Grafana instance. 1. **Dashboard folder** - When you click the dashboard folder name, you can search for other dashboards contained in the folder and perform other [folder management tasks](ref:dashboard-folders). 1. **Dashboard title** - You can create your own dashboard titles or have Grafana create them for you using [generative AI features](ref:generative-ai-features). 1. **Mark as favorite** - Mark the dashboard as one of your favorites so it's included in your list of **Starred** dashboards in the main menu. +1. **Public label** - When you share a dashboard externally, it's marked with the **Public** label. 1. **Dashboard insights** - Click to view analytics about your dashboard including information about users, activity, query counts. Learn more about [dashboard analytics](ref:dashboard-analytics). -1. **Share dashboard** - Access several [dashboard sharing](ref:sharing) options. 1. **Edit** - Click to leave view-only mode and enter edit mode, where you can make changes directly to the dashboard and access dashboard settings, as well as several panel editing functions. +1. **Export** - Access [dashboard exporting](ref:export-dashboards) options. +1. **Share dashboard** - Access several [dashboard sharing](ref:sharing) options. 1. **Kiosk mode** - Click to display the dashboard on a large screen such as a TV or a kiosk. Kiosk mode hides elements such as navigation menus. Learn more about kiosk mode in our [How to Create Kiosks to Display Dashboards on a TV blog post](https://grafana.com/blog/2019/05/02/grafana-tutorial-how-to-create-kiosks-to-display-dashboards-on-a-tv/). Press `Enter` to leave kiosk mode. 1. **Variables** - Use [variables](ref:variables) to create more interactive and dynamic dashboards. 1. **Dashboard links** - Link to other dashboards, panels, and external websites. Learn more about [dashboard links](ref:dashboard-links). From 267f4179245c8d4dbdfaebc550c27a5cd45d4890 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:47:33 -0400 Subject: [PATCH 043/102] Docs: update docs to reflect new sharing UI - 2 (#93002) --- .../custom-role-actions-scopes/index.md | 2 +- .../rbac-fixed-basic-role-definitions/index.md | 2 +- .../panels-visualizations/panel-overview/index.md | 14 +++++++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md index 396ad7dea81..7f3f60f1c96 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md @@ -79,7 +79,7 @@ The following list contains role-based access control actions. | `dashboards.permissions:write` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Update permissions for one or more dashboards. | | `dashboards:read` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Read one or more dashboards. | | `dashboards:write` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Update one or more dashboards. | -| `dashboards.public:write` |
  • `dashboards:*`
  • `dashboards:uid:*`
| Write public dashboard configuration. | +| `dashboards.public:write` |
  • `dashboards:*`
  • `dashboards:uid:*`
| Write shared dashboard configuration. | | `datasources.caching:read` |
  • `datasources:*`
  • `datasources:uid:*`
| Read data source query caching settings. | | `datasources.caching:write` |
  • `datasources:*`
  • `datasources:uid:*`
| Update data source query caching settings. | | `datasources:create` | None | Create data sources. | diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md index 68ea28a7814..ccc283db650 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md @@ -98,7 +98,7 @@ To learn how to use the roles API to determine the role UUIDs, refer to [Manage | `fixed:dashboards.insights:reader` | `fixed_JlBJ2_gizP8zhgaeGE2rjyZe2Rs` | `dashboards.insights:read` | Read dashboard insights data and see presence indicators. | | `fixed:dashboards.permissions:reader` | `fixed_f17oxuXW_58LL8mYJsm4T_mCeIw` | `dashboards.permissions:read` | Read all dashboard permissions. | | `fixed:dashboards.permissions:writer` | `fixed_CcznxhWX_Yqn8uWMXMQ-b5iFW9k` | All permissions from `fixed:dashboards.permissions:reader` and
`dashboards.permissions:write` | Read and update all dashboard permissions. | -| `fixed:dashboards.public:writer` | `fixed_f_GHHRBciaqESXfGz2oCcooqHxs` | `dashboards.public:write` | Create, update, delete or pause a public dashboard. | +| `fixed:dashboards.public:writer` | `fixed_f_GHHRBciaqESXfGz2oCcooqHxs` | `dashboards.public:write` | Create, update, delete or pause a shared dashboard. | | `fixed:datasources:creator` | `fixed_XX8jHREgUt-wo1A-rPXIiFlX6Zw` | `datasources:create` | Create data sources. | | `fixed:datasources:explorer` | `fixed_qDzW9mzx9yM91T5Bi8dHUM2muTw` | `datasources:explore` | Enable the Explore feature. Data source permissions still apply, you can only query data sources for which you have query permissions. | | `fixed:datasources:reader` | `fixed_C2x8IxkiBc1KZVjyYH775T9jNMQ` | `datasources:read`
`datasources:query` | Read and query data sources. | diff --git a/docs/sources/panels-visualizations/panel-overview/index.md b/docs/sources/panels-visualizations/panel-overview/index.md index 5816a6f1ce8..7acc0cf1ff1 100644 --- a/docs/sources/panels-visualizations/panel-overview/index.md +++ b/docs/sources/panels-visualizations/panel-overview/index.md @@ -140,7 +140,7 @@ To access the panel editor, hover over the top-right corner of any panel. Click - **View**: View the panel in full screen. - **Edit**: Open the panel editor to edit panel and visualization options. -- **Share**: Share the panel as a link, embed, or library panel. +- **Share**: Share the panel as a link, embed, or snapshot. - **Explore**: Open the panel in **Explore**, where you can focus on your query. - **Inspect**: Open the **Inspect** drawer, where you can review the panel data, stats, metadata, JSON, and query. - **Data**: Open the **Inspect** drawer in the **Data** tab. @@ -150,8 +150,8 @@ To access the panel editor, hover over the top-right corner of any panel. Click - **More**: Access other panel actions. - **Duplicate**: Make a copy of the panel. Duplicated panels query data separately from the original panel. You can use the special `Dashboard` data source to [share the same query results across panels](ref:share) instead. - **Copy**: Copy the panel to the clipboard. - - **Create library panel**: Create a panel that can be imported into other dashboards. - - **Create alert**: Open the alert rule configuration page in **Alerting**, where you can [create a Grafana-managed alert](ref:create) based on the panel queries. + - **New library panel**: Create a panel that can be imported into other dashboards. + - **New alert rule**: Open the alert rule configuration page in **Alerting**, where you can [create a Grafana-managed alert](ref:create) based on the panel queries. - **Hide legend**: Hide the panel legend. - **Get help**: Send a snapshot or panel data to Grafana Labs Technical Support. - **Remove**: Remove the panel from the dashboard. @@ -164,10 +164,14 @@ By hovering over a panel with the mouse you can use some shortcuts that will tar - `e`: Toggle panel edit view - `v`: Toggle panel fullscreen view -- `ps`: Open Panel Share Modal +- `pu`: Share link +- `pe`: Share embed +- `ps`: Share snapshot +- `px`: Open panel in **Explore** - `pd`: Duplicate Panel +- `i`: Inspect +- `pl`: Hide or show legend - `pr`: Remove Panel -- `pl`: Toggle panel legend ## Add a panel From b6906cc866effbafa60e72d474abb1351084edb5 Mon Sep 17 00:00:00 2001 From: linoman <2051016+linoman@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:59:57 +0200 Subject: [PATCH 044/102] SSO LDAP: ironing details (#93584) * Rework description tooltip * Null protect empty * Add missing ref properties for form controllers * Rework LDAP provider label * Add missing `*` for required input * Hide multiselect visual queues * Add modal * Adjust translated text hierarchies * Add redirect confirmation modal --- public/app/features/admin/ldap/LdapDrawer.tsx | 31 +++++--- .../features/admin/ldap/LdapSettingsPage.tsx | 77 +++++++++++++++---- .../auth-config/AuthProvidersListPage.tsx | 8 +- public/locales/en-US/grafana.json | 28 ++++--- public/locales/pseudo-LOCALE/grafana.json | 28 ++++--- 5 files changed, 116 insertions(+), 56 deletions(-) diff --git a/public/app/features/admin/ldap/LdapDrawer.tsx b/public/app/features/admin/ldap/LdapDrawer.tsx index 42b3abdc660..caa5edefa1f 100644 --- a/public/app/features/admin/ldap/LdapDrawer.tsx +++ b/public/app/features/admin/ldap/LdapDrawer.tsx @@ -60,7 +60,7 @@ export const LdapDrawerComponent = ({ useEffect(() => { const { client_cert, client_key, root_ca_cert } = getValues(serverConfig); setEncryptionProvider( - !client_cert.length && !client_key.length && !root_ca_cert?.length + !client_cert?.length && !client_key?.length && !root_ca_cert?.length ? EncryptionProvider.Base64 : EncryptionProvider.FilePath ); @@ -83,19 +83,21 @@ export const LdapDrawerComponent = ({ ); const useTlsDescription = ( - - For a complete list of supported ciphers and TLS versions, refer to:{' '} - { - - https://go.dev/src/crypto/tls/cipher_suites.go - - } - + <> + + For a complete list of supported ciphers and TLS versions, refer to: + {' '} + {/* eslint-disable-next-line @grafana/no-untranslated-strings */} + + https://go.dev/src/crypto/tls/cipher_suites.go + + ); const onAddGroupMapping = () => { + const groupMappings = getValues(`${serverConfig}.group_mappings`) || []; setValue(`${serverConfig}.group_mappings`, [ - ...getValues(`${serverConfig}.group_mappings`), + ...groupMappings, { group_dn: '', org_id: 1, @@ -338,10 +340,12 @@ export const LdapDrawerComponent = ({ ( + render={({ field: { onChange, ref, value, ...field } }) => ( onChange(v.map(({ value }) => String(value)))} value={value?.map((v) => ({ label: renderMultiSelectLabel(v), value: v }))} /> @@ -447,5 +451,10 @@ function getStyles(theme: GrafanaTheme2) { button: css({ marginBottom: theme.spacing(4), }), + multiSelect: css({ + svg: { + display: 'none', + }, + }), }; } diff --git a/public/app/features/admin/ldap/LdapSettingsPage.tsx b/public/app/features/admin/ldap/LdapSettingsPage.tsx index 7b7335cc649..736a40a76c5 100644 --- a/public/app/features/admin/ldap/LdapSettingsPage.tsx +++ b/public/app/features/admin/ldap/LdapSettingsPage.tsx @@ -4,7 +4,7 @@ import { Controller, FormProvider, useForm } from 'react-hook-form'; import { connect } from 'react-redux'; import { AppEvents, GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { getBackendSrv, getAppEvents } from '@grafana/runtime'; +import { getBackendSrv, getAppEvents, reportInteraction } from '@grafana/runtime'; import { useStyles2, Alert, @@ -15,12 +15,14 @@ import { Input, LinkButton, Menu, + Modal, Stack, Text, TextLink, Dropdown, MultiSelect, } from '@grafana/ui'; +import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt'; import { Page } from 'app/core/components/Page/Page'; import config from 'app/core/config'; import { t, Trans } from 'app/core/internationalization'; @@ -94,6 +96,7 @@ const emptySettings: LdapPayload = { export const LdapSettingsPage = () => { const [isLoading, setIsLoading] = useState(true); const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); const [mapKeyCertConfigured, setMapKeyCertConfigured] = useState({ // values @@ -107,14 +110,25 @@ export const LdapSettingsPage = () => { }); const methods = useForm({ defaultValues: emptySettings }); - const { control, getValues, handleSubmit, register, reset, watch } = methods; + const { + control, + formState: { isDirty }, + getValues, + handleSubmit, + register, + reset, + watch, + } = methods; const styles = useStyles2(getStyles); useEffect(() => { async function init() { const payload = await getSettings(); - const serverConfig = payload.settings.config.servers[0]; + let serverConfig = emptySettings.settings.config.servers[0]; + if (payload.settings.config.servers?.length > 0) { + serverConfig = payload.settings.config.servers[0]; + } setMapKeyCertConfigured({ rootCaCertValue: serverConfig.root_ca_cert_value?.length > 0, clientCertValue: serverConfig.client_cert_value !== '', @@ -203,12 +217,14 @@ export const LdapSettingsPage = () => { /** * Button's Actions */ - const submitAndEnableLdapSettings = (payload: LdapPayload) => { + const submitAndEnableLdapSettings = async (payload: LdapPayload) => { payload.settings.enabled = true; - putPayload(payload); + await putPayload(payload); + reportInteraction('authentication_ldap_enabled'); }; - const saveForm = () => { - putPayload(getValues()); + const saveForm = async () => { + await putPayload(getValues()); + reportInteraction('authentication_ldap_saved'); }; const deleteLDAPConfig = async () => { try { @@ -220,6 +236,7 @@ export const LdapSettingsPage = () => { payload: [t('ldap-settings-page.alert.discard-success', 'LDAP settings discarded')], }); reset(payload); + reportInteraction('authentication_ldap_deleted'); } catch (error) { appEvents.publish({ type: AppEvents.alertError.name, @@ -230,6 +247,10 @@ export const LdapSettingsPage = () => { } }; + const onDiscard = () => { + reportInteraction('authentication_ldap_abandoned'); + }; + const subTitle = ( The LDAP integration in Grafana allows your Grafana users to log in with their LDAP credentials. Find out more in @@ -259,6 +280,7 @@ export const LdapSettingsPage = () => { {config.disableLoginForm && disabledFormAlert}
+ {isLoading && } {!isLoading && (
@@ -266,7 +288,7 @@ export const LdapSettingsPage = () => { Basic Settings { /> { ( + render={({ field: { onChange, ref, ...field } }) => ( onChange(v.map(({ value }) => String(value)))} /> )} @@ -346,20 +370,20 @@ export const LdapSettingsPage = () => { - Discard + Discard { + setIsModalOpen(false)} + > + + + Are you sure you want to abandon the changes you‘ve made to the LDAP configuration? All changes will + be lost. + + + + + + + ); }; @@ -401,6 +445,11 @@ function getStyles(theme: GrafanaTheme2) { form: css({ width: theme.spacing(68), }), + multiSelect: css({ + svg: { + display: 'none', + }, + }), }; } diff --git a/public/app/features/auth-config/AuthProvidersListPage.tsx b/public/app/features/auth-config/AuthProvidersListPage.tsx index 560c1a3a79a..717906f0f3a 100644 --- a/public/app/features/auth-config/AuthProvidersListPage.tsx +++ b/public/app/features/auth-config/AuthProvidersListPage.tsx @@ -57,7 +57,13 @@ export const AuthConfigPageUnconnected = ({ providers = providers.map((p) => { if (p.provider === 'ldap') { - p.settings.type = p.provider; + return { + ...p, + settings: { + ...p.settings, + type: 'LDAP', + }, + }; } return p; }); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index b20956af416..66dd73190d3 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1091,7 +1091,7 @@ "tls-ciphers-placeholder": "e.g. [\"TLS_AES_256_GCM_SHA384\"]", "use-ssl-description": "Set to true if LDAP server should use TLS connection (either with STARTTLS or LDAPS)", "use-ssl-label": "Use SSL", - "use-ssl-tooltip": "For a complete list of supported ciphers and TLS versions, refer to: {\n \n https://go.dev/src/crypto/tls/cipher_suites.go\n }" + "use-ssl-tooltip": "For a complete list of supported ciphers and TLS versions, refer to:" }, "group-mapping": { "grafana-admin": { @@ -1141,9 +1141,7 @@ }, "ldap-settings-page": { "advanced-settings-section": { - "edit": { - "button": "Edit" - }, + "edit-button": "Edit", "subtitle": "Mappings, extra security measures, and more.", "title": "Advanced Settings" }, @@ -1164,20 +1162,20 @@ "label": "Bind password" }, "buttons-section": { - "discard": { - "button": "Discard" - }, - "save": { - "button": "Save" - }, - "save-and-enable": { - "button": "Save and enable" - } + "discard-button": "Discard", + "save-and-enable-button": "Save and enable", + "save-button": "Save" + }, + "discard-modal": { + "cancel-button": "Back to editing", + "description": "Are you sure you want to abandon the changes you‘ve made to the LDAP configuration? All changes will be lost.", + "discard-button": "Abandon LDAP", + "title": "Leave LDAP configuration?" }, "documentation": "documentation", "host": { "description": "Hostname or IP address of the LDAP server you wish to connect to.", - "label": "Server host", + "label": "Server host *", "placeholder": "example: 127.0.0.1" }, "login-form-alert": { @@ -1186,7 +1184,7 @@ }, "search_filter": { "description": "LDAP search filter used to locate specific entries within the directory.", - "label": "Search filter*", + "label": "Search filter *", "placeholder": "example: cn=%s" }, "search-base-dns": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 8ac07e0c62d..9cb08bd3fc4 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1091,7 +1091,7 @@ "tls-ciphers-placeholder": "ę.ģ. [\"ŦĿŜ_ÅĒŜ_256_ĞCM_ŜĦÅ384\"]", "use-ssl-description": "Ŝęŧ ŧő ŧřūę įƒ ĿĐÅP şęřvęř şĥőūľđ ūşę ŦĿŜ čőʼnʼnęčŧįőʼn (ęįŧĥęř ŵįŧĥ ŜŦÅŖŦŦĿŜ őř ĿĐÅPŜ)", "use-ssl-label": "Ůşę ŜŜĿ", - "use-ssl-tooltip": "Főř ä čőmpľęŧę ľįşŧ őƒ şūppőřŧęđ čįpĥęřş äʼnđ ŦĿŜ vęřşįőʼnş, řęƒęř ŧő: {\n <ŦęχŧĿįʼnĸ şŧyľę={{ fontSize: 'inherit' }} ĥřęƒ=\"ĥŧŧpş://ģő.đęv/şřč/čřypŧő/ŧľş/čįpĥęř_şūįŧęş.ģő\" ęχŧęřʼnäľ>\n ĥŧŧpş://ģő.đęv/şřč/čřypŧő/ŧľş/čįpĥęř_şūįŧęş.ģő\n }" + "use-ssl-tooltip": "Főř ä čőmpľęŧę ľįşŧ őƒ şūppőřŧęđ čįpĥęřş äʼnđ ŦĿŜ vęřşįőʼnş, řęƒęř ŧő:" }, "group-mapping": { "grafana-admin": { @@ -1141,9 +1141,7 @@ }, "ldap-settings-page": { "advanced-settings-section": { - "edit": { - "button": "Ēđįŧ" - }, + "edit-button": "Ēđįŧ", "subtitle": "Mäppįʼnģş, ęχŧřä şęčūřįŧy męäşūřęş, äʼnđ mőřę.", "title": "Åđväʼnčęđ Ŝęŧŧįʼnģş" }, @@ -1164,20 +1162,20 @@ "label": "ßįʼnđ päşşŵőřđ" }, "buttons-section": { - "discard": { - "button": "Đįşčäřđ" - }, - "save": { - "button": "Ŝävę" - }, - "save-and-enable": { - "button": "Ŝävę äʼnđ ęʼnäþľę" - } + "discard-button": "Đįşčäřđ", + "save-and-enable-button": "Ŝävę äʼnđ ęʼnäþľę", + "save-button": "Ŝävę" + }, + "discard-modal": { + "cancel-button": "ßäčĸ ŧő ęđįŧįʼnģ", + "description": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő äþäʼnđőʼn ŧĥę čĥäʼnģęş yőū&ľşqūő;vę mäđę ŧő ŧĥę ĿĐÅP čőʼnƒįģūřäŧįőʼn? Åľľ čĥäʼnģęş ŵįľľ þę ľőşŧ.", + "discard-button": "Åþäʼnđőʼn ĿĐÅP", + "title": "Ŀęävę ĿĐÅP čőʼnƒįģūřäŧįőʼn?" }, "documentation": "đőčūmęʼnŧäŧįőʼn", "host": { "description": "Ħőşŧʼnämę őř ĨP äđđřęşş őƒ ŧĥę ĿĐÅP şęřvęř yőū ŵįşĥ ŧő čőʼnʼnęčŧ ŧő.", - "label": "Ŝęřvęř ĥőşŧ", + "label": "Ŝęřvęř ĥőşŧ *", "placeholder": "ęχämpľę: 127.0.0.1" }, "login-form-alert": { @@ -1186,7 +1184,7 @@ }, "search_filter": { "description": "ĿĐÅP şęäřčĥ ƒįľŧęř ūşęđ ŧő ľőčäŧę şpęčįƒįč ęʼnŧřįęş ŵįŧĥįʼn ŧĥę đįřęčŧőřy.", - "label": "Ŝęäřčĥ ƒįľŧęř*", + "label": "Ŝęäřčĥ ƒįľŧęř *", "placeholder": "ęχämpľę: čʼn=%ş" }, "search-base-dns": { From 682a4073b4428f3bd1a4c91267e6af83467496b6 Mon Sep 17 00:00:00 2001 From: Juan Cabanas Date: Wed, 25 Sep 2024 16:03:30 +0200 Subject: [PATCH 045/102] ShareDrawer: Add share button tracking (#93742) --- .../dashboard-scene/sharing/ShareButton/ShareButton.tsx | 3 ++- public/app/features/dashboard-scene/utils/interactions.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareButton.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareButton.tsx index 0d8363c9d1b..f5574ea89f9 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareButton.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareButton.tsx @@ -18,12 +18,13 @@ export default function ShareButton({ dashboard, panel }: { dashboard: Dashboard const [isOpen, setIsOpen] = useState(false); const [_, buildUrl] = useAsyncFn(async () => { + DashboardInteractions.toolbarShareClick(); return await buildShareUrl(dashboard, panel); }, [dashboard]); const onMenuClick = useCallback((isOpen: boolean) => { if (isOpen) { - DashboardInteractions.toolbarShareClick(); + DashboardInteractions.toolbarShareDropdownClick(); } setIsOpen(isOpen); diff --git a/public/app/features/dashboard-scene/utils/interactions.ts b/public/app/features/dashboard-scene/utils/interactions.ts index fef5f346551..6ed95fe6cb8 100644 --- a/public/app/features/dashboard-scene/utils/interactions.ts +++ b/public/app/features/dashboard-scene/utils/interactions.ts @@ -28,6 +28,9 @@ export const DashboardInteractions = { toolbarShareClick: () => { reportDashboardInteraction('toolbar_actions_clicked', { item: 'share' }); }, + toolbarShareDropdownClick: () => { + reportDashboardInteraction('toolbar_actions_clicked', { item: 'share_dropdown' }); + }, toolbarAddClick: () => { reportDashboardInteraction('toolbar_actions_clicked', { item: 'add' }); }, From d24d59299b0af2c23bc92936126fa07310851de7 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed, 25 Sep 2024 10:15:35 -0400 Subject: [PATCH 046/102] Docs: Change weight of sharing page (#93746) --- docs/sources/dashboards/share-dashboards-panels/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/dashboards/share-dashboards-panels/_index.md b/docs/sources/dashboards/share-dashboards-panels/_index.md index 6043351cc4f..26b269b1231 100644 --- a/docs/sources/dashboards/share-dashboards-panels/_index.md +++ b/docs/sources/dashboards/share-dashboards-panels/_index.md @@ -30,7 +30,7 @@ labels: menuTitle: Sharing title: Share dashboards and panels description: Share Grafana dashboards and panels within your organization and publicly -weight: 85 +weight: 650 refs: image-rendering: - pattern: /docs/grafana/ From 40ce035ed793faad3149c2bbd109499e9204605a Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Wed, 25 Sep 2024 16:16:59 +0200 Subject: [PATCH 047/102] LibraryElements: remove viewer check when connecting a dashboard on General folder (#93737) --- pkg/services/libraryelements/guard.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/services/libraryelements/guard.go b/pkg/services/libraryelements/guard.go index 41df9c47049..38479965753 100644 --- a/pkg/services/libraryelements/guard.go +++ b/pkg/services/libraryelements/guard.go @@ -58,7 +58,7 @@ func (l *LibraryElementService) requireEditPermissionsOnFolder(ctx context.Conte } func (l *LibraryElementService) requireViewPermissionsOnFolder(ctx context.Context, user identity.Requester, folderID int64) error { - if isGeneralFolder(folderID) && user.HasRole(org.RoleViewer) { + if isGeneralFolder(folderID) { return nil } From 2651ce5dce0a3a16fd82daaf03d01d46541188d5 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:22:50 +0100 Subject: [PATCH 048/102] I18n: Download translations from Crowdin (#93744) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/de-DE/grafana.json | 22 ++++++++++------------ public/locales/es-ES/grafana.json | 22 ++++++++++------------ public/locales/fr-FR/grafana.json | 22 ++++++++++------------ public/locales/pt-BR/grafana.json | 22 ++++++++++------------ public/locales/zh-Hans/grafana.json | 22 ++++++++++------------ 5 files changed, 50 insertions(+), 60 deletions(-) diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 039bc88a41f..e1a2fccdd30 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -1141,9 +1141,7 @@ }, "ldap-settings-page": { "advanced-settings-section": { - "edit": { - "button": "" - }, + "edit-button": "", "subtitle": "", "title": "" }, @@ -1164,15 +1162,15 @@ "label": "" }, "buttons-section": { - "discard": { - "button": "" - }, - "save": { - "button": "" - }, - "save-and-enable": { - "button": "" - } + "discard-button": "", + "save-and-enable-button": "", + "save-button": "" + }, + "discard-modal": { + "cancel-button": "", + "description": "", + "discard-button": "", + "title": "" }, "documentation": "", "host": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 38ecdacc400..8292e1f6e37 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -1141,9 +1141,7 @@ }, "ldap-settings-page": { "advanced-settings-section": { - "edit": { - "button": "" - }, + "edit-button": "", "subtitle": "", "title": "" }, @@ -1164,15 +1162,15 @@ "label": "" }, "buttons-section": { - "discard": { - "button": "" - }, - "save": { - "button": "" - }, - "save-and-enable": { - "button": "" - } + "discard-button": "", + "save-and-enable-button": "", + "save-button": "" + }, + "discard-modal": { + "cancel-button": "", + "description": "", + "discard-button": "", + "title": "" }, "documentation": "", "host": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 44377b1b2d6..f426bc2a362 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -1141,9 +1141,7 @@ }, "ldap-settings-page": { "advanced-settings-section": { - "edit": { - "button": "" - }, + "edit-button": "", "subtitle": "", "title": "" }, @@ -1164,15 +1162,15 @@ "label": "" }, "buttons-section": { - "discard": { - "button": "" - }, - "save": { - "button": "" - }, - "save-and-enable": { - "button": "" - } + "discard-button": "", + "save-and-enable-button": "", + "save-button": "" + }, + "discard-modal": { + "cancel-button": "", + "description": "", + "discard-button": "", + "title": "" }, "documentation": "", "host": { diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 23d4453a4fb..bb74869005e 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -1141,9 +1141,7 @@ }, "ldap-settings-page": { "advanced-settings-section": { - "edit": { - "button": "" - }, + "edit-button": "", "subtitle": "", "title": "" }, @@ -1164,15 +1162,15 @@ "label": "" }, "buttons-section": { - "discard": { - "button": "" - }, - "save": { - "button": "" - }, - "save-and-enable": { - "button": "" - } + "discard-button": "", + "save-and-enable-button": "", + "save-button": "" + }, + "discard-modal": { + "cancel-button": "", + "description": "", + "discard-button": "", + "title": "" }, "documentation": "", "host": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 45cc6f1b0e2..17cf9eb4440 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -1133,9 +1133,7 @@ }, "ldap-settings-page": { "advanced-settings-section": { - "edit": { - "button": "" - }, + "edit-button": "", "subtitle": "", "title": "" }, @@ -1156,15 +1154,15 @@ "label": "" }, "buttons-section": { - "discard": { - "button": "" - }, - "save": { - "button": "" - }, - "save-and-enable": { - "button": "" - } + "discard-button": "", + "save-and-enable-button": "", + "save-button": "" + }, + "discard-modal": { + "cancel-button": "", + "description": "", + "discard-button": "", + "title": "" }, "documentation": "", "host": { From 1f7457c02ca2d45f5b394353fc07f04be9ce8719 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Wed, 25 Sep 2024 16:27:17 +0200 Subject: [PATCH 049/102] New Select: Extract floating ui setup into hook (#93387) * New Select: Extratc floating ui setup into hook * Remove unused exports * Rename exported floatStyles * Set maxHeight instead of using js to find it * Extarct into seperate file --- .../src/components/Combobox/Combobox.tsx | 97 +------------------ .../components/Combobox/getComboboxStyles.ts | 8 +- .../components/Combobox/useComboboxFloat.ts | 96 ++++++++++++++++++ 3 files changed, 105 insertions(+), 96 deletions(-) create mode 100644 packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index cb8a5f7bbba..33eeed308eb 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -1,8 +1,7 @@ import { cx } from '@emotion/css'; -import { autoUpdate, flip, size, useFloating } from '@floating-ui/react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useCombobox } from 'downshift'; -import { SetStateAction, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { useCallback, useId, useMemo, useState } from 'react'; import { useStyles2 } from '../../themes'; import { t } from '../../utils/i18n'; @@ -10,6 +9,7 @@ import { Icon } from '../Icon/Icon'; import { Input, Props as InputProps } from '../Input/Input'; import { getComboboxStyles } from './getComboboxStyles'; +import { estimateSize, useComboboxFloat } from './useComboboxFloat'; export type ComboboxOption = { label: string; @@ -42,16 +42,6 @@ function itemFilter(inputValue: string) { }; } -function estimateSize() { - return 45; -} - -const MIN_HEIGHT = 400; -// On every 100th index we will recalculate the width of the popover. -const INDEX_WIDTH_CALCULATION = 100; -// A multiplier guesstimate times the amount of characters. If any padding or image support etc. is added this will need to be updated. -const WIDTH_MULTIPLIER = 7.3; - /** * A performant Select replacement. * @@ -97,15 +87,10 @@ export const Combobox = ({ return null; }, [selectedItemIndex, options, value]); - const inputRef = useRef(null); - const floatingRef = useRef(null); - const menuId = `downshift-${useId().replace(/:/g, '--')}-menu`; const labelId = `downshift-${useId().replace(/:/g, '--')}-label`; const styles = useStyles2(getComboboxStyles); - const [popoverMaxWidth, setPopoverMaxWidth] = useState(undefined); - const [popoverWidth, setPopoverWidth] = useState(undefined); const virtualizerOptions = { count: items.length, @@ -167,38 +152,12 @@ export const Combobox = ({ } }, }); + const { inputRef, floatingRef, floatStyles } = useComboboxFloat(items, rowVirtualizer.range, isOpen); const onBlur = useCallback(() => { setInputValue(selectedItem?.label ?? value?.toString() ?? ''); }, [selectedItem, setInputValue, value]); - // the order of middleware is important! - const middleware = [ - flip({ - // see https://floating-ui.com/docs/flip#combining-with-shift - crossAxis: true, - boundary: document.body, - }), - size({ - apply({ availableWidth }) { - setPopoverMaxWidth(availableWidth); - }, - }), - ]; - const elements = { reference: inputRef.current, floating: floatingRef.current }; - const { floatingStyles } = useFloating({ - strategy: 'fixed', - open: isOpen, - placement: 'bottom-start', - middleware, - elements, - whileElementsMounted: autoUpdate, - }); - - const hasMinHeight = isOpen && rowVirtualizer.getTotalSize() >= MIN_HEIGHT; - - useDynamicWidth(items, rowVirtualizer.range, setPopoverWidth); - return (
({ })} />
({
); }; - -const useDynamicWidth = ( - items: Array>, - range: { startIndex: number; endIndex: number } | null, - setPopoverWidth: { (value: SetStateAction): void } -) => { - useEffect(() => { - if (range === null) { - return; - } - const startVisibleIndex = range?.startIndex; - const endVisibleIndex = range?.endIndex; - - if (typeof startVisibleIndex === 'undefined' || typeof endVisibleIndex === 'undefined') { - return; - } - - // Scroll down and default case - if ( - startVisibleIndex === 0 || - (startVisibleIndex % INDEX_WIDTH_CALCULATION === 0 && startVisibleIndex >= INDEX_WIDTH_CALCULATION) - ) { - let maxLength = 0; - const calculationEnd = Math.min(items.length, endVisibleIndex + INDEX_WIDTH_CALCULATION); - - for (let i = startVisibleIndex; i < calculationEnd; i++) { - maxLength = Math.max(maxLength, items[i].label.length); - } - - setPopoverWidth(maxLength * WIDTH_MULTIPLIER); - } else if (endVisibleIndex % INDEX_WIDTH_CALCULATION === 0 && endVisibleIndex >= INDEX_WIDTH_CALCULATION) { - // Scroll up case - let maxLength = 0; - const calculationStart = Math.max(0, startVisibleIndex - INDEX_WIDTH_CALCULATION); - - for (let i = calculationStart; i < endVisibleIndex; i++) { - maxLength = Math.max(maxLength, items[i].label.length); - } - - setPopoverWidth(maxLength * WIDTH_MULTIPLIER); - } - }, [items, range, setPopoverWidth]); -}; diff --git a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts index ae5c94bc409..d6de3c76dfd 100644 --- a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts +++ b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts @@ -2,6 +2,8 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; +const MAX_HEIGHT = 400; + export const getComboboxStyles = (theme: GrafanaTheme2) => { return { menuClosed: css({ @@ -12,10 +14,8 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => { background: theme.components.dropdown.background, boxShadow: theme.shadows.z3, zIndex: theme.zIndex.dropdown, - }), - menuHeight: css({ - height: 400, - overflowY: 'scroll', + maxHeight: MAX_HEIGHT, + overflowY: 'auto', position: 'relative', }), menuUlContainer: css({ diff --git a/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts b/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts new file mode 100644 index 00000000000..bdac3088f96 --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts @@ -0,0 +1,96 @@ +import { autoUpdate, flip, size, useFloating } from '@floating-ui/react'; +import { useEffect, useRef, useState } from 'react'; + +import { ComboboxOption } from './Combobox'; + +// On every 100th index we will recalculate the width of the popover. +const INDEX_WIDTH_CALCULATION = 100; +// A multiplier guesstimate times the amount of characters. If any padding or image support etc. is added this will need to be updated. +const WIDTH_MULTIPLIER = 7.3; + +/** + * Used with Downshift to get the height of each item + */ +export function estimateSize() { + return 45; +} + +export const useComboboxFloat = ( + items: Array>, + range: { startIndex: number; endIndex: number } | null, + isOpen: boolean +) => { + const inputRef = useRef(null); + const floatingRef = useRef(null); + const [popoverWidth, setPopoverWidth] = useState(undefined); + const [popoverMaxWidth, setPopoverMaxWidth] = useState(undefined); + + // the order of middleware is important! + const middleware = [ + flip({ + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: true, + boundary: document.body, + }), + size({ + apply({ availableWidth }) { + setPopoverMaxWidth(availableWidth); + }, + }), + ]; + const elements = { reference: inputRef.current, floating: floatingRef.current }; + const { floatingStyles } = useFloating({ + strategy: 'fixed', + open: isOpen, + placement: 'bottom-start', + middleware, + elements, + whileElementsMounted: autoUpdate, + }); + + useEffect(() => { + if (range === null) { + return; + } + const startVisibleIndex = range?.startIndex; + const endVisibleIndex = range?.endIndex; + + if (typeof startVisibleIndex === 'undefined' || typeof endVisibleIndex === 'undefined') { + return; + } + + // Scroll down and default case + if ( + startVisibleIndex === 0 || + (startVisibleIndex % INDEX_WIDTH_CALCULATION === 0 && startVisibleIndex >= INDEX_WIDTH_CALCULATION) + ) { + let maxLength = 0; + const calculationEnd = Math.min(items.length, endVisibleIndex + INDEX_WIDTH_CALCULATION); + + for (let i = startVisibleIndex; i < calculationEnd; i++) { + maxLength = Math.max(maxLength, items[i].label.length); + } + + setPopoverWidth(maxLength * WIDTH_MULTIPLIER); + } else if (endVisibleIndex % INDEX_WIDTH_CALCULATION === 0 && endVisibleIndex >= INDEX_WIDTH_CALCULATION) { + // Scroll up case + let maxLength = 0; + const calculationStart = Math.max(0, startVisibleIndex - INDEX_WIDTH_CALCULATION); + + for (let i = calculationStart; i < endVisibleIndex; i++) { + maxLength = Math.max(maxLength, items[i].label.length); + } + + setPopoverWidth(maxLength * WIDTH_MULTIPLIER); + } + }, [items, range, setPopoverWidth]); + + const floatStyles = { + ...floatingStyles, + width: popoverWidth, + maxWidth: popoverMaxWidth, + minWidth: inputRef.current?.offsetWidth, + }; + + return { inputRef, floatingRef, floatStyles }; +}; From 5880579be83206bb748d2b02caf7737093679fb2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 17:30:12 +0300 Subject: [PATCH 050/102] Update dependency sass to v1.79.3 (#93741) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-prometheus/package.json | 2 +- yarn.lock | 32 ++++++++++++++++++------ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 1eb4d557005..335da9c514c 100644 --- a/package.json +++ b/package.json @@ -222,7 +222,7 @@ "redux-mock-store": "1.5.4", "rimraf": "6.0.1", "rudder-sdk-js": "2.48.18", - "sass": "1.78.0", + "sass": "1.79.3", "sass-loader": "14.2.1", "smtp-tester": "^2.1.0", "style-loader": "4.0.0", diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index d9652d00f9c..6ef2c9afa99 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -131,7 +131,7 @@ "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "5.0.0", "rollup-plugin-node-externals": "^5.0.0", - "sass": "1.78.0", + "sass": "1.79.3", "sass-loader": "14.2.1", "style-loader": "4.0.0", "testing-library-selector": "0.3.1", diff --git a/yarn.lock b/yarn.lock index ee4492170e2..7d617d39f3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4033,7 +4033,7 @@ __metadata: rollup-plugin-esbuild: "npm:5.0.0" rollup-plugin-node-externals: "npm:^5.0.0" rxjs: "npm:7.8.1" - sass: "npm:1.78.0" + sass: "npm:1.79.3" sass-loader: "npm:14.2.1" semver: "npm:7.6.3" style-loader: "npm:4.0.0" @@ -13377,7 +13377,7 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:>=3.0.0 <4.0.0, chokidar@npm:^3.3.1, chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": +"chokidar@npm:^3.3.1, chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" dependencies: @@ -13396,6 +13396,15 @@ __metadata: languageName: node linkType: hard +"chokidar@npm:^4.0.0": + version: 4.0.1 + resolution: "chokidar@npm:4.0.1" + dependencies: + readdirp: "npm:^4.0.1" + checksum: 10/62749d2173a60cc5632d6c6e0b7024f33aadce47b06d02e55ad03c7b8daaaf2fc85d4296c047473d04387fd992dab9384cc5263c70a3dc3018b7ebecfb5b5217 + languageName: node + linkType: hard + "chownr@npm:^1.1.1": version: 1.1.4 resolution: "chownr@npm:1.1.4" @@ -19090,7 +19099,7 @@ __metadata: rimraf: "npm:6.0.1" rudder-sdk-js: "npm:2.48.18" rxjs: "npm:7.8.1" - sass: "npm:1.78.0" + sass: "npm:1.79.3" sass-loader: "npm:14.2.1" selecto: "npm:1.26.3" semver: "npm:7.6.3" @@ -28191,6 +28200,13 @@ __metadata: languageName: node linkType: hard +"readdirp@npm:^4.0.1": + version: 4.0.1 + resolution: "readdirp@npm:4.0.1" + checksum: 10/f8a2d3308c9dd19d9da4fc7f19a02fc057259a80014949d8f3d98f4e6042896119fb96eb3f3e6a743747d12f0bf781b771902b0b03aba58f884589c50968fad4 + languageName: node + linkType: hard + "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -29087,16 +29103,16 @@ __metadata: languageName: node linkType: hard -"sass@npm:1.78.0": - version: 1.78.0 - resolution: "sass@npm:1.78.0" +"sass@npm:1.79.3": + version: 1.79.3 + resolution: "sass@npm:1.79.3" dependencies: - chokidar: "npm:>=3.0.0 <4.0.0" + chokidar: "npm:^4.0.0" immutable: "npm:^4.0.0" source-map-js: "npm:>=0.6.2 <2.0.0" bin: sass: sass.js - checksum: 10/a180135addd9108d9a3549e111b2b442a97761bc429ef04374d57ef741d4343378e7092a6cba12e1f94e48b02d9b997cc79e737c0a2b834ad9204a761b14734d + checksum: 10/9b83e91c44a4c5d738ded27fcd2c88260f5f407e49c6aab92f75a1f768831182a8f4f89f30e2599b74447451d6536ea6f3a55cac34c5ec00b45983d493732e5d languageName: node linkType: hard From 1e720306dd12f58d3aacc3fdfb1216d04a875447 Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Wed, 25 Sep 2024 16:52:22 +0200 Subject: [PATCH 051/102] CloudMigrations: replace slicesext.Chunk with stdlib implementation (#93743) --- .../cloudmigrationimpl/snapshot_mgmt.go | 4 +- .../cloudmigration/slicesext/slicesext.go | 33 -------- .../slicesext/slicesext_test.go | 80 ------------------- 3 files changed, 2 insertions(+), 115 deletions(-) delete mode 100644 pkg/services/cloudmigration/slicesext/slicesext.go delete mode 100644 pkg/services/cloudmigration/slicesext/slicesext_test.go diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go index 334a52eac96..3bc126068d9 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sort" "time" @@ -13,7 +14,6 @@ import ( "github.com/grafana/grafana-cloud-migration-snapshot/src/contracts" "github.com/grafana/grafana-cloud-migration-snapshot/src/infra/crypto" "github.com/grafana/grafana/pkg/services/cloudmigration" - "github.com/grafana/grafana/pkg/services/cloudmigration/slicesext" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -230,7 +230,7 @@ func (s *Service) buildSnapshot(ctx context.Context, signedInUser *user.SignedIn cloudmigration.FolderDataType, cloudmigration.DashboardDataType, } { - for _, chunk := range slicesext.Chunks(int(maxItemsPerPartition), resourcesGroupedByType[resourceType]) { + for chunk := range slices.Chunk(resourcesGroupedByType[resourceType], int(maxItemsPerPartition)) { if err := snapshotWriter.Write(string(resourceType), chunk); err != nil { return fmt.Errorf("writing resources to snapshot writer: resourceType=%s %w", resourceType, err) } diff --git a/pkg/services/cloudmigration/slicesext/slicesext.go b/pkg/services/cloudmigration/slicesext/slicesext.go deleted file mode 100644 index 66d9fb6b6cf..00000000000 --- a/pkg/services/cloudmigration/slicesext/slicesext.go +++ /dev/null @@ -1,33 +0,0 @@ -package slicesext - -import "math" - -// Partitions the input into slices where the length is <= chunkSize. -// -// Example: -// -// Chunks(2, []int{1, 2, 3, 4}) -// => [][]int{{1, 2}, {3, 4}} -func Chunks[T any](chunkSize int, xs []T) [][]T { - if chunkSize < 0 { - panic("chunk size must be greater than or equal to 0") - } - if chunkSize == 0 { - return [][]T{} - } - - out := make([][]T, 0, int(math.Ceil(float64(len(xs))/float64(chunkSize)))) - - for i := 0; i < len(xs); i += chunkSize { - var chunk []T - if i+chunkSize < len(xs) { - chunk = xs[i : i+chunkSize] - } else { - chunk = xs[i:] - } - - out = append(out, chunk) - } - - return out -} diff --git a/pkg/services/cloudmigration/slicesext/slicesext_test.go b/pkg/services/cloudmigration/slicesext/slicesext_test.go deleted file mode 100644 index 150f92b004e..00000000000 --- a/pkg/services/cloudmigration/slicesext/slicesext_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package slicesext - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestChunks(t *testing.T) { - t.Parallel() - - t.Run("chunkSize must be greater than 0", func(t *testing.T) { - t.Parallel() - - assert.PanicsWithValue(t, "chunk size must be greater than or equal to 0", func() { - Chunks(-1, []string{}) - }) - }) - - t.Run("basic", func(t *testing.T) { - t.Parallel() - - cases := []struct { - description string - chunkSize int - input []int - expected [][]int - }{ - { - description: "empty slice", - chunkSize: 2, - input: []int{}, - expected: [][]int{}, - }, - { - description: "nil slice", - chunkSize: 2, - input: nil, - expected: [][]int{}, - }, - { - description: "chunk size is 0", - chunkSize: 0, - input: []int{1, 2, 3}, - expected: [][]int{}, - }, - { - description: "chunk size is greater than slice length", - chunkSize: 3, - input: []int{1}, - expected: [][]int{{1}}, - }, - { - description: "chunk size is 1", - chunkSize: 1, - input: []int{1, 2, 3}, - expected: [][]int{{1}, {2}, {3}}, - }, - { - description: "chunk size is 2 and slice length is 3", - chunkSize: 2, - input: []int{1, 2, 3}, - expected: [][]int{{1, 2}, {3}}, - }, - { - description: "chunk size is 2 and slice length is 6", - chunkSize: 2, - input: []int{1, 2, 3, 4, 5, 6}, - expected: [][]int{{1, 2}, {3, 4}, {5, 6}}, - }, - } - - for _, tt := range cases { - t.Run(tt.description, func(t *testing.T) { - result := Chunks(tt.chunkSize, tt.input) - assert.Equal(t, tt.expected, result) - }) - } - }) -} From 2c98767d17039cbeead63f69a3fe141a3b539ae6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 16:19:58 +0100 Subject: [PATCH 052/102] Update dependency webpack to v5.95.0 [SECURITY] (#93751) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-plugin-configs/package.json | 2 +- packages/grafana-prometheus/package.json | 2 +- packages/grafana-ui/package.json | 2 +- .../datasource/azuremonitor/package.json | 2 +- .../datasource/cloud-monitoring/package.json | 2 +- .../package.json | 2 +- .../grafana-pyroscope-datasource/package.json | 2 +- .../grafana-testdata-datasource/package.json | 2 +- .../plugins/datasource/jaeger/package.json | 2 +- .../app/plugins/datasource/mssql/package.json | 2 +- .../app/plugins/datasource/mysql/package.json | 2 +- .../app/plugins/datasource/parca/package.json | 2 +- .../app/plugins/datasource/tempo/package.json | 2 +- .../plugins/datasource/zipkin/package.json | 2 +- yarn.lock | 38 +++++++++---------- 16 files changed, 34 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 335da9c514c..d466315ba6a 100644 --- a/package.json +++ b/package.json @@ -234,7 +234,7 @@ "ts-jest": "29.2.5", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0", + "webpack": "5.95.0", "webpack-assets-manifest": "^5.1.0", "webpack-bundle-analyzer": "4.10.2", "webpack-cli": "5.1.4", diff --git a/packages/grafana-plugin-configs/package.json b/packages/grafana-plugin-configs/package.json index 46e4e985bcb..fa204ae3c75 100644 --- a/packages/grafana-plugin-configs/package.json +++ b/packages/grafana-plugin-configs/package.json @@ -18,7 +18,7 @@ "replace-in-file-webpack-plugin": "1.0.6", "swc-loader": "0.2.6", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "packageManager": "yarn@4.5.0" } diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 6ef2c9afa99..56172771b60 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -137,7 +137,7 @@ "testing-library-selector": "0.3.1", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0", + "webpack": "5.95.0", "webpack-cli": "5.1.4" }, "peerDependencies": { diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index e8c5928a837..be56923c1a9 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -185,7 +185,7 @@ "storybook-dark-mode": "^4.0.1", "style-loader": "4.0.0", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "peerDependencies": { "react": "^18.0.0", diff --git a/public/app/plugins/datasource/azuremonitor/package.json b/public/app/plugins/datasource/azuremonitor/package.json index 598566ab3a0..81d7b52fc16 100644 --- a/public/app/plugins/datasource/azuremonitor/package.json +++ b/public/app/plugins/datasource/azuremonitor/package.json @@ -40,7 +40,7 @@ "react-select-event": "5.5.1", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "peerDependencies": { "@grafana/runtime": "*" diff --git a/public/app/plugins/datasource/cloud-monitoring/package.json b/public/app/plugins/datasource/cloud-monitoring/package.json index 2d45f479e39..d8bd0516ee6 100644 --- a/public/app/plugins/datasource/cloud-monitoring/package.json +++ b/public/app/plugins/datasource/cloud-monitoring/package.json @@ -44,7 +44,7 @@ "react-test-renderer": "18.2.0", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "peerDependencies": { "@grafana/runtime": "*" diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json index a6f0a203728..f332a2f44ac 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json @@ -27,7 +27,7 @@ "@types/testing-library__jest-dom": "5.14.9", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "peerDependencies": { "@grafana/runtime": "*" diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json index 7a43cc547a1..a055fefa7bd 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json @@ -37,7 +37,7 @@ "style-loader": "4.0.0", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "peerDependencies": { "@grafana/runtime": "*" diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/package.json b/public/app/plugins/datasource/grafana-testdata-datasource/package.json index 3aa1709fe77..776f7818591 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/package.json +++ b/public/app/plugins/datasource/grafana-testdata-datasource/package.json @@ -37,7 +37,7 @@ "@types/uuid": "9.0.8", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "peerDependencies": { "@grafana/runtime": "*" diff --git a/public/app/plugins/datasource/jaeger/package.json b/public/app/plugins/datasource/jaeger/package.json index c5bf8138976..7c4ee433ed9 100644 --- a/public/app/plugins/datasource/jaeger/package.json +++ b/public/app/plugins/datasource/jaeger/package.json @@ -38,7 +38,7 @@ "@types/uuid": "9.0.8", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "peerDependencies": { "@grafana/runtime": "*" diff --git a/public/app/plugins/datasource/mssql/package.json b/public/app/plugins/datasource/mssql/package.json index ebe610883ba..e7d061944f1 100644 --- a/public/app/plugins/datasource/mssql/package.json +++ b/public/app/plugins/datasource/mssql/package.json @@ -27,7 +27,7 @@ "@types/testing-library__jest-dom": "5.14.9", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "peerDependencies": { "@grafana/runtime": "*" diff --git a/public/app/plugins/datasource/mysql/package.json b/public/app/plugins/datasource/mysql/package.json index ada5f5be5f8..9568689446f 100644 --- a/public/app/plugins/datasource/mysql/package.json +++ b/public/app/plugins/datasource/mysql/package.json @@ -27,7 +27,7 @@ "@types/testing-library__jest-dom": "5.14.9", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "peerDependencies": { "@grafana/runtime": "*" diff --git a/public/app/plugins/datasource/parca/package.json b/public/app/plugins/datasource/parca/package.json index b03bf9c48b1..8b25a7a579e 100644 --- a/public/app/plugins/datasource/parca/package.json +++ b/public/app/plugins/datasource/parca/package.json @@ -28,7 +28,7 @@ "@types/react-dom": "18.2.25", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "peerDependencies": { "@grafana/runtime": "*" diff --git a/public/app/plugins/datasource/tempo/package.json b/public/app/plugins/datasource/tempo/package.json index 4dc0e403968..af758a14ebe 100644 --- a/public/app/plugins/datasource/tempo/package.json +++ b/public/app/plugins/datasource/tempo/package.json @@ -56,7 +56,7 @@ "react-select-event": "5.5.1", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "peerDependencies": { "@grafana/runtime": "*" diff --git a/public/app/plugins/datasource/zipkin/package.json b/public/app/plugins/datasource/zipkin/package.json index 8c9fb495a4e..c21f55228d6 100644 --- a/public/app/plugins/datasource/zipkin/package.json +++ b/public/app/plugins/datasource/zipkin/package.json @@ -30,7 +30,7 @@ "@types/react-dom": "18.2.25", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "peerDependencies": { "@grafana/runtime": "*" diff --git a/yarn.lock b/yarn.lock index 7d617d39f3f..918b0f64368 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3151,7 +3151,7 @@ __metadata: ts-node: "npm:10.9.2" tslib: "npm:2.7.0" typescript: "npm:5.5.4" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" peerDependencies: "@grafana/runtime": "*" languageName: unknown @@ -3182,7 +3182,7 @@ __metadata: ts-node: "npm:10.9.2" tslib: "npm:2.7.0" typescript: "npm:5.5.4" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" peerDependencies: "@grafana/runtime": "*" languageName: unknown @@ -3223,7 +3223,7 @@ __metadata: ts-node: "npm:10.9.2" tslib: "npm:2.7.0" typescript: "npm:5.5.4" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" peerDependencies: "@grafana/runtime": "*" languageName: unknown @@ -3264,7 +3264,7 @@ __metadata: tslib: "npm:2.7.0" typescript: "npm:5.5.4" uuid: "npm:9.0.1" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" peerDependencies: "@grafana/runtime": "*" languageName: unknown @@ -3306,7 +3306,7 @@ __metadata: tslib: "npm:2.7.0" typescript: "npm:5.5.4" uuid: "npm:9.0.1" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" peerDependencies: "@grafana/runtime": "*" languageName: unknown @@ -3337,7 +3337,7 @@ __metadata: ts-node: "npm:10.9.2" tslib: "npm:2.7.0" typescript: "npm:5.5.4" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" peerDependencies: "@grafana/runtime": "*" languageName: unknown @@ -3368,7 +3368,7 @@ __metadata: ts-node: "npm:10.9.2" tslib: "npm:2.7.0" typescript: "npm:5.5.4" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" peerDependencies: "@grafana/runtime": "*" languageName: unknown @@ -3400,7 +3400,7 @@ __metadata: ts-node: "npm:10.9.2" tslib: "npm:2.7.0" typescript: "npm:5.5.4" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" peerDependencies: "@grafana/runtime": "*" languageName: unknown @@ -3448,7 +3448,7 @@ __metadata: ts-node: "npm:10.9.2" tslib: "npm:2.7.0" typescript: "npm:5.5.4" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" peerDependencies: "@grafana/runtime": "*" languageName: unknown @@ -3508,7 +3508,7 @@ __metadata: tslib: "npm:2.7.0" typescript: "npm:5.5.4" uuid: "npm:9.0.1" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" peerDependencies: "@grafana/runtime": "*" languageName: unknown @@ -3542,7 +3542,7 @@ __metadata: ts-node: "npm:10.9.2" tslib: "npm:2.7.0" typescript: "npm:5.5.4" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" peerDependencies: "@grafana/runtime": "*" languageName: unknown @@ -3923,7 +3923,7 @@ __metadata: swc-loader: "npm:0.2.6" tslib: "npm:2.7.0" typescript: "npm:5.5.4" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" languageName: unknown linkType: soft @@ -4042,7 +4042,7 @@ __metadata: tslib: "npm:2.7.0" typescript: "npm:5.5.4" uuid: "npm:9.0.1" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" webpack-cli: "npm:5.1.4" whatwg-fetch: "npm:3.6.20" peerDependencies: @@ -4369,7 +4369,7 @@ __metadata: typescript: "npm:5.5.4" uplot: "npm:1.6.30" uuid: "npm:9.0.1" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" peerDependencies: react: ^18.0.0 react-dom: ^18.0.0 @@ -19128,7 +19128,7 @@ __metadata: uplot: "npm:1.6.30" uuid: "npm:9.0.1" visjs-network: "npm:4.25.0" - webpack: "npm:5.94.0" + webpack: "npm:5.95.0" webpack-assets-manifest: "npm:^5.1.0" webpack-bundle-analyzer: "npm:4.10.2" webpack-cli: "npm:5.1.4" @@ -32907,9 +32907,9 @@ __metadata: languageName: node linkType: hard -"webpack@npm:5, webpack@npm:5.94.0, webpack@npm:^5": - version: 5.94.0 - resolution: "webpack@npm:5.94.0" +"webpack@npm:5, webpack@npm:5.95.0, webpack@npm:^5": + version: 5.95.0 + resolution: "webpack@npm:5.95.0" dependencies: "@types/estree": "npm:^1.0.5" "@webassemblyjs/ast": "npm:^1.12.1" @@ -32939,7 +32939,7 @@ __metadata: optional: true bin: webpack: bin/webpack.js - checksum: 10/648449c5fbbb0839814116e3b2b044ac6c75a7ba272435155ddeb1e64dfaa2f8079be3adfbb691f648b69900756ce0f6fb73beab0ced3cf5e0fd46868b4593a6 + checksum: 10/0377ad3a550b041f26237c96fb55754625b0ce6bae83c1c2447e3262ad056b0b0ad770dcbb92b59f188e9a2bd56155ce910add17dcf023cfbe78bdec774380c1 languageName: node linkType: hard From 5fe4a2f6a9dcd4381f0489ef2f54ed66ae03ebb5 Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Wed, 25 Sep 2024 17:53:26 +0200 Subject: [PATCH 053/102] Explore Metrics: Add consistent maxDataPoints value across the app (#93618) Add consistent maxDataPoints value across the app --- .../features/trails/AutomaticMetricQueries/AutoVizPanel.tsx | 4 ++-- .../app/features/trails/Breakdown/LabelBreakdownScene.tsx | 6 +++--- public/app/features/trails/MetricSelect/previewPanel.ts | 4 ++-- public/app/features/trails/shared.ts | 5 +++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx b/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx index 17b59f21322..9a6b164ca7f 100644 --- a/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx +++ b/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx @@ -1,7 +1,7 @@ import { SceneObjectState, SceneObjectBase, SceneComponentProps, VizPanel, SceneQueryRunner } from '@grafana/scenes'; import { RadioButtonGroup } from '@grafana/ui'; -import { trailDS } from '../shared'; +import { MDP_METRIC_OVERVIEW, trailDS } from '../shared'; import { getMetricSceneFor } from '../utils'; import { AutoQueryDef } from './types'; @@ -52,7 +52,7 @@ export class AutoVizPanel extends SceneObjectBase { .setData( new SceneQueryRunner({ datasource: trailDS, - maxDataPoints: 500, + maxDataPoints: MDP_METRIC_OVERVIEW, queries: def.queries, }) ) diff --git a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx index fc1b8d3f9c8..620e5a05766 100644 --- a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx +++ b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx @@ -33,7 +33,7 @@ import { MetricScene } from '../MetricScene'; import { StatusWrapper } from '../StatusWrapper'; import { reportExploreMetrics } from '../interactions'; import { ALL_VARIABLE_VALUE } from '../services/variables'; -import { trailDS, VAR_FILTERS, VAR_GROUP_BY, VAR_GROUP_BY_EXP } from '../shared'; +import { MDP_METRIC_PREVIEW, trailDS, VAR_FILTERS, VAR_GROUP_BY, VAR_GROUP_BY_EXP } from '../shared'; import { getColorByIndex, getTrailFor } from '../utils'; import { AddToFiltersGraphAction } from './AddToFiltersGraphAction'; @@ -304,7 +304,7 @@ export function buildAllLayout( .setTitle(option.label!) .setData( new SceneQueryRunner({ - maxDataPoints: 250, + maxDataPoints: MDP_METRIC_PREVIEW, datasource: trailDS, queries: [ { @@ -395,7 +395,7 @@ function buildNormalLayout( return new LayoutSwitcher({ $data: new SceneQueryRunner({ datasource: trailDS, - maxDataPoints: 300, + maxDataPoints: MDP_METRIC_PREVIEW, queries: queryDef.queries, }), breakdownLayoutOptions: [ diff --git a/public/app/features/trails/MetricSelect/previewPanel.ts b/public/app/features/trails/MetricSelect/previewPanel.ts index 2300a8eb88d..eda861dfa2b 100644 --- a/public/app/features/trails/MetricSelect/previewPanel.ts +++ b/public/app/features/trails/MetricSelect/previewPanel.ts @@ -2,7 +2,7 @@ import { PromQuery } from '@grafana/prometheus'; import { SceneCSSGridItem, SceneQueryRunner, SceneVariableSet } from '@grafana/scenes'; import { getAutoQueriesForMetric } from '../AutomaticMetricQueries/AutoQueryEngine'; -import { getVariablesWithMetricConstant, trailDS } from '../shared'; +import { getVariablesWithMetricConstant, MDP_METRIC_PREVIEW, trailDS } from '../shared'; import { getColorByIndex } from '../utils'; import { SelectMetricAction } from './SelectMetricAction'; @@ -29,7 +29,7 @@ export function getPreviewPanelFor(metric: string, index: number, currentFilterC $behaviors: [hideEmptyPreviews(metric)], $data: new SceneQueryRunner({ datasource: trailDS, - maxDataPoints: 200, + maxDataPoints: MDP_METRIC_PREVIEW, queries, }), body: vizPanel, diff --git a/public/app/features/trails/shared.ts b/public/app/features/trails/shared.ts index ed23c24cfbe..9bad5b0b449 100644 --- a/public/app/features/trails/shared.ts +++ b/public/app/features/trails/shared.ts @@ -37,11 +37,12 @@ export const trailDS = { uid: VAR_DATASOURCE_EXPR }; // Local storage keys export const RECENT_TRAILS_KEY = 'grafana.trails.recent'; - export const TRAIL_BOOKMARKS_KEY = 'grafana.trails.bookmarks'; - export const TRAIL_BREAKDOWN_VIEW_KEY = 'grafana.trails.breakdown.view'; +export const MDP_METRIC_PREVIEW = 250; +export const MDP_METRIC_OVERVIEW = 500; + export type MakeOptional = Pick, K> & Omit; export function getVariablesWithMetricConstant(metric: string) { From 95b998d77f22c07ecdddb854d9808230f9d1f96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Wed, 25 Sep 2024 17:56:47 +0200 Subject: [PATCH 054/102] SingleTopNav: `MegaMenu` full page height (#93729) --- public/app/core/components/AppChrome/AppChrome.tsx | 12 +++++++++--- .../app/core/components/AppChrome/AppChromeMenu.tsx | 7 +++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index 7e5cfd0d365..e41bc143940 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -36,7 +36,6 @@ export function AppChrome({ children }: Props) { const scopesDashboardsState = useScopesDashboardsState(); const isScopesDashboardsOpen = Boolean(scopesDashboardsState?.isEnabled && scopesDashboardsState?.isPanelOpened); const isSingleTopNav = config.featureToggles.singleTopNav; - useMediaQueryChange({ breakpoint: dockedMenuBreakpoint, onChange: (e) => { @@ -93,7 +92,7 @@ export function AppChrome({ children }: Props) { Skip to main content -
+
{isSingleTopNav ? ( { }, { position: 'fixed', - height: `calc(100% - ${searchBarHidden || isSingleTopNav ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2}px)`, + height: `calc(100% - ${searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2}px)`, zIndex: 2, + }, + isSingleTopNav && { + height: '100%', + top: 0, } ), scopesDashboardsContainer: css({ @@ -202,6 +205,9 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden: boolean) => { background: theme.colors.background.primary, flexDirection: 'column', }), + topNavMenuDocked: css({ + left: MENU_WIDTH, + }), panes: css({ display: 'flex', flexDirection: 'column', diff --git a/public/app/core/components/AppChrome/AppChromeMenu.tsx b/public/app/core/components/AppChrome/AppChromeMenu.tsx index bf5022ec01c..7926e41aae9 100644 --- a/public/app/core/components/AppChrome/AppChromeMenu.tsx +++ b/public/app/core/components/AppChrome/AppChromeMenu.tsx @@ -77,8 +77,11 @@ export function AppChromeMenu({}: Props) { } const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => { - const topPosition = - searchBarHidden || config.featureToggles.singleTopNav ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2; + let topPosition = searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2; + + if (config.featureToggles.singleTopNav) { + topPosition = 0; + } return { backdrop: css({ From 26a3e70cfa5550df23913425480fbee86f77c4e5 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed, 25 Sep 2024 13:06:33 -0400 Subject: [PATCH 055/102] Docs: Table visualization refactor (#93633) --- .../visualizations/table/index.md | 372 +++++++++--------- .../visualizations/thresholds-options-2.md | 10 +- public/mockServiceWorker.js | 281 +++++++++++++ 3 files changed, 465 insertions(+), 198 deletions(-) create mode 100644 public/mockServiceWorker.js diff --git a/docs/sources/panels-visualizations/visualizations/table/index.md b/docs/sources/panels-visualizations/visualizations/table/index.md index e7c8b2294fd..e1b6ecdaaaf 100644 --- a/docs/sources/panels-visualizations/visualizations/table/index.md +++ b/docs/sources/panels-visualizations/visualizations/table/index.md @@ -127,197 +127,22 @@ The table visualization helps with debugging when you need to know exactly what ![The Table view switch](/media/docs/grafana/panels-visualizations/screenshot-table-view-on-11.2.png) -## Sort column - -Click a column title to change the sort order from default to descending to ascending. Each time you click, the sort order changes to the next option in the cycle. You can sort multiple columns by holding the `shift` key and clicking the column name. - -![Sort descending](/static/img/docs/tables/sort-descending.png 'Sort descending') - -## Data set selector - -If the data queried contains multiple data sets, a table displays a drop-down list at the bottom, so you can select the data set you want to visualize. - -![Table visualization with multiple data sets](/media/docs/grafana/panels-visualizations/TablePanelMultiSet.png) - -## Panel options - -{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="" >}} - -## Table options - -{{% admonition type="note" %}} -If you are using a table created before Grafana 7.0, then you need to migrate to the new table version in order to see these options. To migrate, on the Panel tab, click **Table** visualization. Grafana updates the table version and you can then access all table options. -{{% /admonition %}} - -### Show header - -Show or hide column names imported from your data source. - -### Column width - -By default, Grafana automatically calculates the column width based on the table size and the minimum column width. This field option can override the setting and define the width for all columns in pixels. - -For example, if you enter `100` in the field, then when you click outside the field, all the columns will be set to 100 pixels wide. - -### Minimum column width - -By default, the minimum width of the table column is 150 pixels. This field option can override that default and will define the new minimum column width for the table in pixels. - -For example, if you enter `75` in the field, then when you click outside the field, all the columns will scale to no smaller than 75 pixels wide. - -For small-screen devices, such as smartphones or tablets, reduce the default `150` pixel value to`50` to allow table-based panels to render correctly in dashboards. - -### Column alignment - -Choose how Grafana should align cell contents: - -- Auto (default) -- Left -- Center -- Right - -### Column filter - -You can temporarily change how column data is displayed. For example, you can order values from highest to lowest or hide specific values. For more information, refer to [Filter table columns](#filter-table-columns). - -### Pagination - -Use this option to enable or disable pagination. It is a front-end option that does not affect queries. When enabled, the page size automatically adjusts to the height of the table. - -## Cell options - -### Cell type - -By default, Grafana automatically chooses display settings. You can override the settings by choosing one of the following options to set the default for all fields. Additional configuration is available for some cell types. - -{{% admonition type="note" %}} -If you set these in the Field tab, then the type will apply to all fields, including the time field. Many options will work best if you set them in the Override tab so that they can be restricted to one or more fields. -{{% /admonition %}} - -#### Auto - -The **Auto** cell type automatically displays values, with sensible defaults applied. - -#### Color text - -If thresholds are set, then the field text is displayed in the appropriate threshold color. - -{{< figure src="/static/img/docs/tables/color-text.png" max-width="500px" caption="Color text" class="docs-image--no-shadow" >}} - -#### Color background (gradient or solid) - -If thresholds are set, then the field background is displayed in the appropriate threshold color. - -{{< figure src="/static/img/docs/tables/color-background.png" max-width="500px" caption="Color background" class="docs-image--no-shadow" >}} - -Toggle the **Apply to entire row** switch, to apply the background color that's configured for the cell to the whole row. - -{{< figure src="/static/img/docs/tables/colored-rows.png" max-width="500px" alt="Colored row background" class="docs-image--no-shadow" >}} - -#### Gauge - -Cells can be displayed as a graphical gauge, with several different presentation types. - -{{< admonition type="note" >}} -The maximum and minimum values of the gauges are configured automatically from the smallest and largest values in your whole data set. If you don't want the max/min values to be pulled from the whole data set, you can configure them for each column with field overrides. -{{< /admonition >}} - -##### Basic - -The basic mode will show a simple gauge with the threshold levels defining the color of gauge. - -{{< figure src="/static/img/docs/tables/basic-gauge.png" max-width="500px" caption="Gradient gauge" class="docs-image--no-shadow" >}} - -##### Gradient - -The threshold levels define a gradient. - -{{< figure src="/static/img/docs/tables/gradient-gauge.png" max-width="500px" caption="Gradient gauge" class="docs-image--no-shadow" >}} - -##### LCD - -The gauge is split up in small cells that are lit or unlit. - -{{< figure src="/static/img/docs/tables/lcd-gauge.png" max-width="500px" caption="LCD gauge" class="docs-image--no-shadow" >}} - -##### Label Options - -Additionally, labels displayed alongside of the gauges can be set to be colored by value, match the theme text color, or be hidden. - -**Value Color** - -{{< figure src="/static/img/docs/tables/value-color-mode.png" max-width="500px" caption="Color Label by Value" class="docs-image--no-shadow" >}} - -**Text Color** - -{{< figure src="/static/img/docs/tables/text-color-mode.png" max-width="500px" caption="Color Label by theme color" class="docs-image--no-shadow" >}} - -**Hidden** - -{{< figure src="/static/img/docs/tables/hidden-mode.png" max-width="500px" caption="Hide Label" class="docs-image--no-shadow" >}} - -#### Data links - -If you've configured data links, when the cell type is **Auto** mode, the cell text becomes clickable. If you change the cell type to **Data links**, the cell text reflects the titles of the configured data links. To control the application of data link text more granularly use a **Cell option > Cell type > Data links** field override. - -#### JSON view - -Shows value formatted as code. If a value is an object the JSON view allowing browsing the JSON object will appear on hover. - -{{< figure src="/static/img/docs/tables/json-view.png" max-width="500px" caption="JSON view" class="docs-image--no-shadow" >}} - -#### Image - -> Only available in Grafana 7.3+ - -If you have a field value that is an image URL or a base64 encoded image you can configure the table to display it as an image. - -{{< figure src="/static/img/docs/v73/table_hover.gif" max-width="900px" caption="Table hover" >}} - -Use the **Alt text** option to set the alternative text of an image. The text will be available for screen readers and in cases when images can't be loaded. - -Use the **Title text** option to set the text that's displayed when the image is hovered over with a cursor. - -#### Sparkline - -Shows values rendered as a sparkline. You can show sparklines using the [Time series to table transformation](ref:time-series-to-table-transformation) on data with multiple time series to process it into a format the table can show. - -{{< figure src="/static/img/docs/tables/sparkline2.png" max-width="500px" caption="Sparkline" class="docs-image--no-shadow" >}} - -You can be customize sparklines with many of the same options as the [Time series panel](ref:time-series-panel) including line width, fill opacity, and more. You can also change the color of the sparkline by updating the [color scheme](ref:color-scheme) in the _Standard options_ section of the panel configuration. - -### Wrap text - -{{< admonition type="note" >}} -Text wrapping is in [public preview](https://grafana.com/docs/release-life-cycle/#public-preview), however, it’s available to use by default. We’d love hear from you about how this new feature is working. To provide feedback, you can open an issue in the [Grafana GitHub repository](https://github.com/grafana/grafana). -{{< /admonition >}} - -Toggle the **Wrap text** switch to wrap text in the cell with the longest content in your table. To wrap the text in a specific column only, use the Wrap Text option in a [field override](ref:field-override). - -### Cell value inspect - -Enables value inspection from table cells. When the **Cell inspect value** switch is toggled on, clicking the inspect icon in a cell opens the **Inspect value** drawer. - -The **Inspect value** drawer has two tabs, **Plain text** and **Code editor**. Grafana attempts to automatically detect the type of data in the cell and opens the drawer with the associated tab showing. However, you can switch back and forth between tabs. - -Cell value inspection is only available when the **Cell type** selection is **Auto**, **Colored text**, **Colored background**, or **JSON View**. - ## Turn on column filtering 1. In Grafana, navigate to the dashboard with the table with the columns that you want to filter. 1. On the table panel you want to filter, open the panel editor. -1. Click the **Field** tab. -1. In Table options, turn on the **Column filter** option. +1. Expand the the **Table** options section. +1. Toggle on the [**Column filter** switch](#table-options). A filter icon appears next to each column title. -{{< figure src="/static/img/docs/tables/column-filter-with-icon.png" max-width="500px" caption="Column filtering turned on" class="docs-image--no-shadow" >}} +{{< figure src="/static/img/docs/tables/column-filter-with-icon.png" max-width="350px" alt="Column filtering turned on" class="docs-image--no-shadow" >}} ### Filter column values To filter column values, click the filter (funnel) icon next to a column title. Grafana displays the filter options for that column. -{{< figure src="/static/img/docs/tables/filter-column-values.png" max-width="500px" caption="Filter column values" class="docs-image--no-shadow" >}} +{{< figure src="/static/img/docs/tables/filter-column-values.png" max-width="300px" alt="Filter column values" class="docs-image--no-shadow" >}} Click the check box next to the values that you want to display. Enter text in the search field at the top to show those values in the display so that you can select them rather than scroll to find them. @@ -333,41 +158,202 @@ Click the check box above the **Ok** and **Cancel** buttons to add or remove all Columns with filters applied have a blue funnel displayed next to the title. -{{< figure src="/static/img/docs/tables/filtered-column.png" max-width="500px" caption="Filtered column" class="docs-image--no-shadow" >}} +{{< figure src="/static/img/docs/tables/filtered-column.png" max-width="100px" alt="Filtered column" class="docs-image--no-shadow" >}} To remove the filter, click the blue funnel icon and then click **Clear filter**. -## Table footer +## Sort columns -You can use the table footer to show [calculations](ref:calculations) on fields. +Click a column title to change the sort order from default to descending to ascending. Each time you click, the sort order changes to the next option in the cycle. You can sort multiple columns by holding the `shift` key and clicking the column name. -After you enable the table footer: +{{< figure src="/static/img/docs/tables/sort-descending.png" max-width="350px" alt="Sort descending" class="docs-image--no-shadow" >}} -1. Select the **Calculation** -2. Select the **Fields** that you want to calculate +## Dataset selector -The system applies the calculation to all numeric fields if you do not select a field. +If the data queried contains multiple datasets, a table displays a drop-down list at the bottom, so you can select the dataset you want to visualize. -### Count rows +{{< figure src="/media/docs/grafana/panels-visualizations/TablePanelMultiSet.png" max-width="650px" alt="Table visualization with multiple datasets" class="docs-image--no-shadow" >}} -If you want to show the number of rows in the dataset instead of the number of values in the selected fields, select the **Count** calculation and enable **Count rows**. +## Configuration options -## Standard options +### Panel options + +{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="" >}} + +### Table options + +{{% admonition type="note" %}} +If you are using a table created before Grafana 7.0, then you need to migrate to the new table version in order to see these options. To migrate, on the Panel tab, click **Table** visualization. Grafana updates the table version and you can then access all table options. +{{% /admonition %}} + +| Option | Description | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Show table header | Show or hide column names imported from your data source. | +| Cell height | Set the height of the cell. Choose from **Small**, **Medium**, and **Large**. | +| Enable pagination | Toggle the switch to control how many table rows are visible at once. When switched on, the page size automatically adjusts to the height of the table. This option doesn't affect queries. | +| Minimum column width | Define the lower limit of the column width, in pixels. By default, the minimum width of the table column is 150 pixels. For small-screen devices, such as smartphones or tablets, reduce the default `150` pixel value to `50` to allow table-based panels to render correctly in dashboards. | +| Column width | Define a column width, in pixels, rather than allowing the width to be set automatically. By default, Grafana calculates the column width based on the table size and the minimum column width. | +| Column alignment | Set how Grafana should align cell contents. Choose from: **Auto** (default), **Left**, **Center**, and **Right**. | +| Column filter | Temporarily change how column data is displayed. For example, you can order values from highest to lowest or hide specific values. For more information, refer to [Filter table columns](#filter-table-columns). | + +### Table footer options + +Toggle the **Show table footer** switch on and off to control the display of the footer. When the toggle is switched on, you can use the table footer to show [calculations](ref:calculations) on fields. + +After you activate the table footer, make selections in the following options: + +- **Calculation** - The calculation that you want to apply. +- **Fields** - The fields to which you want to apply the calculations. The system applies the calculation to all numeric fields if you do not select a field. +- **Count rows** - This options is displayed if you select the **Count** calculation. If you want to show the number of rows in the dataset instead of the number of values in the selected fields, toggle on the **Count rows** switch. + +### Cell options + +Cell options allow you to control how data is displayed in a table. + +#### Cell type + +By default, Grafana automatically chooses display settings. You can override the settings by choosing one of the following options to set the default for all fields. Additional configuration is available for some cell types. + +{{% admonition type="note" %}} +If you set these in the Field tab, then the type will apply to all fields, including the time field. Many options will work best if you set them in the Override tab so that they can be restricted to one or more fields. +{{% /admonition %}} + +| Cell type | Description | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Auto | The **Auto** cell type automatically displays values, with sensible defaults applied. | +| [Sparkline](#sparkline) | Shows values rendered as a sparkline. | +| [Colored text](#colored-text) | If thresholds are set, then the field text is displayed in the appropriate threshold color. | +| [Colored background](#colored-background) | If thresholds are set, then the field background is displayed in the appropriate threshold color. | +| [Gauge](#gauge) | Cells can be displayed as a graphical gauge, with several different presentation types. You can set the [Gauge display mode](#gauge-display-mode) and the [Value display](#value-display) options. | +| Data links | If you've configured data links, when the cell type is **Auto** mode, the cell text becomes clickable. If you change the cell type to **Data links**, the cell text reflects the titles of the configured data links. To control the application of data link text more granularly use a **Cell option > Cell type > Data links** field override. | +| [JSON View](#json-view) | Shows value formatted as code. | +| [Image](#image) | If you have a field value that is an image URL or a base64 encoded image you can configure the table to display it as an image. | + +##### Sparkline + +Shows values rendered as a sparkline. You can show sparklines using the [Time series to table transformation](ref:time-series-to-table-transformation) on data with multiple time series to process it into a format the table can show. + +{{< figure src="/static/img/docs/tables/sparkline2.png" max-width="500px" alt="Sparkline" class="docs-image--no-shadow" >}} + +You can customize sparklines with many of the same options as the [time series visualization](ref:time-series-panel) including line style and width, fill opacity, gradient mode, and more. You can also change the color of the sparkline by updating the [color scheme](ref:color-scheme) in the **Standard options** section of the panel configuration. + +##### Colored text + +If thresholds are set, then the field text is displayed in the appropriate threshold color. + +{{< figure src="/static/img/docs/tables/color-text.png" max-width="500px" alt="Color text" class="docs-image--no-shadow" >}} + +{{< admonition type="note" >}} +This is an experimental feature. +{{< /admonition >}} + +##### Colored background + +If thresholds are set, then the field background is displayed in the appropriate threshold color. + +{{< figure src="/static/img/docs/tables/color-background.png" max-width="500px" alt="Color background" class="docs-image--no-shadow" >}} + +Choose between **Basic** and **Gradient** to set the **Background display mode**. + +Toggle the **Apply to entire row** switch, to apply the background color that's configured for the cell to the whole row. + +{{< figure src="/static/img/docs/tables/colored-rows.png" max-width="500px" alt="Colored row background" class="docs-image--no-shadow" >}} + +##### Gauge + +Cells can be displayed as a graphical gauge, with several different presentation types controlled by the gauge display mode and the value display. + +{{< admonition type="note" >}} +The maximum and minimum values of the gauges are configured automatically from the smallest and largest values in your whole data set. If you don't want the max/min values to be pulled from the whole data set, you can configure them for each column with field overrides. +{{< /admonition >}} + +###### Gauge display mode + +You can set three gauge display modes. + +- **Basic** - Shows a simple gauge with the threshold levels defining the color of gauge. + + {{< figure src="/static/img/docs/tables/basic-gauge.png" max-width="500px" alt="Gradient gauge" class="docs-image--no-shadow" >}} + +- **Gradient** - The threshold levels define a gradient. + + {{< figure src="/static/img/docs/tables/gradient-gauge.png" max-width="500px" alt="Gradient gauge" class="docs-image--no-shadow" >}} + +- **Retro LCD** - The gauge is split up in small cells that are lit or unlit. + + {{< figure src="/static/img/docs/tables/lcd-gauge.png" max-width="500px" alt="LCD gauge" class="docs-image--no-shadow" >}} + +###### Value display + +Labels displayed alongside of the gauges can be set to be colored by value, match the theme text color, or be hidden. + +- **Value color** + + {{< figure src="/static/img/docs/tables/value-color-mode.png" max-width="500px" alt="Color Label by Value" class="docs-image--no-shadow" >}} + +- **Text color** + + {{< figure src="/static/img/docs/tables/text-color-mode.png" max-width="500px" alt="Color Label by theme color" class="docs-image--no-shadow" >}} + +- **Hidden** + + {{< figure src="/static/img/docs/tables/hidden-mode.png" max-width="500px" alt="Hide Label" class="docs-image--no-shadow" >}} + +##### JSON View + +Shows value formatted as code. If a value is an object the JSON view allowing browsing the JSON object will appear on hover. + +{{< figure src="/static/img/docs/tables/json-view.png" max-width="350px" alt="JSON view" class="docs-image--no-shadow" >}} + +##### Image + +{{< admonition type="note" >}} +Only available in Grafana 7.3+ +{{< /admonition >}} + +If you have a field value that is an image URL or a base64 encoded image you can configure the table to display it as an image. + +{{< figure src="/static/img/docs/v73/table_hover.gif" max-width="900px" alt="Table hover" >}} + +- **Alt text** - Set the alternative text of an image. The text will be available for screen readers and in cases when images can't be loaded. +- **Title text** - Set the text that's displayed when the image is hovered over with a cursor. + +#### Wrap text + +{{< admonition type="note" >}} +Text wrapping is in [public preview](https://grafana.com/docs/release-life-cycle/#public-preview), however, it’s available to use by default. We’d love hear from you about how this new feature is working. To provide feedback, you can open an issue in the [Grafana GitHub repository](https://github.com/grafana/grafana). +{{< /admonition >}} + +Toggle the **Wrap text** switch to wrap text in the cell with the longest content in your table. To wrap the text in a specific column only, use the Wrap Text option in a [field override](ref:field-override). + +This option isn't available when you set the cell type to **Gauge** or Data links,JSON View, Image. + +#### Cell value inspect + +Enables value inspection from table cells. When the **Cell inspect value** switch is toggled on, clicking the inspect icon in a cell opens the **Inspect value** drawer. + +The **Inspect value** drawer has two tabs, **Plain text** and **Code editor**. Grafana attempts to automatically detect the type of data in the cell and opens the drawer with the associated tab showing. However, you can switch back and forth between tabs. + +Cell value inspection is only available when the **Cell type** selection is **Auto**, **Colored text**, **Colored background**, or **JSON View**. + +This option isn't available when you set the cell type to **Gauge** or Data links, Image, . + +### Standard options {{< docs/shared lookup="visualizations/standard-options.md" source="grafana" version="" >}} -## Data links +### Data links {{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}} -## Value mappings +### Value mappings {{< docs/shared lookup="visualizations/value-mappings-options.md" source="grafana" version="" >}} -## Thresholds +### Thresholds {{< docs/shared lookup="visualizations/thresholds-options-2.md" source="grafana" version="" >}} -## Field overrides +### Field overrides {{< docs/shared lookup="visualizations/overrides-options.md" source="grafana" version="" >}} diff --git a/docs/sources/shared/visualizations/thresholds-options-2.md b/docs/sources/shared/visualizations/thresholds-options-2.md index d2ea9c2a44a..f6defc3928e 100644 --- a/docs/sources/shared/visualizations/thresholds-options-2.md +++ b/docs/sources/shared/visualizations/thresholds-options-2.md @@ -8,11 +8,11 @@ comments: | A threshold is a value or limit you set for a metric that’s reflected visually when it’s met or exceeded. Thresholds are one way you can conditionally style and color your visualizations based on query results. -Set the following options: +For each threshold, set the following options: -- **Value** - Set the value for each threshold. -- **Thresholds mode** - Choose from: - - **Absolute** - - **Percentage** +| Option | Description | +| --------------- | -------------------------------------------- | +| Value | Set the value for each threshold. | +| Thresholds mode | Choose from **Absolute** and **Percentage**. | To learn more, refer to [Configure thresholds](../../configure-thresholds/). diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 00000000000..aa19b0cbce6 --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,281 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.4.9'; +const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +self.addEventListener('install', function () { + self.skipWaiting(); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('message', async function (event) { + const clientId = event.source.id; + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }); + break; + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +self.addEventListener('fetch', function (event) { + const { request } = event; + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + // Generate unique request ID. + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId)); +}); + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + (async function () { + const responseClone = response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body] + ); + })(); + } + + return response; +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +async function getResponse(event, client, requestId) { + const { request } = event; + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone(); + + function passthrough() { + const headers = Object.fromEntries(requestClone.headers.entries()); + + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention']; + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer(); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer] + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'PASSTHROUGH': { + return passthrough(); + } + } + + return passthrough(); +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean))); + }); +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} From c822feff9edd6da63da44d935f7cac4365871f01 Mon Sep 17 00:00:00 2001 From: Serena <26805916+sleepyfoodie@users.noreply.github.com> Date: Wed, 25 Sep 2024 13:20:04 -0400 Subject: [PATCH 056/102] Home: add setup guide tab under home tab (#92947) * feat: add setup guide in home tab * chore: add feature toggle for setup guide * chore: add feature toggle for sub menu * chore: run pretier * chore: run i18n * chore: run generated files again * chore: update description * chore: update comment to trigger test flow * chore: trigger test * chore: fix styling --- .../configure-grafana/feature-toggles/index.md | 1 + .../grafana-data/src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/codeowners.go | 1 + pkg/services/featuremgmt/registry.go | 7 +++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++++ pkg/services/featuremgmt/toggles_gen.json | 16 ++++++++++++++++ pkg/services/navtree/navtreeimpl/navtree.go | 12 ++++++++++++ .../components/AppChrome/MegaMenu/MegaMenu.tsx | 4 +++- public/app/core/utils/navBarItem-translations.ts | 2 ++ public/locales/en-US/grafana.json | 3 +++ public/locales/pseudo-LOCALE/grafana.json | 3 +++ 12 files changed, 54 insertions(+), 1 deletion(-) 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 3102a626929..6edf736dadd 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -201,6 +201,7 @@ Experimental features might be changed or removed without prior notice. | `exploreLogsShardSplitting` | Used in Explore Logs to split queries into multiple queries based on the number of shards | | `exploreLogsAggregatedMetrics` | Used in Explore Logs to query by aggregated metrics | | `exploreLogsLimitedTimeRange` | Used in Explore Logs to limit the time range | +| `homeSetupGuide` | Used in Home for users who want to return to the onboarding flow or quickly find popular config pages | | `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index fe6aceaadec..5b7e7fc1dcb 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -209,6 +209,7 @@ export interface FeatureToggles { exploreLogsShardSplitting?: boolean; exploreLogsAggregatedMetrics?: boolean; exploreLogsLimitedTimeRange?: boolean; + homeSetupGuide?: boolean; appPlatformAccessTokens?: boolean; appSidecar?: boolean; groupAttributeSync?: boolean; diff --git a/pkg/services/featuremgmt/codeowners.go b/pkg/services/featuremgmt/codeowners.go index 2269fd6a1d1..83f9fda7ee2 100644 --- a/pkg/services/featuremgmt/codeowners.go +++ b/pkg/services/featuremgmt/codeowners.go @@ -28,4 +28,5 @@ const ( enterpriseDatasourcesSquad codeowner = "@grafana/enterprise-datasources" grafanaSharingSquad codeowner = "@grafana/sharing-squad" grafanaDatabasesFrontend codeowner = "@grafana/databases-frontend" + growthAndOnboarding codeowner = "@grafana/growth-and-onboarding" ) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index ba60f9270c8..7909994201f 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1438,6 +1438,13 @@ var ( FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, }, + { + Name: "homeSetupGuide", + Description: "Used in Home for users who want to return to the onboarding flow or quickly find popular config pages", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: growthAndOnboarding, + }, { Name: "appPlatformAccessTokens", Description: "Enables the use of access tokens for the App Platform", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 15adc71435a..d05dfd61e64 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -190,6 +190,7 @@ singleTopNav,experimental,@grafana/grafana-frontend-platform,false,false,true exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true +homeSetupGuide,experimental,@grafana/growth-and-onboarding,false,false,true appPlatformAccessTokens,experimental,@grafana/identity-access-team,false,false,false appSidecar,experimental,@grafana/explore-squad,false,false,false groupAttributeSync,experimental,@grafana/identity-access-team,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index d1bcec7d3d0..ae8f0c3d4e5 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -771,6 +771,10 @@ const ( // Used in Explore Logs to limit the time range FlagExploreLogsLimitedTimeRange = "exploreLogsLimitedTimeRange" + // FlagHomeSetupGuide + // Used in Home for users who want to return to the onboarding flow or quickly find popular config pages + FlagHomeSetupGuide = "homeSetupGuide" + // FlagAppPlatformAccessTokens // Enables the use of access tokens for the App Platform FlagAppPlatformAccessTokens = "appPlatformAccessTokens" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 4c8ad4a0a3f..f1f8041c5b5 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1433,6 +1433,22 @@ "hideFromAdminPage": true } }, + { + "metadata": { + "name": "homeSetupGuide", + "resourceVersion": "1726258153467", + "creationTimestamp": "2024-09-10T15:46:32Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-09-13 20:09:13.467989 +0000 UTC" + } + }, + "spec": { + "description": "Used in Home for users who want to return to the onboarding flow or quickly find popular config pages", + "stage": "experimental", + "codeowner": "@grafana/growth-and-onboarding", + "frontend": true + } + }, { "metadata": { "name": "idForwarding", diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 6bbc3277383..d33e93f763b 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -202,6 +202,18 @@ func (s *ServiceImpl) getHomeNode(c *contextmodel.ReqContext, prefs *pref.Prefer Icon: "home-alt", SortWeight: navtree.WeightHome, } + ctx := c.Req.Context() + if s.features.IsEnabled(ctx, featuremgmt.FlagHomeSetupGuide) { + var children []*navtree.NavLink + // setup guide (a submenu item under Home) + children = append(children, &navtree.NavLink{ + Id: "home-setup-guide", + Text: "Setup guide", + Url: homeUrl + "/setup-guide", + SortWeight: navtree.WeightHome, + }) + homeNode.Children = children + } return homeNode } diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx index eafdf5426c2..d7deaaeb3cd 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx @@ -123,7 +123,7 @@ export const MegaMenu = memo(
    {navItems.map((link, index) => ( - + {index === 0 && ( ({ }), dockMenuButton: css({ display: 'none', + position: 'relative', + top: theme.spacing(1), [theme.breakpoints.up('xl')]: { display: 'inline-flex', diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index eca9f15e11a..57be9f87a5f 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -11,6 +11,8 @@ export function getNavTitle(navId: string | undefined) { switch (navId) { case 'home': return t('nav.home.title', 'Home'); + case 'home-setup-guide': + return t('nav.setup-guide.title', 'Setup guide'); case 'new': return t('nav.new.title', 'New'); case 'create': diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 66dd73190d3..455063f88b9 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1745,6 +1745,9 @@ "subtitle": "Use service accounts to run automated workloads in Grafana", "title": "Service accounts" }, + "setup-guide": { + "title": "Setup guide" + }, "shared-dashboard": { "subtitle": "Manage your organization's externally shared dashboards", "title": "Shared dashboards" diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 9cb08bd3fc4..be08f9a971b 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1745,6 +1745,9 @@ "subtitle": "Ůşę şęřvįčę äččőūʼnŧş ŧő řūʼn äūŧőmäŧęđ ŵőřĸľőäđş įʼn Ğřäƒäʼnä", "title": "Ŝęřvįčę äččőūʼnŧş" }, + "setup-guide": { + "title": "Ŝęŧūp ģūįđę" + }, "shared-dashboard": { "subtitle": "Mäʼnäģę yőūř őřģäʼnįžäŧįőʼn'ş ęχŧęřʼnäľľy şĥäřęđ đäşĥþőäřđş", "title": "Ŝĥäřęđ đäşĥþőäřđş" From b68d9630fb5267b850372a0bcbf218cfd0b4b8cf Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Wed, 25 Sep 2024 20:08:05 +0200 Subject: [PATCH 057/102] PanelEdit: Hide overflow for query options in small viewports (#93700) Hide overflow for query options in small viewports --- .../components/QueryOperationRow/QueryOperationRowHeader.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/public/app/core/components/QueryOperationRow/QueryOperationRowHeader.tsx b/public/app/core/components/QueryOperationRow/QueryOperationRowHeader.tsx index 077614193b1..abd98c64977 100644 --- a/public/app/core/components/QueryOperationRow/QueryOperationRowHeader.tsx +++ b/public/app/core/components/QueryOperationRow/QueryOperationRowHeader.tsx @@ -111,6 +111,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ label: Column; display: flex; align-items: center; + overflow: hidden; `, dragIcon: css` cursor: grab; From 57ab3541395a8f92dbab0957c3af1021925966db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 25 Sep 2024 20:14:52 +0200 Subject: [PATCH 058/102] DashboardScene: Fixes issue with dashboard starting with auto refresh set (#93756) --- .../transformSceneToSaveModel.test.ts.snap | 6 +++--- .../dashboard/state/DashboardMigrator.test.ts | 15 +++++++++++++++ .../features/dashboard/state/DashboardMigrator.ts | 9 ++++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap index 9b5c0a119ae..8ab27fc7a70 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap @@ -280,7 +280,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back ], "preload": false, "refresh": "", - "schemaVersion": 39, + "schemaVersion": 40, "tags": [ "templating", "gdev", @@ -548,7 +548,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho ], "preload": false, "refresh": "5m", - "schemaVersion": 39, + "schemaVersion": 40, "tags": [ "tag1", "tag2", @@ -906,7 +906,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr ], "preload": false, "refresh": "", - "schemaVersion": 39, + "schemaVersion": 40, "tags": [ "gdev", "graph-ng", diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index b3aef514daf..6ba62a860e5 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -2413,6 +2413,21 @@ describe('when migrating table cell display mode to cell options', () => { }); }); +describe('when migrating variable refresh to on dashboard load', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + //@ts-ignore + refresh: false, + }); + }); + + it('should migrate to empty string', () => { + expect(model.refresh).toBe(''); + }); +}); + function createRow(options: any, panelDescriptions: any[]) { const PANEL_HEIGHT_STEP = GRID_CELL_HEIGHT + GRID_CELL_VMARGIN; const { collapse, showTitle, title, repeat, repeatIteration } = options; diff --git a/public/app/features/dashboard/state/DashboardMigrator.ts b/public/app/features/dashboard/state/DashboardMigrator.ts index bd04031eebd..618d684ab16 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.ts @@ -81,7 +81,7 @@ type PanelSchemeUpgradeHandler = (panel: PanelModel) => PanelModel; * kinds/dashboard/dashboard_kind.cue * Example PR: #87712 */ -export const DASHBOARD_SCHEMA_VERSION = 39; +export const DASHBOARD_SCHEMA_VERSION = 40; export class DashboardMigrator { dashboard: DashboardModel; @@ -904,6 +904,13 @@ export class DashboardMigrator { }); } + if (oldVersion < 40) { + // In old ashboards refresh property can be a boolean + if (typeof this.dashboard.refresh !== 'string') { + this.dashboard.refresh = ''; + } + } + /** * -==- Add migration here -==- * Your migration should go below the previous From 277d82db9a15107aca46884fe8ab30ae67caea93 Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Wed, 25 Sep 2024 16:20:35 -0400 Subject: [PATCH 059/102] Live: Add ha_prefix (#93759) --- conf/defaults.ini | 3 +++ conf/sample.ini | 3 +++ pkg/services/live/live.go | 13 ++++++++++--- pkg/services/live/managedstream/cache_redis.go | 12 +++++++----- .../live/managedstream/cache_redis_test.go | 15 ++++++++++++++- pkg/setting/setting.go | 3 +++ 6 files changed, 40 insertions(+), 9 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 79ecacdf39b..3e7fc20ef90 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1764,6 +1764,9 @@ ha_engine_address = "127.0.0.1:6379" # ha_engine_password allows setting an optional password to authenticate with the engine ha_engine_password = "" +# ha_prefix is a prefix for keys in the HA engine. It's used to separate keys for different Grafana instances. +ha_prefix = + #################################### Grafana Image Renderer Plugin ########################## [plugin.grafana-image-renderer] # Instruct headless browser instance to use a default timezone when not provided by Grafana, e.g. when rendering panel image of alert. diff --git a/conf/sample.ini b/conf/sample.ini index 3368d7e288e..a36d17ff618 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -1718,6 +1718,9 @@ timeout = 30s # ha_engine_password allows setting an optional password to authenticate with the engine ;ha_engine_password = "" +# ha_prefix is a prefix for keys in the HA engine. It's used to separate keys for different Grafana instances. +;ha_prefix = + #################################### Grafana Image Renderer Plugin ########################## [plugin.grafana-image-renderer] # Instruct headless browser instance to use a default timezone when not provided by Grafana, e.g. when rendering panel image of alert. diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index 56eedf5e1ea..feabf87ad83 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -97,6 +97,11 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r }, usageStatsService: usageStatsService, orgService: orgService, + keyPrefix: "gf_live", + } + + if cfg.LiveHAPrefix != "" { + g.keyPrefix = cfg.LiveHAPrefix + ".gf_live" } logger.Debug("GrafanaLive initialization", "ha", g.IsHA()) @@ -153,7 +158,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r managedStreamRunner = managedstream.NewRunner( g.Publish, channelLocalPublisher, - managedstream.NewRedisFrameCache(redisClient), + managedstream.NewRedisFrameCache(redisClient, g.keyPrefix), ) } else { managedStreamRunner = managedstream.NewRunner( @@ -344,7 +349,7 @@ func setupRedisLiveEngine(g *GrafanaLive, node *centrifuge.Node) error { } broker, err := centrifuge.NewRedisBroker(node, centrifuge.RedisBrokerConfig{ - Prefix: "gf_live", + Prefix: g.keyPrefix, Shards: redisShards, }) if err != nil { @@ -354,7 +359,7 @@ func setupRedisLiveEngine(g *GrafanaLive, node *centrifuge.Node) error { node.SetBroker(broker) presenceManager, err := centrifuge.NewRedisPresenceManager(node, centrifuge.RedisPresenceManagerConfig{ - Prefix: "gf_live", + Prefix: g.keyPrefix, Shards: redisShards, }) if err != nil { @@ -385,6 +390,8 @@ type GrafanaLive struct { queryDataService query.Service orgService org.Service + keyPrefix string + node *centrifuge.Node surveyCaller *survey.Caller diff --git a/pkg/services/live/managedstream/cache_redis.go b/pkg/services/live/managedstream/cache_redis.go index b547a11cd20..34b16991671 100644 --- a/pkg/services/live/managedstream/cache_redis.go +++ b/pkg/services/live/managedstream/cache_redis.go @@ -18,11 +18,13 @@ type RedisFrameCache struct { mu sync.RWMutex redisClient *redis.Client frames map[int64]map[string]data.FrameJSONCache + keyPrefix string } // NewRedisFrameCache ... -func NewRedisFrameCache(redisClient *redis.Client) *RedisFrameCache { +func NewRedisFrameCache(redisClient *redis.Client, keyPrefix string) *RedisFrameCache { return &RedisFrameCache{ + keyPrefix: keyPrefix, frames: map[int64]map[string]data.FrameJSONCache{}, redisClient: redisClient, } @@ -43,7 +45,7 @@ func (c *RedisFrameCache) GetActiveChannels(orgID int64) (map[string]json.RawMes } func (c *RedisFrameCache) GetFrame(ctx context.Context, orgID int64, channel string) (json.RawMessage, bool, error) { - key := getCacheKey(orgchannel.PrependOrgID(orgID, channel)) + key := c.getCacheKey(orgchannel.PrependOrgID(orgID, channel)) cmd := c.redisClient.HGetAll(ctx, key) result, err := cmd.Result() if err != nil { @@ -69,7 +71,7 @@ func (c *RedisFrameCache) Update(ctx context.Context, orgID int64, channel strin stringSchema := string(jsonFrame.Bytes(data.IncludeSchemaOnly)) - key := getCacheKey(orgchannel.PrependOrgID(orgID, channel)) + key := c.getCacheKey(orgchannel.PrependOrgID(orgID, channel)) pipe := c.redisClient.TxPipeline() defer func() { _ = pipe.Close() }() @@ -107,6 +109,6 @@ func (c *RedisFrameCache) Update(ctx context.Context, orgID int64, channel strin return true, nil } -func getCacheKey(channelID string) string { - return "gf_live.managed_stream." + channelID +func (c *RedisFrameCache) getCacheKey(channelID string) string { + return c.keyPrefix + ".managed_stream." + channelID } diff --git a/pkg/services/live/managedstream/cache_redis_test.go b/pkg/services/live/managedstream/cache_redis_test.go index d7c3f9fddbb..bbeececeb07 100644 --- a/pkg/services/live/managedstream/cache_redis_test.go +++ b/pkg/services/live/managedstream/cache_redis_test.go @@ -2,6 +2,7 @@ package managedstream import ( "os" + "strings" "testing" "github.com/go-redis/redis/v8" @@ -30,7 +31,19 @@ func TestIntegrationRedisCacheStorage(t *testing.T) { Addr: addr, DB: db, }) - c := NewRedisFrameCache(redisClient) + c := NewRedisFrameCache(redisClient, "A") require.NotNil(t, c) testFrameCache(t, c) + + keys, err := redisClient.Keys(redisClient.Context(), "*").Result() + if err != nil { + require.NoError(t, err) + } + + require.NotZero(t, len(keys)) + + for _, key := range keys { + require.Equal(t, "A", key[:1]) + require.True(t, strings.HasPrefix(key, "A"), "key", key) + } } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 4c5f3bf5a48..2488ea5fddd 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -423,6 +423,8 @@ type Cfg struct { // LiveHAEngine is a type of engine to use to achieve HA with Grafana Live. // Zero value means in-memory single node setup. LiveHAEngine string + // LiveHAPRefix is a prefix for HA engine keys. + LiveHAPrefix string // LiveHAEngineAddress is a connection address for Live HA engine. LiveHAEngineAddress string LiveHAEnginePassword string @@ -2024,6 +2026,7 @@ func (cfg *Cfg) readLiveSettings(iniFile *ini.File) error { default: return fmt.Errorf("unsupported live HA engine type: %s", cfg.LiveHAEngine) } + cfg.LiveHAPrefix = section.Key("ha_prefix").MustString("") cfg.LiveHAEngineAddress = section.Key("ha_engine_address").MustString("127.0.0.1:6379") cfg.LiveHAEnginePassword = section.Key("ha_engine_password").MustString("") From b9964865cbc7ef243f9ca523c5c16eabde598efb Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Wed, 25 Sep 2024 22:31:02 +0200 Subject: [PATCH 060/102] Alerting: Copy alert rule metadata when the rule is updated via provisioning API (#93723) Alerting: Copy alert rule metadata when the rule is updated --- pkg/services/ngalert/models/alert_rule.go | 7 ++ .../ngalert/models/alert_rule_test.go | 9 ++- .../ngalert/provisioning/alert_rules.go | 8 +++ .../ngalert/provisioning/alert_rules_test.go | 68 +++++++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index fe9be233d03..bec7137c4b0 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -795,6 +795,13 @@ func PatchPartialAlertRule(existingRule *AlertRule, ruleToPatch *AlertRuleWithOp if !ruleToPatch.HasPause { ruleToPatch.IsPaused = existingRule.IsPaused } + + // Currently metadata contains only editor settings, so we can just copy it. + // If we add more fields to metadata, we might need to handle them separately, + // and/or merge or update their values. + if ruleToPatch.Metadata == (AlertRuleMetadata{}) { + ruleToPatch.Metadata = existingRule.Metadata + } } func ValidateRuleGroupInterval(intervalSeconds, baseIntervalSeconds int64) error { diff --git a/pkg/services/ngalert/models/alert_rule_test.go b/pkg/services/ngalert/models/alert_rule_test.go index 3cfbb07a392..652e0c23d41 100644 --- a/pkg/services/ngalert/models/alert_rule_test.go +++ b/pkg/services/ngalert/models/alert_rule_test.go @@ -253,10 +253,17 @@ func TestPatchPartialAlertRule(t *testing.T) { r.IsPaused = true }, }, + { + name: "No metadata", + mutator: func(r *AlertRuleWithOptionals) { + r.Metadata = AlertRuleMetadata{} + }, + }, } gen := RuleGen.With( - RuleMuts.WithFor(time.Duration(rand.Int63n(1000) + 1)), + RuleMuts.WithFor(time.Duration(rand.Int63n(1000)+1)), + RuleMuts.WithEditorSettingsSimplifiedQueryAndExpressionsSection(true), ) for _, testCase := range testCases { diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index 47a481b6efa..ee56a6e134b 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -598,6 +598,14 @@ func (service *AlertRuleService) UpdateAlertRule(ctx context.Context, user ident rule.Updated = time.Now() rule.ID = storedRule.ID rule.IntervalSeconds = storedRule.IntervalSeconds + + // Currently metadata contains only editor settings, so we can just copy it. + // If we add more fields to metadata, we might need to handle them separately, + // and/or merge or update their values. + if rule.Metadata == (models.AlertRuleMetadata{}) { + rule.Metadata = storedRule.Metadata + } + err = rule.SetDashboardAndPanelFromAnnotations() if err != nil { return models.AlertRule{}, err diff --git a/pkg/services/ngalert/provisioning/alert_rules_test.go b/pkg/services/ngalert/provisioning/alert_rules_test.go index 5728b976230..ed9bf7e3514 100644 --- a/pkg/services/ngalert/provisioning/alert_rules_test.go +++ b/pkg/services/ngalert/provisioning/alert_rules_test.go @@ -183,6 +183,74 @@ func TestAlertRuleService(t *testing.T) { require.Equal(t, int64(2), readGroup.Rules[0].Version) }) + t.Run("updating a group should not override its rules editor settings", func(t *testing.T) { + namespaceUID := "my-namespace" + groupTitle := "test-group-123" + + // create the rule group via the rule store, to persist the editor settings + rule := createTestRule(util.GenerateShortUID(), groupTitle, orgID, namespaceUID) + ruleMetadata := models.AlertRuleMetadata{ + EditorSettings: models.EditorSettings{ + SimplifiedQueryAndExpressionsSection: true, + }, + } + rule.Metadata = ruleMetadata + r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), []models.AlertRule{rule}) + require.NoError(t, err) + require.Len(t, r, 1) + + // Set the UID for the rule to update it + rule.UID = r[0].UID + // clear the metadata to check that the existing metadata is not overridden + rule.Metadata = models.AlertRuleMetadata{} + + // Now update the rule group with the rule to update its metadata + group := models.AlertRuleGroup{ + Title: groupTitle, + Interval: 60, + FolderUID: namespaceUID, + Rules: []models.AlertRule{rule}, + } + + err = ruleService.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceAPI) + require.NoError(t, err) + + readGroup, err := ruleService.GetRuleGroup(context.Background(), u, namespaceUID, groupTitle) + require.NoError(t, err) + require.NotEmpty(t, readGroup.Rules) + require.Len(t, readGroup.Rules, 1) + + // check that the metadata is still there + require.Equal(t, ruleMetadata, readGroup.Rules[0].Metadata) + }) + + t.Run("updating a rule should not override its editor settings", func(t *testing.T) { + rule := createTestRule(util.GenerateShortUID(), "my-group", orgID, "my-folder") + ruleMetadata := models.AlertRuleMetadata{ + EditorSettings: models.EditorSettings{ + SimplifiedQueryAndExpressionsSection: true, + }, + } + rule.Metadata = ruleMetadata + r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), []models.AlertRule{rule}) + require.NoError(t, err) + require.Len(t, r, 1) + + // Set the UID for the rule to update it + rule.UID = r[0].UID + // clear the metadata to check that the existing metadata is not overridden + rule.Metadata = models.AlertRuleMetadata{} + + // Update the rule + _, err = ruleService.UpdateAlertRule(context.Background(), u, rule, models.ProvenanceAPI) + require.NoError(t, err) + + // Read the rule and check that the editor settings are preserved + readRule, _, err := ruleService.GetAlertRule(context.Background(), u, rule.UID) + require.NoError(t, err) + require.Equal(t, ruleMetadata, readRule.Metadata) + }) + t.Run("updating a group to temporarily overlap rule names should not throw unique constraint", func(t *testing.T) { var orgID int64 = 1 group := models.AlertRuleGroup{ From 25ca760ee17410d2a11eca78ebf1011b6ca44719 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed, 25 Sep 2024 16:39:14 -0400 Subject: [PATCH 061/102] Docs: Fix broken links (#93767) --- .../dashboards/search-dashboards/index.md | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/docs/sources/dashboards/search-dashboards/index.md b/docs/sources/dashboards/search-dashboards/index.md index 2524de95867..f1f78724204 100644 --- a/docs/sources/dashboards/search-dashboards/index.md +++ b/docs/sources/dashboards/search-dashboards/index.md @@ -12,6 +12,22 @@ labels: menutitle: Search dashboards title: Search dashboards and folders weight: 400 +refs: + service-accounts: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/service-accounts/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/account-management/authentication-and-permissions/service-accounts/ + config-file: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#configuration-file-location + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#configuration-file-location + feature-toggles: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#feature_toggles + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#feature_toggles --- # Search dashboards and folders @@ -58,7 +74,7 @@ You can search for a dashboard by the title of a panel that appears in a dashboa If a panel's title matches your search query, the dashboard appears in the search results. This feature is available by default in Grafana Cloud and in Grafana OSS v9.1 and higher, you access this feature by enabling the `panelTitleSearch` feature toggle. -For more information about enabling panel title search, refer to [Enable the panelTitleSearch feature toggle.](#enable-panelTitleSearch-feature-toggle) +For more information about enabling panel title search, refer to [Enable the panelTitleSearch feature toggle.](#enable-the-paneltitlesearch-feature-toggle) The following image shows the search results when you search using panel title. @@ -70,13 +86,13 @@ Complete the following steps to enable the `panelTitleSearch` feature toggle. **Before you begin:** -- If you are running Grafana Enterprise with RBAC, enable [service accounts]({{< relref "../administration/service-accounts/" >}}). +- If you are running Grafana Enterprise with RBAC, enable [service accounts](ref:service-accounts). **To enable the panelTitleSearch feature toggle:** -1. Open the Grafana [configuration file]({{< relref "../setup-grafana/configure-grafana/#configuration-file-location" >}}). +1. Open the Grafana [configuration file](ref:config-file). -1. Locate the [feature_toggles]({{< relref "../setup-grafana/configure-grafana/#feature_toggles" >}}) section. +1. Locate the [feature_toggles](ref:feature-toggles) section. 1. Add the following parameter to the `feature_toggles` section: From a21a232a8e57eb1cd1f664806e60be4f38ddbca8 Mon Sep 17 00:00:00 2001 From: Jeff Levin Date: Wed, 25 Sep 2024 15:21:39 -0800 Subject: [PATCH 062/102] Revert read replica POC (#93551) * Revert "chore: add replDB to team service (#91799)" This reverts commit c6ae2d7999aa6fc797db39e9d66c6fea70278f83. * Revert "experiment: use read replica for Get and Find Dashboards (#91706)" This reverts commit 54177ca619dbb5ded2dcb158405802d8dbdbc982. * Revert "QuotaService: refactor to use ReplDB for Get queries (#91333)" This reverts commit 299c142f6a6e8c5673cfdea9f87b56ac304f9834. * Revert "refactor replCfg to look more like plugins/plugin config (#91142)" This reverts commit ac0b4bb34d495914cbe8daad85b7c75c31e8070d. * Revert "chore (replstore): fix registration with multiple sql drivers, again (#90990)" This reverts commit daedb358dded00d349d9fac6106aaaa6bf18322e. * Revert "Chore (sqlstore): add validation and testing for repl config (#90683)" This reverts commit af19f039b62d9945377292a8e679ee258fd56b3d. * Revert "ReplStore: Add support for round robin load balancing between multiple read replicas (#90530)" This reverts commit 27b52b1507f5218a7b38046b4d96bc004d949d46. * Revert "DashboardStore: Use ReplDB and get dashboard quotas from the ReadReplica (#90235)" This reverts commit 8a6107cd35f6444c0674ee4230d3d6bcfbbd4a58. * Revert "accesscontrol service read replica (#89963)" This reverts commit 77a4869fcadf13827d76d5767d4de74812d6dd6d. * Revert "Fix: add mapping for the new mysqlRepl driver (#89551)" This reverts commit ab5a079bcc5b0f0a6929f0a3742eb2859d4a3498. * Revert "fix: sql instrumentation dual registration error (#89508)" This reverts commit d988f5c3b064fade6e96511e0024190c22d48e50. * Revert "Experimental Feature Toggle: databaseReadReplica (#89232)" This reverts commit 50244ed4a1435cbf3e3c87d4af34fd7937f7c259. --- .golangci.toml | 1 + .../feature-toggles/index.md | 1 - .../src/types/featureToggles.gen.ts | 1 - pkg/api/dashboard_test.go | 2 +- pkg/api/folder_bench_test.go | 20 +- pkg/api/org_users_test.go | 2 +- .../commands/conflict_user_command.go | 16 +- .../commands/conflict_user_command_test.go | 6 +- pkg/infra/db/db.go | 9 - pkg/infra/db/dbrepl.go | 19 -- .../statscollector/concurrent_users_test.go | 4 +- pkg/server/test_env.go | 3 - pkg/server/wire.go | 8 +- pkg/services/accesscontrol/acimpl/service.go | 4 +- .../acimpl/service_bench_test.go | 2 +- .../accesscontrol/acimpl/service_test.go | 4 +- .../accesscontrol/database/database.go | 20 +- .../accesscontrol/database/database_test.go | 4 +- .../database/externalservices.go | 4 +- .../database/externalservices_test.go | 17 +- .../accesscontrol/migrator/migrator.go | 6 +- .../migrator/migrator_bench_test.go | 2 +- .../accesscontrol/migrator/migrator_test.go | 2 +- .../resourcepermissions/service_test.go | 2 +- .../resourcepermissions/store_bench_test.go | 2 +- .../accesscontrol/accesscontrol_test.go | 2 +- .../annotationsimpl/annotations_test.go | 7 +- .../loki/historian_store_test.go | 8 +- .../annotationsimpl/xorm_store_test.go | 2 +- pkg/services/annotations/testutil/testutil.go | 7 +- pkg/services/dashboards/database/database.go | 79 +++-- .../database/database_folder_test.go | 42 +-- .../database/database_provisioning_test.go | 2 +- .../dashboards/database/database_test.go | 28 +- .../dashboards/service/dashboard_service.go | 1 + .../dashboard_service_integration_test.go | 30 +- .../service/service_test.go | 2 +- pkg/services/featuremgmt/registry.go | 7 - pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 - pkg/services/featuremgmt/toggles_gen.json | 1 + .../folderimpl/dashboard_folder_store_test.go | 6 +- pkg/services/folder/folderimpl/folder_test.go | 12 +- .../libraryelements_delete_test.go | 5 +- .../libraryelements_get_test.go | 5 +- .../libraryelements/libraryelements_test.go | 26 +- .../librarypanels/librarypanels_test.go | 23 +- .../ngalert/api/api_provisioning_test.go | 5 +- .../ngalert/provisioning/alert_rules_test.go | 13 +- pkg/services/ngalert/store/alert_rule_test.go | 37 +-- pkg/services/ngalert/store/testing.go | 4 +- pkg/services/ngalert/tests/util.go | 5 +- pkg/services/ngalert/testutil/testutil.go | 4 +- pkg/services/org/orgimpl/store_test.go | 2 +- .../publicdashboards/api/query_test.go | 2 +- .../database/database_test.go | 80 +++-- .../publicdashboards/service/common_test.go | 4 +- .../publicdashboards/service/query_test.go | 15 +- .../publicdashboards/service/service_test.go | 20 +- pkg/services/quota/quotaimpl/quota.go | 2 +- pkg/services/quota/quotaimpl/quota_test.go | 7 +- pkg/services/quota/quotaimpl/store.go | 10 +- pkg/services/quota/quotaimpl/store_test.go | 2 +- pkg/services/searchV2/index_test.go | 5 +- pkg/services/serviceaccounts/tests/common.go | 10 +- pkg/services/sqlstore/database_config.go | 70 +---- pkg/services/sqlstore/database_config_test.go | 91 ------ pkg/services/sqlstore/database_wrapper.go | 21 -- pkg/services/sqlstore/migrator/dialect.go | 17 +- .../sqlstore/permissions/dashboard_test.go | 2 +- .../permissions/dashboards_bench_test.go | 2 +- pkg/services/sqlstore/replstore.go | 277 ------------------ pkg/services/sqlstore/replstore_test.go | 74 ----- pkg/services/stats/statsimpl/stats.go | 13 +- pkg/services/stats/statsimpl/stats_test.go | 12 +- pkg/services/team/teamimpl/store.go | 47 ++- pkg/services/team/teamimpl/store_test.go | 33 +-- pkg/services/team/teamimpl/team.go | 2 +- pkg/services/user/userimpl/store_test.go | 6 +- .../api/alerting/api_alertmanager_test.go | 8 +- pkg/tests/api/correlations/common_test.go | 5 +- .../api/dashboards/api_dashboards_test.go | 8 +- pkg/tests/api/folders/api_folder_test.go | 13 +- pkg/tests/api/plugins/api_plugins_test.go | 8 +- pkg/tests/api/stats/admin_test.go | 8 +- pkg/tests/apis/helper.go | 8 +- pkg/tests/testinfra/testinfra.go | 2 +- pkg/tests/utils.go | 11 +- 88 files changed, 416 insertions(+), 1010 deletions(-) delete mode 100644 pkg/infra/db/dbrepl.go delete mode 100644 pkg/services/sqlstore/replstore.go delete mode 100644 pkg/services/sqlstore/replstore_test.go diff --git a/.golangci.toml b/.golangci.toml index dc3ebd63afc..693ce6bc26e 100644 --- a/.golangci.toml +++ b/.golangci.toml @@ -1,6 +1,7 @@ [run] timeout = "20m" concurrency = 10 +allow-parallel-runners = true [linters-settings.exhaustive] default-signifies-exhaustive = true 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 6edf736dadd..0ccd6b5c223 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -191,7 +191,6 @@ Experimental features might be changed or removed without prior notice. | `alertingCentralAlertHistory` | Enables the new central alert history. | | `pinNavItems` | Enables pinning of nav items | | `failWrongDSUID` | Throws an error if a datasource has an invalid UIDs | -| `databaseReadReplica` | Use a read replica for some database queries. | | `alertingApiServer` | Register Alerting APIs with the K8s API server | | `dashboardRestoreUI` | Enables the frontend to be able to restore a recently deleted dashboard | | `dataplaneAggregator` | Enable grafana dataplane aggregator | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 5b7e7fc1dcb..d4706619503 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -193,7 +193,6 @@ export interface FeatureToggles { openSearchBackendFlowEnabled?: boolean; ssoSettingsLDAP?: boolean; failWrongDSUID?: boolean; - databaseReadReplica?: boolean; zanzana?: boolean; passScopeToDashboardApi?: boolean; alertingApiServer?: boolean; diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 617d836f22a..2ad87a053c6 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -820,7 +820,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr features := featuremgmt.WithFeatures() var err error if dashboardStore == nil { - sql, cfg := db.InitTestReplDBWithCfg(t) + sql, cfg := db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) dashboardStore, err = database.ProvideDashboardStore(sql, cfg, features, tagimpl.ProvideService(sql), quotaService) require.NoError(t, err) diff --git a/pkg/api/folder_bench_test.go b/pkg/api/folder_bench_test.go index 059a0260287..58b4b7040d1 100644 --- a/pkg/api/folder_bench_test.go +++ b/pkg/api/folder_bench_test.go @@ -69,7 +69,7 @@ const ( ) type benchScenario struct { - db db.ReplDB + db db.DB // signedInUser is the user that is signed in to the server cfg *setting.Cfg signedInUser *user.SignedInUser @@ -202,7 +202,7 @@ func BenchmarkFolderListAndSearch(b *testing.B) { func setupDB(b testing.TB) benchScenario { b.Helper() - db, cfg := sqlstore.InitTestReplDB(b) + db, cfg := sqlstore.InitTestDB(b) IDs := map[int64]struct{}{} opts := sqlstore.NativeSettingsForDialect(db.GetDialect()) @@ -451,26 +451,26 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog quotaSrv := quotatest.New(false, nil) - dashStore, err := database.ProvideDashboardStore(sc.db, sc.cfg, features, tagimpl.ProvideService(sc.db.DB()), quotaSrv) + dashStore, err := database.ProvideDashboardStore(sc.db, sc.cfg, features, tagimpl.ProvideService(sc.db), quotaSrv) require.NoError(b, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sc.db.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sc.db) ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()) - folderServiceWithFlagOn := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, sc.db.DB(), features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + folderServiceWithFlagOn := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, sc.db, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) cfg := setting.NewCfg() actionSets := resourcepermissions.NewActionSetService(features) acSvc := acimpl.ProvideOSSService( sc.cfg, acdb.ProvideService(sc.db), actionSets, localcache.ProvideService(), - features, tracing.InitializeTracerForTest(), zanzana.NewNoopClient(), sc.db.DB(), permreg.ProvidePermissionRegistry(), + features, tracing.InitializeTracerForTest(), zanzana.NewNoopClient(), sc.db, permreg.ProvidePermissionRegistry(), ) folderPermissions, err := ossaccesscontrol.ProvideFolderPermissions( - cfg, features, routing.NewRouteRegister(), sc.db.DB(), ac, license, &dashboards.FakeDashboardStore{}, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets) + cfg, features, routing.NewRouteRegister(), sc.db, ac, license, &dashboards.FakeDashboardStore{}, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets) require.NoError(b, err) dashboardPermissions, err := ossaccesscontrol.ProvideDashboardPermissions( - cfg, features, routing.NewRouteRegister(), sc.db.DB(), ac, license, &dashboards.FakeDashboardStore{}, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets) + cfg, features, routing.NewRouteRegister(), sc.db, ac, license, &dashboards.FakeDashboardStore{}, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets) require.NoError(b, err) dashboardSvc, err := dashboardservice.ProvideDashboardServiceImpl( @@ -486,10 +486,10 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog hs := &HTTPServer{ CacheService: localcache.New(5*time.Minute, 10*time.Minute), Cfg: sc.cfg, - SQLStore: sc.db.DB(), + SQLStore: sc.db, Features: features, QuotaService: quotaSrv, - SearchService: search.ProvideService(sc.cfg, sc.db.DB(), starSvc, dashboardSvc), + SearchService: search.ProvideService(sc.cfg, sc.db, starSvc, dashboardSvc), folderService: folderServiceWithFlagOn, DashboardService: dashboardSvc, } diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 3c4caa64ecc..ea5f4930661 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -41,7 +41,7 @@ func setUpGetOrgUsersDB(t *testing.T, sqlStore db.DB, cfg *setting.Cfg) { cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = int(testOrgID) - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(sqlStore), cfg) + quotaService := quotaimpl.ProvideService(sqlStore, cfg) orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command.go b/pkg/cmd/grafana-cli/commands/conflict_user_command.go index 01eb1336ec4..eb74c6f11f6 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command.go @@ -71,7 +71,7 @@ func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx if err != nil { return nil, fmt.Errorf("%v: %w", "failed to load configuration", err) } - s, replstore, err := getSqlStore(cfg, tracer, features) + s, err := getSqlStore(cfg, tracer, features) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get to sql", err) } @@ -79,14 +79,14 @@ func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get users with conflicting logins", err) } - quotaService := quotaimpl.ProvideService(replstore, cfg) + quotaService := quotaimpl.ProvideService(s, cfg) userService, err := userimpl.ProvideService(s, nil, cfg, nil, nil, tracer, quotaService, supportbundlestest.NewFakeBundleService()) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get user service", err) } routing := routing.ProvideRegister() - acService, err := acimpl.ProvideService(cfg, replstore, routing, nil, nil, nil, features, tracer, zanzana.NewNoopClient(), permreg.ProvidePermissionRegistry()) + acService, err := acimpl.ProvideService(cfg, s, routing, nil, nil, nil, features, tracer, zanzana.NewNoopClient(), permreg.ProvidePermissionRegistry()) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get access control", err) } @@ -95,15 +95,9 @@ func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx return &resolver, nil } -func getSqlStore(cfg *setting.Cfg, tracer tracing.Tracer, features featuremgmt.FeatureToggles) (*sqlstore.SQLStore, *sqlstore.ReplStore, error) { +func getSqlStore(cfg *setting.Cfg, tracer tracing.Tracer, features featuremgmt.FeatureToggles) (*sqlstore.SQLStore, error) { bus := bus.ProvideBus(tracer) - ss, err := sqlstore.ProvideService(cfg, features, &migrations.OSSMigrations{}, bus, tracer) - if err != nil { - return nil, nil, err - } - - replStore, err := sqlstore.ProvideServiceWithReadReplica(ss, cfg, features, &migrations.OSSMigrations{}, bus, tracer) - return ss, replStore, err + return sqlstore.ProvideService(cfg, features, &migrations.OSSMigrations{}, bus, tracer) } func runListConflictUsers() func(context *cli.Context) error { diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go index a43a07e1e53..c0aafacd3c0 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go @@ -636,7 +636,7 @@ func TestIntegrationMergeUser(t *testing.T) { } t.Run("should be able to merge user", func(t *testing.T) { // Restore after destructive operation - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) teamSvc, err := teamimpl.ProvideService(sqlStore, setting.NewCfg(), tracing.InitializeTracerForTest()) require.NoError(t, err) team1, err := teamSvc.CreateTeam(context.Background(), "team1 name", "", 1) @@ -714,10 +714,10 @@ func TestIntegrationMergeUser(t *testing.T) { } // get users - conflictUsers, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore.SQLStore) + conflictUsers, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore) require.NoError(t, err) r := ConflictResolver{ - Store: sqlStore.SQLStore, + Store: sqlStore, userService: usertest.NewUserServiceFake(), ac: actest.FakeService{}, } diff --git a/pkg/infra/db/db.go b/pkg/infra/db/db.go index c642a411840..5f61d5d3847 100644 --- a/pkg/infra/db/db.go +++ b/pkg/infra/db/db.go @@ -59,15 +59,6 @@ func InitTestDB(t sqlutil.ITestDB, opts ...InitTestDBOpt) *sqlstore.SQLStore { return db } -func InitTestReplDBWithCfg(t sqlutil.ITestDB, opts ...InitTestDBOpt) (*sqlstore.ReplStore, *setting.Cfg) { - return sqlstore.InitTestReplDB(t, opts...) -} - -func InitTestReplDB(t sqlutil.ITestDB, opts ...InitTestDBOpt) *sqlstore.ReplStore { - db, _ := InitTestReplDBWithCfg(t, opts...) - return db -} - func InitTestDBWithCfg(t sqlutil.ITestDB, opts ...InitTestDBOpt) (*sqlstore.SQLStore, *setting.Cfg) { return sqlstore.InitTestDB(t, opts...) } diff --git a/pkg/infra/db/dbrepl.go b/pkg/infra/db/dbrepl.go deleted file mode 100644 index 028989b44ec..00000000000 --- a/pkg/infra/db/dbrepl.go +++ /dev/null @@ -1,19 +0,0 @@ -package db - -import ( - "github.com/grafana/grafana/pkg/services/sqlstore" -) - -type ReplDB interface { - // DB is the primary database connection. - DB() *sqlstore.SQLStore - - // ReadReplica is the read-only database connection. If no read replica is configured, the implementation must return the primary DB. - ReadReplica() *sqlstore.SQLStore -} - -// FakeREplDBFromDBForTests returns a ReplDB that uses the given DB as the primary connection. It's a helper function for tests. -func FakeReplDBFromDB(primary DB) ReplDB { - ss := primary.(*sqlstore.SQLStore) - return sqlstore.FakeReplStoreFromStore(ss) -} diff --git a/pkg/infra/usagestats/statscollector/concurrent_users_test.go b/pkg/infra/usagestats/statscollector/concurrent_users_test.go index 919a74fa22e..decb1642657 100644 --- a/pkg/infra/usagestats/statscollector/concurrent_users_test.go +++ b/pkg/infra/usagestats/statscollector/concurrent_users_test.go @@ -24,7 +24,7 @@ func TestMain(m *testing.M) { } func TestConcurrentUsersMetrics(t *testing.T) { - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore) s := createService(t, cfg, sqlStore, statsService) @@ -42,7 +42,7 @@ func TestConcurrentUsersMetrics(t *testing.T) { } func TestConcurrentUsersStats(t *testing.T) { - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore) s := createService(t, cfg, sqlStore, statsService) diff --git a/pkg/server/test_env.go b/pkg/server/test_env.go index 104dc1242f6..51a54dde56c 100644 --- a/pkg/server/test_env.go +++ b/pkg/server/test_env.go @@ -16,7 +16,6 @@ import ( func ProvideTestEnv( server *Server, db db.DB, - repldb db.ReplDB, cfg *setting.Cfg, ns *notifications.NotificationServiceMock, grpcServer grpcserver.Provider, @@ -29,7 +28,6 @@ func ProvideTestEnv( return &TestEnv{ Server: server, SQLStore: db, - ReadReplStore: repldb, Cfg: cfg, NotificationService: ns, GRPCServer: grpcServer, @@ -44,7 +42,6 @@ func ProvideTestEnv( type TestEnv struct { Server *Server SQLStore db.DB - ReadReplStore db.ReplDB Cfg *setting.Cfg NotificationService *notifications.NotificationServiceMock GRPCServer grpcserver.Provider diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 98edf7282b9..81574ec9677 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -391,13 +391,11 @@ var wireSet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, - sqlstore.ProvideServiceWithReadReplica, ngmetrics.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), - wire.Bind(new(db.ReplDB), new(*sqlstore.ReplStore)), prefimpl.ProvideService, oauthtoken.ProvideService, wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), @@ -408,13 +406,11 @@ var wireCLISet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, - sqlstore.ProvideServiceWithReadReplica, ngmetrics.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), - wire.Bind(new(db.ReplDB), new(*sqlstore.ReplStore)), prefimpl.ProvideService, oauthtoken.ProvideService, wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), @@ -425,14 +421,12 @@ var wireTestSet = wire.NewSet( ProvideTestEnv, metrics.WireSetForTest, sqlstore.ProvideServiceForTests, - sqlstore.ProvideServiceWithReadReplicaForTests, ngmetrics.ProvideServiceForTest, notifications.MockNotificationService, wire.Bind(new(notifications.Service), new(*notifications.NotificationServiceMock)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationServiceMock)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationServiceMock)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), - wire.Bind(new(db.ReplDB), new(*sqlstore.ReplStore)), prefimpl.ProvideService, oauthtoken.ProvideService, oauthtokentest.ProvideService, @@ -446,7 +440,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser func InitializeForTest(t sqlutil.ITestDB, cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*TestEnv, error) { wire.Build(wireExtsTestSet) - return &TestEnv{Server: &Server{}, SQLStore: &sqlstore.SQLStore{}, ReadReplStore: &sqlstore.ReplStore{}, Cfg: &setting.Cfg{}}, nil + return &TestEnv{Server: &Server{}, SQLStore: &sqlstore.SQLStore{}, Cfg: &setting.Cfg{}}, nil } func InitializeForCLI(cfg *setting.Cfg) (Runner, error) { diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index 3d5502afbf1..50616a33d3f 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -51,11 +51,11 @@ var SharedWithMeFolderPermission = accesscontrol.Permission{ var OSSRolesPrefixes = []string{accesscontrol.ManagedRolePrefix, accesscontrol.ExternalServiceRolePrefix} func ProvideService( - cfg *setting.Cfg, db db.ReplDB, routeRegister routing.RouteRegister, cache *localcache.CacheService, + cfg *setting.Cfg, db db.DB, routeRegister routing.RouteRegister, cache *localcache.CacheService, accessControl accesscontrol.AccessControl, actionResolver accesscontrol.ActionResolver, features featuremgmt.FeatureToggles, tracer tracing.Tracer, zclient zanzana.Client, permRegistry permreg.PermissionRegistry, ) (*Service, error) { - service := ProvideOSSService(cfg, database.ProvideService(db), actionResolver, cache, features, tracer, zclient, db.DB(), permRegistry) + service := ProvideOSSService(cfg, database.ProvideService(db), actionResolver, cache, features, tracer, zclient, db, permRegistry) api.NewAccessControlAPI(routeRegister, accessControl, service, features).RegisterAPIEndpoints() if err := accesscontrol.DeclareFixedRoles(service, cfg); err != nil { diff --git a/pkg/services/accesscontrol/acimpl/service_bench_test.go b/pkg/services/accesscontrol/acimpl/service_bench_test.go index 864676ae54b..19e13645263 100644 --- a/pkg/services/accesscontrol/acimpl/service_bench_test.go +++ b/pkg/services/accesscontrol/acimpl/service_bench_test.go @@ -27,7 +27,7 @@ import ( // - each managed role will have 3 permissions {"resources:action2", "resources:id:x"} where x belongs to [1, 3] func setupBenchEnv(b *testing.B, usersCount, resourceCount int) (accesscontrol.Service, *user.SignedInUser) { now := time.Now() - sqlStore := db.InitTestReplDB(b) + sqlStore := db.InitTestDB(b) store := database.ProvideService(sqlStore) acService := &Service{ cfg: setting.NewCfg(), diff --git a/pkg/services/accesscontrol/acimpl/service_test.go b/pkg/services/accesscontrol/acimpl/service_test.go index 6ac4e258c8f..7096b2fcac0 100644 --- a/pkg/services/accesscontrol/acimpl/service_test.go +++ b/pkg/services/accesscontrol/acimpl/service_test.go @@ -42,7 +42,7 @@ func setupTestEnv(t testing.TB) *Service { log: log.New("accesscontrol"), registrations: accesscontrol.RegistrationList{}, roles: accesscontrol.BuildBasicRoleDefinitions(), - store: database.ProvideService(db.InitTestReplDB(t)), + store: database.ProvideService(db.InitTestDB(t)), permRegistry: permreg.ProvidePermissionRegistry(), } require.NoError(t, ac.RegisterFixedRoles(context.Background())) @@ -66,7 +66,7 @@ func TestUsageMetrics(t *testing.T) { s := ProvideOSSService( cfg, - database.ProvideService(db.InitTestReplDB(t)), + database.ProvideService(db.InitTestDB(t)), &resourcepermissions.FakeActionSetSvc{}, localcache.ProvideService(), featuremgmt.WithFeatures(), diff --git a/pkg/services/accesscontrol/database/database.go b/pkg/services/accesscontrol/database/database.go index 8443af055f7..34ad42c332e 100644 --- a/pkg/services/accesscontrol/database/database.go +++ b/pkg/services/accesscontrol/database/database.go @@ -39,12 +39,12 @@ const ( WHERE br.role = ?` ) -func ProvideService(sql db.ReplDB) *AccessControlStore { +func ProvideService(sql db.DB) *AccessControlStore { return &AccessControlStore{sql} } type AccessControlStore struct { - sql db.ReplDB + sql db.DB } func (s *AccessControlStore) GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error) { @@ -52,7 +52,7 @@ func (s *AccessControlStore) GetUserPermissions(ctx context.Context, query acces defer span.End() result := make([]accesscontrol.Permission, 0) - err := s.sql.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { if query.UserID == 0 && len(query.TeamIDs) == 0 && len(query.Roles) == 0 { // no permission to fetch return nil @@ -113,7 +113,7 @@ func (s *AccessControlStore) GetTeamsPermissions(ctx context.Context, query acce orgID := query.OrgID rolePrefixes := query.RolePrefixes result := make([]teamPermission, 0) - err := s.sql.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { if len(teams) == 0 { // no permission to fetch return nil @@ -184,7 +184,7 @@ func (s *AccessControlStore) SearchUsersPermissions(ctx context.Context, orgID i } } - if err := s.sql.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + if err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { roleNameFilterJoin := "" if len(options.RolePrefixes) > 0 { roleNameFilterJoin = "INNER JOIN role AS r ON up.role_id = r.id" @@ -210,7 +210,7 @@ func (s *AccessControlStore) SearchUsersPermissions(ctx context.Context, orgID i params = append(params, userID) } - grafanaAdmin := fmt.Sprintf(grafanaAdminAssignsSQL, s.sql.ReadReplica().Quote("user")) + grafanaAdmin := fmt.Sprintf(grafanaAdminAssignsSQL, s.sql.Quote("user")) params = append(params, accesscontrol.RoleGrafanaAdmin) if userID >= 0 { grafanaAdmin += " AND sa.user_id = ?" @@ -299,11 +299,11 @@ func (s *AccessControlStore) GetUsersBasicRoles(ctx context.Context, userFilter IsAdmin bool `xorm:"is_admin"` } dbRoles := make([]UserOrgRole, 0) - if err := s.sql.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + if err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { // Find roles q := ` SELECT u.id, ou.role, u.is_admin - FROM ` + s.sql.ReadReplica().GetDialect().Quote("user") + ` AS u + FROM ` + s.sql.GetDialect().Quote("user") + ` AS u LEFT JOIN org_user AS ou ON u.id = ou.user_id WHERE (u.is_admin OR ou.org_id = ?) ` @@ -336,7 +336,7 @@ func (s *AccessControlStore) DeleteUserPermissions(ctx context.Context, orgID, u ctx, span := tracer.Start(ctx, "accesscontrol.database.DeleteUserPermissions") defer span.End() - err := s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { roleDeleteQuery := "DELETE FROM user_role WHERE user_id = ?" roleDeleteParams := []any{roleDeleteQuery, userID} if orgID != accesscontrol.GlobalOrgID { @@ -404,7 +404,7 @@ func (s *AccessControlStore) DeleteTeamPermissions(ctx context.Context, orgID, t ctx, span := tracer.Start(ctx, "accesscontrol.database.DeleteTeamPermissions") defer span.End() - err := s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { roleDeleteQuery := "DELETE FROM team_role WHERE team_id = ? AND org_id = ?" roleDeleteParams := []any{roleDeleteQuery, teamID, orgID} diff --git a/pkg/services/accesscontrol/database/database_test.go b/pkg/services/accesscontrol/database/database_test.go index e1d4f4c694b..7eaa72db708 100644 --- a/pkg/services/accesscontrol/database/database_test.go +++ b/pkg/services/accesscontrol/database/database_test.go @@ -470,8 +470,8 @@ func createUsersAndTeams(t *testing.T, store db.DB, svcs helperServices, orgID i return res } -func setupTestEnv(t testing.TB) (*database.AccessControlStore, rs.Store, user.Service, team.Service, org.Service, *sqlstore.ReplStore) { - sql, cfg := db.InitTestReplDBWithCfg(t) +func setupTestEnv(t testing.TB) (*database.AccessControlStore, rs.Store, user.Service, team.Service, org.Service, *sqlstore.SQLStore) { + sql, cfg := db.InitTestDBWithCfg(t) cfg.AutoAssignOrg = true cfg.AutoAssignOrgRole = "Viewer" cfg.AutoAssignOrgId = 1 diff --git a/pkg/services/accesscontrol/database/externalservices.go b/pkg/services/accesscontrol/database/externalservices.go index 3c658a85e8f..2ced82d908b 100644 --- a/pkg/services/accesscontrol/database/externalservices.go +++ b/pkg/services/accesscontrol/database/externalservices.go @@ -21,7 +21,7 @@ func (s *AccessControlStore) DeleteExternalServiceRole(ctx context.Context, exte defer span.End() uid := accesscontrol.PrefixedRoleUID(extServiceRoleName(externalServiceID)) - return s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + return s.sql.WithDbSession(ctx, func(sess *db.Session) error { stored, errGet := getRoleByUID(ctx, sess, uid) if errGet != nil { // Role not found, nothing to do @@ -61,7 +61,7 @@ func (s *AccessControlStore) SaveExternalServiceRole(ctx context.Context, cmd ac role := genExternalServiceRole(cmd) assignment := genExternalServiceAssignment(cmd) - return s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + return s.sql.WithDbSession(ctx, func(sess *db.Session) error { // Create or update the role existingRole, errSaveRole := s.saveRole(ctx, sess, &role) if errSaveRole != nil { diff --git a/pkg/services/accesscontrol/database/externalservices_test.go b/pkg/services/accesscontrol/database/externalservices_test.go index 7bde04735a5..e346f3be1d8 100644 --- a/pkg/services/accesscontrol/database/externalservices_test.go +++ b/pkg/services/accesscontrol/database/externalservices_test.go @@ -7,10 +7,9 @@ import ( "errors" "testing" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/stretchr/testify/require" ) func TestAccessControlStore_SaveExternalServiceRole(t *testing.T) { @@ -115,7 +114,7 @@ func TestAccessControlStore_SaveExternalServiceRole(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() s := &AccessControlStore{ - sql: db.InitTestReplDB(t), + sql: db.InitTestDB(t), } for i := range tt.runs { @@ -126,7 +125,7 @@ func TestAccessControlStore_SaveExternalServiceRole(t *testing.T) { } require.NoError(t, err) - errDBSession := s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + errDBSession := s.sql.WithDbSession(ctx, func(sess *db.Session) error { storedRole, err := getRoleByUID(ctx, sess, accesscontrol.PrefixedRoleUID(extServiceRoleName(tt.runs[i].cmd.ExternalServiceID))) require.NoError(t, err) require.NotNil(t, storedRole) @@ -188,13 +187,13 @@ func TestAccessControlStore_DeleteExternalServiceRole(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() s := &AccessControlStore{ - sql: db.InitTestReplDB(t), + sql: db.InitTestDB(t), } if tt.init != nil { tt.init(t, ctx, s) } roleID := int64(-1) - err := s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { role, err := getRoleByUID(ctx, sess, accesscontrol.PrefixedRoleUID(extServiceRoleName(tt.id))) if err != nil && !errors.Is(err, accesscontrol.ErrRoleNotFound) { return err @@ -218,7 +217,7 @@ func TestAccessControlStore_DeleteExternalServiceRole(t *testing.T) { } // Assignments should be deleted - _ = s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + _ = s.sql.WithDbSession(ctx, func(sess *db.Session) error { var assignment accesscontrol.UserRole count, err := sess.Where("role_id = ?", roleID).Count(&assignment) require.NoError(t, err) @@ -227,7 +226,7 @@ func TestAccessControlStore_DeleteExternalServiceRole(t *testing.T) { }) // Permissions should be deleted - _ = s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + _ = s.sql.WithDbSession(ctx, func(sess *db.Session) error { var permission accesscontrol.Permission count, err := sess.Where("role_id = ?", roleID).Count(&permission) require.NoError(t, err) @@ -236,7 +235,7 @@ func TestAccessControlStore_DeleteExternalServiceRole(t *testing.T) { }) // Role should be deleted - _ = s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + _ = s.sql.WithDbSession(ctx, func(sess *db.Session) error { storedRole, err := getRoleByUID(ctx, sess, accesscontrol.PrefixedRoleUID(extServiceRoleName(tt.id))) require.ErrorIs(t, err, accesscontrol.ErrRoleNotFound) require.Nil(t, storedRole) diff --git a/pkg/services/accesscontrol/migrator/migrator.go b/pkg/services/accesscontrol/migrator/migrator.go index 33317b4b5be..8ecc20ad553 100644 --- a/pkg/services/accesscontrol/migrator/migrator.go +++ b/pkg/services/accesscontrol/migrator/migrator.go @@ -19,14 +19,14 @@ const ( maxLen = 40 ) -func MigrateScopeSplit(db db.ReplDB, log log.Logger) error { +func MigrateScopeSplit(db db.DB, log log.Logger) error { t := time.Now() ctx := context.Background() cnt := 0 // Search for the permissions to update var permissions []ac.Permission - if errFind := db.DB().WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + if errFind := db.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { return sess.SQL("SELECT * FROM permission WHERE NOT scope = '' AND identifier = ''").Find(&permissions) }); errFind != nil { log.Error("Could not search for permissions to update", "migration", "scopeSplit", "error", errFind) @@ -76,7 +76,7 @@ func MigrateScopeSplit(db db.ReplDB, log log.Logger) error { delQuery = delQuery[:len(delQuery)-1] + ")" // Batch update the permissions - if errBatchUpdate := db.DB().GetSqlxSession().WithTransaction(ctx, func(tx *session.SessionTx) error { + if errBatchUpdate := db.GetSqlxSession().WithTransaction(ctx, func(tx *session.SessionTx) error { if _, errDel := tx.Exec(ctx, delQuery, delArgs...); errDel != nil { log.Error("Error deleting permissions", "migration", "scopeSplit", "error", errDel) return errDel diff --git a/pkg/services/accesscontrol/migrator/migrator_bench_test.go b/pkg/services/accesscontrol/migrator/migrator_bench_test.go index 7246b0812e3..5257a2fb2dd 100644 --- a/pkg/services/accesscontrol/migrator/migrator_bench_test.go +++ b/pkg/services/accesscontrol/migrator/migrator_bench_test.go @@ -10,7 +10,7 @@ import ( ) func benchScopeSplitConcurrent(b *testing.B, count int) { - store := db.InitTestReplDB(b) + store := db.InitTestDB(b) // Populate permissions require.NoError(b, batchInsertPermissions(count, store), "could not insert permissions") logger := log.New("migrator.test") diff --git a/pkg/services/accesscontrol/migrator/migrator_test.go b/pkg/services/accesscontrol/migrator/migrator_test.go index 0c98aa24a26..bfa35f17efc 100644 --- a/pkg/services/accesscontrol/migrator/migrator_test.go +++ b/pkg/services/accesscontrol/migrator/migrator_test.go @@ -46,7 +46,7 @@ func batchInsertPermissions(cnt int, sqlStore db.DB) error { // TestIntegrationMigrateScopeSplit tests the scope split migration // also tests the scope split truncation logic func TestIntegrationMigrateScopeSplitTruncation(t *testing.T) { - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) logger := log.New("accesscontrol.migrator.test") batchSize = 20 diff --git a/pkg/services/accesscontrol/resourcepermissions/service_test.go b/pkg/services/accesscontrol/resourcepermissions/service_test.go index c8c9dbb406c..94ead810dd7 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/service_test.go @@ -493,7 +493,7 @@ func setupTestEnvironment(t *testing.T, ops Options) (*Service, user.Service, te cfg := setting.NewCfg() tracer := tracing.InitializeTracerForTest() - teamSvc, err := teamimpl.ProvideService(db.FakeReplDBFromDB(sql), cfg, tracer) + teamSvc, err := teamimpl.ProvideService(sql, cfg, tracer) require.NoError(t, err) orgSvc, err := orgimpl.ProvideService(sql, cfg, quotatest.New(false, nil)) diff --git a/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go b/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go index 60addeeee24..51a63a5deba 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go @@ -140,7 +140,7 @@ func GenerateDatasourcePermissions(b *testing.B, db db.DB, cfg *setting.Cfg, ac } func generateTeamsAndUsers(b *testing.B, store db.DB, cfg *setting.Cfg, users int) ([]int64, []int64) { - teamSvc, err := teamimpl.ProvideService(db.FakeReplDBFromDB(store), cfg, tracing.InitializeTracerForTest()) + teamSvc, err := teamimpl.ProvideService(store, cfg, tracing.InitializeTracerForTest()) require.NoError(b, err) numberOfTeams := int(math.Ceil(float64(users) / UsersPerTeam)) globalUserId := 0 diff --git a/pkg/services/annotations/accesscontrol/accesscontrol_test.go b/pkg/services/annotations/accesscontrol/accesscontrol_test.go index 00106f0f74b..42f767bec35 100644 --- a/pkg/services/annotations/accesscontrol/accesscontrol_test.go +++ b/pkg/services/annotations/accesscontrol/accesscontrol_test.go @@ -27,7 +27,7 @@ func TestIntegrationAuthorize(t *testing.T) { t.Skip("skipping integration test") } - sql, cfg := db.InitTestReplDBWithCfg(t) + sql, cfg := db.InitTestDBWithCfg(t) dash1 := testutil.CreateDashboard(t, sql, cfg, featuremgmt.WithFeatures(), dashboards.SaveDashboardCommand{ UserID: 1, diff --git a/pkg/services/annotations/annotationsimpl/annotations_test.go b/pkg/services/annotations/annotationsimpl/annotations_test.go index eb87f894916..7a1223bfb64 100644 --- a/pkg/services/annotations/annotationsimpl/annotations_test.go +++ b/pkg/services/annotations/annotationsimpl/annotations_test.go @@ -27,7 +27,6 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" alertingStore "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" @@ -43,7 +42,7 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - sql := db.InitTestReplDB(t) + sql := db.InitTestDB(t) cfg := setting.NewCfg() cfg.AnnotationMaximumTagsLength = 60 @@ -210,8 +209,8 @@ func TestIntegrationAnnotationListingWithInheritedRBAC(t *testing.T) { allDashboards := make([]dashInfo, 0, folder.MaxNestedFolderDepth+1) annotationsTexts := make([]string, 0, folder.MaxNestedFolderDepth+1) - setupFolderStructure := func() *sqlstore.ReplStore { - sql, cfg := db.InitTestReplDBWithCfg(t) + setupFolderStructure := func() db.DB { + sql, cfg := db.InitTestDBWithCfg(t) // enable nested folders so that the folder table is populated for all the tests features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders) diff --git a/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go b/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go index 6b378e7b8d6..bcd5e536ae0 100644 --- a/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go +++ b/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go @@ -44,7 +44,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) { t.Skip("skipping integration test") } - sql, cfg := db.InitTestReplDBWithCfg(t) + sql, cfg := db.InitTestDBWithCfg(t) dashboard1 := testutil.CreateDashboard(t, sql, cfg, featuremgmt.WithFeatures(), dashboards.SaveDashboardCommand{ UserID: 1, @@ -604,7 +604,7 @@ func TestBuildTransition(t *testing.T) { }) } -func createTestLokiStore(t *testing.T, sql *sqlstore.ReplStore, client lokiQueryClient) *LokiHistorianStore { +func createTestLokiStore(t *testing.T, sql *sqlstore.SQLStore, client lokiQueryClient) *LokiHistorianStore { t.Helper() ruleStore := store.SetupStoreForTesting(t, sql) @@ -618,7 +618,7 @@ func createTestLokiStore(t *testing.T, sql *sqlstore.ReplStore, client lokiQuery // createAlertRule creates an alert rule in the database and returns it. // If a generator is not specified, uniqueness of primary key is not guaranteed. -func createAlertRule(t *testing.T, sql *sqlstore.ReplStore, title string, generator *ngmodels.AlertRuleGenerator) *ngmodels.AlertRule { +func createAlertRule(t *testing.T, sql *sqlstore.SQLStore, title string, generator *ngmodels.AlertRuleGenerator) *ngmodels.AlertRule { t.Helper() if generator == nil { @@ -645,7 +645,7 @@ func createAlertRule(t *testing.T, sql *sqlstore.ReplStore, title string, genera // createAlertRuleFromDashboard creates an alert rule with a linked dashboard and panel in the database and returns it. // If a generator is not specified, uniqueness of primary key is not guaranteed. -func createAlertRuleFromDashboard(t *testing.T, sql *sqlstore.ReplStore, title string, dashboard dashboards.Dashboard, generator *ngmodels.AlertRuleGenerator) *ngmodels.AlertRule { +func createAlertRuleFromDashboard(t *testing.T, sql *sqlstore.SQLStore, title string, dashboard dashboards.Dashboard, generator *ngmodels.AlertRuleGenerator) *ngmodels.AlertRule { t.Helper() panelID := new(int64) diff --git a/pkg/services/annotations/annotationsimpl/xorm_store_test.go b/pkg/services/annotations/annotationsimpl/xorm_store_test.go index 6a5f11089e4..ae06428f835 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store_test.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store_test.go @@ -29,7 +29,7 @@ func TestIntegrationAnnotations(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - sql := db.InitTestReplDB(t) + sql := db.InitTestDB(t) cfg := setting.NewCfg() cfg.AnnotationMaximumTagsLength = 60 diff --git a/pkg/services/annotations/testutil/testutil.go b/pkg/services/annotations/testutil/testutil.go index 6113e8f2367..3d71f85e40f 100644 --- a/pkg/services/annotations/testutil/testutil.go +++ b/pkg/services/annotations/testutil/testutil.go @@ -5,8 +5,6 @@ import ( "testing" "time" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" @@ -17,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" ) func SetupRBACRole(t *testing.T, db db.DB, user *user.SignedInUser) *accesscontrol.Role { @@ -79,14 +78,14 @@ func SetupRBACPermission(t *testing.T, db db.DB, role *accesscontrol.Role, user require.NoError(t, err) } -func CreateDashboard(t *testing.T, db db.ReplDB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, cmd dashboards.SaveDashboardCommand) *dashboards.Dashboard { +func CreateDashboard(t *testing.T, db db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, cmd dashboards.SaveDashboardCommand) *dashboards.Dashboard { t.Helper() dashboardStore, err := dashboardstore.ProvideDashboardStore( db, cfg, features, - tagimpl.ProvideService(db.DB()), + tagimpl.ProvideService(db), quotatest.New(false, nil), ) require.NoError(t, err) diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index b68ca7357fc..1eeb5fa5569 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -31,7 +31,7 @@ import ( var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/dashboard/database") type dashboardStore struct { - store db.ReplDB + store db.DB cfg *setting.Cfg log log.Logger features featuremgmt.FeatureToggles @@ -48,7 +48,7 @@ type dashboardTag struct { // DashboardStore implements the Store interface var _ dashboards.Store = (*dashboardStore)(nil) -func ProvideDashboardStore(sqlStore db.ReplDB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tagService tag.Service, quotaService quota.Service) (dashboards.Store, error) { +func ProvideDashboardStore(sqlStore db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tagService tag.Service, quotaService quota.Service) (dashboards.Store, error) { s := &dashboardStore{store: sqlStore, cfg: cfg, log: log.New("dashboard-store"), features: features, tagService: tagService} defaultLimits, err := readQuotaConfig(cfg) @@ -76,7 +76,7 @@ func (d *dashboardStore) ValidateDashboardBeforeSave(ctx context.Context, dashbo defer span.End() isParentFolderChanged := false - err := d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { var err error isParentFolderChanged, err = getExistingDashboardByIDOrUIDForUpdate(sess, dashboard, overwrite) if err != nil { @@ -103,7 +103,7 @@ func (d *dashboardStore) GetProvisionedDataByDashboardID(ctx context.Context, da defer span.End() var data dashboards.DashboardProvisioning - err := d.store.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { _, err := sess.Where("dashboard_id = ?", dashboardID).Get(&data) return err }) @@ -119,7 +119,7 @@ func (d *dashboardStore) GetProvisionedDataByDashboardUID(ctx context.Context, o defer span.End() var provisionedDashboard dashboards.DashboardProvisioning - err := d.store.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { var dashboard dashboards.Dashboard exists, err := sess.Where("org_id = ? AND uid = ?", orgID, dashboardUID).Get(&dashboard) if err != nil { @@ -145,7 +145,7 @@ func (d *dashboardStore) GetProvisionedDashboardData(ctx context.Context, name s defer span.End() var result []*dashboards.DashboardProvisioning - err := d.store.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { return sess.Where("name = ?", name).Find(&result) }) return result, err @@ -157,7 +157,7 @@ func (d *dashboardStore) SaveProvisionedDashboard(ctx context.Context, cmd dashb var result *dashboards.Dashboard var err error - err = d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + err = d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { result, err = saveDashboard(sess, &cmd, d.emitEntityEvent()) if err != nil { return err @@ -178,7 +178,7 @@ func (d *dashboardStore) SaveDashboard(ctx context.Context, cmd dashboards.SaveD var result *dashboards.Dashboard var err error - err = d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + err = d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { result, err = saveDashboard(sess, &cmd, d.emitEntityEvent()) if err != nil { return err @@ -197,7 +197,7 @@ func (d *dashboardStore) UnprovisionDashboard(ctx context.Context, id int64) err ctx, span := tracer.Start(ctx, "dashboards.database.UnprovisionDashboard") defer span.End() - return d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { _, err := sess.Where("dashboard_id = ?", id).Delete(&dashboards.DashboardProvisioning{}) return err }) @@ -207,7 +207,7 @@ func (d *dashboardStore) DeleteOrphanedProvisionedDashboards(ctx context.Context ctx, span := tracer.Start(ctx, "dashboards.database.DeleteOrphanedProvisionedDashboards") defer span.End() - return d.store.DB().WithDbSession(ctx, func(sess *db.Session) error { + return d.store.WithDbSession(ctx, func(sess *db.Session) error { var result []*dashboards.DashboardProvisioning convertedReaderNames := make([]any, len(cmd.ReaderNames)) @@ -241,8 +241,8 @@ func (d *dashboardStore) Count(ctx context.Context, scopeParams *quota.ScopePara } r := result{} - if err := d.store.ReadReplica().WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM dashboard WHERE is_folder=%s", d.store.ReadReplica().GetDialect().BooleanStr(false)) + if err := d.store.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM dashboard WHERE is_folder=%s", d.store.GetDialect().BooleanStr(false)) if _, err := sess.SQL(rawSQL).Get(&r); err != nil { return err } @@ -258,8 +258,8 @@ func (d *dashboardStore) Count(ctx context.Context, scopeParams *quota.ScopePara } if scopeParams != nil && scopeParams.OrgID != 0 { - if err := d.store.ReadReplica().WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM dashboard WHERE org_id=? AND is_folder=%s", d.store.ReadReplica().GetDialect().BooleanStr(false)) + if err := d.store.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM dashboard WHERE org_id=? AND is_folder=%s", d.store.GetDialect().BooleanStr(false)) if _, err := sess.SQL(rawSQL, scopeParams.OrgID).Get(&r); err != nil { return err } @@ -534,8 +534,8 @@ func (d *dashboardStore) GetDashboardsByPluginID(ctx context.Context, query *das defer span.End() var dashboards = make([]*dashboards.Dashboard, 0) - err := d.store.DB().WithDbSession(ctx, func(dbSession *db.Session) error { - whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + d.store.DB().GetDialect().BooleanStr(false) + err := d.store.WithDbSession(ctx, func(dbSession *db.Session) error { + whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + d.store.GetDialect().BooleanStr(false) err := dbSession.Where(whereExpr, query.OrgID, query.PluginID).Find(&dashboards) return err @@ -545,7 +545,6 @@ func (d *dashboardStore) GetDashboardsByPluginID(ctx context.Context, query *das } return dashboards, nil } - func (d *dashboardStore) GetSoftDeletedDashboard(ctx context.Context, orgID int64, uid string) (*dashboards.Dashboard, error) { ctx, span := tracer.Start(ctx, "dashboards.database.GetSoftDeletedDashboard") defer span.End() @@ -555,7 +554,7 @@ func (d *dashboardStore) GetSoftDeletedDashboard(ctx context.Context, orgID int6 } var queryResult *dashboards.Dashboard - err := d.store.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { dashboard := dashboards.Dashboard{OrgID: orgID, UID: uid} has, err := sess.Where("deleted IS NOT NULL").Get(&dashboard) @@ -576,7 +575,7 @@ func (d *dashboardStore) RestoreDashboard(ctx context.Context, orgID int64, dash ctx, span := tracer.Start(ctx, "dashboards.database.RestoreDashboard") defer span.End() - return d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { if folder == nil || folder.UID == "" { _, err := sess.Exec("UPDATE dashboard SET deleted=NULL, folder_id=0, folder_uid=NULL WHERE org_id=? AND uid=?", orgID, dashboardUID) return err @@ -591,7 +590,7 @@ func (d *dashboardStore) SoftDeleteDashboard(ctx context.Context, orgID int64, d ctx, span := tracer.Start(ctx, "dashboards.database.SoftDeleteDashboard") defer span.End() - return d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { _, err := sess.Exec("UPDATE dashboard SET deleted=? WHERE org_id=? AND uid=?", time.Now(), orgID, dashboardUID) return err }) @@ -605,7 +604,7 @@ func (d *dashboardStore) SoftDeleteDashboardsInFolders(ctx context.Context, orgI return nil } - return d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { s := strings.Builder{} s.WriteString("UPDATE dashboard SET deleted=? WHERE ") s.WriteString(fmt.Sprintf("folder_uid IN (%s)", strings.Repeat("?,", len(folderUids)-1)+"?")) @@ -617,7 +616,7 @@ func (d *dashboardStore) SoftDeleteDashboardsInFolders(ctx context.Context, orgI for _, folderUID := range folderUids { args = append(args, folderUID) } - args = append(args, orgID, d.store.DB().GetDialect().BooleanStr(false)) + args = append(args, orgID, d.store.GetDialect().BooleanStr(false)) _, err := sess.Exec(args...) return err @@ -628,7 +627,7 @@ func (d *dashboardStore) DeleteDashboard(ctx context.Context, cmd *dashboards.De ctx, span := tracer.Start(ctx, "dashboards.database.DeleteDashboard") defer span.End() - return d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { return d.deleteDashboard(cmd, sess, d.emitEntityEvent()) }) } @@ -664,14 +663,14 @@ func (d *dashboardStore) deleteDashboard(cmd *dashboards.DeleteDashboardCommand, if dashboard.IsFolder { if !d.features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) { - sqlStatements = append(sqlStatements, statement{SQL: "DELETE FROM dashboard WHERE org_id = ? AND folder_uid = ? AND is_folder = ? AND deleted IS NULL", args: []any{dashboard.OrgID, dashboard.UID, d.store.DB().GetDialect().BooleanStr(false)}}) + sqlStatements = append(sqlStatements, statement{SQL: "DELETE FROM dashboard WHERE org_id = ? AND folder_uid = ? AND is_folder = ? AND deleted IS NULL", args: []any{dashboard.OrgID, dashboard.UID, d.store.GetDialect().BooleanStr(false)}}) if err := d.deleteChildrenDashboardAssociations(sess, &dashboard); err != nil { return err } } else { // soft delete all dashboards in the folder - sqlStatements = append(sqlStatements, statement{SQL: "UPDATE dashboard SET deleted = ? WHERE org_id = ? AND folder_uid = ? AND is_folder = ? ", args: []any{time.Now(), dashboard.OrgID, dashboard.UID, d.store.DB().GetDialect().BooleanStr(false)}}) + sqlStatements = append(sqlStatements, statement{SQL: "UPDATE dashboard SET deleted = ? WHERE org_id = ? AND folder_uid = ? AND is_folder = ? ", args: []any{time.Now(), dashboard.OrgID, dashboard.UID, d.store.GetDialect().BooleanStr(false)}}) } // remove all access control permission with folder scope @@ -788,7 +787,7 @@ func (d *dashboardStore) GetDashboard(ctx context.Context, query *dashboards.Get defer span.End() var queryResult *dashboards.Dashboard - err := d.store.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck if query.ID == 0 && len(query.UID) == 0 && (query.Title == nil || (query.FolderID == nil && query.FolderUID == nil)) { @@ -833,7 +832,7 @@ func (d *dashboardStore) GetDashboardUIDByID(ctx context.Context, query *dashboa defer span.End() us := &dashboards.DashboardRef{} - err := d.store.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { var rawSQL = `SELECT uid, slug from dashboard WHERE Id=?` exists, err := sess.SQL(rawSQL, query.ID).Get(us) if err != nil { @@ -854,7 +853,7 @@ func (d *dashboardStore) GetDashboards(ctx context.Context, query *dashboards.Ge defer span.End() var dashboards = make([]*dashboards.Dashboard, 0) - err := d.store.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { if len(query.DashboardIDs) == 0 && len(query.DashboardUIDs) == 0 { return star.ErrCommandValidationFailed } @@ -884,7 +883,7 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F ctx, span := tracer.Start(ctx, "dashboards.database.FindDashboards") defer span.End() - recursiveQueriesAreSupported, err := d.store.ReadReplica().RecursiveQueriesAreSupported() + recursiveQueriesAreSupported, err := d.store.RecursiveQueriesAreSupported() if err != nil { return nil, err } @@ -917,11 +916,11 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F } if len(query.Title) > 0 { - filters = append(filters, searchstore.TitleFilter{Dialect: d.store.ReadReplica().GetDialect(), Title: query.Title}) + filters = append(filters, searchstore.TitleFilter{Dialect: d.store.GetDialect(), Title: query.Title}) } if len(query.Type) > 0 { - filters = append(filters, searchstore.TypeFilter{Dialect: d.store.ReadReplica().GetDialect(), Type: query.Type}) + filters = append(filters, searchstore.TypeFilter{Dialect: d.store.GetDialect(), Type: query.Type}) } metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck @@ -931,7 +930,7 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F if len(query.FolderUIDs) > 0 { filters = append(filters, searchstore.FolderUIDFilter{ - Dialect: d.store.ReadReplica().GetDialect(), + Dialect: d.store.GetDialect(), OrgID: orgID, UIDs: query.FolderUIDs, NestedFoldersEnabled: d.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders), @@ -948,7 +947,7 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F filters = append(filters, searchstore.DeletedFilter{Deleted: query.IsDeleted}) var res []dashboards.DashboardSearchProjection - sb := &searchstore.Builder{Dialect: d.store.ReadReplica().GetDialect(), Filters: filters, Features: d.features} + sb := &searchstore.Builder{Dialect: d.store.GetDialect(), Filters: filters, Features: d.features} limit := query.Limit if limit < 1 { @@ -962,7 +961,7 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F sql, params := sb.ToSQL(limit, page) - err = d.store.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err = d.store.WithDbSession(ctx, func(sess *db.Session) error { return sess.SQL(sql, params...).Find(&res) }) @@ -978,7 +977,7 @@ func (d *dashboardStore) GetDashboardTags(ctx context.Context, query *dashboards defer span.End() queryResult := make([]*dashboards.DashboardTagCloudItem, 0) - err := d.store.DB().WithDbSession(ctx, func(dbSession *db.Session) error { + err := d.store.WithDbSession(ctx, func(dbSession *db.Session) error { sql := `SELECT COUNT(*) as count, term @@ -1009,7 +1008,7 @@ func (d *dashboardStore) CountDashboardsInFolders( return 0, nil } var count int64 - err := d.store.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() s := strings.Builder{} args := make([]any, 0, 3) @@ -1023,7 +1022,7 @@ func (d *dashboardStore) CountDashboardsInFolders( } } s.WriteString(" AND org_id = ? AND is_folder = ? AND deleted IS NULL") - args = append(args, req.OrgID, d.store.ReadReplica().GetDialect().BooleanStr(false)) + args = append(args, req.OrgID, d.store.GetDialect().BooleanStr(false)) sql := s.String() _, err := sess.SQL(sql, args...).Get(&count) return err @@ -1036,7 +1035,7 @@ func (d *dashboardStore) DeleteDashboardsInFolders( ctx, span := tracer.Start(ctx, "dashboards.database.DeleteDashboardsInFolders") defer span.End() - return d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { // TODO delete all dashboards in the folder in a bulk query for _, folderUID := range req.FolderUIDs { dashboard := dashboards.Dashboard{OrgID: req.OrgID} @@ -1066,7 +1065,7 @@ func (d *dashboardStore) GetAllDashboards(ctx context.Context) ([]*dashboards.Da defer span.End() var dashboards = make([]*dashboards.Dashboard, 0) - err := d.store.ReadReplica().WithDbSession(ctx, func(session *db.Session) error { + err := d.store.WithDbSession(ctx, func(session *db.Session) error { err := session.Find(&dashboards) return err }) @@ -1081,7 +1080,7 @@ func (d *dashboardStore) GetSoftDeletedExpiredDashboards(ctx context.Context, du defer span.End() var dashboards = make([]*dashboards.Dashboard, 0) - err := d.store.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { err := sess.Where("deleted IS NOT NULL AND deleted < ?", time.Now().Add(-duration)).Find(&dashboards) return err }) diff --git a/pkg/services/dashboards/database/database_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go index 6f52c415e55..f654bc428e1 100644 --- a/pkg/services/dashboards/database/database_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -39,17 +39,17 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Skip("skipping integration test") } t.Run("Testing DB", func(t *testing.T) { - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var flder, dashInRoot, childDash *dashboards.Dashboard var currentUser *user.SignedInUser var dashboardStore dashboards.Store setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) var err error - dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) flder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, "", true, "prod", "webapp") dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, "", false, "prod", "webapp") @@ -68,7 +68,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("and user can read folders and dashboards", func(t *testing.T) { currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: []string{dashboards.ScopeDashboardsAll}, dashboards.ActionFoldersRead: []string{dashboards.ScopeFoldersAll}}} - actest.AddUserPermissionToDB(t, sqlStore.DB(), currentUser) + actest.AddUserPermissionToDB(t, sqlStore, currentUser) t.Run("should return all dashboards and folders", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ @@ -86,7 +86,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("and user can only read dashboards", func(t *testing.T) { currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: []string{dashboards.ScopeDashboardsAll}}} - actest.AddUserPermissionToDB(t, sqlStore.DB(), currentUser) + actest.AddUserPermissionToDB(t, sqlStore, currentUser) t.Run("should not return folder", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ @@ -104,7 +104,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("and permissions are set for dashboard child and folder has all permissions removed", func(t *testing.T) { currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashInRoot.UID)}}} - actest.AddUserPermissionToDB(t, sqlStore.DB(), currentUser) + actest.AddUserPermissionToDB(t, sqlStore, currentUser) t.Run("should not return folder or child", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ @@ -119,7 +119,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("when the user is given permission to child", func(t *testing.T) { currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}}} - actest.AddUserPermissionToDB(t, sqlStore.DB(), currentUser) + actest.AddUserPermissionToDB(t, sqlStore, currentUser) t.Run("should be able to search for child dashboard but not folder", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ @@ -138,17 +138,17 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { }) t.Run("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func(t *testing.T) { - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var folder1, folder2, dashInRoot, childDash1, childDash2 *dashboards.Dashboard var rootFolderId int64 = 0 var currentUser *user.SignedInUser setup2 := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) var err error - dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) folder1 = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, "", true, "prod") folder2 = insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, "", true, "prod") @@ -166,7 +166,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup2() t.Run("and one folder is expanded, the other collapsed", func(t *testing.T) { currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}, dashboards.ActionFoldersRead: []string{dashboards.ScopeFoldersAll}}} - actest.AddUserPermissionToDB(t, sqlStore.DB(), currentUser) + actest.AddUserPermissionToDB(t, sqlStore, currentUser) t.Run("should return dashboards in root and expanded folder", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ @@ -191,7 +191,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("and a dashboard is moved from folder without acl to the folder with an acl", func(t *testing.T) { moveDashboard(t, dashboardStore, 1, childDash2.Data, folder1.ID, folder1.UID) currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2.UID), dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashInRoot.UID)}}} - actest.AddUserPermissionToDB(t, sqlStore.DB(), currentUser) + actest.AddUserPermissionToDB(t, sqlStore, currentUser) t.Run("should not return folder with acl or its children", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ @@ -210,7 +210,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup2() moveDashboard(t, dashboardStore, 1, childDash1.Data, folder2.ID, childDash2.FolderUID) currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashInRoot.UID), dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2.UID)}, dashboards.ActionFoldersRead: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2.UID)}}} - actest.AddUserPermissionToDB(t, sqlStore.DB(), currentUser) + actest.AddUserPermissionToDB(t, sqlStore, currentUser) t.Run("should return folder without acl and its children", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ @@ -240,7 +240,7 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { // the maximux nested folder hierarchy starting from parent down to subfolders nestedFolders := make([]*folder.Folder, 0, folder.MaxNestedFolderDepth+1) - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg const ( dashInRootTitle = "dashboard in root" @@ -250,7 +250,7 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { var viewer *user.SignedInUser setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 cfg.AutoAssignOrgRole = string(org.RoleViewer) @@ -262,13 +262,13 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders) var err error - dashboardWriteStore, err := ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardWriteStore, err := ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - orgService, err := orgimpl.ProvideService(sqlStore.DB(), cfg, quotaService) + orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - sqlStore.DB(), orgService, cfg, nil, nil, tracer, + sqlStore, orgService, cfg, nil, nil, tracer, quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) @@ -303,7 +303,7 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { guardian.New = origNewGuardian }) - folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracer), dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(sqlStore.DB()), sqlStore.DB(), features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracer), dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(sqlStore), sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) parentUID := "" for i := 0; ; i++ { @@ -405,11 +405,11 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - dashboardReadStore, err := ProvideDashboardStore(sqlStore, cfg, tc.features, tagimpl.ProvideService(sqlStore.DB()), quotatest.New(false, nil)) + dashboardReadStore, err := ProvideDashboardStore(sqlStore, cfg, tc.features, tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) viewer.Permissions = map[int64]map[string][]string{viewer.OrgID: tc.permissions} - actest.AddUserPermissionToDB(t, sqlStore.DB(), viewer) + actest.AddUserPermissionToDB(t, sqlStore, viewer) query := &dashboards.FindPersistedDashboardsQuery{ SignedInUser: viewer, diff --git a/pkg/services/dashboards/database/database_provisioning_test.go b/pkg/services/dashboards/database/database_provisioning_test.go index 12cbe8aa4ea..798368370e9 100644 --- a/pkg/services/dashboards/database/database_provisioning_test.go +++ b/pkg/services/dashboards/database/database_provisioning_test.go @@ -18,7 +18,7 @@ func TestIntegrationDashboardProvisioningTest(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) dashboardStore, err := ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index 0e77bfee2d5..83c118d6047 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -44,16 +44,16 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var savedFolder, savedDash, savedDash2 *dashboards.Dashboard var dashboardStore dashboards.Store setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) var err error - dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) // insertTestDashboard creates the following hierarchy: // 1 test dash folder @@ -539,16 +539,16 @@ func TestIntegrationGetSoftDeletedDashboard(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore *sqlstore.SQLStore var cfg *setting.Cfg var savedFolder, savedDash *dashboards.Dashboard var dashboardStore dashboards.Store setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) var err error - dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, "", true, "prod", "webapp") savedDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, savedFolder.ID, savedFolder.UID, false, "prod", "webapp") @@ -660,7 +660,7 @@ func TestIntegrationDashboardDataAccessGivenPluginWithImportedDashboards(t *test if testing.Short() { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) quotaService := quotatest.New(false, nil) dashboardStore, err := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) @@ -685,7 +685,7 @@ func TestIntegrationDashboard_SortingOptions(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) quotaService := quotatest.New(false, nil) dashboardStore, err := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) @@ -736,7 +736,7 @@ func TestIntegrationDashboard_Filter(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) dashboardStore, err := ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) @@ -780,7 +780,7 @@ func TestIntegrationDashboard_Filter(t *testing.T) { } func TestGetExistingDashboardByTitleAndFolder(t *testing.T) { - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) dashboardStore, err := ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) @@ -818,7 +818,7 @@ func TestIntegrationFindDashboardsByTitle(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagPanelTitleSearch) @@ -936,7 +936,7 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagPanelTitleSearch) @@ -1118,8 +1118,8 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) { } } -func insertTestRule(t *testing.T, sqlStore db.ReplDB, foderOrgID int64, folderUID string) { - err := sqlStore.DB().WithDbSession(context.Background(), func(sess *db.Session) error { +func insertTestRule(t *testing.T, sqlStore db.DB, foderOrgID int64, folderUID string) { + err := sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { type alertQuery struct { RefID string DatasourceUID string diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 99f68aa917b..34c400461c4 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" + "golang.org/x/exp/slices" "github.com/grafana/grafana/pkg/apimachinery/identity" diff --git a/pkg/services/dashboards/service/dashboard_service_integration_test.go b/pkg/services/dashboards/service/dashboard_service_integration_test.go index e536a332ea5..c1fb4f426a0 100644 --- a/pkg/services/dashboards/service/dashboard_service_integration_test.go +++ b/pkg/services/dashboards/service/dashboard_service_integration_test.go @@ -103,7 +103,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { permissionScenario(t, "When creating a new dashboard in the General folder", canSave, func(t *testing.T, sc *permissionScenarioContext) { - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cmd := dashboards.SaveDashboardCommand{ OrgID: testOrgID, Dashboard: simplejson.NewFromAny(map[string]any{ @@ -838,7 +838,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { type permissionScenarioContext struct { dashboardGuardianMock *guardian.FakeDashboardGuardian - sqlStore db.ReplDB + sqlStore db.DB dashboardStore dashboards.Store savedFolder *dashboards.Dashboard savedDashInFolder *dashboards.Dashboard @@ -858,7 +858,7 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc t.Run(desc, func(t *testing.T) { features := featuremgmt.WithFeatures() cfg := setting.NewCfg() - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) quotaService := quotatest.New(false, nil) ac := actest.FakeAccessControl{ExpectedEvaluate: true} dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) @@ -917,16 +917,16 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc }) } -func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.ReplDB) *dashboards.Dashboard { +func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.DB) *dashboards.Dashboard { t.Helper() features := featuremgmt.WithFeatures() dto := toSaveDashboardDto(cmd) cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) folderPermissions := accesscontrolmock.NewMockedPermissionsService() folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) @@ -948,14 +948,14 @@ func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSt return res } -func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.ReplDB) error { +func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.DB) error { features := featuremgmt.WithFeatures() dto := toSaveDashboardDto(cmd) cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) service, err := ProvideDashboardServiceImpl( cfg, dashboardStore, folderStore, featuremgmt.WithFeatures(), @@ -970,7 +970,7 @@ func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSto return err } -func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string, sqlStore db.ReplDB) *dashboards.Dashboard { +func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string, sqlStore db.DB) *dashboards.Dashboard { t.Helper() cmd := dashboards.SaveDashboardCommand{ @@ -994,9 +994,9 @@ func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string features := featuremgmt.WithFeatures() cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) dashboardPermissions := accesscontrolmock.NewMockedPermissionsService() dashboardPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) service, err := ProvideDashboardServiceImpl( @@ -1016,7 +1016,7 @@ func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string return res } -func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.ReplDB) *dashboards.Dashboard { +func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *dashboards.Dashboard { t.Helper() cmd := dashboards.SaveDashboardCommand{ OrgID: orgID, @@ -1044,9 +1044,9 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.ReplDB) features := featuremgmt.WithFeatures() cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) folderPermissions := accesscontrolmock.NewMockedPermissionsService() folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) service, err := ProvideDashboardServiceImpl( diff --git a/pkg/services/dashboardsnapshots/service/service_test.go b/pkg/services/dashboardsnapshots/service/service_test.go index 0c56c52ebd5..c2cdf73efad 100644 --- a/pkg/services/dashboardsnapshots/service/service_test.go +++ b/pkg/services/dashboardsnapshots/service/service_test.go @@ -92,7 +92,7 @@ func TestDashboardSnapshotsService(t *testing.T) { } func TestValidateDashboardExists(t *testing.T) { - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() dsStore := dashsnapdb.ProvideStore(sqlStore, cfg) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 7909994201f..cafad0f1346 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1324,13 +1324,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaPluginsPlatformSquad, }, - { - Name: "databaseReadReplica", - Description: "Use a read replica for some database queries.", - Stage: FeatureStageExperimental, - Owner: grafanaBackendServicesSquad, - Expression: "false", // enabled by default - }, { Name: "zanzana", Description: "Use openFGA as authorization engine.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index d05dfd61e64..8cafcf6844f 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -174,7 +174,6 @@ authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false openSearchBackendFlowEnabled,GA,@grafana/aws-datasources,false,false,false ssoSettingsLDAP,experimental,@grafana/identity-access-team,false,false,false failWrongDSUID,experimental,@grafana/plugins-platform-backend,false,false,false -databaseReadReplica,experimental,@grafana/grafana-backend-services-squad,false,false,false zanzana,experimental,@grafana/identity-access-team,false,false,false passScopeToDashboardApi,experimental,@grafana/dashboards-squad,false,false,false alertingApiServer,experimental,@grafana/alerting-squad,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index ae8f0c3d4e5..f32b5c65dca 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -707,10 +707,6 @@ const ( // Throws an error if a datasource has an invalid UIDs FlagFailWrongDSUID = "failWrongDSUID" - // FlagDatabaseReadReplica - // Use a read replica for some database queries. - FlagDatabaseReadReplica = "databaseReadReplica" - // FlagZanzana // Use openFGA as authorization engine. FlagZanzana = "zanzana" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index f1f8041c5b5..9a2acd079e6 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -869,6 +869,7 @@ "name": "databaseReadReplica", "resourceVersion": "1720021873452", "creationTimestamp": "2024-06-18T15:07:15Z", + "deletionTimestamp": "2024-09-20T20:03:26Z", "annotations": { "grafana.app/updatedTimestamp": "2024-07-03 15:51:13.452477 +0000 UTC" } diff --git a/pkg/services/folder/folderimpl/dashboard_folder_store_test.go b/pkg/services/folder/folderimpl/dashboard_folder_store_test.go index 660fdeb40ec..8345f6ce32a 100644 --- a/pkg/services/folder/folderimpl/dashboard_folder_store_test.go +++ b/pkg/services/folder/folderimpl/dashboard_folder_store_test.go @@ -23,15 +23,15 @@ func TestMain(m *testing.M) { } func TestIntegrationDashboardFolderStore(t *testing.T) { - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) var err error - dashboardStore, err = database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err = database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) } t.Run("Given dashboard and folder with the same title", func(t *testing.T) { diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index f2712ff2359..7ad8b4b8235 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -418,7 +418,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - db, cfg := sqlstore.InitTestReplDB(t) + db, cfg := sqlstore.InitTestDB(t) cfg.UnifiedAlerting.BaseInterval = time.Second quotaService := quotatest.New(false, nil) folderStore := ProvideDashboardFolderStore(db) @@ -828,7 +828,7 @@ func TestFolderServiceDualWrite(t *testing.T) { guardian.New = g }) - db, _ := sqlstore.InitTestReplDB(t) + db, _ := sqlstore.InitTestDB(t) cfg := setting.NewCfg() features := featuremgmt.WithFeatures() nestedFolderStore := ProvideStore(db) @@ -1459,7 +1459,7 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - db, cfg := sqlstore.InitTestReplDB(t) + db, cfg := sqlstore.InitTestDB(t) quotaService := quotatest.New(false, nil) folderStore := ProvideDashboardFolderStore(db) @@ -1863,7 +1863,7 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { } func TestFolderServiceGetFolder(t *testing.T) { - db, _ := sqlstore.InitTestReplDB(t) + db, _ := sqlstore.InitTestDB(t) signedInAdminUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{ orgID: { @@ -1962,7 +1962,7 @@ func TestFolderServiceGetFolder(t *testing.T) { } func TestFolderServiceGetFolders(t *testing.T) { - db, cfg := sqlstore.InitTestReplDB(t) + db, cfg := sqlstore.InitTestDB(t) quotaService := quotatest.New(false, nil) folderStore := ProvideDashboardFolderStore(db) @@ -2038,7 +2038,7 @@ func TestFolderServiceGetFolders(t *testing.T) { // TODO replace it with an API test under /pkg/tests/api/folders // whenever the golang client with get updated to allow filtering child folders by permission func TestGetChildrenFilterByPermission(t *testing.T) { - db, cfg := sqlstore.InitTestReplDB(t) + db, cfg := sqlstore.InitTestDB(t) signedInAdminUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{ orgID: { diff --git a/pkg/services/libraryelements/libraryelements_delete_test.go b/pkg/services/libraryelements/libraryelements_delete_test.go index c09062b18a4..597787d6ee0 100644 --- a/pkg/services/libraryelements/libraryelements_delete_test.go +++ b/pkg/services/libraryelements/libraryelements_delete_test.go @@ -4,13 +4,12 @@ import ( "encoding/json" "testing" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/require" ) func TestDeleteLibraryElement(t *testing.T) { @@ -75,7 +74,7 @@ func TestDeleteLibraryElement(t *testing.T) { Data: simplejson.NewFromAny(dashJSON), } // nolint:staticcheck - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID) require.NoError(t, err) diff --git a/pkg/services/libraryelements/libraryelements_get_test.go b/pkg/services/libraryelements/libraryelements_get_test.go index 869e326389b..b441505639b 100644 --- a/pkg/services/libraryelements/libraryelements_get_test.go +++ b/pkg/services/libraryelements/libraryelements_get_test.go @@ -4,14 +4,13 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/kinds/librarypanel" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/require" ) func TestGetLibraryElement(t *testing.T) { @@ -125,7 +124,7 @@ func TestGetLibraryElement(t *testing.T) { Data: simplejson.NewFromAny(dashJSON), } // nolint:staticcheck - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID) require.NoError(t, err) diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 9937ceb6aae..1f5c22168ff 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -89,7 +89,7 @@ func TestDeleteLibraryPanelsInFolder(t *testing.T) { Data: simplejson.NewFromAny(dashJSON), } // nolint:staticcheck - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID) require.NoError(t, err) @@ -164,7 +164,7 @@ func TestGetLibraryPanelConnections(t *testing.T) { Data: simplejson.NewFromAny(dashJSON), } // nolint:staticcheck - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID) require.NoError(t, err) @@ -279,11 +279,10 @@ type scenarioContext struct { folder *folder.Folder initialResult libraryElementResult sqlStore db.DB - replStore db.ReplDB log log.Logger } -func createDashboard(t *testing.T, sqlStore db.ReplDB, user user.SignedInUser, dash *dashboards.Dashboard, folderID int64, folderUID string) *dashboards.Dashboard { +func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash *dashboards.Dashboard, folderID int64, folderUID string) *dashboards.Dashboard { // nolint:staticcheck dash.FolderID = folderID dash.FolderUID = folderUID @@ -298,13 +297,13 @@ func createDashboard(t *testing.T, sqlStore db.ReplDB, user user.SignedInUser, d features := featuremgmt.WithFeatures() cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) ac := actest.FakeAccessControl{ExpectedEvaluate: true} folderPermissions := acmock.NewMockedPermissionsService() dashboardPermissions := acmock.NewMockedPermissionsService() dashboardPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) service, err := dashboardservice.ProvideDashboardServiceImpl( cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, ac, @@ -325,7 +324,7 @@ func createFolder(t *testing.T, sc scenarioContext, title string) *folder.Folder cfg := setting.NewCfg() ac := actest.FakeAccessControl{ExpectedEvaluate: true} quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sc.replStore, cfg, features, tagimpl.ProvideService(sc.sqlStore), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sc.sqlStore, cfg, features, tagimpl.ProvideService(sc.sqlStore), quotaService) require.NoError(t, err) folderStore := folderimpl.ProvideDashboardFolderStore(sc.sqlStore) @@ -381,7 +380,7 @@ func scenarioWithPanel(t *testing.T, desc string, fn func(t *testing.T, sc scena t.Helper() features := featuremgmt.WithFeatures() - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) ac := actest.FakeAccessControl{} quotaService := quotatest.New(false, nil) dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) @@ -442,7 +441,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo features := featuremgmt.WithFeatures() tracer := tracing.InitializeTracerForTest() - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) @@ -485,11 +484,10 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo require.NoError(t, err) sc := scenarioContext{ - user: usr, - ctx: &webCtx, - service: &service, - sqlStore: sqlStore.DB(), - replStore: sqlStore, + user: usr, + ctx: &webCtx, + service: &service, + sqlStore: sqlStore, reqContext: &contextmodel.ReqContext{ Context: &webCtx, SignedInUser: &usr, diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index a5389988491..89bcb12b3cb 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -84,7 +84,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash) err := sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB) require.NoError(t, err) @@ -182,7 +182,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash) err = sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB) require.NoError(t, err) @@ -228,7 +228,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash) err := sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB) require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error()) @@ -284,7 +284,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash) err = sc.elementService.ConnectElementsToDashboard(sc.ctx, sc.user, []string{sc.initialResult.Result.UID}, dashInDB.ID) require.NoError(t, err) @@ -635,7 +635,6 @@ type scenarioContext struct { folder *folder.Folder initialResult libraryPanelResult sqlStore db.DB - replStore db.ReplDB lps LibraryPanelService } @@ -712,7 +711,7 @@ func getExpected(t *testing.T, res model.LibraryElementDTO, UID string, name str } } -func createDashboard(t *testing.T, sqlStore db.ReplDB, user *user.SignedInUser, dash *dashboards.Dashboard) *dashboards.Dashboard { +func createDashboard(t *testing.T, sqlStore db.DB, user *user.SignedInUser, dash *dashboards.Dashboard) *dashboards.Dashboard { dashItem := &dashboards.SaveDashboardDTO{ Dashboard: dash, Message: "", @@ -724,10 +723,10 @@ func createDashboard(t *testing.T, sqlStore db.ReplDB, user *user.SignedInUser, features := featuremgmt.WithFeatures() cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) ac := actest.FakeAccessControl{ExpectedEvaluate: true} - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) dashPermissionService := acmock.NewMockedPermissionsService() dashPermissionService.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) service, err := dashboardservice.ProvideDashboardServiceImpl( @@ -750,7 +749,7 @@ func createFolder(t *testing.T, sc scenarioContext, title string) *folder.Folder ac := actest.FakeAccessControl{ExpectedEvaluate: true} cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sc.replStore, cfg, features, tagimpl.ProvideService(sc.sqlStore), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sc.sqlStore, cfg, features, tagimpl.ProvideService(sc.sqlStore), quotaService) require.NoError(t, err) folderStore := folderimpl.ProvideDashboardFolderStore(sc.sqlStore) s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, sc.sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) @@ -815,8 +814,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo t.Run(desc, func(t *testing.T) { orgID := int64(1) role := org.RoleAdmin - replStore, cfg := db.InitTestReplDBWithCfg(t) - sqlStore := replStore.DB() + sqlStore, cfg := db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) ac := actest.FakeAccessControl{ExpectedEvaluate: true} @@ -833,7 +831,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo require.NoError(t, err) guardian.InitAccessControlGuardian(setting.NewCfg(), ac, dashService) - dashboardStore, err := database.ProvideDashboardStore(replStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) features := featuremgmt.WithFeatures() folderService := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) @@ -886,7 +884,6 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo service: &service, elementService: elementService, sqlStore: sqlStore, - replStore: replStore, lps: service, } diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index 785d433769f..32fa6c7d460 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -1793,8 +1793,7 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment { GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: string(raw), }) - replDB, cfg := db.InitTestReplDBWithCfg(t) - sqlStore := replDB.DB() + sqlStore, cfg := db.InitTestDBWithCfg(t) quotas := &provisioning.MockQuotaChecker{} quotas.EXPECT().LimitOK() @@ -1818,7 +1817,7 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment { }}, nil).Maybe() ac := &recordingAccessControlFake{} - dashboardStore, err := database.ProvideDashboardStore(replDB, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) diff --git a/pkg/services/ngalert/provisioning/alert_rules_test.go b/pkg/services/ngalert/provisioning/alert_rules_test.go index ed9bf7e3514..e5e819ef45f 100644 --- a/pkg/services/ngalert/provisioning/alert_rules_test.go +++ b/pkg/services/ngalert/provisioning/alert_rules_test.go @@ -16,6 +16,12 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" + "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -25,15 +31,10 @@ import ( "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/folder/foldertest" - "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" - "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/services/ngalert/testutil" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" - "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" ) func TestAlertRuleService(t *testing.T) { @@ -1541,7 +1542,7 @@ func TestDeleteRuleGroup(t *testing.T) { func TestProvisiongWithFullpath(t *testing.T) { tracer := tracing.InitializeTracerForTest() inProcBus := bus.ProvideBus(tracer) - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) _, dashboardStore := testutil.SetupDashboardService(t, sqlStore, folderStore, cfg) diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 8300f0a213f..2969f24d56b 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -16,21 +16,22 @@ import ( "golang.org/x/exp/rand" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/folderimpl" - "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/testutil" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" + + "github.com/grafana/grafana/pkg/infra/db" + acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -41,7 +42,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { } cfg := setting.NewCfg() cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{BaseInterval: time.Duration(rand.Int63n(100)+1) * time.Second} - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) store := &DBstore{ SQLStore: sqlStore, Cfg: cfg.UnifiedAlerting, @@ -120,7 +121,7 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) } cfg := setting.NewCfg() cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{BaseInterval: time.Duration(rand.Int63n(100)+1) * time.Second} - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) store := &DBstore{ SQLStore: sqlStore, Cfg: cfg.UnifiedAlerting, @@ -377,7 +378,7 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) { BaseInterval: time.Duration(rand.Int63n(100)) * time.Second, } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) store := &DBstore{ Logger: &logtest.Fake{}, SQLStore: sqlStore, @@ -501,7 +502,7 @@ func TestIntegration_CountAlertRules(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() store := &DBstore{SQLStore: sqlStore, FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())} @@ -566,7 +567,7 @@ func TestIntegration_DeleteInFolder(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() store := &DBstore{ SQLStore: sqlStore, @@ -599,7 +600,7 @@ func TestIntegration_GetNamespaceByUID(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() store := &DBstore{ SQLStore: sqlStore, @@ -653,7 +654,7 @@ func TestIntegrationInsertAlertRules(t *testing.T) { } orgID := int64(1) - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.UnifiedAlerting.BaseInterval = 1 * time.Second store := &DBstore{ @@ -804,7 +805,7 @@ func TestIntegrationAlertRulesNotificationSettings(t *testing.T) { return result } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.UnifiedAlerting.BaseInterval = 1 * time.Second store := &DBstore{ @@ -1080,7 +1081,7 @@ func TestIntegrationListNotificationSettings(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.UnifiedAlerting.BaseInterval = 1 * time.Second store := &DBstore{ @@ -1203,7 +1204,7 @@ func TestIntegrationGetNamespacesByRuleUID(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.UnifiedAlerting.BaseInterval = 1 * time.Second store := &DBstore{ @@ -1254,7 +1255,7 @@ func TestIntegrationRuleGroupsCaseSensitive(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.UnifiedAlerting.BaseInterval = 1 * time.Second store := &DBstore{ @@ -1363,7 +1364,7 @@ func TestIncreaseVersionForAllRulesInNamespaces(t *testing.T) { } cfg := setting.NewCfg() cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{BaseInterval: time.Duration(rand.Int63n(100)+1) * time.Second} - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) store := &DBstore{ SQLStore: sqlStore, Cfg: cfg.UnifiedAlerting, @@ -1463,11 +1464,11 @@ func createFolder(t *testing.T, store *DBstore, uid, title string, orgID int64, require.NoError(t, err) } -func setupFolderService(t *testing.T, sqlStore db.ReplDB, cfg *setting.Cfg, features featuremgmt.FeatureToggles) folder.Service { +func setupFolderService(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles) folder.Service { tracer := tracing.InitializeTracerForTest() inProcBus := bus.ProvideBus(tracer) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) _, dashboardStore := testutil.SetupDashboardService(t, sqlStore, folderStore, cfg) - return testutil.SetupFolderService(t, cfg, sqlStore.DB(), dashboardStore, folderStore, inProcBus, features, &actest.FakeAccessControl{ExpectedEvaluate: true}) + return testutil.SetupFolderService(t, cfg, sqlStore, dashboardStore, folderStore, inProcBus, features, &actest.FakeAccessControl{ExpectedEvaluate: true}) } diff --git a/pkg/services/ngalert/store/testing.go b/pkg/services/ngalert/store/testing.go index 9efa3804f80..39f6f4ffb09 100644 --- a/pkg/services/ngalert/store/testing.go +++ b/pkg/services/ngalert/store/testing.go @@ -7,10 +7,10 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" ) @@ -134,7 +134,7 @@ func (f *FakeAdminConfigStore) UpdateAdminConfiguration(cmd UpdateAdminConfigura return nil } -func SetupStoreForTesting(t *testing.T, db *sqlstore.ReplStore) *DBstore { +func SetupStoreForTesting(t *testing.T, db db.DB) *DBstore { t.Helper() cfg := setting.NewCfg() cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{BaseInterval: 1 * time.Second} diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index 4b635292ba5..ae5664adeec 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -54,8 +54,7 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, *cfg.UnifiedAlerting.Enabled = true m := metrics.NewNGAlert(prometheus.NewRegistry()) - replStore := db.InitTestReplDB(tb) - sqlStore := replStore.DB() + sqlStore := db.InitTestDB(tb) secretsService := secretsManager.SetupTestService(tb, database.ProvideSecretsStore(sqlStore)) ac := acmock.New() @@ -63,7 +62,7 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, tracer := tracing.InitializeTracerForTest() bus := bus.ProvideBus(tracer) folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - dashboardService, dashboardStore := testutil.SetupDashboardService(tb, replStore, folderStore, cfg) + dashboardService, dashboardStore := testutil.SetupDashboardService(tb, sqlStore, folderStore, cfg) features := featuremgmt.WithFeatures() folderService := testutil.SetupFolderService(tb, cfg, sqlStore, dashboardStore, folderStore, bus, features, ac) ruleStore, err := store.ProvideDBStore(cfg, featuremgmt.WithFeatures(), sqlStore, folderService, &dashboards.FakeDashboardService{}, ac) diff --git a/pkg/services/ngalert/testutil/testutil.go b/pkg/services/ngalert/testutil/testutil.go index a1986de8734..6311a9ffdbb 100644 --- a/pkg/services/ngalert/testutil/testutil.go +++ b/pkg/services/ngalert/testutil/testutil.go @@ -30,7 +30,7 @@ func SetupFolderService(tb testing.TB, cfg *setting.Cfg, db db.DB, dashboardStor return folderimpl.ProvideService(ac, bus, dashboardStore, folderStore, db, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) } -func SetupDashboardService(tb testing.TB, sqlStore db.ReplDB, fs *folderimpl.DashboardFolderStoreImpl, cfg *setting.Cfg) (*dashboardservice.DashboardServiceImpl, dashboards.Store) { +func SetupDashboardService(tb testing.TB, sqlStore db.DB, fs *folderimpl.DashboardFolderStoreImpl, cfg *setting.Cfg) (*dashboardservice.DashboardServiceImpl, dashboards.Store) { tb.Helper() origNewGuardian := guardian.New @@ -51,7 +51,7 @@ func SetupDashboardService(tb testing.TB, sqlStore db.ReplDB, fs *folderimpl.Das features := featuremgmt.WithFeatures() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(tb, err) dashboardService, err := dashboardservice.ProvideDashboardServiceImpl( diff --git a/pkg/services/org/orgimpl/store_test.go b/pkg/services/org/orgimpl/store_test.go index 72288d14c26..a4f839d6842 100644 --- a/pkg/services/org/orgimpl/store_test.go +++ b/pkg/services/org/orgimpl/store_test.go @@ -906,7 +906,7 @@ func TestIntegration_SQLStore_RemoveOrgUser(t *testing.T) { func createOrgAndUserSvc(t *testing.T, store db.DB, cfg *setting.Cfg) (org.Service, user.Service) { t.Helper() - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) + quotaService := quotaimpl.ProvideService(store, cfg) orgService, err := ProvideService(store, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/services/publicdashboards/api/query_test.go b/pkg/services/publicdashboards/api/query_test.go index bb6a201c3c4..6146c36de2f 100644 --- a/pkg/services/publicdashboards/api/query_test.go +++ b/pkg/services/publicdashboards/api/query_test.go @@ -254,7 +254,7 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) if testing.Short() { t.Skip("skipping integration test") } - db, cfg := db.InitTestReplDBWithCfg(t) + db, cfg := db.InitTestDBWithCfg(t) cacheService := datasourcesService.ProvideCacheService(localcache.ProvideService(), db, guardian.ProvideGuardian()) qds := buildQueryDataService(t, cacheService, nil, db) diff --git a/pkg/services/publicdashboards/database/database_test.go b/pkg/services/publicdashboards/database/database_test.go index 2138e4ccd5f..d2e506063b1 100644 --- a/pkg/services/publicdashboards/database/database_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -48,7 +48,6 @@ func TestIntegrationListPublicDashboard(t *testing.T) { } var sqlStore db.DB - var replStore db.ReplDB var cfg *setting.Cfg var aDash *dashboards.Dashboard @@ -64,10 +63,9 @@ func TestIntegrationListPublicDashboard(t *testing.T) { var publicdashboardStore *PublicDashboardStoreImpl setup := func() { - replStore, cfg = db.InitTestReplDBWithCfg(t, db.InitTestDBOpt{}) - sqlStore = replStore.DB() + sqlStore, cfg = db.InitTestDBWithCfg(t, db.InitTestDBOpt{}) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(replStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) @@ -173,19 +171,19 @@ func TestIntegrationExistsEnabledByAccessToken(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *dashboards.Dashboard setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboardStore = store - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } t.Run("ExistsEnabledByAccessToken will return true when at least one public dashboard has a matching access token", func(t *testing.T) { @@ -246,19 +244,19 @@ func TestIntegrationExistsEnabledByDashboardUid(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *dashboards.Dashboard setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboardStore = store - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } @@ -311,19 +309,19 @@ func TestIntegrationFindByDashboardUid(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *dashboards.Dashboard setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboardStore = store - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } @@ -379,7 +377,7 @@ func TestIntegrationFindByAccessToken(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl @@ -387,10 +385,10 @@ func TestIntegrationFindByAccessToken(t *testing.T) { var err error setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) - dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotatest.New(false, nil)) + sqlStore, cfg = db.InitTestDBWithCfg(t) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } @@ -447,7 +445,7 @@ func TestIntegrationCreatePublicDashboard(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl @@ -455,12 +453,12 @@ func TestIntegrationCreatePublicDashboard(t *testing.T) { var savedDashboard2 *dashboards.Dashboard setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t, db.InitTestDBOpt{}) + sqlStore, cfg = db.InitTestDBWithCfg(t, db.InitTestDBOpt{}) quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboardStore = store - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) savedDashboard2 = insertTestDashboard(t, dashboardStore, "testDashie2", 1, "", true) insertPublicDashboard(t, publicdashboardStore, savedDashboard2.UID, savedDashboard2.OrgID, false, PublicShareType) @@ -526,7 +524,7 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl @@ -535,11 +533,11 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { var err error setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t, db.InitTestDBOpt{}) + sqlStore, cfg = db.InitTestDBWithCfg(t, db.InitTestDBOpt{}) quotaService := quotatest.New(false, nil) - dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) anotherSavedDashboard = insertTestDashboard(t, dashboardStore, "test another Dashie", 1, "", true) } @@ -631,7 +629,7 @@ func TestIntegrationGetOrgIdByAccessToken(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl @@ -639,11 +637,11 @@ func TestIntegrationGetOrgIdByAccessToken(t *testing.T) { var err error setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) - dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } t.Run("GetOrgIdByAccessToken will OrgId when enabled", func(t *testing.T) { @@ -703,7 +701,7 @@ func TestIntegrationDelete(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl @@ -712,10 +710,10 @@ func TestIntegrationDelete(t *testing.T) { var err error setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) - dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotatest.New(false, nil)) + sqlStore, cfg = db.InitTestDBWithCfg(t) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) savedPublicDashboard = insertPublicDashboard(t, publicdashboardStore, savedDashboard.UID, savedDashboard.OrgID, true, PublicShareType) } @@ -763,7 +761,7 @@ func TestFindByFolder(t *testing.T) { }) t.Run("can get all pubdashes for dashboard folder and org", func(t *testing.T) { - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) @@ -792,7 +790,7 @@ func TestGetMetrics(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl @@ -802,12 +800,12 @@ func TestGetMetrics(t *testing.T) { var savedDashboard4 *dashboards.Dashboard setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t, db.InitTestDBOpt{}) + sqlStore, cfg = db.InitTestDBWithCfg(t, db.InitTestDBOpt{}) quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboardStore = store - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", false) savedDashboard2 = insertTestDashboard(t, dashboardStore, "testDashie2", 1, "", false) savedDashboard3 = insertTestDashboard(t, dashboardStore, "testDashie3", 2, "", false) diff --git a/pkg/services/publicdashboards/service/common_test.go b/pkg/services/publicdashboards/service/common_test.go index a81fbf32784..fd014362050 100644 --- a/pkg/services/publicdashboards/service/common_test.go +++ b/pkg/services/publicdashboards/service/common_test.go @@ -24,10 +24,10 @@ func newPublicDashboardServiceImpl( publicDashboardStore publicdashboards.Store, dashboardService dashboards.DashboardService, annotationsRepo annotations.Repository, -) (*PublicDashboardServiceImpl, db.ReplDB, *setting.Cfg) { +) (*PublicDashboardServiceImpl, db.DB, *setting.Cfg) { t.Helper() - db, cfg := db.InitTestReplDBWithCfg(t) + db, cfg := db.InitTestDBWithCfg(t) tagService := tagimpl.ProvideService(db) if annotationsRepo == nil { annotationsRepo = annotationsimpl.ProvideService(db, cfg, featuremgmt.WithFeatures(), tagService, tracing.InitializeTracerForTest(), nil) diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index e82d452e77a..7a356effc7b 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -10,9 +10,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" @@ -28,6 +25,10 @@ import ( "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/util" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) const ( @@ -681,7 +682,7 @@ func TestGetQueryDataResponse(t *testing.T) { fakeQueryService.On("QueryData", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&backend.QueryDataResponse{}, nil) service.QueryDataService = fakeQueryService - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, service.cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotatest.New(false, nil)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, service.cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) publicDashboardQueryDTO := PublicDashboardQueryDTO{ @@ -1084,7 +1085,7 @@ func TestFindAnnotations(t *testing.T) { func TestGetMetricRequest(t *testing.T) { service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, nil, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotatest.New(false, nil)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]interface{}{}, nil) @@ -1166,7 +1167,7 @@ func TestBuildMetricRequest(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotatest.New(false, nil)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) publicDashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]interface{}{}, nil) nonPublicDashboard := insertTestDashboard(t, dashboardStore, "testNonPublicDashie", 1, 0, "", true, []map[string]interface{}{}, nil) @@ -1319,7 +1320,7 @@ func TestBuildMetricRequest(t *testing.T) { } func TestBuildAnonymousUser(t *testing.T) { - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]interface{}{}, nil) diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index 2970fbb9b87..fa28eed134c 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -586,7 +586,7 @@ func TestCreatePublicDashboard(t *testing.T) { service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) @@ -666,7 +666,7 @@ func TestCreatePublicDashboard(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) @@ -698,7 +698,7 @@ func TestCreatePublicDashboard(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) @@ -725,7 +725,7 @@ func TestCreatePublicDashboard(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) templateVars := make([]map[string]any, 1) @@ -791,7 +791,7 @@ func TestCreatePublicDashboard(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) @@ -857,7 +857,7 @@ func TestCreatePublicDashboard(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]interface{}{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) @@ -909,7 +909,7 @@ func TestCreatePublicDashboard(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, publicdashboardStore, fakeDashboardService, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotatest.New(false, nil)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) @@ -935,7 +935,7 @@ func TestCreatePublicDashboard(t *testing.T) { service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) @@ -973,7 +973,7 @@ func TestUpdatePublicDashboard(t *testing.T) { service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) dashboard2 := insertTestDashboard(t, dashboardStore, "testDashie2", 1, 0, "", true, []map[string]any{}, nil) @@ -1156,7 +1156,7 @@ func TestUpdatePublicDashboard(t *testing.T) { service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) diff --git a/pkg/services/quota/quotaimpl/quota.go b/pkg/services/quota/quotaimpl/quota.go index e8867ad38e8..34ba998317f 100644 --- a/pkg/services/quota/quotaimpl/quota.go +++ b/pkg/services/quota/quotaimpl/quota.go @@ -58,7 +58,7 @@ type service struct { targetToSrv *quota.TargetToSrv } -func ProvideService(db db.ReplDB, cfg *setting.Cfg) quota.Service { +func ProvideService(db db.DB, cfg *setting.Cfg) quota.Service { logger := log.New("quota_service") s := service{ store: &sqlStore{db: db, logger: logger}, diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index 4c334396003..66763397395 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -69,7 +69,7 @@ func TestIntegrationQuotaCommandsAndQueries(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) cfg.Quota = setting.QuotaSettings{ Enabled: true, @@ -479,14 +479,13 @@ func getQuotaBySrvTargetScope(t *testing.T, quotaService quota.Service, srv quot return quota.QuotaDTO{}, err } -func setupEnv(t *testing.T, replStore db.ReplDB, cfg *setting.Cfg, b bus.Bus, quotaService quota.Service) { - sqlStore := replStore.DB() +func setupEnv(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, b bus.Bus, quotaService quota.Service) { tracer := tracing.InitializeTracerForTest() _, err := apikeyimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) _, err = authimpl.ProvideUserAuthTokenService(sqlStore, nil, quotaService, cfg) require.NoError(t, err) - _, err = dashboardStore.ProvideDashboardStore(replStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) + _, err = dashboardStore.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) diff --git a/pkg/services/quota/quotaimpl/store.go b/pkg/services/quota/quotaimpl/store.go index 743e4f20275..78bc38fe4d9 100644 --- a/pkg/services/quota/quotaimpl/store.go +++ b/pkg/services/quota/quotaimpl/store.go @@ -16,12 +16,12 @@ type store interface { } type sqlStore struct { - db db.ReplDB + db db.DB logger log.Logger } func (ss *sqlStore) DeleteByUser(ctx quota.Context, userID int64) error { - return ss.db.DB().WithDbSession(ctx, func(sess *db.Session) error { + return ss.db.WithDbSession(ctx, func(sess *db.Session) error { var rawSQL = "DELETE FROM quota WHERE user_id = ?" _, err := sess.Exec(rawSQL, userID) return err @@ -54,7 +54,7 @@ func (ss *sqlStore) Get(ctx quota.Context, scopeParams *quota.ScopeParameters) ( } func (ss *sqlStore) Update(ctx quota.Context, cmd *quota.UpdateQuotaCmd) error { - return ss.db.DB().WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { // Check if quota is already defined in the DB quota := quota.Quota{ Target: cmd.Target, @@ -87,7 +87,7 @@ func (ss *sqlStore) Update(ctx quota.Context, cmd *quota.UpdateQuotaCmd) error { func (ss *sqlStore) getUserScopeQuota(ctx quota.Context, userID int64) (*quota.Map, error) { r := quota.Map{} - err := ss.db.ReadReplica().WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { quotas := make([]*quota.Quota, 0) if err := sess.Table("quota").Where("user_id=? AND org_id=0", userID).Find("as); err != nil { return err @@ -111,7 +111,7 @@ func (ss *sqlStore) getUserScopeQuota(ctx quota.Context, userID int64) (*quota.M func (ss *sqlStore) getOrgScopeQuota(ctx quota.Context, OrgID int64) (*quota.Map, error) { r := quota.Map{} - err := ss.db.ReadReplica().WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { quotas := make([]*quota.Quota, 0) if err := sess.Table("quota").Where("user_id=0 AND org_id=?", OrgID).Find("as); err != nil { return err diff --git a/pkg/services/quota/quotaimpl/store_test.go b/pkg/services/quota/quotaimpl/store_test.go index 0128f86ad1d..2e8211871d1 100644 --- a/pkg/services/quota/quotaimpl/store_test.go +++ b/pkg/services/quota/quotaimpl/store_test.go @@ -20,7 +20,7 @@ func TestIntegrationQuotaDataAccess(t *testing.T) { t.Skip("skipping integration test") } - ss := db.InitTestReplDB(t) + ss := db.InitTestDB(t) quotaStore := sqlStore{ db: ss, } diff --git a/pkg/services/searchV2/index_test.go b/pkg/services/searchV2/index_test.go index bc1d4b5d07b..e6a569fb57c 100644 --- a/pkg/services/searchV2/index_test.go +++ b/pkg/services/searchV2/index_test.go @@ -773,8 +773,7 @@ func TestIntegrationSoftDeletion(t *testing.T) { // Set up search v2. folderCount := 1 dashboardsPerFolder := 1 - replStore, cfg := db.InitTestReplDBWithCfg(t) - sqlStore := replStore.DB() + sqlStore, cfg := db.InitTestDBWithCfg(t) searchService, testUser, err := setupIntegrationEnv(t, folderCount, dashboardsPerFolder, sqlStore) require.NoError(t, err) @@ -796,7 +795,7 @@ func TestIntegrationSoftDeletion(t *testing.T) { featuremgmt.FlagDashboardRestore, featuremgmt.FlagMysqlParseTime, ) - dashboardStore, err := database.ProvideDashboardStore(replStore, cfg, featureToggles, tagimpl.ProvideService(sqlStore), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) // Soft delete "dashboard2". diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index 5ef35d18c59..66d15e35b9d 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -36,17 +36,17 @@ type TestApiKey struct { ServiceAccountID *int64 } -func SetupUserServiceAccount(t *testing.T, store db.DB, cfg *setting.Cfg, testUser TestUser) *user.User { +func SetupUserServiceAccount(t *testing.T, db db.DB, cfg *setting.Cfg, testUser TestUser) *user.User { role := string(org.RoleViewer) if testUser.Role != "" { role = testUser.Role } - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) - orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) @@ -112,7 +112,7 @@ func SetupApiKey(t *testing.T, store db.DB, cfg *setting.Cfg, testKey TestApiKey func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, testUsers []TestUser) (orgID int64) { role := string(org.RoleNone) - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(sqlStore), cfg) + quotaService := quotaimpl.ProvideService(sqlStore, cfg) orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/services/sqlstore/database_config.go b/pkg/services/sqlstore/database_config.go index e469455670f..171ff4972b9 100644 --- a/pkg/services/sqlstore/database_config.go +++ b/pkg/services/sqlstore/database_config.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/go-sql-driver/mysql" - "gopkg.in/ini.v1" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" @@ -66,15 +65,9 @@ func NewDatabaseConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles) (* return dbCfg, nil } -// readConfigSection reads the database configuration from the given block of -// the configuration file. This method allows us to add a "database_replica" -// section to the configuration file while using the same cfg struct. -func (dbCfg *DatabaseConfig) readConfigSection(cfg *setting.Cfg, section string) error { - sec := cfg.Raw.Section(section) - return dbCfg.parseConfigIni(sec) -} +func (dbCfg *DatabaseConfig) readConfig(cfg *setting.Cfg) error { + sec := cfg.Raw.Section("database") -func (dbCfg *DatabaseConfig) parseConfigIni(sec *ini.Section) error { cfgURL := sec.Key("url").String() if len(cfgURL) != 0 { dbURL, err := url.Parse(cfgURL) @@ -108,6 +101,7 @@ func (dbCfg *DatabaseConfig) parseConfigIni(sec *ini.Section) error { dbCfg.MaxOpenConn = sec.Key("max_open_conn").MustInt(0) dbCfg.MaxIdleConn = sec.Key("max_idle_conn").MustInt(2) dbCfg.ConnMaxLifetime = sec.Key("conn_max_lifetime").MustInt(14400) + dbCfg.SslMode = sec.Key("ssl_mode").String() dbCfg.SSLSNI = sec.Key("ssl_sni").String() dbCfg.CaCertPath = sec.Key("ca_cert_path").String() @@ -116,20 +110,19 @@ func (dbCfg *DatabaseConfig) parseConfigIni(sec *ini.Section) error { dbCfg.ServerCertName = sec.Key("server_cert_name").String() dbCfg.Path = sec.Key("path").MustString("data/grafana.db") dbCfg.IsolationLevel = sec.Key("isolation_level").String() + dbCfg.CacheMode = sec.Key("cache_mode").MustString("private") dbCfg.WALEnabled = sec.Key("wal").MustBool(false) dbCfg.SkipMigrations = sec.Key("skip_migrations").MustBool() dbCfg.MigrationLock = sec.Key("migration_locking").MustBool(true) dbCfg.MigrationLockAttemptTimeout = sec.Key("locking_attempt_timeout_sec").MustInt() + dbCfg.QueryRetries = sec.Key("query_retries").MustInt() dbCfg.TransactionRetries = sec.Key("transaction_retries").MustInt(5) - dbCfg.LogQueries = sec.Key("log_queries").MustBool(false) - return nil -} -// readConfig is a wrapper around readConfigSection that read the "database" configuration block. -func (dbCfg *DatabaseConfig) readConfig(cfg *setting.Cfg) error { - return dbCfg.readConfigSection(cfg, "database") + dbCfg.LogQueries = sec.Key("log_queries").MustBool(false) + + return nil } func (dbCfg *DatabaseConfig) buildConnectionString(cfg *setting.Cfg, features featuremgmt.FeatureToggles) error { @@ -235,50 +228,3 @@ func buildExtraConnectionString(sep rune, urlQueryParams map[string][]string) st } return sb.String() } - -func validateReplicaConfigs(primary *DatabaseConfig, cfgs []DatabaseConfig) error { - if cfgs == nil { - return errors.New("cfg cannot be nil") - } - - // Return multiple errors so we can fix them all at once! - var result error - - // Check for duplicate connection strings - seen := make(map[string]struct{}) - seen[primary.ConnectionString] = struct{}{} - for _, cfg := range cfgs { - if _, ok := seen[cfg.ConnectionString]; ok { - result = errors.Join(result, errors.New("duplicate connection string")) - } else { - seen[cfg.ConnectionString] = struct{}{} - } - } - - // Verify that every database is the same type and version, and that it matches the primary database. - // The database Yype may include a "withHooks" suffix, which is used to differentiate drivers for instrumentation and ignored for the purpose of this check. - for _, cfg := range cfgs { - if databaseDriverFromName(cfg.Type) != databaseDriverFromName(primary.Type) { - result = errors.Join(result, fmt.Errorf("the replicas must have the same database type as the primary database (%s != %s)", primary.Type, cfg.Type)) - break // Only need to report this once - } - } - - return result -} - -// databaseDriverFromName strips any suffixes from the driver type that are not relevant to the database driver. -// This is used to remove the "WithHooks" or "ReplWithHooks" suffixes which are used to differentiate drivers for instrumentation. -func databaseDriverFromName(driverTy string) string { - if strings.HasPrefix(driverTy, migrator.MySQL) { - return migrator.MySQL - } - if strings.HasPrefix(driverTy, migrator.Postgres) { - return migrator.Postgres - } - if strings.HasPrefix(driverTy, migrator.SQLite) { - return migrator.SQLite - } - // default - return driverTy -} diff --git a/pkg/services/sqlstore/database_config_test.go b/pkg/services/sqlstore/database_config_test.go index 5dd882f85b6..cd13fc3b8fa 100644 --- a/pkg/services/sqlstore/database_config_test.go +++ b/pkg/services/sqlstore/database_config_test.go @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/ini.v1" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" @@ -222,93 +221,3 @@ func TestBuildConnectionStringPostgres(t *testing.T) { }) } } - -func TestValidateReplicaConfigs(t *testing.T) { - t.Run("valid config", func(t *testing.T) { - inicfg, err := ini.Load([]byte(testReplCfg)) - require.NoError(t, err) - cfg, err := setting.NewCfgFromINIFile(inicfg) - require.NoError(t, err) - - dbCfgs, err := NewRODatabaseConfigs(cfg, nil) - require.NoError(t, err) - - err = validateReplicaConfigs(&DatabaseConfig{Type: "mysql"}, dbCfgs) - require.NoError(t, err) - }) - - t.Run("valid but awkward config", func(t *testing.T) { - inicfg, err := ini.Load([]byte(testReplCfg)) - require.NoError(t, err) - cfg, err := setting.NewCfgFromINIFile(inicfg) - require.NoError(t, err) - - dbCfgs, err := NewRODatabaseConfigs(cfg, nil) - require.NoError(t, err) - - // The primary is mysql, but the replicas are mysqlWithHooks. This can - // occur when some but not all the replicas (or primary) have - // instrument_queries enabled - err = validateReplicaConfigs(&DatabaseConfig{Type: "mysqlWithHooks"}, dbCfgs) - require.NoError(t, err) - }) - - t.Run("invalid config: primary database type mismatch", func(t *testing.T) { - // valid repl config, the issue is that the primary has a different type - inicfg, err := ini.Load([]byte(testReplCfg)) - require.NoError(t, err) - cfg, err := setting.NewCfgFromINIFile(inicfg) - require.NoError(t, err) - - dbCfgs, err := NewRODatabaseConfigs(cfg, nil) - require.NoError(t, err) - - err = validateReplicaConfigs(&DatabaseConfig{Type: "postgres"}, dbCfgs) - require.Error(t, err) - - if uw, ok := err.(interface{ Unwrap() []error }); ok { - errs := uw.Unwrap() - require.Equal(t, 1, len(errs)) - } - }) - - t.Run("invalid repl config", func(t *testing.T) { - // Type mismatch + duplicate hosts - inicfg, err := ini.Load([]byte(invalidReplCfg)) - require.NoError(t, err) - cfg, err := setting.NewCfgFromINIFile(inicfg) - require.NoError(t, err) - - dbCfgs, err := NewRODatabaseConfigs(cfg, nil) - require.NoError(t, err) - - err = validateReplicaConfigs(&DatabaseConfig{Type: "mysql"}, dbCfgs) - require.Error(t, err) - - if uw, ok := err.(interface{ Unwrap() []error }); ok { - errs := uw.Unwrap() - require.Equal(t, 2, len(errs)) - } - }) -} - -// This cfg has a duplicate host for repls 0 and 1, and a type mismatch in repl 2 -var invalidReplCfg = ` -[database_replicas] -type = mysql -name = grafana -user = grafana -password = password -host = 127.0.0.1:3306 -[database_replica.one] -name = grafana -user = grafana -password = password -type = mysql -host = 127.0.0.1:3306 -[database_replica.two] -name = grafana -user = grafana -password = password -type = postgres -host = 127.0.0.1:3308` diff --git a/pkg/services/sqlstore/database_wrapper.go b/pkg/services/sqlstore/database_wrapper.go index b6358e73002..d173f48158a 100644 --- a/pkg/services/sqlstore/database_wrapper.go +++ b/pkg/services/sqlstore/database_wrapper.go @@ -58,27 +58,6 @@ func WrapDatabaseDriverWithHooks(dbType string, tracer tracing.Tracer) string { return driverWithHooks } -// WrapDatabaseDriverWithHooks creates a fake database driver that -// executes pre and post functions which we use to gather metrics about -// database queries. It also registers the metrics. -func WrapDatabaseReplDriverWithHooks(dbType string, index uint, tracer tracing.Tracer) string { - drivers := map[string]driver.Driver{ - migrator.SQLite: &sqlite3.SQLiteDriver{}, - migrator.MySQL: &mysql.MySQLDriver{}, - migrator.Postgres: &pq.Driver{}, - } - - d, exist := drivers[dbType] - if !exist { - return dbType - } - - driverWithHooks := dbType + fmt.Sprintf("ReplicaWithHooks%d", index) - sql.Register(driverWithHooks, sqlhooks.Wrap(d, &databaseQueryWrapper{log: log.New("sqlstore.metrics"), tracer: tracer})) - core.RegisterDriver(driverWithHooks, &databaseQueryWrapperDriver{dbType: dbType}) - return driverWithHooks -} - // databaseQueryWrapper satisfies the sqlhook.databaseQueryWrapper interface // which allow us to wrap all SQL queries with a `Before` & `After` hook. type databaseQueryWrapper struct { diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index ff9ec8ef8f3..5aae742b6ca 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -6,10 +6,9 @@ import ( "strconv" "strings" + "github.com/grafana/grafana/pkg/services/sqlstore/session" "golang.org/x/exp/slices" "xorm.io/xorm" - - "github.com/grafana/grafana/pkg/services/sqlstore/session" ) var ( @@ -107,14 +106,12 @@ type LockCfg struct { type dialectFunc func() Dialect var supportedDialects = map[string]dialectFunc{ - MySQL: NewMysqlDialect, - SQLite: NewSQLite3Dialect, - Postgres: NewPostgresDialect, - MySQL + "WithHooks": NewMysqlDialect, - MySQL + "ReplicaWithHooks": NewMysqlDialect, - SQLite + "WithHooks": NewSQLite3Dialect, - Postgres + "WithHooks": NewPostgresDialect, - Postgres + "ReplicaWithHooks": NewPostgresDialect, + MySQL: NewMysqlDialect, + SQLite: NewSQLite3Dialect, + Postgres: NewPostgresDialect, + MySQL + "WithHooks": NewMysqlDialect, + SQLite + "WithHooks": NewSQLite3Dialect, + Postgres + "WithHooks": NewPostgresDialect, } func NewDialect(driverName string) Dialect { diff --git a/pkg/services/sqlstore/permissions/dashboard_test.go b/pkg/services/sqlstore/permissions/dashboard_test.go index 1acf17fce41..4c184e56e17 100644 --- a/pkg/services/sqlstore/permissions/dashboard_test.go +++ b/pkg/services/sqlstore/permissions/dashboard_test.go @@ -816,7 +816,7 @@ func setupTest(t *testing.T, numFolders, numDashboards int, permissions []access func setupNestedTest(t *testing.T, usr *user.SignedInUser, perms []accesscontrol.Permission, orgID int64, features featuremgmt.FeatureToggles) db.DB { t.Helper() - db, cfg := db.InitTestReplDBWithCfg(t) + db, cfg := db.InitTestDBWithCfg(t) // dashboard store commands that should be called. dashStore, err := database.ProvideDashboardStore(db, cfg, features, tagimpl.ProvideService(db), quotatest.New(false, nil)) diff --git a/pkg/services/sqlstore/permissions/dashboards_bench_test.go b/pkg/services/sqlstore/permissions/dashboards_bench_test.go index dfb2fe43b81..4dc62cadd3a 100644 --- a/pkg/services/sqlstore/permissions/dashboards_bench_test.go +++ b/pkg/services/sqlstore/permissions/dashboards_bench_test.go @@ -74,7 +74,7 @@ func setupBenchMark(b *testing.B, usr user.SignedInUser, features featuremgmt.Fe nestingLevel = folder.MaxNestedFolderDepth } - store, cfg := db.InitTestReplDBWithCfg(b) + store, cfg := db.InitTestDBWithCfg(b) quotaService := quotatest.New(false, nil) diff --git a/pkg/services/sqlstore/replstore.go b/pkg/services/sqlstore/replstore.go deleted file mode 100644 index 68854acde0a..00000000000 --- a/pkg/services/sqlstore/replstore.go +++ /dev/null @@ -1,277 +0,0 @@ -package sqlstore - -import ( - "errors" - "fmt" - "regexp" - "sync/atomic" - "time" - - "github.com/dlmiddlecote/sqlstats" - "github.com/prometheus/client_golang/prometheus" - "xorm.io/xorm" - - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/registry" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/sqlstore/migrations" - "github.com/grafana/grafana/pkg/services/sqlstore/migrator" - "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" - "github.com/grafana/grafana/pkg/setting" -) - -// ReplStore is a wrapper around a main SQLStore and a read-only SQLStore. The -// main SQLStore is anonymous, so the ReplStore may be used directly as a -// SQLStore. -type ReplStore struct { - *SQLStore - repls []*SQLStore - - // next is the index of the next read-only SQLStore in the chain. - next uint64 -} - -// DB returns the main SQLStore. -func (rs *ReplStore) DB() *SQLStore { - return rs.SQLStore -} - -// ReadReplica returns the read-only SQLStore. If no read replica is configured, -// it returns the main SQLStore. -func (rs *ReplStore) ReadReplica() *SQLStore { - if len(rs.repls) == 0 { - rs.log.Debug("ReadReplica not configured, using main SQLStore") - return rs.SQLStore - } - return rs.nextRepl() -} - -// nextRepl() returns the next read-only SQLStore in the chain. If no read replica is configured, the Primary is returned. -func (rs *ReplStore) nextRepl() *SQLStore { - // start by grabbing the replica at the current index - selected := rs.repls[(int(rs.next))%len(rs.repls)] - - // then increment the index for the next call - atomic.AddUint64(&rs.next, 1) - - return selected -} - -// ProvideServiceWithReadReplica creates a new *SQLStore connection intended for -// use as a ReadReplica of the main SQLStore. The primary SQLStore must already -// be initialized. -func ProvideServiceWithReadReplica(primary *SQLStore, cfg *setting.Cfg, - features featuremgmt.FeatureToggles, migrations registry.DatabaseMigrator, - bus bus.Bus, tracer tracing.Tracer) (*ReplStore, error) { - // start with the initialized SQLStore - replStore := &ReplStore{primary, nil, 0} - - // FeatureToggle fallback: If the FlagDatabaseReadReplica feature flag is not enabled, return a single SQLStore. - if !features.IsEnabledGlobally(featuremgmt.FlagDatabaseReadReplica) { - primary.log.Debug("ReadReplica feature flag not enabled, using main SQLStore") - return replStore, nil - } - - // This change will make xorm use an empty default schema for postgres and - // by that mimic the functionality of how it was functioning before - // xorm's changes above. - xorm.DefaultPostgresSchema = "" - - // Parsing the configuration to get the number of repls - replCfgs, err := NewRODatabaseConfigs(cfg, features) - if err != nil { - return nil, err - } - - if err := validateReplicaConfigs(primary.dbCfg, replCfgs); err != nil { - return nil, fmt.Errorf("failed to validate replica configurations: %w", err) - } - - if len(replCfgs) > 0 { - replStore.repls = make([]*SQLStore, len(replCfgs)) - } - - for i, replCfg := range replCfgs { - // If the database_instrument_queries feature is enabled, wrap the driver with hooks. - if cfg.DatabaseInstrumentQueries { - replCfg.Type = WrapDatabaseReplDriverWithHooks(replCfg.Type, uint(i), tracer) - } - - s, err := newReadOnlySQLStore(cfg, &replCfg, features, bus, tracer) - if err != nil { - return nil, err - } - - // initialize and register metrics wrapper around the *sql.DB - db := s.engine.DB().DB - - // register the go_sql_stats_connections_* metrics - if err := prometheus.Register(sqlstats.NewStatsCollector("grafana_repl", db)); err != nil { - s.log.Warn("Failed to register sqlstore stats collector", "error", err) - } - replStore.repls[i] = s - } - return replStore, nil -} - -// newReadOnlySQLStore creates a new *SQLStore intended for use with a -// fully-populated read replica of the main Grafana Database. It provides no -// write capabilities and does not run migrations, but other tracing and logging -// features are enabled. -func newReadOnlySQLStore(cfg *setting.Cfg, dbCfg *DatabaseConfig, features featuremgmt.FeatureToggles, bus bus.Bus, tracer tracing.Tracer) (*SQLStore, error) { - s := &SQLStore{ - log: log.New("replstore"), - bus: bus, - tracer: tracer, - features: features, - dbCfg: dbCfg, - cfg: cfg, - } - - err := s.initReadOnlyEngine(s.engine) - if err != nil { - return nil, err - } - - // When there are multiple read replicas, we append an index to the driver name (ex: mysqlWithHooks11). - // Remove the index from the end of the driver name to get the original driver name that xorm and other libraries recognize. - driverName := digitsRegexp.ReplaceAllString(s.engine.DriverName(), "") - - s.dialect = migrator.NewDialect(driverName) - return s, nil -} - -// digitsRegexp is used to remove the index from the end of the driver name. -var digitsRegexp = regexp.MustCompile("[0-9]+") - -// initReadOnlyEngine initializes ss.engine for read-only operations. The database must be a fully-populated read replica. -func (ss *SQLStore) initReadOnlyEngine(engine *xorm.Engine) error { - if ss.engine != nil { - ss.log.Debug("Already connected to database replica") - return nil - } - - if engine == nil { - var err error - engine, err = xorm.NewEngine(ss.dbCfg.Type, ss.dbCfg.ConnectionString) - if err != nil { - ss.log.Error("failed to connect to database replica", "error", err) - return err - } - // Only for MySQL or MariaDB, verify we can connect with the current connection string's system var for transaction isolation. - // If not, create a new engine with a compatible connection string. - if ss.dbCfg.Type == migrator.MySQL { - engine, err = ss.ensureTransactionIsolationCompatibility(engine, ss.dbCfg.ConnectionString) - if err != nil { - return err - } - } - } - - engine.SetMaxOpenConns(ss.dbCfg.MaxOpenConn) - engine.SetMaxIdleConns(ss.dbCfg.MaxIdleConn) - engine.SetConnMaxLifetime(time.Second * time.Duration(ss.dbCfg.ConnMaxLifetime)) - - // configure sql logging - debugSQL := ss.cfg.Raw.Section("database_replica").Key("log_queries").MustBool(false) - if !debugSQL { - engine.SetLogger(&xorm.DiscardLogger{}) - } else { - // add stack to database calls to be able to see what repository initiated queries. Top 7 items from the stack as they are likely in the xorm library. - engine.SetLogger(NewXormLogger(log.LvlInfo, log.WithSuffix(log.New("replsstore.xorm"), log.CallerContextKey, log.StackCaller(log.DefaultCallerDepth)))) - engine.ShowSQL(true) - engine.ShowExecTime(true) - } - - ss.engine = engine - return nil -} - -// NewRODatabaseConfig creates a new read-only database configuration. -func NewRODatabaseConfigs(cfg *setting.Cfg, features featuremgmt.FeatureToggles) ([]DatabaseConfig, error) { - if cfg == nil { - return nil, errors.New("cfg cannot be nil") - } - - // If one replica is configured in the database_replicas section, use it as the default - defaultReplCfg := DatabaseConfig{} - if err := defaultReplCfg.readConfigSection(cfg, "database_replicas"); err != nil { - return nil, err - } - err := defaultReplCfg.buildConnectionString(cfg, features) - if err != nil { - return nil, err - } - ret := []DatabaseConfig{defaultReplCfg} - - // Check for individual replicas in the database_replica section (e.g. database_replica.one, database_replica.cheetara) - repls := cfg.Raw.Section("database_replica") - if len(repls.ChildSections()) > 0 { - for _, sec := range repls.ChildSections() { - replCfg := DatabaseConfig{} - if err := replCfg.parseConfigIni(sec); err != nil { - return nil, err - } - if err := replCfg.buildConnectionString(cfg, features); err != nil { - return nil, err - } - ret = append(ret, replCfg) - } - } - - return ret, nil -} - -// ProvideServiceWithReadReplicaForTests wraps the SQLStore in a ReplStore, with the main sqlstore as both the primary and read replica. -// TODO: eventually this should be replaced with a more robust test setup which in -func ProvideServiceWithReadReplicaForTests(testDB *SQLStore, t sqlutil.ITestDB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, migrations registry.DatabaseMigrator) (*ReplStore, error) { - return newReplStore(testDB, testDB), nil -} - -// InitTestReplDB initializes a test DB and returns it wrapped in a ReplStore with the main SQLStore as both the primary and read replica. -func InitTestReplDB(t sqlutil.ITestDB, opts ...InitTestDBOpt) (*ReplStore, *setting.Cfg) { - t.Helper() - features := getFeaturesForTesting(opts...) - cfg := getCfgForTesting(opts...) - ss, err := initTestDB(t, cfg, features, migrations.ProvideOSSMigrations(features), opts...) - if err != nil { - t.Fatalf("failed to initialize sql repl store: %s", err) - } - return newReplStore(ss, ss), cfg -} - -// InitTestReplDBWithMigration initializes the test DB given custom migrations. -func InitTestReplDBWithMigration(t sqlutil.ITestDB, migration registry.DatabaseMigrator, opts ...InitTestDBOpt) *ReplStore { - t.Helper() - features := getFeaturesForTesting(opts...) - cfg := getCfgForTesting(opts...) - ss, err := initTestDB(t, cfg, features, migration, opts...) - if err != nil { - t.Fatalf("failed to initialize sql store: %s", err) - } - return newReplStore(ss, ss) -} - -// newReplStore is a wrapper function that returns a ReplStore with the given primary and read replicas. -func newReplStore(primary *SQLStore, readReplicas ...*SQLStore) *ReplStore { - ret := &ReplStore{ - SQLStore: primary, - repls: make([]*SQLStore, len(readReplicas)), - next: 0, - } - ret.repls = readReplicas - return ret -} - -// FakeReplStoreFromStore returns a ReplStore with the given primary -// SQLStore and no read replicas. This is a bare-minimum wrapper for testing, -// and should be removed when all services are using ReplStore in favor of -// InitTestReplDB. -func FakeReplStoreFromStore(primary *SQLStore) *ReplStore { - return &ReplStore{ - SQLStore: primary, - next: 0, - } -} diff --git a/pkg/services/sqlstore/replstore_test.go b/pkg/services/sqlstore/replstore_test.go deleted file mode 100644 index 6cb475b3b06..00000000000 --- a/pkg/services/sqlstore/replstore_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package sqlstore - -import ( - "fmt" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/require" - "gopkg.in/ini.v1" - - "github.com/grafana/grafana/pkg/setting" -) - -func TestReplStore_ReadReplica(t *testing.T) { - // Using the connection strings to differentiate between the replicas - replStore, _ := InitTestReplDB(t) - replStore.repls[0].dbCfg.ConnectionString = "repl0" - - repl1 := &SQLStore{dbCfg: &DatabaseConfig{ConnectionString: "repl1"}} - repl2 := &SQLStore{dbCfg: &DatabaseConfig{ConnectionString: "repl2"}} - replStore.repls = append(replStore.repls, repl1, repl2) - - got := make([]string, 5) - for i := 0; i < 5; i++ { - got[i] = replStore.ReadReplica().dbCfg.ConnectionString - } - - want := []string{"repl0", "repl1", "repl2", "repl0", "repl1"} - if cmp.Equal(got, want) == false { - t.Fatal("wrong result. Got:", got, "Want:", want) - } -} - -func TestNewRODatabaseConfig(t *testing.T) { - t.Run("valid config", func(t *testing.T) { - inicfg, err := ini.Load([]byte(testReplCfg)) - require.NoError(t, err) - cfg, err := setting.NewCfgFromINIFile(inicfg) - require.NoError(t, err) - - dbCfgs, err := NewRODatabaseConfigs(cfg, nil) - require.NoError(t, err) - require.Len(t, dbCfgs, 3) - - var connStr = func(port int) string { - return fmt.Sprintf("grafana:password@tcp(127.0.0.1:%d)/grafana?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", port) - } - for i, c := range dbCfgs { - if !cmp.Equal(c.ConnectionString, connStr(i+3306)) { - t.Errorf("wrong result for connection string %d.\nGot: %s,\nWant: %s", i, c.ConnectionString, connStr(i+3306)) - } - } - }) -} - -var testReplCfg = ` -[database_replicas] -type = mysql -name = grafana -user = grafana -password = password -host = 127.0.0.1:3306 -[database_replica.one] -host = 127.0.0.1:3307 -type = mysql -name = grafana -user = grafana -password = password -[database_replica.two] -host = 127.0.0.1:3308 -type = mysql -name = grafana -user = grafana -password = password` diff --git a/pkg/services/stats/statsimpl/stats.go b/pkg/services/stats/statsimpl/stats.go index 7f580471212..e2139c80b57 100644 --- a/pkg/services/stats/statsimpl/stats.go +++ b/pkg/services/stats/statsimpl/stats.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/stats" "github.com/grafana/grafana/pkg/setting" @@ -18,12 +17,12 @@ import ( const activeUserTimeLimit = time.Hour * 24 * 30 const dailyActiveUserTimeLimit = time.Hour * 24 -func ProvideService(cfg *setting.Cfg, db *sqlstore.ReplStore) stats.Service { +func ProvideService(cfg *setting.Cfg, db db.DB) stats.Service { return &sqlStatsService{cfg: cfg, db: db} } type sqlStatsService struct { - db *sqlstore.ReplStore + db db.DB cfg *setting.Cfg } @@ -63,8 +62,8 @@ func notServiceAccount(dialect migrator.Dialect) string { } func (ss *sqlStatsService) GetSystemStats(ctx context.Context, query *stats.GetSystemStatsQuery) (result *stats.SystemStats, err error) { - dialect := ss.db.ReadReplica().GetDialect() - err = ss.db.ReadReplica().WithDbSession(ctx, func(dbSession *db.Session) error { + dialect := ss.db.GetDialect() + err = ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { sb := &db.SQLBuilder{} sb.Write("SELECT ") sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + ` WHERE ` + notServiceAccount(dialect) + `) AS users,`) @@ -149,8 +148,8 @@ func (ss *sqlStatsService) roleCounterSQL(ctx context.Context) string { } func (ss *sqlStatsService) GetAdminStats(ctx context.Context, query *stats.GetAdminStatsQuery) (result *stats.AdminStats, err error) { - err = ss.db.ReadReplica().WithDbSession(ctx, func(dbSession *db.Session) error { - dialect := ss.db.ReadReplica().GetDialect() + err = ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { + dialect := ss.db.GetDialect() now := time.Now() activeEndDate := now.Add(-activeUserTimeLimit) dailyActiveEndDate := now.Add(-dailyActiveUserTimeLimit) diff --git a/pkg/services/stats/statsimpl/stats_test.go b/pkg/services/stats/statsimpl/stats_test.go index cce64977c32..e07c059bf9d 100644 --- a/pkg/services/stats/statsimpl/stats_test.go +++ b/pkg/services/stats/statsimpl/stats_test.go @@ -32,9 +32,9 @@ func TestIntegrationStatsDataAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - store, cfg := db.InitTestReplDBWithCfg(t) - statsService := &sqlStatsService{db: store} - populateDB(t, store, cfg) + db, cfg := db.InitTestDBWithCfg(t) + statsService := &sqlStatsService{db: db} + populateDB(t, db, cfg) t.Run("Get system stats should not results in error", func(t *testing.T) { query := stats.GetSystemStatsQuery{} @@ -49,7 +49,7 @@ func TestIntegrationStatsDataAccess(t *testing.T) { assert.Equal(t, int64(0), result.APIKeys) assert.Equal(t, int64(2), result.Correlations) assert.NotNil(t, result.DatabaseCreatedTime) - assert.Equal(t, store.GetDialect().DriverName(), result.DatabaseDriver) + assert.Equal(t, db.GetDialect().DriverName(), result.DatabaseDriver) }) t.Run("Get system user count stats should not results in error", func(t *testing.T) { @@ -157,8 +157,8 @@ func TestIntegration_GetAdminStats(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - store, cfg := db.InitTestReplDBWithCfg(t) - statsService := ProvideService(cfg, store) + db, cfg := db.InitTestDBWithCfg(t) + statsService := ProvideService(cfg, db) query := stats.GetAdminStatsQuery{} _, err := statsService.GetAdminStats(context.Background(), &query) diff --git a/pkg/services/team/teamimpl/store.go b/pkg/services/team/teamimpl/store.go index 8d3b0a068ed..e1069cd9f4e 100644 --- a/pkg/services/team/teamimpl/store.go +++ b/pkg/services/team/teamimpl/store.go @@ -31,7 +31,7 @@ type store interface { } type xormStore struct { - db db.ReplDB + db db.DB cfg *setting.Cfg deletes []string } @@ -84,7 +84,7 @@ func (ss *xormStore) Create(name, email string, orgID int64) (team.Team, error) Created: time.Now(), Updated: time.Now(), } - err := ss.db.DB().WithTransactionalDbSession(context.Background(), func(sess *db.Session) error { + err := ss.db.WithTransactionalDbSession(context.Background(), func(sess *db.Session) error { if isNameTaken, err := isTeamNameTaken(orgID, name, 0, sess); err != nil { return err } else if isNameTaken { @@ -98,7 +98,7 @@ func (ss *xormStore) Create(name, email string, orgID int64) (team.Team, error) } func (ss *xormStore) Update(ctx context.Context, cmd *team.UpdateTeamCommand) error { - return ss.db.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { if isNameTaken, err := isTeamNameTaken(cmd.OrgID, cmd.Name, cmd.ID, sess); err != nil { return err } else if isNameTaken { @@ -129,7 +129,7 @@ func (ss *xormStore) Update(ctx context.Context, cmd *team.UpdateTeamCommand) er // DeleteTeam will delete a team, its member and any permissions connected to the team func (ss *xormStore) Delete(ctx context.Context, cmd *team.DeleteTeamCommand) error { - return ss.db.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { if _, err := teamExists(cmd.OrgID, cmd.ID, sess); err != nil { return err } @@ -180,7 +180,7 @@ func (ss *xormStore) Search(ctx context.Context, query *team.SearchTeamsQuery) ( queryResult := team.SearchTeamQueryResult{ Teams: make([]*team.TeamDTO, 0), } - err := ss.db.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { queryWithWildcards := "%" + query.Query + "%" var sql bytes.Buffer @@ -191,12 +191,12 @@ func (ss *xormStore) Search(ctx context.Context, query *team.SearchTeamsQuery) ( params = append(params, user) } - sql.WriteString(getTeamSelectSQLBase(ss.db.ReadReplica(), filteredUsers)) + sql.WriteString(getTeamSelectSQLBase(ss.db, filteredUsers)) sql.WriteString(` WHERE team.org_id = ?`) params = append(params, query.OrgID) if query.Query != "" { - sql.WriteString(` and team.name ` + ss.db.ReadReplica().GetDialect().LikeStr() + ` ?`) + sql.WriteString(` and team.name ` + ss.db.GetDialect().LikeStr() + ` ?`) params = append(params, queryWithWildcards) } @@ -233,7 +233,7 @@ func (ss *xormStore) Search(ctx context.Context, query *team.SearchTeamsQuery) ( if query.Limit != 0 { offset := query.Limit * (query.Page - 1) - sql.WriteString(ss.db.ReadReplica().GetDialect().LimitOffset(int64(query.Limit), int64(offset))) + sql.WriteString(ss.db.GetDialect().LimitOffset(int64(query.Limit), int64(offset))) } if err := sess.SQL(sql.String(), params...).Find(&queryResult.Teams); err != nil { @@ -245,7 +245,7 @@ func (ss *xormStore) Search(ctx context.Context, query *team.SearchTeamsQuery) ( countSess.Where("team.org_id=?", query.OrgID) if query.Query != "" { - countSess.Where(`name `+ss.db.ReadReplica().GetDialect().LikeStr()+` ?`, queryWithWildcards) + countSess.Where(`name `+ss.db.GetDialect().LikeStr()+` ?`, queryWithWildcards) } if query.Name != "" { @@ -268,12 +268,12 @@ func (ss *xormStore) Search(ctx context.Context, query *team.SearchTeamsQuery) ( func (ss *xormStore) GetByID(ctx context.Context, query *team.GetTeamByIDQuery) (*team.TeamDTO, error) { var queryResult *team.TeamDTO - err := ss.db.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { var sql bytes.Buffer params := make([]any, 0) filteredUsers := getFilteredUsers(query.SignedInUser, query.HiddenUsers) - sql.WriteString(getTeamSelectSQLBase(ss.db.ReadReplica(), filteredUsers)) + sql.WriteString(getTeamSelectSQLBase(ss.db, filteredUsers)) for _, user := range filteredUsers { params = append(params, user) } @@ -304,12 +304,12 @@ func (ss *xormStore) GetByID(ctx context.Context, query *team.GetTeamByIDQuery) // GetTeamsByUser is used by the Guardian when checking a users' permissions func (ss *xormStore) GetByUser(ctx context.Context, query *team.GetTeamsByUserQuery) ([]*team.TeamDTO, error) { queryResult := make([]*team.TeamDTO, 0) - err := ss.db.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { var sql bytes.Buffer var params []any params = append(params, query.OrgID, query.UserID) - sql.WriteString(getTeamSelectSQLBase(ss.db.ReadReplica(), []string{})) + sql.WriteString(getTeamSelectSQLBase(ss.db, []string{})) sql.WriteString(` INNER JOIN team_member on team.id = team_member.team_id`) sql.WriteString(` WHERE team.org_id = ? and team_member.user_id = ?`) @@ -333,7 +333,7 @@ func (ss *xormStore) GetByUser(ctx context.Context, query *team.GetTeamsByUserQu func (ss *xormStore) GetIDsByUser(ctx context.Context, query *team.GetTeamIDsByUserQuery) ([]int64, error) { queryResult := make([]int64, 0) - err := ss.db.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { return sess.SQL(`SELECT tm.team_id FROM team_member as tm WHERE tm.user_id=? AND tm.org_id=?;`, query.UserID, query.OrgID).Find(&queryResult) @@ -363,7 +363,7 @@ func getTeamMember(sess *db.Session, orgId int64, teamId int64, userId int64) (t func (ss *xormStore) IsMember(orgId int64, teamId int64, userId int64) (bool, error) { var isMember bool - err := ss.db.ReadReplica().WithDbSession(context.Background(), func(sess *db.Session) error { + err := ss.db.WithDbSession(context.Background(), func(sess *db.Session) error { var err error isMember, err = isTeamMember(sess, orgId, teamId, userId) return err @@ -460,7 +460,7 @@ func removeTeamMember(sess *db.Session, cmd *team.RemoveTeamMemberCommand) error // RemoveUsersMemberships removes all the team membership entries for the given user. // Only used when removing a user from a Grafana instance. func (ss *xormStore) RemoveUsersMemberships(ctx context.Context, userID int64) error { - return ss.db.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { var rawSQL = "DELETE FROM team_member WHERE user_id = ?" _, err := sess.Exec(rawSQL, userID) return err @@ -488,7 +488,7 @@ func (ss *xormStore) GetMembers(ctx context.Context, query *team.GetTeamMembersQ // With accesscontrol we filter out users based on the SignedInUser's permissions // Note we assume that checking SignedInUser is allowed to see team members for this team has already been performed // If the signed in user is not set no member will be returned - sqlID := fmt.Sprintf("%s.%s", ss.db.ReadReplica().GetDialect().Quote("user"), ss.db.ReadReplica().GetDialect().Quote("id")) + sqlID := fmt.Sprintf("%s.%s", ss.db.GetDialect().Quote("user"), ss.db.GetDialect().Quote("id")) *acFilter, err = ac.Filter(query.SignedInUser, sqlID, "users:id:", ac.ActionOrgUsersRead) if err != nil { return nil, err @@ -500,16 +500,15 @@ func (ss *xormStore) GetMembers(ctx context.Context, query *team.GetTeamMembersQ // getTeamMembers return a list of members for the specified team func (ss *xormStore) getTeamMembers(ctx context.Context, query *team.GetTeamMembersQuery, acUserFilter *ac.SQLFilter) ([]*team.TeamMemberDTO, error) { queryResult := make([]*team.TeamMemberDTO, 0) - err := ss.db.ReadReplica().WithDbSession(ctx, func(dbSess *db.Session) error { - dialect := ss.db.ReadReplica().GetDialect() + err := ss.db.WithDbSession(ctx, func(dbSess *db.Session) error { sess := dbSess.Table("team_member") - sess.Join("INNER", dialect.Quote("user"), - fmt.Sprintf("team_member.user_id=%s.%s", dialect.Quote("user"), dialect.Quote("id")), + sess.Join("INNER", ss.db.GetDialect().Quote("user"), + fmt.Sprintf("team_member.user_id=%s.%s", ss.db.GetDialect().Quote("user"), ss.db.GetDialect().Quote("id")), ) sess.Join("INNER", "team", "team.id=team_member.team_id") // explicitly check for serviceaccounts - sess.Where(fmt.Sprintf("%s.is_service_account=?", dialect.Quote("user")), dialect.BooleanStr(false)) + sess.Where(fmt.Sprintf("%s.is_service_account=?", ss.db.GetDialect().Quote("user")), ss.db.GetDialect().BooleanStr(false)) if acUserFilter != nil { sess.Where(acUserFilter.Where, acUserFilter.Args...) @@ -521,7 +520,7 @@ func (ss *xormStore) getTeamMembers(ctx context.Context, query *team.GetTeamMemb FROM user_auth WHERE user_auth.user_id = team_member.user_id ORDER BY user_auth.created DESC ` + - dialect.Limit(1) + ")" + ss.db.GetDialect().Limit(1) + ")" sess.Join("LEFT", "user_auth", authJoinCondition) if query.OrgID != 0 { @@ -537,7 +536,7 @@ func (ss *xormStore) getTeamMembers(ctx context.Context, query *team.GetTeamMemb sess.Where("team_member.user_id=?", query.UserID) } if query.External { - sess.Where("team_member.external=?", dialect.BooleanStr(true)) + sess.Where("team_member.external=?", ss.db.GetDialect().BooleanStr(true)) } sess.Cols( "team_member.org_id", diff --git a/pkg/services/team/teamimpl/store_test.go b/pkg/services/team/teamimpl/store_test.go index 1041bdb86ed..066cc02b13e 100644 --- a/pkg/services/team/teamimpl/store_test.go +++ b/pkg/services/team/teamimpl/store_test.go @@ -16,7 +16,6 @@ import ( "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/serviceaccounts" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/team/sortopts" @@ -35,7 +34,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { t.Skip("skipping integration test") } t.Run("Testing Team commands and queries", func(t *testing.T) { - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) teamSvc, err := ProvideService(sqlStore, cfg, tracing.InitializeTracerForTest()) require.NoError(t, err) testUser := &user.SignedInUser{ @@ -48,7 +47,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }, }, } - quotaService := quotaimpl.ProvideService(sqlstore.FakeReplStoreFromStore(sqlStore.SQLStore), cfg) + quotaService := quotaimpl.ProvideService(sqlStore, cfg) orgSvc, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) userSvc, err := userimpl.ProvideService( @@ -148,7 +147,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("Should return latest auth module for users when getting team members", func(t *testing.T) { - sqlStore = db.InitTestReplDB(t) + sqlStore = db.InitTestDB(t) setup() userId := userIds[1] @@ -199,7 +198,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("Should default to member permission level when updating a user with invalid permission level", func(t *testing.T) { - sqlStore = db.InitTestReplDB(t) + sqlStore = db.InitTestDB(t) setup() userID := userIds[0] @@ -313,7 +312,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("Should be able to return all teams a user is member of", func(t *testing.T) { - sqlStore = db.InitTestReplDB(t) + sqlStore = db.InitTestDB(t) setup() groupId := team2.ID err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { @@ -374,7 +373,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("A user should be able to remove the admin permission if there are other admins", func(t *testing.T) { - sqlStore = db.InitTestReplDB(t) + sqlStore = db.InitTestDB(t) setup() err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { @@ -393,7 +392,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("Should not return hidden users in team member count", func(t *testing.T) { - sqlStore = db.InitTestReplDB(t) + sqlStore = db.InitTestDB(t) setup() signedInUser := &user.SignedInUser{ Login: "loginuser0", @@ -434,8 +433,8 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("Should be able to exclude service accounts from teamembers", func(t *testing.T) { - sqlStore = db.InitTestReplDB(t) - quotaService := quotaimpl.ProvideService(sqlstore.FakeReplStoreFromStore(sqlStore.SQLStore), cfg) + sqlStore = db.InitTestDB(t) + quotaService := quotaimpl.ProvideService(sqlStore, cfg) orgSvc, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) userSvc, err := userimpl.ProvideService( @@ -529,7 +528,7 @@ func TestIntegrationSQLStore_SearchTeams(t *testing.T) { }, } - store, cfg := db.InitTestReplDBWithCfg(t, db.InitTestDBOpt{}) + store, cfg := db.InitTestDBWithCfg(t, db.InitTestDBOpt{}) teamSvc, err := ProvideService(store, cfg, tracing.InitializeTracerForTest()) require.NoError(t, err) @@ -566,18 +565,18 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { userIds := make([]int64, 4) // Seed 2 teams with 2 members - setup := func(store db.ReplDB, cfg *setting.Cfg) { + setup := func(store db.DB, cfg *setting.Cfg) { teamSvc, err := ProvideService(store, cfg, tracing.InitializeTracerForTest()) require.NoError(t, err) team1, errCreateTeam := teamSvc.CreateTeam(context.Background(), "group1 name", "test1@example.org", testOrgID) require.NoError(t, errCreateTeam) team2, errCreateTeam := teamSvc.CreateTeam(context.Background(), "group2 name", "test2@example.org", testOrgID) require.NoError(t, errCreateTeam) - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store.DB()), cfg) - orgSvc, err := orgimpl.ProvideService(store.DB(), cfg, quotaService) + quotaService := quotaimpl.ProvideService(store, cfg) + orgSvc, err := orgimpl.ProvideService(store, cfg, quotaService) require.NoError(t, err) userSvc, err := userimpl.ProvideService( - store.DB(), orgSvc, cfg, teamSvc, nil, tracing.InitializeTracerForTest(), + store, orgSvc, cfg, teamSvc, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) @@ -593,7 +592,7 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { userIds[i] = user.ID } - errAddMembers := store.DB().WithDbSession(context.Background(), func(sess *db.Session) error { + errAddMembers := store.WithDbSession(context.Background(), func(sess *db.Session) error { err := AddOrUpdateTeamMemberHook(sess, userIds[0], testOrgID, team1.ID, false, 0) if err != nil { return err @@ -611,7 +610,7 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { require.NoError(t, errAddMembers) } - store, cfg := db.InitTestReplDBWithCfg(t, db.InitTestDBOpt{}) + store, cfg := db.InitTestDBWithCfg(t, db.InitTestDBOpt{}) setup(store, cfg) teamSvc, err := ProvideService(store, cfg, tracing.InitializeTracerForTest()) require.NoError(t, err) diff --git a/pkg/services/team/teamimpl/team.go b/pkg/services/team/teamimpl/team.go index 14212f22b7f..e7c9537b751 100644 --- a/pkg/services/team/teamimpl/team.go +++ b/pkg/services/team/teamimpl/team.go @@ -17,7 +17,7 @@ type Service struct { tracer tracing.Tracer } -func ProvideService(db db.ReplDB, cfg *setting.Cfg, tracer tracing.Tracer) (team.Service, error) { +func ProvideService(db db.DB, cfg *setting.Cfg, tracer tracing.Tracer) (team.Service, error) { return &Service{ store: &xormStore{db: db, cfg: cfg, deletes: []string{}}, tracer: tracer, diff --git a/pkg/services/user/userimpl/store_test.go b/pkg/services/user/userimpl/store_test.go index 86f38407821..45ed734c2f7 100644 --- a/pkg/services/user/userimpl/store_test.go +++ b/pkg/services/user/userimpl/store_test.go @@ -34,7 +34,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { } ss, cfg := db.InitTestDBWithCfg(t) - quotaService := quotaimpl.ProvideService(sqlstore.FakeReplStoreFromStore(ss), cfg) + quotaService := quotaimpl.ProvideService(ss, cfg) orgService, err := orgimpl.ProvideService(ss, cfg, quotaService) require.NoError(t, err) userStore := ProvideStore(ss, setting.NewCfg()) @@ -903,7 +903,7 @@ func createFiveTestUsers(t *testing.T, svc user.Service, fn func(i int) *user.Cr func TestMetricsUsage(t *testing.T) { ss, cfg := db.InitTestDBWithCfg(t) userStore := ProvideStore(ss, setting.NewCfg()) - quotaService := quotaimpl.ProvideService(sqlstore.FakeReplStoreFromStore(ss), cfg) + quotaService := quotaimpl.ProvideService(ss, cfg) orgService, err := orgimpl.ProvideService(ss, cfg, quotaService) require.NoError(t, err) @@ -962,7 +962,7 @@ func assertEqualUser(t *testing.T, expected, got *user.User) { func createOrgAndUserSvc(t *testing.T, store db.DB, cfg *setting.Cfg) (org.Service, user.Service) { t.Helper() - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) + quotaService := quotaimpl.ProvideService(store, cfg) orgService, err := orgimpl.ProvideService(store, cfg, quotaService) require.NoError(t, err) usrSvc, err := ProvideService( diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 9a943e1b1f8..e5d7b5fb01f 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -2691,17 +2691,17 @@ func rulesNamespaceWithoutVariableValues(t *testing.T, b []byte) (string, map[st return string(json), m } -func createUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { +func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { t.Helper() cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) - orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) diff --git a/pkg/tests/api/correlations/common_test.go b/pkg/tests/api/correlations/common_test.go index efb707841de..2bd291db9e8 100644 --- a/pkg/tests/api/correlations/common_test.go +++ b/pkg/tests/api/correlations/common_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/services/correlations" @@ -145,7 +144,7 @@ func (c TestContext) createOrg(name string) int64 { c.t.Helper() store := c.env.SQLStore c.env.Cfg.AutoAssignOrg = false - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), c.env.Cfg) + quotaService := quotaimpl.ProvideService(store, c.env.Cfg) orgService, err := orgimpl.ProvideService(store, c.env.Cfg, quotaService) require.NoError(c.t, err) orgId, err := orgService.GetOrCreate(context.Background(), name) @@ -159,7 +158,7 @@ func (c TestContext) createUser(cmd user.CreateUserCommand) User { c.env.Cfg.AutoAssignOrg = true c.env.Cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), c.env.Cfg) + quotaService := quotaimpl.ProvideService(store, c.env.Cfg) orgService, err := orgimpl.ProvideService(store, c.env.Cfg, quotaService) require.NoError(c.t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/tests/api/dashboards/api_dashboards_test.go b/pkg/tests/api/dashboards/api_dashboards_test.go index f3a6d3c1a23..604f0df60b8 100644 --- a/pkg/tests/api/dashboards/api_dashboards_test.go +++ b/pkg/tests/api/dashboards/api_dashboards_test.go @@ -115,17 +115,17 @@ func TestIntegrationDashboardQuota(t *testing.T) { }) } -func createUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { +func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { t.Helper() cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) - orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) diff --git a/pkg/tests/api/folders/api_folder_test.go b/pkg/tests/api/folders/api_folder_test.go index dcb2ef7e28c..c61e94b0cd9 100644 --- a/pkg/tests/api/folders/api_folder_test.go +++ b/pkg/tests/api/folders/api_folder_test.go @@ -9,9 +9,6 @@ import ( "github.com/go-openapi/runtime" "github.com/grafana/grafana-openapi-client-go/client/folders" "github.com/grafana/grafana-openapi-client-go/models" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -24,6 +21,8 @@ import ( "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) const orgID = 1 @@ -208,17 +207,17 @@ func TestIntegrationNestedFoldersOn(t *testing.T) { }) } -func createUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { +func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { t.Helper() cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = orgID - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) - orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) diff --git a/pkg/tests/api/plugins/api_plugins_test.go b/pkg/tests/api/plugins/api_plugins_test.go index cfdd8851692..ba04251c1fb 100644 --- a/pkg/tests/api/plugins/api_plugins_test.go +++ b/pkg/tests/api/plugins/api_plugins_test.go @@ -193,17 +193,17 @@ func TestIntegrationPluginAssets(t *testing.T) { }) } -func createUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) { +func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) { t.Helper() cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) - orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) diff --git a/pkg/tests/api/stats/admin_test.go b/pkg/tests/api/stats/admin_test.go index eb65250778c..3e264a75e0c 100644 --- a/pkg/tests/api/stats/admin_test.go +++ b/pkg/tests/api/stats/admin_test.go @@ -81,17 +81,17 @@ func grafanaSetup(t *testing.T, opts testinfra.GrafanaOpts) string { return fmt.Sprintf("http://%s:%s@%s/api/admin/stats", "grafana", "password", grafanaListedAddr) } -func createUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { +func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { t.Helper() cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) - orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index eaa24a5b228..12f1aa512ed 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -20,6 +20,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer/yaml" yamlutil "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" @@ -437,13 +438,12 @@ func (c *K8sTestHelper) CreateUser(name string, orgName string, basicRole org.Ro c.t.Helper() store := c.env.SQLStore - replStore := c.env.ReadReplStore defer func() { c.env.Cfg.AutoAssignOrg = false c.env.Cfg.AutoAssignOrgId = 1 // the default }() - quotaService := quotaimpl.ProvideService(replStore, c.env.Cfg) + quotaService := quotaimpl.ProvideService(store, c.env.Cfg) orgService, err := orgimpl.ProvideService(store, c.env.Cfg, quotaService) require.NoError(c.t, err) @@ -464,7 +464,7 @@ func (c *K8sTestHelper) CreateUser(name string, orgName string, basicRole org.Ro c.env.Cfg.AutoAssignOrg = true c.env.Cfg.AutoAssignOrgId = int(orgId) - teamSvc, err := teamimpl.ProvideService(replStore, c.env.Cfg, tracing.InitializeTracerForTest()) + teamSvc, err := teamimpl.ProvideService(store, c.env.Cfg, tracing.InitializeTracerForTest()) require.NoError(c.t, err) cache := localcache.ProvideService() @@ -533,7 +533,7 @@ func (c *K8sTestHelper) SetPermissions(user User, permissions []resourcepermissi } func (c *K8sTestHelper) AddOrUpdateTeamMember(user User, teamID int64, permission team.PermissionType) { - teamSvc, err := teamimpl.ProvideService(c.env.ReadReplStore, c.env.Cfg, tracing.InitializeTracerForTest()) + teamSvc, err := teamimpl.ProvideService(c.env.SQLStore, c.env.Cfg, tracing.InitializeTracerForTest()) require.NoError(c.t, err) orgService, err := orgimpl.ProvideService(c.env.SQLStore, c.env.Cfg, c.env.Server.HTTPServer.QuotaService) diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 9f6f5a0220b..8468cb154e2 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -498,7 +498,7 @@ func CreateUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUser cfg.AutoAssignOrgId = 1 cmd.OrgID = 1 - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) + quotaService := quotaimpl.ProvideService(store, cfg) orgService, err := orgimpl.ProvideService(store, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/tests/utils.go b/pkg/tests/utils.go index c1208211e95..f81683c80f4 100644 --- a/pkg/tests/utils.go +++ b/pkg/tests/utils.go @@ -9,8 +9,6 @@ import ( "github.com/go-openapi/strfmt" goapi "github.com/grafana/grafana-openapi-client-go/client" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" @@ -21,19 +19,20 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" ) -func CreateUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { +func CreateUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { t.Helper() cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) - orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) From c36f7aa92b11a4688279787963a61c5d498918d0 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Wed, 25 Sep 2024 19:47:03 -0600 Subject: [PATCH 063/102] VizTooltip: Improved sizing (#93762) Co-authored-by: Staton Hysell --- .../VizTooltip/VizTooltipContent.tsx | 2 +- .../VizTooltip/VizTooltipHeader.tsx | 1 + .../VizTooltip/VizTooltipWrapper.tsx | 24 +++++++++++++++++++ .../components/uPlot/plugins/CloseButton.tsx | 4 ++-- .../panel/canvas/components/CanvasTooltip.tsx | 1 + .../plugins/panel/heatmap/HeatmapTooltip.tsx | 10 ++++---- .../panel/histogram/HistogramTooltip.tsx | 16 +++---------- .../state-timeline/StateTimelineTooltip2.tsx | 11 ++++----- .../panel/timeseries/TimeSeriesTooltip.tsx | 8 +++---- .../plugins/panel/xychart/XYChartTooltip.tsx | 9 +++---- .../panel/xychart/v2/XYChartTooltip.tsx | 9 +++---- 11 files changed, 50 insertions(+), 45 deletions(-) create mode 100644 packages/grafana-ui/src/components/VizTooltip/VizTooltipWrapper.tsx diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx index 66fa1d71b44..89273ea291e 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx @@ -59,7 +59,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: 'flex', flexDirection: 'column', flex: 1, - gap: 4, + gap: 2, borderTop: `1px solid ${theme.colors.border.medium}`, padding: theme.spacing(1), }), diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeader.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeader.tsx index d10180ac823..52a294b82ec 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeader.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeader.tsx @@ -36,5 +36,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ flexDirection: 'column', flex: 1, padding: theme.spacing(1), + lineHeight: 1, }), }); diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipWrapper.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipWrapper.tsx new file mode 100644 index 00000000000..4c803abc085 --- /dev/null +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipWrapper.tsx @@ -0,0 +1,24 @@ +import { css, cx } from '@emotion/css'; +import React, { HTMLAttributes } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes'; + +export interface Props extends HTMLAttributes { + children?: React.ReactNode; +} + +export const VizTooltipWrapper = ({ children, className }: Props) => { + const styles = useStyles2(getStyles); + + return
    {children}
    ; +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css({ + display: 'flex', + flexDirection: 'column', + fontSize: theme.typography.bodySmall.fontSize, + }), +}); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/CloseButton.tsx b/packages/grafana-ui/src/components/uPlot/plugins/CloseButton.tsx index 30212dcedc3..2430ae6a8b3 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/CloseButton.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/CloseButton.tsx @@ -24,6 +24,6 @@ const getStyles = (theme: GrafanaTheme2) => css({ position: 'absolute', margin: '0px', - right: theme.spacing(1), - top: theme.spacing(1.25), + right: 5, + top: 6, }); diff --git a/public/app/plugins/panel/canvas/components/CanvasTooltip.tsx b/public/app/plugins/panel/canvas/components/CanvasTooltip.tsx index 955656b7af7..39390afefdf 100644 --- a/public/app/plugins/panel/canvas/components/CanvasTooltip.tsx +++ b/public/app/plugins/panel/canvas/components/CanvasTooltip.tsx @@ -175,6 +175,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ boxShadow: theme.shadows.z2, userSelect: 'text', padding: 0, + fontSize: theme.typography.bodySmall.fontSize, }), pinned: css({ boxShadow: theme.shadows.z3, diff --git a/public/app/plugins/panel/heatmap/HeatmapTooltip.tsx b/public/app/plugins/panel/heatmap/HeatmapTooltip.tsx index ebb3a860587..924c287fd23 100644 --- a/public/app/plugins/panel/heatmap/HeatmapTooltip.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapTooltip.tsx @@ -12,7 +12,7 @@ import { PanelData, } from '@grafana/data'; import { HeatmapCellLayout } from '@grafana/schema'; -import { TooltipDisplayMode, useStyles2, useTheme2 } from '@grafana/ui'; +import { TooltipDisplayMode, useTheme2 } from '@grafana/ui'; import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader'; @@ -22,8 +22,8 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { isHeatmapCellsDense, readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap'; import { DataHoverView } from 'app/features/visualization/data-hover/DataHoverView'; +import { VizTooltipWrapper } from '../../../../../packages/grafana-ui/src/components/VizTooltip/VizTooltipWrapper'; import { getDataLinks } from '../status-history/utils'; -import { getStyles } from '../timeseries/TimeSeriesTooltip'; import { isTooltipScrollable } from '../timeseries/utils'; import { HeatmapData } from './fields'; @@ -360,10 +360,8 @@ const HeatmapHoverCell = ({ } } - const styles = useStyles2(getStyles); - return ( -
    + {footer} -
    + ); }; diff --git a/public/app/plugins/panel/histogram/HistogramTooltip.tsx b/public/app/plugins/panel/histogram/HistogramTooltip.tsx index 0a8f5979cc6..4fee3eb5c11 100644 --- a/public/app/plugins/panel/histogram/HistogramTooltip.tsx +++ b/public/app/plugins/panel/histogram/HistogramTooltip.tsx @@ -1,15 +1,14 @@ -import { css } from '@emotion/css'; import { ReactNode, useMemo } from 'react'; import { DataFrame, formattedValueToString } from '@grafana/data'; import { SortOrder, TooltipDisplayMode } from '@grafana/schema/dist/esm/common/common.gen'; -import { useStyles2 } from '@grafana/ui'; import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader'; import { VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; import { getContentItems } from '@grafana/ui/src/components/VizTooltip/utils'; +import { VizTooltipWrapper } from '../../../../../packages/grafana-ui/src/components/VizTooltip/VizTooltipWrapper'; import { getDataLinks } from '../status-history/utils'; import { isTooltipScrollable } from '../timeseries/utils'; @@ -39,8 +38,6 @@ export const HistogramTooltip = ({ isPinned, maxHeight, }: HistogramTooltipProps) => { - const styles = useStyles2(getStyles); - const xMinField = series.fields[0]; const xMaxField = series.fields[1]; @@ -74,7 +71,7 @@ export const HistogramTooltip = ({ } return ( -
    + {headerItem != null && } {footer} -
    + ); }; - -export const getStyles = () => ({ - wrapper: css({ - display: 'flex', - flexDirection: 'column', - }), -}); diff --git a/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx b/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx index 1588f7a353c..efb1d4ff1db 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelineTooltip2.tsx @@ -2,7 +2,7 @@ import { ReactNode } from 'react'; import { FieldType, TimeRange } from '@grafana/data'; import { SortOrder } from '@grafana/schema/dist/esm/common/common.gen'; -import { TooltipDisplayMode, useStyles2 } from '@grafana/ui'; +import { TooltipDisplayMode } from '@grafana/ui'; import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader'; @@ -10,8 +10,9 @@ import { VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; import { getContentItems } from '@grafana/ui/src/components/VizTooltip/utils'; import { findNextStateIndex, fmtDuration } from 'app/core/components/TimelineChart/utils'; +import { VizTooltipWrapper } from '../../../../../packages/grafana-ui/src/components/VizTooltip/VizTooltipWrapper'; import { getDataLinks } from '../status-history/utils'; -import { TimeSeriesTooltipProps, getStyles } from '../timeseries/TimeSeriesTooltip'; +import { TimeSeriesTooltipProps } from '../timeseries/TimeSeriesTooltip'; import { isTooltipScrollable } from '../timeseries/utils'; interface StateTimelineTooltip2Props extends TimeSeriesTooltipProps { @@ -31,8 +32,6 @@ export const StateTimelineTooltip2 = ({ withDuration, maxHeight, }: StateTimelineTooltip2Props) => { - const styles = useStyles2(getStyles); - const xField = series.fields[0]; const dataIdx = seriesIdx != null ? dataIdxs[seriesIdx] : dataIdxs.find((idx) => idx != null); @@ -81,7 +80,7 @@ export const StateTimelineTooltip2 = ({ }; return ( -
    + {footer} -
    + ); }; diff --git a/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx b/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx index a30bb03ce17..eb6faf49742 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx @@ -3,13 +3,13 @@ import { ReactNode } from 'react'; import { DataFrame, Field, FieldType, formattedValueToString } from '@grafana/data'; import { SortOrder, TooltipDisplayMode } from '@grafana/schema/dist/esm/common/common.gen'; -import { useStyles2 } from '@grafana/ui'; import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader'; import { VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; import { getContentItems } from '@grafana/ui/src/components/VizTooltip/utils'; +import { VizTooltipWrapper } from '../../../../../packages/grafana-ui/src/components/VizTooltip/VizTooltipWrapper'; import { getDataLinks } from '../status-history/utils'; import { fmt } from '../xychart/utils'; @@ -49,8 +49,6 @@ export const TimeSeriesTooltip = ({ annotate, maxHeight, }: TimeSeriesTooltipProps) => { - const styles = useStyles2(getStyles); - const xField = series.fields[0]; const xVal = formattedValueToString(xField.display!(xField.values[dataIdxs[0]!])); @@ -91,7 +89,7 @@ export const TimeSeriesTooltip = ({ }; return ( -
    + {headerItem != null && } {footer} -
    + ); }; diff --git a/public/app/plugins/panel/xychart/XYChartTooltip.tsx b/public/app/plugins/panel/xychart/XYChartTooltip.tsx index 06f40f45228..9ef55c0df9a 100644 --- a/public/app/plugins/panel/xychart/XYChartTooltip.tsx +++ b/public/app/plugins/panel/xychart/XYChartTooltip.tsx @@ -2,14 +2,13 @@ import { ReactNode } from 'react'; import { DataFrame, Field, getFieldDisplayName } from '@grafana/data'; import { alpha } from '@grafana/data/src/themes/colorManipulator'; -import { useStyles2 } from '@grafana/ui'; import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader'; import { ColorIndicator, VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; +import { VizTooltipWrapper } from '../../../../../packages/grafana-ui/src/components/VizTooltip/VizTooltipWrapper'; import { getDataLinks } from '../status-history/utils'; -import { getStyles } from '../timeseries/TimeSeriesTooltip'; import { Options } from './panelcfg.gen'; import { ScatterSeries } from './types'; @@ -26,8 +25,6 @@ export interface Props { } export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, allSeries, dismiss, options, isPinned }: Props) => { - const styles = useStyles2(getStyles); - const rowIndex = dataIdxs.find((idx) => idx !== null); // @todo: remove -1 when uPlot v2 arrive // context: first value in dataIdxs always null and represent X series @@ -92,10 +89,10 @@ export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, allSeries, dismiss, } return ( -
    + {footer} -
    + ); }; diff --git a/public/app/plugins/panel/xychart/v2/XYChartTooltip.tsx b/public/app/plugins/panel/xychart/v2/XYChartTooltip.tsx index c35a964f832..a680b9d2a67 100644 --- a/public/app/plugins/panel/xychart/v2/XYChartTooltip.tsx +++ b/public/app/plugins/panel/xychart/v2/XYChartTooltip.tsx @@ -2,14 +2,13 @@ import { ReactNode } from 'react'; import { DataFrame } from '@grafana/data'; import { alpha } from '@grafana/data/src/themes/colorManipulator'; -import { useStyles2 } from '@grafana/ui'; import { VizTooltipContent } from '@grafana/ui/src/components/VizTooltip/VizTooltipContent'; import { VizTooltipFooter } from '@grafana/ui/src/components/VizTooltip/VizTooltipFooter'; import { VizTooltipHeader } from '@grafana/ui/src/components/VizTooltip/VizTooltipHeader'; import { ColorIndicator, VizTooltipItem } from '@grafana/ui/src/components/VizTooltip/types'; +import { VizTooltipWrapper } from '../../../../../../packages/grafana-ui/src/components/VizTooltip/VizTooltipWrapper'; import { getDataLinks } from '../../status-history/utils'; -import { getStyles } from '../../timeseries/TimeSeriesTooltip'; import { XYSeries } from './types2'; import { fmt } from './utils'; @@ -32,8 +31,6 @@ function stripSeriesName(fieldName: string, seriesName: string) { } export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, xySeries, dismiss, isPinned }: Props) => { - const styles = useStyles2(getStyles); - const rowIndex = dataIdxs.find((idx) => idx !== null)!; const series = xySeries[seriesIdx! - 1]; @@ -102,10 +99,10 @@ export const XYChartTooltip = ({ dataIdxs, seriesIdx, data, xySeries, dismiss, i } return ( -
    + {footer} -
    + ); }; From 536edee7bff0e393054775b02a9362c8d49ed699 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Thu, 26 Sep 2024 08:33:14 +0200 Subject: [PATCH 064/102] Alerting: Query and expressions section simplification (#93022) * Add mode switch in Query section * Implement simple query mode : WIP * fix logic switching mode * move guard and get methodd to another folder * Add more requiremts for being transformable from advanced to not advanced mode * fix usig mode when it's not a grafana managed alert * Show warning when switching to not advanced and its not possible to convert * Add feature toggle alertingQueryAndExpressionsStepMode * fix test * add translations * address PR feedback * Use form context for sharing simplfied mode used, save in local storage and use the new fields in the api * add check to valid reducer and threshold when switching to simplified mode * Use only one expression list * fix test * move existing rule check outside storeInLocalStorageValues * add id in InlineSwitch to handle onClick on label * fix * Fix default values when editing existing rule * Update dto fields for the api request * fix snapshot * Fix recording rules to not show switch mode * remove unnecessary Boolean conversion * fix areQueriesTransformableToSimpleCondition * update text * pr review nit * pr review part2 --- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 13 + .../components/expressions/Expression.tsx | 2 +- .../components/rule-editor/QueryWrapper.tsx | 33 +- .../rule-editor/RuleEditorSection.tsx | 32 +- .../alert-rule-form/AlertRuleForm.tsx | 31 +- .../QueryAndExpressionsStep.tsx | 481 ++++++++++++------ .../SimpleCondition.tsx | 241 +++++++++ ...riesTransformableToSimpleCondition.test.ts | 121 +++++ .../query-and-alert-condition/reducer.ts | 7 + .../alerting/unified/types/rule-form.ts | 5 + .../__snapshots__/rule-form.test.ts.snap | 2 + .../alerting/unified/utils/rule-form.ts | 51 +- public/app/features/expressions/guards.ts | 19 +- .../expressions/utils/expressionTypes.ts | 14 +- .../query/components/QueryEditorRow.tsx | 11 +- public/app/types/unified-alerting-dto.ts | 7 + public/locales/en-US/grafana.json | 6 + public/locales/pseudo-LOCALE/grafana.json | 6 + 23 files changed, 904 insertions(+), 192 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SimpleCondition.tsx create mode 100644 public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts 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 0ccd6b5c223..03e430aa0d1 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -202,6 +202,7 @@ Experimental features might be changed or removed without prior notice. | `exploreLogsLimitedTimeRange` | Used in Explore Logs to limit the time range | | `homeSetupGuide` | Used in Home for users who want to return to the onboarding flow or quickly find popular config pages | | `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time | +| `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index d4706619503..f1a8b19a821 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -212,6 +212,7 @@ export interface FeatureToggles { appPlatformAccessTokens?: boolean; appSidecar?: boolean; groupAttributeSync?: boolean; + alertingQueryAndExpressionsStepMode?: boolean; improvedExternalSessionHandling?: boolean; useSessionStorageForRedirection?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index cafad0f1346..733cf634261 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1459,6 +1459,13 @@ var ( Owner: identityAccessTeam, HideFromDocs: true, }, + { + Name: "alertingQueryAndExpressionsStepMode", + Description: "Enables step mode for alerting queries and expressions", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + FrontendOnly: true, + }, { Name: "improvedExternalSessionHandling", Description: "Enable improved support for external sessions in Grafana", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 8cafcf6844f..2ab59f20b61 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -193,5 +193,6 @@ homeSetupGuide,experimental,@grafana/growth-and-onboarding,false,false,true appPlatformAccessTokens,experimental,@grafana/identity-access-team,false,false,false appSidecar,experimental,@grafana/explore-squad,false,false,false groupAttributeSync,experimental,@grafana/identity-access-team,false,false,false +alertingQueryAndExpressionsStepMode,experimental,@grafana/alerting-squad,false,false,true improvedExternalSessionHandling,experimental,@grafana/identity-access-team,false,false,false useSessionStorageForRedirection,preview,@grafana/identity-access-team,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index f32b5c65dca..09aafaa2a5e 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -783,6 +783,10 @@ const ( // Enable the groupsync extension for managing Group Attribute Sync feature FlagGroupAttributeSync = "groupAttributeSync" + // FlagAlertingQueryAndExpressionsStepMode + // Enables step mode for alerting queries and expressions + FlagAlertingQueryAndExpressionsStepMode = "alertingQueryAndExpressionsStepMode" + // FlagImprovedExternalSessionHandling // Enable improved support for external sessions in Grafana FlagImprovedExternalSessionHandling = "improvedExternalSessionHandling" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 9a2acd079e6..b2bf3a33aef 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -240,6 +240,19 @@ "hideFromAdminPage": true } }, + { + "metadata": { + "name": "alertingQueryAndExpressionsStepMode", + "resourceVersion": "1725978395461", + "creationTimestamp": "2024-09-10T14:26:35Z" + }, + "spec": { + "description": "Enables step mode for alerting queries and expressions", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "frontend": true + } + }, { "metadata": { "name": "alertingQueryOptimization", diff --git a/public/app/features/alerting/unified/components/expressions/Expression.tsx b/public/app/features/alerting/unified/components/expressions/Expression.tsx index 9110dbbf8fa..20a0a9561bb 100644 --- a/public/app/features/alerting/unified/components/expressions/Expression.tsx +++ b/public/app/features/alerting/unified/components/expressions/Expression.tsx @@ -51,7 +51,7 @@ export const Expression: FC = ({ onSetCondition, onUpdateRefId, onRemoveExpression, - onUpdateExpressionType, + onUpdateExpressionType, // this method is not used? maybe we should remove it onChangeQuery, }) => { const styles = useStyles2(getStyles); diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx index 5263fea8af6..d5468f64cde 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx @@ -1,7 +1,8 @@ import { css } from '@emotion/css'; import { cloneDeep } from 'lodash'; -import { ChangeEvent, useState } from 'react'; import * as React from 'react'; +import { ChangeEvent, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { CoreApp, @@ -14,11 +15,12 @@ import { ThresholdsConfig, } from '@grafana/data'; import { DataQuery } from '@grafana/schema'; -import { GraphThresholdsStyleMode, Icon, InlineField, Input, Tooltip, useStyles2, Stack } from '@grafana/ui'; +import { GraphThresholdsStyleMode, Icon, InlineField, Input, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { logInfo } from 'app/features/alerting/unified/Analytics'; import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow'; import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; +import { RuleFormValues } from '../../types/rule-form'; import { msToSingleUnitDuration } from '../../utils/time'; import { ExpressionStatusIndicator } from '../expressions/ExpressionStatusIndicator'; @@ -78,6 +80,9 @@ export const QueryWrapper = ({ const [dsInstance, setDsInstance] = useState(); const defaults = dsInstance?.getDefaultQuery ? dsInstance.getDefaultQuery(CoreApp.UnifiedAlerting) : {}; + const { getValues } = useFormContext(); + const isAdvancedMode = getValues('editorSettings.simplifiedQueryEditor') !== true; + const queryWithDefaults = { ...defaults, ...cloneDeep(query.model), @@ -123,7 +128,17 @@ export const QueryWrapper = ({ } // TODO add a warning label here too when the data looks like time series data and is used as an alert condition - function HeaderExtras({ query, error, index }: { query: AlertQuery; error?: Error; index: number }) { + function HeaderExtras({ + query, + error, + index, + isAdvancedMode = true, + }: { + query: AlertQuery; + error?: Error; + index: number; + isAdvancedMode?: boolean; + }) { const queryOptions: AlertQueryOptions = { maxDataPoints: query.model.maxDataPoints, minInterval: query.model.intervalMs ? msToSingleUnitDuration(query.model.intervalMs) : undefined, @@ -145,7 +160,12 @@ export const QueryWrapper = ({ onChangeQueryOptions={onChangeQueryOptions} index={index} /> - onSetCondition(query.refId)} isCondition={isAlertCondition} /> + {isAdvancedMode && ( + onSetCondition(query.refId)} + isCondition={isAlertCondition} + /> + )}
    ); } @@ -160,6 +180,7 @@ export const QueryWrapper = ({
    alerting + hideActionButtons={!isAdvancedMode} collapsable={false} dataSource={dsSettings} onDataSourceLoaded={setDsInstance} @@ -174,7 +195,9 @@ export const QueryWrapper = ({ onAddQuery={() => onDuplicateQuery(cloneDeep(query))} onRunQuery={onRunQueries} queries={editorQueries} - renderHeaderExtras={() => } + renderHeaderExtras={() => ( + + )} app={CoreApp.UnifiedAlerting} hideHideQueryButton={true} /> diff --git a/public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx b/public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx index 200e1bc8583..3c0083fe47c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx @@ -1,15 +1,19 @@ import { css, cx } from '@emotion/css'; -import { ReactElement } from 'react'; import * as React from 'react'; +import { ReactElement } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { FieldSet, Text, useStyles2, Stack } from '@grafana/ui'; +import { FieldSet, InlineSwitch, Stack, Text, useStyles2 } from '@grafana/ui'; export interface RuleEditorSectionProps { title: string; stepNo: number; description?: string | ReactElement; fullWidth?: boolean; + switchMode?: { + isAdvancedMode: boolean; + setAdvancedMode: (isAdvanced: boolean) => void; + }; } export const RuleEditorSection = ({ @@ -18,17 +22,33 @@ export const RuleEditorSection = ({ children, fullWidth = false, description, + switchMode, }: React.PropsWithChildren) => { const styles = useStyles2(getStyles); - return (
    - {stepNo}. {title} - + + + {stepNo}. {title} + + {switchMode && ( + + { + switchMode.setAdvancedMode(event.currentTarget.checked); + }} + label="Advanced options" + showLabel + transparent + /> + + )} + } > diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index 22eda969175..7c010fcfc5c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -35,13 +35,14 @@ import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { DEFAULT_GROUP_EVALUATION_INTERVAL, MANUAL_ROUTING_KEY, + SIMPLIFIED_QUERY_EDITOR_KEY, formValuesFromExistingRule, + formValuesToRulerGrafanaRuleDTO, + formValuesToRulerRuleDTO, getDefaultFormValues, getDefaultQueries, ignoreHiddenQueries, normalizeDefaultAnnotations, - formValuesToRulerGrafanaRuleDTO, - formValuesToRulerRuleDTO, } from '../../../utils/rule-form'; import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier, stringifyIdentifier } from '../../../utils/rule-id'; import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; @@ -135,15 +136,6 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { trackAlertRuleFormSaved({ formAction: existing ? 'update' : 'create', ruleType: values.type }); - // when creating a new rule, we save the manual routing setting in local storage - if (!existing) { - if (values.manualRouting) { - localStorage.setItem(MANUAL_ROUTING_KEY, 'true'); - } else { - localStorage.setItem(MANUAL_ROUTING_KEY, 'false'); - } - } - const ruleDefinition = grafanaTypeRule ? formValuesToRulerGrafanaRuleDTO(values) : formValuesToRulerRuleDTO(values); const ruleGroupIdentifier = existing @@ -153,6 +145,8 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { // @TODO what is "evaluateEvery" being used for? // @TODO move this to a hook too to make sure the logic here is tested for regressions? if (!existing) { + // when creating a new rule, we save the manual routing setting , and editorSettings.simplifiedQueryEditor to the local storage + storeInLocalStorageValues(values); await addRuleToRuleGroup.execute(ruleGroupIdentifier, ruleDefinition, values.evaluateEvery); } else { const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule); @@ -353,6 +347,21 @@ function formValuesFromPrefill(rule: Partial): RuleFormValues { }); } +function storeInLocalStorageValues(values: RuleFormValues) { + if (values.manualRouting) { + localStorage.setItem(MANUAL_ROUTING_KEY, 'true'); + } else { + localStorage.setItem(MANUAL_ROUTING_KEY, 'false'); + } + if (values.editorSettings) { + if (values.editorSettings.simplifiedQueryEditor) { + localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'true'); + } else { + localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'false'); + } + } +} + const getStyles = (theme: GrafanaTheme2) => ({ buttonSpinner: css({ marginRight: theme.spacing(1), diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx index ed2f94a7542..65ffe897e4a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx @@ -3,15 +3,34 @@ import { cloneDeep } from 'lodash'; import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import { getDefaultRelativeTimeRange, GrafanaTheme2 } from '@grafana/data'; +import { getDefaultRelativeTimeRange, GrafanaTheme2, ReducerID } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, getDataSourceSrv } from '@grafana/runtime'; -import { Alert, Button, Dropdown, Field, Icon, Menu, MenuItem, Stack, Tooltip, useStyles2 } from '@grafana/ui'; +import { + Alert, + Button, + ConfirmModal, + Dropdown, + Field, + Icon, + Menu, + MenuItem, + Stack, + Tooltip, + useStyles2, +} from '@grafana/ui'; import { Text } from '@grafana/ui/src/components/Text/Text'; +import { EvalFunction } from 'app/features/alerting/state/alertDef'; import { isExpressionQuery } from 'app/features/expressions/guards'; -import { ExpressionDatasourceUID, ExpressionQueryType, expressionTypes } from 'app/features/expressions/types'; +import { + ExpressionDatasourceUID, + ExpressionQuery, + ExpressionQueryType, + expressionTypes, + ReducerMode, +} from 'app/features/expressions/types'; import { useDispatch } from 'app/types'; -import { AlertQuery } from 'app/types/unified-alerting-dto'; +import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler'; import { fetchAllPromBuildInfoAction } from '../../../state/actions'; @@ -34,6 +53,14 @@ import { RuleEditorSection } from '../RuleEditorSection'; import { errorFromCurrentCondition, errorFromPreviewData, findRenamedDataQueryReferences, refIdExists } from '../util'; import { CloudDataSourceSelector } from './CloudDataSourceSelector'; +import { + getSimpleConditionFromExpressions, + SIMPLE_CONDITION_QUERY_ID, + SIMPLE_CONDITION_REDUCER_ID, + SIMPLE_CONDITION_THRESHOLD_ID, + SimpleCondition, + SimpleConditionEditor, +} from './SimpleCondition'; import { SmartAlertTypeDetector } from './SmartAlertTypeDetector'; import { DESCRIPTIONS } from './descriptions'; import { @@ -44,6 +71,7 @@ import { queriesAndExpressionsReducer, removeExpression, removeExpressions, + resetToSimpleCondition, rewireExpressions, setDataQueries, setRecordingRulesQueries, @@ -54,6 +82,44 @@ import { } from './reducer'; import { useAlertQueryRunner } from './useAlertQueryRunner'; +export function areQueriesTransformableToSimpleCondition( + dataQueries: Array>, + expressionQueries: Array> +) { + if (dataQueries.length !== 1) { + return false; + } + + if (expressionQueries.length !== 2) { + return false; + } + + const query = dataQueries[0]; + + if (query.refId !== SIMPLE_CONDITION_QUERY_ID) { + return false; + } + + const reduceExpressionIndex = expressionQueries.findIndex( + (query) => query.model.type === ExpressionQueryType.reduce && query.refId === SIMPLE_CONDITION_REDUCER_ID + ); + const reduceExpression = expressionQueries.at(reduceExpressionIndex); + const reduceOk = + reduceExpression && + reduceExpressionIndex === 0 && + (reduceExpression.model.settings?.mode === ReducerMode.Strict || + reduceExpression.model.settings?.mode === undefined); + + const thresholdExpressionIndex = expressionQueries.findIndex( + (query) => query.model.type === ExpressionQueryType.threshold && query.refId === SIMPLE_CONDITION_THRESHOLD_ID + ); + const thresholdExpression = expressionQueries.at(thresholdExpressionIndex); + const conditions = thresholdExpression?.model.conditions ?? []; + const thresholdOk = + thresholdExpression && thresholdExpressionIndex === 1 && conditions[0]?.unloadEvaluator === undefined; + return Boolean(reduceOk) && Boolean(thresholdOk); +} + interface Props { editingExistingRule: boolean; onDataChange: (error: string) => void; @@ -69,18 +135,59 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P } = useFormContext(); const { queryPreviewData, runQueries, cancelQueries, isPreviewLoading, clearPreviewData } = useAlertQueryRunner(); + const isSwitchModeEnabled = config.featureToggles.alertingQueryAndExpressionsStepMode ?? false; const initialState = { queries: getValues('queries'), }; const [{ queries }, dispatch] = useReducer(queriesAndExpressionsReducer, initialState); - const [type, condition, dataSourceName] = watch(['type', 'condition', 'dataSourceName']); + + // data queries only + const dataQueries = useMemo(() => { + return queries.filter((query) => !isExpressionQuery(query.model)); + }, [queries]); + + // expression queries only + const expressionQueries = useMemo(() => { + return queries.filter((query) => isExpressionQueryInAlert(query)); + }, [queries]); + + const [type, condition, dataSourceName, editorSettings] = watch([ + 'type', + 'condition', + 'dataSourceName', + 'editorSettings', + ]); + //if its a new rule, look at the local storage const isGrafanaAlertingType = isGrafanaAlertingRuleByType(type); const isRecordingRuleType = isCloudRecordingRuleByType(type); const isCloudAlertRuleType = isCloudAlertingRuleByType(type); + const isAdvancedMode = editorSettings?.simplifiedQueryEditor !== true || !isGrafanaAlertingType; + + const [showResetModeModal, setShowResetModal] = useState(false); + + const [simpleCondition, setSimpleCondition] = useState( + isGrafanaAlertingType && areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries) + ? getSimpleConditionFromExpressions(expressionQueries) + : { + whenField: ReducerID.last, + evaluator: { + params: [0], + type: EvalFunction.IsAbove, + }, + } + ); + + // If we switch to simple mode we need to update the simple condition with the data in the queries reducer + useEffect(() => { + if (!isAdvancedMode && isGrafanaAlertingType) { + setSimpleCondition(getSimpleConditionFromExpressions(expressionQueries)); + } + }, [isAdvancedMode, expressionQueries, isGrafanaAlertingType]); + const dispatchReduxAction = useDispatch(); useEffect(() => { dispatchReduxAction(fetchAllPromBuildInfoAction()); @@ -95,10 +202,15 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P // Grafana Managed rules and recording rules do return; } - - runQueries(getValues('queries'), condition || (getValues('condition') ?? '')); + // we need to be sure the condition is set once we switch to simple mode + if (!isAdvancedMode) { + setValue('condition', SIMPLE_CONDITION_THRESHOLD_ID); + runQueries(getValues('queries'), SIMPLE_CONDITION_THRESHOLD_ID); + } else { + runQueries(getValues('queries'), condition || (getValues('condition') ?? '')); + } }, - [isCloudAlertRuleType, runQueries, getValues] + [isCloudAlertRuleType, runQueries, getValues, isAdvancedMode, setValue] ); // whenever we update the queries we have to update the form too @@ -108,16 +220,6 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P const noCompatibleDataSources = getDefaultOrFirstCompatibleDataSource() === undefined; - // data queries only - const dataQueries = useMemo(() => { - return queries.filter((query) => !isExpressionQuery(query.model)); - }, [queries]); - - // expression queries only - const expressionQueries = useMemo(() => { - return queries.filter((query) => isExpressionQuery(query.model)); - }, [queries]); - const emptyQueries = queries.length === 0; // apply some validations and asserts to the results of the evaluation when creating or editing @@ -368,168 +470,221 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P ]); const { sectionTitle, helpLabel, helpContent, helpLink } = DESCRIPTIONS[type ?? RuleFormType.grafana]; + if (!type) { return null; } + + const switchMode = + isGrafanaAlertingType && isSwitchModeEnabled + ? { + isAdvancedMode, + setAdvancedMode: (isAdvanced: boolean) => { + if (!isAdvanced) { + if (!areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries)) { + setShowResetModal(true); + return; + } + } + setValue('editorSettings', { simplifiedQueryEditor: !isAdvanced }); + }, + } + : undefined; + return ( - - - {helpLabel} - - - - } - > - {/* This is the cloud data source selector */} - {isDataSourceManagedRuleByType(type) && ( - - )} + <> + + + {helpLabel} + + + + } + switchMode={switchMode} + > + {/* This is the cloud data source selector */} + {isDataSourceManagedRuleByType(type) && ( + + )} - {/* This is the PromQL Editor for recording rules */} - {isRecordingRuleType && dataSourceName && ( - - runQueriesPreview()} - onChangeQuery={onChangeRecordingRulesQueries} - panelData={queryPreviewData} - /> - - )} - - {/* This is the PromQL Editor for Cloud rules */} - {isCloudAlertRuleType && dataSourceName && ( - + {/* This is the PromQL Editor for recording rules */} + {isRecordingRuleType && dataSourceName && ( - { - return ( - - ); - }} - control={control} - rules={{ - required: { value: true, message: 'A valid expression is required' }, - }} + runQueriesPreview()} + onChangeQuery={onChangeRecordingRulesQueries} + panelData={queryPreviewData} /> - - - )} + )} - {/* This is the editor for Grafana managed rules and Grafana managed recording rules */} - {isGrafanaManagedRuleByType(type) && ( - - {/* Data Queries */} - runQueriesPreview()} - onChangeQueries={onChangeQueries} - onDuplicateQuery={onDuplicateQuery} - panelData={queryPreviewData} - condition={condition} - onSetCondition={handleSetCondition} - /> - - - - {/* We only show Switch for Grafana managed alerts */} - {isGrafanaAlertingType && ( + {/* This is the PromQL Editor for Cloud rules */} + {isCloudAlertRuleType && dataSourceName && ( + + + { + return ( + + ); + }} + control={control} + rules={{ + required: { value: true, message: 'A valid expression is required' }, + }} + /> + - )} - {/* Expression Queries */} - - Expressions - - Manipulate data returned from queries with math and other operations. - + )} - { - dispatch(removeExpression(refId)); - }} - onUpdateRefId={onUpdateRefId} - onUpdateExpressionType={(refId, type) => { - dispatch(updateExpressionType({ refId, type })); - }} - onUpdateQueryExpression={(model) => { - dispatch(updateExpression(model)); - }} - /> - {/* action buttons */} - - {config.expressionsEnabled && } - - {isPreviewLoading && ( - + {/* This is the editor for Grafana managed rules and Grafana managed recording rules */} + {isGrafanaManagedRuleByType(type) && ( + + {/* Data Queries */} + runQueriesPreview()} + onChangeQueries={onChangeQueries} + onDuplicateQuery={onDuplicateQuery} + panelData={queryPreviewData} + condition={condition} + onSetCondition={handleSetCondition} + /> + {isAdvancedMode && ( + + + )} - {!isPreviewLoading && ( - + {/* We only show Switch for Grafana managed alerts */} + {isGrafanaAlertingType && isAdvancedMode && ( + + )} + {/* Expression Queries */} + {isAdvancedMode && isGrafanaAlertingType && ( + <> + + Expressions + + Manipulate data returned from queries with math and other operations. + + + + { + dispatch(removeExpression(refId)); + }} + onUpdateRefId={onUpdateRefId} + onUpdateExpressionType={(refId, type) => { + dispatch(updateExpressionType({ refId, type })); + }} + onUpdateQueryExpression={(model) => { + dispatch(updateExpression(model)); + }} + /> + + )} + {/* action buttons */} + + {!isAdvancedMode && ( + + )} + + {isAdvancedMode && config.expressionsEnabled && } + + {isPreviewLoading && ( + + )} + {!isPreviewLoading && ( + + )} + + + + {/* No Queries */} + {emptyQueries && ( + + Create at least one query or expression to be alerted on + )} + )} + - {/* No Queries */} - {emptyQueries && ( - - Create at least one query or expression to be alerted on - - )} - - )} - + { + setValue('editorSettings', { simplifiedQueryEditor: true }); + setShowResetModal(false); + dispatch(resetToSimpleCondition()); + }} + onDismiss={() => setShowResetModal(false)} + /> + ); }; @@ -602,3 +757,9 @@ const useSetExpressionAndDataSource = () => { } }; }; + +function isExpressionQueryInAlert( + query: AlertQuery +): query is AlertQuery { + return isExpressionQuery(query.model); +} diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SimpleCondition.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SimpleCondition.tsx new file mode 100644 index 00000000000..6dcd0204d5f --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SimpleCondition.tsx @@ -0,0 +1,241 @@ +import { css } from '@emotion/css'; +import { produce } from 'immer'; +import { Dispatch, FormEvent } from 'react'; +import { UnknownAction } from 'redux'; + +import { GrafanaTheme2, PanelData, ReducerID, SelectableValue } from '@grafana/data'; +import { ButtonSelect, InlineField, InlineFieldRow, Input, Select, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; +import { EvalFunction } from 'app/features/alerting/state/alertDef'; +import { ExpressionQuery, ExpressionQueryType, reducerTypes, thresholdFunctions } from 'app/features/expressions/types'; +import { getReducerType } from 'app/features/expressions/utils/expressionTypes'; +import { AlertQuery } from 'app/types/unified-alerting-dto'; + +import { ExpressionResult } from '../../expressions/Expression'; + +import { updateExpression } from './reducer'; + +export const SIMPLE_CONDITION_QUERY_ID = 'A'; +export const SIMPLE_CONDITION_REDUCER_ID = 'B'; +export const SIMPLE_CONDITION_THRESHOLD_ID = 'C'; + +export interface SimpleCondition { + whenField: string; + evaluator: { + params: number[]; + type: EvalFunction; + }; +} + +/** + * This is the simple condition editor if the user is in the simple mode in the query section + */ +export interface SimpleConditionEditorProps { + simpleCondition: SimpleCondition; + onChange: (condition: SimpleCondition) => void; + expressionQueriesList: Array>; + dispatch: Dispatch; + previewData?: PanelData; +} + +/** + * + * This represents the simple condition editor for the alerting query section + * The state for this simple condition is kept in the parent component + * But we have also to keep the reducer state in sync with this condition state (both kept in the parent) + */ + +export const SimpleConditionEditor = ({ + simpleCondition, + onChange, + expressionQueriesList, + dispatch, + previewData, +}: SimpleConditionEditorProps) => { + const onReducerTypeChange = (value: SelectableValue) => { + onChange({ ...simpleCondition, whenField: value.value ?? ReducerID.last }); + updateReduceExpression(value.value ?? ReducerID.last, expressionQueriesList, dispatch); + }; + + const isRange = + simpleCondition.evaluator.type === EvalFunction.IsWithinRange || + simpleCondition.evaluator.type === EvalFunction.IsOutsideRange; + + const thresholdFunction = thresholdFunctions.find((fn) => fn.value === simpleCondition.evaluator?.type); + + const onEvalFunctionChange = (value: SelectableValue) => { + // change the condition kept in the parent + onChange({ + ...simpleCondition, + evaluator: { ...simpleCondition.evaluator, type: value.value ?? EvalFunction.IsAbove }, + }); + // update the reducer state where we store the queries + updateThresholdFunction(value.value ?? EvalFunction.IsAbove, expressionQueriesList, dispatch); + }; + + const onEvaluateValueChange = (event: FormEvent, index?: number) => { + if (isRange) { + const newParams = produce(simpleCondition.evaluator.params, (draft) => { + draft[index ?? 0] = parseFloat(event.currentTarget.value); + }); + // update the condition kept in the parent + onChange({ ...simpleCondition, evaluator: { ...simpleCondition.evaluator, params: newParams } }); + // update the reducer state where we store the queries + updateThresholdValue(parseFloat(event.currentTarget.value), index ?? 0, expressionQueriesList, dispatch); + } else { + // update the condition kept in the parent + onChange({ + ...simpleCondition, + evaluator: { ...simpleCondition.evaluator, params: [parseFloat(event.currentTarget.value)] }, + }); + // update the reducer state where we store the queries + updateThresholdValue(parseFloat(event.currentTarget.value), 0, expressionQueriesList, dispatch); + } + }; + + const styles = useStyles2(getStyles); + + return ( +
    + +
    + + Alert condition + +
    + + + onEvaluateValueChange(event, 0)} + /> +
    + TO +
    + onEvaluateValueChange(event, 1)} + /> + + ) : ( + + )} +
    + + + {previewData?.series && } + +
    + ); +}; + +function updateReduceExpression( + reducer: string, + expressionQueriesList: Array>, + dispatch: Dispatch +) { + const reduceExpression = expressionQueriesList.find( + (query) => query.model.type === ExpressionQueryType.reduce && query.model.refId === SIMPLE_CONDITION_REDUCER_ID + ); + + const newReduceExpression = reduceExpression + ? produce(reduceExpression?.model, (draft) => { + if (draft && draft.conditions) { + draft.reducer = reducer; + draft.conditions[0].reducer.type = getReducerType(reducer) ?? ReducerID.last; + } + }) + : undefined; + newReduceExpression && dispatch(updateExpression(newReduceExpression)); +} + +function updateThresholdFunction( + evaluator: EvalFunction, + expressionQueriesList: Array>, + dispatch: Dispatch +) { + const thresholdExpression = expressionQueriesList.find( + (query) => query.model.type === ExpressionQueryType.threshold && query.model.refId === SIMPLE_CONDITION_THRESHOLD_ID + ); + + const newThresholdExpression = produce(thresholdExpression, (draft) => { + if (draft && draft.model.conditions) { + draft.model.conditions[0].evaluator.type = evaluator; + } + }); + newThresholdExpression && dispatch(updateExpression(newThresholdExpression.model)); +} + +function updateThresholdValue( + value: number, + index: number, + expressionQueriesList: Array>, + dispatch: Dispatch +) { + const thresholdExpression = expressionQueriesList.find( + (query) => query.model.type === ExpressionQueryType.threshold && query.model.refId === SIMPLE_CONDITION_THRESHOLD_ID + ); + + const newThresholdExpression = produce(thresholdExpression, (draft) => { + if (draft && draft.model.conditions) { + draft.model.conditions[0].evaluator.params[index] = value; + } + }); + newThresholdExpression && dispatch(updateExpression(newThresholdExpression.model)); +} + +export function getSimpleConditionFromExpressions(expressions: Array>): SimpleCondition { + const reduceExpression = expressions.find( + (query) => query.model.type === ExpressionQueryType.reduce && query.refId === SIMPLE_CONDITION_REDUCER_ID + ); + const thresholdExpression = expressions.find( + (query) => query.model.type === ExpressionQueryType.threshold && query.refId === SIMPLE_CONDITION_THRESHOLD_ID + ); + const conditionsFromThreshold = thresholdExpression?.model.conditions ?? []; + return { + whenField: reduceExpression?.model.reducer ?? ReducerID.last, + evaluator: { + params: [...conditionsFromThreshold[0]?.evaluator?.params] ?? [0], + type: conditionsFromThreshold[0]?.evaluator?.type ?? EvalFunction.IsAbove, + }, + }; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + condition: { + wrapper: css({ + display: 'flex', + border: `solid 1px ${theme.colors.border.medium}`, + flex: 1, + height: 'fit-content', + borderRadius: theme.shape.radius.default, + }), + header: css({ + background: theme.colors.background.secondary, + padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`, + borderBottom: `solid 1px ${theme.colors.border.weak}`, + flex: 1, + }), + }, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts new file mode 100644 index 00000000000..0357d99f4d1 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts @@ -0,0 +1,121 @@ +// QueryAndExpressionsStep.test.tsx + +import { produce } from 'immer'; + +import { EvalFunction } from 'app/features/alerting/state/alertDef'; +import { ExpressionQuery, ExpressionQueryType, ReducerMode } from 'app/features/expressions/types'; +import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; + +import { areQueriesTransformableToSimpleCondition } from '../QueryAndExpressionsStep'; +import { + SIMPLE_CONDITION_QUERY_ID, + SIMPLE_CONDITION_REDUCER_ID, + SIMPLE_CONDITION_THRESHOLD_ID, +} from '../SimpleCondition'; + +const dataQuery: AlertQuery = { + refId: SIMPLE_CONDITION_QUERY_ID, + datasourceUid: 'abc123', + queryType: '', + model: { refId: SIMPLE_CONDITION_QUERY_ID }, +}; + +const reduceExpression: AlertQuery = { + refId: SIMPLE_CONDITION_REDUCER_ID, + queryType: 'expression', + datasourceUid: '__expr__', + model: { + type: ExpressionQueryType.reduce, + refId: SIMPLE_CONDITION_REDUCER_ID, + settings: { mode: ReducerMode.Strict }, + }, +}; +const thresholdExpression: AlertQuery = { + refId: SIMPLE_CONDITION_THRESHOLD_ID, + queryType: 'expression', + datasourceUid: '__expr__', + model: { + type: ExpressionQueryType.threshold, + refId: SIMPLE_CONDITION_THRESHOLD_ID, + }, +}; + +const expressionQueries: Array> = [reduceExpression, thresholdExpression]; + +describe('areQueriesTransformableToSimpleCondition', () => { + it('should return false if dataQueries length is not 1', () => { + // zero dataQueries + expect(areQueriesTransformableToSimpleCondition([], expressionQueries)).toBe(false); + // more than one dataQueries + expect(areQueriesTransformableToSimpleCondition([dataQuery, dataQuery], expressionQueries)).toBe(false); + }); + it('should return false if expressionQueries length is not 2', () => { + const dataQueries: Array> = [dataQuery]; + const result = areQueriesTransformableToSimpleCondition(dataQueries, []); + expect(result).toBe(false); + }); + + it('should return false if the dataQuery refId does not match SIMPLE_CONDITION_QUERY_ID', () => { + const dataQueries: Array> = [ + { refId: 'notSimpleCondition', datasourceUid: 'abc123', queryType: '', model: { refId: 'notSimpleCondition' } }, + ]; + const result = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries); + expect(result).toBe(false); + }); + it('should return false if no reduce expression is found with correct type and refId', () => { + const dataQueries: Array> = [dataQuery]; + const result = areQueriesTransformableToSimpleCondition(dataQueries, [ + { ...reduceExpression, refId: 'hello' }, + thresholdExpression, + ]); + expect(result).toBe(false); + }); + + it('should return false if no threshold expression is found with correct type and refId', () => { + const dataQueries: Array> = [dataQuery]; + const result = areQueriesTransformableToSimpleCondition(dataQueries, [ + reduceExpression, + { ...thresholdExpression, refId: 'hello' }, + ]); + expect(result).toBe(false); + }); + + it('should return false if reduceExpression settings mode is not ReducerMode.Strict', () => { + const dataQueries: Array> = [dataQuery]; + const transformedReduceExpression = produce(reduceExpression, (draft) => { + draft.model.settings = { mode: ReducerMode.DropNonNumbers }; + }); + + const result = areQueriesTransformableToSimpleCondition(dataQueries, [ + transformedReduceExpression, + thresholdExpression, + ]); + expect(result).toBe(false); + }); + + it('should return false if thresholdExpression unloadEvaluator has a value', () => { + const dataQueries: Array> = [dataQuery]; + + const transformedThresholdExpression = produce(thresholdExpression, (draft) => { + draft.model.conditions = [ + { + evaluator: { params: [1], type: EvalFunction.IsAbove }, + unloadEvaluator: { params: [1], type: EvalFunction.IsAbove }, + query: { params: ['A'] }, + reducer: { params: [], type: 'avg' }, + type: 'query', + }, + ]; + }); + const result = areQueriesTransformableToSimpleCondition(dataQueries, [ + reduceExpression, + transformedThresholdExpression, + ]); + expect(result).toBe(false); + }); + it('should return true when all conditions are met', () => { + const dataQueries: Array> = [dataQuery]; + const result = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries); + expect(result).toBe(true); + }); +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts index 44ebf2f1f18..e1e7f66092b 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts @@ -16,6 +16,7 @@ import { AlertQuery } from 'app/types/unified-alerting-dto'; import { logError } from '../../../Analytics'; import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource'; +import { getDefaultQueries } from '../../../utils/rule-form'; import { createDagFromQueries, getOriginOfRefId } from '../dag'; import { queriesWithUpdatedReferences, refIdExists } from '../util'; @@ -58,6 +59,8 @@ export const updateExpressionTimeRange = createAction('updateExpressionTimeRange export const updateMaxDataPoints = createAction<{ refId: string; maxDataPoints: number }>('updateMaxDataPoints'); export const updateMinInterval = createAction<{ refId: string; minInterval: string }>('updateMinInterval'); +export const resetToSimpleCondition = createAction('resetToSimpleCondition'); + export const setRecordingRulesQueries = createAction<{ recordingRuleQueries: AlertQuery[]; expression: string }>( 'setRecordingRulesQueries' ); @@ -65,6 +68,10 @@ export const setRecordingRulesQueries = createAction<{ recordingRuleQueries: Ale export const queriesAndExpressionsReducer = createReducer(initialState, (builder) => { // data queries actions builder + // simple condition actions + .addCase(resetToSimpleCondition, (state) => { + state.queries = getDefaultQueries(); + }) .addCase(duplicateQuery, (state, { payload }) => { state.queries = addQuery(state.queries, payload); }) diff --git a/public/app/features/alerting/unified/types/rule-form.ts b/public/app/features/alerting/unified/types/rule-form.ts index 3e65e2ca556..e99d56ee7e0 100644 --- a/public/app/features/alerting/unified/types/rule-form.ts +++ b/public/app/features/alerting/unified/types/rule-form.ts @@ -25,6 +25,10 @@ export interface AlertManagerManualRouting { [key: string]: ContactPoint; } +export interface SimplifiedEditor { + simplifiedQueryEditor: boolean; +} + export interface RuleFormValues { // common name: string; @@ -46,6 +50,7 @@ export interface RuleFormValues { isPaused?: boolean; manualRouting: boolean; // if true contactPoints are used. This field will not be used for saving the rule contactPoints?: AlertManagerManualRouting; + editorSettings?: SimplifiedEditor; metric?: string; // cortex / loki rules diff --git a/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap b/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap index 70899d7e938..71e08950c88 100644 --- a/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap +++ b/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap @@ -9,6 +9,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu "data": [], "exec_err_state": "Error", "is_paused": false, + "metadata": undefined, "no_data_state": "NoData", "notification_settings": undefined, "title": "", @@ -59,6 +60,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range ], "exec_err_state": "Error", "is_paused": false, + "metadata": undefined, "no_data_state": "NoData", "notification_settings": undefined, "title": "", diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 547e3031aea..231a3c51acf 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -43,7 +43,13 @@ import { type KVObject = { key: string; value: string }; import { EvalFunction } from '../../state/alertDef'; -import { AlertManagerManualRouting, ContactPoint, RuleFormType, RuleFormValues } from '../types/rule-form'; +import { + AlertManagerManualRouting, + ContactPoint, + RuleFormType, + RuleFormValues, + SimplifiedEditor, +} from '../types/rule-form'; import { getRulesAccess } from './access-control'; import { Annotation, defaultAnnotations } from './constants'; @@ -62,6 +68,7 @@ import { formatPrometheusDuration, parseInterval, safeParsePrometheusDuration } export type PromOrLokiQuery = PromQuery | LokiQuery; export const MANUAL_ROUTING_KEY = 'grafana.alerting.manualRouting'; +export const SIMPLIFIED_QUERY_EDITOR_KEY = 'grafana.alerting.simplifiedQueryEditor'; // even if the min interval is < 1m we should default to 1m, but allow arbitrary values for minInterval > 1m const GROUP_EVALUATION_MIN_INTERVAL_MS = safeParsePrometheusDuration(config.unifiedAlerting?.minInterval ?? '10s'); @@ -98,6 +105,7 @@ export const getDefaultFormValues = (): RuleFormValues => { overrideGrouping: false, overrideTimings: false, muteTimeIntervals: [], + editorSettings: getDefaultEditorSettings(), // cortex / loki namespace: '', @@ -119,6 +127,18 @@ export const getDefautManualRouting = () => { return manualRouting !== 'false'; }; +function getDefaultEditorSettings() { + const editorSettingsEnabled = config.featureToggles.alertingQueryAndExpressionsStepMode ?? false; + if (!editorSettingsEnabled) { + return undefined; + } + //then, check in local storage if the user has saved last rule with simplified query editor + const queryEditorSettings = localStorage.getItem(SIMPLIFIED_QUERY_EDITOR_KEY); + return { + simplifiedQueryEditor: queryEditorSettings !== 'false', + }; +} + export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO { const { name, expression, forTime, forTimeUnit, keepFiringForTime, keepFiringForTimeUnit, type } = values; @@ -202,7 +222,11 @@ export function getNotificationSettingsForDTO( } return undefined; } - +function getEditorSettingsForDTO(simplifiedEditor: SimplifiedEditor) { + return { + simplified_query_and_expressions_section: simplifiedEditor.simplifiedQueryEditor, + }; +} export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): PostableRuleGrafanaRuleDTO { const { name, @@ -222,6 +246,9 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl } const notificationSettings = getNotificationSettingsForDTO(manualRouting, contactPoints); + const metadata = values.editorSettings + ? { editor_settings: getEditorSettingsForDTO(values.editorSettings) } + : undefined; const annotations = arrayToRecord(cleanAnnotations(values.annotations)); const labels = arrayToRecord(cleanLabels(values.labels)); @@ -241,6 +268,7 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl no_data_state: noDataState, exec_err_state: execErrState, notification_settings: notificationSettings, + metadata, }, annotations, labels, @@ -307,6 +335,23 @@ export function getContactPointsFromDTO(ga: GrafanaRuleDefinition): AlertManager return routingSettings; } +function getEditorSettingsFromDTO(ga: GrafanaRuleDefinition) { + // we need to check if the feature toggle is enabled as it might be disabled after the rule was created with the feature enabled + if (!config.featureToggles.alertingQueryAndExpressionsStepMode) { + return undefined; + } + + if (ga.metadata?.editor_settings) { + return { + simplifiedQueryEditor: ga.metadata.editor_settings.simplified_query_and_expressions_section, + }; + } + + return { + simplifiedQueryEditor: false, + }; +} + export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleFormValues { const { ruleSourceName, namespace, group, rule } = ruleWithLocation; @@ -353,6 +398,8 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF contactPoints: routingSettings, manualRouting: Boolean(routingSettings), + + editorSettings: getEditorSettingsFromDTO(ga), }; } else { throw new Error('Unexpected type of rule for grafana rules source'); diff --git a/public/app/features/expressions/guards.ts b/public/app/features/expressions/guards.ts index ba272a88537..40a61add235 100644 --- a/public/app/features/expressions/guards.ts +++ b/public/app/features/expressions/guards.ts @@ -1,7 +1,7 @@ import { isExpressionReference } from '@grafana/runtime/src/utils/DataSourceWithBackend'; import { DataQuery } from '@grafana/schema'; -import { ExpressionQuery, ExpressionQueryType } from './types'; +import { ExpressionQuery, ExpressionQueryType, ReducerType } from './types'; export const isExpressionQuery = (dataQuery?: DataQuery): dataQuery is ExpressionQuery => { if (!dataQuery) { @@ -19,3 +19,20 @@ export const isExpressionQuery = (dataQuery?: DataQuery): dataQuery is Expressio } return Object.values(ExpressionQueryType).includes(expression.type); }; + +export function isReducerType(value: string): value is ReducerType { + return [ + 'avg', + 'min', + 'max', + 'sum', + 'count', + 'last', + 'median', + 'diff', + 'diff_abs', + 'percent_diff', + 'percent_diff_abs', + 'count_non_null', + ].includes(value); +} diff --git a/public/app/features/expressions/utils/expressionTypes.ts b/public/app/features/expressions/utils/expressionTypes.ts index 27c8b2c5941..514dfdcb907 100644 --- a/public/app/features/expressions/utils/expressionTypes.ts +++ b/public/app/features/expressions/utils/expressionTypes.ts @@ -1,7 +1,8 @@ import { ReducerID } from '@grafana/data'; import { EvalFunction } from '../../alerting/state/alertDef'; -import { ClassicCondition, ExpressionQuery, ExpressionQueryType } from '../types'; +import { isReducerType } from '../guards'; +import { ClassicCondition, ExpressionQuery, ExpressionQueryType, ReducerType } from '../types'; export const getDefaults = (query: ExpressionQuery) => { switch (query.type) { @@ -57,3 +58,14 @@ export const defaultCondition: ClassicCondition = { type: EvalFunction.IsAbove, }, }; + +/** + * Returns the ReducerType if the value is a valid ReducerType, otherwise undefined + * @param value string + */ +export function getReducerType(value: string): ReducerType | undefined { + if (isReducerType(value)) { + return value; + } + return undefined; +} diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index 637817cfad6..fb76109bc45 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -2,8 +2,8 @@ import classNames from 'classnames'; import { cloneDeep, filter, has, uniqBy, uniqueId } from 'lodash'; import pluralize from 'pluralize'; -import { PureComponent, ReactNode } from 'react'; import * as React from 'react'; +import { PureComponent, ReactNode } from 'react'; // Utils & Services import { @@ -35,7 +35,7 @@ import { QueryOperationRow, QueryOperationRowRenderProps, } from 'app/core/components/QueryOperationRow/QueryOperationRow'; -import { t, Trans } from 'app/core/internationalization'; +import { Trans, t } from 'app/core/internationalization'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; @@ -64,6 +64,7 @@ export interface Props { history?: Array>; eventBus?: EventBusExtended; alerting?: boolean; + hideActionButtons?: boolean; onQueryCopied?: () => void; onQueryRemoved?: () => void; onQueryToggled?: (queryStatus?: boolean | undefined) => void; @@ -527,7 +528,7 @@ export class QueryEditorRow extends PureComponent extends PureComponent
    diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index dd69743f700..4e5418c7305 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -219,6 +219,10 @@ export interface GrafanaNotificationSettings { repeat_interval?: string; mute_time_intervals?: string[]; } + +export interface GrafanaEditorSettings { + simplified_query_and_expressions_section: boolean; +} export interface PostableGrafanaRuleDefinition { uid?: string; title: string; @@ -228,6 +232,9 @@ export interface PostableGrafanaRuleDefinition { data: AlertQuery[]; is_paused?: boolean; notification_settings?: GrafanaNotificationSettings; + metadata?: { + editor_settings?: GrafanaEditorSettings; + }; record?: { metric: string; from: string; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 455063f88b9..51e5a1e67d2 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -244,6 +244,12 @@ "state": "State" }, "save-query": "Save current search" + }, + "simpleCondition": { + "alertCondition": "Alert condition", + "ofQuery": { + "To": "TO" + } } }, "annotations": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index be08f9a971b..23911fd61f2 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -244,6 +244,12 @@ "state": "Ŝŧäŧę" }, "save-query": "Ŝävę čūřřęʼnŧ şęäřčĥ" + }, + "simpleCondition": { + "alertCondition": "Åľęřŧ čőʼnđįŧįőʼn", + "ofQuery": { + "To": "ŦØ" + } } }, "annotations": { From cdbc04ab2be22d01285971c920251a5f0e486262 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 26 Sep 2024 10:58:59 +0300 Subject: [PATCH 065/102] GRPC/Authn: Remove org name header (#93780) --- pkg/storage/unified/resource/grpc/authenticator.go | 8 +++----- pkg/storage/unified/resource/grpc/authenticator_test.go | 1 - 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/pkg/storage/unified/resource/grpc/authenticator.go b/pkg/storage/unified/resource/grpc/authenticator.go index f1f9aea5bec..29184097935 100644 --- a/pkg/storage/unified/resource/grpc/authenticator.go +++ b/pkg/storage/unified/resource/grpc/authenticator.go @@ -5,11 +5,12 @@ import ( "fmt" "strconv" - "github.com/grafana/authlib/authn" - authClaims "github.com/grafana/authlib/claims" "google.golang.org/grpc" "google.golang.org/grpc/metadata" + "github.com/grafana/authlib/authn" + authClaims "github.com/grafana/authlib/claims" + "github.com/grafana/grafana/pkg/apimachinery/identity" ) @@ -18,7 +19,6 @@ const ( mdLogin = "grafana-login" mdUserID = "grafana-user-id" mdUserUID = "grafana-user-uid" - mdOrgName = "grafana-org-name" mdOrgID = "grafana-org-id" mdOrgRole = "grafana-org-role" ) @@ -105,7 +105,6 @@ func (f *Authenticator) decodeMetadata(ctx context.Context, meta metadata.MD) (i } user.UserUID = id - user.OrgName = getter(mdOrgName) user.OrgID, err = strconv.ParseInt(getter(mdOrgID), 10, 64) if err != nil { return nil, fmt.Errorf("invalid org id: %w", err) @@ -154,7 +153,6 @@ func encodeIdentityInMetadata(user identity.Requester) metadata.MD { // Or we can create it directly mdUserID, user.GetID(), mdUserUID, user.GetUID(), - mdOrgName, user.GetOrgName(), mdOrgID, strconv.FormatInt(user.GetOrgID(), 10), mdOrgRole, string(user.GetOrgRole()), mdLogin, user.GetLogin(), diff --git a/pkg/storage/unified/resource/grpc/authenticator_test.go b/pkg/storage/unified/resource/grpc/authenticator_test.go index d44268a0232..674d846c486 100644 --- a/pkg/storage/unified/resource/grpc/authenticator_test.go +++ b/pkg/storage/unified/resource/grpc/authenticator_test.go @@ -17,7 +17,6 @@ func TestBasicEncodeDecode(t *testing.T) { Login: "test", Type: claims.TypeUser, OrgID: 456, - OrgName: "org", OrgRole: identity.RoleAdmin, } From 368fc0f1202897587e6d3c6e5b745588af87e9b7 Mon Sep 17 00:00:00 2001 From: Hugo Kiyodi Oshiro Date: Thu, 26 Sep 2024 10:19:26 +0200 Subject: [PATCH 066/102] Plugins: Improve update all modal UX (#93448) --- .../admin/components/UpdateAllModal.tsx | 200 ++++------------- .../admin/components/UpdateAllModalBody.tsx | 208 ++++++++++++++++++ public/locales/en-US/grafana.json | 5 +- public/locales/pseudo-LOCALE/grafana.json | 5 +- 4 files changed, 259 insertions(+), 159 deletions(-) create mode 100644 public/app/features/plugins/admin/components/UpdateAllModalBody.tsx diff --git a/public/app/features/plugins/admin/components/UpdateAllModal.tsx b/public/app/features/plugins/admin/components/UpdateAllModal.tsx index 6dfa1758a32..d0c07295f59 100644 --- a/public/app/features/plugins/admin/components/UpdateAllModal.tsx +++ b/public/app/features/plugins/admin/components/UpdateAllModal.tsx @@ -1,14 +1,13 @@ -import { css } from '@emotion/css'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; -import { Checkbox, ConfirmModal, EmptyState, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; -import { t, Trans } from 'app/core/internationalization'; +import { ConfirmModal } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { useInstall, useInstallStatus } from '../state/hooks'; import { CatalogPlugin } from '../types'; +import { UpdateModalBody } from './UpdateAllModalBody'; const PLUGINS_UPDATE_ALL_INTERACTION_EVENT_NAME = 'plugins_update_all_clicked'; type UpdateError = { @@ -16,150 +15,6 @@ type UpdateError = { message: string; }; -function getIcon({ - id, - inProgress, - errorMap, - selectedPlugins, -}: { - id: string; - inProgress: boolean; - errorMap: Map; - selectedPlugins?: Set; -}) { - if (errorMap && errorMap.has(id)) { - return ( - - - - ); - } - if (inProgress && selectedPlugins?.has(id)) { - return ; - } - return ''; -} - -const getStyles = (theme: GrafanaTheme2) => ({ - table: css({ - marginTop: theme.spacing(2), - width: '100%', - borderCollapse: 'collapse', - }), - tableRow: css({ - borderBottom: `1px solid ${theme.colors.border.weak}`, - td: { - paddingRight: theme.spacing(1), - }, - }), - icon: css({ - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - }), - header: css({ - textAlign: 'left', - padding: theme.spacing(1), - borderBottom: `2px solid ${theme.colors.border.strong}`, - th: { - paddingRight: theme.spacing(1), - }, - }), - data: css({ - padding: '10px', - }), - footer: css({ - fontSize: theme.typography.bodySmall.fontSize, - marginTop: theme.spacing(3), - }), - noPluginsMessage: css({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - height: '100%', - }), - tableContainer: css({ - overflowY: 'auto', - overflowX: 'hidden', - height: theme.spacing(32), - }), - modalContainer: css({ - height: theme.spacing(41), - }), -}); - -type ModalBodyProps = { - plugins: CatalogPlugin[]; - inProgress: boolean; - selectedPlugins?: Set; - onCheckboxChange: (id: string) => void; - errorMap: Map; -}; - -const ModalBody = ({ plugins, inProgress, selectedPlugins, onCheckboxChange, errorMap }: ModalBodyProps) => { - const styles = useStyles2(getStyles); - - return ( -
    - {plugins.length === 0 ? ( - - ) : ( - <> -
    - The following plugins have update available -
    -
    - - - - - - - - - - - - {plugins.map(({ id, name, installedVersion, latestVersion }: CatalogPlugin) => ( - - - - - - - - ))} - -
    - Update - - Name - - Installed - - Available -
    - onCheckboxChange(id)} value={selectedPlugins?.has(id)} /> - {name}{installedVersion}{latestVersion}{getIcon({ id, inProgress, errorMap, selectedPlugins })}
    -
    - {config.pluginAdminExternalManageEnabled && config.featureToggles.managedPluginsInstall && ( -
    - - * It may take a few minutes for the plugins to be available for usage. - -
    - )} - - )} -
    - ); -}; - type Props = { isOpen: boolean; isLoading: boolean; @@ -173,10 +28,19 @@ export const UpdateAllModal = ({ isOpen, onDismiss, isLoading, plugins }: Props) const [errorMap, setErrorMap] = useState(new Map()); const [inProgress, setInProgress] = useState(false); const [selectedPlugins, setSelectedPlugins] = useState>(); + const initialPluginsRef = useRef(plugins); const pluginsSet = useMemo(() => new Set(plugins.map((plugin) => plugin.id)), [plugins]); const installsRemaining = plugins.length; + // Since the plugins comes from the store and changes every time we update a plugin, + // we need to keep track of the initial plugins. + useEffect(() => { + if (initialPluginsRef.current.length === 0) { + initialPluginsRef.current = [...plugins]; + } + }, [plugins]); + // Updates the component state on every plugins change, since the installation will change the store content useEffect(() => { if (inProgress) { @@ -245,6 +109,7 @@ export const UpdateAllModal = ({ isOpen, onDismiss, isLoading, plugins }: Props) }; const onDismissClick = () => { + initialPluginsRef.current = []; setErrorMap(new Map()); setInProgress(false); setSelectedPlugins(undefined); @@ -277,8 +142,9 @@ export const UpdateAllModal = ({ isOpen, onDismiss, isLoading, plugins }: Props) isOpen={isOpen} title={t('plugins.catalog.update-all.modal-title', 'Update Plugins')} body={ - 0 ? onConfirm : onDismissClick} onDismiss={onDismissClick} - disabled={pluginsSelected === 0 || inProgress} - confirmText={ - installsRemaining > 0 - ? `${t('plugins.catalog.update-all.modal-confirmation', 'Update')} (${pluginsSelected})` - : t('plugins.catalog.update-all.modal-dismiss', 'Close') - } + disabled={shouldDisableConfirm(inProgress, installsRemaining, pluginsSelected)} + confirmText={getConfirmationText(installsRemaining, inProgress, pluginsSelected)} + confirmButtonVariant="primary" /> ); }; +function getConfirmationText(installsRemaining: number, inProgress: boolean, pluginsSelected: number) { + if (inProgress) { + return t('plugins.catalog.update-all.modal-in-progress', 'Updating...'); + } + + if (installsRemaining > 0) { + return t('plugins.catalog.update-all.modal-confirmation', 'Update') + ` (${pluginsSelected})`; + } + return t('plugins.catalog.update-all.modal-dismiss', 'Close'); +} + +function shouldDisableConfirm(inProgress: boolean, installsRemaining: number, pluginsSelected: number) { + if (inProgress) { + return true; + } + + if (installsRemaining > 0 && pluginsSelected === 0) { + return true; + } + + return false; +} + export default UpdateAllModal; diff --git a/public/app/features/plugins/admin/components/UpdateAllModalBody.tsx b/public/app/features/plugins/admin/components/UpdateAllModalBody.tsx new file mode 100644 index 00000000000..20214900d3a --- /dev/null +++ b/public/app/features/plugins/admin/components/UpdateAllModalBody.tsx @@ -0,0 +1,208 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { Checkbox, EmptyState, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { CatalogPlugin } from '../types'; + +type UpdateError = { + id: string; + message: string; +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + table: css({ + marginTop: theme.spacing(2), + width: '100%', + borderCollapse: 'collapse', + }), + tableRow: css({ + borderBottom: `1px solid ${theme.colors.border.weak}`, + td: { + paddingRight: theme.spacing(1), + }, + }), + icon: css({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }), + header: css({ + textAlign: 'left', + padding: theme.spacing(1), + borderBottom: `2px solid ${theme.colors.border.strong}`, + th: { + paddingRight: theme.spacing(1), + }, + }), + data: css({ + padding: '10px', + }), + footer: css({ + fontSize: theme.typography.bodySmall.fontSize, + marginTop: theme.spacing(3), + }), + noPluginsMessage: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }), + tableContainer: css({ + overflowY: 'auto', + overflowX: 'hidden', + maxHeight: theme.spacing(41), + marginBottom: theme.spacing(2), + }), + errorIcon: css({ + color: theme.colors.error.main, + }), + successIcon: css({ + color: theme.colors.success.main, + }), + pluginsInstalled: css({ + svg: { + marginRight: theme.spacing(1), + }, + }), +}); + +const StatusIcon = ({ + id, + inProgress, + isSelected, + isInstalled, + errorMap, +}: { + id: string; + inProgress: boolean; + isSelected: boolean; + isInstalled: boolean; + errorMap: Map; +}) => { + const styles = useStyles2(getStyles); + + if (errorMap && errorMap.has(id)) { + return ( + + + + ); + } + if (isInstalled) { + return ; + } + if (inProgress && isSelected) { + return ; + } + return ''; +}; + +type Props = { + plugins: CatalogPlugin[]; + pluginsNotInstalled: Set; + inProgress: boolean; + selectedPlugins?: Set; + onCheckboxChange: (id: string) => void; + errorMap: Map; +}; + +export const UpdateModalBody = ({ + plugins, + pluginsNotInstalled, + inProgress, + selectedPlugins, + onCheckboxChange, + errorMap, +}: Props) => { + const styles = useStyles2(getStyles); + + const numberInstalled = plugins.length - pluginsNotInstalled.size; + const installationFinished = plugins.length !== pluginsNotInstalled.size && !inProgress; + + return ( +
    + {plugins.length === 0 ? ( + + ) : ( + <> +
    + The following plugins have update available +
    +
    + + + + + + + + + + + + {plugins.map(({ id, name, installedVersion, latestVersion }: CatalogPlugin) => ( + + + + + + + + ))} + +
    + Update + + Name + + Installed + + Available +
    + onCheckboxChange(id)} + value={selectedPlugins?.has(id)} + disabled={!pluginsNotInstalled.has(id)} + /> + {name}{installedVersion}{latestVersion} + +
    +
    + {numberInstalled > 0 && installationFinished && ( +
    + + {`${numberInstalled} ${t('plugins.catalog.update-all.update-status-text', 'plugins updated')}`} +
    + )} + {errorMap.size > 0 && installationFinished && ( +
    + + {`${errorMap.size} ${t('plugins.catalog.update-all.error-status-text', 'failed - see error messages')}`} +
    + )} + {config.pluginAdminExternalManageEnabled && config.featureToggles.managedPluginsInstall && ( +
    + + * It may take a few minutes for the plugins to be available for usage. + +
    + )} + + )} +
    + ); +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 51e5a1e67d2..a6eaee746f6 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1926,13 +1926,16 @@ "button": "Update all", "cloud-update-message": "* It may take a few minutes for the plugins to be available for usage.", "error": "Error updating plugin:", + "error-status-text": "failed - see error messages", "header": "The following plugins have update available", "installed-header": "Installed", "modal-confirmation": "Update", "modal-dismiss": "Close", + "modal-in-progress": "Updating...", "modal-title": "Update Plugins", "name-header": "Name", - "update-header": "Update" + "update-header": "Update", + "update-status-text": "plugins updated" } }, "details": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 23911fd61f2..618d140017d 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1926,13 +1926,16 @@ "button": "Ůpđäŧę äľľ", "cloud-update-message": "* Ĩŧ mäy ŧäĸę ä ƒęŵ mįʼnūŧęş ƒőř ŧĥę pľūģįʼnş ŧő þę äväįľäþľę ƒőř ūşäģę.", "error": "Ēřřőř ūpđäŧįʼnģ pľūģįʼn:", + "error-status-text": "ƒäįľęđ - şęę ęřřőř męşşäģęş", "header": "Ŧĥę ƒőľľőŵįʼnģ pľūģįʼnş ĥävę ūpđäŧę äväįľäþľę", "installed-header": "Ĩʼnşŧäľľęđ", "modal-confirmation": "Ůpđäŧę", "modal-dismiss": "Cľőşę", + "modal-in-progress": "Ůpđäŧįʼnģ...", "modal-title": "Ůpđäŧę Pľūģįʼnş", "name-header": "Ńämę", - "update-header": "Ůpđäŧę" + "update-header": "Ůpđäŧę", + "update-status-text": "pľūģįʼnş ūpđäŧęđ" } }, "details": { From c63c5857df3c6669976bd12680e2c7b42f4c9a75 Mon Sep 17 00:00:00 2001 From: Irene Rodriguez Date: Thu, 26 Sep 2024 10:54:51 +0200 Subject: [PATCH 067/102] Update CODEOWNERS (#93786) --- .github/CODEOWNERS | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9858f0a295b..c30c40ee279 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -43,12 +43,12 @@ /docs/sources/dashboards/ @imatwawana /docs/sources/datasources/ @jdbaldry /docs/sources/explore/ @grafana/explore-squad @lwandz13 -/docs/sources/fundamentals @chri2547 -/docs/sources/getting-started/ @chri2547 -/docs/sources/introduction/ @chri2547 +/docs/sources/fundamentals @irenerl24 +/docs/sources/getting-started/ @irenerl24 +/docs/sources/introduction/ @irenerl24 /docs/sources/panels-visualizations/ @imatwawana /docs/sources/release-notes/ @Eve832 @GrafanaWriter -/docs/sources/setup-grafana/ @chri2547 +/docs/sources/setup-grafana/ @irenerl24 /docs/sources/upgrade-guide/ @imatwawana /docs/sources/whatsnew/ @imatwawana From fa4baa7c384612c3927ce231d363fa0b4f33014c Mon Sep 17 00:00:00 2001 From: Roman Pertl <533172+roock@users.noreply.github.com> Date: Thu, 26 Sep 2024 11:08:31 +0200 Subject: [PATCH 068/102] Docs: update grafana-renderer auth token documentation (#93645) --- docs/sources/setup-grafana/image-rendering/_index.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/sources/setup-grafana/image-rendering/_index.md b/docs/sources/setup-grafana/image-rendering/_index.md index f77ccbdedfd..47a74351af8 100644 --- a/docs/sources/setup-grafana/image-rendering/_index.md +++ b/docs/sources/setup-grafana/image-rendering/_index.md @@ -82,8 +82,10 @@ AUTH_TOKEN=- ```json { - "security": { - "authToken": "-" + "service": { + "security": { + "authToken": "-" + } } } ``` From 6be7033d294d7ad7e7b126e57dd8885b4b5f516a Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Thu, 26 Sep 2024 10:10:33 +0100 Subject: [PATCH 069/102] Chore: Delete mockServiceWorker.js (#93768) --- public/mockServiceWorker.js | 281 ------------------------------------ 1 file changed, 281 deletions(-) delete mode 100644 public/mockServiceWorker.js diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js deleted file mode 100644 index aa19b0cbce6..00000000000 --- a/public/mockServiceWorker.js +++ /dev/null @@ -1,281 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ - -/** - * Mock Service Worker. - * @see https://github.com/mswjs/msw - * - Please do NOT modify this file. - * - Please do NOT serve this file on production. - */ - -const PACKAGE_VERSION = '2.4.9'; -const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'; -const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); -const activeClientIds = new Set(); - -self.addEventListener('install', function () { - self.skipWaiting(); -}); - -self.addEventListener('activate', function (event) { - event.waitUntil(self.clients.claim()); -}); - -self.addEventListener('message', async function (event) { - const clientId = event.source.id; - - if (!clientId || !self.clients) { - return; - } - - const client = await self.clients.get(clientId); - - if (!client) { - return; - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }); - - switch (event.data) { - case 'KEEPALIVE_REQUEST': { - sendToClient(client, { - type: 'KEEPALIVE_RESPONSE', - }); - break; - } - - case 'INTEGRITY_CHECK_REQUEST': { - sendToClient(client, { - type: 'INTEGRITY_CHECK_RESPONSE', - payload: { - packageVersion: PACKAGE_VERSION, - checksum: INTEGRITY_CHECKSUM, - }, - }); - break; - } - - case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId); - - sendToClient(client, { - type: 'MOCKING_ENABLED', - payload: true, - }); - break; - } - - case 'MOCK_DEACTIVATE': { - activeClientIds.delete(clientId); - break; - } - - case 'CLIENT_CLOSED': { - activeClientIds.delete(clientId); - - const remainingClients = allClients.filter((client) => { - return client.id !== clientId; - }); - - // Unregister itself when there are no more clients - if (remainingClients.length === 0) { - self.registration.unregister(); - } - - break; - } - } -}); - -self.addEventListener('fetch', function (event) { - const { request } = event; - - // Bypass navigation requests. - if (request.mode === 'navigate') { - return; - } - - // Opening the DevTools triggers the "only-if-cached" request - // that cannot be handled by the worker. Bypass such requests. - if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { - return; - } - - // Bypass all requests when there are no active clients. - // Prevents the self-unregistered worked from handling requests - // after it's been deleted (still remains active until the next reload). - if (activeClientIds.size === 0) { - return; - } - - // Generate unique request ID. - const requestId = crypto.randomUUID(); - event.respondWith(handleRequest(event, requestId)); -}); - -async function handleRequest(event, requestId) { - const client = await resolveMainClient(event); - const response = await getResponse(event, client, requestId); - - // Send back the response clone for the "response:*" life-cycle events. - // Ensure MSW is active and ready to handle the message, otherwise - // this message will pend indefinitely. - if (client && activeClientIds.has(client.id)) { - (async function () { - const responseClone = response.clone(); - - sendToClient( - client, - { - type: 'RESPONSE', - payload: { - requestId, - isMockedResponse: IS_MOCKED_RESPONSE in response, - type: responseClone.type, - status: responseClone.status, - statusText: responseClone.statusText, - body: responseClone.body, - headers: Object.fromEntries(responseClone.headers.entries()), - }, - }, - [responseClone.body] - ); - })(); - } - - return response; -} - -// Resolve the main client for the given event. -// Client that issues a request doesn't necessarily equal the client -// that registered the worker. It's with the latter the worker should -// communicate with during the response resolving phase. -async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId); - - if (client?.frameType === 'top-level') { - return client; - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }); - - return allClients - .filter((client) => { - // Get only those clients that are currently visible. - return client.visibilityState === 'visible'; - }) - .find((client) => { - // Find the client ID that's recorded in the - // set of clients that have registered the worker. - return activeClientIds.has(client.id); - }); -} - -async function getResponse(event, client, requestId) { - const { request } = event; - - // Clone the request because it might've been already used - // (i.e. its body has been read and sent to the client). - const requestClone = request.clone(); - - function passthrough() { - const headers = Object.fromEntries(requestClone.headers.entries()); - - // Remove internal MSW request header so the passthrough request - // complies with any potential CORS preflight checks on the server. - // Some servers forbid unknown request headers. - delete headers['x-msw-intention']; - - return fetch(requestClone, { headers }); - } - - // Bypass mocking when the client is not active. - if (!client) { - return passthrough(); - } - - // Bypass initial page load requests (i.e. static assets). - // The absence of the immediate/parent client in the map of the active clients - // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet - // and is not ready to handle requests. - if (!activeClientIds.has(client.id)) { - return passthrough(); - } - - // Notify the client that a request has been intercepted. - const requestBuffer = await request.arrayBuffer(); - const clientMessage = await sendToClient( - client, - { - type: 'REQUEST', - payload: { - id: requestId, - url: request.url, - mode: request.mode, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: requestBuffer, - keepalive: request.keepalive, - }, - }, - [requestBuffer] - ); - - switch (clientMessage.type) { - case 'MOCK_RESPONSE': { - return respondWithMock(clientMessage.data); - } - - case 'PASSTHROUGH': { - return passthrough(); - } - } - - return passthrough(); -} - -function sendToClient(client, message, transferrables = []) { - return new Promise((resolve, reject) => { - const channel = new MessageChannel(); - - channel.port1.onmessage = (event) => { - if (event.data && event.data.error) { - return reject(event.data.error); - } - - resolve(event.data); - }; - - client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean))); - }); -} - -async function respondWithMock(response) { - // Setting response status code to 0 is a no-op. - // However, when responding with a "Response.error()", the produced Response - // instance will have status code set to 0. Since it's not possible to create - // a Response instance with status code 0, handle that use-case separately. - if (response.status === 0) { - return Response.error(); - } - - const mockedResponse = new Response(response.body, response); - - Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { - value: true, - enumerable: true, - }); - - return mockedResponse; -} From 9531be9162610918570a4604218b35a1c87ac36e Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Thu, 26 Sep 2024 10:19:24 +0100 Subject: [PATCH 070/102] I18n: Download translations from Crowdin (#93760) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/de-DE/grafana.json | 14 +++++++++++++- public/locales/es-ES/grafana.json | 14 +++++++++++++- public/locales/fr-FR/grafana.json | 14 +++++++++++++- public/locales/pt-BR/grafana.json | 14 +++++++++++++- public/locales/zh-Hans/grafana.json | 14 +++++++++++++- 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index e1a2fccdd30..d3d76b05f1d 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -244,6 +244,12 @@ "state": "" }, "save-query": "" + }, + "simpleCondition": { + "alertCondition": "", + "ofQuery": { + "To": "" + } } }, "annotations": { @@ -1745,6 +1751,9 @@ "subtitle": "Verwende Servicekonten, um automatisierte Arbeitsabläufe in Grafana auszuführen", "title": "Servicekonten" }, + "setup-guide": { + "title": "" + }, "shared-dashboard": { "subtitle": "", "title": "" @@ -1917,13 +1926,16 @@ "button": "", "cloud-update-message": "", "error": "", + "error-status-text": "", "header": "", "installed-header": "", "modal-confirmation": "", "modal-dismiss": "", + "modal-in-progress": "", "modal-title": "", "name-header": "", - "update-header": "" + "update-header": "", + "update-status-text": "" } }, "details": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 8292e1f6e37..fff0891604b 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -244,6 +244,12 @@ "state": "" }, "save-query": "" + }, + "simpleCondition": { + "alertCondition": "", + "ofQuery": { + "To": "" + } } }, "annotations": { @@ -1745,6 +1751,9 @@ "subtitle": "Use cuentas de servicio para ejecutar cargas de trabajo automatizadas en Grafana", "title": "Cuentas de servicios" }, + "setup-guide": { + "title": "" + }, "shared-dashboard": { "subtitle": "", "title": "" @@ -1917,13 +1926,16 @@ "button": "", "cloud-update-message": "", "error": "", + "error-status-text": "", "header": "", "installed-header": "", "modal-confirmation": "", "modal-dismiss": "", + "modal-in-progress": "", "modal-title": "", "name-header": "", - "update-header": "" + "update-header": "", + "update-status-text": "" } }, "details": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index f426bc2a362..c466169af69 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -244,6 +244,12 @@ "state": "" }, "save-query": "" + }, + "simpleCondition": { + "alertCondition": "", + "ofQuery": { + "To": "" + } } }, "annotations": { @@ -1745,6 +1751,9 @@ "subtitle": "Utiliser des comptes de service pour exécuter des charges de travail automatisées dans Grafana", "title": "Comptes de service" }, + "setup-guide": { + "title": "" + }, "shared-dashboard": { "subtitle": "", "title": "" @@ -1917,13 +1926,16 @@ "button": "", "cloud-update-message": "", "error": "", + "error-status-text": "", "header": "", "installed-header": "", "modal-confirmation": "", "modal-dismiss": "", + "modal-in-progress": "", "modal-title": "", "name-header": "", - "update-header": "" + "update-header": "", + "update-status-text": "" } }, "details": { diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index bb74869005e..ed03bd7997e 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -244,6 +244,12 @@ "state": "" }, "save-query": "" + }, + "simpleCondition": { + "alertCondition": "", + "ofQuery": { + "To": "" + } } }, "annotations": { @@ -1745,6 +1751,9 @@ "subtitle": "Use contas de serviço para executar cargas de trabalho automatizadas no Grafana", "title": "Contas de serviço" }, + "setup-guide": { + "title": "" + }, "shared-dashboard": { "subtitle": "", "title": "" @@ -1917,13 +1926,16 @@ "button": "", "cloud-update-message": "", "error": "", + "error-status-text": "", "header": "", "installed-header": "", "modal-confirmation": "", "modal-dismiss": "", + "modal-in-progress": "", "modal-title": "", "name-header": "", - "update-header": "" + "update-header": "", + "update-status-text": "" } }, "details": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 17cf9eb4440..98e517fd53c 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -241,6 +241,12 @@ "state": "" }, "save-query": "" + }, + "simpleCondition": { + "alertCondition": "", + "ofQuery": { + "To": "" + } } }, "annotations": { @@ -1736,6 +1742,9 @@ "subtitle": "使用服务帐户在 Grafana 中运行自动化工作负载", "title": "服务帐户" }, + "setup-guide": { + "title": "" + }, "shared-dashboard": { "subtitle": "", "title": "" @@ -1908,13 +1917,16 @@ "button": "", "cloud-update-message": "", "error": "", + "error-status-text": "", "header": "", "installed-header": "", "modal-confirmation": "", "modal-dismiss": "", + "modal-in-progress": "", "modal-title": "", "name-header": "", - "update-header": "" + "update-header": "", + "update-status-text": "" } }, "details": { From 0a01b60305268d32e76597cea9e500213a13fb79 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 26 Sep 2024 10:23:20 +0100 Subject: [PATCH 071/102] Chore: Add mock for `useChromeHeaderHeight` (#93788) add mock for useChromeHeaderHeight --- .../features/dashboard/containers/DashboardPageProxy.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx b/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx index 35370d47621..178c4b96cef 100644 --- a/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx @@ -61,6 +61,7 @@ jest.mock('@grafana/runtime', () => ({ }, get: jest.fn().mockResolvedValue({}), }), + useChromeHeaderHeight: jest.fn(), })); jest.mock('react-virtualized-auto-sizer', () => { From 6c8f24adc9b6f7c30dcf85b2b72bba364e965b04 Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Thu, 26 Sep 2024 11:15:51 +0100 Subject: [PATCH 072/102] Dashboards: Fix issue with percentage difference formatting (#93389) * Dashboards: Fix issue with percentage difference formatting --------- Co-authored-by: Ihor Yeromin --- packages/grafana-data/src/transformations/fieldReducer.test.ts | 3 ++- packages/grafana-data/src/transformations/fieldReducer.ts | 2 +- packages/grafana-ui/src/components/BigValue/BigValue.test.tsx | 2 +- packages/grafana-ui/src/components/BigValue/PercentChange.tsx | 3 ++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/grafana-data/src/transformations/fieldReducer.test.ts b/packages/grafana-data/src/transformations/fieldReducer.test.ts index baa5d704911..120a416d81a 100644 --- a/packages/grafana-data/src/transformations/fieldReducer.test.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.test.ts @@ -55,13 +55,14 @@ describe('Stats Calculators', () => { it('should calculate basic stats', () => { const stats = reduceField({ field: basicTable.fields[0], - reducers: [ReducerID.first, ReducerID.last, ReducerID.mean, ReducerID.count], + reducers: [ReducerID.first, ReducerID.last, ReducerID.mean, ReducerID.count, ReducerID.diffperc], }); expect(stats.first).toEqual(10); expect(stats.last).toEqual(20); expect(stats.mean).toEqual(15); expect(stats.count).toEqual(2); + expect(stats.diffperc).toEqual(100); }); it('should handle undefined field data without crashing', () => { diff --git a/packages/grafana-data/src/transformations/fieldReducer.ts b/packages/grafana-data/src/transformations/fieldReducer.ts index a032619aeb3..a7d1f0c7242 100644 --- a/packages/grafana-data/src/transformations/fieldReducer.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.ts @@ -582,7 +582,7 @@ export function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: } if (isNumber(calcs.firstNotNull) && isNumber(calcs.diff)) { - calcs.diffperc = calcs.diff / calcs.firstNotNull; + calcs.diffperc = (calcs.diff / calcs.firstNotNull) * 100; } return calcs; } diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx index ff682372578..e7819cd110d 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx @@ -41,7 +41,7 @@ describe('BigValue', () => { /> ); - expect(screen.getByText('50%')).toBeInTheDocument(); + expect(screen.getByText('0.5%')).toBeInTheDocument(); }); it('should render without percent change', () => { diff --git a/packages/grafana-ui/src/components/BigValue/PercentChange.tsx b/packages/grafana-ui/src/components/BigValue/PercentChange.tsx index 24de61cbbc6..1e9c97a2ee2 100644 --- a/packages/grafana-ui/src/components/BigValue/PercentChange.tsx +++ b/packages/grafana-ui/src/components/BigValue/PercentChange.tsx @@ -27,6 +27,7 @@ export const PercentChange = ({ percentChange, styles }: Props) => { ); }; +// percentChange is expected to be a value between 0-100 export const percentChangeString = (percentChange: number) => { - return percentChange.toLocaleString(undefined, { style: 'percent', maximumSignificantDigits: 3 }); + return (percentChange / 100).toLocaleString(undefined, { style: 'percent', maximumSignificantDigits: 3 }); }; From 20a66343bd82d8e1264ca68cd6633e54dd20c07a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edvard=20Falksk=C3=A4r?= <457523+edvard-falkskar@users.noreply.github.com> Date: Thu, 26 Sep 2024 13:41:03 +0200 Subject: [PATCH 073/102] SparklineCell: Improve text alignment factor (#93539) --- packages/grafana-ui/src/components/Table/SparklineCell.tsx | 5 ++--- packages/grafana-ui/src/components/Table/utils.ts | 7 ++++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/grafana-ui/src/components/Table/SparklineCell.tsx b/packages/grafana-ui/src/components/Table/SparklineCell.tsx index 3c047c0fdf0..0d6c26c4a1d 100644 --- a/packages/grafana-ui/src/components/Table/SparklineCell.tsx +++ b/packages/grafana-ui/src/components/Table/SparklineCell.tsx @@ -8,6 +8,7 @@ import { isDataFrame, Field, isDataFrameWithValue, + formattedValueToString, } from '@grafana/data'; import { BarAlignment, @@ -92,9 +93,7 @@ export const SparklineCell = (props: TableCellProps) => { const displayValue = field.display!(value); const alignmentFactor = getAlignmentFactor(field, displayValue, cell.row.index); - valueWidth = - measureText(`${alignmentFactor.prefix ?? ''}${alignmentFactor.text}${alignmentFactor.suffix ?? ''}`, 16).width + - theme.spacing.gridSize; + valueWidth = measureText(formattedValueToString(alignmentFactor), 16).width + theme.spacing.gridSize; valueElement = ( alignmentFactor.text.length) { + if (formattedValueToString(alignmentFactor).length > formattedValueToString(nextDisplayValue).length) { alignmentFactor.text = displayValue.text; } } From 415b4b57823a82afae02c21ea378a105cb7156ed Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 16:24:47 +0000 Subject: [PATCH 074/102] Update dependency @grafana/scenes to v5.16.0 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index d466315ba6a..775c61d834d 100644 --- a/package.json +++ b/package.json @@ -268,7 +268,7 @@ "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", "@grafana/saga-icons": "workspace:*", - "@grafana/scenes": "5.15.0", + "@grafana/scenes": "5.16.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/yarn.lock b/yarn.lock index 918b0f64368..5fddfdcdd09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4128,9 +4128,9 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes@npm:5.15.0": - version: 5.15.0 - resolution: "@grafana/scenes@npm:5.15.0" +"@grafana/scenes@npm:5.16.0": + version: 5.16.0 + resolution: "@grafana/scenes@npm:5.16.0" dependencies: "@floating-ui/react": "npm:0.26.16" "@grafana/e2e-selectors": "npm:^11.0.0" @@ -4147,7 +4147,7 @@ __metadata: "@grafana/ui": ">=10.4" react: ^18.0.0 react-dom: ^18.0.0 - checksum: 10/da545f038df708faff29714aabf08c57a204b0754a7411d2654613b855279642d00bb6239951394c89bccfc60f67bbd578b8264d5177c55a427e7984c4fe889a + checksum: 10/0c2d96a2b8a77d1c3d6768e927f36bd6d971cadb4578560c1deb50d0d1f3d162591027b2792506de08738deaaf472cbd3008b39ea9902faa3da96dc71022e06e languageName: node linkType: hard @@ -18833,7 +18833,7 @@ __metadata: "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" "@grafana/saga-icons": "workspace:*" - "@grafana/scenes": "npm:5.15.0" + "@grafana/scenes": "npm:5.16.0" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/tsconfig": "npm:^2.0.0" From c602041f4a9ed426b76ea0df8ef24c8380c33d20 Mon Sep 17 00:00:00 2001 From: kay delaney Date: Thu, 26 Sep 2024 11:27:14 +0100 Subject: [PATCH 075/102] Fix failing tests --- .../pages/DashboardScenePage.test.tsx | 16 ++++++++++--- .../pages/PublicDashboardScenePage.test.tsx | 13 ++++++++-- .../scene/NavToolbarActions.test.tsx | 10 ++++---- .../PublicDashboardPageProxy.test.tsx | 24 ++++++++++--------- 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx index 11146de7f4d..2dd6ebcba26 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx @@ -6,7 +6,13 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { PanelProps } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; -import { config, getPluginLinkExtensions, locationService, setPluginImportUtils } from '@grafana/runtime'; +import { + LocationServiceProvider, + config, + getPluginLinkExtensions, + locationService, + setPluginImportUtils, +} from '@grafana/runtime'; import { VizPanel } from '@grafana/scenes'; import { Dashboard } from '@grafana/schema'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; @@ -60,14 +66,18 @@ function setup({ routeProps }: { routeProps?: Partial - + + + ); const rerender = (newProps: Props) => { renderResult.rerender( - + + + ); }; diff --git a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx index 2824025d1e6..bd7e7b4fc64 100644 --- a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx +++ b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx @@ -6,7 +6,14 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { getDefaultTimeRange, LoadingState, PanelData, PanelProps } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; -import { config, getPluginLinkExtensions, setPluginImportUtils, setRunRequest } from '@grafana/runtime'; +import { + config, + getPluginLinkExtensions, + locationService, + LocationServiceProvider, + setPluginImportUtils, + setRunRequest, +} from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; import { DashboardRoutes } from 'app/types/dashboard'; @@ -47,7 +54,9 @@ function setup(props?: Partial) { return render( - + + + ); } diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx index 22c2acde4fa..39048ed5724 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx @@ -4,7 +4,7 @@ import { TestProvider } from 'test/helpers/TestProvider'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { selectors } from '@grafana/e2e-selectors'; -import { config, locationService } from '@grafana/runtime'; +import { LocationServiceProvider, config, locationService } from '@grafana/runtime'; import { SceneGridLayout, SceneQueryRunner, SceneTimeRange, UrlSyncContextProvider, VizPanel } from '@grafana/scenes'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; import { DashboardMeta } from 'app/types'; @@ -236,9 +236,11 @@ function setup(meta?: DashboardMeta) { render( - - - + + + + + ); diff --git a/public/app/features/dashboard/containers/PublicDashboardPageProxy.test.tsx b/public/app/features/dashboard/containers/PublicDashboardPageProxy.test.tsx index 6da46204b9a..49193505061 100644 --- a/public/app/features/dashboard/containers/PublicDashboardPageProxy.test.tsx +++ b/public/app/features/dashboard/containers/PublicDashboardPageProxy.test.tsx @@ -4,7 +4,7 @@ import { Router } from 'react-router-dom'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; -import { config, locationService } from '@grafana/runtime'; +import { LocationServiceProvider, config, locationService } from '@grafana/runtime'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; import { backendSrv } from 'app/core/services/backend_srv'; import { configureStore } from 'app/store/configureStore'; @@ -32,16 +32,18 @@ function setup(props: Partial) { return render( - - null, path: '/:accessToken' }} - match={{ params: { accessToken: 'an-access-token' }, isExact: true, path: '/', url: '/' }} - {...props} - /> - + + + null, path: '/:accessToken' }} + match={{ params: { accessToken: 'an-access-token' }, isExact: true, path: '/', url: '/' }} + {...props} + /> + + ); From df9925f3bb9800311a01e9ff416bde3441f94250 Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Thu, 26 Sep 2024 08:41:47 -0400 Subject: [PATCH 076/102] Live: Clean up redis after test run (#93806) --- .../live/managedstream/cache_redis_test.go | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/pkg/services/live/managedstream/cache_redis_test.go b/pkg/services/live/managedstream/cache_redis_test.go index bbeececeb07..807a1b890b2 100644 --- a/pkg/services/live/managedstream/cache_redis_test.go +++ b/pkg/services/live/managedstream/cache_redis_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/go-redis/redis/v8" + "github.com/google/uuid" "github.com/stretchr/testify/require" ) @@ -31,7 +32,11 @@ func TestIntegrationRedisCacheStorage(t *testing.T) { Addr: addr, DB: db, }) - c := NewRedisFrameCache(redisClient, "A") + prefix := uuid.New().String() + + t.Cleanup(redisCleanup(t, redisClient, prefix)) + + c := NewRedisFrameCache(redisClient, prefix) require.NotNil(t, c) testFrameCache(t, c) @@ -43,7 +48,20 @@ func TestIntegrationRedisCacheStorage(t *testing.T) { require.NotZero(t, len(keys)) for _, key := range keys { - require.Equal(t, "A", key[:1]) - require.True(t, strings.HasPrefix(key, "A"), "key", key) + require.True(t, strings.HasPrefix(key, prefix)) + } +} + +func redisCleanup(t *testing.T, redisClient *redis.Client, prefix string) func() { + return func() { + keys, err := redisClient.Keys(redisClient.Context(), prefix+"*").Result() + if err != nil { + require.NoError(t, err) + } + + for _, key := range keys { + _, err := redisClient.Del(redisClient.Context(), key).Result() + require.NoError(t, err) + } } } From e2816ee51a1497f1cced62584a026fc8121b59a0 Mon Sep 17 00:00:00 2001 From: linoman <2051016+linoman@users.noreply.github.com> Date: Thu, 26 Sep 2024 14:51:38 +0200 Subject: [PATCH 077/102] RolePickerDrawer: Add feature toggle (#93752) Add feature toggle --- .../configure-grafana/feature-toggles/index.md | 1 + .../grafana-data/src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 6 ++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++++ pkg/services/featuremgmt/toggles_gen.json | 12 ++++++++++++ 6 files changed, 25 insertions(+) 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 03e430aa0d1..39ab938fb28 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -203,6 +203,7 @@ Experimental features might be changed or removed without prior notice. | `homeSetupGuide` | Used in Home for users who want to return to the onboarding flow or quickly find popular config pages | | `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time | | `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions | +| `rolePickerDrawer` | Enables the new role picker drawer design | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f1a8b19a821..7104c77c50e 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -215,4 +215,5 @@ export interface FeatureToggles { alertingQueryAndExpressionsStepMode?: boolean; improvedExternalSessionHandling?: boolean; useSessionStorageForRedirection?: boolean; + rolePickerDrawer?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 733cf634261..c87b872e64f 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1480,6 +1480,12 @@ var ( Stage: FeatureStagePublicPreview, Owner: identityAccessTeam, }, + { + Name: "rolePickerDrawer", + Description: "Enables the new role picker drawer design", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 2ab59f20b61..ae8b748ca22 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -196,3 +196,4 @@ groupAttributeSync,experimental,@grafana/identity-access-team,false,false,false alertingQueryAndExpressionsStepMode,experimental,@grafana/alerting-squad,false,false,true improvedExternalSessionHandling,experimental,@grafana/identity-access-team,false,false,false useSessionStorageForRedirection,preview,@grafana/identity-access-team,false,false,false +rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 09aafaa2a5e..2be496880cb 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -794,4 +794,8 @@ const ( // FlagUseSessionStorageForRedirection // Use session storage for handling the redirection after login FlagUseSessionStorageForRedirection = "useSessionStorageForRedirection" + + // FlagRolePickerDrawer + // Enables the new role picker drawer design + FlagRolePickerDrawer = "rolePickerDrawer" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index b2bf3a33aef..24e8ef6522f 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2657,6 +2657,18 @@ "requiresRestart": true } }, + { + "metadata": { + "name": "rolePickerDrawer", + "resourceVersion": "1727337187819", + "creationTimestamp": "2024-09-26T07:53:07Z" + }, + "spec": { + "description": "Enables the new role picker drawer design", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team" + } + }, { "metadata": { "name": "scenes", From 65a12e46a6c990b7cbdfb149931f43920d50f370 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 26 Sep 2024 12:32:34 +0000 Subject: [PATCH 078/102] Update dependency @grafana/faro-core to v1.10.1 --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5fddfdcdd09..9305581ceff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3744,12 +3744,12 @@ __metadata: linkType: hard "@grafana/faro-core@npm:^1.10.0, @grafana/faro-core@npm:^1.3.6": - version: 1.10.0 - resolution: "@grafana/faro-core@npm:1.10.0" + version: 1.10.1 + resolution: "@grafana/faro-core@npm:1.10.1" dependencies: "@opentelemetry/api": "npm:^1.9.0" "@opentelemetry/otlp-transformer": "npm:^0.53.0" - checksum: 10/538198a8fafc2532e300233986cadf8b1bfb23e7b261cf9fa82a64f082ddc8996ae1ca8ee6aaf6421898e2e2d75261d340694bd011c58169582638a0d5b9383f + checksum: 10/074397a6d886fd999d9aead5dc4c72ecec6e0aa995704582760b43a0cf8d89014769a7c432488549e3e8ae54151ade2c110e9cefabe0ec25c04c57764f374daa languageName: node linkType: hard From dbd8b082a623e7ec75aea5e79f5e8850c2c8d679 Mon Sep 17 00:00:00 2001 From: "grafana-delivery-bot[bot]" <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Thu, 26 Sep 2024 13:54:38 +0100 Subject: [PATCH 079/102] Release: update changelog for 10.3.10 (#93802) Update changelog Co-authored-by: github-actions[bot] --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a46622e06..96ba9813e22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ + + +# 10.3.10 (2024-09-26) + + # 11.2.0 (2024-08-27) From a68993825ef75cc51f8e019c5ddd833d22ad2b4f Mon Sep 17 00:00:00 2001 From: "grafana-delivery-bot[bot]" <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Thu, 26 Sep 2024 14:06:12 +0100 Subject: [PATCH 080/102] Release: update changelog for 10.4.9 (#93808) Update changelog Co-authored-by: github-actions[bot] Co-authored-by: Sergej-Vlasov --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ba9813e22..6b91b9ab6ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ + + +# 10.4.9 (2024-09-26) + +### Features and enhancements + +- **Chore:** Update swagger ui (4.3.0 to 5.17.14) [#92344](https://github.com/grafana/grafana/pull/92344), [@ryantxu](https://github.com/ryantxu) + +### Bug fixes + +- **Provisioning:** Prevent provisioning folder errors from failing startup [#92591](https://github.com/grafana/grafana/pull/92591), [@suntala](https://github.com/suntala) + + # 10.3.10 (2024-09-26) From b80bba387f3f9c0c3949d7151b071c09f4be5676 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 26 Sep 2024 12:54:41 +0000 Subject: [PATCH 081/102] Update dependency @grafana/faro-web-sdk to v1.10.1 --- yarn.lock | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index 9305581ceff..17567be387a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3743,7 +3743,7 @@ __metadata: languageName: node linkType: hard -"@grafana/faro-core@npm:^1.10.0, @grafana/faro-core@npm:^1.3.6": +"@grafana/faro-core@npm:^1.10.0, @grafana/faro-core@npm:^1.10.1, @grafana/faro-core@npm:^1.3.6": version: 1.10.1 resolution: "@grafana/faro-core@npm:1.10.1" dependencies: @@ -3753,7 +3753,7 @@ __metadata: languageName: node linkType: hard -"@grafana/faro-web-sdk@npm:1.10.0, @grafana/faro-web-sdk@npm:^1.10.0, @grafana/faro-web-sdk@npm:^1.3.6": +"@grafana/faro-web-sdk@npm:1.10.0": version: 1.10.0 resolution: "@grafana/faro-web-sdk@npm:1.10.0" dependencies: @@ -3764,6 +3764,17 @@ __metadata: languageName: node linkType: hard +"@grafana/faro-web-sdk@npm:^1.10.0, @grafana/faro-web-sdk@npm:^1.3.6": + version: 1.10.1 + resolution: "@grafana/faro-web-sdk@npm:1.10.1" + dependencies: + "@grafana/faro-core": "npm:^1.10.1" + ua-parser-js: "npm:^1.0.32" + web-vitals: "npm:^4.0.1" + checksum: 10/e9b45708e43e640f8dfc3789b89ad067a89a40d37168531a5ce85670d5d2b85c1cb9d63d1953cf5179a7bc2f5cd62e76a4217bc9f2023ef51639787338980d95 + languageName: node + linkType: hard + "@grafana/faro-web-tracing@npm:^1.8.2": version: 1.10.0 resolution: "@grafana/faro-web-tracing@npm:1.10.0" From afe464bc209fa73227311e1039543f6d8771ab02 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 26 Sep 2024 16:26:45 +0300 Subject: [PATCH 082/102] K8s/Storage: add a reusable generic storage builder (#93778) --- pkg/apiserver/registry/generic/storage.go | 31 +++++++++++ .../notifications/receiver/storage.go | 18 ++----- .../notifications/template_group/storage.go | 20 ++----- .../notifications/timeinterval/storage.go | 20 ++----- pkg/registry/apis/dashboard/legacy_storage.go | 29 ++-------- pkg/registry/apis/dashboard/register.go | 7 +-- pkg/registry/apis/dashboard/storage.go | 35 ------------ pkg/registry/apis/folders/register.go | 3 +- pkg/registry/apis/folders/storage.go | 40 -------------- pkg/registry/apis/peakq/register.go | 8 +-- pkg/registry/apis/peakq/storage.go | 41 -------------- pkg/registry/apis/playlist/storage.go | 54 ++++++------------- pkg/registry/apis/service/register.go | 8 +-- pkg/registry/apis/service/storage.go | 41 -------------- 14 files changed, 73 insertions(+), 282 deletions(-) create mode 100644 pkg/apiserver/registry/generic/storage.go delete mode 100644 pkg/registry/apis/dashboard/storage.go delete mode 100644 pkg/registry/apis/folders/storage.go delete mode 100644 pkg/registry/apis/peakq/storage.go delete mode 100644 pkg/registry/apis/service/storage.go diff --git a/pkg/apiserver/registry/generic/storage.go b/pkg/apiserver/registry/generic/storage.go new file mode 100644 index 00000000000..945d51fbd58 --- /dev/null +++ b/pkg/apiserver/registry/generic/storage.go @@ -0,0 +1,31 @@ +package generic + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/generic/registry" + + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter) (*registry.Store, error) { + strategy := NewStrategy(scheme, resourceInfo.GroupVersion()) + store := ®istry.Store{ + NewFunc: resourceInfo.NewFunc, + NewListFunc: resourceInfo.NewListFunc, + KeyRootFunc: KeyRootFunc(resourceInfo.GroupResource()), + KeyFunc: NamespaceKeyFunc(resourceInfo.GroupResource()), + PredicateFunc: Matcher, + DefaultQualifiedResource: resourceInfo.GroupResource(), + SingularQualifiedResource: resourceInfo.SingularGroupResource(), + TableConvertor: resourceInfo.TableConverter(), + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + } + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs} + if err := store.CompleteWithOptions(options); err != nil { + return nil, err + } + return store, nil +} diff --git a/pkg/registry/apis/alerting/notifications/receiver/storage.go b/pkg/registry/apis/alerting/notifications/receiver/storage.go index 5a2f5804e9b..36b8932604a 100644 --- a/pkg/registry/apis/alerting/notifications/receiver/storage.go +++ b/pkg/registry/apis/alerting/notifications/receiver/storage.go @@ -43,23 +43,11 @@ func NewStorage( metadata: metadata, } if optsGetter != nil && dualWriteBuilder != nil { - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - s := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - PredicateFunc: Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - TableConvertor: legacyStore.tableConverter, - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} - if err := s.CompleteWithOptions(options); err != nil { + store, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) + if err != nil { return nil, err } - return dualWriteBuilder(resourceInfo.GroupResource(), legacyStore, storage{Store: s}) + return dualWriteBuilder(resourceInfo.GroupResource(), legacyStore, store) } return legacyStore, nil } diff --git a/pkg/registry/apis/alerting/notifications/template_group/storage.go b/pkg/registry/apis/alerting/notifications/template_group/storage.go index e1f6a9f6886..49c14ea2863 100644 --- a/pkg/registry/apis/alerting/notifications/template_group/storage.go +++ b/pkg/registry/apis/alerting/notifications/template_group/storage.go @@ -41,25 +41,11 @@ func NewStorage( tableConverter: resourceInfo.TableConverter(), } if optsGetter != nil && dualWriteBuilder != nil { - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - s := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - TableConvertor: legacyStore.tableConverter, - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs} - if err := s.CompleteWithOptions(options); err != nil { + store, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) + if err != nil { return nil, err } - return dualWriteBuilder(resourceInfo.GroupResource(), legacyStore, storage{Store: s}) + return dualWriteBuilder(resourceInfo.GroupResource(), legacyStore, store) } return legacyStore, nil } diff --git a/pkg/registry/apis/alerting/notifications/timeinterval/storage.go b/pkg/registry/apis/alerting/notifications/timeinterval/storage.go index 3bf20b03ddd..63e1943faf8 100644 --- a/pkg/registry/apis/alerting/notifications/timeinterval/storage.go +++ b/pkg/registry/apis/alerting/notifications/timeinterval/storage.go @@ -41,25 +41,11 @@ func NewStorage( tableConverter: resourceInfo.TableConverter(), } if optsGetter != nil && dualWriteBuilder != nil { - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - s := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - TableConvertor: legacyStore.tableConverter, - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs} - if err := s.CompleteWithOptions(options); err != nil { + store, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) + if err != nil { return nil, err } - return dualWriteBuilder(resourceInfo.GroupResource(), legacyStore, storage{Store: s}) + return dualWriteBuilder(resourceInfo.GroupResource(), legacyStore, store) } return legacyStore, nil } diff --git a/pkg/registry/apis/dashboard/legacy_storage.go b/pkg/registry/apis/dashboard/legacy_storage.go index 6d3c29eb876..f8607e2a75c 100644 --- a/pkg/registry/apis/dashboard/legacy_storage.go +++ b/pkg/registry/apis/dashboard/legacy_storage.go @@ -1,16 +1,16 @@ package dashboard import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" + "github.com/grafana/grafana/pkg/apimachinery/utils" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/storage/unified/apistore" "github.com/grafana/grafana/pkg/storage/unified/resource" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/generic" - genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" - "k8s.io/apiserver/pkg/registry/rest" ) type dashboardStorage struct { @@ -46,24 +46,5 @@ func (s *dashboardStorage) newStore(scheme *runtime.Scheme, defaultOptsGetter ge defaultOpts.StorageConfig.Config, ) - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - store := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - TableConvertor: s.tableConverter, - } - - options := &generic.StoreOptions{RESTOptions: optsGetter} - if err := store.CompleteWithOptions(options); err != nil { - return nil, err - } - return store, err + return grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) } diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index 10a5826a392..ae57adfe147 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -140,15 +140,10 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver // Dual writes if a RESTOptionsGetter is provided if optsGetter != nil && dualWriteBuilder != nil { - store, err := newStorage(scheme) + store, err := grafanaregistry.NewRegistryStore(scheme, dash, optsGetter) if err != nil { return err } - - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} - if err := store.CompleteWithOptions(options); err != nil { - return err - } storage[dash.StoragePath()], err = dualWriteBuilder(dash.GroupResource(), legacyStore, store) if err != nil { return err diff --git a/pkg/registry/apis/dashboard/storage.go b/pkg/registry/apis/dashboard/storage.go deleted file mode 100644 index 91ae97525aa..00000000000 --- a/pkg/registry/apis/dashboard/storage.go +++ /dev/null @@ -1,35 +0,0 @@ -package dashboard - -import ( - dashboard "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" - grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" - "k8s.io/apimachinery/pkg/runtime" - genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" -) - -var _ grafanarest.Storage = (*storage)(nil) - -type storage struct { - *genericregistry.Store -} - -func newStorage(scheme *runtime.Scheme) (*storage, error) { - resourceInfo := dashboard.DashboardResourceInfo - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - store := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - TableConvertor: resourceInfo.TableConverter(), - } - - return &storage{Store: store}, nil -} diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index 60c6a278ece..618a4c509d8 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/builder" @@ -109,7 +110,7 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API // enable dual writer if optsGetter != nil && dualWriteBuilder != nil { - store, err := newStorage(scheme, optsGetter, legacyStore) + store, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) if err != nil { return err } diff --git a/pkg/registry/apis/folders/storage.go b/pkg/registry/apis/folders/storage.go deleted file mode 100644 index 21f98e93842..00000000000 --- a/pkg/registry/apis/folders/storage.go +++ /dev/null @@ -1,40 +0,0 @@ -package folders - -import ( - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/generic" - genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" - - grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" -) - -var _ grafanarest.Storage = (*storage)(nil) - -type storage struct { - *genericregistry.Store -} - -func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, legacy *legacyStorage) (*storage, error) { - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - - store := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - TableConvertor: legacy.tableConverter, - - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} - if err := store.CompleteWithOptions(options); err != nil { - return nil, err - } - return &storage{Store: store}, nil -} diff --git a/pkg/registry/apis/peakq/register.go b/pkg/registry/apis/peakq/register.go index 31f41d8c379..bb2d38c356d 100644 --- a/pkg/registry/apis/peakq/register.go +++ b/pkg/registry/apis/peakq/register.go @@ -1,6 +1,7 @@ package peakq import ( + "github.com/prometheus/client_golang/prometheus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -12,9 +13,8 @@ import ( "k8s.io/kube-openapi/pkg/spec3" "k8s.io/kube-openapi/pkg/validation/spec" - "github.com/prometheus/client_golang/prometheus" - peakq "github.com/grafana/grafana/pkg/apis/peakq/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -68,10 +68,12 @@ func (b *PeakQAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { func (b *PeakQAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, _ grafanarest.DualWriteBuilder) error { resourceInfo := peakq.QueryTemplateResourceInfo storage := map[string]rest.Storage{} - peakqStorage, err := newStorage(scheme, optsGetter) + + peakqStorage, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) if err != nil { return err } + storage[resourceInfo.StoragePath()] = peakqStorage storage[resourceInfo.StoragePath("render")] = &renderREST{ getter: peakqStorage, diff --git a/pkg/registry/apis/peakq/storage.go b/pkg/registry/apis/peakq/storage.go deleted file mode 100644 index 3953ba3e360..00000000000 --- a/pkg/registry/apis/peakq/storage.go +++ /dev/null @@ -1,41 +0,0 @@ -package peakq - -import ( - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/generic" - genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" - - peakq "github.com/grafana/grafana/pkg/apis/peakq/v0alpha1" - grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" -) - -var _ grafanarest.Storage = (*storage)(nil) - -type storage struct { - *genericregistry.Store -} - -func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*storage, error) { - resourceInfo := peakq.QueryTemplateResourceInfo - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - - store := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - TableConvertor: resourceInfo.TableConverter(), - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} - if err := store.CompleteWithOptions(options); err != nil { - return nil, err - } - return &storage{Store: store}, nil -} diff --git a/pkg/registry/apis/playlist/storage.go b/pkg/registry/apis/playlist/storage.go index ecf75910373..bd5a03b14bb 100644 --- a/pkg/registry/apis/playlist/storage.go +++ b/pkg/registry/apis/playlist/storage.go @@ -4,52 +4,28 @@ import ( "strings" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/registry/generic" genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" playlist "github.com/grafana/grafana/apps/playlist/apis/playlist/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" ) -var _ grafanarest.Storage = (*storage)(nil) - -type storage struct { - *genericregistry.Store -} - -func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, legacy *legacyStorage) (*storage, error) { - gr := schema.GroupResource{ - Group: playlist.PlaylistKind().Group(), - Resource: playlist.PlaylistKind().Plural(), - } - singularGR := schema.GroupResource{ - Group: playlist.PlaylistKind().Group(), - Resource: strings.ToLower(playlist.PlaylistKind().Kind()), - } - strategy := grafanaregistry.NewStrategy(scheme, gr.WithVersion(playlist.PlaylistKind().Version()).GroupVersion()) - store := &genericregistry.Store{ - NewFunc: func() runtime.Object { - return playlist.PlaylistKind().ZeroValue() +func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, legacy *legacyStorage) (*genericregistry.Store, error) { + kind := playlist.PlaylistKind() + singular := strings.ToLower(kind.Kind()) // ??? + resourceInfo := utils.NewResourceInfo( + kind.Group(), kind.Version(), + kind.GroupVersionResource().Resource, singular, + kind.Kind(), + func() runtime.Object { + return kind.ZeroValue() }, - NewListFunc: func() runtime.Object { - return playlist.PlaylistKind().ZeroListValue() + func() runtime.Object { + return kind.ZeroListValue() }, - KeyRootFunc: grafanaregistry.KeyRootFunc(gr), - KeyFunc: grafanaregistry.NamespaceKeyFunc(gr), - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: gr, - SingularQualifiedResource: singularGR, - TableConvertor: legacy.tableConverter, - - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} - if err := store.CompleteWithOptions(options); err != nil { - return nil, err - } - return &storage{Store: store}, nil + utils.TableColumns{}, // will use default columns... TODO? legacy.tableConverter., + ) + return grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) } diff --git a/pkg/registry/apis/service/register.go b/pkg/registry/apis/service/register.go index 17763fa64e9..39138660134 100644 --- a/pkg/registry/apis/service/register.go +++ b/pkg/registry/apis/service/register.go @@ -1,6 +1,7 @@ package service import ( + "github.com/prometheus/client_golang/prometheus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -10,9 +11,8 @@ import ( genericapiserver "k8s.io/apiserver/pkg/server" "k8s.io/kube-openapi/pkg/common" - "github.com/prometheus/client_golang/prometheus" - service "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -73,10 +73,12 @@ func (b *ServiceAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { func (b *ServiceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, _ grafanarest.DualWriteBuilder) error { resourceInfo := service.ExternalNameResourceInfo storage := map[string]rest.Storage{} - serviceStorage, err := newStorage(scheme, optsGetter) + + serviceStorage, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) if err != nil { return err } + storage[resourceInfo.StoragePath()] = serviceStorage apiGroupInfo.VersionedResourcesStorageMap[service.VERSION] = storage return nil diff --git a/pkg/registry/apis/service/storage.go b/pkg/registry/apis/service/storage.go deleted file mode 100644 index 2ed49bb53f6..00000000000 --- a/pkg/registry/apis/service/storage.go +++ /dev/null @@ -1,41 +0,0 @@ -package service - -import ( - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/generic" - genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" - - service "github.com/grafana/grafana/pkg/apis/service/v0alpha1" - grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" -) - -var _ grafanarest.Storage = (*storage)(nil) - -type storage struct { - *genericregistry.Store -} - -func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*storage, error) { - resourceInfo := service.ExternalNameResourceInfo - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - - store := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - TableConvertor: resourceInfo.TableConverter(), - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} - if err := store.CompleteWithOptions(options); err != nil { - return nil, err - } - return &storage{Store: store}, nil -} From 14abf2dab3010785afd0f38f501989e034adf1ef Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Thu, 26 Sep 2024 15:29:28 +0200 Subject: [PATCH 083/102] CloudMigrations: add Library Element as a valid migration resource type (#93781) --- pkg/services/cloudmigration/api/dtos.go | 7 ++++--- pkg/services/cloudmigration/gmsclient/dtos.go | 6 ------ pkg/services/cloudmigration/model.go | 7 ++++--- public/api-enterprise-spec.json | 3 ++- public/api-merged.json | 3 ++- public/app/features/migrate-to-cloud/api/endpoints.gen.ts | 2 +- public/app/features/migrate-to-cloud/onprem/NameCell.tsx | 2 ++ public/openapi3.json | 3 ++- 8 files changed, 17 insertions(+), 16 deletions(-) diff --git a/pkg/services/cloudmigration/api/dtos.go b/pkg/services/cloudmigration/api/dtos.go index 0d6dae53306..270eab0952f 100644 --- a/pkg/services/cloudmigration/api/dtos.go +++ b/pkg/services/cloudmigration/api/dtos.go @@ -119,9 +119,10 @@ type MigrateDataResponseItemDTO struct { type MigrateDataType string const ( - DashboardDataType MigrateDataType = "DASHBOARD" - DatasourceDataType MigrateDataType = "DATASOURCE" - FolderDataType MigrateDataType = "FOLDER" + DashboardDataType MigrateDataType = "DASHBOARD" + DatasourceDataType MigrateDataType = "DATASOURCE" + FolderDataType MigrateDataType = "FOLDER" + LibraryElementDataType MigrateDataType = "LIBRARY_ELEMENT" ) // swagger:enum ItemStatus diff --git a/pkg/services/cloudmigration/gmsclient/dtos.go b/pkg/services/cloudmigration/gmsclient/dtos.go index 91c305ce7c3..1cb7a24d2c1 100644 --- a/pkg/services/cloudmigration/gmsclient/dtos.go +++ b/pkg/services/cloudmigration/gmsclient/dtos.go @@ -4,12 +4,6 @@ import "time" type MigrateDataType string -const ( - DashboardDataType MigrateDataType = "DASHBOARD" - DatasourceDataType MigrateDataType = "DATASOURCE" - FolderDataType MigrateDataType = "FOLDER" -) - type MigrateDataRequestDTO struct { Items []MigrateDataRequestItemDTO `json:"items"` } diff --git a/pkg/services/cloudmigration/model.go b/pkg/services/cloudmigration/model.go index 0ea1b8b7525..ac899a434e2 100644 --- a/pkg/services/cloudmigration/model.go +++ b/pkg/services/cloudmigration/model.go @@ -79,9 +79,10 @@ type CloudMigrationResource struct { type MigrateDataType string const ( - DashboardDataType MigrateDataType = "DASHBOARD" - DatasourceDataType MigrateDataType = "DATASOURCE" - FolderDataType MigrateDataType = "FOLDER" + DashboardDataType MigrateDataType = "DASHBOARD" + DatasourceDataType MigrateDataType = "DATASOURCE" + FolderDataType MigrateDataType = "FOLDER" + LibraryElementDataType MigrateDataType = "LIBRARY_ELEMENT" ) type ItemStatus string diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index cfcba3e9b58..ac04c23d278 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -5434,7 +5434,8 @@ "enum": [ "DASHBOARD", "DATASOURCE", - "FOLDER" + "FOLDER", + "LIBRARY_ELEMENT" ] } } diff --git a/public/api-merged.json b/public/api-merged.json index 203197677a4..b3664b756c6 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -16886,7 +16886,8 @@ "enum": [ "DASHBOARD", "DATASOURCE", - "FOLDER" + "FOLDER", + "LIBRARY_ELEMENT" ] } } diff --git a/public/app/features/migrate-to-cloud/api/endpoints.gen.ts b/public/app/features/migrate-to-cloud/api/endpoints.gen.ts index c5eebb2a30a..447ba395491 100644 --- a/public/app/features/migrate-to-cloud/api/endpoints.gen.ts +++ b/public/app/features/migrate-to-cloud/api/endpoints.gen.ts @@ -157,7 +157,7 @@ export type MigrateDataResponseItemDto = { message?: string; refId: string; status: 'OK' | 'WARNING' | 'ERROR' | 'PENDING' | 'UNKNOWN'; - type: 'DASHBOARD' | 'DATASOURCE' | 'FOLDER'; + type: 'DASHBOARD' | 'DATASOURCE' | 'FOLDER' | 'LIBRARY_ELEMENT'; }; export type SnapshotResourceStats = { statuses?: { diff --git a/public/app/features/migrate-to-cloud/onprem/NameCell.tsx b/public/app/features/migrate-to-cloud/onprem/NameCell.tsx index b69d622dcd1..b95c7403267 100644 --- a/public/app/features/migrate-to-cloud/onprem/NameCell.tsx +++ b/public/app/features/migrate-to-cloud/onprem/NameCell.tsx @@ -35,6 +35,8 @@ function ResourceInfo({ data }: { data: ResourceTableItem }) { return ; case 'FOLDER': return ; + case 'LIBRARY_ELEMENT': + return null; } } diff --git a/public/openapi3.json b/public/openapi3.json index 4a6e0fa3ad4..df22bf2188d 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -7112,7 +7112,8 @@ "enum": [ "DASHBOARD", "DATASOURCE", - "FOLDER" + "FOLDER", + "LIBRARY_ELEMENT" ], "type": "string" } From eee53810c0cbd6673c0476f90a3e2be9135ef89f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 26 Sep 2024 13:14:19 +0000 Subject: [PATCH 084/102] Update dependency @grafana/faro-web-sdk to v1.10.1 --- packages/grafana-prometheus/package.json | 2 +- yarn.lock | 17 +++-------------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 56172771b60..e2f45b33fb5 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -40,7 +40,7 @@ "@floating-ui/react": "0.26.24", "@grafana/data": "11.3.0-pre", "@grafana/experimental": "1.8.0", - "@grafana/faro-web-sdk": "1.10.0", + "@grafana/faro-web-sdk": "1.10.1", "@grafana/runtime": "11.3.0-pre", "@grafana/schema": "11.3.0-pre", "@grafana/ui": "11.3.0-pre", diff --git a/yarn.lock b/yarn.lock index 17567be387a..807a95b11cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3743,7 +3743,7 @@ __metadata: languageName: node linkType: hard -"@grafana/faro-core@npm:^1.10.0, @grafana/faro-core@npm:^1.10.1, @grafana/faro-core@npm:^1.3.6": +"@grafana/faro-core@npm:^1.10.1, @grafana/faro-core@npm:^1.3.6": version: 1.10.1 resolution: "@grafana/faro-core@npm:1.10.1" dependencies: @@ -3753,18 +3753,7 @@ __metadata: languageName: node linkType: hard -"@grafana/faro-web-sdk@npm:1.10.0": - version: 1.10.0 - resolution: "@grafana/faro-web-sdk@npm:1.10.0" - dependencies: - "@grafana/faro-core": "npm:^1.10.0" - ua-parser-js: "npm:^1.0.32" - web-vitals: "npm:^4.0.1" - checksum: 10/01f2cd2a1d3e9f0f3ede733a9a354643f31b2600ec62bd41c64caf256846fc8ae1a857ea3952a641df4e74971786cc605293248bd3bd8bff1debace369069e1e - languageName: node - linkType: hard - -"@grafana/faro-web-sdk@npm:^1.10.0, @grafana/faro-web-sdk@npm:^1.3.6": +"@grafana/faro-web-sdk@npm:1.10.1, @grafana/faro-web-sdk@npm:^1.10.0, @grafana/faro-web-sdk@npm:^1.3.6": version: 1.10.1 resolution: "@grafana/faro-web-sdk@npm:1.10.1" dependencies: @@ -3961,7 +3950,7 @@ __metadata: "@grafana/data": "npm:11.3.0-pre" "@grafana/e2e-selectors": "npm:11.3.0-pre" "@grafana/experimental": "npm:1.8.0" - "@grafana/faro-web-sdk": "npm:1.10.0" + "@grafana/faro-web-sdk": "npm:1.10.1" "@grafana/runtime": "npm:11.3.0-pre" "@grafana/schema": "npm:11.3.0-pre" "@grafana/tsconfig": "npm:^2.0.0" From 570c5934d393aa2059e9be2a0cce6b65370e588a Mon Sep 17 00:00:00 2001 From: "grafana-delivery-bot[bot]" <132647405+grafana-delivery-bot[bot]@users.noreply.github.com> Date: Thu, 26 Sep 2024 14:33:10 +0100 Subject: [PATCH 085/102] Release: update changelog for 11.0.5 (#93816) Update changelog Co-authored-by: github-actions[bot] --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b91b9ab6ed..d54798d9bab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ + + +# 11.0.5 (2024-09-26) + +### Features and enhancements + +- **Chore:** Update swagger ui (4.3.0 to 5.17.14) [#92345](https://github.com/grafana/grafana/pull/92345), [@ryantxu](https://github.com/ryantxu) + +### Bug fixes + +- **Provisioning:** Prevent provisioning folder errors from failing startup [#92588](https://github.com/grafana/grafana/pull/92588), [@suntala](https://github.com/suntala) +- **TutorialCard:** Fix link to tutorial not opening [#92645](https://github.com/grafana/grafana/pull/92645), [@eledobleefe](https://github.com/eledobleefe) + + # 10.4.9 (2024-09-26) From 7aaa4b241ee50baa53f854e46b63600fac48c70a Mon Sep 17 00:00:00 2001 From: Brendan O'Handley Date: Thu, 26 Sep 2024 08:34:28 -0500 Subject: [PATCH 086/102] Explore metrics: Fix warning message for limiting metrics (#93817) don't show undefined error --- public/app/features/trails/MetricSelect/MetricSelectScene.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx index f6a5001689e..1a28aac9146 100644 --- a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx +++ b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx @@ -287,8 +287,7 @@ export class MetricSelectScene extends SceneObjectBase i } if (missingOtelTargets) { - metricNamesWarning += - 'The list of metrics is not complete. Select more OTel resource attributes to see a full list of metrics.'; + metricNamesWarning = `${metricNamesWarning ?? ''} The list of metrics is not complete. Select more OTel resource attributes to see a full list of metrics.`; } let bodyLayout = this.state.body; From 4b1d8eeef04b252fef27f53ffa03abfa62d88c28 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Thu, 26 Sep 2024 15:40:04 +0200 Subject: [PATCH 087/102] Alerting: Remove filter for state without reason (#93604) --- .../CentralHistoryRuntimeDataSource.ts | 10 +- .../EventListSceneObject.tsx | 73 +- .../__fixtures__/alert-state-history.ts | 896 ++++++++++++++++++ .../useRuleHistoryRecords.test.ts.snap | 822 ++++++++++++++++ .../__snapshots__/utils.test.ts.snap | 71 ++ .../useRuleHistoryRecords.test.ts | 20 + .../useRuleHistoryRecords.ts | 65 ++ .../rules/central-state-history/utils.test.ts | 12 + .../rules/central-state-history/utils.ts | 64 +- 9 files changed, 1937 insertions(+), 96 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rules/central-state-history/__fixtures__/alert-state-history.ts create mode 100644 public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/useRuleHistoryRecords.test.ts.snap create mode 100644 public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/utils.test.ts.snap create mode 100644 public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.test.ts create mode 100644 public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.ts create mode 100644 public/app/features/alerting/unified/components/rules/central-state-history/utils.test.ts diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts b/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts index 287eceabbf1..0f4eedfef85 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts +++ b/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts @@ -9,7 +9,7 @@ import { stateHistoryApi } from '../../../api/stateHistoryApi'; import { DataSourceInformation } from '../../../home/Insights'; import { LIMIT_EVENTS } from './EventListSceneObject'; -import { historyResultToDataFrame } from './utils'; +import { getStateFilterFromInQueryParams, getStateFilterToInQueryParams, historyResultToDataFrame } from './utils'; const historyDataSourceUid = '__history_api_ds_uid__'; const historyDataSourcePluginId = '__history_api_ds_pluginId__'; @@ -47,8 +47,14 @@ class HistoryAPIDatasource extends RuntimeDataSource { const from = request.range.from.unix(); const to = request.range.to.unix(); + // Get the labels and states filters from the URL + const stateTo = getStateFilterToInQueryParams(); + const stateFrom = getStateFilterFromInQueryParams(); + + const historyResult = await getHistory(from, to); + return { - data: historyResultToDataFrame(await getHistory(from, to)), + data: historyResultToDataFrame(historyResult, { stateTo, stateFrom }), }; } diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx index 0ba6f9d4e7a..0c57ef96c0a 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx @@ -1,9 +1,9 @@ import { css, cx } from '@emotion/css'; -import { ReactElement, useMemo, useState } from 'react'; +import { ReactElement, useState } from 'react'; import { useLocation } from 'react-router'; import { useMeasure } from 'react-use'; -import { DataFrameJSON, GrafanaTheme2, IconName, TimeRange } from '@grafana/data'; +import { GrafanaTheme2, IconName, TimeRange } from '@grafana/data'; import { CustomVariable, SceneComponentProps, @@ -25,18 +25,17 @@ import { import { trackUseCentralHistoryFilterByClicking, trackUseCentralHistoryMaxEventsReached } from '../../../Analytics'; import { stateHistoryApi } from '../../../api/stateHistoryApi'; import { usePagination } from '../../../hooks/usePagination'; -import { combineMatcherStrings, labelsMatchMatchers } from '../../../utils/alertmanager'; +import { combineMatcherStrings } from '../../../utils/alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; -import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; import { createRelativeUrl } from '../../../utils/url'; import { AlertLabels } from '../../AlertLabels'; import { CollapseToggle } from '../../CollapseToggle'; import { LogRecord } from '../state-history/common'; -import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords'; -import { LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO, StateFilterValues } from './CentralAlertHistoryScene'; +import { LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO } from './CentralAlertHistoryScene'; import { EventDetails } from './EventDetails'; import { HistoryErrorMessage } from './HistoryErrorMessage'; +import { useRuleHistoryRecords } from './useRuleHistoryRecords'; export const LIMIT_EVENTS = 5000; // limit is hard-capped at 5000 at the BE level. const PAGE_SIZE = 100; @@ -75,12 +74,11 @@ export const HistoryEventsList = ({ limit: LIMIT_EVENTS, }); - const { historyRecords: historyRecordsNotSorted } = useRuleHistoryRecords( - valueInLabelFilter.toString(), - valueInStateToFilter.toString(), - valueInStateFromFilter.toString(), - stateHistory - ); + const { historyRecords: historyRecordsNotSorted } = useRuleHistoryRecords(stateHistory, { + labels: valueInLabelFilter.toString(), + stateFrom: valueInStateFromFilter.toString(), + stateTo: valueInStateToFilter.toString(), + }); const historyRecords = historyRecordsNotSorted.sort((a, b) => b.timestamp - a.timestamp); @@ -542,54 +540,3 @@ export function HistoryEventsListObjectRenderer({ model }: SceneComponentProps ); } -/** - * This hook filters the history records based on the label, stateTo and stateFrom filters. - * @param filterInLabel - * @param filterInStateTo - * @param filterInStateFrom - * @param stateHistory the original history records - * @returns the filtered history records - */ -function useRuleHistoryRecords( - filterInLabel: string, - filterInStateTo: string, - filterInStateFrom: string, - stateHistory?: DataFrameJSON -) { - return useMemo(() => { - if (!stateHistory?.data) { - return { historyRecords: [] }; - } - - const filterMatchers = filterInLabel ? parsePromQLStyleMatcherLooseSafe(filterInLabel) : []; - - const [tsValues, lines] = stateHistory.data.values; - const timestamps = isNumbers(tsValues) ? tsValues : []; - - // merge timestamp with "line" - const logRecords = timestamps.reduce((acc: LogRecord[], timestamp: number, index: number) => { - const line = lines[index]; - if (!isLine(line)) { - return acc; - } - // values property can be undefined for some instance states (e.g. NoData) - const filterMatch = line.labels && labelsMatchMatchers(line.labels, filterMatchers); - if (!isGrafanaAlertState(line.current) || !isGrafanaAlertState(line.previous)) { - return acc; - } - const baseStateTo = mapStateWithReasonToBaseState(line.current); - const baseStateFrom = mapStateWithReasonToBaseState(line.previous); - const stateToMatch = filterInStateTo !== StateFilterValues.all ? filterInStateTo === baseStateTo : true; - const stateFromMatch = filterInStateFrom !== StateFilterValues.all ? filterInStateFrom === baseStateFrom : true; - if (filterMatch && stateToMatch && stateFromMatch) { - acc.push({ timestamp, line }); - } - - return acc; - }, []); - - return { - historyRecords: logRecords, - }; - }, [stateHistory, filterInLabel, filterInStateTo, filterInStateFrom]); -} diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/__fixtures__/alert-state-history.ts b/public/app/features/alerting/unified/components/rules/central-state-history/__fixtures__/alert-state-history.ts new file mode 100644 index 00000000000..8debd4d8498 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/__fixtures__/alert-state-history.ts @@ -0,0 +1,896 @@ +import { DataFrameJSON } from '@grafana/data'; + +const data: DataFrameJSON = { + data: { + values: [ + [ + 1727189670000, 1727189670000, 1727189670000, 1727189670000, 1727189670000, 1727189670000, 1727189670000, + 1727189670000, 1727189670000, 1727189670000, 1727189680000, 1727189700000, 1727189690000, 1727189690000, + 1727189690000, 1727189690000, 1727189690000, 1727189690000, 1727189690000, 1727189690000, 1727189680000, + 1727189670000, 1727189700000, 1727189710000, 1727189710000, 1727189710000, 1727189710000, 1727189710000, + 1727189710000, 1727189710000, 1727189710000, 1727189710000, + ], + [ + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'C', + dashboardUID: '-W9yw_X7k', + panelID: 3, + fingerprint: '009794b4d3074732', + ruleTitle: 'CPU Usage 6', + ruleID: 10605, + ruleUID: '-rRg_HcVz', + labels: { + alertname: 'CPU Usage 6', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Demonstrations', + ref_id: 'A', + team: 'operations', + type: 'test', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'C', + dashboardUID: 'stzZvVw7k', + panelID: 8, + fingerprint: '186e7d0c9173ddf4', + ruleTitle: 'CPU Usage 4', + ruleID: 150, + ruleUID: 's984p7n4k', + labels: { + alertname: 'CPU Usage 4', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Demonstrations', + ref_id: 'A', + region: 'EMEA', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Alerting', + current: 'Normal', + values: { + B: -6.886811320670644, + C: 0, + }, + condition: 'C', + dashboardUID: '', + panelID: 0, + fingerprint: 'bcf38efbb616d8d2', + ruleTitle: 'direct to contact point', + ruleID: 10656, + ruleUID: 'ddc5zx4l3zls0a', + labels: { + alertname: 'direct to contact point', + grafana_folder: 'Testing and reproducing', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'D', + dashboardUID: 'iYSJkozVk', + panelID: 13, + fingerprint: 'd3f3ec7ad90a37fd', + ruleTitle: 'With Disabled Query', + ruleID: 10631, + ruleUID: 'ab2ac850-1669-4dac-b566-c91728425f39', + labels: { + alertname: 'With Disabled Query', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Testing and reproducing', + ref_id: 'A,B', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'C', + dashboardUID: '', + panelID: 0, + fingerprint: 'f4c073dd4c60e897', + ruleTitle: 'Alert State History Random', + ruleID: 10614, + ruleUID: 'e4d7252b-c66f-4f5c-b7db-28b9936150bd', + labels: { + alertname: 'Alert State History Random', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Testing and reproducing', + ref_id: 'A', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + B: 54.137611900622574, + C: 1, + }, + condition: 'C', + dashboardUID: '', + panelID: 0, + fingerprint: 'e11b0ff29886e8f2', + ruleTitle: 'node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate4', + ruleID: 10662, + ruleUID: 'ddgfq7c57fqpsc', + labels: { + alertname: 'node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate4', + grafana_folder: 'Testing and reproducing', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'B', + dashboardUID: '', + panelID: 0, + fingerprint: 'd6d7e256298c8ea8', + ruleTitle: 'delete-ds', + ruleID: 144, + ruleUID: 'wXS0SnWVz', + labels: { + alertname: 'delete-ds', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Testing and reproducing', + ref_id: 'A', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'C', + dashboardUID: '', + panelID: 0, + fingerprint: 'bf1b309577b4fc0c', + ruleTitle: 'Threshold Test', + ruleID: 148, + ruleUID: 'u0QVOqMVz', + labels: { + alertname: 'Threshold Test', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Testing and reproducing', + ref_id: 'A', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'C', + dashboardUID: '', + panelID: 0, + fingerprint: 'd6b1cde6ab63bfca', + ruleTitle: 'Alert History fingerprint', + ruleID: 10619, + ruleUID: 'c7a7989f-f12c-4aa5-8fb3-27f4509a5809', + labels: { + alertname: 'Alert History fingerprint', + datasource_uid: 'PDDA8E780A17E7EF1', + grafana_folder: 'Testing and reproducing', + ref_id: 'A', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + B: 0.0020875, + C: 1, + }, + condition: 'C', + dashboardUID: 'd4c61dfe-24d8-433e-984e-701a2f14faab', + panelID: 1, + fingerprint: '40ddb0f6e97426e9', + ruleTitle: 'UniFi CPU Usage', + ruleID: 10638, + ruleUID: 'd10fc550-3552-4083-a3aa-f6b68058bc7f', + labels: { + alertname: 'UniFi CPU Usage', + grafana_folder: 'UniFi', + instance: 'unpoller:9130', + job: 'unifipoller', + name: 'Dream Router', + site_name: 'Default (default)', + source: 'https://192.168.1.1', + type: 'udm', + }, + }, + { + schemaVersion: 1, + previous: 'Pending', + current: 'Alerting', + values: { + B0: 36.956598435022215, + }, + condition: 'B', + dashboardUID: '', + panelID: 0, + fingerprint: '10716534bbf8086b', + ruleTitle: 'XSS attack vector', + ruleID: 37, + ruleUID: 'p8TBxnq7k', + labels: { + alertname: 'XSS attack vector', + grafana_folder: 'gdev dashboards', + }, + }, + { + schemaVersion: 1, + previous: 'NoData', + current: 'Normal (MissingSeries)', + values: {}, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '4a5519a42c382438', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Demonstrations', + ref_id: 'A', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.067575, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: 'cc4aceb3018da2e8', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '7', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.06216900000000001, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '20dfd648881e698f', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '2', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.07027800000000001, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '234db8cd1e160f6a', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '1', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.06727466666666668, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '4e1f85b7efc3ed75', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '0', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.05315900000000001, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '50bf9f17b9534593', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '6', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.046551666666666686, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '2fb0af95df9b2bfe', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '5', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.05466066666666668, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: 'd0303e26f6c376e9', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '4', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.059165666666666665, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: 'af82d1220e70c3b4', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '3', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Error', + current: 'Normal (MissingSeries)', + values: {}, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '4a5519a42c382438', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Demonstrations', + ref_id: 'A', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '4a5519a42c382438', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Demonstrations', + ref_id: 'A', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + C0: 9.319781010143206, + }, + condition: 'C', + dashboardUID: '', + panelID: 0, + fingerprint: 'abeb2444057d7f01', + ruleTitle: 'test-read-only-threshold-indicators', + ruleID: 10606, + ruleUID: 'Wq5jJlc4k', + labels: { + alertname: 'test-read-only-threshold-indicators', + grafana_folder: 'Testing and reproducing', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.07305751032920166, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: '2e7f5e6f5a5f2902', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '6', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.06820108623217379, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: '1c1b21f738468ea5', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '5', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.07685819005731041, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: '23ee5f02850e38f8', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '4', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.08614874050379848, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: '2a810fa152d6407b', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '3', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.0926943555910969, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: '48d127e8826c6fb6', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '2', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.1097974143675863, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: 'c9582e3d28402fe9', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '1', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.12985655737704918, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: 'fb3e32509acec7ec', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '0', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + B0: 8.406488484275167, + }, + condition: 'B', + dashboardUID: '', + panelID: 0, + fingerprint: '91a8126f41689d98', + ruleTitle: 'Random Values', + ruleID: 146, + ruleUID: 'I1M_iCWVk', + labels: { + alertname: 'Random Values', + grafana_folder: 'Demonstrations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.08973827135812343, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: '6cd2ab200de1cf77', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '7', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + ], + [ + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'system-metrics', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default 2', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'Qh4ctRZVz', + from: 'state-history', + group: 'system', + orgID: '1', + }, + { + folderUID: 'pPyg37Qnk', + from: 'state-history', + group: 'security', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'thresholds', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + ], + ], + }, +}; + +export default data; diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/useRuleHistoryRecords.test.ts.snap b/public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/useRuleHistoryRecords.test.ts.snap new file mode 100644 index 00000000000..b1ff6c28eb6 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/useRuleHistoryRecords.test.ts.snap @@ -0,0 +1,822 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ruleHistoryToRecords should be empty with no matches 1`] = ` +{ + "historyRecords": [], +} +`; + +exports[`ruleHistoryToRecords should convert rule history JSON response to log records 1`] = ` +{ + "historyRecords": [ + { + "line": { + "condition": "C", + "current": "NoData", + "dashboardUID": "-W9yw_X7k", + "fingerprint": "009794b4d3074732", + "labels": { + "alertname": "CPU Usage 6", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Demonstrations", + "ref_id": "A", + "team": "operations", + "type": "test", + }, + "panelID": 3, + "previous": "Normal", + "ruleID": 10605, + "ruleTitle": "CPU Usage 6", + "ruleUID": "-rRg_HcVz", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "NoData", + "dashboardUID": "stzZvVw7k", + "fingerprint": "186e7d0c9173ddf4", + "labels": { + "alertname": "CPU Usage 4", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Demonstrations", + "ref_id": "A", + "region": "EMEA", + "team": "operations", + }, + "panelID": 8, + "previous": "Normal", + "ruleID": 150, + "ruleTitle": "CPU Usage 4", + "ruleUID": "s984p7n4k", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "Normal", + "dashboardUID": "", + "fingerprint": "bcf38efbb616d8d2", + "labels": { + "alertname": "direct to contact point", + "grafana_folder": "Testing and reproducing", + }, + "panelID": 0, + "previous": "Alerting", + "ruleID": 10656, + "ruleTitle": "direct to contact point", + "ruleUID": "ddc5zx4l3zls0a", + "schemaVersion": 1, + "values": { + "B": -6.886811320670644, + "C": 0, + }, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "D", + "current": "NoData", + "dashboardUID": "iYSJkozVk", + "fingerprint": "d3f3ec7ad90a37fd", + "labels": { + "alertname": "With Disabled Query", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Testing and reproducing", + "ref_id": "A,B", + }, + "panelID": 13, + "previous": "Normal", + "ruleID": 10631, + "ruleTitle": "With Disabled Query", + "ruleUID": "ab2ac850-1669-4dac-b566-c91728425f39", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "NoData", + "dashboardUID": "", + "fingerprint": "f4c073dd4c60e897", + "labels": { + "alertname": "Alert State History Random", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Testing and reproducing", + "ref_id": "A", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10614, + "ruleTitle": "Alert State History Random", + "ruleUID": "e4d7252b-c66f-4f5c-b7db-28b9936150bd", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "e11b0ff29886e8f2", + "labels": { + "alertname": "node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate4", + "grafana_folder": "Testing and reproducing", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10662, + "ruleTitle": "node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate4", + "ruleUID": "ddgfq7c57fqpsc", + "schemaVersion": 1, + "values": { + "B": 54.137611900622574, + "C": 1, + }, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "B", + "current": "NoData", + "dashboardUID": "", + "fingerprint": "d6d7e256298c8ea8", + "labels": { + "alertname": "delete-ds", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Testing and reproducing", + "ref_id": "A", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 144, + "ruleTitle": "delete-ds", + "ruleUID": "wXS0SnWVz", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "NoData", + "dashboardUID": "", + "fingerprint": "bf1b309577b4fc0c", + "labels": { + "alertname": "Threshold Test", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Testing and reproducing", + "ref_id": "A", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 148, + "ruleTitle": "Threshold Test", + "ruleUID": "u0QVOqMVz", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "NoData", + "dashboardUID": "", + "fingerprint": "d6b1cde6ab63bfca", + "labels": { + "alertname": "Alert History fingerprint", + "datasource_uid": "PDDA8E780A17E7EF1", + "grafana_folder": "Testing and reproducing", + "ref_id": "A", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10619, + "ruleTitle": "Alert History fingerprint", + "ruleUID": "c7a7989f-f12c-4aa5-8fb3-27f4509a5809", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "Pending", + "dashboardUID": "d4c61dfe-24d8-433e-984e-701a2f14faab", + "fingerprint": "40ddb0f6e97426e9", + "labels": { + "alertname": "UniFi CPU Usage", + "grafana_folder": "UniFi", + "instance": "unpoller:9130", + "job": "unifipoller", + "name": "Dream Router", + "site_name": "Default (default)", + "source": "https://192.168.1.1", + "type": "udm", + }, + "panelID": 1, + "previous": "Normal", + "ruleID": 10638, + "ruleTitle": "UniFi CPU Usage", + "ruleUID": "d10fc550-3552-4083-a3aa-f6b68058bc7f", + "schemaVersion": 1, + "values": { + "B": 0.0020875, + "C": 1, + }, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "B", + "current": "Alerting", + "dashboardUID": "", + "fingerprint": "10716534bbf8086b", + "labels": { + "alertname": "XSS attack vector", + "grafana_folder": "gdev dashboards", + }, + "panelID": 0, + "previous": "Pending", + "ruleID": 37, + "ruleTitle": "XSS attack vector", + "ruleUID": "p8TBxnq7k", + "schemaVersion": 1, + "values": { + "B0": 36.956598435022215, + }, + }, + "timestamp": 1727189680000, + }, + { + "line": { + "condition": "C", + "current": "Normal (MissingSeries)", + "dashboardUID": "iYSJkozVk", + "fingerprint": "4a5519a42c382438", + "labels": { + "alertname": "CPU Usage", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Demonstrations", + "ref_id": "A", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "NoData", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189700000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "cc4aceb3018da2e8", + "labels": { + "alertname": "CPU Usage", + "cpu": "7", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.067575, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "20dfd648881e698f", + "labels": { + "alertname": "CPU Usage", + "cpu": "2", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.06216900000000001, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "234db8cd1e160f6a", + "labels": { + "alertname": "CPU Usage", + "cpu": "1", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.07027800000000001, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "4e1f85b7efc3ed75", + "labels": { + "alertname": "CPU Usage", + "cpu": "0", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.06727466666666668, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "50bf9f17b9534593", + "labels": { + "alertname": "CPU Usage", + "cpu": "6", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.05315900000000001, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "2fb0af95df9b2bfe", + "labels": { + "alertname": "CPU Usage", + "cpu": "5", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.046551666666666686, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "d0303e26f6c376e9", + "labels": { + "alertname": "CPU Usage", + "cpu": "4", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.05466066666666668, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "af82d1220e70c3b4", + "labels": { + "alertname": "CPU Usage", + "cpu": "3", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.059165666666666665, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Normal (MissingSeries)", + "dashboardUID": "iYSJkozVk", + "fingerprint": "4a5519a42c382438", + "labels": { + "alertname": "CPU Usage", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Demonstrations", + "ref_id": "A", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Error", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189680000, + }, + { + "line": { + "condition": "C", + "current": "NoData", + "dashboardUID": "iYSJkozVk", + "fingerprint": "4a5519a42c382438", + "labels": { + "alertname": "CPU Usage", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Demonstrations", + "ref_id": "A", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "abeb2444057d7f01", + "labels": { + "alertname": "test-read-only-threshold-indicators", + "grafana_folder": "Testing and reproducing", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10606, + "ruleTitle": "test-read-only-threshold-indicators", + "ruleUID": "Wq5jJlc4k", + "schemaVersion": 1, + "values": { + "C0": 9.319781010143206, + }, + }, + "timestamp": 1727189700000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "2e7f5e6f5a5f2902", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "6", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.07305751032920166, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "1c1b21f738468ea5", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "5", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.06820108623217379, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "23ee5f02850e38f8", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "4", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.07685819005731041, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "2a810fa152d6407b", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "3", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.08614874050379848, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "48d127e8826c6fb6", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "2", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.0926943555910969, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "c9582e3d28402fe9", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "1", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.1097974143675863, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "fb3e32509acec7ec", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "0", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.12985655737704918, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "B", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "91a8126f41689d98", + "labels": { + "alertname": "Random Values", + "grafana_folder": "Demonstrations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 146, + "ruleTitle": "Random Values", + "ruleUID": "I1M_iCWVk", + "schemaVersion": 1, + "values": { + "B0": 8.406488484275167, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "6cd2ab200de1cf77", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "7", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.08973827135812343, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + ], +} +`; + +exports[`ruleHistoryToRecords should convert rule history JSON response with filters 1`] = ` +{ + "historyRecords": [ + { + "line": { + "condition": "B", + "current": "Alerting", + "dashboardUID": "", + "fingerprint": "10716534bbf8086b", + "labels": { + "alertname": "XSS attack vector", + "grafana_folder": "gdev dashboards", + }, + "panelID": 0, + "previous": "Pending", + "ruleID": 37, + "ruleTitle": "XSS attack vector", + "ruleUID": "p8TBxnq7k", + "schemaVersion": 1, + "values": { + "B0": 36.956598435022215, + }, + }, + "timestamp": 1727189680000, + }, + ], +} +`; diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/utils.test.ts.snap b/public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/utils.test.ts.snap new file mode 100644 index 00000000000..e1edf6a4842 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/utils.test.ts.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`historyResultToDataFrame should decode 1`] = ` +[ + { + "fields": [ + { + "config": { + "custom": { + "fillOpacity": 100, + }, + "displayName": "Time", + }, + "name": "time", + "type": "time", + "values": [ + 1727189670000, + 1727189680000, + 1727189700000, + 1727189690000, + 1727189710000, + ], + }, + { + "config": {}, + "name": "value", + "type": "number", + "values": [ + 11, + 2, + 2, + 8, + 9, + ], + }, + ], + "length": 5, + }, +] +`; + +exports[`historyResultToDataFrame should decode and filter 1`] = ` +[ + { + "fields": [ + { + "config": { + "custom": { + "fillOpacity": 100, + }, + "displayName": "Time", + }, + "name": "time", + "type": "time", + "values": [ + 1727189680000, + ], + }, + { + "config": {}, + "name": "value", + "type": "number", + "values": [ + 1, + ], + }, + ], + "length": 1, + }, +] +`; diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.test.ts b/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.test.ts new file mode 100644 index 00000000000..9acf26edd49 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.test.ts @@ -0,0 +1,20 @@ +import fixtureData from './__fixtures__/alert-state-history'; +import { ruleHistoryToRecords } from './useRuleHistoryRecords'; + +describe('ruleHistoryToRecords', () => { + it('should convert rule history JSON response to log records', () => { + expect(ruleHistoryToRecords(fixtureData)).toMatchSnapshot(); + }); + + it('should convert rule history JSON response with filters', () => { + expect( + ruleHistoryToRecords(fixtureData, { stateFrom: 'Pending', stateTo: 'Alerting', labels: '' }) + ).toMatchSnapshot(); + }); + + it('should be empty with no matches', () => { + expect( + ruleHistoryToRecords(fixtureData, { stateFrom: 'Pending', stateTo: 'Alerting', labels: 'doesNot=exist' }) + ).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.ts b/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.ts new file mode 100644 index 00000000000..ee8c739d3e5 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; + +import { DataFrameJSON } from '@grafana/data'; +import { mapStateWithReasonToBaseState } from 'app/types/unified-alerting-dto'; + +import { labelsMatchMatchers } from '../../../utils/alertmanager'; +import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; +import { LogRecord } from '../state-history/common'; +import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords'; + +import { StateFilterValues } from './CentralAlertHistoryScene'; + +const emptyFilters = { + labels: '', + stateFrom: 'all', + stateTo: 'all', +}; + +/** + * This hook filters the history records based on the label, stateTo and stateFrom filters. + * @param filterInLabel + * @param filterInStateTo + * @param filterInStateFrom + * @param stateHistory the original history records + * @returns the filtered history records + */ +export function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filters = emptyFilters) { + return useMemo(() => ruleHistoryToRecords(stateHistory, filters), [filters, stateHistory]); +} + +export function ruleHistoryToRecords(stateHistory?: DataFrameJSON, filters = emptyFilters) { + const { labels, stateFrom, stateTo } = filters; + + if (!stateHistory?.data) { + return { historyRecords: [] }; + } + + const filterMatchers = labels ? parsePromQLStyleMatcherLooseSafe(labels) : []; + + const [tsValues, lines] = stateHistory.data.values; + const timestamps = isNumbers(tsValues) ? tsValues : []; + + // merge timestamp with "line" + const logRecords = timestamps.reduce((acc: LogRecord[], timestamp: number, index: number) => { + const line = lines[index]; + if (!isLine(line)) { + return acc; + } + // values property can be undefined for some instance states (e.g. NoData) + const filterMatch = line.labels && labelsMatchMatchers(line.labels, filterMatchers); + const baseStateTo = mapStateWithReasonToBaseState(line.current); + const baseStateFrom = mapStateWithReasonToBaseState(line.previous); + const stateToMatch = stateTo !== StateFilterValues.all ? stateTo === baseStateTo : true; + const stateFromMatch = stateFrom !== StateFilterValues.all ? stateFrom === baseStateFrom : true; + if (filterMatch && stateToMatch && stateFromMatch) { + acc.push({ timestamp, line }); + } + + return acc; + }, []); + + return { + historyRecords: logRecords, + }; +} diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/utils.test.ts b/public/app/features/alerting/unified/components/rules/central-state-history/utils.test.ts new file mode 100644 index 00000000000..547db61db8f --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/utils.test.ts @@ -0,0 +1,12 @@ +import fixtureData from './__fixtures__/alert-state-history'; +import { historyResultToDataFrame } from './utils'; + +describe('historyResultToDataFrame', () => { + it('should decode', () => { + expect(historyResultToDataFrame(fixtureData)).toMatchSnapshot(); + }); + + it('should decode and filter', () => { + expect(historyResultToDataFrame(fixtureData, { stateFrom: 'Pending', stateTo: 'Alerting' })).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts b/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts index e032858c69d..28c620b4605 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts +++ b/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts @@ -12,7 +12,7 @@ import { getDisplayProcessor, } from '@grafana/data'; import { fieldIndexComparer } from '@grafana/data/src/field/fieldComparers'; -import { isGrafanaAlertState, mapStateWithReasonToBaseState } from 'app/types/unified-alerting-dto'; +import { mapStateWithReasonToBaseState } from 'app/types/unified-alerting-dto'; import { labelsMatchMatchers } from '../../../utils/alertmanager'; import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; @@ -23,43 +23,43 @@ import { LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO, StateFilterValues } const GROUPING_INTERVAL = 10 * 1000; // 10 seconds const QUERY_PARAM_PREFIX = 'var-'; // Prefix used by Grafana to sync variables in the URL + +const emptyFilters = { + stateTo: 'all', + stateFrom: 'all', +}; + /* * This function is used to convert the history response to a DataFrame list and filter the data by labels and states * The response is a list of log records, each log record has a timestamp and a line. * We group all records by alert instance (unique set of labels) and create a DataFrame for each group (instance). * This allows us to be able to filter by labels and states in the groupDataFramesByTime function. */ -export function historyResultToDataFrame(data: DataFrameJSON): DataFrame[] { - // Get the labels and states filters from the URL - const stateToInQueryParams = getStateFilterToInQueryParams(); - const stateFromInQueryParams = getStateFilterFromInQueryParams(); - const stateToFilterValue = stateToInQueryParams === '' ? StateFilterValues.all : stateToInQueryParams; - const stateFromFilterValue = stateFromInQueryParams === '' ? StateFilterValues.all : stateFromInQueryParams; +export function historyResultToDataFrame({ data }: DataFrameJSON, filters = emptyFilters): DataFrame[] { + const { stateTo, stateFrom } = filters; // Extract timestamps and lines from the response - const tsValues = data?.data?.values[0] ?? []; - const timestamps: number[] = isNumbers(tsValues) ? tsValues : []; - const lines = data?.data?.values[1] ?? []; + const [tsValues = [], lines = []] = data?.values ?? []; + const timestamps = isNumbers(tsValues) ? tsValues : []; // Filter log records by state and create a list of log records with the timestamp and line - const logRecords = timestamps.reduce((acc: LogRecord[], timestamp: number, index: number) => { + const logRecords = timestamps.reduce((acc, timestamp: number, index: number) => { const line = lines[index]; - // values property can be undefined for some instance states (e.g. NoData) - if (isLine(line)) { - if (!isGrafanaAlertState(line.current)) { - return acc; - } - // we have to filter out by state at that point , because we are going to group by timestamp and these states are going to be lost - const baseStateTo = mapStateWithReasonToBaseState(line.current); - const baseStateFrom = mapStateWithReasonToBaseState(line.previous); - const stateToMatch = stateToFilterValue !== StateFilterValues.all ? stateToFilterValue === baseStateTo : true; - const stateFromMatch = - stateFromFilterValue !== StateFilterValues.all ? stateFromFilterValue === baseStateFrom : true; - // filter by state - if (stateToMatch && stateFromMatch) { - acc.push({ timestamp, line }); - } + if (!isLine(line)) { + return acc; } + + // we have to filter out by state at that point , because we are going to group by timestamp and these states are going to be lost + const baseStateTo = mapStateWithReasonToBaseState(line.current); + const baseStateFrom = mapStateWithReasonToBaseState(line.previous); + const stateToMatch = stateTo !== StateFilterValues.all ? stateTo === baseStateTo : true; + const stateFromMatch = stateFrom !== StateFilterValues.all ? stateFrom === baseStateFrom : true; + + // filter by state + if (stateToMatch && stateFromMatch) { + acc.push({ timestamp, line }); + } + return acc; }, []); @@ -79,19 +79,21 @@ export function historyResultToDataFrame(data: DataFrameJSON): DataFrame[] { } // Scenes sync variables in the URL adding a prefix to the variable name. -function getLabelsFilterInQueryParams() { +export function getLabelsFilterInQueryParams() { const queryParams = new URLSearchParams(window.location.search); return queryParams.get(`${QUERY_PARAM_PREFIX}${LABELS_FILTER}`) ?? ''; } -function getStateFilterToInQueryParams() { + +export function getStateFilterToInQueryParams() { const queryParams = new URLSearchParams(window.location.search); - return queryParams.get(`${QUERY_PARAM_PREFIX}${STATE_FILTER_TO}`) ?? ''; + return queryParams.get(`${QUERY_PARAM_PREFIX}${STATE_FILTER_TO}`) ?? StateFilterValues.all; } -function getStateFilterFromInQueryParams() { +export function getStateFilterFromInQueryParams() { const queryParams = new URLSearchParams(window.location.search); - return queryParams.get(`${QUERY_PARAM_PREFIX}${STATE_FILTER_FROM}`) ?? ''; + return queryParams.get(`${QUERY_PARAM_PREFIX}${STATE_FILTER_FROM}`) ?? StateFilterValues.all; } + /* * This function groups the data frames by time and filters them by labels. * The interval is set to 10 seconds. From be8c8baf6f4ceaf0267f21dec6fda711c2087a14 Mon Sep 17 00:00:00 2001 From: Hugo Kiyodi Oshiro Date: Thu, 26 Sep 2024 15:56:25 +0200 Subject: [PATCH 088/102] ConfirmModal: Allow button disable to be externally managed (#93446) --- .../ConfirmModal/ConfirmContent.tsx | 8 ++- .../ConfirmModal/ConfirmModal.test.tsx | 57 ++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx index b63f2ffe7ad..27b94d395c0 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx @@ -77,11 +77,15 @@ export const ConfirmContent = ({ }, [confirmPromptText, disabled]); const onConfirmClick = async () => { - setIsDisabled(true); + if (disabled === undefined) { + setIsDisabled(true); + } try { await onConfirm(); } finally { - setIsDisabled(false); + if (disabled === undefined) { + setIsDisabled(false); + } } }; diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx index 53ac68e20ad..a667bc2ee80 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx @@ -1,8 +1,11 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; import { ConfirmModal } from './ConfirmModal'; +jest.useFakeTimers(); + describe('ConfirmModal', () => { const mockOnConfirm = jest.fn(); @@ -170,4 +173,56 @@ describe('ConfirmModal', () => { return expect(screen.getByRole('button', { name: 'Please Confirm' })).toBeEnabled(); }); }); + + it('should disable the confirm button when disabled prop changes from false to true', async () => { + const TestComponent = () => { + const [disabled, setDisabled] = useState(false); + + const handleConfirm = async () => { + act(() => { + setDisabled(true); + setTimeout(() => { + setDisabled(false); + }, 4000); + }); + }; + + return ( + {}} + onAlternative={() => {}} + disabled={disabled} + /> + ); + }; + + render(); + + const confirmButton = screen.getByRole('button', { name: 'Please Confirm' }); + + expect(confirmButton).toBeEnabled(); + + fireEvent.click(confirmButton); + + // Ensure React processes the state update and calls useEffect in ConfirmModal + await act(() => { + jest.advanceTimersByTime(0); + }); + + expect(confirmButton).toBeDisabled(); + + // Fast-forward time by 4 seconds + await act(() => { + jest.advanceTimersByTime(4000); + }); + + await waitFor(() => { + expect(confirmButton).toBeEnabled(); + }); + }); }); From 02fff05607ed3d38c0feb741556ae43dbede504c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 26 Sep 2024 13:33:26 +0000 Subject: [PATCH 089/102] Update dependency @grafana/faro-web-tracing to v1.10.1 --- yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index 807a95b11cf..22d74f00611 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3753,7 +3753,7 @@ __metadata: languageName: node linkType: hard -"@grafana/faro-web-sdk@npm:1.10.1, @grafana/faro-web-sdk@npm:^1.10.0, @grafana/faro-web-sdk@npm:^1.3.6": +"@grafana/faro-web-sdk@npm:1.10.1, @grafana/faro-web-sdk@npm:^1.10.1, @grafana/faro-web-sdk@npm:^1.3.6": version: 1.10.1 resolution: "@grafana/faro-web-sdk@npm:1.10.1" dependencies: @@ -3765,10 +3765,10 @@ __metadata: linkType: hard "@grafana/faro-web-tracing@npm:^1.8.2": - version: 1.10.0 - resolution: "@grafana/faro-web-tracing@npm:1.10.0" + version: 1.10.1 + resolution: "@grafana/faro-web-tracing@npm:1.10.1" dependencies: - "@grafana/faro-web-sdk": "npm:^1.10.0" + "@grafana/faro-web-sdk": "npm:^1.10.1" "@opentelemetry/api": "npm:^1.9.0" "@opentelemetry/context-zone": "npm:1.26.0" "@opentelemetry/core": "npm:^1.26.0" @@ -3780,7 +3780,7 @@ __metadata: "@opentelemetry/resources": "npm:^1.26.0" "@opentelemetry/sdk-trace-web": "npm:^1.26.0" "@opentelemetry/semantic-conventions": "npm:^1.27.0" - checksum: 10/ba986abf1e91b0b829f101e0f7abbd7cb14ff76c7968c30051d1005c2ab1f13e41118441e1d8fe76c3a71890e47ff24e9af185464d1e86ef6616af5c3fa7f4d1 + checksum: 10/b2c933e84d886aee022ecb503bcbe72bdeddabc9fbb08f213d887dd911de204d8709bb335074d093bb42b10d5d68d6423daec8ea5bb99958304ee63e529fd4e4 languageName: node linkType: hard From eb8e6c6a9f306b4adf39aae5b6d9266eb174067d Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Thu, 26 Sep 2024 08:10:02 -0600 Subject: [PATCH 090/102] Query Library: Display query text even when datasource doesn't have getQueryDisplayText (#93668) Display query text even when datasource doesn't have getQueryDisplayText --- .../QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx index 7d44fe5a634..4dce16f9ced 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx @@ -2,6 +2,7 @@ import { cx } from '@emotion/css'; import { CellProps } from 'react-table'; import { Spinner, Tooltip } from '@grafana/ui'; +import { createQueryText } from 'app/core/utils/richHistory'; import { useDatasource } from '../utils/useDatasource'; @@ -20,7 +21,7 @@ export function QueryDescriptionCell(props: CellProps) { return
    No queries
    ; } const query = props.row.original.query; - const queryDisplayText = datasourceApi?.getQueryDisplayText?.(query) || ''; + const queryDisplayText = createQueryText(query, datasourceApi); const description = props.row.original.description; const dsName = datasourceApi?.name || ''; From 2e559bb33390e0449bb6250d0a7f5070cbd3326b Mon Sep 17 00:00:00 2001 From: Ihor Yeromin Date: Thu, 26 Sep 2024 16:12:58 +0200 Subject: [PATCH 091/102] Transformation: UI date field transformation (#93807) fix(transformation): ui date field transformation --- .../ConvertFieldTypeTransformerEditor.tsx | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx index db059c21230..77a981540e4 100644 --- a/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx +++ b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx @@ -172,22 +172,21 @@ export const ConvertFieldTypeTransformerEditor = ({ )} - {c.dateFormat || - (targetField?.type === FieldType.time && ( - <> - - - - - + + +