From 9801b92c3de8c26e5cc5db840c07227b0cb8d4ae Mon Sep 17 00:00:00 2001 From: Ihor Yeromin Date: Fri, 4 Oct 2024 13:20:15 +0200 Subject: [PATCH 001/115] Table: Fix table cell text jumping on hover (#93913) fix(table): table cell text jumping on hover --- packages/grafana-ui/src/components/Table/TableCell.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/grafana-ui/src/components/Table/TableCell.tsx b/packages/grafana-ui/src/components/Table/TableCell.tsx index ef45bd98400..5c4b9f455dc 100644 --- a/packages/grafana-ui/src/components/Table/TableCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableCell.tsx @@ -40,6 +40,7 @@ export const TableCell = ({ } if (cellProps.style) { + cellProps.style.wordBreak = 'break-word'; cellProps.style.minWidth = cellProps.style.width; const justifyContent = (cell.column as any).justifyContent; From 458fc6961677ff4c1d79fb98abdc7fafe32129de Mon Sep 17 00:00:00 2001 From: Aaron Godin Date: Fri, 4 Oct 2024 06:35:25 -0500 Subject: [PATCH 002/115] IAM: Add test for AddDataSource managed permissions (#94113) Add test for AddDataSource managed permissions and fix control flow issues --- .../datasources/service/datasource.go | 12 ++++-- .../datasources/service/datasource_test.go | 38 ++++++++++++++++--- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/pkg/services/datasources/service/datasource.go b/pkg/services/datasources/service/datasource.go index ef51ebef430..495861cc941 100644 --- a/pkg/services/datasources/service/datasource.go +++ b/pkg/services/datasources/service/datasource.go @@ -255,7 +255,7 @@ func (s *Service) AddDataSource(ctx context.Context, cmd *datasources.AddDataSou } var dataSource *datasources.DataSource - return dataSource, s.db.InTransaction(ctx, func(ctx context.Context) error { + err = s.db.InTransaction(ctx, func(ctx context.Context) error { var err error cmd.EncryptedSecureJsonData = make(map[string][]byte) @@ -293,12 +293,18 @@ func (s *Service) AddDataSource(ctx context.Context, cmd *datasources.AddDataSou if cmd.UserID != 0 { permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{UserID: cmd.UserID, Permission: "Admin"}) } - _, err = s.permissionsService.SetPermissions(ctx, cmd.OrgID, dataSource.UID, permissions...) - return err + if _, err = s.permissionsService.SetPermissions(ctx, cmd.OrgID, dataSource.UID, permissions...); err != nil { + return err + } } return nil }) + if err != nil { + return nil, err + } + + return dataSource, nil } // This will valid validate the instance settings return a version that is safe to be saved diff --git a/pkg/services/datasources/service/datasource_test.go b/pkg/services/datasources/service/datasource_test.go index 3433abdd2e2..c13456d4cf0 100644 --- a/pkg/services/datasources/service/datasource_test.go +++ b/pkg/services/datasources/service/datasource_test.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -104,6 +105,27 @@ func TestService_AddDataSource(t *testing.T) { require.EqualError(t, err, "[datasource.urlInvalid] max length is 255") }) + t.Run("should fail if the datasource managed permissions fail", func(t *testing.T) { + dsService := initDSService(t) + enableRBACManagedPermissions(t, dsService.cfg) + dsService.permissionsService = &actest.FakePermissionsService{ + ExpectedErr: errors.New("failed to set datasource permissions"), + } + dsService.pluginStore = &pluginstore.FakePluginStore{ + PluginList: []pluginstore.Plugin{}, + } + + cmd := &datasources.AddDataSourceCommand{ + OrgID: 1, + Type: datasources.DS_TESTDATA, + Name: "test", + } + + ds, err := dsService.AddDataSource(context.Background(), cmd) + assert.Nil(t, ds) + assert.ErrorContains(t, err, "failed to set datasource permissions") + }) + t.Run("if a plugin has an API version defined (EXPERIMENTAL)", func(t *testing.T) { t.Run("should success to run admission hooks", func(t *testing.T) { dsService := initDSService(t) @@ -580,11 +602,8 @@ func TestService_DeleteDataSource(t *testing.T) { permissionSvc := acmock.NewMockedPermissionsService() permissionSvc.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil).Once() permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() - - f := ini.Empty() - f.Section("rbac").Key("resources_with_managed_permissions_on_creation").SetValue("datasource") - cfg, err := setting.NewCfgFromINIFile(f) - require.NoError(t, err) + cfg := &setting.Cfg{} + enableRBACManagedPermissions(t, cfg) dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) require.NoError(t, err) @@ -1521,6 +1540,15 @@ func initDSService(t *testing.T) *Service { return dsService } +func enableRBACManagedPermissions(t testing.TB, cfg *setting.Cfg) { + t.Helper() + f := ini.Empty() + f.Section("rbac").Key("resources_with_managed_permissions_on_creation").SetValue("datasource") + tempCfg, err := setting.NewCfgFromINIFile(f) + cfg.RBAC = tempCfg.RBAC + require.NoError(t, err) +} + const caCert string = `-----BEGIN CERTIFICATE----- MIIDATCCAemgAwIBAgIJAMQ5hC3CPDTeMA0GCSqGSIb3DQEBCwUAMBcxFTATBgNV BAMMDGNhLWs4cy1zdGhsbTAeFw0xNjEwMjcwODQyMjdaFw00NDAzMTQwODQyMjda From a82f1028783a3dd0c50c7c460eb3f307f9aa987a Mon Sep 17 00:00:00 2001 From: Mihai Doarna Date: Fri, 4 Oct 2024 14:59:42 +0300 Subject: [PATCH 003/115] Auth: Promote `ssoSettingsLDAP` flag to public preview (#94242) * promote ssoSettingsLDAP flag to public preview * add generated file --- .../configure-grafana/feature-toggles/index.md | 1 + pkg/services/featuremgmt/registry.go | 12 ++++++------ pkg/services/featuremgmt/toggles_gen.csv | 2 +- pkg/services/featuremgmt/toggles_gen.json | 15 +++++++++------ 4 files changed, 17 insertions(+), 13 deletions(-) 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 65e636dfa77..27333e0979c 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -111,6 +111,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `ssoSettingsSAML` | Use the new SSO Settings API to configure the SAML connector | | `accessActionSets` | Introduces action sets for resource permissions. Also ensures that all folder editors and admins can create subfolders without needing any additional permissions. | | `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | +| `ssoSettingsLDAP` | Use the new SSO Settings API to configure LDAP | | `cloudwatchMetricInsightsCrossAccount` | Enables cross account observability for Cloudwatch Metric Insights query builder | | `useSessionStorageForRedirection` | Use session storage for handling the redirection after login | diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 60e78e82db3..cadc10ab7d2 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1324,12 +1324,12 @@ var ( Expression: "true", }, { - Name: "ssoSettingsLDAP", - Description: "Use the new SSO Settings API to configure LDAP", - Stage: FeatureStageExperimental, - Owner: identityAccessTeam, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "ssoSettingsLDAP", + Description: "Use the new SSO Settings API to configure LDAP", + Stage: FeatureStagePublicPreview, + Owner: identityAccessTeam, + AllowSelfServe: true, + RequiresRestart: true, }, { Name: "failWrongDSUID", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 800f41a8e6f..54e538d5207 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -173,7 +173,7 @@ azureMonitorPrometheusExemplars,preview,@grafana/partner-datasources,false,false pinNavItems,GA,@grafana/grafana-frontend-platform,false,false,false 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 +ssoSettingsLDAP,preview,@grafana/identity-access-team,false,true,false failWrongDSUID,experimental,@grafana/plugins-platform-backend,false,false,false zanzana,experimental,@grafana/identity-access-team,false,false,false passScopeToDashboardApi,experimental,@grafana/dashboards-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index eff0b8b4b6f..0b1c37e9e21 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2842,15 +2842,18 @@ { "metadata": { "name": "ssoSettingsLDAP", - "resourceVersion": "1718727528075", - "creationTimestamp": "2024-06-18T11:31:27Z" + "resourceVersion": "1728034012257", + "creationTimestamp": "2024-06-18T11:31:27Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-10-04 09:26:52.257203 +0000 UTC" + } }, "spec": { "description": "Use the new SSO Settings API to configure LDAP", - "stage": "experimental", + "stage": "preview", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, - "hideFromDocs": true + "requiresRestart": true, + "allowSelfServe": true } }, { @@ -3116,4 +3119,4 @@ } } ] -} \ No newline at end of file +} From 7d32d5eff41c3a0be5bd5494163463d6a2ad529e Mon Sep 17 00:00:00 2001 From: Diego Augusto Molina Date: Fri, 4 Oct 2024 09:07:20 -0300 Subject: [PATCH 004/115] Unistore: Reuse MySQL and Postgres Grafana core config instead of the object (#94223) * Reuse MySQL and Postgres Grafana config instead of the object - Only reuse the Grafana DB object for SQLite. Support for SQLite will be added in a different PR - Fail when reusing the Grafana DB object if it is using DB instrumentation - In the case that we have to reuse a Grafana DB with its instrumentation, fail with an error that describes a workaround - Add regression tests to reproduce incident 2144 * remove temp file * fix linter * fix linter x2 * fix linter x3 --- pkg/storage/unified/sql/db/dbimpl/dbEngine.go | 37 ++-- .../unified/sql/db/dbimpl/dbEngine_test.go | 74 ++++--- pkg/storage/unified/sql/db/dbimpl/dbimpl.go | 70 ++++-- .../unified/sql/db/dbimpl/dbimpl_test.go | 159 ++++++++++++++ .../dbimpl/regression_incident_2144_test.go | 204 ++++++++++++++++++ pkg/storage/unified/sql/db/dbimpl/util.go | 24 ++- .../unified/sql/db/dbimpl/util_test.go | 70 ++++-- pkg/storage/unified/sql/server.go | 2 +- .../unified/sql/test/integration_test.go | 3 +- 9 files changed, 562 insertions(+), 81 deletions(-) create mode 100644 pkg/storage/unified/sql/db/dbimpl/dbimpl_test.go create mode 100644 pkg/storage/unified/sql/db/dbimpl/regression_incident_2144_test.go diff --git a/pkg/storage/unified/sql/db/dbimpl/dbEngine.go b/pkg/storage/unified/sql/db/dbimpl/dbEngine.go index d068a658481..3968cfc7839 100644 --- a/pkg/storage/unified/sql/db/dbimpl/dbEngine.go +++ b/pkg/storage/unified/sql/db/dbimpl/dbEngine.go @@ -7,23 +7,26 @@ import ( "time" "github.com/go-sql-driver/mysql" + "xorm.io/xorm" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/storage/unified/sql/db" - "xorm.io/xorm" ) -func getEngineMySQL(getter *sectionGetter, tracer tracing.Tracer) (*xorm.Engine, error) { +func getEngineMySQL(getter confGetter, tracer tracing.Tracer) (*xorm.Engine, error) { config := mysql.NewConfig() - config.User = getter.String("db_user") - config.Passwd = getter.String("db_pass") + config.User = getter.String("user") + // accept the core Grafana jargon of `password` as well, originally Unified + // Storage used `pass` + config.Passwd = cmp.Or(getter.String("pass"), getter.String("password")) config.Net = "tcp" - config.Addr = getter.String("db_host") - config.DBName = getter.String("db_name") + config.Addr = getter.String("host") + config.DBName = getter.String("name") config.Params = map[string]string{ // See: https://dev.mysql.com/doc/refman/en/sql-mode.html "@@SESSION.sql_mode": "ANSI", } - tls := getter.String("db_tls") + tls := getter.String("tls") if tls != "" { config.Params["tls"] = tls } @@ -39,8 +42,8 @@ func getEngineMySQL(getter *sectionGetter, tracer tracing.Tracer) (*xorm.Engine, //config.MultiStatements = true // TODO: do we want to support these? - // config.ServerPubKey = getter.String("db_server_pub_key") - // config.TLSConfig = getter.String("db_tls_config_name") + // config.ServerPubKey = getter.String("server_pub_key") + // config.TLSConfig = getter.String("tls_config_name") if err := getter.Err(); err != nil { return nil, fmt.Errorf("config error: %w", err) @@ -65,12 +68,14 @@ func getEngineMySQL(getter *sectionGetter, tracer tracing.Tracer) (*xorm.Engine, return engine, nil } -func getEnginePostgres(getter *sectionGetter, tracer tracing.Tracer) (*xorm.Engine, error) { +func getEnginePostgres(getter confGetter, tracer tracing.Tracer) (*xorm.Engine, error) { dsnKV := map[string]string{ - "user": getter.String("db_user"), - "password": getter.String("db_pass"), - "dbname": getter.String("db_name"), - "sslmode": cmp.Or(getter.String("db_sslmode"), "disable"), + "user": getter.String("user"), + // accept the core Grafana jargon of `password` as well, originally + // Unified Storage used `pass` + "password": cmp.Or(getter.String("pass"), getter.String("password")), + "dbname": getter.String("name"), + "sslmode": cmp.Or(getter.String("sslmode"), "disable"), } // TODO: probably interesting: @@ -88,7 +93,7 @@ func getEnginePostgres(getter *sectionGetter, tracer tracing.Tracer) (*xorm.Engi // More on Postgres connection string parameters: // https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING - hostport := getter.String("db_host") + hostport := getter.String("host") if err := getter.Err(); err != nil { return nil, fmt.Errorf("config error: %w", err) @@ -96,7 +101,7 @@ func getEnginePostgres(getter *sectionGetter, tracer tracing.Tracer) (*xorm.Engi host, port, err := splitHostPortDefault(hostport, "127.0.0.1", "5432") if err != nil { - return nil, fmt.Errorf("invalid db_host: %w", err) + return nil, fmt.Errorf("invalid host: %w", err) } dsnKV["host"] = host dsnKV["port"] = port diff --git a/pkg/storage/unified/sql/db/dbimpl/dbEngine_test.go b/pkg/storage/unified/sql/db/dbimpl/dbEngine_test.go index ab761b98744..659d3bd1bb6 100644 --- a/pkg/storage/unified/sql/db/dbimpl/dbEngine_test.go +++ b/pkg/storage/unified/sql/db/dbimpl/dbEngine_test.go @@ -6,22 +6,33 @@ import ( "github.com/stretchr/testify/assert" ) -func newValidMySQLGetter() *sectionGetter { - return newTestSectionGetter(map[string]string{ - "db_type": dbTypeMySQL, - "db_host": "/var/run/mysql.socket", - "db_name": "grafana", - "db_user": "user", - "db_password": "password", - }) +func newValidMySQLGetter(withKeyPrefix bool) confGetter { + var prefix string + if withKeyPrefix { + prefix = "db_" + } + return newTestConfGetter(map[string]string{ + prefix + "type": dbTypeMySQL, + prefix + "host": "/var/run/mysql.socket", + prefix + "name": "grafana", + prefix + "user": "user", + prefix + "password": "password", + }, prefix) } func TestGetEngineMySQLFromConfig(t *testing.T) { t.Parallel() - t.Run("happy path", func(t *testing.T) { + t.Run("happy path - with key prefix", func(t *testing.T) { t.Parallel() - engine, err := getEngineMySQL(newValidMySQLGetter(), nil) + engine, err := getEngineMySQL(newValidMySQLGetter(true), nil) + assert.NotNil(t, engine) + assert.NoError(t, err) + }) + + t.Run("happy path - without key prefix", func(t *testing.T) { + t.Parallel() + engine, err := getEngineMySQL(newValidMySQLGetter(false), nil) assert.NotNil(t, engine) assert.NoError(t, err) }) @@ -29,13 +40,13 @@ func TestGetEngineMySQLFromConfig(t *testing.T) { t.Run("invalid string", func(t *testing.T) { t.Parallel() - getter := newTestSectionGetter(map[string]string{ + getter := newTestConfGetter(map[string]string{ "db_type": dbTypeMySQL, "db_host": "/var/run/mysql.socket", "db_name": string(invalidUTF8ByteSequence), "db_user": "user", "db_password": "password", - }) + }, "db_") engine, err := getEngineMySQL(getter, nil) assert.Nil(t, engine) assert.Error(t, err) @@ -43,35 +54,46 @@ func TestGetEngineMySQLFromConfig(t *testing.T) { }) } -func newValidPostgresGetter() *sectionGetter { - return newTestSectionGetter(map[string]string{ - "db_type": dbTypePostgres, - "db_host": "localhost", - "db_name": "grafana", - "db_user": "user", - "db_password": "password", - }) +func newValidPostgresGetter(withKeyPrefix bool) confGetter { + var prefix string + if withKeyPrefix { + prefix = "db_" + } + return newTestConfGetter(map[string]string{ + prefix + "type": dbTypePostgres, + prefix + "host": "localhost", + prefix + "name": "grafana", + prefix + "user": "user", + prefix + "password": "password", + }, prefix) } func TestGetEnginePostgresFromConfig(t *testing.T) { t.Parallel() - t.Run("happy path", func(t *testing.T) { + t.Run("happy path - with key prefix", func(t *testing.T) { t.Parallel() - engine, err := getEnginePostgres(newValidPostgresGetter(), nil) + engine, err := getEnginePostgres(newValidPostgresGetter(true), nil) + assert.NotNil(t, engine) + assert.NoError(t, err) + }) + + t.Run("happy path - without key prefix", func(t *testing.T) { + t.Parallel() + engine, err := getEnginePostgres(newValidPostgresGetter(false), nil) assert.NotNil(t, engine) assert.NoError(t, err) }) t.Run("invalid string", func(t *testing.T) { t.Parallel() - getter := newTestSectionGetter(map[string]string{ + getter := newTestConfGetter(map[string]string{ "db_type": dbTypePostgres, "db_host": string(invalidUTF8ByteSequence), "db_name": "grafana", "db_user": "user", "db_password": "password", - }) + }, "db_") engine, err := getEnginePostgres(getter, nil) assert.Nil(t, engine) @@ -81,13 +103,13 @@ func TestGetEnginePostgresFromConfig(t *testing.T) { t.Run("invalid hostport", func(t *testing.T) { t.Parallel() - getter := newTestSectionGetter(map[string]string{ + getter := newTestConfGetter(map[string]string{ "db_type": dbTypePostgres, "db_host": "1:1:1", "db_name": "grafana", "db_user": "user", "db_password": "password", - }) + }, "db_") engine, err := getEnginePostgres(getter, nil) assert.Nil(t, engine) diff --git a/pkg/storage/unified/sql/db/dbimpl/dbimpl.go b/pkg/storage/unified/sql/db/dbimpl/dbimpl.go index b9498b87cd5..4756a1f4d56 100644 --- a/pkg/storage/unified/sql/db/dbimpl/dbimpl.go +++ b/pkg/storage/unified/sql/db/dbimpl/dbimpl.go @@ -2,6 +2,7 @@ package dbimpl import ( "context" + "errors" "fmt" "sync" @@ -12,7 +13,6 @@ import ( infraDB "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/sql/db" "github.com/grafana/grafana/pkg/storage/unified/sql/db/migrations" @@ -23,8 +23,17 @@ const ( dbTypePostgres = "postgres" ) -func ProvideResourceDB(grafanaDB infraDB.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer) (db.DBProvider, error) { - p, err := newResourceDBProvider(grafanaDB, cfg, features, tracer) +const grafanaDBInstrumentQueriesKey = "instrument_queries" + +var errGrafanaDBInstrumentedNotSupported = errors.New("the Resource API is " + + "attempting to leverage the database from core Grafana defined in the" + + " [database] INI section since a database configuration was not provided" + + " in the [resource_api] section. But we detected that the key `" + + grafanaDBInstrumentQueriesKey + "` is enabled in [database], and that" + + " setup is currently unsupported. Please, consider disabling that flag") + +func ProvideResourceDB(grafanaDB infraDB.DB, cfg *setting.Cfg, tracer tracing.Tracer) (db.DBProvider, error) { + p, err := newResourceDBProvider(grafanaDB, cfg, tracer) if err != nil { return nil, fmt.Errorf("provide Resource DB: %w", err) } @@ -54,41 +63,67 @@ type resourceDBProvider struct { logQueries bool } -func newResourceDBProvider(grafanaDB infraDB.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tracer tracing.Tracer) (p *resourceDBProvider, err error) { - // TODO: This should be renamed resource_api - getter := §ionGetter{ - DynamicSection: cfg.SectionWithEnvOverrides("resource_api"), - } +func newResourceDBProvider(grafanaDB infraDB.DB, cfg *setting.Cfg, tracer tracing.Tracer) (p *resourceDBProvider, err error) { + // Resource API has other configs in its section besides database ones, so + // we prefix them with "db_". We use the database config from core Grafana + // as fallback, and as it uses a dedicated INI section, then keys are not + // prefixed with "db_" + getter := newConfGetter(cfg.SectionWithEnvOverrides("resource_api"), "db_") + fallbackGetter := newConfGetter(cfg.SectionWithEnvOverrides("database"), "") p = &resourceDBProvider{ cfg: cfg, log: log.New("entity-db"), - logQueries: getter.Key("log_queries").MustBool(false), + logQueries: getter.Bool("log_queries"), migrateFunc: migrations.MigrateResourceStore, } - switch dbType := getter.Key("db_type").MustString(""); dbType { - case dbTypePostgres: + dbType := getter.String("type") + grafanaDBType := fallbackGetter.String("type") + switch { + // First try with the config in the "resource_api" section, which is + // specific to Unified Storage + case dbType == dbTypePostgres: p.registerMetrics = true p.engine, err = getEnginePostgres(getter, tracer) return p, err - case dbTypeMySQL: + case dbType == dbTypeMySQL: p.registerMetrics = true p.engine, err = getEngineMySQL(getter, tracer) return p, err - case "": + // TODO: add support for SQLite + + case dbType != "": + return p, fmt.Errorf("invalid db type specified: %s", dbType) + + // If we have an empty Resource API db config, try with the core Grafana + // database config + + case grafanaDBType == dbTypePostgres: + p.registerMetrics = true + p.engine, err = getEnginePostgres(fallbackGetter, tracer) + return p, err + + case grafanaDBType == dbTypeMySQL: + p.registerMetrics = true + p.engine, err = getEngineMySQL(fallbackGetter, tracer) + return p, err + + // TODO: add support for SQLite + + case grafanaDB != nil: // try to use the grafana db connection - if grafanaDB == nil { - return p, fmt.Errorf("no db connection provided") + + if fallbackGetter.Bool(grafanaDBInstrumentQueriesKey) { + return nil, errGrafanaDBInstrumentedNotSupported } p.engine = grafanaDB.GetEngine() return p, nil default: - // TODO: sqlite support - return p, fmt.Errorf("invalid db type specified: %s", dbType) + return p, fmt.Errorf("no db connection provided") } } @@ -102,7 +137,6 @@ func (p *resourceDBProvider) init(ctx context.Context) (db.DB, error) { _ = p.logQueries // TODO: configure SQL logging // TODO: change the migrator to use db.DB instead of xorm - // Skip migrations if feature flag is not enabled if p.migrateFunc != nil { err := p.migrateFunc(ctx, p.engine, p.cfg) if err != nil { diff --git a/pkg/storage/unified/sql/db/dbimpl/dbimpl_test.go b/pkg/storage/unified/sql/db/dbimpl/dbimpl_test.go new file mode 100644 index 00000000000..bb317509888 --- /dev/null +++ b/pkg/storage/unified/sql/db/dbimpl/dbimpl_test.go @@ -0,0 +1,159 @@ +package dbimpl + +import ( + "context" + "database/sql" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" + traceNoop "go.opentelemetry.io/otel/trace/noop" + ini "gopkg.in/ini.v1" + + "github.com/grafana/grafana/pkg/bus" + infraDB "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" +) + +type ( + // cfgSectionMap represents an INI section, mapping from an INI key to an + // INI value. + cfgSectionMap = map[string]string + // cfgMap is a map from INI section name to INI section contents. + cfgMap = map[string]cfgSectionMap +) + +// setupDBForGrafana modifies `m` in the following way: +// +// [database] +// type = sqlite3 +// path = unique-random-path +// +// After that, it initializes a temporary SQLite filesystem-backed database that +// is later deleted when the test finishes. +func setupDBForGrafana(t *testing.T, ctx context.Context, m cfgMap) { + dbSection, ok := m["database"] + if !ok { + dbSection = cfgSectionMap{} + m["database"] = dbSection + } + dbSection["type"] = "sqlite3" + dbSection["path"] = t.TempDir() + "/" + uuid.New().String() + + db, err := sql.Open("sqlite3", "file:"+dbSection["path"]) + require.NoError(t, err) + + _, err = db.ExecContext(ctx, ` + CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + version INTEGER NOT NULL, + login TEXT NOT NULL, + email TEXT NOT NULL, + name TEXT NULL, + password TEXT NULL, + salt TEXT NULL, + rands TEXT NULL, + company TEXT NULL, + org_id INTEGER NOT NULL, + is_admin INTEGER NOT NULL, + email_verified INTEGER NULL, + theme TEXT NULL, + created DATETIME NOT NULL, + updated DATETIME NOT NULL, + help_flags1 INTEGER NOT NULL DEFAULT 0, + last_seen_at DATETIME NULL, + is_disabled INTEGER NOT NULL DEFAULT 0, + is_service_account BOOLEAN DEFAULT 0, + uid TEXT NULL + ); + CREATE TABLE org ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + version INTEGER NOT NULL, + name TEXT NOT NULL, + address1 TEXT NULL, + address2 TEXT NULL, + city TEXT NULL, + state TEXT NULL, + zip_code TEXT NULL, + country TEXT NULL, + billing_email TEXT NULL, + created DATETIME NOT NULL, + updated DATETIME NOT NULL + ); + CREATE TABLE org_user ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + org_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + created DATETIME NOT NULL, + updated DATETIME NOT NULL + ); + `) + require.NoError(t, err) +} + +func newTestInfraDB(t *testing.T, m cfgMap) infraDB.DB { + t.Helper() + // nil migrations means no migrations + sqlstoreDB, err := sqlstore.ProvideService( + newCfgFromIniMap(t, m), // *setting.Cfg + featureTogglesNop{}, // featuremgmt.FeatureToggles + nil, // registry.DatabaseMigrator + nopBus{}, // github.com/grafana/grafana/pkg/bus.Bus + newNopTestGrafanaTracer(), + ) + require.NoError(t, err) + + return sqlstoreDB +} + +func newCfgFromIniMap(t *testing.T, m cfgMap) *setting.Cfg { + t.Helper() + cfg, err := setting.NewCfgFromINIFile(newTestINIFile(t, m)) + require.NoError(t, err) + return cfg +} + +func newTestINIFile(t *testing.T, m cfgMap) *ini.File { + t.Helper() + f := ini.Empty() + for sectionName, kvs := range m { + section, err := f.NewSection(sectionName) + require.NoError(t, err) + for k, v := range kvs { + _, err := section.NewKey(k, v) + require.NoError(t, err) + } + } + return f +} + +type ( + testGrafanaTracer struct { + trace.Tracer + } + featureTogglesNop struct{} + nopBus struct{} +) + +func (testGrafanaTracer) Inject(context.Context, http.Header, trace.Span) {} +func newNopTestGrafanaTracer() tracing.Tracer { + return testGrafanaTracer{traceNoop.NewTracerProvider().Tracer("test")} +} + +func (featureTogglesNop) IsEnabled(context.Context, string) bool { + return false +} +func (featureTogglesNop) IsEnabledGlobally(string) bool { + return false +} +func (featureTogglesNop) GetEnabled(context.Context) map[string]bool { + return map[string]bool{} +} + +func (nopBus) Publish(context.Context, bus.Msg) error { return nil } +func (nopBus) AddEventListener(bus.HandlerFunc) {} diff --git a/pkg/storage/unified/sql/db/dbimpl/regression_incident_2144_test.go b/pkg/storage/unified/sql/db/dbimpl/regression_incident_2144_test.go new file mode 100644 index 00000000000..1d0b6ba9b02 --- /dev/null +++ b/pkg/storage/unified/sql/db/dbimpl/regression_incident_2144_test.go @@ -0,0 +1,204 @@ +package dbimpl + +import ( + "context" + "database/sql" + "database/sql/driver" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/util/testutil" +) + +// defined in the standard library in database/sql/ctxutil.go +const noIsolationLevelSupportErrStr = "sql: driver does not support non-" + + "default isolation level" + +var _ driver.Driver = driverWithoutIsolationLevel{} +var _ driver.Driver = driverWithIsolationLevel{} + +const ( + driverWithoutIsolationLevelName = "test driver without isolation levels" + driverWithIsolationLevelName = "test driver with isolation levels" +) + +var registerTestDriversOnce sync.Once + +func registerTestSQLDrivers() { + registerTestDriversOnce.Do(func() { + sql.Register(driverWithoutIsolationLevelName, driverWithoutIsolationLevel{}) + sql.Register(driverWithIsolationLevelName, driverWithIsolationLevel{}) + }) +} + +type ( + // without isolation level + + driverWithoutIsolationLevel struct{} + connWithoutIsolationLevel struct{} + + // with isolation level + + driverWithIsolationLevel struct{} + connWithIsolationLevel struct { + connWithoutIsolationLevel + } + + // common + + testStmt struct{} + testTx struct{} + testResults struct{} + testRows struct{} +) + +// driver.Driver + +func (driverWithoutIsolationLevel) Open(name string) (driver.Conn, error) { + return connWithoutIsolationLevel{}, nil +} + +func (driverWithIsolationLevel) Open(name string) (driver.Conn, error) { + return connWithIsolationLevel{}, nil +} + +// driver.Conn + +func (connWithoutIsolationLevel) Prepare(query string) (driver.Stmt, error) { + return testStmt{}, nil +} +func (connWithoutIsolationLevel) Close() error { + return nil +} +func (connWithoutIsolationLevel) Begin() (driver.Tx, error) { + return testTx{}, nil +} + +func (connWithIsolationLevel) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) { + return testTx{}, nil +} + +// driver.Stmt + +func (testStmt) Close() error { return nil } +func (testStmt) NumInput() int { return 0 } +func (testStmt) Exec(args []driver.Value) (driver.Result, error) { return testResults{}, nil } +func (testStmt) Query(args []driver.Value) (driver.Rows, error) { return testRows{}, nil } + +// driver.Tx + +func (testTx) Commit() error { return nil } +func (testTx) Rollback() error { return nil } + +// driver.Results + +func (testResults) LastInsertId() (int64, error) { return 1, nil } +func (testResults) RowsAffected() (int64, error) { return 1, nil } + +// driver.Rows + +func (testRows) Columns() []string { return nil } +func (testRows) Close() error { return nil } +func (testRows) Next(dest []driver.Value) error { return nil } + +func TestReproIncident2144IndependentOfGrafanaDB(t *testing.T) { + t.Parallel() + registerTestSQLDrivers() + txOpts := &sql.TxOptions{ + Isolation: sql.LevelSerializable, + } + + t.Run("driver without isolation level should fail", func(t *testing.T) { + t.Parallel() + ctx := testutil.NewDefaultTestContext(t) + + db, err := sql.Open(driverWithoutIsolationLevelName, "") + require.NoError(t, err) + require.NotNil(t, db) + + _, err = db.BeginTx(ctx, txOpts) + require.Error(t, err) + require.Equal(t, noIsolationLevelSupportErrStr, err.Error()) + }) + + t.Run("driver with isolation level should work", func(t *testing.T) { + t.Parallel() + ctx := testutil.NewDefaultTestContext(t) + + db, err := sql.Open(driverWithIsolationLevelName, "") + require.NoError(t, err) + require.NotNil(t, db) + + _, err = db.BeginTx(ctx, txOpts) + require.NoError(t, err) + }) +} + +func TestReproIncident2144UsingGrafanaDB(t *testing.T) { + t.Parallel() + txOpts := &sql.TxOptions{ + Isolation: sql.LevelSerializable, + } + + t.Run("core Grafana db without instrumentation preserves driver ability to use isolation levels", + func(t *testing.T) { + t.Parallel() + + t.Run("base behaviour is preserved", func(t *testing.T) { + t.Parallel() + ctx := testutil.NewDefaultTestContext(t) + cfgMap := cfgMap{} + setupDBForGrafana(t, ctx, cfgMap) + grafanaDB := newTestInfraDB(t, cfgMap) + db := grafanaDB.GetEngine().DB().DB + _, err := db.BeginTx(ctx, txOpts) + require.NoError(t, err) + }) + + t.Run("Resource API does not fail and correctly uses Grafana DB as fallback", + func(t *testing.T) { + t.Parallel() + ctx := testutil.NewDefaultTestContext(t) + cfgMap := cfgMap{} + cfg := newCfgFromIniMap(t, cfgMap) + setupDBForGrafana(t, ctx, cfgMap) + grafanaDB := newTestInfraDB(t, cfgMap) + resourceDB, err := ProvideResourceDB(grafanaDB, cfg, testGrafanaTracer{}) + require.NotNil(t, resourceDB) + require.NoError(t, err) + }) + }) + + t.Run("core Grafana db instrumentation removes driver ability to use isolation levels", + func(t *testing.T) { + t.Parallel() + ctx := testutil.NewDefaultTestContext(t) + cfgMap := cfgMap{ + "database": cfgSectionMap{ + grafanaDBInstrumentQueriesKey: "true", + }, + } + setupDBForGrafana(t, ctx, cfgMap) + grafanaDB := newTestInfraDB(t, cfgMap) + + t.Run("base failure caused by instrumentation", func(t *testing.T) { + t.Parallel() + ctx := testutil.NewDefaultTestContext(t) + db := grafanaDB.GetEngine().DB().DB + _, err := db.BeginTx(ctx, txOpts) + require.Error(t, err) + require.Equal(t, noIsolationLevelSupportErrStr, err.Error()) + }) + + t.Run("Resource API provides a reasonable error for this case", func(t *testing.T) { + t.Parallel() + cfg := newCfgFromIniMap(t, cfgMap) + resourceDB, err := ProvideResourceDB(grafanaDB, cfg, testGrafanaTracer{}) + require.Nil(t, resourceDB) + require.Error(t, err) + require.ErrorIs(t, err, errGrafanaDBInstrumentedNotSupported) + }) + }) +} diff --git a/pkg/storage/unified/sql/db/dbimpl/util.go b/pkg/storage/unified/sql/db/dbimpl/util.go index 4065121fe56..da142be7d04 100644 --- a/pkg/storage/unified/sql/db/dbimpl/util.go +++ b/pkg/storage/unified/sql/db/dbimpl/util.go @@ -14,17 +14,35 @@ import ( var errInvalidUTF8Sequence = errors.New("invalid UTF-8 sequence") +type confGetter interface { + Err() error + Bool(key string) bool + String(key string) string +} + +func newConfGetter(ds *setting.DynamicSection, keyPrefix string) confGetter { + return §ionGetter{ + ds: ds, + keyPrefix: keyPrefix, + } +} + type sectionGetter struct { - *setting.DynamicSection - err error + ds *setting.DynamicSection + keyPrefix string + err error } func (g *sectionGetter) Err() error { return g.err } +func (g *sectionGetter) Bool(key string) bool { + return g.ds.Key(g.keyPrefix + key).MustBool(false) +} + func (g *sectionGetter) String(key string) string { - v := g.DynamicSection.Key(key).MustString("") + v := g.ds.Key(g.keyPrefix + key).MustString("") if !utf8.ValidString(v) { g.err = fmt.Errorf("value for key %q: %w", key, errInvalidUTF8Sequence) diff --git a/pkg/storage/unified/sql/db/dbimpl/util_test.go b/pkg/storage/unified/sql/db/dbimpl/util_test.go index 8364d0f653f..9fff4209e3e 100644 --- a/pkg/storage/unified/sql/db/dbimpl/util_test.go +++ b/pkg/storage/unified/sql/db/dbimpl/util_test.go @@ -17,35 +17,75 @@ func setSectionKeyValues(section *setting.DynamicSection, m map[string]string) { } } -func newTestSectionGetter(m map[string]string) *sectionGetter { +func newTestConfGetter(m map[string]string, keyPrefix string) confGetter { section := setting.NewCfg().SectionWithEnvOverrides("entity_api") setSectionKeyValues(section, m) - return §ionGetter{ - DynamicSection: section, - } + return newConfGetter(section, keyPrefix) } func TestSectionGetter(t *testing.T) { t.Parallel() var ( - key = "the key" - val = string(invalidUTF8ByteSequence) + key = "the key" + keyBoolTrue = "I'm true" + keyBoolFalse = "not me!" + prefix = "this is some prefix" + val = string(invalidUTF8ByteSequence) ) - g := newTestSectionGetter(map[string]string{ - key: val, + t.Run("with prefix", func(t *testing.T) { + t.Parallel() + + g := newTestConfGetter(map[string]string{ + prefix + key: val, + prefix + keyBoolTrue: "YES", + prefix + keyBoolFalse: "0", + }, prefix) + + require.False(t, g.Bool("whatever bool")) + require.NoError(t, g.Err()) + + require.False(t, g.Bool(keyBoolFalse)) + require.NoError(t, g.Err()) + + require.True(t, g.Bool(keyBoolTrue)) + require.NoError(t, g.Err()) + + require.Empty(t, g.String("whatever string")) + require.NoError(t, g.Err()) + + require.Empty(t, g.String(key)) + require.Error(t, g.Err()) + require.ErrorIs(t, g.Err(), errInvalidUTF8Sequence) }) - v := g.String("whatever") - require.Empty(t, v) - require.NoError(t, g.Err()) + t.Run("without prefix", func(t *testing.T) { + t.Parallel() - v = g.String(key) - require.Empty(t, v) - require.Error(t, g.Err()) - require.ErrorIs(t, g.Err(), errInvalidUTF8Sequence) + g := newTestConfGetter(map[string]string{ + key: val, + keyBoolTrue: "true", + keyBoolFalse: "f", + }, "") + + require.False(t, g.Bool("whatever bool")) + require.NoError(t, g.Err()) + + require.False(t, g.Bool(keyBoolFalse)) + require.NoError(t, g.Err()) + + require.True(t, g.Bool(keyBoolTrue)) + require.NoError(t, g.Err()) + + require.Empty(t, g.String("whatever string")) + require.NoError(t, g.Err()) + + require.Empty(t, g.String(key)) + require.Error(t, g.Err()) + require.ErrorIs(t, g.Err(), errInvalidUTF8Sequence) + }) } func TestMakeDSN(t *testing.T) { diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index 58770875bc3..7fcef520fc1 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -15,7 +15,7 @@ func NewResourceServer(db infraDB.DB, cfg *setting.Cfg, features featuremgmt.Fea Tracer: tracer, } - eDB, err := dbimpl.ProvideResourceDB(db, cfg, features, tracer) + eDB, err := dbimpl.ProvideResourceDB(db, cfg, tracer) if err != nil { return nil, err } diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index dcc655867d6..6b54d131f95 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -31,9 +31,8 @@ func newServer(t *testing.T) (sql.Backend, resource.ResourceServer) { dbstore := infraDB.InitTestDB(t) cfg := setting.NewCfg() - features := featuremgmt.WithFeatures() - eDB, err := dbimpl.ProvideResourceDB(dbstore, cfg, features, nil) + eDB, err := dbimpl.ProvideResourceDB(dbstore, cfg, nil) require.NoError(t, err) require.NotNil(t, eDB) From 153036be2ec7111d0b96076448ba0d3724af74e4 Mon Sep 17 00:00:00 2001 From: Misi Date: Fri, 4 Oct 2024 14:32:26 +0200 Subject: [PATCH 005/115] Docs: Add docs for configuring kc_idp_hint (#94226) Add docs for configuring kc_idp_hint --- .../configure-authentication/keycloak/index.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md index 6f2dbea11ab..1bd6cfde205 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/keycloak/index.md @@ -53,6 +53,12 @@ role_attribute_path = contains(roles[*], 'admin') && 'Admin' || contains(roles[* As an example, `` can be `keycloak-demo.grafana.org` and `` can be `grafana`. +To configure the `kc_idp_hint` parameter for Keycloak, you need to change the `auth_url` configuration to include the `kc_idp_hint` parameter. For example if you want to hint the Google identity provider: + +```ini +auth_url = https:///realms//protocol/openid-connect/auth?kc_idp_hint=google +``` + {{% admonition type="note" %}} api_url is not required if the id_token contains all the necessary user information and can add latency to the login process. It is useful as a fallback or if the user has more than 150 group memberships. From 0db65d229e36b78802c1e8bd0713ac44e7a7cdc7 Mon Sep 17 00:00:00 2001 From: Giuseppe Guerra Date: Fri, 4 Oct 2024 14:55:09 +0200 Subject: [PATCH 006/115] Plugins: Add Subresource Integrity checks (#93024) * Plugins: Pass hashes for SRI to frontend * Add SRI hashes to frontendsettings DTOs * Add docstring * TestSriHashes * Fix typo * Changed SriHashes to ModuleHash * update loader_test compareOpts * update ModuleHash error message * Add TestModuleHash/no_module.js * Add omitEmpty to moduleHash * Add ModuleHash to api/plugins/${pluginId}/settings * moved ModuleHash field * feat(plugins): add moduleHash to bootData and plugin types * feat(plugins): if moduleHash is available apply it to systemjs importmap * Calculate ModuleHash for CDN provisioned plugins * Add ModuleHash tests for TestCalculate * adjust test case name * removed .envrc * Fix signature verification failing for internal plugins * fix tests * Add pluginsFilesystemSriChecks feature togglemk * renamed FilesystemSriChecksEnabled * refactor(plugin_loader): prefer extending type declaration over ts-error * added a couple more tests * Removed unused features * Removed unused argument from signature.DefaultCalculator call * Removed unused argument from bootstrap.DefaultConstructFunc * Moved ModuleHash to pluginassets service * update docstring * lint * Removed cdn dependency from manifest.Signature * add tests * fix extra parameters in tests * "fix" tests * removed outdated test * removed unused cdn dependency in signature.DefaultCalculator * reduce diff * Cache returned values * Add support for deeply nested plugins (more than 1 hierarchy level) * simplify cache usage * refactor TestService_ModuleHash_Cache * removed unused testdata * re-generate feature toggles * use version for module hash cache * Renamed feature toggle to pluginsSriChecks and use it for both cdn and filesystem * Removed app/types/system-integrity.d.ts * re-generate feature toggles * re-generate feature toggles * feat(plugins): put systemjs integrity hash behind feature flag --------- Co-authored-by: Jack Westbrook --- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + packages/grafana-data/src/types/plugin.ts | 1 + packages/grafana-runtime/src/config.ts | 1 + pkg/api/dtos/plugins.go | 1 + pkg/api/frontendsettings.go | 8 +- pkg/api/frontendsettings_test.go | 54 +-- pkg/api/plugins.go | 1 + pkg/api/plugins_test.go | 9 +- pkg/plugins/config/config.go | 1 + pkg/plugins/manager/signature/manifest.go | 73 ++-- .../manager/signature/manifest_test.go | 34 +- pkg/plugins/models.go | 3 + 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 + .../pluginassets/pluginassets.go | 123 +++++- .../pluginassets/pluginassets_test.go | 374 +++++++++++++++++- .../module-hash-no-manifest-txt/module.js | 1 + .../module-hash-no-manifest-txt/plugin.json | 15 + .../module-hash-no-module-js/MANIFEST.txt | 29 ++ .../module-hash-no-module-js/plugin.json | 15 + .../module-hash-no-module-js/something.js | 1 + .../MANIFEST.txt | 33 ++ .../datasource/module.js | 1 + .../datasource/panels/one/module.js | 1 + .../datasource/panels/one/plugin.json | 13 + .../datasource/plugin.json | 15 + .../module-hash-valid-deeply-nested/module.js | 1 + .../plugin.json | 15 + .../module-hash-valid-nested/MANIFEST.txt | 33 ++ .../datasource/module.js | 1 + .../datasource/plugin.json | 15 + .../module-hash-valid-nested/module.js | 1 + .../panels/one/module.js | 1 + .../panels/one/plugin.json | 13 + .../module-hash-valid-nested/plugin.json | 15 + .../testdata/module-hash-valid/MANIFEST.txt | 32 ++ .../testdata/module-hash-valid/module.js | 1 + .../testdata/module-hash-valid/plugin.json | 15 + .../pluginsintegration/pluginconfig/config.go | 1 + .../pluginsintegration/pluginstore/plugins.go | 6 +- .../app/features/plugins/importPanelPlugin.ts | 1 + .../app/features/plugins/pluginPreloader.ts | 1 + public/app/features/plugins/plugin_loader.ts | 21 +- 46 files changed, 901 insertions(+), 104 deletions(-) create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/module.js create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/plugin.json create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/MANIFEST.txt create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/plugin.json create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/something.js create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/MANIFEST.txt create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/module.js create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/module.js create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/plugin.json create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/plugin.json create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/module.js create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/plugin.json create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/MANIFEST.txt create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/module.js create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/plugin.json create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/module.js create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/module.js create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/plugin.json create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/plugin.json create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/MANIFEST.txt create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/module.js create mode 100644 pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/plugin.json 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 27333e0979c..5e11f8b5448 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -207,6 +207,7 @@ Experimental features might be changed or removed without prior notice. | `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 | +| `pluginsSriChecks` | Enables SRI checks for plugin assets | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f01cb7197a0..7371f32a641 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -219,4 +219,5 @@ export interface FeatureToggles { useSessionStorageForRedirection?: boolean; rolePickerDrawer?: boolean; unifiedStorageSearch?: boolean; + pluginsSriChecks?: boolean; } diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index 4233771dbe6..1f64879d317 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -99,6 +99,7 @@ export interface PluginMeta { angularDetected?: boolean; loadingStrategy?: PluginLoadingStrategy; extensions?: PluginExtensions; + moduleHash?: string; } interface PluginDependencyInfo { diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 71ec381d45b..c7c00a37c3b 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -46,6 +46,7 @@ export type AppPluginConfig = { loadingStrategy: PluginLoadingStrategy; dependencies: PluginDependencies; extensions: PluginExtensions; + moduleHash?: string; }; export type PreinstalledPlugin = { diff --git a/pkg/api/dtos/plugins.go b/pkg/api/dtos/plugins.go index b14b915e5ff..703178e7000 100644 --- a/pkg/api/dtos/plugins.go +++ b/pkg/api/dtos/plugins.go @@ -30,6 +30,7 @@ type PluginSetting struct { SignatureOrg string `json:"signatureOrg"` AngularDetected bool `json:"angularDetected"` LoadingStrategy plugins.LoadingStrategy `json:"loadingStrategy"` + ModuleHash string `json:"moduleHash,omitempty"` } type PluginListItem struct { diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 511d673ca6b..3aea664fe2d 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -145,6 +145,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro AliasIDs: panel.AliasIDs, Info: panel.Info, Module: panel.Module, + ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), panel), BaseURL: panel.BaseURL, SkipDataQuery: panel.SkipDataQuery, HideFromList: panel.HideFromList, @@ -453,6 +454,7 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug JSONData: plugin.JSONData, Signature: plugin.Signature, Module: plugin.Module, + ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin), BaseURL: plugin.BaseURL, Angular: plugin.Angular, MultiValueFilterOperators: plugin.MultiValueFilterOperators, @@ -538,8 +540,9 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug JSONData: ds.JSONData, Signature: ds.Signature, Module: ds.Module, - BaseURL: ds.BaseURL, - Angular: ds.Angular, + // ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), ds), + BaseURL: ds.BaseURL, + Angular: ds.Angular, }, } if ds.Name == grafanads.DatasourceName { @@ -563,6 +566,7 @@ func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin, LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin), Extensions: plugin.Extensions, Dependencies: plugin.Dependencies, + ModuleHash: hs.pluginAssets.ModuleHash(ctx, plugin), } if settings.Enabled { diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index 6d7139e28db..9edb77235f4 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -18,6 +18,8 @@ import ( "github.com/grafana/grafana/pkg/login/social/socialimpl" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/manager/signature" + "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginscdn" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" @@ -51,10 +53,11 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F }) } - pluginsCDN := pluginscdn.ProvideService(&config.PluginManagementCfg{ + pluginsCfg := &config.PluginManagementCfg{ PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate, PluginSettings: cfg.PluginSettings, - }) + } + pluginsCDN := pluginscdn.ProvideService(pluginsCfg) var pluginStore = pstore if pluginStore == nil { @@ -68,7 +71,8 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F var pluginsAssets = passets if pluginsAssets == nil { - pluginsAssets = pluginassets.ProvideService(cfg, pluginsCDN) + sig := signature.ProvideService(pluginsCfg, statickey.New()) + pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, sig, pluginStore) } hs := &HTTPServer{ @@ -240,6 +244,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { PluginList: []pluginstore.Plugin{ { Module: fmt.Sprintf("/%s/module.js", "test-app"), + // ModuleHash: "sha256-test", JSONData: plugins.JSONData{ ID: "test-app", Info: plugins.Info{Version: "0.5.0"}, @@ -255,9 +260,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { Plugins: newAppSettings("test-app", false), } }, - pluginAssets: func() *pluginassets.Service { - return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{})) - }, + pluginAssets: newPluginAssets(), expected: settings{ Apps: map[string]*plugins.AppDTO{ "test-app": { @@ -266,6 +269,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { Path: "/test-app/module.js", Version: "0.5.0", LoadingStrategy: plugins.LoadingStrategyScript, + // ModuleHash: "sha256-test", }, }, }, @@ -277,6 +281,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { PluginList: []pluginstore.Plugin{ { Module: fmt.Sprintf("/%s/module.js", "test-app"), + // ModuleHash: "sha256-test", JSONData: plugins.JSONData{ ID: "test-app", Info: plugins.Info{Version: "0.5.0"}, @@ -292,9 +297,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { Plugins: newAppSettings("test-app", true), } }, - pluginAssets: func() *pluginassets.Service { - return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{})) - }, + pluginAssets: newPluginAssets(), expected: settings{ Apps: map[string]*plugins.AppDTO{ "test-app": { @@ -303,6 +306,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { Path: "/test-app/module.js", Version: "0.5.0", LoadingStrategy: plugins.LoadingStrategyScript, + // ModuleHash: "sha256-test", }, }, }, @@ -330,9 +334,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { Plugins: newAppSettings("test-app", true), } }, - pluginAssets: func() *pluginassets.Service { - return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{})) - }, + pluginAssets: newPluginAssets(), expected: settings{ Apps: map[string]*plugins.AppDTO{ "test-app": { @@ -368,15 +370,13 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { Plugins: newAppSettings("test-app", true), } }, - pluginAssets: func() *pluginassets.Service { - return pluginassets.ProvideService(&setting.Cfg{ - PluginSettings: map[string]map[string]string{ - "test-app": { - pluginassets.CreatePluginVersionCfgKey: pluginassets.CreatePluginVersionScriptSupportEnabled, - }, + pluginAssets: newPluginAssetsWithConfig(&config.PluginManagementCfg{ + PluginSettings: map[string]map[string]string{ + "test-app": { + pluginassets.CreatePluginVersionCfgKey: pluginassets.CreatePluginVersionScriptSupportEnabled, }, - }, pluginscdn.ProvideService(&config.PluginManagementCfg{})) - }, + }, + }), expected: settings{ Apps: map[string]*plugins.AppDTO{ "test-app": { @@ -412,9 +412,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { Plugins: newAppSettings("test-app", true), } }, - pluginAssets: func() *pluginassets.Service { - return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{})) - }, + pluginAssets: newPluginAssets(), expected: settings{ Apps: map[string]*plugins.AppDTO{ "test-app": { @@ -456,3 +454,13 @@ func newAppSettings(id string, enabled bool) map[string]*pluginsettings.DTO { }, } } + +func newPluginAssets() func() *pluginassets.Service { + return newPluginAssetsWithConfig(&config.PluginManagementCfg{}) +} + +func newPluginAssetsWithConfig(pCfg *config.PluginManagementCfg) func() *pluginassets.Service { + return func() *pluginassets.Service { + return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), signature.ProvideService(pCfg, statickey.New()), &pluginstore.FakePluginStore{}) + } +} diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index e690dc91709..e947233e2fb 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -201,6 +201,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response. Includes: plugin.Includes, BaseUrl: plugin.BaseURL, Module: plugin.Module, + ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin), DefaultNavUrl: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL), State: plugin.State, Signature: plugin.Signature, diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index b517c964f84..7cb30bd428d 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -27,6 +27,8 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/plugins/manager/filestore" "github.com/grafana/grafana/pkg/plugins/manager/registry" + "github.com/grafana/grafana/pkg/plugins/manager/signature" + "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pfs" "github.com/grafana/grafana/pkg/plugins/pluginscdn" ac "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -788,7 +790,6 @@ func Test_PluginsSettings(t *testing.T) { Info: plugins.Info{ Version: "1.0.0", }}, plugins.ClassExternal, plugins.NewFakeFS()) - pluginRegistry := &fakes.FakePluginRegistry{ Store: map[string]*plugins.Plugin{ p1.ID: p1, @@ -843,8 +844,10 @@ func Test_PluginsSettings(t *testing.T) { ErrorCode: tc.errCode, }) } - pluginCDN := pluginscdn.ProvideService(&config.PluginManagementCfg{}) - hs.pluginAssets = pluginassets.ProvideService(hs.Cfg, pluginCDN) + pCfg := &config.PluginManagementCfg{} + pluginCDN := pluginscdn.ProvideService(pCfg) + sig := signature.ProvideService(pCfg, statickey.New()) + hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, sig, hs.pluginStore) hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker) var err error hs.pluginsUpdateChecker, err = updatechecker.ProvidePluginsService(hs.Cfg, nil, tracing.InitializeTracerForTest()) diff --git a/pkg/plugins/config/config.go b/pkg/plugins/config/config.go index a8081f728a7..37ba863c86f 100644 --- a/pkg/plugins/config/config.go +++ b/pkg/plugins/config/config.go @@ -32,6 +32,7 @@ type PluginManagementCfg struct { type Features struct { ExternalCorePluginsEnabled bool SkipHostEnvVarsEnabled bool + SriChecksEnabled bool } // NewPluginManagementCfg returns a new PluginManagementCfg. diff --git a/pkg/plugins/manager/signature/manifest.go b/pkg/plugins/manager/signature/manifest.go index 4bb035f011e..c363f888198 100644 --- a/pkg/plugins/manager/signature/manifest.go +++ b/pkg/plugins/manager/signature/manifest.go @@ -53,7 +53,7 @@ type PluginManifest struct { RootURLs []string `json:"rootUrls"` } -func (m *PluginManifest) isV2() bool { +func (m *PluginManifest) IsV2() bool { return strings.HasPrefix(m.ManifestVersion, "2.") } @@ -107,34 +107,17 @@ func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*Plugi return &manifest, nil } -func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) { - if defaultSignature, exists := src.DefaultSignature(ctx); exists { - return defaultSignature, nil - } - fsFiles, err := plugin.FS.Files() - if err != nil { - return plugins.Signature{}, fmt.Errorf("files: %w", err) - } - if len(fsFiles) == 0 { - s.log.Warn("No plugin file information in directory", "pluginId", plugin.JSONData.ID) - return plugins.Signature{ - Status: plugins.SignatureStatusInvalid, - }, nil - } +var ErrSignatureTypeUnsigned = errors.New("plugin is unsigned") - f, err := plugin.FS.Open("MANIFEST.txt") +// ReadPluginManifestFromFS reads the plugin manifest from the provided plugins.FS. +// If the manifest is not found, it will return an error wrapping ErrSignatureTypeUnsigned. +func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*PluginManifest, error) { + f, err := pfs.Open("MANIFEST.txt") if err != nil { if errors.Is(err, plugins.ErrFileNotExist) { - s.log.Debug("Could not find a MANIFEST.txt", "id", plugin.JSONData.ID, "error", err) - return plugins.Signature{ - Status: plugins.SignatureStatusUnsigned, - }, nil + return nil, fmt.Errorf("%w: could not find a MANIFEST.txt", ErrSignatureTypeUnsigned) } - - s.log.Debug("Could not open MANIFEST.txt", "id", plugin.JSONData.ID, "error", err) - return plugins.Signature{ - Status: plugins.SignatureStatusInvalid, - }, nil + return nil, fmt.Errorf("could not open MANIFEST.txt: %w", err) } defer func() { if f == nil { @@ -147,21 +130,47 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu byteValue, err := io.ReadAll(f) if err != nil || len(byteValue) < 10 { - s.log.Debug("MANIFEST.TXT is invalid", "id", plugin.JSONData.ID) - return plugins.Signature{ - Status: plugins.SignatureStatusUnsigned, - }, nil + return nil, fmt.Errorf("%w: MANIFEST.txt is invalid", ErrSignatureTypeUnsigned) } manifest, err := s.readPluginManifest(ctx, byteValue) if err != nil { - s.log.Warn("Plugin signature invalid", "id", plugin.JSONData.ID, "error", err) + return nil, err + } + return manifest, nil +} + +func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) { + if defaultSignature, exists := src.DefaultSignature(ctx); exists { + return defaultSignature, nil + } + + manifest, err := s.ReadPluginManifestFromFS(ctx, plugin.FS) + switch { + case errors.Is(err, ErrSignatureTypeUnsigned): + s.log.Warn("Plugin is unsigned", "id", plugin.JSONData.ID, "err", err) + return plugins.Signature{ + Status: plugins.SignatureStatusUnsigned, + }, nil + case err != nil: + s.log.Warn("Plugin signature is invalid", "id", plugin.JSONData.ID, "err", err) return plugins.Signature{ Status: plugins.SignatureStatusInvalid, }, nil } - if !manifest.isV2() { + if !manifest.IsV2() { + return plugins.Signature{ + Status: plugins.SignatureStatusInvalid, + }, nil + } + + fsFiles, err := plugin.FS.Files() + if err != nil { + return plugins.Signature{}, fmt.Errorf("files: %w", err) + } + if len(fsFiles) == 0 { + s.log.Warn("No plugin file information in directory", "pluginId", plugin.JSONData.ID) return plugins.Signature{ Status: plugins.SignatureStatusInvalid, }, nil @@ -328,7 +337,7 @@ func (s *Signature) validateManifest(ctx context.Context, m PluginManifest, bloc if len(m.Files) == 0 { return invalidFieldErr{field: "files"} } - if m.isV2() { + if m.IsV2() { if len(m.SignedByOrg) == 0 { return invalidFieldErr{field: "signedByOrg"} } diff --git a/pkg/plugins/manager/signature/manifest_test.go b/pkg/plugins/manager/signature/manifest_test.go index cb7364ed93a..e83b527eaf0 100644 --- a/pkg/plugins/manager/signature/manifest_test.go +++ b/pkg/plugins/manager/signature/manifest_test.go @@ -19,6 +19,14 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" ) +func provideDefaultTestService() *Signature { + return provideTestServiceWithConfig(&config.PluginManagementCfg{}) +} + +func provideTestServiceWithConfig(cfg *config.PluginManagementCfg) *Signature { + return ProvideService(cfg, statickey.New()) +} + func TestReadPluginManifest(t *testing.T) { txt := `-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA512 @@ -52,7 +60,7 @@ NR7DnB0CCQHO+4FlSPtXFTzNepoc+CytQyDAeOLMLmf2Tqhk2YShk+G/YlVX -----END PGP SIGNATURE-----` t.Run("valid manifest", func(t *testing.T) { - s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) + s := provideDefaultTestService() manifest, err := s.readPluginManifest(context.Background(), []byte(txt)) require.NoError(t, err) @@ -68,8 +76,8 @@ NR7DnB0CCQHO+4FlSPtXFTzNepoc+CytQyDAeOLMLmf2Tqhk2YShk+G/YlVX }) t.Run("invalid manifest", func(t *testing.T) { + s := provideDefaultTestService() modified := strings.ReplaceAll(txt, "README.md", "xxxxxxxxxx") - s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) _, err := s.readPluginManifest(context.Background(), []byte(modified)) require.Error(t, err) }) @@ -107,7 +115,7 @@ khdr/tZ1PDgRxMqB/u+Vtbpl0xSxgblnrDOYMSI= -----END PGP SIGNATURE-----` t.Run("valid manifest", func(t *testing.T) { - s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) + s := provideDefaultTestService() manifest, err := s.readPluginManifest(context.Background(), []byte(txt)) require.NoError(t, err) @@ -126,6 +134,12 @@ khdr/tZ1PDgRxMqB/u+Vtbpl0xSxgblnrDOYMSI= } func TestCalculate(t *testing.T) { + parentDir, err := filepath.Abs("../") + if err != nil { + t.Errorf("could not construct absolute path of current dir") + return + } + t.Run("Validate root URL against App URL for non-private plugin if is specified in manifest", func(t *testing.T) { tcs := []struct { appURL string @@ -147,15 +161,9 @@ func TestCalculate(t *testing.T) { }, } - parentDir, err := filepath.Abs("../") - if err != nil { - t.Errorf("could not construct absolute path of current dir") - return - } - for _, tc := range tcs { basePath := filepath.Join(parentDir, "testdata/non-pvt-with-root-url/plugin") - s := ProvideService(&config.PluginManagementCfg{GrafanaAppURL: tc.appURL}, statickey.New()) + s := provideTestServiceWithConfig(&config.PluginManagementCfg{GrafanaAppURL: tc.appURL}) sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal @@ -183,7 +191,7 @@ func TestCalculate(t *testing.T) { basePath := "../testdata/renderer-added-file/plugin" runningWindows = true - s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) + s := provideDefaultTestService() sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{ PluginClassFunc: func(ctx context.Context) plugins.Class { return plugins.ClassExternal @@ -247,7 +255,7 @@ func TestCalculate(t *testing.T) { toSlash = tc.platform.toSlashFunc() fromSlash = tc.platform.fromSlashFunc() - s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) + s := provideDefaultTestService() pfs, err := tc.fsFactory() require.NoError(t, err) pfs, err = newPathSeparatorOverrideFS(string(tc.platform.separator), pfs) @@ -721,7 +729,7 @@ func Test_validateManifest(t *testing.T) { } for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { - s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) + s := provideDefaultTestService() err := s.validateManifest(context.Background(), *tc.manifest, nil) require.Errorf(t, err, tc.expectedErr) }) diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index c14cc44dd17..6b9b8e05ad2 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -262,6 +262,7 @@ type PluginMetaDTO struct { JSONData Signature SignatureStatus `json:"signature"` Module string `json:"module"` + ModuleHash string `json:"moduleHash,omitempty"` BaseURL string `json:"baseUrl"` Angular AngularMeta `json:"angular"` MultiValueFilterOperators bool `json:"multiValueFilterOperators"` @@ -314,6 +315,7 @@ type PanelDTO struct { Module string `json:"module"` Angular AngularMeta `json:"angular"` LoadingStrategy LoadingStrategy `json:"loadingStrategy"` + ModuleHash string `json:"moduleHash,omitempty"` } type AppDTO struct { @@ -325,6 +327,7 @@ type AppDTO struct { LoadingStrategy LoadingStrategy `json:"loadingStrategy"` Extensions Extensions `json:"extensions"` Dependencies Dependencies `json:"dependencies"` + ModuleHash string `json:"moduleHash,omitempty"` } const ( diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index cadc10ab7d2..e6827e35024 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1514,6 +1514,12 @@ var ( HideFromDocs: true, HideFromAdminPage: true, }, + { + Name: "pluginsSriChecks", + Description: "Enables SRI checks for plugin assets", + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 54e538d5207..3abc3046a2a 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -200,3 +200,4 @@ improvedExternalSessionHandling,experimental,@grafana/identity-access-team,false useSessionStorageForRedirection,preview,@grafana/identity-access-team,false,false,false rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false +pluginsSriChecks,experimental,@grafana/plugins-platform-backend,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index d6df6fbfae8..ee1c81f5d0b 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -810,4 +810,8 @@ const ( // FlagUnifiedStorageSearch // Enable unified storage search FlagUnifiedStorageSearch = "unifiedStorageSearch" + + // FlagPluginsSriChecks + // Enables SRI checks for plugin assets + FlagPluginsSriChecks = "pluginsSriChecks" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 0b1c37e9e21..b9f8b76f90d 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2367,6 +2367,18 @@ "codeowner": "@grafana/plugins-platform-backend" } }, + { + "metadata": { + "name": "pluginsSriChecks", + "resourceVersion": "1727785264632", + "creationTimestamp": "2024-10-01T12:21:04Z" + }, + "spec": { + "description": "Enables SRI checks for plugin assets", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, { "metadata": { "name": "preserveDashboardStateWhenNavigating", diff --git a/pkg/services/pluginsintegration/pluginassets/pluginassets.go b/pkg/services/pluginsintegration/pluginassets/pluginassets.go index aca1e536a12..3184a4ec772 100644 --- a/pkg/services/pluginsintegration/pluginassets/pluginassets.go +++ b/pkg/services/pluginsintegration/pluginassets/pluginassets.go @@ -2,14 +2,21 @@ package pluginassets import ( "context" + "encoding/base64" + "encoding/hex" + "fmt" + "path" + "path/filepath" + "sync" "github.com/Masterminds/semver/v3" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" - "github.com/grafana/grafana/pkg/setting" ) const ( @@ -21,18 +28,24 @@ var ( scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled) ) -func ProvideService(cfg *setting.Cfg, cdn *pluginscdn.Service) *Service { +func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, sig *signature.Signature, store pluginstore.Store) *Service { return &Service{ - cfg: cfg, - cdn: cdn, - log: log.New("pluginassets"), + cfg: cfg, + cdn: cdn, + signature: sig, + store: store, + log: log.New("pluginassets"), } } type Service struct { - cfg *setting.Cfg - cdn *pluginscdn.Service - log log.Logger + cfg *config.PluginManagementCfg + cdn *pluginscdn.Service + signature *signature.Signature + store pluginstore.Store + log log.Logger + + moduleHashCache sync.Map } // LoadingStrategy calculates the loading strategy for a plugin. @@ -69,6 +82,86 @@ func (s *Service) LoadingStrategy(_ context.Context, p pluginstore.Plugin) plugi return plugins.LoadingStrategyFetch } +// ModuleHash returns the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks. +// The module hash is read from the plugin's MANIFEST.txt file. +// The plugin can also be a nested plugin. +// If the plugin is unsigned, an empty string is returned. +// The results are cached to avoid repeated reads from the MANIFEST.txt file. +func (s *Service) ModuleHash(ctx context.Context, p pluginstore.Plugin) string { + k := s.moduleHashCacheKey(p) + cachedValue, ok := s.moduleHashCache.Load(k) + if ok { + return cachedValue.(string) + } + mh, err := s.moduleHash(ctx, p, "") + if err != nil { + s.log.Error("Failed to calculate module hash", "plugin", p.ID, "error", err) + } + s.moduleHashCache.Store(k, mh) + return mh +} + +// moduleHash is the underlying function for ModuleHash. See its documentation for more information. +// It will read the module hash from the MANIFEST.txt in the [[plugins.FS]] of the provided plugin. +// If childFSBase is provided, the function will try to get the hash from MANIFEST.txt for the provided children's +// module.js file, rather than for the provided plugin. +func (s *Service) moduleHash(ctx context.Context, p pluginstore.Plugin, childFSBase string) (r string, err error) { + if !s.cfg.Features.SriChecksEnabled { + return "", nil + } + + // Ignore unsigned plugins + if !p.Signature.IsValid() { + return "", nil + } + + if p.Parent != nil { + // Nested plugin + parent, ok := s.store.Plugin(ctx, p.Parent.ID) + if !ok { + return "", fmt.Errorf("parent plugin plugin %q for child plugin %q not found", p.Parent.ID, p.ID) + } + + // The module hash is contained within the parent's MANIFEST.txt file. + // For example, the parent's MANIFEST.txt will contain an entry similar to this: + // + // ``` + // "datasource/module.js": "1234567890abcdef..." + // ``` + // + // Recursively call moduleHash with the parent plugin and with the children plugin folder path + // to get the correct module hash for the nested plugin. + if childFSBase == "" { + childFSBase = p.Base() + } + return s.moduleHash(ctx, parent, childFSBase) + } + + manifest, err := s.signature.ReadPluginManifestFromFS(ctx, p.FS) + if err != nil { + return "", fmt.Errorf("read plugin manifest: %w", err) + } + if !manifest.IsV2() { + return "", nil + } + + var childPath string + if childFSBase != "" { + // Calculate the relative path of the child plugin folder from the parent plugin folder. + childPath, err = p.FS.Rel(childFSBase) + if err != nil { + return "", fmt.Errorf("rel path: %w", err) + } + // MANIFETS.txt uses forward slashes as path separators. + childPath = filepath.ToSlash(childPath) + } + moduleHash, ok := manifest.Files[path.Join(childPath, "module.js")] + if !ok { + return "", nil + } + return convertHashForSRI(moduleHash) +} + func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool { if cpv, ok := ps[CreatePluginVersionCfgKey]; ok { createPluginVer, err := semver.NewVersion(cpv) @@ -86,3 +179,17 @@ func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool { func (s *Service) cdnEnabled(pluginID string, class plugins.Class) bool { return s.cdn.PluginSupported(pluginID) || class == plugins.ClassCDN } + +// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks. +func convertHashForSRI(h string) (string, error) { + hb, err := hex.DecodeString(h) + if err != nil { + return "", fmt.Errorf("hex decode string: %w", err) + } + return "sha256-" + base64.StdEncoding.EncodeToString(hb), nil +} + +// moduleHashCacheKey returns a unique key for the module hash cache. +func (s *Service) moduleHashCacheKey(p pluginstore.Plugin) string { + return p.ID + ":" + p.Info.Version +} diff --git a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go index 26f7fa181a5..371a116f86c 100644 --- a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go +++ b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go @@ -2,13 +2,17 @@ package pluginassets import ( "context" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/manager/signature" + "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" @@ -34,7 +38,7 @@ func TestService_Calculate(t *testing.T) { pluginSettings: newPluginSettings(pluginID, map[string]string{ CreatePluginVersionCfgKey: compatVersion, }), - plugin: newPlugin(pluginID, false), + plugin: newPlugin(pluginID, withAngular(false)), expected: plugins.LoadingStrategyScript, }, { @@ -42,7 +46,7 @@ func TestService_Calculate(t *testing.T) { pluginSettings: newPluginSettings("parent-datasource", map[string]string{ CreatePluginVersionCfgKey: compatVersion, }), - plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin { + plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin { p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"} return p }), @@ -53,7 +57,7 @@ func TestService_Calculate(t *testing.T) { pluginSettings: newPluginSettings(pluginID, map[string]string{ CreatePluginVersionCfgKey: futureVersion, }), - plugin: newPlugin(pluginID, false), + plugin: newPlugin(pluginID, withAngular(false)), expected: plugins.LoadingStrategyScript, }, { @@ -61,7 +65,7 @@ func TestService_Calculate(t *testing.T) { pluginSettings: newPluginSettings(pluginID, map[string]string{ // NOTE: cdn key is not set }), - plugin: newPlugin(pluginID, false), + plugin: newPlugin(pluginID, withAngular(false)), expected: plugins.LoadingStrategyScript, }, { @@ -70,7 +74,7 @@ func TestService_Calculate(t *testing.T) { CreatePluginVersionCfgKey: incompatVersion, // NOTE: cdn key is not set }), - plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin { + plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin { p.Class = plugins.ClassExternal return p }), @@ -83,7 +87,7 @@ func TestService_Calculate(t *testing.T) { "cdn": "true", }, }, - plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin { + plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin { p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"} return p }), @@ -96,8 +100,7 @@ func TestService_Calculate(t *testing.T) { "cdn": "true", }, }, - plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin { - p.Angular.Detected = true + plugin: newPlugin(pluginID, withAngular(true), func(p pluginstore.Plugin) pluginstore.Plugin { p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"} return p }), @@ -106,8 +109,7 @@ func TestService_Calculate(t *testing.T) { { name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is not configured as CDN enabled and plugin is angular", pluginSettings: setting.PluginSettings{}, - plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin { - p.Angular.Detected = true + plugin: newPlugin(pluginID, withAngular(true), func(p pluginstore.Plugin) pluginstore.Plugin { p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"} return p }), @@ -119,7 +121,7 @@ func TestService_Calculate(t *testing.T) { "cdn": "true", CreatePluginVersionCfgKey: incompatVersion, }), - plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin { + plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin { p.Class = plugins.ClassExternal return p }), @@ -130,7 +132,7 @@ func TestService_Calculate(t *testing.T) { pluginSettings: newPluginSettings(pluginID, map[string]string{ CreatePluginVersionCfgKey: incompatVersion, }), - plugin: newPlugin(pluginID, true), + plugin: newPlugin(pluginID, withAngular(true)), expected: plugins.LoadingStrategyFetch, }, { @@ -139,7 +141,7 @@ func TestService_Calculate(t *testing.T) { "cdn": "true", CreatePluginVersionCfgKey: incompatVersion, }), - plugin: newPlugin(pluginID, false), + plugin: newPlugin(pluginID, withAngular(false)), expected: plugins.LoadingStrategyFetch, }, { @@ -147,7 +149,7 @@ func TestService_Calculate(t *testing.T) { pluginSettings: newPluginSettings(pluginID, map[string]string{ CreatePluginVersionCfgKey: incompatVersion, }), - plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin { + plugin: newPlugin(pluginID, withAngular(false), func(p pluginstore.Plugin) pluginstore.Plugin { p.Class = plugins.ClassCDN return p }), @@ -158,7 +160,7 @@ func TestService_Calculate(t *testing.T) { pluginSettings: newPluginSettings(pluginID, map[string]string{ CreatePluginVersionCfgKey: "invalidSemver", }), - plugin: newPlugin(pluginID, false), + plugin: newPlugin(pluginID, withAngular(false)), expected: plugins.LoadingStrategyScript, }, } @@ -179,12 +181,305 @@ func TestService_Calculate(t *testing.T) { } } -func newPlugin(pluginID string, angular bool, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin { +func TestService_ModuleHash(t *testing.T) { + const ( + pluginID = "grafana-test-datasource" + parentPluginID = "grafana-test-app" + ) + for _, tc := range []struct { + name string + features *config.Features + store []pluginstore.Plugin + plugin pluginstore.Plugin + cdn bool + expModuleHash string + }{ + { + name: "unsigned should not return module hash", + plugin: newPlugin(pluginID, withSignatureStatus(plugins.SignatureStatusUnsigned)), + cdn: false, + features: &config.Features{SriChecksEnabled: false}, + expModuleHash: "", + }, + { + name: "feature flag on with cdn on should return module hash", + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), + ), + cdn: true, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"), + }, + { + name: "feature flag on with cdn off should return module hash", + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), + ), + cdn: false, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"), + }, + { + name: "feature flag off with cdn on should not return module hash", + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), + ), + cdn: true, + features: &config.Features{SriChecksEnabled: false}, + expModuleHash: "", + }, + { + name: "feature flag off with cdn off should not return module hash", + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), + ), + cdn: false, + features: &config.Features{SriChecksEnabled: false}, + expModuleHash: "", + }, + { + // parentPluginID (/) + // └── pluginID (/datasource) + name: "nested plugin should return module hash from parent MANIFEST.txt", + store: []pluginstore.Plugin{ + newPlugin( + parentPluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))), + ), + }, + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource"))), + withParent(parentPluginID), + ), + cdn: false, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: newSRIHash(t, "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711"), + }, + { + // parentPluginID (/) + // └── pluginID (/panels/one) + name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt", + store: []pluginstore.Plugin{ + newPlugin( + parentPluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))), + ), + }, + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))), + withParent(parentPluginID), + ), + cdn: false, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"), + }, + { + // grand-parent-app (/) + // ├── parent-datasource (/datasource) + // │ └── child-panel (/datasource/panels/one) + name: "nested plugin of a nested plugin should return module hash from parent MANIFEST.txt", + store: []pluginstore.Plugin{ + newPlugin( + "grand-parent-app", + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested"))), + ), + newPlugin( + "parent-datasource", + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource"))), + withParent("grand-parent-app"), + ), + }, + plugin: newPlugin( + "child-panel", + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one"))), + withParent("parent-datasource"), + ), + cdn: false, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"), + }, + { + name: "nested plugin should not return module hash from parent if it's not registered in the store", + store: []pluginstore.Plugin{}, + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))), + withParent(parentPluginID), + ), + cdn: false, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: "", + }, + { + name: "missing module.js entry from MANIFEST.txt should not return module hash", + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js"))), + ), + cdn: false, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: "", + }, + { + name: "signed status but missing MANIFEST.txt should not return module hash", + plugin: newPlugin( + pluginID, + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt"))), + ), + cdn: false, + features: &config.Features{SriChecksEnabled: true}, + expModuleHash: "", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var pluginSettings setting.PluginSettings + if tc.cdn { + pluginSettings = newPluginSettings(pluginID, map[string]string{ + "cdn": "true", + }) + } + features := tc.features + if features == nil { + features = &config.Features{} + } + pCfg := &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "http://cdn.example.com", + PluginSettings: pluginSettings, + Features: *features, + } + svc := ProvideService( + pCfg, + pluginscdn.ProvideService(pCfg), + signature.ProvideService(pCfg, statickey.New()), + pluginstore.NewFakePluginStore(tc.store...), + ) + mh := svc.ModuleHash(context.Background(), tc.plugin) + require.Equal(t, tc.expModuleHash, mh) + }) + } +} + +func TestService_ModuleHash_Cache(t *testing.T) { + pCfg := &config.PluginManagementCfg{ + PluginSettings: setting.PluginSettings{}, + Features: config.Features{SriChecksEnabled: true}, + } + svc := ProvideService( + pCfg, + pluginscdn.ProvideService(pCfg), + signature.ProvideService(pCfg, statickey.New()), + pluginstore.NewFakePluginStore(), + ) + const pluginID = "grafana-test-datasource" + + t.Run("cache key", func(t *testing.T) { + t.Run("with version", func(t *testing.T) { + const pluginVersion = "1.0.0" + p := newPlugin(pluginID, withInfo(plugins.Info{Version: pluginVersion})) + k := svc.moduleHashCacheKey(p) + require.Equal(t, pluginID+":"+pluginVersion, k, "cache key should be correct") + }) + + t.Run("without version", func(t *testing.T) { + p := newPlugin(pluginID) + k := svc.moduleHashCacheKey(p) + require.Equal(t, pluginID+":", k, "cache key should be correct") + }) + }) + + t.Run("ModuleHash usage", func(t *testing.T) { + pV1 := newPlugin( + pluginID, + withInfo(plugins.Info{Version: "1.0.0"}), + withSignatureStatus(plugins.SignatureStatusValid), + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), + ) + k := svc.moduleHashCacheKey(pV1) + + _, ok := svc.moduleHashCache.Load(k) + require.False(t, ok, "cache should initially be empty") + + mhV1 := svc.ModuleHash(context.Background(), pV1) + pV1Exp := newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03") + require.Equal(t, pV1Exp, mhV1, "returned value should be correct") + + cachedMh, ok := svc.moduleHashCache.Load(k) + require.True(t, ok) + require.Equal(t, pV1Exp, cachedMh, "cache should contain the returned value") + + t.Run("different version uses different cache key", func(t *testing.T) { + pV2 := newPlugin( + pluginID, + withInfo(plugins.Info{Version: "2.0.0"}), + withSignatureStatus(plugins.SignatureStatusValid), + // different fs for different hash + withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))), + ) + mhV2 := svc.ModuleHash(context.Background(), pV2) + require.NotEqual(t, mhV2, mhV1, "different version should have different hash") + require.Equal(t, newSRIHash(t, "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"), mhV2) + }) + + t.Run("cache should be used", func(t *testing.T) { + // edit cache directly + svc.moduleHashCache.Store(k, "hax") + require.Equal(t, "hax", svc.ModuleHash(context.Background(), pV1)) + }) + }) +} + +func TestConvertHashFromSRI(t *testing.T) { + for _, tc := range []struct { + hash string + expHash string + expErr bool + }{ + { + hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811", + expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=", + }, + { + hash: "not-a-valid-hash", + expErr: true, + }, + } { + t.Run(tc.hash, func(t *testing.T) { + r, err := convertHashForSRI(tc.hash) + if tc.expErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.expHash, r) + } + }) + } +} + +func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin { p := pluginstore.Plugin{ JSONData: plugins.JSONData{ ID: pluginID, }, - Angular: plugins.AngularMeta{Detected: angular}, } for _, cb := range cbs { p = cb(p) @@ -192,8 +487,43 @@ func newPlugin(pluginID string, angular bool, cbs ...func(p pluginstore.Plugin) return p } -func newCfg(ps setting.PluginSettings) *setting.Cfg { - return &setting.Cfg{ +func withInfo(info plugins.Info) func(p pluginstore.Plugin) pluginstore.Plugin { + return func(p pluginstore.Plugin) pluginstore.Plugin { + p.Info = info + return p + } +} + +func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin { + return func(p pluginstore.Plugin) pluginstore.Plugin { + p.FS = fs + return p + } +} + +func withSignatureStatus(status plugins.SignatureStatus) func(p pluginstore.Plugin) pluginstore.Plugin { + return func(p pluginstore.Plugin) pluginstore.Plugin { + p.Signature = status + return p + } +} + +func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin { + return func(p pluginstore.Plugin) pluginstore.Plugin { + p.Angular = plugins.AngularMeta{Detected: angular} + return p + } +} + +func withParent(parentID string) func(p pluginstore.Plugin) pluginstore.Plugin { + return func(p pluginstore.Plugin) pluginstore.Plugin { + p.Parent = &pluginstore.ParentPlugin{ID: parentID} + return p + } +} + +func newCfg(ps setting.PluginSettings) *config.PluginManagementCfg { + return &config.PluginManagementCfg{ PluginSettings: ps, } } @@ -203,3 +533,9 @@ func newPluginSettings(pluginID string, kv map[string]string) setting.PluginSett pluginID: kv, } } + +func newSRIHash(t *testing.T, s string) string { + r, err := convertHashForSRI(s) + require.NoError(t, err) + return r +} diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/module.js new file mode 100644 index 00000000000..fcb71c21418 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/module.js @@ -0,0 +1 @@ +hello parent diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/plugin.json new file mode 100644 index 00000000000..122b5358b89 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-manifest-txt/plugin.json @@ -0,0 +1,15 @@ +{ + "type": "app", + "name": "Test", + "id": "test-app", + "backend": true, + "executable": "test", + "state": "alpha", + "info": { + "version": "1.0.0", + "description": "Test", + "author": { + "name": "Giuseppe Guerra" + } + } +} diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/MANIFEST.txt b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/MANIFEST.txt new file mode 100644 index 00000000000..d216cf1c023 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/MANIFEST.txt @@ -0,0 +1,29 @@ + +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +{ + "manifestVersion": "2.0.0", + "signatureType": "grafana", + "signedByOrg": "grafana", + "signedByOrgName": "Grafana Labs", + "plugin": "test-app", + "version": "1.0.0", + "time": 1726230812215, + "keyId": "7e4d0c6a708866e7", + "files": { + "plugin.json": "31f04aceb2a9b14c2e501f38a4de5ab1c7a3e7306f58353fa5c1a86b716c971c", + "something.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a" + } +} +-----BEGIN PGP SIGNATURE----- +Version: OpenPGP.js v4.10.11 +Comment: https://openpgpjs.org + +wrkEARMKAAYFAmbkMRwAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq +cIhm53UWAgkBE2oxqyzBji86eCOzLmCT7IgQaoSMMF48tu+XdgwFS5/NU5su +deKad3taDnSU9a7GkCaisRVQOWy/UtFS1FNQTtkCCQBc1cZ6JsPWh2Pd60h0 +9U5aviYde6g1DCKO1riaUzHzrruBiHmHWjzr2aYwACb89vs2XcZqvue1Byb+ +y2inBDhHvQ== +=qMej +-----END PGP SIGNATURE----- diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/plugin.json new file mode 100644 index 00000000000..122b5358b89 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/plugin.json @@ -0,0 +1,15 @@ +{ + "type": "app", + "name": "Test", + "id": "test-app", + "backend": true, + "executable": "test", + "state": "alpha", + "info": { + "version": "1.0.0", + "description": "Test", + "author": { + "name": "Giuseppe Guerra" + } + } +} diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/something.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/something.js new file mode 100644 index 00000000000..fcb71c21418 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-no-module-js/something.js @@ -0,0 +1 @@ +hello parent diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/MANIFEST.txt b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/MANIFEST.txt new file mode 100644 index 00000000000..ae9ff16a609 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/MANIFEST.txt @@ -0,0 +1,33 @@ + +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +{ + "manifestVersion": "2.0.0", + "signatureType": "grafana", + "signedByOrg": "grafana", + "signedByOrgName": "Grafana Labs", + "plugin": "test-app", + "version": "1.0.0", + "time": 1726234125061, + "keyId": "7e4d0c6a708866e7", + "files": { + "datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711", + "datasource/plugin.json": "3fd712717a21617cc76f9043efcd43d4ebf5564dd155a28e4e3c736739f6931e", + "datasource/panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f", + "datasource/panels/one/plugin.json": "b9b4556a7220ea77650ffd228da6d441e68df3405d50dab5773c10f4afae5ad3", + "module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a", + "plugin.json": "31f04aceb2a9b14c2e501f38a4de5ab1c7a3e7306f58353fa5c1a86b716c971c" + } +} +-----BEGIN PGP SIGNATURE----- +Version: OpenPGP.js v4.10.11 +Comment: https://openpgpjs.org + +wrkEARMKAAYFAmbkPg0AIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq +cIhm5xTlAgkB3mG37KEdlP34nC69NbmriMpDH6PyyJ0IUwXB/SMTr4Gc2SvG +cVHvih/0WqVjYKxxQI0QHoYpBQW2jPx0YJLFof8CCQBHpdEEXNTYOOZWG6Cg +M3wB3AdCO+ChjXkKosbWqiMDfVqHFoLoLurwWxwOjvk/xTvX5GFbOxSfISyU +8iW03F5/Sw== +=wobV +-----END PGP SIGNATURE----- diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/module.js new file mode 100644 index 00000000000..c04165fc4e6 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/module.js @@ -0,0 +1 @@ +hello datasource diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/module.js new file mode 100644 index 00000000000..5bc5bf404df --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/module.js @@ -0,0 +1 @@ +hello panel diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/plugin.json new file mode 100644 index 00000000000..137ce642626 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/panels/one/plugin.json @@ -0,0 +1,13 @@ +{ + "type": "panel", + "name": "Test Panel", + "id": "test-panel", + "state": "alpha", + "info": { + "version": "1.0.0", + "description": "Test", + "author": { + "name": "Giuseppe Guerra" + } + } +} diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/plugin.json new file mode 100644 index 00000000000..ee61a2361ea --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/datasource/plugin.json @@ -0,0 +1,15 @@ +{ + "type": "datasource", + "name": "Test Datasource", + "id": "test-datasource", + "backend": true, + "executable": "test", + "state": "alpha", + "info": { + "version": "1.0.0", + "description": "Test", + "author": { + "name": "Giuseppe Guerra" + } + } +} diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/module.js new file mode 100644 index 00000000000..fcb71c21418 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/module.js @@ -0,0 +1 @@ +hello parent diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/plugin.json new file mode 100644 index 00000000000..122b5358b89 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-deeply-nested/plugin.json @@ -0,0 +1,15 @@ +{ + "type": "app", + "name": "Test", + "id": "test-app", + "backend": true, + "executable": "test", + "state": "alpha", + "info": { + "version": "1.0.0", + "description": "Test", + "author": { + "name": "Giuseppe Guerra" + } + } +} diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/MANIFEST.txt b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/MANIFEST.txt new file mode 100644 index 00000000000..ceb0a5e762b --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/MANIFEST.txt @@ -0,0 +1,33 @@ + +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +{ + "manifestVersion": "2.0.0", + "signatureType": "grafana", + "signedByOrg": "grafana", + "signedByOrgName": "Grafana Labs", + "plugin": "test-app", + "version": "1.0.0", + "time": 1726230803822, + "keyId": "7e4d0c6a708866e7", + "files": { + "module.js": "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a", + "plugin.json": "31f04aceb2a9b14c2e501f38a4de5ab1c7a3e7306f58353fa5c1a86b716c971c", + "datasource/module.js": "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711", + "datasource/plugin.json": "3fd712717a21617cc76f9043efcd43d4ebf5564dd155a28e4e3c736739f6931e", + "panels/one/module.js": "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f", + "panels/one/plugin.json": "b9b4556a7220ea77650ffd228da6d441e68df3405d50dab5773c10f4afae5ad3" + } +} +-----BEGIN PGP SIGNATURE----- +Version: OpenPGP.js v4.10.11 +Comment: https://openpgpjs.org + +wrkEARMKAAYFAmbkMRQAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq +cIhm50C8AgkAmzQpeYPnCgYimLGp5UGnCTrkbUEEqW+qXESrhi5T5ZuM+SzT +BcRlC5pP6+wuyXAIdfppzWQ/umkkoaTIuub0TXQCCQHVcpWKy4acRL9TlORQ +1VzVEV9PW0+x606HsDDHkterKQZgr5X6I/sTbSpBDMWPCMxqAk9fZn3G4iuq +MyS+hwUZDQ== +=7/Rd +-----END PGP SIGNATURE----- diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/module.js new file mode 100644 index 00000000000..c04165fc4e6 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/module.js @@ -0,0 +1 @@ +hello datasource diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/plugin.json new file mode 100644 index 00000000000..ee61a2361ea --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/datasource/plugin.json @@ -0,0 +1,15 @@ +{ + "type": "datasource", + "name": "Test Datasource", + "id": "test-datasource", + "backend": true, + "executable": "test", + "state": "alpha", + "info": { + "version": "1.0.0", + "description": "Test", + "author": { + "name": "Giuseppe Guerra" + } + } +} diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/module.js new file mode 100644 index 00000000000..fcb71c21418 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/module.js @@ -0,0 +1 @@ +hello parent diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/module.js new file mode 100644 index 00000000000..5bc5bf404df --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/module.js @@ -0,0 +1 @@ +hello panel diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/plugin.json new file mode 100644 index 00000000000..137ce642626 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/panels/one/plugin.json @@ -0,0 +1,13 @@ +{ + "type": "panel", + "name": "Test Panel", + "id": "test-panel", + "state": "alpha", + "info": { + "version": "1.0.0", + "description": "Test", + "author": { + "name": "Giuseppe Guerra" + } + } +} diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/plugin.json new file mode 100644 index 00000000000..122b5358b89 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid-nested/plugin.json @@ -0,0 +1,15 @@ +{ + "type": "app", + "name": "Test", + "id": "test-app", + "backend": true, + "executable": "test", + "state": "alpha", + "info": { + "version": "1.0.0", + "description": "Test", + "author": { + "name": "Giuseppe Guerra" + } + } +} diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/MANIFEST.txt b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/MANIFEST.txt new file mode 100644 index 00000000000..d34df338e30 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/MANIFEST.txt @@ -0,0 +1,32 @@ + +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +{ + "manifestVersion": "2.0.0", + "signatureType": "private", + "signedByOrg": "giuseppeguerra", + "signedByOrgName": "giuseppeguerra", + "rootUrls": [ + "http://127.0.0.1:3000/" + ], + "plugin": "test-datasource", + "version": "1.0.0", + "time": 1725959570435, + "keyId": "7e4d0c6a708866e7", + "files": { + "module.js": "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", + "plugin.json": "129fab4e0584d18c778ebdfa5fe1a68edf2e5c5aeb8290b2c68182c857cb59f8" + } +} +-----BEGIN PGP SIGNATURE----- +Version: OpenPGP.js v4.10.11 +Comment: https://openpgpjs.org + +wrkEARMKAAYFAmbgDZIAIQkQfk0ManCIZucWIQTzOyW2kQdOhGNlcPN+TQxq +cIhm5wbfAgkAXmKJcM8uAKb3TepYW/oyGhRLR8L6eM9mCoYwKkatITKJ6bRe +Wnz37AMcPx0DahgfCzCXRLo4CspPJylr2JV8DagCCQCfCjHgLFhKGpBP71Y1 +mgcQ1/CJefb6B2H45G25MwUFTlSTGLDqW4QMi2kQvXnnUMjXquv2+iVd6qyz +0Rqvpou/QQ== +=QNmr +-----END PGP SIGNATURE----- diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/module.js b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/module.js new file mode 100644 index 00000000000..ce013625030 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/module.js @@ -0,0 +1 @@ +hello diff --git a/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/plugin.json b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/plugin.json new file mode 100644 index 00000000000..328c11e6e22 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/testdata/module-hash-valid/plugin.json @@ -0,0 +1,15 @@ +{ + "type": "datasource", + "name": "Test", + "id": "test-datasource", + "backend": true, + "executable": "test", + "state": "alpha", + "info": { + "version": "1.0.0", + "description": "Test", + "author": { + "name": "Giuseppe Guerra" + } + } +} diff --git a/pkg/services/pluginsintegration/pluginconfig/config.go b/pkg/services/pluginsintegration/pluginconfig/config.go index 83ec526c33f..56b20aa6e1a 100644 --- a/pkg/services/pluginsintegration/pluginconfig/config.go +++ b/pkg/services/pluginsintegration/pluginconfig/config.go @@ -32,6 +32,7 @@ func ProvidePluginManagementConfig(cfg *setting.Cfg, settingProvider setting.Pro config.Features{ ExternalCorePluginsEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalCorePlugins), SkipHostEnvVarsEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSkipHostEnvVars), + SriChecksEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSriChecks), }, cfg.AngularSupportEnabled, cfg.GrafanaComAPIURL, diff --git a/pkg/services/pluginsintegration/pluginstore/plugins.go b/pkg/services/pluginsintegration/pluginstore/plugins.go index f041c77c5e5..30321e69286 100644 --- a/pkg/services/pluginsintegration/pluginstore/plugins.go +++ b/pkg/services/pluginsintegration/pluginstore/plugins.go @@ -10,7 +10,7 @@ import ( type Plugin struct { plugins.JSONData - fs plugins.FS + FS plugins.FS supportsStreaming bool Class plugins.Class @@ -42,7 +42,7 @@ func (p Plugin) SupportsStreaming() bool { } func (p Plugin) Base() string { - return p.fs.Base() + return p.FS.Base() } func (p Plugin) IsApp() bool { @@ -61,7 +61,7 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin { } dto := Plugin{ - fs: p.FS, + FS: p.FS, supportsStreaming: supportsStreaming, Class: p.Class, JSONData: p.JSONData, diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts index 81819a8745f..45732de7fb1 100644 --- a/public/app/features/plugins/importPanelPlugin.ts +++ b/public/app/features/plugins/importPanelPlugin.ts @@ -63,6 +63,7 @@ function getPanelPlugin(meta: PanelPluginMeta): Promise { isAngular: meta.angular?.detected, loadingStrategy: fallbackLoadingStrategy, pluginId: meta.id, + moduleHash: meta.moduleHash, }) .then((pluginExports) => { if (pluginExports.plugin) { diff --git a/public/app/features/plugins/pluginPreloader.ts b/public/app/features/plugins/pluginPreloader.ts index 378ad0ba257..b4d7489f80f 100644 --- a/public/app/features/plugins/pluginPreloader.ts +++ b/public/app/features/plugins/pluginPreloader.ts @@ -57,6 +57,7 @@ async function preload(config: AppPluginConfig): Promise { isAngular: config.angular.detected, pluginId, loadingStrategy, + moduleHash: config.moduleHash, }); const { exposedComponentConfigs = [], addedComponentConfigs = [], addedLinkConfigs = [] } = plugin; diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index e9f7866761b..3fa1f3bf98d 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -7,6 +7,7 @@ import { PluginLoadingStrategy, PluginMeta, } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { GenericDataSourcePlugin } from '../datasources/types'; @@ -73,12 +74,14 @@ export async function importPluginModule({ loadingStrategy, version, isAngular, + moduleHash, }: { path: string; pluginId: string; loadingStrategy: PluginLoadingStrategy; version?: string; isAngular?: boolean; + moduleHash?: string; }): Promise { if (version) { registerPluginInCache({ path, version, loadingStrategy }); @@ -94,7 +97,21 @@ export async function importPluginModule({ } } - let modulePath = resolveModulePath(path); + const modulePath = resolveModulePath(path); + + // inject integrity hash into SystemJS import map + if (config.featureToggles.pluginsSriChecks) { + const resolvedModule = System.resolve(modulePath); + const integrityMap = System.getImportMap().integrity; + + if (moduleHash && integrityMap && !integrityMap[resolvedModule]) { + SystemJS.addImportMap({ + integrity: { + [resolvedModule]: moduleHash, + }, + }); + } + } // the sandboxing environment code cannot work in nodejs and requires a real browser if (await isFrontendSandboxSupported({ isAngular, pluginId })) { @@ -113,6 +130,7 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise { if (pluginExports.plugin) { const dsPlugin: GenericDataSourcePlugin = pluginExports.plugin; @@ -144,6 +162,7 @@ export function importAppPlugin(meta: PluginMeta): Promise { isAngular, loadingStrategy: fallbackLoadingStrategy, pluginId: meta.id, + moduleHash: meta.moduleHash, }).then((pluginExports) => { const plugin: AppPlugin = pluginExports.plugin ? pluginExports.plugin : new AppPlugin(); plugin.init(meta); From 6dfe9aef952dbb77494db36c739e029158fb0c38 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Oct 2024 16:10:01 +0300 Subject: [PATCH 007/115] Update dependency @grafana/scenes to v5.17.0 (#94249) 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 2550f233202..1c7f6b4531d 100644 --- a/package.json +++ b/package.json @@ -268,7 +268,7 @@ "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", "@grafana/saga-icons": "workspace:*", - "@grafana/scenes": "5.16.2", + "@grafana/scenes": "5.17.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/yarn.lock b/yarn.lock index 1e376702e99..73a7e6f9d79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4150,9 +4150,9 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes@npm:5.16.2": - version: 5.16.2 - resolution: "@grafana/scenes@npm:5.16.2" +"@grafana/scenes@npm:5.17.0": + version: 5.17.0 + resolution: "@grafana/scenes@npm:5.17.0" dependencies: "@floating-ui/react": "npm:0.26.16" "@grafana/e2e-selectors": "npm:^11.0.0" @@ -4169,7 +4169,7 @@ __metadata: "@grafana/ui": ">=10.4" react: ^18.0.0 react-dom: ^18.0.0 - checksum: 10/e7ed539277c9739b7d13be37f6704a3c1e1bc5facc0f86d2e9df8f046208e3ead93f68067058ea9c280d81b7afb705358aed2c9165f767fe5b4147df19bd8d43 + checksum: 10/da18f6b27119cd3d8c7698d58f7f24c10d1531d3caaff0f1b48eb2a79a0cb5c9295078c89e06882b57840e5c2a22721205736243bbd7160d215f252582f9742b languageName: node linkType: hard @@ -18947,7 +18947,7 @@ __metadata: "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" "@grafana/saga-icons": "workspace:*" - "@grafana/scenes": "npm:5.16.2" + "@grafana/scenes": "npm:5.17.0" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/tsconfig": "npm:^2.0.0" From 544b5f905ca4b69ca40da7cca35d851a66a14b88 Mon Sep 17 00:00:00 2001 From: Jo Date: Fri, 4 Oct 2024 15:20:55 +0200 Subject: [PATCH 008/115] Anonymous: Fix anonymous cache ignoring device limit evaluation (#94218) * ensure cache contains the evaluation result for device limit * add device limit errors and warnings * fix lint --- pkg/services/anonymous/anonimpl/client.go | 7 +- pkg/services/anonymous/anonimpl/impl.go | 18 +++- pkg/services/anonymous/anonimpl/impl_test.go | 96 ++++++++++++++++++++ 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/pkg/services/anonymous/anonimpl/client.go b/pkg/services/anonymous/anonimpl/client.go index 57b4c9873fb..43c1d8107c2 100644 --- a/pkg/services/anonymous/anonimpl/client.go +++ b/pkg/services/anonymous/anonimpl/client.go @@ -17,8 +17,9 @@ import ( ) var ( - errInvalidOrg = errutil.Unauthorized("anonymous.invalid-org") - errInvalidID = errutil.Unauthorized("anonymous.invalid-id") + errInvalidOrg = errutil.Unauthorized("anonymous.invalid-org") + errInvalidID = errutil.Unauthorized("anonymous.invalid-id") + errDeviceLimit = errutil.Unauthorized("anonymous.device-limit-reached", errutil.WithPublicMessage("Anonymous device limit reached. Contact Administrator")) ) var _ authn.ContextAwareClient = new(Anonymous) @@ -51,7 +52,7 @@ func (a *Anonymous) Authenticate(ctx context.Context, r *authn.Request) (*authn. if err := a.anonDeviceService.TagDevice(ctx, httpReqCopy, anonymous.AnonDeviceUI); err != nil { if errors.Is(err, anonstore.ErrDeviceLimitReached) { - return nil, err + return nil, errDeviceLimit.Errorf("limit reached for anonymous devices: %w", err) } a.log.Warn("Failed to tag anonymous session", "error", err) diff --git a/pkg/services/anonymous/anonimpl/impl.go b/pkg/services/anonymous/anonimpl/impl.go index b34048be407..c0105441d62 100644 --- a/pkg/services/anonymous/anonimpl/impl.go +++ b/pkg/services/anonymous/anonimpl/impl.go @@ -2,6 +2,7 @@ package anonimpl import ( "context" + "errors" "net/http" "time" @@ -79,20 +80,29 @@ func (a *AnonDeviceService) usageStatFn(ctx context.Context) (map[string]any, er }, nil } -func (a *AnonDeviceService) tagDeviceUI(ctx context.Context, httpReq *http.Request, device *anonstore.Device) error { +func (a *AnonDeviceService) tagDeviceUI(ctx context.Context, device *anonstore.Device) error { key := device.CacheKey() - if _, ok := a.localCache.Get(key); ok { + if val, ok := a.localCache.Get(key); ok { + if boolVal, ok := val.(bool); ok && !boolVal { + return anonstore.ErrDeviceLimitReached + } return nil } - a.localCache.SetDefault(key, struct{}{}) + a.localCache.SetDefault(key, true) if a.cfg.Env == setting.Dev { a.log.Debug("Tagging device for UI", "deviceID", device.DeviceID, "device", device, "key", key) } if err := a.anonStore.CreateOrUpdateDevice(ctx, device); err != nil { + if errors.Is(err, anonstore.ErrDeviceLimitReached) { + a.localCache.SetDefault(key, false) + return err + } + // invalidate cache if there is an error + a.localCache.Delete(key) return err } @@ -142,7 +152,7 @@ func (a *AnonDeviceService) TagDevice(ctx context.Context, httpReq *http.Request UpdatedAt: time.Now(), } - err = a.tagDeviceUI(ctx, httpReq, taggedDevice) + err = a.tagDeviceUI(ctx, taggedDevice) if err != nil { a.log.Debug("Failed to tag device for UI", "error", err) return err diff --git a/pkg/services/anonymous/anonimpl/impl_test.go b/pkg/services/anonymous/anonimpl/impl_test.go index a84e913f3b1..b193d22edb6 100644 --- a/pkg/services/anonymous/anonimpl/impl_test.go +++ b/pkg/services/anonymous/anonimpl/impl_test.go @@ -26,6 +26,10 @@ func TestMain(m *testing.M) { } func TestIntegrationDeviceService_tag(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode") + } + type tagReq struct { httpReq *http.Request kind anonymous.DeviceKind @@ -152,6 +156,9 @@ func TestIntegrationDeviceService_tag(t *testing.T) { // Ensure that the local cache prevents request from being tagged func TestIntegrationAnonDeviceService_localCacheSafety(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode") + } store := db.InitTestDB(t) anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{}, &authntest.FakeService{}, store, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil, actest.FakeAccessControl{}, &routing.RouteRegisterImpl{}) @@ -184,6 +191,10 @@ func TestIntegrationAnonDeviceService_localCacheSafety(t *testing.T) { } func TestIntegrationDeviceService_SearchDevice(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode") + } + fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) // Fixed timestamp for testing testCases := []struct { @@ -271,3 +282,88 @@ func TestIntegrationDeviceService_SearchDevice(t *testing.T) { }) } } + +func TestIntegrationAnonDeviceService_DeviceLimitWithCache(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode") + } + // Setup test environment + store := db.InitTestDB(t) + cfg := setting.NewCfg() + cfg.AnonymousDeviceLimit = 1 // Set device limit to 1 for testing + anonService := ProvideAnonymousDeviceService( + &usagestats.UsageStatsMock{}, + &authntest.FakeService{}, + store, + cfg, + orgtest.NewOrgServiceFake(), + nil, + actest.FakeAccessControl{}, + &routing.RouteRegisterImpl{}, + ) + + // Define test cases + testCases := []struct { + name string + httpReq *http.Request + expectedErr error + }{ + { + name: "first request should succeed", + httpReq: &http.Request{ + Header: http.Header{ + "User-Agent": []string{"test"}, + "X-Forwarded-For": []string{"10.30.30.1"}, + http.CanonicalHeaderKey(deviceIDHeader): []string{"device1"}, + }, + }, + expectedErr: nil, + }, + { + name: "second request should fail due to device limit", + httpReq: &http.Request{ + Header: http.Header{ + "User-Agent": []string{"test"}, + "X-Forwarded-For": []string{"10.30.30.2"}, + http.CanonicalHeaderKey(deviceIDHeader): []string{"device2"}, + }, + }, + expectedErr: anonstore.ErrDeviceLimitReached, + }, + { + name: "repeat request should hit cache and succeed", + httpReq: &http.Request{ + Header: http.Header{ + "User-Agent": []string{"test"}, + "X-Forwarded-For": []string{"10.30.30.1"}, + http.CanonicalHeaderKey(deviceIDHeader): []string{"device1"}, + }, + }, + expectedErr: nil, + }, + { + name: "third request should hit cache and fail due to device limit", + httpReq: &http.Request{ + Header: http.Header{ + "User-Agent": []string{"test"}, + "X-Forwarded-For": []string{"10.30.30.2"}, + http.CanonicalHeaderKey(deviceIDHeader): []string{"device2"}, + }, + }, + expectedErr: anonstore.ErrDeviceLimitReached, + }, + } + + // Run test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := anonService.TagDevice(context.Background(), tc.httpReq, anonymous.AnonDeviceUI) + if tc.expectedErr != nil { + require.Error(t, err) + assert.Equal(t, tc.expectedErr, err) + } else { + require.NoError(t, err) + } + }) + } +} From 93b8243da768eaa3fcafa3aa5f825559a677119f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Oct 2024 14:21:10 +0100 Subject: [PATCH 009/115] Update dependency @grafana/experimental to v2 (#93919) * Update dependency @grafana/experimental to v2 * add data-testid to old save button so it works properly in e2e test * fix azure monitor e2e tests * use raw selectors * remove .only --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ashley Harrison --- e2e/cloud-plugins-suite/azure-monitor.spec.ts | 52 ++++++++++++------- package.json | 2 +- .../grafana-o11y-ds-frontend/package.json | 2 +- packages/grafana-prometheus/package.json | 2 +- packages/grafana-sql/package.json | 2 +- .../datasource/azuremonitor/package.json | 2 +- .../datasource/cloud-monitoring/package.json | 2 +- .../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/tempo/package.json | 2 +- .../plugins/datasource/zipkin/package.json | 2 +- yarn.lock | 48 ++++++++--------- 15 files changed, 70 insertions(+), 56 deletions(-) diff --git a/e2e/cloud-plugins-suite/azure-monitor.spec.ts b/e2e/cloud-plugins-suite/azure-monitor.spec.ts index 64ab9069c91..1c6db341089 100644 --- a/e2e/cloud-plugins-suite/azure-monitor.spec.ts +++ b/e2e/cloud-plugins-suite/azure-monitor.spec.ts @@ -2,6 +2,8 @@ import { Interception } from 'cypress/types/net-stubbing'; import { load } from 'js-yaml'; import { v4 as uuidv4 } from 'uuid'; +import { selectors as rawSelectors } from '@grafana/e2e-selectors'; + import { selectors } from '../../public/app/plugins/datasource/azuremonitor/e2e/selectors'; import { AzureDataSourceJsonData, @@ -75,12 +77,13 @@ const addAzureMonitorVariable = ( isFirst: boolean, options?: { subscription?: string; resourceGroup?: string; namespace?: string; resource?: string; region?: string } ) => { - e2e.components.PageToolbar.item('Dashboard settings').click(); + e2e.components.NavToolbar.editDashboard.editButton().should('be.visible').click(); + e2e.components.NavToolbar.editDashboard.settingsButton().should('be.visible').click(); e2e.components.Tab.title('Variables').click(); if (isFirst) { e2e.pages.Dashboard.Settings.Variables.List.addVariableCTAV2().click(); } else { - e2e.pages.Dashboard.Settings.Variables.List.newButton().click(); + cy.get(`[data-testid="${rawSelectors.pages.Dashboard.Settings.Variables.List.newButton}"]`).click(); } e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type(name); e2e.components.DataSourcePicker.inputV2().type(`${dataSourceName}{enter}`); @@ -113,7 +116,8 @@ const addAzureMonitorVariable = ( break; } e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click(); - e2e.pages.Dashboard.Settings.Actions.close().click(); + e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); + e2e.components.NavToolbar.editDashboard.exitButton().click(); }; const storageAcctName = 'azmonteststorage'; @@ -189,7 +193,8 @@ describe('Azure monitor datasource', () => { }, timeout: 10000, }); - e2e.components.PanelEditor.applyButton().click(); + e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); + e2e.components.NavToolbar.editDashboard.exitButton().click(); e2e.flows.addPanel({ dataSourceName, visitDashboardAtStart: false, @@ -209,7 +214,8 @@ describe('Azure monitor datasource', () => { }, timeout: 10000, }); - e2e.components.PanelEditor.applyButton().click(); + e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); + e2e.components.NavToolbar.editDashboard.exitButton().click(); e2e.flows.addPanel({ dataSourceName, visitDashboardAtStart: false, @@ -228,7 +234,8 @@ describe('Azure monitor datasource', () => { }, timeout: 10000, }); - e2e.components.PanelEditor.applyButton().click(); + e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); + e2e.components.NavToolbar.editDashboard.exitButton().click(); e2e.flows.addPanel({ dataSourceName, visitDashboardAtStart: false, @@ -275,25 +282,32 @@ describe('Azure monitor datasource', () => { namespace: '$namespace', region: '$region', }); - e2e.pages.Dashboard.SubMenu.submenuItemLabels('subscription').click(); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('grafanalabs-datasources-dev').click(); - e2e.pages.Dashboard.SubMenu.submenuItemLabels('resourceGroups').parent().find('button').click(); + e2e.pages.Dashboard.SubMenu.submenuItemLabels('subscription') + .parent() + .within(() => { + cy.get('input').click(); + }); + e2e.components.Select.option().contains('grafanalabs-datasources-dev').click(); e2e.pages.Dashboard.SubMenu.submenuItemLabels('resourceGroups') .parent() - .find('input') - .type('cloud-plugins-e2e-test-azmon{downArrow}{enter}'); - e2e.pages.Dashboard.SubMenu.submenuItemLabels('namespaces').parent().find('button').click(); + .within(() => { + cy.get('input').type('cloud-plugins-e2e-test-azmon{downArrow}{enter}'); + }); e2e.pages.Dashboard.SubMenu.submenuItemLabels('namespaces') .parent() - .find('input') - .type('microsoft.storage/storageaccounts{downArrow}{enter}'); - e2e.pages.Dashboard.SubMenu.submenuItemLabels('region').parent().find('button').click(); - e2e.pages.Dashboard.SubMenu.submenuItemLabels('region').parent().find('input').type('uk south{downArrow}{enter}'); - e2e.pages.Dashboard.SubMenu.submenuItemLabels('resource').parent().find('button').click(); + .within(() => { + cy.get('input').type('microsoft.storage/storageaccounts{downArrow}{enter}'); + }); + e2e.pages.Dashboard.SubMenu.submenuItemLabels('region') + .parent() + .within(() => { + cy.get('input').type('uk south{downArrow}{enter}'); + }); e2e.pages.Dashboard.SubMenu.submenuItemLabels('resource') .parent() - .find('input') - .type(`${storageAcctName}{downArrow}{enter}`); + .within(() => { + cy.get('input').type(`${storageAcctName}{downArrow}{enter}`); + }); e2e.flows.addPanel({ dataSourceName, visitDashboardAtStart: false, diff --git a/package.json b/package.json index 1c7f6b4531d..874959ab95e 100644 --- a/package.json +++ b/package.json @@ -256,7 +256,7 @@ "@grafana/azure-sdk": "0.0.3", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", - "@grafana/experimental": "1.8.0", + "@grafana/experimental": "2.1.1", "@grafana/faro-core": "^1.3.6", "@grafana/faro-web-sdk": "^1.3.6", "@grafana/faro-web-tracing": "^1.8.2", diff --git a/packages/grafana-o11y-ds-frontend/package.json b/packages/grafana-o11y-ds-frontend/package.json index df4acb26060..ab744dff5dd 100644 --- a/packages/grafana-o11y-ds-frontend/package.json +++ b/packages/grafana-o11y-ds-frontend/package.json @@ -20,7 +20,7 @@ "@emotion/css": "11.13.0", "@grafana/data": "11.3.0-pre", "@grafana/e2e-selectors": "11.3.0-pre", - "@grafana/experimental": "1.8.0", + "@grafana/experimental": "2.1.1", "@grafana/runtime": "11.3.0-pre", "@grafana/schema": "11.3.0-pre", "@grafana/ui": "11.3.0-pre", diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 08c7f213e9f..eb84c87824b 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -39,7 +39,7 @@ "@emotion/css": "11.13.0", "@floating-ui/react": "0.26.24", "@grafana/data": "11.3.0-pre", - "@grafana/experimental": "1.8.0", + "@grafana/experimental": "2.1.1", "@grafana/faro-web-sdk": "1.10.1", "@grafana/runtime": "11.3.0-pre", "@grafana/schema": "11.3.0-pre", diff --git a/packages/grafana-sql/package.json b/packages/grafana-sql/package.json index 854d02f8ac6..86910329ee2 100644 --- a/packages/grafana-sql/package.json +++ b/packages/grafana-sql/package.json @@ -17,7 +17,7 @@ "@emotion/css": "11.13.0", "@grafana/data": "11.3.0-pre", "@grafana/e2e-selectors": "11.3.0-pre", - "@grafana/experimental": "1.8.0", + "@grafana/experimental": "2.1.1", "@grafana/runtime": "11.3.0-pre", "@grafana/ui": "11.3.0-pre", "@react-awesome-query-builder/ui": "6.6.3", diff --git a/public/app/plugins/datasource/azuremonitor/package.json b/public/app/plugins/datasource/azuremonitor/package.json index 6ffbd08797f..d01ccc70204 100644 --- a/public/app/plugins/datasource/azuremonitor/package.json +++ b/public/app/plugins/datasource/azuremonitor/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.0", "@grafana/data": "11.3.0-pre", - "@grafana/experimental": "1.8.0", + "@grafana/experimental": "2.1.1", "@grafana/runtime": "11.3.0-pre", "@grafana/schema": "11.3.0-pre", "@grafana/ui": "11.3.0-pre", diff --git a/public/app/plugins/datasource/cloud-monitoring/package.json b/public/app/plugins/datasource/cloud-monitoring/package.json index 089365eab7d..d5342665c49 100644 --- a/public/app/plugins/datasource/cloud-monitoring/package.json +++ b/public/app/plugins/datasource/cloud-monitoring/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.0", "@grafana/data": "11.3.0-pre", - "@grafana/experimental": "1.8.0", + "@grafana/experimental": "2.1.1", "@grafana/google-sdk": "0.1.2", "@grafana/runtime": "11.3.0-pre", "@grafana/schema": "11.3.0-pre", diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json index 09d80e2af2b..54329b2c4ec 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.0", "@grafana/data": "11.3.0-pre", - "@grafana/experimental": "1.8.0", + "@grafana/experimental": "2.1.1", "@grafana/runtime": "11.3.0-pre", "@grafana/sql": "11.3.0-pre", "@grafana/ui": "11.3.0-pre", diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/package.json b/public/app/plugins/datasource/grafana-testdata-datasource/package.json index 20c86a52164..b87ff9df019 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/package.json +++ b/public/app/plugins/datasource/grafana-testdata-datasource/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.0", "@grafana/data": "11.3.0-pre", - "@grafana/experimental": "1.8.0", + "@grafana/experimental": "2.1.1", "@grafana/runtime": "11.3.0-pre", "@grafana/schema": "11.3.0-pre", "@grafana/ui": "11.3.0-pre", diff --git a/public/app/plugins/datasource/jaeger/package.json b/public/app/plugins/datasource/jaeger/package.json index 22c26740e6b..7d691f6fddc 100644 --- a/public/app/plugins/datasource/jaeger/package.json +++ b/public/app/plugins/datasource/jaeger/package.json @@ -7,7 +7,7 @@ "@emotion/css": "11.13.0", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", - "@grafana/experimental": "1.8.0", + "@grafana/experimental": "2.1.1", "@grafana/o11y-ds-frontend": "workspace:*", "@grafana/runtime": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/public/app/plugins/datasource/mssql/package.json b/public/app/plugins/datasource/mssql/package.json index 315d96d6d63..ba04f0de9e8 100644 --- a/public/app/plugins/datasource/mssql/package.json +++ b/public/app/plugins/datasource/mssql/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.0", "@grafana/data": "11.3.0-pre", - "@grafana/experimental": "1.8.0", + "@grafana/experimental": "2.1.1", "@grafana/runtime": "11.3.0-pre", "@grafana/sql": "11.3.0-pre", "@grafana/ui": "11.3.0-pre", diff --git a/public/app/plugins/datasource/mysql/package.json b/public/app/plugins/datasource/mysql/package.json index 0061ab2abbd..e9527d9d97c 100644 --- a/public/app/plugins/datasource/mysql/package.json +++ b/public/app/plugins/datasource/mysql/package.json @@ -6,7 +6,7 @@ "dependencies": { "@emotion/css": "11.13.0", "@grafana/data": "11.3.0-pre", - "@grafana/experimental": "1.8.0", + "@grafana/experimental": "2.1.1", "@grafana/runtime": "11.3.0-pre", "@grafana/sql": "11.3.0-pre", "@grafana/ui": "11.3.0-pre", diff --git a/public/app/plugins/datasource/tempo/package.json b/public/app/plugins/datasource/tempo/package.json index d8fd3286232..0a80d92c7c5 100644 --- a/public/app/plugins/datasource/tempo/package.json +++ b/public/app/plugins/datasource/tempo/package.json @@ -7,7 +7,7 @@ "@emotion/css": "11.13.0", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", - "@grafana/experimental": "1.8.0", + "@grafana/experimental": "2.1.1", "@grafana/lezer-logql": "0.2.6", "@grafana/lezer-traceql": "0.0.19", "@grafana/monaco-logql": "^0.0.7", diff --git a/public/app/plugins/datasource/zipkin/package.json b/public/app/plugins/datasource/zipkin/package.json index 7e5efc05bb9..66c885f6d73 100644 --- a/public/app/plugins/datasource/zipkin/package.json +++ b/public/app/plugins/datasource/zipkin/package.json @@ -7,7 +7,7 @@ "@emotion/css": "11.13.0", "@grafana/data": "workspace:*", "@grafana/e2e-selectors": "workspace:*", - "@grafana/experimental": "1.8.0", + "@grafana/experimental": "2.1.1", "@grafana/o11y-ds-frontend": "workspace:*", "@grafana/runtime": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/yarn.lock b/yarn.lock index 73a7e6f9d79..67104ce803f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3142,7 +3142,7 @@ __metadata: "@emotion/css": "npm:11.13.0" "@grafana/data": "npm:11.3.0-pre" "@grafana/e2e-selectors": "npm:11.3.0-pre" - "@grafana/experimental": "npm:1.8.0" + "@grafana/experimental": "npm:2.1.1" "@grafana/plugin-configs": "npm:11.3.0-pre" "@grafana/runtime": "npm:11.3.0-pre" "@grafana/schema": "npm:11.3.0-pre" @@ -3186,7 +3186,7 @@ __metadata: "@emotion/css": "npm:11.13.0" "@grafana/data": "npm:11.3.0-pre" "@grafana/e2e-selectors": "npm:11.3.0-pre" - "@grafana/experimental": "npm:1.8.0" + "@grafana/experimental": "npm:2.1.1" "@grafana/plugin-configs": "npm:11.3.0-pre" "@grafana/runtime": "npm:11.3.0-pre" "@grafana/sql": "npm:11.3.0-pre" @@ -3258,7 +3258,7 @@ __metadata: "@emotion/css": "npm:11.13.0" "@grafana/data": "npm:11.3.0-pre" "@grafana/e2e-selectors": "npm:11.3.0-pre" - "@grafana/experimental": "npm:1.8.0" + "@grafana/experimental": "npm:2.1.1" "@grafana/plugin-configs": "npm:11.3.0-pre" "@grafana/runtime": "npm:11.3.0-pre" "@grafana/schema": "npm:11.3.0-pre" @@ -3299,7 +3299,7 @@ __metadata: "@emotion/css": "npm:11.13.0" "@grafana/data": "workspace:*" "@grafana/e2e-selectors": "workspace:*" - "@grafana/experimental": "npm:1.8.0" + "@grafana/experimental": "npm:2.1.1" "@grafana/o11y-ds-frontend": "workspace:*" "@grafana/plugin-configs": "workspace:*" "@grafana/runtime": "workspace:*" @@ -3341,7 +3341,7 @@ __metadata: "@emotion/css": "npm:11.13.0" "@grafana/data": "npm:11.3.0-pre" "@grafana/e2e-selectors": "npm:11.3.0-pre" - "@grafana/experimental": "npm:1.8.0" + "@grafana/experimental": "npm:2.1.1" "@grafana/plugin-configs": "npm:11.3.0-pre" "@grafana/runtime": "npm:11.3.0-pre" "@grafana/sql": "npm:11.3.0-pre" @@ -3372,7 +3372,7 @@ __metadata: "@emotion/css": "npm:11.13.0" "@grafana/data": "npm:11.3.0-pre" "@grafana/e2e-selectors": "npm:11.3.0-pre" - "@grafana/experimental": "npm:1.8.0" + "@grafana/experimental": "npm:2.1.1" "@grafana/plugin-configs": "npm:11.3.0-pre" "@grafana/runtime": "npm:11.3.0-pre" "@grafana/sql": "npm:11.3.0-pre" @@ -3435,7 +3435,7 @@ __metadata: "@emotion/css": "npm:11.13.0" "@grafana/data": "npm:11.3.0-pre" "@grafana/e2e-selectors": "npm:11.3.0-pre" - "@grafana/experimental": "npm:1.8.0" + "@grafana/experimental": "npm:2.1.1" "@grafana/google-sdk": "npm:0.1.2" "@grafana/plugin-configs": "npm:11.3.0-pre" "@grafana/runtime": "npm:11.3.0-pre" @@ -3483,7 +3483,7 @@ __metadata: "@emotion/css": "npm:11.13.0" "@grafana/data": "workspace:*" "@grafana/e2e-selectors": "workspace:*" - "@grafana/experimental": "npm:1.8.0" + "@grafana/experimental": "npm:2.1.1" "@grafana/lezer-logql": "npm:0.2.6" "@grafana/lezer-traceql": "npm:0.0.19" "@grafana/monaco-logql": "npm:^0.0.7" @@ -3543,7 +3543,7 @@ __metadata: "@emotion/css": "npm:11.13.0" "@grafana/data": "workspace:*" "@grafana/e2e-selectors": "workspace:*" - "@grafana/experimental": "npm:1.8.0" + "@grafana/experimental": "npm:2.1.1" "@grafana/o11y-ds-frontend": "workspace:*" "@grafana/plugin-configs": "workspace:*" "@grafana/runtime": "workspace:*" @@ -3739,9 +3739,9 @@ __metadata: languageName: node linkType: hard -"@grafana/experimental@npm:1.8.0": - version: 1.8.0 - resolution: "@grafana/experimental@npm:1.8.0" +"@grafana/experimental@npm:2.1.1": + version: 2.1.1 + resolution: "@grafana/experimental@npm:2.1.1" dependencies: "@hello-pangea/dnd": "npm:^16.6.0" "@types/uuid": "npm:^8.3.3" @@ -3753,15 +3753,15 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@emotion/css": 11.11.2 - "@grafana/data": ^10.0.0 + "@grafana/data": ^10.4.0 "@grafana/e2e-selectors": ^10.0.0 - "@grafana/runtime": ^10.0.0 - "@grafana/ui": ^10.0.0 - react: 17.0.2 - react-dom: 17.0.2 - react-select: ^5.2.1 - rxjs: 7.8.0 - checksum: 10/a58d66254e9220f27580fcf42f3a0507c4e48da0cc9a26bcf110c37f16bef282a5c53119afa6f5588712c38bdcf113dd98a01fcf275917288a52cd88fcaae317 + "@grafana/runtime": ^10.4.0 + "@grafana/ui": ^10.4.0 + react: 18.2.0 + react-dom: 18.2.0 + react-select: 5.8.0 + rxjs: ^7.8.1 + checksum: 10/114ad749002dd8fa7f903b384351a0aac73c7d95bf4eba6a827fd9e47e2148c88f7eb7a70d9c140b17b3afbd7c28aacbd0f0e26b06b9851584721ce4a03a1060 languageName: node linkType: hard @@ -3900,7 +3900,7 @@ __metadata: "@emotion/css": "npm:11.13.0" "@grafana/data": "npm:11.3.0-pre" "@grafana/e2e-selectors": "npm:11.3.0-pre" - "@grafana/experimental": "npm:1.8.0" + "@grafana/experimental": "npm:2.1.1" "@grafana/runtime": "npm:11.3.0-pre" "@grafana/schema": "npm:11.3.0-pre" "@grafana/tsconfig": "npm:^2.0.0" @@ -3971,7 +3971,7 @@ __metadata: "@floating-ui/react": "npm:0.26.24" "@grafana/data": "npm:11.3.0-pre" "@grafana/e2e-selectors": "npm:11.3.0-pre" - "@grafana/experimental": "npm:1.8.0" + "@grafana/experimental": "npm:2.1.1" "@grafana/faro-web-sdk": "npm:1.10.1" "@grafana/runtime": "npm:11.3.0-pre" "@grafana/schema": "npm:11.3.0-pre" @@ -4198,7 +4198,7 @@ __metadata: "@emotion/css": "npm:11.13.0" "@grafana/data": "npm:11.3.0-pre" "@grafana/e2e-selectors": "npm:11.3.0-pre" - "@grafana/experimental": "npm:1.8.0" + "@grafana/experimental": "npm:2.1.1" "@grafana/runtime": "npm:11.3.0-pre" "@grafana/tsconfig": "npm:^2.0.0" "@grafana/ui": "npm:11.3.0-pre" @@ -18934,7 +18934,7 @@ __metadata: "@grafana/e2e-selectors": "workspace:*" "@grafana/eslint-config": "npm:7.0.0" "@grafana/eslint-plugin": "link:./packages/grafana-eslint-rules" - "@grafana/experimental": "npm:1.8.0" + "@grafana/experimental": "npm:2.1.1" "@grafana/faro-core": "npm:^1.3.6" "@grafana/faro-web-sdk": "npm:^1.3.6" "@grafana/faro-web-tracing": "npm:^1.8.2" From 4c27b2c59d3ac678057079fe39bb159726d702e0 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Fri, 4 Oct 2024 15:57:29 +0200 Subject: [PATCH 010/115] Alerting: Fix default value for input in simple condition (#94248) fix default value for input --- .../rule-editor/query-and-alert-condition/SimpleCondition.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 12b967cd743..7307675defd 100644 --- 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 @@ -143,7 +143,7 @@ export const SimpleConditionEditor = ({ type="number" width={10} onChange={onEvaluateValueChange} - value={simpleCondition.evaluator.params[0] || 0} + value={simpleCondition.evaluator.params[0]} /> )} From 94444319ecbe0591ce78ba7fcb7a21595017e76c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Oct 2024 13:25:46 +0000 Subject: [PATCH 011/115] Update React Aria --- package.json | 8 +- packages/grafana-ui/package.json | 8 +- yarn.lock | 249 +++++++++++++++++-------------- 3 files changed, 148 insertions(+), 117 deletions(-) diff --git a/package.json b/package.json index 874959ab95e..a761ce54ef0 100644 --- a/package.json +++ b/package.json @@ -287,10 +287,10 @@ "@opentelemetry/exporter-collector": "0.25.0", "@opentelemetry/semantic-conventions": "1.27.0", "@popperjs/core": "2.11.8", - "@react-aria/dialog": "3.5.17", - "@react-aria/focus": "3.18.2", - "@react-aria/overlays": "3.23.2", - "@react-aria/utils": "3.25.2", + "@react-aria/dialog": "3.5.18", + "@react-aria/focus": "3.18.3", + "@react-aria/overlays": "3.23.3", + "@react-aria/utils": "3.25.3", "@react-awesome-query-builder/ui": "6.6.3", "@reduxjs/toolkit": "2.2.7", "@testing-library/react-hooks": "^8.0.1", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index b44d5cd61a9..6c80336eeb9 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -59,10 +59,10 @@ "@leeoniya/ufuzzy": "1.0.14", "@monaco-editor/react": "4.6.0", "@popperjs/core": "2.11.8", - "@react-aria/dialog": "3.5.17", - "@react-aria/focus": "3.18.2", - "@react-aria/overlays": "3.23.2", - "@react-aria/utils": "3.25.2", + "@react-aria/dialog": "3.5.18", + "@react-aria/focus": "3.18.3", + "@react-aria/overlays": "3.23.3", + "@react-aria/utils": "3.25.3", "@tanstack/react-virtual": "^3.5.1", "@types/jquery": "3.5.31", "@types/lodash": "4.17.9", diff --git a/yarn.lock b/yarn.lock index 67104ce803f..830a39bd0ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4270,10 +4270,10 @@ __metadata: "@leeoniya/ufuzzy": "npm:1.0.14" "@monaco-editor/react": "npm:4.6.0" "@popperjs/core": "npm:2.11.8" - "@react-aria/dialog": "npm:3.5.17" - "@react-aria/focus": "npm:3.18.2" - "@react-aria/overlays": "npm:3.23.2" - "@react-aria/utils": "npm:3.25.2" + "@react-aria/dialog": "npm:3.5.18" + "@react-aria/focus": "npm:3.18.3" + "@react-aria/overlays": "npm:3.23.3" + "@react-aria/utils": "npm:3.25.3" "@rollup/plugin-node-resolve": "npm:15.3.0" "@storybook/addon-a11y": "npm:^8.1.6" "@storybook/addon-actions": "npm:^8.1.6" @@ -4497,40 +4497,40 @@ __metadata: languageName: node linkType: hard -"@internationalized/date@npm:^3.5.5": - version: 3.5.5 - resolution: "@internationalized/date@npm:3.5.5" +"@internationalized/date@npm:^3.5.6": + version: 3.5.6 + resolution: "@internationalized/date@npm:3.5.6" dependencies: "@swc/helpers": "npm:^0.5.0" - checksum: 10/5f045faf7af0d217874e537507ad9a68753eabc5fa8905524801acaafd6c5e2b4df050c467b423b738ab40a327e1889e620bab41b47c4032aa17f7ca731dc06b + checksum: 10/54734b53ca74a32aae368a8f963324352b1fd5b13029b6e82555307b8f2ff355658c90e82a4f38f154a3edf874387d1efd26fc80f2edd068ce04f48f6467f26c languageName: node linkType: hard -"@internationalized/message@npm:^3.1.4": - version: 3.1.4 - resolution: "@internationalized/message@npm:3.1.4" +"@internationalized/message@npm:^3.1.5": + version: 3.1.5 + resolution: "@internationalized/message@npm:3.1.5" dependencies: "@swc/helpers": "npm:^0.5.0" intl-messageformat: "npm:^10.1.0" - checksum: 10/1b895871cbf81cab360046aca07d7d1433aed5f8904abed03fb5e581516403c7b9b075a0e497d1095368329a5980e0ff38a14103b6d9fdb0621fbeeded8b71aa + checksum: 10/210951fd8055af4db70d465e49bcbbdf2545ed223b936af9c1f18b745a51689ecb0ca49cbd5ee2dbfeccce2447808b7fe309bd12ee81f7e09283f20bf04200e9 languageName: node linkType: hard -"@internationalized/number@npm:^3.5.3": - version: 3.5.3 - resolution: "@internationalized/number@npm:3.5.3" +"@internationalized/number@npm:^3.5.4": + version: 3.5.4 + resolution: "@internationalized/number@npm:3.5.4" dependencies: "@swc/helpers": "npm:^0.5.0" - checksum: 10/2b154a82f1150224ce0ae0e97a87e3eff5c60111342a89f0360d3146f8ca3b482b704d25d370a7233e4ff21eeb62cff8fb6e9594dc79984d05459f03a0d348f7 + checksum: 10/16641aecb58c075a6322dc6b36a2c6e521845296f81b86a128d015f072d1af998289b71b4d8b9521e7576bdeabfaf8067a3e741b0116c8595d82a4461c1ae03b languageName: node linkType: hard -"@internationalized/string@npm:^3.2.3": - version: 3.2.3 - resolution: "@internationalized/string@npm:3.2.3" +"@internationalized/string@npm:^3.2.4": + version: 3.2.4 + resolution: "@internationalized/string@npm:3.2.4" dependencies: "@swc/helpers": "npm:^0.5.0" - checksum: 10/d7ff86646e8cd10696fadd43f59eae767b7bcced652ecc70afaddcea396d6cebc34f8e08af274a32324a923f9a88f1ecf477b1cd2a64954fed8bc1111808f0d7 + checksum: 10/5fdb7f0bf7fa7055cdf62ded4efd6849d3db9cf0e6d53f349889e2ec9517b9135ad38a6bb8dcf25142c69c381618c0dd1a6a072117dd7cf2867ce17374f0f835 languageName: node linkType: hard @@ -6686,129 +6686,129 @@ __metadata: languageName: node linkType: hard -"@react-aria/dialog@npm:3.5.17": - version: 3.5.17 - resolution: "@react-aria/dialog@npm:3.5.17" +"@react-aria/dialog@npm:3.5.18": + version: 3.5.18 + resolution: "@react-aria/dialog@npm:3.5.18" dependencies: - "@react-aria/focus": "npm:^3.18.2" - "@react-aria/overlays": "npm:^3.23.2" - "@react-aria/utils": "npm:^3.25.2" - "@react-types/dialog": "npm:^3.5.12" - "@react-types/shared": "npm:^3.24.1" + "@react-aria/focus": "npm:^3.18.3" + "@react-aria/overlays": "npm:^3.23.3" + "@react-aria/utils": "npm:^3.25.3" + "@react-types/dialog": "npm:^3.5.13" + "@react-types/shared": "npm:^3.25.0" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10/dd4fd27e1c44633e1d84cfc506eaa4d1af11bf5643f8fbe265e42ed4ea293452056040940ef1f72e70d3eb12712c27e5400b3e80444d2151c1bcf54a717315af + checksum: 10/dbd40d14baeea7dae56956985234e29ada74a93899177c737c3312ec788b22a6d65179b7132cdbd6609e973d410f749071c68ceb6620d3cf6f60a03ddf648983 languageName: node linkType: hard -"@react-aria/focus@npm:3.18.2, @react-aria/focus@npm:^3.18.2": - version: 3.18.2 - resolution: "@react-aria/focus@npm:3.18.2" +"@react-aria/focus@npm:3.18.3, @react-aria/focus@npm:^3.18.3": + version: 3.18.3 + resolution: "@react-aria/focus@npm:3.18.3" dependencies: - "@react-aria/interactions": "npm:^3.22.2" - "@react-aria/utils": "npm:^3.25.2" - "@react-types/shared": "npm:^3.24.1" + "@react-aria/interactions": "npm:^3.22.3" + "@react-aria/utils": "npm:^3.25.3" + "@react-types/shared": "npm:^3.25.0" "@swc/helpers": "npm:^0.5.0" clsx: "npm:^2.0.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10/4243764952737ec33f463534e69c7d581073d5531ae87504d574083a4d9a08a9e3b5a8e2b69a936bf6476a35eb8cf38db751d52629e66451be58a6c635ce9449 + checksum: 10/b11632e638de2f40ec12a4a8c818059b9bf7e90b288a93b46985350c887ae7ecdf037391537f86fbacb2a186dec7e7c41a8f2ff767fd232a8cac3189f03735b2 languageName: node linkType: hard -"@react-aria/i18n@npm:^3.12.2": - version: 3.12.2 - resolution: "@react-aria/i18n@npm:3.12.2" +"@react-aria/i18n@npm:^3.12.3": + version: 3.12.3 + resolution: "@react-aria/i18n@npm:3.12.3" dependencies: - "@internationalized/date": "npm:^3.5.5" - "@internationalized/message": "npm:^3.1.4" - "@internationalized/number": "npm:^3.5.3" - "@internationalized/string": "npm:^3.2.3" - "@react-aria/ssr": "npm:^3.9.5" - "@react-aria/utils": "npm:^3.25.2" - "@react-types/shared": "npm:^3.24.1" + "@internationalized/date": "npm:^3.5.6" + "@internationalized/message": "npm:^3.1.5" + "@internationalized/number": "npm:^3.5.4" + "@internationalized/string": "npm:^3.2.4" + "@react-aria/ssr": "npm:^3.9.6" + "@react-aria/utils": "npm:^3.25.3" + "@react-types/shared": "npm:^3.25.0" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10/46f6ea24d366e7efd3360fb6042c18592a33e09f5c8603544d3899dbf344cedae6dcf7c5a1f2fb97abbef56d930934477b37699da76625eeda65fe74ccddc669 + checksum: 10/54f111d9a9da68edcb8b821f7c8ead92f0c4d85307dbabee78bc5c89f5a19cdfa406b1e40b7c6f9dc26f7cedce4c9c5a10f8dcdae289e5a404c07b6fdda98aba languageName: node linkType: hard -"@react-aria/interactions@npm:^3.22.2": - version: 3.22.2 - resolution: "@react-aria/interactions@npm:3.22.2" +"@react-aria/interactions@npm:^3.22.3": + version: 3.22.3 + resolution: "@react-aria/interactions@npm:3.22.3" dependencies: - "@react-aria/ssr": "npm:^3.9.5" - "@react-aria/utils": "npm:^3.25.2" - "@react-types/shared": "npm:^3.24.1" + "@react-aria/ssr": "npm:^3.9.6" + "@react-aria/utils": "npm:^3.25.3" + "@react-types/shared": "npm:^3.25.0" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10/df0ce7d438b6f9d04774120ed6a3b66ef928e8e8ce97af42b12a5feabcd8d6cdd858e14cd6ccf602bbe8c0dbb620ce94bd974f1e2b832f497c7125647f8be471 + checksum: 10/bc1e8381bda81c106d64bb6eebe06c244bcd6905d1be95fdc26bad1c5d83c48d1ec5159fb1cb8ea9ee7ebafc76595702e2d174f3c8394b766779c0d34bfa6de7 languageName: node linkType: hard -"@react-aria/overlays@npm:3.23.2, @react-aria/overlays@npm:^3.23.2": - version: 3.23.2 - resolution: "@react-aria/overlays@npm:3.23.2" +"@react-aria/overlays@npm:3.23.3, @react-aria/overlays@npm:^3.23.3": + version: 3.23.3 + resolution: "@react-aria/overlays@npm:3.23.3" dependencies: - "@react-aria/focus": "npm:^3.18.2" - "@react-aria/i18n": "npm:^3.12.2" - "@react-aria/interactions": "npm:^3.22.2" - "@react-aria/ssr": "npm:^3.9.5" - "@react-aria/utils": "npm:^3.25.2" - "@react-aria/visually-hidden": "npm:^3.8.15" - "@react-stately/overlays": "npm:^3.6.10" - "@react-types/button": "npm:^3.9.6" - "@react-types/overlays": "npm:^3.8.9" - "@react-types/shared": "npm:^3.24.1" + "@react-aria/focus": "npm:^3.18.3" + "@react-aria/i18n": "npm:^3.12.3" + "@react-aria/interactions": "npm:^3.22.3" + "@react-aria/ssr": "npm:^3.9.6" + "@react-aria/utils": "npm:^3.25.3" + "@react-aria/visually-hidden": "npm:^3.8.16" + "@react-stately/overlays": "npm:^3.6.11" + "@react-types/button": "npm:^3.10.0" + "@react-types/overlays": "npm:^3.8.10" + "@react-types/shared": "npm:^3.25.0" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10/2d0b68c5d5eb38e4728193525c658c48cb2e27bd8abb4a3655ebf6e99d7d6f5c27aa1c4e21caf5258783a8aece2eaea4c6e6416c0871c8f5975444d209e48c82 + checksum: 10/c70af63d4ae828963b9fa780330cabf49e5a70f8981ae65d173e32934fa190fc8df1283de65d6a8b71b6340050718df19c2e7353b406114962d85ee5deb811ee languageName: node linkType: hard -"@react-aria/ssr@npm:^3.9.5": - version: 3.9.5 - resolution: "@react-aria/ssr@npm:3.9.5" +"@react-aria/ssr@npm:^3.9.6": + version: 3.9.6 + resolution: "@react-aria/ssr@npm:3.9.6" dependencies: "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10/0284561e7b084c567fd8f35e7982f201582acc937b950be8411678352682c7b45ad3ab99272cd2d6f0b4919ddaa5b0e553d784f190d1d05ceb8594bfee3f763e + checksum: 10/ea6b290346ce1e119ed9233fc0e34693d52ab9dc2509f07ab10710409b89484a544b7f26c1438802e97f3fb634844ae54638850cdd95caca0d1f5571781bf982 languageName: node linkType: hard -"@react-aria/utils@npm:3.25.2, @react-aria/utils@npm:^3.25.2": - version: 3.25.2 - resolution: "@react-aria/utils@npm:3.25.2" +"@react-aria/utils@npm:3.25.3, @react-aria/utils@npm:^3.25.3": + version: 3.25.3 + resolution: "@react-aria/utils@npm:3.25.3" dependencies: - "@react-aria/ssr": "npm:^3.9.5" - "@react-stately/utils": "npm:^3.10.3" - "@react-types/shared": "npm:^3.24.1" + "@react-aria/ssr": "npm:^3.9.6" + "@react-stately/utils": "npm:^3.10.4" + "@react-types/shared": "npm:^3.25.0" "@swc/helpers": "npm:^0.5.0" clsx: "npm:^2.0.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10/c0dbbff1f93b3f275e6db2f01c7a09ffd96da57fd373a8b3b3cb5dbb0aca99d721c2453fbd742800d0df2fbb0ffa5f3052669bbb2998db753b1090f573d5ef7b + checksum: 10/86aed35da5cb0d48d949e40bf8226d5a6d6c92a8cdc60e3e12d524d1f3cc91ab6b54c5e1642823773cbb889fb61af7da22e89488b704b56fc5f4d8d59da7519b languageName: node linkType: hard -"@react-aria/visually-hidden@npm:^3.8.15": - version: 3.8.15 - resolution: "@react-aria/visually-hidden@npm:3.8.15" +"@react-aria/visually-hidden@npm:^3.8.16": + version: 3.8.16 + resolution: "@react-aria/visually-hidden@npm:3.8.16" dependencies: - "@react-aria/interactions": "npm:^3.22.2" - "@react-aria/utils": "npm:^3.25.2" - "@react-types/shared": "npm:^3.24.1" + "@react-aria/interactions": "npm:^3.22.3" + "@react-aria/utils": "npm:^3.25.3" + "@react-types/shared": "npm:^3.25.0" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10/5923eebcaa1873503f9c19bcdea5d6f6d5051583d0076aadbb627886608ef7f0b7ef96eff5ac794afe099cfeb0479fbb2bc54c40b5375b8b1ae1b53e67e12e2b + checksum: 10/263b4d0e78ae2932165cad3d91f8111527d04feae06c42078cbaaa8f8a8bc13f46321cf1c3203e3e7418ca319b2f02b8ff24764e8c4af714a6200450fa955277 languageName: node linkType: hard @@ -6846,31 +6846,31 @@ __metadata: languageName: node linkType: hard -"@react-stately/overlays@npm:^3.6.10": - version: 3.6.10 - resolution: "@react-stately/overlays@npm:3.6.10" +"@react-stately/overlays@npm:^3.6.11": + version: 3.6.11 + resolution: "@react-stately/overlays@npm:3.6.11" dependencies: - "@react-stately/utils": "npm:^3.10.3" - "@react-types/overlays": "npm:^3.8.9" + "@react-stately/utils": "npm:^3.10.4" + "@react-types/overlays": "npm:^3.8.10" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10/80dda26b348a2dcae737e3b570d0985b26700cfe86bc248aa56ac0091842379f234d8a236cf33625b4afa36646a115d8dda309a0159cb6eb1df1fdd1e57b0874 + checksum: 10/98190b4b0ced5c94d924cf97b5d43a6e28f68aa44de7bb789c20354f30f00309c86089fb6948b5ec9d09f01605b5a412fb246545b7ee9bc34e3183e7261a2805 languageName: node linkType: hard -"@react-stately/utils@npm:^3.10.3": - version: 3.10.3 - resolution: "@react-stately/utils@npm:3.10.3" +"@react-stately/utils@npm:^3.10.4": + version: 3.10.4 + resolution: "@react-stately/utils@npm:3.10.4" dependencies: "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10/0ac737e678d949787d05889bfd67047ed0ee91d93a8d727c89d7a7568a027d0cf4a53cebad13e6526c2322f51069bbaa40d5912364230e6b9374cf653683a73d + checksum: 10/8a56b4d0cf8d5a7a692d6f94ffff63feac2d7078fbc5642b94b0afcaaf7c8f7f4682cfe546f98265034c52576c198be5502cff3f9b145137884e50eb9ffb96d5 languageName: node linkType: hard -"@react-types/button@npm:3.9.6, @react-types/button@npm:^3.9.6": +"@react-types/button@npm:3.9.6": version: 3.9.6 resolution: "@react-types/button@npm:3.9.6" dependencies: @@ -6881,15 +6881,26 @@ __metadata: languageName: node linkType: hard -"@react-types/dialog@npm:^3.5.12": - version: 3.5.12 - resolution: "@react-types/dialog@npm:3.5.12" +"@react-types/button@npm:^3.10.0": + version: 3.10.0 + resolution: "@react-types/button@npm:3.10.0" dependencies: - "@react-types/overlays": "npm:^3.8.9" - "@react-types/shared": "npm:^3.24.1" + "@react-types/shared": "npm:^3.25.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 - checksum: 10/10d00b738f49298cea9975377b0f3cecd56592bb1b6561a8fa6323bd6f11fb3e9c83ed3d0f01a349312ec3d196164602099a0664789fe2a0850d6a8986bce822 + checksum: 10/13973108d935e81a9e852bdc3a530a26a4cacc4a7ec37f1dde48202be0545066a71f4d7c476806d7911e91b2b9193c79f4e89dc616280b74db37cec3dd749fea + languageName: node + linkType: hard + +"@react-types/dialog@npm:^3.5.13": + version: 3.5.13 + resolution: "@react-types/dialog@npm:3.5.13" + dependencies: + "@react-types/overlays": "npm:^3.8.10" + "@react-types/shared": "npm:^3.25.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10/c7ee923c326b7377660400ec794ef98330388250aa32df23f5c22a63e483bcad221283de26181bf2a5cab2c20d517f528bf351c9786ac9fece3e3726e4ae07c3 languageName: node linkType: hard @@ -6905,7 +6916,7 @@ __metadata: languageName: node linkType: hard -"@react-types/overlays@npm:3.8.9, @react-types/overlays@npm:^3.8.9": +"@react-types/overlays@npm:3.8.9": version: 3.8.9 resolution: "@react-types/overlays@npm:3.8.9" dependencies: @@ -6916,7 +6927,18 @@ __metadata: languageName: node linkType: hard -"@react-types/shared@npm:3.24.1, @react-types/shared@npm:^3.24.1": +"@react-types/overlays@npm:^3.8.10, @react-types/overlays@npm:^3.8.9": + version: 3.8.10 + resolution: "@react-types/overlays@npm:3.8.10" + dependencies: + "@react-types/shared": "npm:^3.25.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10/2e8edf37f75df2884a280cbd25a3798d24b93669b8b2b606cadacaf40f605f63e437749cea28861fabecd78293302ac39108f4e65cedd412c474e92be9895561 + languageName: node + linkType: hard + +"@react-types/shared@npm:3.24.1": version: 3.24.1 resolution: "@react-types/shared@npm:3.24.1" peerDependencies: @@ -6925,6 +6947,15 @@ __metadata: languageName: node linkType: hard +"@react-types/shared@npm:^3.24.1, @react-types/shared@npm:^3.25.0": + version: 3.25.0 + resolution: "@react-types/shared@npm:3.25.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + checksum: 10/fa31eb6153c223210c2eee46934a63b922917bcde0ee583f2cfe59675db122c10e1cbae6549b1fea4284391fdbeca6888b36e9dc797231ad4a76def01490aea5 + languageName: node + linkType: hard + "@reduxjs/toolkit@npm:2.2.7": version: 2.2.7 resolution: "@reduxjs/toolkit@npm:2.2.7" @@ -18970,10 +19001,10 @@ __metadata: "@playwright/test": "npm:1.47.2" "@pmmmwh/react-refresh-webpack-plugin": "npm:0.5.15" "@popperjs/core": "npm:2.11.8" - "@react-aria/dialog": "npm:3.5.17" - "@react-aria/focus": "npm:3.18.2" - "@react-aria/overlays": "npm:3.23.2" - "@react-aria/utils": "npm:3.25.2" + "@react-aria/dialog": "npm:3.5.18" + "@react-aria/focus": "npm:3.18.3" + "@react-aria/overlays": "npm:3.23.3" + "@react-aria/utils": "npm:3.25.3" "@react-awesome-query-builder/ui": "npm:6.6.3" "@react-types/button": "npm:3.9.6" "@react-types/menu": "npm:3.9.11" From b5821ef473f264a16c8fc966168defea33ceedb3 Mon Sep 17 00:00:00 2001 From: kay delaney <45561153+kaydelaney@users.noreply.github.com> Date: Fri, 4 Oct 2024 15:01:01 +0100 Subject: [PATCH 012/115] PanelSearch: Add support for rows & repeats (#94243) * PanelSearch: Add support for rows & repeats * Show message if there are no matches --- .../scene/PanelSearchLayout.tsx | 69 +++++++++++++++---- public/locales/en-US/grafana.json | 1 + public/locales/pseudo-LOCALE/grafana.json | 1 + 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx b/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx index 06d2e65d0b8..679bca8cb59 100644 --- a/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx +++ b/public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import { useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { VizPanel, sceneGraph } from '@grafana/scenes'; +import { SceneGridRow, VizPanel, sceneGraph } from '@grafana/scenes'; import { useStyles2 } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; @@ -34,26 +34,33 @@ export function PanelSearchLayout({ dashboard, panelSearch = '', panelsPerRow }: for (const gridItem of bodyGrid.state.children) { if (gridItem instanceof DashboardGridItem) { - const panels = gridItem.state.repeatedPanels ?? [gridItem.state.body]; - for (const panel of panels) { - const interpolatedTitle = panel.interpolate(panel.state.title, undefined, 'text').toLowerCase(); - const interpolatedSearchString = sceneGraph.interpolate(dashboard, panelSearch).toLowerCase(); - if (interpolatedTitle.includes(interpolatedSearchString)) { - filteredPanels.push(panel); + filterPanels(gridItem, dashboard, panelSearch, filteredPanels); + } else if (gridItem instanceof SceneGridRow) { + for (const rowItem of gridItem.state.children) { + if (rowItem instanceof DashboardGridItem) { + filterPanels(rowItem, dashboard, panelSearch, filteredPanels); } } } } + if (filteredPanels.length > 0) { + return ( +
} + > + {filteredPanels.map((panel) => ( + + ))} +
+ ); + } + return ( -
} - > - {filteredPanels.map((panel) => ( - - ))} -
+

+ No matches found +

); } @@ -74,5 +81,37 @@ function getStyles(theme: GrafanaTheme2) { perRow: css({ gridTemplateColumns: `repeat(var(${panelsPerRowCSSVar}, 3), 1fr)`, }), + noHits: css({ + display: 'grid', + placeItems: 'center', + }), }; } + +function filterPanels( + gridItem: DashboardGridItem, + dashboard: DashboardScene, + searchString: string, + filteredPanels: VizPanel[] +) { + const interpolatedSearchString = sceneGraph.interpolate(dashboard, searchString).toLowerCase(); + + // activate inactive repeat panel if one of its children will be matched + if (gridItem.state.variableName && !gridItem.isActive) { + const panel = gridItem.state.body; + const interpolatedTitle = panel.interpolate(panel.state.title, undefined, 'text').toLowerCase(); + if (interpolatedTitle.includes(interpolatedSearchString)) { + gridItem.activate(); + } + } + + const panels = gridItem.state.repeatedPanels ?? [gridItem.state.body]; + for (const panel of panels) { + const interpolatedTitle = panel.interpolate(panel.state.title, undefined, 'text').toLowerCase(); + if (interpolatedTitle.includes(interpolatedSearchString)) { + filteredPanels.push(panel); + } + } + + return filteredPanels; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index b03939b26b6..63d9bb626dc 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1924,6 +1924,7 @@ } }, "panel-search": { + "no-matches": "No matches found", "unsupported-layout": "Unsupported layout" }, "playlist-edit": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 820267b08dc..fabb719f921 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1924,6 +1924,7 @@ } }, "panel-search": { + "no-matches": "Ńő mäŧčĥęş ƒőūʼnđ", "unsupported-layout": "Ůʼnşūppőřŧęđ ľäyőūŧ" }, "playlist-edit": { From b2d930e079e8a178edec17b5aff9b520b1b100a9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Oct 2024 13:28:11 +0000 Subject: [PATCH 013/115] Update dependency @tanstack/react-virtual to v3.10.8 --- yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index 830a39bd0ee..3e46af6e5f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9191,21 +9191,21 @@ __metadata: linkType: hard "@tanstack/react-virtual@npm:^3.5.1, @tanstack/react-virtual@npm:^3.9.0": - version: 3.10.7 - resolution: "@tanstack/react-virtual@npm:3.10.7" + version: 3.10.8 + resolution: "@tanstack/react-virtual@npm:3.10.8" dependencies: - "@tanstack/virtual-core": "npm:3.10.7" + "@tanstack/virtual-core": "npm:3.10.8" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - checksum: 10/cf4324687a3849a981f6e7baad26199fc9419184d69585d9aca35876f9a83a0d9167b31d8d5e74ad739465961eaedc7b5c9177efc62c502adbb7f061be8dc554 + checksum: 10/40a5d6089908096634fec2aa0cd646ca47c044c745e1b0d190ecbf9905ad2e6266ccd56c2550ed92f47349954dc11eb6930beac1354441ce7c98af81c5454d3f languageName: node linkType: hard -"@tanstack/virtual-core@npm:3.10.7": - version: 3.10.7 - resolution: "@tanstack/virtual-core@npm:3.10.7" - checksum: 10/28aa29e454f2674bd2e3609d2a9ace4ad9913f55b2edd71363b770cde150ffafd67d5fd846cc7d1538b5b0e4e62a1960f253c48c85ad9774692b3be14579eaad +"@tanstack/virtual-core@npm:3.10.8": + version: 3.10.8 + resolution: "@tanstack/virtual-core@npm:3.10.8" + checksum: 10/047e95fa72a0d341c0da8468799c176fd448481432f976a4780911bb4a2256aa4788d828f79fad78d127fe859b785189c13ca0fea10c560bf14d8ab8cb2c7790 languageName: node linkType: hard From f32fe9a8464e5cc9491cce2c08313edc2cf17c5a Mon Sep 17 00:00:00 2001 From: Sergej-Vlasov <37613182+Sergej-Vlasov@users.noreply.github.com> Date: Fri, 4 Oct 2024 15:05:52 +0100 Subject: [PATCH 014/115] PanelEditor: add data provider when switching from non data panel (#94220) * add data provider when switching from non data panel * handle adding and cleaning up data provider in panel editor on panel switch * add data provider check sin panel editor tests --- .../panel-edit/PanelEditor.test.ts | 8 +++- .../panel-edit/PanelEditor.tsx | 48 ++++++++++++++++--- 2 files changed, 48 insertions(+), 8 deletions(-) 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 78370296e0a..bc9d4be13b2 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts @@ -277,13 +277,17 @@ describe('PanelEditor', () => { describe('PanelDataPane', () => { it('should not exist if panel is skipDataQuery', async () => { - const { panelEditor } = await setup({ pluginSkipDataQuery: true }); + const { panelEditor, panel } = await setup({ pluginSkipDataQuery: true }); expect(panelEditor.state.dataPane).toBeUndefined(); + + expect(panel.state.$data).toBeUndefined(); }); it('should exist if panel is supporting querying', async () => { - const { panelEditor } = await setup({ pluginSkipDataQuery: false }); + const { panelEditor, panel } = await setup({ pluginSkipDataQuery: false }); expect(panelEditor.state.dataPane).toBeDefined(); + + expect(panel.state.$data).toBeDefined(); }); }); }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index ed0b3f624ae..1eb6db12bf7 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -2,18 +2,21 @@ import * as H from 'history'; import { debounce } from 'lodash'; import { NavIndex, PanelPlugin } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { PanelBuilders, + SceneDataTransformer, SceneObjectBase, SceneObjectRef, SceneObjectState, SceneObjectStateChangedEvent, + SceneQueryRunner, sceneUtils, VizPanel, } from '@grafana/scenes'; import { Panel } from '@grafana/schema/dist/esm/index.gen'; import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; +import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard'; import { saveLibPanel } from 'app/features/library-panels/state/api'; import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; @@ -183,13 +186,46 @@ export class PanelEditor extends SceneObjectBase { private _updateDataPane(plugin: PanelPlugin) { const skipDataQuery = plugin.meta.skipDataQuery; - if (skipDataQuery && this.state.dataPane) { - locationService.partial({ tab: null }, true); - this.setState({ dataPane: undefined }); + const panel = this.state.panelRef.resolve(); + + if (skipDataQuery) { + if (this.state.dataPane) { + locationService.partial({ tab: null }, true); + this.setState({ dataPane: undefined }); + } + + // clean up data provider when switching from data to non data panel + if (panel.state.$data) { + panel.setState({ + $data: undefined, + }); + } } - if (!skipDataQuery && !this.state.dataPane) { - this.setState({ dataPane: PanelDataPane.createFor(this.getPanel()) }); + if (!skipDataQuery) { + if (!this.state.dataPane) { + this.setState({ dataPane: PanelDataPane.createFor(this.getPanel()) }); + } + + // add data provider when switching from non data to data panel + if (!panel.state.$data) { + let ds = getLastUsedDatasourceFromStorage(getDashboardSceneFor(this).state.uid!)?.datasourceUid; + if (!ds) { + ds = config.defaultDatasource; + } + + panel.setState({ + $data: new SceneDataTransformer({ + $data: new SceneQueryRunner({ + datasource: { + uid: ds, + }, + queries: [{ refId: 'A' }], + }), + transformations: [], + }), + }); + } } } From 40ad52fdbc7f50537b761e7c521c690b601e94a9 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 4 Oct 2024 15:11:32 +0100 Subject: [PATCH 015/115] SingleTopNav: Handle for non-scenes dashboards (#94198) handle singleTopNav for non-scenes dashboards --- .../dashboard/components/DashNav/DashNav.tsx | 16 +++++-------- .../AccessControlDashboardPermissions.tsx | 4 ++-- .../DashboardSettings/AnnotationsSettings.tsx | 4 ++-- .../DashboardSettings/DashboardSettings.tsx | 18 ++++++++++---- .../DashboardSettings/GeneralSettings.tsx | 3 ++- .../DashboardSettings/JsonEditorSettings.tsx | 4 ++-- .../DashboardSettings/LinksSettings.tsx | 4 ++-- .../DashboardSettings/VersionsSettings.tsx | 4 ++-- .../components/DashboardSettings/types.ts | 3 ++- .../components/PanelEditor/PanelEditor.tsx | 16 +++++++++---- .../dashboard/containers/DashboardPage.tsx | 24 ++++++++++++++++--- .../editor/VariableEditorContainer.tsx | 4 ++-- 12 files changed, 68 insertions(+), 36 deletions(-) diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 86a3986be31..3d92e3731c3 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -16,7 +16,6 @@ import { Badge, } from '@grafana/ui'; import { updateNavIndex } from 'app/core/actions'; -import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; import config from 'app/core/config'; import { useAppNotification } from 'app/core/copy/appNotification'; @@ -83,6 +82,7 @@ export const DashNav = memo((props) => { // this ensures the component rerenders when the location changes useLocation(); const forceUpdate = useForceUpdate(); + const isSingleTopNav = config.featureToggles.singleTopNav; // We don't really care about the event payload here only that it triggeres a re-render of this component useBusEvent(props.dashboard.events, DashboardMetaChangedEvent); @@ -357,15 +357,11 @@ export const DashNav = memo((props) => { }; return ( - - {renderLeftActions()} - - {renderRightActions()} - - } - /> + <> + {renderLeftActions()} + {!isSingleTopNav && } + {renderRightActions()} + ); }); diff --git a/public/app/features/dashboard/components/DashboardPermissions/AccessControlDashboardPermissions.tsx b/public/app/features/dashboard/components/DashboardPermissions/AccessControlDashboardPermissions.tsx index 6b31090b447..303cae3ac2b 100644 --- a/public/app/features/dashboard/components/DashboardPermissions/AccessControlDashboardPermissions.tsx +++ b/public/app/features/dashboard/components/DashboardPermissions/AccessControlDashboardPermissions.tsx @@ -5,12 +5,12 @@ import { AccessControlAction } from 'app/types'; import { SettingsPageProps } from '../DashboardSettings/types'; -export const AccessControlDashboardPermissions = ({ dashboard, sectionNav }: SettingsPageProps) => { +export const AccessControlDashboardPermissions = ({ dashboard, sectionNav, toolbar }: SettingsPageProps) => { const canSetPermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPermissionsWrite); const pageNav = sectionNav.node.parentItem; return ( - + ); diff --git a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx index 158d80b9dcc..e345cf1533d 100644 --- a/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx @@ -7,7 +7,7 @@ import { AnnotationSettingsEdit, AnnotationSettingsList, newAnnotationName } fro import { SettingsPageProps } from './types'; -export function AnnotationsSettings({ dashboard, editIndex, sectionNav }: SettingsPageProps) { +export function AnnotationsSettings({ dashboard, editIndex, sectionNav, toolbar }: SettingsPageProps) { const onNew = () => { const newAnnotation: AnnotationQuery = { name: newAnnotationName, @@ -27,7 +27,7 @@ export function AnnotationsSettings({ dashboard, editIndex, sectionNav }: Settin const isEditing = editIndex != null && editIndex < dashboard.annotations.list.length; return ( - + {!isEditing && } {isEditing && } diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx index adb6af01c37..ab2fd4d6a21 100644 --- a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx @@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom-v5-compat'; import { locationUtil, NavModel, NavModelItem } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { locationService } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { Button, Stack, Text, ToolbarButtonRow } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { Page } from 'app/core/components/Page/Page'; @@ -36,6 +36,7 @@ const onClose = () => locationService.partial({ editview: null, editIndex: null export function DashboardSettings({ dashboard, editview, pageNav, sectionNav }: Props) { const [updateId, setUpdateId] = useState(0); + const isSingleTopNav = config.featureToggles.singleTopNav; useEffect(() => { dashboard.events.subscribe(DashboardMetaChangedEvent, () => setUpdateId((v) => v + 1)); }, [dashboard]); @@ -81,8 +82,15 @@ export function DashboardSettings({ dashboard, editview, pageNav, sectionNav }: return ( <> - {actions}} /> - + {!isSingleTopNav && ( + {actions}} /> + )} + {actions} : undefined} + sectionNav={subSectionNav} + dashboard={dashboard} + editIndex={editIndex} + /> ); } @@ -209,9 +217,9 @@ function getSectionNav( }; } -function MakeEditable({ dashboard, sectionNav }: SettingsPageProps) { +function MakeEditable({ dashboard, sectionNav, toolbar }: SettingsPageProps) { return ( - + Dashboard not editable From 19844220db28e55c9c5e61730b6df036bc2b7cf9 Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Tue, 8 Oct 2024 13:04:18 -0300 Subject: [PATCH 106/115] Playlists: Remove from menu for users without permissions (#94403) --- pkg/services/navtree/navtreeimpl/navtree.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index d89454d5ee0..e4bdfcf2bfc 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -355,11 +355,13 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt dashboardChildNavs := []*navtree.NavLink{} - dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ - Text: "Playlists", SubTitle: "Groups of dashboards that are displayed in a sequence", Id: "dashboards/playlists", Url: s.cfg.AppSubURL + "/playlists", Icon: "presentation-play", - }) - if c.IsSignedIn { + if c.SignedInUser.HasRole(org.RoleViewer) { + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "Playlists", SubTitle: "Groups of dashboards that are displayed in a sequence", Id: "dashboards/playlists", Url: s.cfg.AppSubURL + "/playlists", Icon: "presentation-play", + }) + } + if s.cfg.SnapshotEnabled { dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ Text: "Snapshots", From 8cade5c55044e625727938a5268a230396a830bc Mon Sep 17 00:00:00 2001 From: Ieva Date: Tue, 8 Oct 2024 17:53:21 +0100 Subject: [PATCH 107/115] Role mapping: Add new query parameter to docs (#94413) * small doc addition * swagger gen * pr feedback --- docs/sources/developers/http_api/access_control.md | 1 + public/api-enterprise-spec.json | 4 ++-- public/api-merged.json | 4 ++-- public/openapi3.json | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/sources/developers/http_api/access_control.md b/docs/sources/developers/http_api/access_control.md index 72c5aec8a76..ecd0ac62d52 100644 --- a/docs/sources/developers/http_api/access_control.md +++ b/docs/sources/developers/http_api/access_control.md @@ -566,6 +566,7 @@ Lists the roles that have been directly assigned to a given user. The list does Query Parameters: - `includeHidden`: Optional. Set to `true` to include roles that are `hidden`. +- `includeMapped`: Optional. Set to `true` to include roles that have been mapped through the group attribute sync feature. #### Required permissions diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index d90d5dbab64..7a4e13be50b 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -228,7 +228,7 @@ }, "/access-control/roles/{roleUID}/assignments": { "get": { - "description": "Get role assignments for the role with the given UID.\n\nYou need to have a permission with action `teams.roles:list` and scope `teams:id:*` and `users.roles:list` and scope `users:id:*`.", + "description": "Get role assignments for the role with the given UID.\nDoes not include role assignments mapped through group attribute sync.\n\nYou need to have a permission with action `teams.roles:list` and scope `teams:id:*` and `users.roles:list` and scope `users:id:*`.", "tags": [ "access_control", "enterprise" @@ -582,7 +582,7 @@ } }, "put": { - "description": "Update the user’s role assignments to match the provided set of UIDs. This will remove any assigned roles that aren’t in the request and add roles that are in the set but are not already assigned to the user.\nIf you want to add or remove a single role, consider using Add a user role assignment or Remove a user role assignment instead.\n\nYou need to have a permission with action `users.roles:add` and `users.roles:remove` and scope `permissions:type:delegate` for each. `permissions:type:delegate` scope ensures that users can only assign or unassign roles which have same, or a subset of permissions which the user has. For example, if a user does not have required permissions for creating users, they won’t be able to assign or unassign a role which will allow to do that. This is done to prevent escalation of privileges.", + "description": "Update the user’s role assignments to match the provided set of UIDs. This will remove any assigned roles that aren’t in the request and add roles that are in the set but are not already assigned to the user.\nRoles mapped through group attribute sync are not impacted.\nIf you want to add or remove a single role, consider using Add a user role assignment or Remove a user role assignment instead.\n\nYou need to have a permission with action `users.roles:add` and `users.roles:remove` and scope `permissions:type:delegate` for each. `permissions:type:delegate` scope ensures that users can only assign or unassign roles which have same, or a subset of permissions which the user has. For example, if a user does not have required permissions for creating users, they won’t be able to assign or unassign a role which will allow to do that. This is done to prevent escalation of privileges.", "tags": [ "access_control", "enterprise" diff --git a/public/api-merged.json b/public/api-merged.json index b0e2b3097d6..51e16c1393a 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -228,7 +228,7 @@ }, "/access-control/roles/{roleUID}/assignments": { "get": { - "description": "Get role assignments for the role with the given UID.\n\nYou need to have a permission with action `teams.roles:list` and scope `teams:id:*` and `users.roles:list` and scope `users:id:*`.", + "description": "Get role assignments for the role with the given UID.\nDoes not include role assignments mapped through group attribute sync.\n\nYou need to have a permission with action `teams.roles:list` and scope `teams:id:*` and `users.roles:list` and scope `users:id:*`.", "tags": [ "access_control", "enterprise" @@ -582,7 +582,7 @@ } }, "put": { - "description": "Update the user’s role assignments to match the provided set of UIDs. This will remove any assigned roles that aren’t in the request and add roles that are in the set but are not already assigned to the user.\nIf you want to add or remove a single role, consider using Add a user role assignment or Remove a user role assignment instead.\n\nYou need to have a permission with action `users.roles:add` and `users.roles:remove` and scope `permissions:type:delegate` for each. `permissions:type:delegate` scope ensures that users can only assign or unassign roles which have same, or a subset of permissions which the user has. For example, if a user does not have required permissions for creating users, they won’t be able to assign or unassign a role which will allow to do that. This is done to prevent escalation of privileges.", + "description": "Update the user’s role assignments to match the provided set of UIDs. This will remove any assigned roles that aren’t in the request and add roles that are in the set but are not already assigned to the user.\nRoles mapped through group attribute sync are not impacted.\nIf you want to add or remove a single role, consider using Add a user role assignment or Remove a user role assignment instead.\n\nYou need to have a permission with action `users.roles:add` and `users.roles:remove` and scope `permissions:type:delegate` for each. `permissions:type:delegate` scope ensures that users can only assign or unassign roles which have same, or a subset of permissions which the user has. For example, if a user does not have required permissions for creating users, they won’t be able to assign or unassign a role which will allow to do that. This is done to prevent escalation of privileges.", "tags": [ "access_control", "enterprise" diff --git a/public/openapi3.json b/public/openapi3.json index 50569072576..fb2ee8f68ca 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -13325,7 +13325,7 @@ }, "/access-control/roles/{roleUID}/assignments": { "get": { - "description": "Get role assignments for the role with the given UID.\n\nYou need to have a permission with action `teams.roles:list` and scope `teams:id:*` and `users.roles:list` and scope `users:id:*`.", + "description": "Get role assignments for the role with the given UID.\nDoes not include role assignments mapped through group attribute sync.\n\nYou need to have a permission with action `teams.roles:list` and scope `teams:id:*` and `users.roles:list` and scope `users:id:*`.", "operationId": "getRoleAssignments", "parameters": [ { @@ -13748,7 +13748,7 @@ ] }, "put": { - "description": "Update the user’s role assignments to match the provided set of UIDs. This will remove any assigned roles that aren’t in the request and add roles that are in the set but are not already assigned to the user.\nIf you want to add or remove a single role, consider using Add a user role assignment or Remove a user role assignment instead.\n\nYou need to have a permission with action `users.roles:add` and `users.roles:remove` and scope `permissions:type:delegate` for each. `permissions:type:delegate` scope ensures that users can only assign or unassign roles which have same, or a subset of permissions which the user has. For example, if a user does not have required permissions for creating users, they won’t be able to assign or unassign a role which will allow to do that. This is done to prevent escalation of privileges.", + "description": "Update the user’s role assignments to match the provided set of UIDs. This will remove any assigned roles that aren’t in the request and add roles that are in the set but are not already assigned to the user.\nRoles mapped through group attribute sync are not impacted.\nIf you want to add or remove a single role, consider using Add a user role assignment or Remove a user role assignment instead.\n\nYou need to have a permission with action `users.roles:add` and `users.roles:remove` and scope `permissions:type:delegate` for each. `permissions:type:delegate` scope ensures that users can only assign or unassign roles which have same, or a subset of permissions which the user has. For example, if a user does not have required permissions for creating users, they won’t be able to assign or unassign a role which will allow to do that. This is done to prevent escalation of privileges.", "operationId": "setUserRoles", "parameters": [ { From c2fb2dcfbe6cec78275834280de5e508ef02a586 Mon Sep 17 00:00:00 2001 From: Scott Lepper Date: Tue, 8 Oct 2024 13:09:56 -0400 Subject: [PATCH 108/115] wire up unified search from the ui; add basic search support (#94358) * wire up search from the ui; add basic search support --- .github/CODEOWNERS | 1 + pkg/api/api.go | 4 + pkg/api/http_server.go | 5 +- pkg/server/wire.go | 3 + .../plugins_integration_test.go | 2 +- pkg/services/unifiedSearch/http.go | 73 +++++ pkg/services/unifiedSearch/service.go | 176 ++++++++++++ pkg/services/unifiedSearch/types.go | 49 ++++ pkg/storage/unified/resource/index.go | 51 +++- pkg/storage/unified/resource/index_server.go | 7 + pkg/storage/unified/resource/resource.pb.go | 261 ++++++++--------- pkg/storage/unified/resource/resource.proto | 1 + pkg/tsdb/grafanads/grafana.go | 50 ++-- pkg/tsdb/grafanads/query.go | 3 + .../app/features/search/service/searcher.ts | 6 + public/app/features/search/service/unified.ts | 265 ++++++++++++++++++ .../grafana/components/QueryEditor.tsx | 20 ++ .../app/plugins/datasource/grafana/types.ts | 2 + 18 files changed, 828 insertions(+), 151 deletions(-) create mode 100644 pkg/services/unifiedSearch/http.go create mode 100644 pkg/services/unifiedSearch/service.go create mode 100644 pkg/services/unifiedSearch/types.go create mode 100644 public/app/features/search/service/unified.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9e0a8720582..aea94e92eeb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -298,6 +298,7 @@ /pkg/modules/ @grafana/grafana-app-platform-squad /pkg/services/grpcserver/ @grafana/grafana-search-and-storage /pkg/generated @grafana/grafana-app-platform-squad +/pkg/services/unifiedSearch/ @grafana/grafana-search-and-storage # Alerting /pkg/services/ngalert/ @grafana/alerting-backend diff --git a/pkg/api/api.go b/pkg/api/api.go index a774627ba5d..e79526134cc 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -306,6 +306,10 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Group("/search-v2", hs.SearchV2HTTPService.RegisterHTTPRoutes) } + if hs.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearch) { + apiRoute.Group("/unified-search", hs.UnifiedSearchHTTPService.RegisterHTTPRoutes) + } + // current org apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { userIDScope := ac.Scope("users", "id", ac.Parameter(":userId")) diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index f9908deaadf..06ce9cc4b17 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -104,6 +104,7 @@ import ( "github.com/grafana/grafana/pkg/services/tag" "github.com/grafana/grafana/pkg/services/team" tempUser "github.com/grafana/grafana/pkg/services/temp_user" + "github.com/grafana/grafana/pkg/services/unifiedSearch" "github.com/grafana/grafana/pkg/services/updatechecker" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/validations" @@ -156,6 +157,7 @@ type HTTPServer struct { LivePushGateway *pushhttp.Gateway StorageService store.StorageService SearchV2HTTPService searchV2.SearchHTTPService + UnifiedSearchHTTPService unifiedSearch.SearchHTTPService ContextHandler *contexthandler.ContextHandler LoggerMiddleware loggermw.Logger SQLStore db.DB @@ -266,7 +268,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi publicDashboardsApi *publicdashboardsApi.Api, userService user.Service, tempUserService tempUser.Service, loginAttemptService loginAttempt.Service, orgService org.Service, teamService team.Service, accesscontrolService accesscontrol.Service, navTreeService navtree.Service, - annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, oauthTokenService oauthtoken.OAuthTokenService, + annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, unifiedSearchHTTPService unifiedSearch.SearchHTTPService, oauthTokenService oauthtoken.OAuthTokenService, statsService stats.Service, authnService authn.Service, pluginsCDNService *pluginscdn.Service, promGatherer prometheus.Gatherer, starApi *starApi.API, promRegister prometheus.Registerer, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, anonService anonymous.Service, userVerifier user.Verifier, @@ -308,6 +310,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi AccessControl: accessControl, DataProxy: dataSourceProxy, SearchV2HTTPService: searchv2HTTPService, + UnifiedSearchHTTPService: unifiedSearchHTTPService, SearchService: searchService, Live: live, LivePushGateway: livePushGateway, diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 5549a7d5966..1d31c7b1d35 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -149,6 +149,7 @@ import ( "github.com/grafana/grafana/pkg/services/team/teamimpl" tempuser "github.com/grafana/grafana/pkg/services/temp_user" "github.com/grafana/grafana/pkg/services/temp_user/tempuserimpl" + "github.com/grafana/grafana/pkg/services/unifiedSearch" "github.com/grafana/grafana/pkg/services/updatechecker" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" @@ -229,6 +230,8 @@ var wireBasicSet = wire.NewSet( search.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, + unifiedSearch.ProvideService, + unifiedSearch.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, diff --git a/pkg/services/pluginsintegration/plugins_integration_test.go b/pkg/services/pluginsintegration/plugins_integration_test.go index 66d6dda25d5..428fa1b8364 100644 --- a/pkg/services/pluginsintegration/plugins_integration_test.go +++ b/pkg/services/pluginsintegration/plugins_integration_test.go @@ -91,7 +91,7 @@ func TestIntegrationPluginManager(t *testing.T) { ms := mssql.ProvideService(cfg) db := db.InitTestDB(t, sqlstore.InitTestDBOpt{Cfg: cfg}) sv2 := searchV2.ProvideService(cfg, db, nil, nil, tracer, features, nil, nil, nil) - graf := grafanads.ProvideService(sv2, nil) + graf := grafanads.ProvideService(sv2, nil, nil, features) pyroscope := pyroscope.ProvideService(hcp) parca := parca.ProvideService(hcp) coreRegistry := coreplugin.ProvideCoreRegistry(tracing.InitializeTracerForTest(), am, cw, cm, es, grap, idb, lk, otsdb, pr, tmpo, td, pg, my, ms, graf, pyroscope, parca) diff --git a/pkg/services/unifiedSearch/http.go b/pkg/services/unifiedSearch/http.go new file mode 100644 index 00000000000..ed885b1c40f --- /dev/null +++ b/pkg/services/unifiedSearch/http.go @@ -0,0 +1,73 @@ +package unifiedSearch + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/middleware" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" +) + +type SearchHTTPService interface { + RegisterHTTPRoutes(storageRoute routing.RouteRegister) +} + +type searchHTTPService struct { + search SearchService +} + +func ProvideSearchHTTPService(search SearchService) SearchHTTPService { + return &searchHTTPService{search: search} +} + +func (s *searchHTTPService) RegisterHTTPRoutes(storageRoute routing.RouteRegister) { + storageRoute.Post("/", middleware.ReqSignedIn, routing.Wrap(s.doQuery)) +} + +func (s *searchHTTPService) doQuery(c *contextmodel.ReqContext) response.Response { + searchReadinessCheckResp := s.search.IsReady(c.Req.Context(), c.SignedInUser.GetOrgID()) + if !searchReadinessCheckResp.IsReady { + return response.JSON(http.StatusOK, &backend.DataResponse{ + Frames: []*data.Frame{{ + Name: "Loading", + }}, + Error: nil, + }) + } + + body, err := io.ReadAll(c.Req.Body) + if err != nil { + return response.Error(http.StatusInternalServerError, "error reading bytes", err) + } + + query := &Query{} + err = json.Unmarshal(body, query) + if err != nil { + return response.Error(http.StatusBadRequest, "error parsing body", err) + } + + resp := s.search.doQuery(c.Req.Context(), c.SignedInUser, c.SignedInUser.GetOrgID(), *query) + + if resp.Error != nil { + return response.Error(http.StatusInternalServerError, "error handling search request", resp.Error) + } + + if len(resp.Frames) == 0 { + msg := "invalid search response" + return response.Error(http.StatusInternalServerError, msg, errors.New(msg)) + } + + bytes, err := resp.MarshalJSON() + if err != nil { + return response.Error(http.StatusInternalServerError, "error marshalling response", err) + } + + return response.JSON(http.StatusOK, bytes) +} diff --git a/pkg/services/unifiedSearch/service.go b/pkg/services/unifiedSearch/service.go new file mode 100644 index 00000000000..eb83ac8bbaa --- /dev/null +++ b/pkg/services/unifiedSearch/service.go @@ -0,0 +1,176 @@ +package unifiedSearch + +import ( + "context" + "errors" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/infra/db" + "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/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/resource" +) + +type StandardSearchService struct { + registry.BackgroundService + cfg *setting.Cfg + sql db.DB + ac accesscontrol.Service + orgService org.Service + userService user.Service + logger log.Logger + reIndexCh chan struct{} + features featuremgmt.FeatureToggles + resourceClient resource.ResourceClient +} + +func (s *StandardSearchService) IsReady(ctx context.Context, orgId int64) IsSearchReadyResponse { + return IsSearchReadyResponse{IsReady: true} +} + +func ProvideService(cfg *setting.Cfg, sql db.DB, entityEventStore store.EntityEventsService, + ac accesscontrol.Service, tracer tracing.Tracer, features featuremgmt.FeatureToggles, orgService org.Service, + userService user.Service, folderStore folder.Store, resourceClient resource.ResourceClient) SearchService { + logger := log.New("searchV3") + s := &StandardSearchService{ + cfg: cfg, + sql: sql, + ac: ac, + logger: logger, + reIndexCh: make(chan struct{}, 1), + orgService: orgService, + userService: userService, + features: features, + resourceClient: resourceClient, + } + return s +} + +func (s *StandardSearchService) IsDisabled() bool { + return !s.features.IsEnabledGlobally(featuremgmt.FlagPanelTitleSearch) +} + +func (s *StandardSearchService) Run(ctx context.Context) error { + // TODO: implement this? ( copied from pkg/services/searchV2/service.go ) + // orgQuery := &org.SearchOrgsQuery{} + // result, err := s.orgService.Search(ctx, orgQuery) + // if err != nil { + // return fmt.Errorf("can't get org list: %w", err) + // } + // orgIDs := make([]int64, 0, len(result)) + // for _, org := range result { + // orgIDs = append(orgIDs, org.ID) + // } + // TODO: do we need to initialize the bleve index again ( should be initialized on startup )? + // return s.dashboardIndex.run(ctx, orgIDs, s.reIndexCh) + return nil +} + +func (s *StandardSearchService) TriggerReIndex() { + select { + case s.reIndexCh <- struct{}{}: + default: + // channel is full => re-index will happen soon anyway. + } +} + +func (s *StandardSearchService) getUser(ctx context.Context, backendUser *backend.User, orgId int64) (*user.SignedInUser, error) { + // TODO: get user & user's permissions from the request context + var usr *user.SignedInUser + if s.cfg.AnonymousEnabled && backendUser.Email == "" && backendUser.Login == "" { + getOrg := org.GetOrgByNameQuery{Name: s.cfg.AnonymousOrgName} + orga, err := s.orgService.GetByName(ctx, &getOrg) + if err != nil { + s.logger.Error("Anonymous access organization error.", "org_name", s.cfg.AnonymousOrgName, "error", err) + return nil, err + } + + usr = &user.SignedInUser{ + OrgID: orga.ID, + OrgName: orga.Name, + OrgRole: org.RoleType(s.cfg.AnonymousOrgRole), + IsAnonymous: true, + } + } else { + getSignedInUserQuery := &user.GetSignedInUserQuery{ + Login: backendUser.Login, + Email: backendUser.Email, + OrgID: orgId, + } + var err error + usr, err = s.userService.GetSignedInUser(ctx, getSignedInUserQuery) + if err != nil { + s.logger.Error("Error while retrieving user", "error", err, "email", backendUser.Email, "login", getSignedInUserQuery.Login) + return nil, errors.New("auth error") + } + + if usr == nil { + s.logger.Error("No user found", "email", backendUser.Email) + return nil, errors.New("auth error") + } + } + + if usr.Permissions == nil { + usr.Permissions = make(map[int64]map[string][]string) + } + + if _, ok := usr.Permissions[orgId]; ok { + // permissions as part of the `s.sql.GetSignedInUser` query - return early + return usr, nil + } + + // TODO: ensure this is cached + permissions, err := s.ac.GetUserPermissions(ctx, usr, + accesscontrol.Options{ReloadCache: false}) + if err != nil { + s.logger.Error("Failed to retrieve user permissions", "error", err, "email", backendUser.Email) + return nil, errors.New("auth error") + } + + usr.Permissions[orgId] = accesscontrol.GroupScopesByActionContext(ctx, permissions) + return usr, nil +} + +func (s *StandardSearchService) DoQuery(ctx context.Context, user *backend.User, orgID int64, q Query) *backend.DataResponse { + signedInUser, err := s.getUser(ctx, user, orgID) + if err != nil { + return &backend.DataResponse{Error: err} + } + + query := s.doQuery(ctx, signedInUser, orgID, q) + return query +} + +func (s *StandardSearchService) doQuery(ctx context.Context, signedInUser *user.SignedInUser, orgID int64, q Query) *backend.DataResponse { + response := s.doSearchQuery(ctx, q, s.cfg.AppSubURL) + return response +} + +func (s *StandardSearchService) doSearchQuery(ctx context.Context, qry Query, _ string) *backend.DataResponse { + response := &backend.DataResponse{} + + req := &resource.SearchRequest{Tenant: s.cfg.StackID, Query: qry.Query} + res, err := s.resourceClient.Search(ctx, req) + if err != nil { + response.Error = err + return response + } + + // TODO: implement this correctly + frame := data.NewFrame("results", data.NewField("value", nil, []string{})) + frame.Meta = &data.FrameMeta{Notices: []data.Notice{{Text: "TODO"}}} + for _, r := range res.Items { + frame.AppendRow(string(r.Value)) + } + response.Frames = append(response.Frames, frame) + return response +} diff --git a/pkg/services/unifiedSearch/types.go b/pkg/services/unifiedSearch/types.go new file mode 100644 index 00000000000..8faa56f93f0 --- /dev/null +++ b/pkg/services/unifiedSearch/types.go @@ -0,0 +1,49 @@ +package unifiedSearch + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/user" +) + +type FacetField struct { + Field string `json:"field"` + Limit int `json:"limit,omitempty"` // explicit page size +} + +type Query struct { + Query string `json:"query"` + Location string `json:"location,omitempty"` // parent folder ID + Sort string `json:"sort,omitempty"` // field ASC/DESC + Datasource string `json:"ds_uid,omitempty"` // "datasource" collides with the JSON value at the same level :() + DatasourceType string `json:"ds_type,omitempty"` + Tags []string `json:"tags,omitempty"` + Kind []string `json:"kind,omitempty"` + PanelType string `json:"panel_type,omitempty"` + UIDs []string `json:"uid,omitempty"` + Explain bool `json:"explain,omitempty"` // adds details on why document matched + WithAllowedActions bool `json:"withAllowedActions,omitempty"` // adds allowed actions per entity + Facet []FacetField `json:"facet,omitempty"` + SkipLocation bool `json:"skipLocation,omitempty"` + HasPreview string `json:"hasPreview,omitempty"` // the light|dark theme + Limit int `json:"limit,omitempty"` // explicit page size + From int `json:"from,omitempty"` // for paging +} + +type IsSearchReadyResponse struct { + IsReady bool + Reason string // initial-indexing-ongoing, org-indexing-ongoing +} + +type SearchService interface { + registry.CanBeDisabled + registry.BackgroundService + DoQuery(ctx context.Context, user *backend.User, orgId int64, query Query) *backend.DataResponse + doQuery(ctx context.Context, user *user.SignedInUser, orgId int64, query Query) *backend.DataResponse + IsReady(ctx context.Context, orgId int64) IsSearchReadyResponse + // RegisterDashboardIndexExtender(ext DashboardIndexExtender) + TriggerReIndex() +} diff --git a/pkg/storage/unified/resource/index.go b/pkg/storage/unified/resource/index.go index 39886ad875c..696ea11147f 100644 --- a/pkg/storage/unified/resource/index.go +++ b/pkg/storage/unified/resource/index.go @@ -8,6 +8,7 @@ import ( "os" "github.com/blevesearch/bleve/v2" + "github.com/blevesearch/bleve/v2/analysis/lang/en" "github.com/blevesearch/bleve/v2/mapping" "github.com/google/uuid" ) @@ -52,7 +53,13 @@ func (i *Index) Init(ctx context.Context) error { if err != nil { return err } - err = shard.batch.Index(res.Metadata.Uid, obj) + + var jsonDoc interface{} + err = json.Unmarshal(obj.Value, &jsonDoc) + if err != nil { + return err + } + err = shard.batch.Index(res.Metadata.Uid, jsonDoc) if err != nil { return err } @@ -99,6 +106,31 @@ func (i *Index) Delete(ctx context.Context, uid string, key *ResourceKey) error return nil } +func (i *Index) Search(ctx context.Context, tenant string, query string) ([]string, error) { + if tenant == "" { + tenant = "default" + } + shard, err := i.getShard(tenant) + if err != nil { + return nil, err + } + req := bleve.NewSearchRequest(bleve.NewQueryStringQuery(query)) + req.Fields = []string{"kind", "spec.title"} + + res, err := shard.index.Search(req) + if err != nil { + return nil, err + } + + hits := res.Hits + results := []string{} + for _, hit := range hits { + val := fmt.Sprintf("%s:%s", hit.Fields["kind"], hit.Fields["spec.title"]) + results = append(results, val) + } + return results, nil +} + func tenant(res *Resource) string { return res.Metadata.Namespace } @@ -142,20 +174,31 @@ func createIndexMappings() *mapping.IndexMappingImpl { metaMapping.AddFieldMappingsAt("name", nameFieldMapping) metaMapping.AddFieldMappingsAt("creationTimestamp", creationTimestampFieldMapping) metaMapping.Dynamic = false + metaMapping.Enabled = true + + specMapping := bleve.NewDocumentMapping() + specMapping.AddFieldMappingsAt("title", nameFieldMapping) + specMapping.Dynamic = false + specMapping.Enabled = true //Create a sub-document mapping for the metadata field objectMapping := bleve.NewDocumentMapping() objectMapping.AddSubDocumentMapping("metadata", metaMapping) + objectMapping.AddSubDocumentMapping("spec", specMapping) + objectMapping.Dynamic = false + objectMapping.Enabled = true + + // a generic reusable mapping for english text + englishTextFieldMapping := bleve.NewTextFieldMapping() + englishTextFieldMapping.Analyzer = en.AnalyzerName // Map top level fields - just kind for now - kindFieldMapping := bleve.NewTextFieldMapping() - objectMapping.AddFieldMappingsAt("kind", kindFieldMapping) + objectMapping.AddFieldMappingsAt("kind", englishTextFieldMapping) objectMapping.Dynamic = false // Create the index mapping indexMapping := bleve.NewIndexMapping() indexMapping.DefaultMapping = objectMapping - indexMapping.DefaultMapping.Dynamic = false return indexMapping } diff --git a/pkg/storage/unified/resource/index_server.go b/pkg/storage/unified/resource/index_server.go index ea45e335db8..c90f176549b 100644 --- a/pkg/storage/unified/resource/index_server.go +++ b/pkg/storage/unified/resource/index_server.go @@ -17,7 +17,14 @@ type IndexServer struct { } func (is IndexServer) Search(ctx context.Context, req *SearchRequest) (*SearchResponse, error) { + results, err := is.index.Search(ctx, req.Tenant, req.Query) + if err != nil { + return nil, err + } res := &SearchResponse{} + for _, r := range results { + res.Items = append(res.Items, &ResourceWrapper{Value: []byte(r)}) + } return res, nil } diff --git a/pkg/storage/unified/resource/resource.pb.go b/pkg/storage/unified/resource/resource.pb.go index 44e5be68f8c..7728dd880be 100644 --- a/pkg/storage/unified/resource/resource.pb.go +++ b/pkg/storage/unified/resource/resource.pb.go @@ -1619,7 +1619,8 @@ type SearchRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Query string `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + Query string `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + Tenant string `protobuf:"bytes,2,opt,name=tenant,proto3" json:"tenant,omitempty"` } func (x *SearchRequest) Reset() { @@ -1661,6 +1662,13 @@ func (x *SearchRequest) GetQuery() string { return "" } +func (x *SearchRequest) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + type SearchResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2441,135 +2449,136 @@ var file_resource_proto_rawDesc = []byte{ 0x09, 0x0a, 0x05, 0x41, 0x44, 0x44, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x03, 0x12, 0x0c, 0x0a, 0x08, 0x42, 0x4f, 0x4f, 0x4b, 0x4d, 0x41, 0x52, - 0x4b, 0x10, 0x04, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x22, 0x25, + 0x4b, 0x10, 0x04, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x22, 0x3d, 0x0a, 0x0d, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x71, 0x75, 0x65, 0x72, 0x79, 0x22, 0x41, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x57, 0x72, 0x61, 0x70, 0x70, 0x65, - 0x72, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0x9a, 0x01, 0x0a, 0x0e, 0x48, 0x69, 0x73, - 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, - 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, - 0x65, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x68, 0x6f, 0x77, 0x5f, 0x64, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x73, 0x68, 0x6f, 0x77, 0x44, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x64, 0x22, 0xbf, 0x01, 0x0a, 0x0f, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, - 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x05, 0x69, 0x74, 0x65, - 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4d, 0x65, 0x74, 0x61, - 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, - 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, - 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, - 0x72, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, - 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x8e, 0x01, 0x0a, 0x0d, 0x4f, 0x72, 0x69, 0x67, - 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, - 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x03, + 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x22, 0x41, 0x0a, + 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x2f, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x57, 0x72, 0x61, 0x70, 0x70, 0x65, 0x72, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, + 0x22, 0x9a, 0x01, 0x0a, 0x0e, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, + 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, + 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, + 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, + 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x68, + 0x6f, 0x77, 0x5f, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x0b, 0x73, 0x68, 0x6f, 0x77, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x22, 0xbf, 0x01, + 0x0a, 0x0f, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x2c, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, + 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, + 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, + 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, + 0x8e, 0x01, 0x0a, 0x0d, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, + 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, + 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x12, + 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x72, 0x69, 0x67, + 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, + 0x22, 0xe5, 0x01, 0x0a, 0x12, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4f, 0x72, 0x69, + 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, - 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x22, 0xe5, 0x01, 0x0a, 0x12, 0x52, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, - 0x27, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x4b, 0x65, 0x79, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, - 0x0c, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x23, 0x0a, - 0x0d, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x48, 0x61, - 0x73, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, - 0x74, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x12, - 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x61, - 0x73, 0x68, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, - 0x22, 0xc4, 0x01, 0x0a, 0x0e, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x66, 0x6f, - 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, - 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, - 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, - 0x72, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, - 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x2e, 0x0a, 0x12, 0x48, 0x65, 0x61, 0x6c, 0x74, - 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, - 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, 0xab, 0x01, 0x0a, 0x13, 0x48, 0x65, 0x61, 0x6c, - 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x43, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x2b, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, - 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, - 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x22, 0x4f, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, - 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, - 0x0f, 0x0a, 0x0b, 0x4e, 0x4f, 0x54, 0x5f, 0x53, 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x02, - 0x12, 0x13, 0x0a, 0x0f, 0x53, 0x45, 0x52, 0x56, 0x49, 0x43, 0x45, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, - 0x4f, 0x57, 0x4e, 0x10, 0x03, 0x2a, 0x33, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x10, 0x0a, - 0x0c, 0x4e, 0x6f, 0x74, 0x4f, 0x6c, 0x64, 0x65, 0x72, 0x54, 0x68, 0x61, 0x6e, 0x10, 0x00, 0x12, - 0x09, 0x0a, 0x05, 0x45, 0x78, 0x61, 0x63, 0x74, 0x10, 0x01, 0x32, 0xed, 0x02, 0x0a, 0x0d, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x74, 0x6f, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x04, - 0x52, 0x65, 0x61, 0x64, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x3b, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, - 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, - 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x4c, 0x69, - 0x73, 0x74, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, - 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x37, 0x0a, 0x05, 0x57, 0x61, 0x74, 0x63, 0x68, 0x12, 0x16, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, - 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x32, 0xc9, 0x01, 0x0a, 0x0d, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x3b, 0x0a, 0x06, - 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x48, 0x69, 0x73, - 0x74, 0x6f, 0x72, 0x79, 0x12, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, - 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, - 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, - 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x4f, 0x72, 0x69, - 0x67, 0x69, 0x6e, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4f, - 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x57, 0x0a, 0x0b, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, - 0x73, 0x74, 0x69, 0x63, 0x73, 0x12, 0x48, 0x0a, 0x09, 0x49, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, - 0x68, 0x79, 0x12, 0x1c, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, - 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, - 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, - 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, - 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, - 0x67, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, - 0x64, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x73, 0x69, 0x7a, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x48, 0x61, 0x73, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x72, + 0x69, 0x67, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x72, 0x69, 0x67, + 0x69, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x06, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x61, 0x73, 0x68, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0xc4, 0x01, 0x0a, 0x0e, 0x4f, 0x72, 0x69, + 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x05, 0x69, + 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4f, 0x72, + 0x69, 0x67, 0x69, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, + 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, + 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, + 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x12, 0x2b, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x15, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x45, 0x72, 0x72, + 0x6f, 0x72, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, + 0x2e, 0x0a, 0x12, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, + 0xab, 0x01, 0x0a, 0x13, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2b, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x4f, 0x0a, 0x0d, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, + 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x45, + 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x4e, 0x4f, 0x54, 0x5f, 0x53, + 0x45, 0x52, 0x56, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x53, 0x45, 0x52, 0x56, + 0x49, 0x43, 0x45, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x03, 0x2a, 0x33, 0x0a, + 0x14, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x10, 0x0a, 0x0c, 0x4e, 0x6f, 0x74, 0x4f, 0x6c, 0x64, 0x65, + 0x72, 0x54, 0x68, 0x61, 0x6e, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x78, 0x61, 0x63, 0x74, + 0x10, 0x01, 0x32, 0xed, 0x02, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, + 0x74, 0x6f, 0x72, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x52, 0x65, 0x61, 0x64, 0x12, 0x15, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x61, 0x64, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x52, + 0x65, 0x61, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x43, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x12, 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x12, + 0x17, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x35, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x15, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4c, 0x69, 0x73, + 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x05, 0x57, 0x61, 0x74, + 0x63, 0x68, 0x12, 0x16, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, + 0x74, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x57, 0x61, 0x74, 0x63, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x30, 0x01, 0x32, 0xc9, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, + 0x6e, 0x64, 0x65, 0x78, 0x12, 0x3b, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x17, + 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x18, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x2e, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x3b, 0x0a, 0x06, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x12, 0x17, 0x2e, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, + 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x57, + 0x0a, 0x0b, 0x44, 0x69, 0x61, 0x67, 0x6e, 0x6f, 0x73, 0x74, 0x69, 0x63, 0x73, 0x12, 0x48, 0x0a, + 0x09, 0x49, 0x73, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x1c, 0x2e, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, + 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x68, 0x75, + 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x67, 0x72, + 0x61, 0x66, 0x61, 0x6e, 0x61, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x61, 0x67, + 0x65, 0x2f, 0x75, 0x6e, 0x69, 0x66, 0x69, 0x65, 0x64, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/pkg/storage/unified/resource/resource.proto b/pkg/storage/unified/resource/resource.proto index 9859ea51994..dcaee25b909 100644 --- a/pkg/storage/unified/resource/resource.proto +++ b/pkg/storage/unified/resource/resource.proto @@ -326,6 +326,7 @@ message WatchEvent { message SearchRequest { string query = 1; + string tenant = 2; } message SearchResponse { diff --git a/pkg/tsdb/grafanads/grafana.go b/pkg/tsdb/grafanads/grafana.go index e3eb5c7f8bd..4a7cc09413b 100644 --- a/pkg/tsdb/grafanads/grafana.go +++ b/pkg/tsdb/grafanads/grafana.go @@ -15,8 +15,10 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/searchV2" "github.com/grafana/grafana/pkg/services/store" + "github.com/grafana/grafana/pkg/services/unifiedSearch" testdatasource "github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource" ) @@ -51,15 +53,17 @@ var ( ) ) -func ProvideService(search searchV2.SearchService, store store.StorageService) *Service { - return newService(search, store) +func ProvideService(search searchV2.SearchService, searchNext unifiedSearch.SearchService, store store.StorageService, features featuremgmt.FeatureToggles) *Service { + return newService(search, searchNext, store, features) } -func newService(search searchV2.SearchService, store store.StorageService) *Service { +func newService(search searchV2.SearchService, searchNext unifiedSearch.SearchService, store store.StorageService, features featuremgmt.FeatureToggles) *Service { s := &Service{ - search: search, - store: store, - log: log.New("grafanads"), + search: search, + searchNext: searchNext, + store: store, + log: log.New("grafanads"), + features: features, } return s @@ -67,9 +71,11 @@ func newService(search searchV2.SearchService, store store.StorageService) *Serv // Service exists regardless of user settings type Service struct { - search searchV2.SearchService - store store.StorageService - log log.Logger + search searchV2.SearchService + searchNext unifiedSearch.SearchService + store store.StorageService + log log.Logger + features featuremgmt.FeatureToggles } func DataSourceModel(orgId int64) *datasources.DataSource { @@ -95,7 +101,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) response.Responses[q.RefID] = s.doListQuery(ctx, q) case queryTypeRead: response.Responses[q.RefID] = s.doReadQuery(ctx, q) - case queryTypeSearch: + case queryTypeSearch, queryTypeSearchNext: response.Responses[q.RefID] = s.doSearchQuery(ctx, req, q) default: response.Responses[q.RefID] = backend.DataResponse{ @@ -177,6 +183,18 @@ func (s *Service) doRandomWalk(query backend.DataQuery) backend.DataResponse { } func (s *Service) doSearchQuery(ctx context.Context, req *backend.QueryDataRequest, query backend.DataQuery) backend.DataResponse { + m := requestModel{} + err := json.Unmarshal(query.JSON, &m) + if err != nil { + return backend.DataResponse{ + Error: err, + } + } + + if s.features.IsEnabled(ctx, featuremgmt.FlagUnifiedStorageSearch) { + return *s.searchNext.DoQuery(ctx, req.PluginContext.User, req.PluginContext.OrgID, m.SearchNext) + } + searchReadinessCheckResp := s.search.IsReady(ctx, req.PluginContext.OrgID) if !searchReadinessCheckResp.IsReady { dashboardSearchNotServedRequestsCounter.With(prometheus.Labels{ @@ -192,17 +210,11 @@ func (s *Service) doSearchQuery(ctx context.Context, req *backend.QueryDataReque } } - m := requestModel{} - err := json.Unmarshal(query.JSON, &m) - if err != nil { - return backend.DataResponse{ - Error: err, - } - } return *s.search.DoDashboardQuery(ctx, req.PluginContext.User, req.PluginContext.OrgID, m.Search) } type requestModel struct { - QueryType string `json:"queryType"` - Search searchV2.DashboardQuery `json:"search,omitempty"` + QueryType string `json:"queryType"` + Search searchV2.DashboardQuery `json:"search,omitempty"` + SearchNext unifiedSearch.Query `json:"searchNext,omitempty"` } diff --git a/pkg/tsdb/grafanads/query.go b/pkg/tsdb/grafanads/query.go index 8abaf7b5f8d..af0b7afdd7f 100644 --- a/pkg/tsdb/grafanads/query.go +++ b/pkg/tsdb/grafanads/query.go @@ -7,6 +7,9 @@ const ( // QueryTypeList will list the files in a folder queryTypeSearch = "search" + // queryTypeSearchNext will perform a search query using the next generation search service + queryTypeSearchNext = "searchNext" + // QueryTypeList will list the files in a folder queryTypeList = "list" diff --git a/public/app/features/search/service/searcher.ts b/public/app/features/search/service/searcher.ts index 354411534c9..b7e8b885424 100644 --- a/public/app/features/search/service/searcher.ts +++ b/public/app/features/search/service/searcher.ts @@ -4,6 +4,7 @@ import { BlugeSearcher } from './bluge'; import { FrontendSearcher } from './frontend'; import { SQLSearcher } from './sql'; import { GrafanaSearcher } from './types'; +import { UnifiedSearcher } from './unified'; let searcher: GrafanaSearcher | undefined = undefined; @@ -13,6 +14,11 @@ export function getGrafanaSearcher(): GrafanaSearcher { const useBluge = config.featureToggles.panelTitleSearch; searcher = useBluge ? new BlugeSearcher(sqlSearcher) : sqlSearcher; + const useUnified = config.featureToggles.unifiedStorageSearch; + if (useUnified) { + searcher = new UnifiedSearcher(sqlSearcher); + } + if (useBluge && location.search.includes('do-frontend-query')) { searcher = new FrontendSearcher(searcher); } diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts new file mode 100644 index 00000000000..882b3a0a5bc --- /dev/null +++ b/public/app/features/search/service/unified.ts @@ -0,0 +1,265 @@ +// TODO: fix - copied from bluge.ts +import { + DataFrame, + DataFrameJSON, + DataFrameView, + getDisplayProcessor, + SelectableValue, + toDataFrame, +} from '@grafana/data'; +import { config, getBackendSrv } from '@grafana/runtime'; +import { TermCount } from 'app/core/components/TagFilter/TagFilter'; + +import { replaceCurrentFolderQuery } from './utils'; + +import { DashboardQueryResult, GrafanaSearcher, QueryResponse, SearchQuery } from '.'; + +// The backend returns an empty frame with a special name to indicate that the indexing engine is being rebuilt, +// and that it can not serve any search requests. We are temporarily using the old SQL Search API as a fallback when that happens. +const loadingFrameName = 'Loading'; + +const searchURI = 'api/unified-search'; + +type SearchAPIResponse = { + frames: DataFrameJSON[]; +}; + +const folderViewSort = 'name_sort'; + +export class UnifiedSearcher implements GrafanaSearcher { + constructor(private fallbackSearcher: GrafanaSearcher) {} + + async search(query: SearchQuery): Promise { + if (query.facet?.length) { + throw new Error('facets not supported!'); + } + return this.doSearchQuery(query); + } + + // TODO: fix - copied from bluge.ts + async starred(query: SearchQuery): Promise { + if (query.facet?.length) { + throw new Error('facets not supported!'); + } + // get the starred dashboards + const starsUIDS = await getBackendSrv().get('api/user/stars'); + if (starsUIDS?.length) { + return this.doSearchQuery({ + uid: starsUIDS, + query: query.query ?? '*', + }); + } + // Nothing is starred + return { + view: new DataFrameView({ length: 0, fields: [] }), + totalRows: 0, + loadMoreItems: async (startIndex: number, stopIndex: number): Promise => { + return; + }, + isItemLoaded: (index: number): boolean => { + return true; + }, + }; + } + + // TODO: fix - copied from bluge.ts + async tags(query: SearchQuery): Promise { + const req = { + ...query, + query: query.query ?? '*', + sort: undefined, // no need to sort the initial query results (not used) + facet: [{ field: 'tag' }], + limit: 1, // 0 would be better, but is ignored by the backend + }; + + const resp = await getBackendSrv().post(searchURI, req); + const frames = resp.frames.map((f) => toDataFrame(f)); + + if (frames[0]?.name === loadingFrameName) { + return this.fallbackSearcher.tags(query); + } + + for (const frame of frames) { + if (frame.fields[0].name === 'tag') { + return getTermCountsFrom(frame); + } + } + + return []; + } + + // TODO: fix - copied from bluge.ts + getSortOptions(): Promise { + const opts: SelectableValue[] = [ + { value: folderViewSort, label: 'Alphabetically (A-Z)' }, + { value: '-name_sort', label: 'Alphabetically (Z-A)' }, + ]; + + if (config.licenseInfo.enabledFeatures.analytics) { + for (const sf of sortFields) { + opts.push({ value: `-${sf.name}`, label: `${sf.display} (most)` }); + opts.push({ value: `${sf.name}`, label: `${sf.display} (least)` }); + } + for (const sf of sortTimeFields) { + opts.push({ value: `-${sf.name}`, label: `${sf.display} (recent)` }); + opts.push({ value: `${sf.name}`, label: `${sf.display} (oldest)` }); + } + } + + return Promise.resolve(opts); + } + + // TODO: update - copied from bluge.ts + async doSearchQuery(query: SearchQuery): Promise { + query = await replaceCurrentFolderQuery(query); + const req = { + ...query, + query: query.query ?? '*', + limit: query.limit ?? firstPageSize, + }; + + const rsp = await getBackendSrv().post(searchURI, req); + const frames = rsp.frames.map((f) => toDataFrame(f)); + + const first = frames.length ? toDataFrame(frames[0]) : { fields: [], length: 0 }; + + if (first.name === loadingFrameName) { + return this.fallbackSearcher.search(query); + } + + for (const field of first.fields) { + field.display = getDisplayProcessor({ field, theme: config.theme2 }); + } + + // Make sure the object exists + if (!first.meta?.custom) { + first.meta = { + ...first.meta, + custom: { + count: first.length, + max_score: 1, + }, + }; + } + + const meta = first.meta.custom || {}; + if (!meta.locationInfo) { + meta.locationInfo = {}; // always set it so we can append + } + + // Set the field name to a better display name + if (meta.sortBy?.length) { + const field = first.fields.find((f) => f.name === meta.sortBy); + if (field) { + const name = getSortFieldDisplayName(field.name); + meta.sortBy = name; + field.name = name; // make it look nicer + } + } + + let loadMax = 0; + let pending: Promise | undefined = undefined; + const getNextPage = async () => { + while (loadMax > view.dataFrame.length) { + const from = view.dataFrame.length; + if (from >= meta.count) { + return; + } + const resp = await getBackendSrv().post(searchURI, { + ...(req ?? {}), + from, + limit: nextPageSizes, + }); + const frame = toDataFrame(resp.frames[0]); + + if (!frame) { + console.log('no results', frame); + return; + } + if (frame.fields.length !== view.dataFrame.fields.length) { + console.log('invalid shape', frame, view.dataFrame); + return; + } + + // Append the raw values to the same array buffer + const length = frame.length + view.dataFrame.length; + for (let i = 0; i < frame.fields.length; i++) { + const values = view.dataFrame.fields[i].values; + values.push(...frame.fields[i].values); + } + view.dataFrame.length = length; + + // Add all the location lookup info + const submeta = frame.meta?.custom; + if (submeta?.locationInfo && meta) { + for (const [key, value] of Object.entries(submeta.locationInfo)) { + meta.locationInfo[key] = value; + } + } + } + pending = undefined; + }; + + const view = new DataFrameView(first); + return { + totalRows: meta.count ?? first.length, + view, + loadMoreItems: async (startIndex: number, stopIndex: number): Promise => { + loadMax = Math.max(loadMax, stopIndex); + if (!pending) { + pending = getNextPage(); + } + return pending; + }, + isItemLoaded: (index: number): boolean => { + return index < view.dataFrame.length; + }, + }; + } + + getFolderViewSort(): string { + return 'name_sort'; + } +} + +const firstPageSize = 50; +const nextPageSizes = 100; + +function getTermCountsFrom(frame: DataFrame): TermCount[] { + const keys = frame.fields[0].values; + const vals = frame.fields[1].values; + const counts: TermCount[] = []; + for (let i = 0; i < frame.length; i++) { + counts.push({ term: keys[i], count: vals[i] }); + } + return counts; +} + +// Enterprise only sort field values for dashboards +const sortFields = [ + { name: 'views_total', display: 'Views total' }, + { name: 'views_last_30_days', display: 'Views 30 days' }, + { name: 'errors_total', display: 'Errors total' }, + { name: 'errors_last_30_days', display: 'Errors 30 days' }, +]; + +// Enterprise only time sort field values for dashboards +const sortTimeFields = [ + { name: 'created_at', display: 'Created time' }, + { name: 'updated_at', display: 'Updated time' }, +]; + +/** Given the internal field name, this gives a reasonable display name for the table colum header */ +function getSortFieldDisplayName(name: string) { + for (const sf of sortFields) { + if (sf.name === name) { + return sf.display; + } + } + for (const sf of sortTimeFields) { + if (sf.name === name) { + return sf.display; + } + } + return name; +} diff --git a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx index e5f3b948d1c..2c473402942 100644 --- a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx @@ -82,6 +82,13 @@ export class UnthemedQueryEditor extends PureComponent { description: 'Search for grafana resources', }); } + if (config.featureToggles.unifiedStorageSearch) { + this.queryTypes.push({ + label: 'Search (experimental)', + value: GrafanaQueryType.SearchNext, + description: 'Search for grafana resources', + }); + } if (config.featureToggles.editPanelCSVDragAndDrop) { this.queryTypes.push({ label: 'Spreadsheet or snapshot', @@ -432,6 +439,16 @@ export class UnthemedQueryEditor extends PureComponent { onRunQuery(); }; + onSearchNextChange = (search: SearchQuery) => { + const { query, onChange, onRunQuery } = this.props; + + onChange({ + ...query, + searchNext: search, + }); + onRunQuery(); + }; + render() { const query = { ...defaultQuery, @@ -475,6 +492,9 @@ export class UnthemedQueryEditor extends PureComponent { {queryType === GrafanaQueryType.Search && ( )} + {queryType === GrafanaQueryType.SearchNext && ( + + )} ); } diff --git a/public/app/plugins/datasource/grafana/types.ts b/public/app/plugins/datasource/grafana/types.ts index a6680231dad..cb00f090443 100644 --- a/public/app/plugins/datasource/grafana/types.ts +++ b/public/app/plugins/datasource/grafana/types.ts @@ -19,6 +19,7 @@ export enum GrafanaQueryType { List = 'list', Read = 'read', Search = 'search', + SearchNext = 'searchNext', } export interface GrafanaQuery extends DataQuery { @@ -28,6 +29,7 @@ export interface GrafanaQuery extends DataQuery { buffer?: number; path?: string; // for list and read search?: SearchQuery; + searchNext?: SearchQuery; snapshot?: DataFrameJSON[]; timeRegion?: TimeRegionConfig; file?: GrafanaQueryFile; From 2d0ea600173d280e24bfeccf225a8af688563226 Mon Sep 17 00:00:00 2001 From: Karl Persson Date: Tue, 8 Oct 2024 19:53:23 +0200 Subject: [PATCH 109/115] ServiceAccounts: Run migration in batches (#94429) * ServiceAccounts: Run migration in batches --- .../sqlstore/migrations/accesscontrol/orphaned.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/services/sqlstore/migrations/accesscontrol/orphaned.go b/pkg/services/sqlstore/migrations/accesscontrol/orphaned.go index 05ade14da83..e96f966c862 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/orphaned.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/orphaned.go @@ -49,7 +49,13 @@ func (m *orphanedServiceAccountPermissions) Exec(sess *xorm.Session, mg *migrato return nil } - // Then find all existing service accounts + return batch(len(ids), batchSize, func(start, end int) error { + return m.exec(sess, mg, ids[start:end]) + }) +} + +func (m *orphanedServiceAccountPermissions) exec(sess *xorm.Session, mg *migrator.Migrator, ids []int64) error { + // get all service accounts from batch raw := "SELECT u.id FROM " + mg.Dialect.Quote("user") + " AS u WHERE u.is_service_account AND u.id IN(?" + strings.Repeat(",?", len(ids)-1) + ")" args := make([]any, 0, len(ids)) for _, id := range ids { @@ -57,7 +63,7 @@ func (m *orphanedServiceAccountPermissions) Exec(sess *xorm.Session, mg *migrato } var existingIDs []int64 - err = sess.SQL(raw, args...).Find(&existingIDs) + err := sess.SQL(raw, args...).Find(&existingIDs) if err != nil { return fmt.Errorf("failed to fetch existing service accounts: %w", err) } From a3764ebeba5dc1ff02bc7a73f7ab8628d981f473 Mon Sep 17 00:00:00 2001 From: Scott Lepper Date: Tue, 8 Oct 2024 16:17:31 -0400 Subject: [PATCH 110/115] [Search] fix: add and delete (#94438) [search] fix: add and delete from index --- pkg/storage/unified/resource/index.go | 7 ++++- pkg/storage/unified/resource/index_server.go | 29 ++++++++++++++------ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/pkg/storage/unified/resource/index.go b/pkg/storage/unified/resource/index.go index 696ea11147f..cdd8b6f68c5 100644 --- a/pkg/storage/unified/resource/index.go +++ b/pkg/storage/unified/resource/index.go @@ -87,7 +87,12 @@ func (i *Index) Index(ctx context.Context, data *Data) error { if err != nil { return err } - err = shard.index.Index(res.Metadata.Uid, data.Value.Value) + var jsonDoc interface{} + err = json.Unmarshal(data.Value.Value, &jsonDoc) + if err != nil { + return err + } + err = shard.index.Index(res.Metadata.Uid, jsonDoc) if err != nil { return err } diff --git a/pkg/storage/unified/resource/index_server.go b/pkg/storage/unified/resource/index_server.go index c90f176549b..47937e7f7f3 100644 --- a/pkg/storage/unified/resource/index_server.go +++ b/pkg/storage/unified/resource/index_server.go @@ -139,14 +139,9 @@ func (f *indexWatchServer) Add(we *WatchEvent) error { } func (f *indexWatchServer) Delete(we *WatchEvent) error { - // TODO: this seems flakey. Does a delete have a Resource or Previous? - // both cases have happened ( maybe because Georges pr was reverted ) - rs := we.Resource - if rs == nil { - rs = we.Previous - } - if rs == nil { - return errors.New("resource not found") + rs, err := resource(we) + if err != nil { + return err } data, err := getData(rs) if err != nil { @@ -160,7 +155,11 @@ func (f *indexWatchServer) Delete(we *WatchEvent) error { } func (f *indexWatchServer) Update(we *WatchEvent) error { - data, err := getData(we.Resource) + rs, err := resource(we) + if err != nil { + return err + } + data, err := getData(rs) if err != nil { return err } @@ -208,3 +207,15 @@ func getData(wr *WatchEvent_Resource) (*Data, error) { } return &Data{Key: key, Value: value, Uid: r.Metadata.Uid}, nil } + +func resource(we *WatchEvent) (*WatchEvent_Resource, error) { + rs := we.Resource + if rs == nil || len(rs.Value) == 0 { + // for updates/deletes + rs = we.Previous + } + if rs == nil || len(rs.Value) == 0 { + return nil, errors.New("resource not found") + } + return rs, nil +} From be7b293b79cadbf3cf8bccd5d5517086da4c6f9a Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Wed, 9 Oct 2024 07:29:07 +0300 Subject: [PATCH 111/115] Routing: Hoist alerting receivers routes (#94309) * Separate receivers components * Update exports * Remove type assertion * Add back test * Format * Update test * Fix test import --- public/app/features/alerting/routes.tsx | 97 ++++++++----------- .../alerting/unified/Receivers.test.tsx | 45 ++++----- .../features/alerting/unified/Receivers.tsx | 24 ----- .../contact-points/ContactPoints.test.tsx | 2 +- .../contact-points/ContactPoints.tsx | 14 ++- .../contact-points/EditContactPoint.tsx | 22 +++-- .../components/GlobalConfig.tsx | 13 ++- .../components/receivers/NewReceiverView.tsx | 12 ++- 8 files changed, 114 insertions(+), 115 deletions(-) delete mode 100644 public/app/features/alerting/unified/Receivers.tsx diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 8430a85d0b0..ee8fe2bbb17 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -3,10 +3,7 @@ import { config } from 'app/core/config'; import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/types'; import { AccessControlAction } from 'app/types'; -import { - PERMISSIONS_CONTACT_POINTS, - PERMISSIONS_CONTACT_POINTS_MODIFY, -} from './unified/components/contact-points/permissions'; +import { PERMISSIONS_CONTACT_POINTS } from './unified/components/contact-points/permissions'; import { evaluateAccess } from './unified/utils/access-control'; export function getAlertingRoutes(cfg = config): RouteDescriptor[] { @@ -104,7 +101,43 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { ...PERMISSIONS_CONTACT_POINTS, ]), component: importAlertingComponent( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') + () => + import( + /* webpackChunkName: "ContactPoints" */ 'app/features/alerting/unified/components/contact-points/ContactPoints' + ) + ), + }, + { + path: '/alerting/notifications/receivers/new', + roles: evaluateAccess([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsExternalRead, + ...PERMISSIONS_CONTACT_POINTS, + ]), + component: importAlertingComponent( + () => + import( + /* webpackChunkName: "NewReceiverView" */ 'app/features/alerting/unified/components/receivers/NewReceiverView' + ) + ), + }, + { + path: '/alerting/notifications/receivers/:name/edit', + roles: evaluateAccess([ + AccessControlAction.AlertingNotificationsWrite, + AccessControlAction.AlertingNotificationsExternalWrite, + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsExternalRead, + // We check any contact point permission here because a user without edit permissions + // still has to be able to visit the "edit" page, because we don't have a separate view for edit vs view + // (we just disable the form instead) + ...PERMISSIONS_CONTACT_POINTS, + ]), + component: importAlertingComponent( + () => + import( + /* webpackChunkName: "EditContactPoint" */ 'app/features/alerting/unified/components/contact-points/EditContactPoint' + ) ), }, { @@ -118,60 +151,16 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { ), }, { - path: '/alerting/notifications/:type/new', - roles: evaluateAccess([ - AccessControlAction.AlertingNotificationsWrite, - AccessControlAction.AlertingNotificationsExternalWrite, - ...PERMISSIONS_CONTACT_POINTS_MODIFY, - ]), - component: importAlertingComponent( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') - ), - }, - { - path: '/alerting/notifications/receivers/:id/edit', - roles: evaluateAccess([ - AccessControlAction.AlertingNotificationsWrite, - AccessControlAction.AlertingNotificationsExternalWrite, - AccessControlAction.AlertingNotificationsRead, - AccessControlAction.AlertingNotificationsExternalRead, - // We check any contact point permission here because a user without edit permissions - // still has to be able to visit the "edit" page, because we don't have a separate view for edit vs view - // (we just disable the form instead) - ...PERMISSIONS_CONTACT_POINTS, - ]), - component: importAlertingComponent( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') - ), - }, - { - path: '/alerting/notifications/:type/:id/edit', + path: '/alerting/notifications/global-config', roles: evaluateAccess([ AccessControlAction.AlertingNotificationsWrite, AccessControlAction.AlertingNotificationsExternalWrite, ]), component: importAlertingComponent( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') - ), - }, - { - path: '/alerting/notifications/:type/:id/duplicate', - roles: evaluateAccess([ - AccessControlAction.AlertingNotificationsWrite, - AccessControlAction.AlertingNotificationsExternalWrite, - ]), - component: importAlertingComponent( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') - ), - }, - { - path: '/alerting/notifications/:type', - roles: evaluateAccess([ - AccessControlAction.AlertingNotificationsWrite, - AccessControlAction.AlertingNotificationsExternalWrite, - ]), - component: importAlertingComponent( - () => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers') + () => + import( + /* webpackChunkName: "GlobalConfig" */ 'app/features/alerting/unified/components/contact-points/components/GlobalConfig' + ) ), }, { diff --git a/public/app/features/alerting/unified/Receivers.test.tsx b/public/app/features/alerting/unified/Receivers.test.tsx index c7619e39f18..6a4d4227e6f 100644 --- a/public/app/features/alerting/unified/Receivers.test.tsx +++ b/public/app/features/alerting/unified/Receivers.test.tsx @@ -1,3 +1,4 @@ +import { Route, Routes } from 'react-router-dom-v5-compat'; import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; import { render, screen, waitFor, userEvent } from 'test/test-utils'; @@ -11,9 +12,10 @@ import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; import { AccessControlAction } from 'app/types'; -import ContactPoints from './Receivers'; - import 'core-js/stable/structured-clone'; +import ContactPoints from './components/contact-points/ContactPoints'; +import EditContactPoint from './components/contact-points/EditContactPoint'; +import NewReceiverView from './components/receivers/NewReceiverView'; const server = setupMswServer(); @@ -28,6 +30,21 @@ const saveContactPoint = async () => { return user.click(await screen.findByRole('button', { name: /save contact point/i })); }; +const setup = (location: string) => { + return render( + + } /> + } /> + } /> + , + { + historyOptions: { + initialEntries: [location], + }, + } + ); +}; + beforeEach(() => { grantUserPermissions([ AccessControlAction.AlertingNotificationsRead, @@ -41,18 +58,7 @@ beforeEach(() => { }); it('can save a contact point with a select dropdown', async () => { - const user = userEvent.setup(); - - render(, { - historyOptions: { - initialEntries: [ - { - pathname: `/alerting/notifications/receivers/new`, - search: `?alertmanager=${PROVISIONED_MIMIR_ALERTMANAGER_UID}`, - }, - ], - }, - }); + const { user } = setup(`/alerting/notifications/receivers/new?alertmanager=${PROVISIONED_MIMIR_ALERTMANAGER_UID}`); // Fill out contact point name const contactPointName = await screen.findByPlaceholderText(/name/i); @@ -75,16 +81,7 @@ it('can save a contact point with a select dropdown', async () => { }); it('can save existing Telegram contact point', async () => { - render(, { - historyOptions: { - initialEntries: [ - { - pathname: `/alerting/notifications/receivers/Telegram/edit`, - search: `?alertmanager=${PROVISIONED_MIMIR_ALERTMANAGER_UID}`, - }, - ], - }, - }); + setup(`/alerting/notifications/receivers/Telegram/edit?alertmanager=${PROVISIONED_MIMIR_ALERTMANAGER_UID}`); // Here, we're implicitly testing that our parsing of an existing Telegram integration works correctly // Our mock server will reject a request if we've sent the Chat ID as `0`, diff --git a/public/app/features/alerting/unified/Receivers.tsx b/public/app/features/alerting/unified/Receivers.tsx deleted file mode 100644 index 7b6e3b87052..00000000000 --- a/public/app/features/alerting/unified/Receivers.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Route, Switch } from 'react-router-dom'; - -import { withErrorBoundary } from '@grafana/ui'; -import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; - -import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; - -const ContactPointsV2 = SafeDynamicImport(() => import('./components/contact-points/ContactPoints')); -const EditContactPoint = SafeDynamicImport(() => import('./components/contact-points/EditContactPoint')); -const NewReceiverView = SafeDynamicImport(() => import('./components/receivers/NewReceiverView')); -const GlobalConfig = SafeDynamicImport(() => import('./components/contact-points/components/GlobalConfig')); - -const ContactPoints = (): JSX.Element => ( - - - - - - - - -); - -export default withErrorBoundary(ContactPoints, { style: 'page' }); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx index cf5417fc4ac..76d8b271183 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx @@ -19,7 +19,7 @@ import { setupDataSources } from '../../testSetup/datasources'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { ContactPoint } from './ContactPoint'; -import ContactPointsPageContents from './ContactPoints'; +import { ContactPointsPageContents } from './ContactPoints'; import setupMimirFlavoredServer, { MIMIR_DATASOURCE_UID } from './__mocks__/mimirFlavoredServer'; import setupVanillaAlertmanagerFlavoredServer, { VANILLA_ALERTMANAGER_DATASOURCE_UID, diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx index 17c4f58cd9b..7f89081d217 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx @@ -12,6 +12,7 @@ import { TabContent, TabsBar, Text, + withErrorBoundary, } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; import { t, Trans } from 'app/core/internationalization'; @@ -24,6 +25,7 @@ import { usePagination } from '../../hooks/usePagination'; import { useURLSearchParams } from '../../hooks/useURLSearchParams'; import { useAlertmanager } from '../../state/AlertmanagerContext'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; import { GrafanaAlertmanagerDeliveryWarning } from '../GrafanaAlertmanagerDeliveryWarning'; import { ContactPoint } from './ContactPoint'; @@ -179,7 +181,7 @@ const useTabQueryParam = () => { return [param, setParam] as const; }; -const ContactPointsPageContents = () => { +export const ContactPointsPageContents = () => { const { selectedAlertmanager } = useAlertmanager(); const [activeTab, setActiveTab] = useTabQueryParam(); @@ -242,4 +244,12 @@ const ContactPointsList = ({ contactPoints, search, pageSize = DEFAULT_PAGE_SIZE ); }; -export default ContactPointsPageContents; +function ContactPointsPage() { + return ( + + + + ); +} + +export default withErrorBoundary(ContactPointsPage, { style: 'page' }); diff --git a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx index f85c3eeeef0..96a00fc1d33 100644 --- a/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx +++ b/public/app/features/alerting/unified/components/contact-points/EditContactPoint.tsx @@ -1,18 +1,18 @@ -import { RouteChildrenProps } from 'react-router-dom'; +import { useParams } from 'react-router-dom-v5-compat'; -import { Alert, LoadingPlaceholder } from '@grafana/ui'; +import { Alert, LoadingPlaceholder, withErrorBoundary } from '@grafana/ui'; import { useGetContactPoint } from 'app/features/alerting/unified/components/contact-points/useContactPoints'; import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; import { useAlertmanager } from '../../state/AlertmanagerContext'; +import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; import { EditReceiverView } from '../receivers/EditReceiverView'; -type Props = RouteChildrenProps<{ name: string }>; - -const EditContactPoint = ({ match }: Props) => { +const EditContactPoint = () => { const { selectedAlertmanager } = useAlertmanager(); + const { name = '' } = useParams(); - const contactPointName = decodeURIComponent(match?.params.name!); + const contactPointName = decodeURIComponent(name); const { isLoading, error, @@ -42,4 +42,12 @@ const EditContactPoint = ({ match }: Props) => { return ; }; -export default EditContactPoint; +function EditContactPointPage() { + return ( + + + + ); +} + +export default withErrorBoundary(EditContactPointPage, { style: 'page' }); diff --git a/public/app/features/alerting/unified/components/contact-points/components/GlobalConfig.tsx b/public/app/features/alerting/unified/components/contact-points/components/GlobalConfig.tsx index da56a3f5148..e5514b01d66 100644 --- a/public/app/features/alerting/unified/components/contact-points/components/GlobalConfig.tsx +++ b/public/app/features/alerting/unified/components/contact-points/components/GlobalConfig.tsx @@ -1,7 +1,8 @@ -import { Alert } from '@grafana/ui'; +import { Alert, withErrorBoundary } from '@grafana/ui'; import { useAlertmanagerConfig } from '../../../hooks/useAlertmanagerConfig'; import { useAlertmanager } from '../../../state/AlertmanagerContext'; +import { AlertmanagerPageWrapper } from '../../AlertingPageWrapper'; import { GlobalConfigForm } from '../../receivers/GlobalConfigForm'; const NewMessageTemplate = () => { @@ -27,4 +28,12 @@ const NewMessageTemplate = () => { return ; }; -export default NewMessageTemplate; +function NewMessageTemplatePage() { + return ( + + + + ); +} + +export default withErrorBoundary(NewMessageTemplatePage, { style: 'page' }); diff --git a/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx b/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx index c84a9492d26..4f2a3322da4 100644 --- a/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx +++ b/public/app/features/alerting/unified/components/receivers/NewReceiverView.tsx @@ -1,6 +1,8 @@ +import { withErrorBoundary } from '@grafana/ui'; import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; +import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; import { CloudReceiverForm } from './form/CloudReceiverForm'; import { GrafanaReceiverForm } from './form/GrafanaReceiverForm'; @@ -14,4 +16,12 @@ const NewReceiverView = () => { } }; -export default NewReceiverView; +function NewReceiverViewPage() { + return ( + + + + ); +} + +export default withErrorBoundary(NewReceiverViewPage, { style: 'page' }); From 9491ab9a936fc21772ff84f75bce9d2fe291d0dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Wed, 9 Oct 2024 10:43:01 +0200 Subject: [PATCH 112/115] Bookmarks: Create e2e tests (#90373) --- e2e/utils/flows/userPreferences.ts | 5 +- e2e/various-suite/bookmarks.spec.ts | 62 +++++++++++++++++++ .../AppChrome/MegaMenu/MegaMenuItemText.tsx | 4 +- 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 e2e/various-suite/bookmarks.spec.ts diff --git a/e2e/utils/flows/userPreferences.ts b/e2e/utils/flows/userPreferences.ts index 621bff04ce6..c69254ac623 100644 --- a/e2e/utils/flows/userPreferences.ts +++ b/e2e/utils/flows/userPreferences.ts @@ -5,7 +5,10 @@ import { fromBaseUrl } from '../support/url'; const defaultUserPreferences = { timezone: '', // "Default" option -} as const; // TODO: when we update typescript >4.9 change to `as const satisfies UserPreferencesDTO` + navbar: { + bookmarkUrls: [], + }, +} as const satisfies UserPreferencesDTO; // TODO: when we update typescript >4.9 change to `as const satisfies UserPreferencesDTO` // Only accept preferences we have defaults for as arguments. To allow a new preference to be set, add a default for it type UserPreferences = Pick; diff --git a/e2e/various-suite/bookmarks.spec.ts b/e2e/various-suite/bookmarks.spec.ts new file mode 100644 index 00000000000..6b607c414c4 --- /dev/null +++ b/e2e/various-suite/bookmarks.spec.ts @@ -0,0 +1,62 @@ +import { e2e } from '../utils'; +import { fromBaseUrl } from '../utils/support/url'; + +describe('Pin nav items', () => { + beforeEach(() => { + cy.viewport(1280, 800); + e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); + cy.visit(fromBaseUrl('/')); + }); + afterEach(() => { + e2e.flows.setDefaultUserPreferences(); + }); + + it('should pin the selected menu item and add it as a Bookmarks menu item child', () => { + // Open, dock and check if the mega menu is visible + cy.get('[aria-label="Open menu"]').click(); + cy.get('[aria-label="Dock menu"]').click(); + e2e.components.NavMenu.Menu().should('be.visible'); + + // Check if the Bookmark section is visible + const bookmarkSection = cy.get('[href="/bookmarks"]'); + bookmarkSection.should('be.visible'); + + // Click on the pin icon to add Administration to the Bookmarks section + const adminItem = cy.contains('a', 'Administration'); + const bookmarkPinIcon = adminItem.siblings('button').should('have.attr', 'aria-label', 'Add to Bookmarks'); + bookmarkPinIcon.click({ force: true }); + + // Check if the Administration menu item is visible in the Bookmarks section + cy.get('[aria-label="Expand section Bookmarks"]').click(); + const bookmarks = cy.get('[href="/bookmarks"]').parentsUntil('li').siblings('ul'); + bookmarks.within(() => { + cy.get('a').should('contain.text', 'Administration'); + }); + }); + + it('should unpin the item and remove it from the Bookmarks section', () => { + // Set Administration as a pinned item and reload the page + e2e.flows.setUserPreferences({ navbar: { bookmarkUrls: ['/admin'] } }); + cy.reload(); + + // Open, dock and check if the mega menu is visible + cy.get('[aria-label="Open menu"]').click(); + cy.get('[aria-label="Dock menu"]').click(); + e2e.components.NavMenu.Menu().should('be.visible'); + + // Check if the Bookmark section is visible and open it + cy.get('[href="/bookmarks"]').should('be.visible'); + cy.get('[aria-label="Expand section Bookmarks"]').click(); + + // Check if the Administration menu item is visible in the Bookmarks section + const bookmarks = cy.get('[href="/bookmarks"]').parentsUntil('li').siblings('ul').children(); + const administrationIsPinned = bookmarks.filter('li').children().should('contain.text', 'Administration'); + + // Click on the pin icon to remove Administration from the Bookmarks section and check if it is removed + administrationIsPinned.within(() => { + cy.get('[aria-label="Remove from Bookmarks"]').click({ force: true }); + }); + cy.wait(500); + administrationIsPinned.should('not.exist'); + }); +}); diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx index 84d090d8584..bb34a6fe969 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx @@ -78,14 +78,14 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ }), wrapperBookmark: css({ '.pin-icon': { - display: 'none', + visibility: 'hidden', }, '&:hover, &:focus-within': { a: { width: 'calc(100% - 20px)', }, '.pin-icon': { - display: 'inline-flex', + visibility: 'visible', }, }, }), From c183a8930b113af7a2515a22c9d129eadf29648c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 9 Oct 2024 09:47:40 +0100 Subject: [PATCH 113/115] Update dependency @playwright/test to v1.48.0 (#94425) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 30 +++++++++++++++--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index d9d0579709b..e261fc3abcf 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@grafana/plugin-e2e": "^1.8.3", "@grafana/tsconfig": "^2.0.0", "@manypkg/get-packages": "^2.2.0", - "@playwright/test": "1.47.2", + "@playwright/test": "1.48.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.15", "@react-types/button": "3.9.6", "@react-types/menu": "3.9.12", diff --git a/yarn.lock b/yarn.lock index 1808a4900c8..915142467a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6182,14 +6182,14 @@ __metadata: languageName: node linkType: hard -"@playwright/test@npm:1.47.2": - version: 1.47.2 - resolution: "@playwright/test@npm:1.47.2" +"@playwright/test@npm:1.48.0": + version: 1.48.0 + resolution: "@playwright/test@npm:1.48.0" dependencies: - playwright: "npm:1.47.2" + playwright: "npm:1.48.0" bin: playwright: cli.js - checksum: 10/374bf386b4eb8f3b6664fa017402f87e57ee121970661a5b3c83f0fa146a7e6b7456e28cd5b1539c0981cb9a9166b1c7484549d87dc0d8076305ec64278ec770 + checksum: 10/8845ed0f0b303e10ee0a0f04562ef83be3f9123fac91d722f697ad964a119af74cd5fb08e1139f1b20b27396479456c984bfdc699fadedd92af9c0490fb4c7c0 languageName: node linkType: hard @@ -18942,7 +18942,7 @@ __metadata: "@opentelemetry/api": "npm:1.9.0" "@opentelemetry/exporter-collector": "npm:0.25.0" "@opentelemetry/semantic-conventions": "npm:1.27.0" - "@playwright/test": "npm:1.47.2" + "@playwright/test": "npm:1.48.0" "@pmmmwh/react-refresh-webpack-plugin": "npm:0.5.15" "@popperjs/core": "npm:2.11.8" "@react-aria/dialog": "npm:3.5.18" @@ -25866,27 +25866,27 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.47.2": - version: 1.47.2 - resolution: "playwright-core@npm:1.47.2" +"playwright-core@npm:1.48.0": + version: 1.48.0 + resolution: "playwright-core@npm:1.48.0" bin: playwright-core: cli.js - checksum: 10/2a2b28b2f1d01bc447f4f1cb4b5248ed053fde38429484c909efa17226e692a79cd5e6d4c337e9040eaaf311b6cb4a36027d6d14f1f44c482c5fb3feb081f913 + checksum: 10/644489b4de9cc181e83eb639a283d3c4f8e4c3b1b1759d7c93b72fd0373b5a66ba376ee6a5ee3eca67f1b773bf15c5e01b6aeedd43c94c355bf4fc0d110713bc languageName: node linkType: hard -"playwright@npm:1.47.2": - version: 1.47.2 - resolution: "playwright@npm:1.47.2" +"playwright@npm:1.48.0": + version: 1.48.0 + resolution: "playwright@npm:1.48.0" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.47.2" + playwright-core: "npm:1.48.0" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10/73494a187be3e75222b65ebcce8d790eada340bd61ca0d07410060a52232ddbc2357c4882d7b42434054dc1f4802fdb039a47530b4b5500dcfd1bf0edd63c191 + checksum: 10/85b06ae8d0ab7a5a8c9a0d416007b18f35a59455fad40438bda98cbe07c48f338e97b98b1d9214e27f08d6ac284eba0eaab722f5684cd17dd4a47f5b69d004b9 languageName: node linkType: hard From 392475182744b17de3fc7809b97f65610a55b550 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Wed, 9 Oct 2024 10:50:05 +0200 Subject: [PATCH 114/115] Alerting: Fix detail view not showing instances nor history tab (#94424) * Fix detail view not showing instances nor history tab * small refactor * Update public/app/features/alerting/unified/hooks/useCombinedRule.ts Co-authored-by: Konrad Lalik * refactor --------- Co-authored-by: Gilles De Mey Co-authored-by: Konrad Lalik --- .../alerting/unified/hooks/useCombinedRule.ts | 14 ++++++++++---- .../unified/hooks/useCombinedRuleNamespaces.ts | 2 +- .../features/alerting/unified/hooks/useFolder.ts | 4 ++++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/public/app/features/alerting/unified/hooks/useCombinedRule.ts b/public/app/features/alerting/unified/hooks/useCombinedRule.ts index 7f0239142fc..a01cce491b8 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRule.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRule.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo } from 'react'; import { useAsync } from 'react-use'; +import { isGrafanaRulesSource } from 'app/features/alerting/unified/utils/datasource'; import { CombinedRule, RuleIdentifier, RulesSource, RuleWithLocation } from 'app/types/unified-alerting'; import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto'; @@ -10,7 +11,8 @@ import { getDataSourceByName } from '../utils/datasource'; import * as ruleId from '../utils/rule-id'; import { isCloudRuleIdentifier, isGrafanaRuleIdentifier, isPrometheusRuleIdentifier } from '../utils/rules'; -import { attachRulerRulesToCombinedRules, combineRulesNamespaces } from './useCombinedRuleNamespaces'; +import { attachRulerRulesToCombinedRules, combineRulesNamespace } from './useCombinedRuleNamespaces'; +import { stringifyFolder, useFolder } from './useFolder'; export function useCloudCombinedRulesMatching( ruleName: string, @@ -116,6 +118,10 @@ export function useCombinedRule({ ruleIdentifier, limitAlerts }: Props): Request refetchOnMountOrArgChange: true, } ); + // in case of Grafana folder, we need to use the folder name instead of uid, as in promrules we don't use uid + const isGrafanaRule = isGrafanaRulesSource(ruleSourceName); + const folder = useFolder(isGrafanaRule ? ruleLocation?.namespace : undefined); + const namespaceName = isGrafanaRule && folder.folder ? stringifyFolder(folder.folder) : ruleLocation?.namespace; const [ fetchRulerRuleGroup, @@ -139,9 +145,9 @@ export function useCombinedRule({ ruleIdentifier, limitAlerts }: Props): Request return; } - const rulerConfig = rulerRuleGroup ? { [ruleLocation.namespace]: [rulerRuleGroup] } : {}; + const rulerConfig = rulerRuleGroup && namespaceName ? { [namespaceName]: [rulerRuleGroup] } : {}; - const combinedNamespaces = combineRulesNamespaces(ruleSource, promRuleNs, rulerConfig); + const combinedNamespaces = combineRulesNamespace(ruleSource, promRuleNs, rulerConfig); const combinedRules = combinedNamespaces.flatMap((ns) => ns.groups).flatMap((group) => group.rules); const matchingRule = combinedRules.find((rule) => @@ -149,7 +155,7 @@ export function useCombinedRule({ ruleIdentifier, limitAlerts }: Props): Request ); return matchingRule; - }, [ruleIdentifier, ruleSourceName, promRuleNs, rulerRuleGroup, ruleSource, ruleLocation]); + }, [ruleIdentifier, ruleSourceName, promRuleNs, rulerRuleGroup, ruleSource, ruleLocation, namespaceName]); return { loading: isLoadingDsFeatures || isLoadingPromRules || isLoadingRulerGroup, diff --git a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts index a8d9ac4be77..c95a2b2ebe6 100644 --- a/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts +++ b/public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts @@ -126,7 +126,7 @@ export function useCombinedRuleNamespaces( }, [promRulesResponses, rulerRulesResponses, rulesSources, grafanaPromRuleNamespaces]); } -export function combineRulesNamespaces( +export function combineRulesNamespace( rulesSource: RulesSource, promNamespaces: RuleNamespace[], rulerRules?: RulerRulesConfigDTO diff --git a/public/app/features/alerting/unified/hooks/useFolder.ts b/public/app/features/alerting/unified/hooks/useFolder.ts index c627453445b..e4fcd3882f8 100644 --- a/public/app/features/alerting/unified/hooks/useFolder.ts +++ b/public/app/features/alerting/unified/hooks/useFolder.ts @@ -32,3 +32,7 @@ export function useFolder(uid?: string): ReturnBag { loading: false, }; } + +export function stringifyFolder({ title, parents }: FolderDTO) { + return parents && parents?.length ? [...parents.map((p) => p.title), title].join('/') : title; +} From 38f57d270a95b8af289baa9c3f5d84724f52a3b8 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Wed, 9 Oct 2024 12:08:10 +0300 Subject: [PATCH 115/115] Routing: Update alerting template routes (#94366) * Templates: Remove Switch routes * Update tests * Fix test * Revert mock behaviour and render AppNotificationList --------- Co-authored-by: Tom Ratcliffe --- .../alerting/unified/Templates.test.tsx | 42 ++++++++++--------- .../features/alerting/unified/Templates.tsx | 26 ++++-------- .../DuplicateMessageTemplate.tsx | 9 ++-- .../contact-points/EditMessageTemplate.tsx | 2 +- 4 files changed, 36 insertions(+), 43 deletions(-) diff --git a/public/app/features/alerting/unified/Templates.test.tsx b/public/app/features/alerting/unified/Templates.test.tsx index 6d695af5826..9d048d60495 100644 --- a/public/app/features/alerting/unified/Templates.test.tsx +++ b/public/app/features/alerting/unified/Templates.test.tsx @@ -1,4 +1,6 @@ +import { InitialEntry } from 'history/createMemoryHistory'; import * as React from 'react'; +import { Route, Routes } from 'react-router-dom-v5-compat'; import { Props } from 'react-virtualized-auto-sizer'; import { render, screen, within } from 'test/test-utils'; import { byRole } from 'testing-library-selector'; @@ -54,23 +56,35 @@ beforeEach(() => { grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite]); }); +const setup = (initialEntries: InitialEntry[]) => { + return render( + <> + + + } /> + + , + { + historyOptions: { initialEntries }, + } + ); +}; + describe('Templates routes', () => { it('allows duplication of template with spaces in name', async () => { - render(, { - historyOptions: { initialEntries: [navUrl.duplicate('template%20with%20spaces')] }, - }); + setup([navUrl.duplicate('template%20with%20spaces')]); expect(await screen.findByText('Edit payload')).toBeInTheDocument(); }); it('allows editing of template with spaces in name', async () => { - render(, { historyOptions: { initialEntries: [navUrl.edit('template%20with%20spaces')] } }); + setup([navUrl.edit('template%20with%20spaces')]); expect(await screen.findByText('Edit payload')).toBeInTheDocument(); }); it('renders empty template form', async () => { - render(, { historyOptions: { initialEntries: [navUrl.new] } }); + setup([navUrl.new]); const form = await ui.templateForm.find(); @@ -83,9 +97,7 @@ describe('Templates K8s API', () => { testWithFeatureToggles(['alertingApiServer']); it('form edit renders with correct form values', async () => { - render(, { - historyOptions: { initialEntries: [navUrl.edit('k8s-custom-email-resource-name')] }, - }); + setup([navUrl.edit('k8s-custom-email-resource-name')]); const form = await ui.templateForm.find(); @@ -97,9 +109,7 @@ describe('Templates K8s API', () => { }); it('renders duplicate template form with correct values', async () => { - render(, { - historyOptions: { initialEntries: [navUrl.duplicate('k8s-custom-email-resource-name')] }, - }); + setup([navUrl.duplicate('k8s-custom-email-resource-name')]); const form = await ui.templateForm.find(); @@ -111,15 +121,7 @@ describe('Templates K8s API', () => { }); it('updates a template', async () => { - const { user } = render( - <> - - - , - { - historyOptions: { initialEntries: [navUrl.edit('k8s-custom-email-resource-name')] }, - } - ); + const { user } = setup([navUrl.edit('k8s-custom-email-resource-name')]); const form = await ui.templateForm.find(); diff --git a/public/app/features/alerting/unified/Templates.tsx b/public/app/features/alerting/unified/Templates.tsx index f7aef31d9fc..dda183041d6 100644 --- a/public/app/features/alerting/unified/Templates.tsx +++ b/public/app/features/alerting/unified/Templates.tsx @@ -1,15 +1,11 @@ -import { Route, Switch } from 'react-router-dom'; +import { Routes, Route } from 'react-router-dom-v5-compat'; import { withErrorBoundary } from '@grafana/ui'; -import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper'; - -const EditMessageTemplate = SafeDynamicImport(() => import('./components/contact-points/EditMessageTemplate')); -const NewMessageTemplate = SafeDynamicImport(() => import('./components/contact-points/NewMessageTemplate')); -const DuplicateMessageTemplate = SafeDynamicImport( - () => import('./components/contact-points/DuplicateMessageTemplate') -); +import DuplicateMessageTemplate from './components/contact-points/DuplicateMessageTemplate'; +import EditMessageTemplate from './components/contact-points/EditMessageTemplate'; +import NewMessageTemplate from './components/contact-points/NewMessageTemplate'; const NotificationTemplates = (): JSX.Element => ( ( accessType="notification" pageNav={{ id: 'templates', text: 'Notification templates', subTitle: 'Create and edit notification templates' }} > - - - - - + + } /> + } /> + } /> + ); diff --git a/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx b/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx index edd4edc2ecb..3ded1052b5d 100644 --- a/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx +++ b/public/app/features/alerting/unified/components/contact-points/DuplicateMessageTemplate.tsx @@ -1,4 +1,4 @@ -import { RouteChildrenProps } from 'react-router-dom'; +import { useParams } from 'react-router-dom-v5-compat'; import { Alert, LoadingPlaceholder } from '@grafana/ui'; import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; @@ -12,13 +12,12 @@ import { TemplateForm } from '../receivers/TemplateForm'; import { useGetNotificationTemplate, useNotificationTemplates } from './useNotificationTemplates'; -type Props = RouteChildrenProps<{ name: string }>; - const notFoundComponent = ; -const DuplicateMessageTemplate = ({ match }: Props) => { +const DuplicateMessageTemplate = () => { const { selectedAlertmanager } = useAlertmanager(); - const templateUid = match?.params.name ? decodeURIComponent(match?.params.name) : undefined; + const { name } = useParams<{ name: string }>(); + const templateUid = name ? decodeURIComponent(name) : undefined; const { currentData: template, diff --git a/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx b/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx index 2e2cfc6273b..6c7df2a7fad 100644 --- a/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx +++ b/public/app/features/alerting/unified/components/contact-points/EditMessageTemplate.tsx @@ -1,4 +1,4 @@ -import { useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom-v5-compat'; import { Alert, LoadingPlaceholder } from '@grafana/ui'; import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';