diff --git a/.betterer.results b/.betterer.results index d266b08b826..87b8a31de53 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4702,8 +4702,7 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "31"], [0, 0, 0, "Styles should be written using objects.", "32"], [0, 0, 0, "Styles should be written using objects.", "33"], - [0, 0, 0, "Styles should be written using objects.", "34"], - [0, 0, 0, "Styles should be written using objects.", "35"] + [0, 0, 0, "Styles should be written using objects.", "34"] ], "public/app/features/logs/components/log-context/LogContextButtons.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fc2ec9a632e..6a2f74a8892 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -314,37 +314,47 @@ /e2e/ @grafana/grafana-frontend-platform /e2e/cloud-plugins-suite/ @grafana/partner-datasources /e2e/plugin-e2e/plugin-e2e-api-tests/ @grafana/plugins-platform-frontend + +# Packages /packages/ @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend +/packages/grafana-data/src/**/*logs* @grafana/observability-logs +/packages/grafana-data/src/transformations/ @grafana/dataviz-squad /packages/grafana-e2e-selectors/ @grafana/grafana-frontend-platform +/packages/grafana-flamegraph/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/ @grafana/observability-logs +/packages/grafana-o11y-ds-frontend/src/IntervalInput/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/NodeGraph/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/pyroscope/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/SpanBar/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/TraceToLogs/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/TraceToMetrics/ @grafana/observability-traces-and-profiling +/packages/grafana-o11y-ds-frontend/src/TraceToProfiles/ @grafana/observability-traces-and-profiling +/packages/grafana-plugin-configs/ @grafana/plugins-platform-frontend +/packages/grafana-prometheus/ @grafana/observability-metrics +/packages/grafana-schema/src/**/*canvas* @grafana/dataviz-squad +/packages/grafana-schema/src/**/*tempo* @grafana/observability-traces-and-profiling +/packages/grafana-sql/ @grafana/partner-datasources @grafana/oss-big-tent /packages/grafana-ui/.storybook/ @grafana/plugins-platform-frontend /packages/grafana-ui/src/components/ @grafana/grafana-frontend-platform +/packages/grafana-ui/src/components/BarGauge/ @grafana/dataviz-squad +/packages/grafana-ui/src/components/DataLinks/ @grafana/dataviz-squad /packages/grafana-ui/src/components/DateTimePickers/ @grafana/grafana-frontend-platform +/packages/grafana-ui/src/components/Gauge/ @grafana/dataviz-squad +/packages/grafana-ui/src/components/Sparkline/ @grafana/grafana-frontend-platform @grafana/app-o11y-visualizations /packages/grafana-ui/src/components/Table/ @grafana/dataviz-squad /packages/grafana-ui/src/components/Table/SparklineCell.tsx @grafana/dataviz-squad @grafana/app-o11y-visualizations -/packages/grafana-ui/src/components/Gauge/ @grafana/dataviz-squad -/packages/grafana-ui/src/components/BarGauge/ @grafana/dataviz-squad /packages/grafana-ui/src/components/uPlot/ @grafana/dataviz-squad -/packages/grafana-ui/src/components/DataLinks/ @grafana/dataviz-squad /packages/grafana-ui/src/components/ValuePicker/ @grafana/dataviz-squad /packages/grafana-ui/src/components/VizLayout/ @grafana/dataviz-squad /packages/grafana-ui/src/components/VizLegend/ @grafana/dataviz-squad /packages/grafana-ui/src/components/VizRepeater/ @grafana/dataviz-squad /packages/grafana-ui/src/components/VizTooltip/ @grafana/dataviz-squad -/packages/grafana-ui/src/components/Sparkline/ @grafana/grafana-frontend-platform @grafana/app-o11y-visualizations /packages/grafana-ui/src/graveyard/Graph/ @grafana/dataviz-squad /packages/grafana-ui/src/graveyard/GraphNG/ @grafana/dataviz-squad /packages/grafana-ui/src/graveyard/TimeSeries/ @grafana/dataviz-squad /packages/grafana-ui/src/utils/storybook/ @grafana/plugins-platform-frontend -/packages/grafana-data/src/transformations/ @grafana/dataviz-squad -/packages/grafana-data/src/**/*logs* @grafana/observability-logs -/packages/grafana-schema/src/**/*tempo* @grafana/observability-traces-and-profiling -/packages/grafana-schema/src/**/*canvas* @grafana/dataviz-squad -/packages/grafana-flamegraph/ @grafana/observability-traces-and-profiling + /plugins-bundled/ @grafana/plugins-platform-frontend -/packages/grafana-plugin-configs/ @grafana/plugins-platform-frontend -/packages/grafana-prometheus/ @grafana/observability-metrics -/packages/grafana-o11y-ds-frontend/ @grafana/observability-logs @grafana/observability-traces-and-profiling -/packages/grafana-sql/ @grafana/partner-datasources @grafana/oss-big-tent # root files, mostly frontend /.browserslistrc @grafana/frontend-ops 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 99c345d9eb0..53578a6dfb7 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -62,7 +62,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | Yes | | `lokiQueryHints` | Enables query hints for Loki | Yes | | `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | | -| `betterPageScrolling` | Removes CustomScrollbar from the UI, relying on native browser scrollbars | Yes | | `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes | | `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | | @@ -191,6 +190,7 @@ Experimental features might be changed or removed without prior notice. | `alertingCentralAlertHistory` | Enables the new central alert history. | | `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | | `pinNavItems` | Enables pinning of nav items | +| `databaseReadReplica` | Use a read replica for some database queries. | ## Development feature toggles diff --git a/docs/sources/setup-grafana/image-rendering/_index.md b/docs/sources/setup-grafana/image-rendering/_index.md index d9438fa0142..ed04d5cc64e 100644 --- a/docs/sources/setup-grafana/image-rendering/_index.md +++ b/docs/sources/setup-grafana/image-rendering/_index.md @@ -207,6 +207,47 @@ HTTP_PORT=0 } ``` +#### HTTP protocol + +{{% admonition type="note" %}} +HTTPS protocol is supported in the image renderer v3.11.0 and later. +{{% /admonition %}} + +Change the protocol of the server, it can be `http` or `https`. Default is `http`. + +```json +{ + "service": { + "protocol": "http" + } +} +``` + +#### HTTPS certificate and key file + +Path to the image renderer certificate and key file used to start an HTTPS server. + +```json +{ + "service": { + "certFile": "./path/to/cert", + "certKey": "./path/to/key" + } +} +``` + +#### HTTPS min TLS version + +Minimum TLS version allowed. Accepted values are: `TLSv1.2`, `TLSv1.3`. Default is `TLSv1.2`. + +```json +{ + "service": { + "minTLSVersion": "TLSv1.2" + } +} +``` + #### Enable Prometheus metrics You can enable [Prometheus](https://prometheus.io/) metrics endpoint `/metrics` using the environment variable `ENABLE_METRICS`. Node.js and render request duration metrics are included, see [Enable Prometheus metrics endpoint]({{< relref "./monitoring#enable-prometheus-metrics-endpoint" >}}) for details. diff --git a/go.work.sum b/go.work.sum index b3c43f29e21..ee55d688198 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1290,6 +1290,7 @@ github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4 h1:BN/Nyn2nWMo github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.15.0 h1:uPRuwkWF4J6fGsJ2R0Gn2jB1EQiav9k3S6CSdygQJXY= +github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245 h1:K1Xf3bKttbF+koVGaX5xngRIZ5bVjbmPnaxE/dR08uY= diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index ca52ce578c1..7f5da38828d 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -169,7 +169,6 @@ export interface FeatureToggles { kubernetesAggregator?: boolean; expressionParser?: boolean; groupByVariable?: boolean; - betterPageScrolling?: boolean; authAPIAccessTokenAuth?: boolean; scopeFilters?: boolean; ssoSettingsSAML?: boolean; @@ -196,4 +195,5 @@ export interface FeatureToggles { authZGRPCServer?: boolean; openSearchBackendFlowEnabled?: boolean; ssoSettingsLDAP?: boolean; + databaseReadReplica?: boolean; } diff --git a/pkg/api/api.go b/pkg/api/api.go index 76ff1a24c70..c6c1ee022cd 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -165,6 +165,10 @@ func (hs *HTTPServer) registerRoutes() { ) } + if hs.Features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) { + r.Get("/dashboard/recently-deleted", reqSignedIn, hs.Index) + } + r.Get("/explore", authorize(ac.EvalPermission(ac.ActionDatasourcesExplore)), hs.Index) r.Get("/playlists/", reqSignedIn, hs.Index) diff --git a/pkg/infra/db/db.go b/pkg/infra/db/db.go index 574354fa605..48581f7d65d 100644 --- a/pkg/infra/db/db.go +++ b/pkg/infra/db/db.go @@ -62,6 +62,15 @@ func InitTestDB(t sqlutil.ITestDB, opts ...InitTestDBOpt) *sqlstore.SQLStore { return db } +func InitTestReplDBWithCfg(t sqlutil.ITestDB, opts ...InitTestDBOpt) (*sqlstore.ReplStore, *setting.Cfg) { + return sqlstore.InitTestReplDB(t, opts...) +} + +func InitTestReplDB(t sqlutil.ITestDB, opts ...InitTestDBOpt) *sqlstore.ReplStore { + db, _ := InitTestReplDBWithCfg(t, opts...) + return db +} + func InitTestDBWithCfg(t sqlutil.ITestDB, opts ...InitTestDBOpt) (*sqlstore.SQLStore, *setting.Cfg) { return sqlstore.InitTestDB(t, opts...) } diff --git a/pkg/infra/db/dbrepl.go b/pkg/infra/db/dbrepl.go new file mode 100644 index 00000000000..4b3bac0bb1d --- /dev/null +++ b/pkg/infra/db/dbrepl.go @@ -0,0 +1,9 @@ +package db + +import "github.com/grafana/grafana/pkg/services/sqlstore" + +type ReplDB interface { + // DB is the primary database connection. + DB() *sqlstore.SQLStore + ReadReplica() *sqlstore.SQLStore +} diff --git a/pkg/infra/usagestats/statscollector/concurrent_users_test.go b/pkg/infra/usagestats/statscollector/concurrent_users_test.go index decb1642657..919a74fa22e 100644 --- a/pkg/infra/usagestats/statscollector/concurrent_users_test.go +++ b/pkg/infra/usagestats/statscollector/concurrent_users_test.go @@ -24,7 +24,7 @@ func TestMain(m *testing.M) { } func TestConcurrentUsersMetrics(t *testing.T) { - sqlStore, cfg := db.InitTestDBWithCfg(t) + sqlStore, cfg := db.InitTestReplDBWithCfg(t) statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore) s := createService(t, cfg, sqlStore, statsService) @@ -42,7 +42,7 @@ func TestConcurrentUsersMetrics(t *testing.T) { } func TestConcurrentUsersStats(t *testing.T) { - sqlStore, cfg := db.InitTestDBWithCfg(t) + sqlStore, cfg := db.InitTestReplDBWithCfg(t) statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore) s := createService(t, cfg, sqlStore, statsService) diff --git a/pkg/server/test_env.go b/pkg/server/test_env.go index 7e4c39fa8c4..a2098c0a8b6 100644 --- a/pkg/server/test_env.go +++ b/pkg/server/test_env.go @@ -15,6 +15,7 @@ import ( func ProvideTestEnv( server *Server, db db.DB, + repldb db.ReplDB, cfg *setting.Cfg, ns *notifications.NotificationServiceMock, grpcServer grpcserver.Provider, @@ -26,6 +27,7 @@ func ProvideTestEnv( return &TestEnv{ Server: server, SQLStore: db, + ReadReplStore: repldb, Cfg: cfg, NotificationService: ns, GRPCServer: grpcServer, @@ -39,6 +41,7 @@ func ProvideTestEnv( type TestEnv struct { Server *Server SQLStore db.DB + ReadReplStore db.ReplDB Cfg *setting.Cfg NotificationService *notifications.NotificationServiceMock GRPCServer grpcserver.Provider diff --git a/pkg/server/wire.go b/pkg/server/wire.go index ccf410105ab..a8bf87483eb 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -390,6 +390,7 @@ var wireSet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, + sqlstore.ProvideServiceWithReadReplica, ngmetrics.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), @@ -405,6 +406,7 @@ var wireCLISet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, + sqlstore.ProvideServiceWithReadReplica, ngmetrics.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), @@ -420,12 +422,14 @@ var wireTestSet = wire.NewSet( ProvideTestEnv, metrics.WireSetForTest, sqlstore.ProvideServiceForTests, + sqlstore.ProvideServiceWithReadReplicaForTests, ngmetrics.ProvideServiceForTest, notifications.MockNotificationService, wire.Bind(new(notifications.Service), new(*notifications.NotificationServiceMock)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationServiceMock)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationServiceMock)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), + wire.Bind(new(db.ReplDB), new(*sqlstore.ReplStore)), prefimpl.ProvideService, oauthtoken.ProvideService, oauthtokentest.ProvideService, @@ -439,7 +443,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser func InitializeForTest(t sqlutil.ITestDB, cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*TestEnv, error) { wire.Build(wireExtsTestSet) - return &TestEnv{Server: &Server{}, SQLStore: &sqlstore.SQLStore{}, Cfg: &setting.Cfg{}}, nil + return &TestEnv{Server: &Server{}, SQLStore: &sqlstore.SQLStore{}, ReadReplStore: &sqlstore.ReplStore{}, Cfg: &setting.Cfg{}}, nil } func InitializeForCLI(cfg *setting.Cfg) (Runner, error) { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index de3a47a560c..51bf6ea8828 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1126,14 +1126,6 @@ var ( HideFromDocs: true, HideFromAdminPage: true, }, - { - Name: "betterPageScrolling", - Description: "Removes CustomScrollbar from the UI, relying on native browser scrollbars", - Stage: FeatureStageGeneralAvailability, - FrontendOnly: true, - Owner: grafanaFrontendPlatformSquad, - Expression: "true", // enabled by default - }, { Name: "authAPIAccessTokenAuth", Description: "Enables the use of Auth API access tokens for authentication", @@ -1331,6 +1323,13 @@ var ( HideFromDocs: true, HideFromAdminPage: true, }, + { + Name: "databaseReadReplica", + Description: "Use a read replica for some database queries.", + Stage: FeatureStageExperimental, + Owner: grafanaBackendServicesSquad, + Expression: "false", // enabled by default + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index c920feb800e..c0b5e76d4c1 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -150,7 +150,6 @@ tlsMemcached,experimental,@grafana/grafana-operator-experience-squad,false,false kubernetesAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false expressionParser,experimental,@grafana/grafana-app-platform-squad,false,true,false groupByVariable,experimental,@grafana/dashboards-squad,false,false,false -betterPageScrolling,GA,@grafana/grafana-frontend-platform,false,false,true authAPIAccessTokenAuth,experimental,@grafana/identity-access-team,false,false,false scopeFilters,experimental,@grafana/dashboards-squad,false,false,false ssoSettingsSAML,preview,@grafana/identity-access-team,false,false,false @@ -177,3 +176,4 @@ pinNavItems,experimental,@grafana/grafana-frontend-platform,false,false,false authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false openSearchBackendFlowEnabled,preview,@grafana/aws-datasources,false,false,false ssoSettingsLDAP,experimental,@grafana/identity-access-team,false,false,false +databaseReadReplica,experimental,@grafana/grafana-backend-services-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 0cd93f4753f..d7f10257ddf 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -611,10 +611,6 @@ const ( // Enable groupBy variable support in scenes dashboards FlagGroupByVariable = "groupByVariable" - // FlagBetterPageScrolling - // Removes CustomScrollbar from the UI, relying on native browser scrollbars - FlagBetterPageScrolling = "betterPageScrolling" - // FlagAuthAPIAccessTokenAuth // Enables the use of Auth API access tokens for authentication FlagAuthAPIAccessTokenAuth = "authAPIAccessTokenAuth" @@ -718,4 +714,8 @@ const ( // FlagSsoSettingsLDAP // Use the new SSO Settings API to configure LDAP FlagSsoSettingsLDAP = "ssoSettingsLDAP" + + // FlagDatabaseReadReplica + // Use a read replica for some database queries. + FlagDatabaseReadReplica = "databaseReadReplica" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 731316282d3..02df39301fd 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -463,7 +463,8 @@ "metadata": { "name": "betterPageScrolling", "resourceVersion": "1717578796182", - "creationTimestamp": "2024-03-06T15:06:47Z" + "creationTimestamp": "2024-03-06T15:06:47Z", + "deletionTimestamp": "2024-06-18T09:25:56Z" }, "spec": { "description": "Removes CustomScrollbar from the UI, relying on native browser scrollbars", @@ -654,6 +655,18 @@ "frontend": true } }, + { + "metadata": { + "name": "databaseReadReplica", + "resourceVersion": "1718308641844", + "creationTimestamp": "2024-06-13T19:57:21Z" + }, + "spec": { + "description": "Use a read replica for some database queries.", + "stage": "experimental", + "codeowner": "@grafana/grafana-backend-services-squad" + } + }, { "metadata": { "name": "dataplaneFrontendFallback", diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 7ed6f2164c0..a9247c16877 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -358,8 +358,8 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ Text: "Recently Deleted", SubTitle: "Any items listed here for more than 30 days will be automatically deleted.", - Id: "dashboards/recentlyDeleted", - Url: s.cfg.AppSubURL + "/dashboard/recentlyDeleted", + Id: "dashboards/recently-deleted", + Url: s.cfg.AppSubURL + "/dashboard/recently-deleted", }) } } diff --git a/pkg/services/sqlstore/database_config.go b/pkg/services/sqlstore/database_config.go index 171ff4972b9..8ffc69fcf66 100644 --- a/pkg/services/sqlstore/database_config.go +++ b/pkg/services/sqlstore/database_config.go @@ -65,9 +65,11 @@ func NewDatabaseConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles) (* return dbCfg, nil } -func (dbCfg *DatabaseConfig) readConfig(cfg *setting.Cfg) error { - sec := cfg.Raw.Section("database") - +// readConfigSection reads the database configuration from the given block of +// the configuration file. This method allows us to add a "database_replica" +// section to the configuration file while using the same cfg struct. +func (dbCfg *DatabaseConfig) readConfigSection(cfg *setting.Cfg, section string) error { + sec := cfg.Raw.Section(section) cfgURL := sec.Key("url").String() if len(cfgURL) != 0 { dbURL, err := url.Parse(cfgURL) @@ -101,7 +103,6 @@ func (dbCfg *DatabaseConfig) readConfig(cfg *setting.Cfg) error { dbCfg.MaxOpenConn = sec.Key("max_open_conn").MustInt(0) dbCfg.MaxIdleConn = sec.Key("max_idle_conn").MustInt(2) dbCfg.ConnMaxLifetime = sec.Key("conn_max_lifetime").MustInt(14400) - dbCfg.SslMode = sec.Key("ssl_mode").String() dbCfg.SSLSNI = sec.Key("ssl_sni").String() dbCfg.CaCertPath = sec.Key("ca_cert_path").String() @@ -110,21 +111,22 @@ func (dbCfg *DatabaseConfig) readConfig(cfg *setting.Cfg) error { dbCfg.ServerCertName = sec.Key("server_cert_name").String() dbCfg.Path = sec.Key("path").MustString("data/grafana.db") dbCfg.IsolationLevel = sec.Key("isolation_level").String() - dbCfg.CacheMode = sec.Key("cache_mode").MustString("private") dbCfg.WALEnabled = sec.Key("wal").MustBool(false) dbCfg.SkipMigrations = sec.Key("skip_migrations").MustBool() dbCfg.MigrationLock = sec.Key("migration_locking").MustBool(true) dbCfg.MigrationLockAttemptTimeout = sec.Key("locking_attempt_timeout_sec").MustInt() - dbCfg.QueryRetries = sec.Key("query_retries").MustInt() dbCfg.TransactionRetries = sec.Key("transaction_retries").MustInt(5) - dbCfg.LogQueries = sec.Key("log_queries").MustBool(false) - return nil } +// readConfig is a wrapper around readConfigSection that read the "database" configuration block. +func (dbCfg *DatabaseConfig) readConfig(cfg *setting.Cfg) error { + return dbCfg.readConfigSection(cfg, "database") +} + func (dbCfg *DatabaseConfig) buildConnectionString(cfg *setting.Cfg, features featuremgmt.FeatureToggles) error { if dbCfg.ConnectionString != "" { return nil diff --git a/pkg/services/sqlstore/replstore.go b/pkg/services/sqlstore/replstore.go new file mode 100644 index 00000000000..c3540d79c4f --- /dev/null +++ b/pkg/services/sqlstore/replstore.go @@ -0,0 +1,194 @@ +package sqlstore + +import ( + "errors" + "time" + + "github.com/dlmiddlecote/sqlstats" + "github.com/prometheus/client_golang/prometheus" + "xorm.io/xorm" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/sqlstore/migrations" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" + "github.com/grafana/grafana/pkg/setting" +) + +// ReplStore is a wrapper around a main SQLStore and a read-only SQLStore. The +// main SQLStore is anonymous, so the ReplStore may be used directly as a +// SQLStore. +type ReplStore struct { + *SQLStore + repl *SQLStore +} + +// DB returns the main SQLStore. +func (rs ReplStore) DB() *SQLStore { + return rs.SQLStore +} + +// ReadReplica returns the read-only SQLStore. If no read replica is configured, +// it returns the main SQLStore. +func (rs ReplStore) ReadReplica() *SQLStore { + if rs.repl == nil { + rs.log.Debug("ReadReplica not configured, using main SQLStore") + return rs.SQLStore + } + rs.log.Debug("Using ReadReplica") + return rs.repl +} + +// ProvideServiceWithReadReplica creates a new *SQLStore connection intended for +// use as a ReadReplica of the main SQLStore. The primary SQLStore must already +// be initialized. +func ProvideServiceWithReadReplica(primary *SQLStore, cfg *setting.Cfg, + features featuremgmt.FeatureToggles, migrations registry.DatabaseMigrator, + bus bus.Bus, tracer tracing.Tracer) (*ReplStore, error) { + // start with the initialized SQLStore + replStore := &ReplStore{primary, nil} + + // FeatureToggle fallback: If the FlagDatabaseReadReplica feature flag is not enabled, return a single SQLStore. + if !features.IsEnabledGlobally(featuremgmt.FlagDatabaseReadReplica) { + primary.log.Debug("ReadReplica feature flag not enabled, using main SQLStore") + return replStore, nil + } + + // This change will make xorm use an empty default schema for postgres and + // by that mimic the functionality of how it was functioning before + // xorm's changes above. + xorm.DefaultPostgresSchema = "" + s, err := newReadOnlySQLStore(cfg, features, bus, tracer) + if err != nil { + return nil, err + } + s.features = features + s.tracer = tracer + + // initialize and register metrics wrapper around the *sql.DB + db := s.engine.DB().DB + + // register the go_sql_stats_connections_* metrics + if err := prometheus.Register(sqlstats.NewStatsCollector("grafana_repl", db)); err != nil { + s.log.Warn("Failed to register sqlstore stats collector", "error", err) + } + + replStore.repl = s + return replStore, nil +} + +// newReadOnlySQLStore creates a new *SQLStore intended for use with a +// fully-populated read replica of the main Grafana Database. It provides no +// write capabilities and does not run migrations, but other tracing and logging +// features are enabled. +func newReadOnlySQLStore(cfg *setting.Cfg, features featuremgmt.FeatureToggles, bus bus.Bus, tracer tracing.Tracer) (*SQLStore, error) { + s := &SQLStore{ + cfg: cfg, + log: log.New("replstore"), + bus: bus, + tracer: tracer, + } + + s.features = features + s.tracer = tracer + + err := s.initReadOnlyEngine(s.engine) + if err != nil { + return nil, err + } + s.dialect = migrator.NewDialect(s.engine.DriverName()) + return s, nil +} + +// initReadOnlyEngine initializes ss.engine for read-only operations. The database must be a fully-populated read replica. +func (ss *SQLStore) initReadOnlyEngine(engine *xorm.Engine) error { + if ss.engine != nil { + ss.log.Debug("Already connected to database replica") + return nil + } + + dbCfg, err := NewRODatabaseConfig(ss.cfg, ss.features) + if err != nil { + return err + } + ss.dbCfg = dbCfg + + if ss.cfg.DatabaseInstrumentQueries { + ss.dbCfg.Type = WrapDatabaseDriverWithHooks(ss.dbCfg.Type, ss.tracer) + } + + if engine == nil { + var err error + engine, err = xorm.NewEngine(ss.dbCfg.Type, ss.dbCfg.ConnectionString) + if err != nil { + ss.log.Error("failed to connect to database replica", "error", err) + return err + } + // Only for MySQL or MariaDB, verify we can connect with the current connection string's system var for transaction isolation. + // If not, create a new engine with a compatible connection string. + if ss.dbCfg.Type == migrator.MySQL { + engine, err = ss.ensureTransactionIsolationCompatibility(engine, ss.dbCfg.ConnectionString) + if err != nil { + return err + } + } + } + + engine.SetMaxOpenConns(ss.dbCfg.MaxOpenConn) + engine.SetMaxIdleConns(ss.dbCfg.MaxIdleConn) + engine.SetConnMaxLifetime(time.Second * time.Duration(ss.dbCfg.ConnMaxLifetime)) + + // configure sql logging + debugSQL := ss.cfg.Raw.Section("database_replica").Key("log_queries").MustBool(false) + if !debugSQL { + engine.SetLogger(&xorm.DiscardLogger{}) + } else { + // add stack to database calls to be able to see what repository initiated queries. Top 7 items from the stack as they are likely in the xorm library. + engine.SetLogger(NewXormLogger(log.LvlInfo, log.WithSuffix(log.New("replsstore.xorm"), log.CallerContextKey, log.StackCaller(log.DefaultCallerDepth)))) + engine.ShowSQL(true) + engine.ShowExecTime(true) + } + + ss.engine = engine + return nil +} + +// NewRODatabaseConfig creates a new read-only database configuration. +func NewRODatabaseConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles) (*DatabaseConfig, error) { + if cfg == nil { + return nil, errors.New("cfg cannot be nil") + } + + dbCfg := &DatabaseConfig{} + if err := dbCfg.readConfigSection(cfg, "database_replica"); err != nil { + return nil, err + } + + if err := dbCfg.buildConnectionString(cfg, features); err != nil { + return nil, err + } + + return dbCfg, nil +} + +// ProvideServiceWithReadReplicaForTests wraps the SQLStore in a ReplStore, with the main sqlstore as both the primary and read replica. +// TODO: eventually this should be replaced with a more robust test setup which in +func ProvideServiceWithReadReplicaForTests(testDB *SQLStore, t sqlutil.ITestDB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, migrations registry.DatabaseMigrator) (*ReplStore, error) { + return &ReplStore{testDB, testDB}, nil +} + +// InitTestReplDB initializes a test DB and returns it wrapped in a ReplStore with the main SQLStore as both the primary and read replica. +func InitTestReplDB(t sqlutil.ITestDB, opts ...InitTestDBOpt) (*ReplStore, *setting.Cfg) { + t.Helper() + features := getFeaturesForTesting(opts...) + cfg := getCfgForTesting(opts...) + ss, err := initTestDB(t, cfg, features, migrations.ProvideOSSMigrations(features), opts...) + if err != nil { + t.Fatalf("failed to initialize sql repl store: %s", err) + } + return &ReplStore{ss, ss}, cfg +} diff --git a/pkg/services/stats/statsimpl/stats.go b/pkg/services/stats/statsimpl/stats.go index e2139c80b57..7f580471212 100644 --- a/pkg/services/stats/statsimpl/stats.go +++ b/pkg/services/stats/statsimpl/stats.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/stats" "github.com/grafana/grafana/pkg/setting" @@ -17,12 +18,12 @@ import ( const activeUserTimeLimit = time.Hour * 24 * 30 const dailyActiveUserTimeLimit = time.Hour * 24 -func ProvideService(cfg *setting.Cfg, db db.DB) stats.Service { +func ProvideService(cfg *setting.Cfg, db *sqlstore.ReplStore) stats.Service { return &sqlStatsService{cfg: cfg, db: db} } type sqlStatsService struct { - db db.DB + db *sqlstore.ReplStore cfg *setting.Cfg } @@ -62,8 +63,8 @@ func notServiceAccount(dialect migrator.Dialect) string { } func (ss *sqlStatsService) GetSystemStats(ctx context.Context, query *stats.GetSystemStatsQuery) (result *stats.SystemStats, err error) { - dialect := ss.db.GetDialect() - err = ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { + dialect := ss.db.ReadReplica().GetDialect() + err = ss.db.ReadReplica().WithDbSession(ctx, func(dbSession *db.Session) error { sb := &db.SQLBuilder{} sb.Write("SELECT ") sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + ` WHERE ` + notServiceAccount(dialect) + `) AS users,`) @@ -148,8 +149,8 @@ func (ss *sqlStatsService) roleCounterSQL(ctx context.Context) string { } func (ss *sqlStatsService) GetAdminStats(ctx context.Context, query *stats.GetAdminStatsQuery) (result *stats.AdminStats, err error) { - err = ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { - dialect := ss.db.GetDialect() + err = ss.db.ReadReplica().WithDbSession(ctx, func(dbSession *db.Session) error { + dialect := ss.db.ReadReplica().GetDialect() now := time.Now() activeEndDate := now.Add(-activeUserTimeLimit) dailyActiveEndDate := now.Add(-dailyActiveUserTimeLimit) diff --git a/pkg/services/stats/statsimpl/stats_test.go b/pkg/services/stats/statsimpl/stats_test.go index e63d51fddc0..15ec1c50334 100644 --- a/pkg/services/stats/statsimpl/stats_test.go +++ b/pkg/services/stats/statsimpl/stats_test.go @@ -32,9 +32,9 @@ func TestIntegrationStatsDataAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - db, cfg := db.InitTestDBWithCfg(t) - statsService := &sqlStatsService{db: db} - populateDB(t, db, cfg) + store, cfg := db.InitTestReplDBWithCfg(t) + statsService := &sqlStatsService{db: store} + populateDB(t, store, cfg) t.Run("Get system stats should not results in error", func(t *testing.T) { query := stats.GetSystemStatsQuery{} @@ -49,7 +49,7 @@ func TestIntegrationStatsDataAccess(t *testing.T) { assert.Equal(t, int64(0), result.APIKeys) assert.Equal(t, int64(2), result.Correlations) assert.NotNil(t, result.DatabaseCreatedTime) - assert.Equal(t, db.GetDialect().DriverName(), result.DatabaseDriver) + assert.Equal(t, store.GetDialect().DriverName(), result.DatabaseDriver) }) t.Run("Get system user count stats should not results in error", func(t *testing.T) { @@ -157,8 +157,8 @@ func TestIntegration_GetAdminStats(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - db, cfg := db.InitTestDBWithCfg(t) - statsService := ProvideService(cfg, db) + store, cfg := db.InitTestReplDBWithCfg(t) + statsService := ProvideService(cfg, store) query := stats.GetAdminStatsQuery{} _, err := statsService.GetAdminStats(context.Background(), &query) diff --git a/public/app/app.ts b/public/app/app.ts index 770653fe974..f48ad85af7a 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -42,7 +42,6 @@ import { import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView'; import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer'; import { setPluginPage } from '@grafana/runtime/src/components/PluginPage'; -import { getScrollbarWidth } from '@grafana/ui'; import config, { updateConfig } from 'app/core/config'; import { arrayMove } from 'app/core/utils/arrayMove'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; @@ -136,10 +135,6 @@ export class GrafanaApp { // This needs to be done after the `initEchoSrv` since it is being used under the hood. startMeasure('frontend_app_init'); - if (!config.featureToggles.betterPageScrolling) { - addClassIfNoOverlayScrollbar(); - } - setLocale(config.bootData.user.locale); setWeekStart(config.bootData.user.weekStart); setPanelRenderer(PanelRenderer); @@ -367,12 +362,6 @@ function initEchoSrv() { } } -function addClassIfNoOverlayScrollbar() { - if (getScrollbarWidth() > 0) { - document.body.classList.add('no-overlay-scrollbar'); - } -} - /** * Report when a metric of a given name was marked during the document lifecycle. Works for markers with no duration, * like PerformanceMark or PerformancePaintTiming (e.g. created with performance.mark, or first-contentful-paint) diff --git a/public/app/core/components/FlaggedScroller.tsx b/public/app/core/components/NativeScrollbar.tsx similarity index 72% rename from public/app/core/components/FlaggedScroller.tsx rename to public/app/core/components/NativeScrollbar.tsx index 9d9c3cc5043..76d447f4311 100644 --- a/public/app/core/components/FlaggedScroller.tsx +++ b/public/app/core/components/NativeScrollbar.tsx @@ -1,21 +1,12 @@ import { css, cx } from '@emotion/css'; import React, { useEffect, useRef } from 'react'; -import { config } from '@grafana/runtime'; import { CustomScrollbar, useStyles2 } from '@grafana/ui'; -type FlaggedScrollerProps = Parameters[0]; - -export default function FlaggedScrollbar(props: FlaggedScrollerProps) { - if (config.featureToggles.betterPageScrolling) { - return {props.children}; - } - - return ; -} +type Props = Parameters[0]; // Shim to provide API-compatibility for Page's scroll-related props -function NativeScrollbar({ children, scrollRefCallback, scrollTop, divId }: FlaggedScrollerProps) { +export default function NativeScrollbar({ children, scrollRefCallback, scrollTop, divId }: Props) { const styles = useStyles2(getStyles); const ref = useRef(null); diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index f88d0dd011b..c588d17be1e 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -5,7 +5,7 @@ import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; -import FlaggedScrollbar from '../FlaggedScroller'; +import NativeScrollbar from '../NativeScrollbar'; import { PageContents } from './PageContents'; import { PageHeader } from './PageHeader'; @@ -53,7 +53,7 @@ export const Page: PageType = ({ return (
{layout === PageLayoutType.Standard && ( - }
{children}
- + )} {layout === PageLayoutType.Canvas && ( -
{children}
-
+ )} {layout === PageLayoutType.Custom && children} diff --git a/public/app/core/internationalization/constants.ts b/public/app/core/internationalization/constants.ts index a241519fed4..f4ab99631cc 100644 --- a/public/app/core/internationalization/constants.ts +++ b/public/app/core/internationalization/constants.ts @@ -5,7 +5,7 @@ export const ENGLISH_US = 'en-US'; export const FRENCH_FRANCE = 'fr-FR'; export const SPANISH_SPAIN = 'es-ES'; export const GERMAN_GERMANY = 'de-DE'; -export const BRAZILIAN_PORTUGUESE = 'pt-br'; +export const BRAZILIAN_PORTUGUESE = 'pt-BR'; export const CHINESE_SIMPLIFIED = 'zh-Hans'; export const PSEUDO_LOCALE = 'pseudo-LOCALE'; diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index 9297cb4816c..6dcaa0ebf74 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -40,8 +40,8 @@ export function getNavTitle(navId: string | undefined) { return t('nav.reporting.title', 'Reporting'); case 'dashboards/public': return t('nav.public.title', 'Public dashboards'); - case 'dashboards/recentlyDeleted': - return t('nav.recentlyDeleted.title', 'Recently Deleted'); + case 'dashboards/recently-deleted': + return t('nav.recently-deleted.title', 'Recently Deleted'); case 'dashboards/new': return t('nav.new-dashboard.title', 'New dashboard'); case 'dashboards/folder/new': @@ -208,9 +208,9 @@ export function getNavSubTitle(navId: string | undefined) { ); case 'dashboards/library-panels': return t('nav.library-panels.subtitle', 'Reusable panels that can be added to multiple dashboards'); - case 'dashboards/recentlyDeleted': + case 'dashboards/recently-deleted': return t( - 'nav.recentlyDeleted.subtitle', + 'nav.recently-deleted.subtitle', 'Any items listed here for more than 30 days will be automatically deleted.' ); case 'alerting': diff --git a/public/app/features/alerting/unified/Receivers.test.tsx b/public/app/features/alerting/unified/Receivers.test.tsx index ac966805830..982b4488eec 100644 --- a/public/app/features/alerting/unified/Receivers.test.tsx +++ b/public/app/features/alerting/unified/Receivers.test.tsx @@ -3,14 +3,13 @@ import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; import { render, screen, waitFor, userEvent } from 'test/test-utils'; import { - EXTERNAL_VANILLA_ALERTMANAGER_UID, + PROVISIONED_MIMIR_ALERTMANAGER_UID, + mockDataSources, setupVanillaAlertmanagerServer, } from 'app/features/alerting/unified/components/settings/__mocks__/server'; import { setupMswServer } from 'app/features/alerting/unified/mockApi'; -import { grantUserPermissions, mockDataSource } from 'app/features/alerting/unified/mocks'; +import { grantUserPermissions } from 'app/features/alerting/unified/mocks'; import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; -import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; -import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; import ContactPoints from './Receivers'; @@ -19,15 +18,15 @@ import 'core-js/stable/structured-clone'; const server = setupMswServer(); -const mockDataSources = { - [EXTERNAL_VANILLA_ALERTMANAGER_UID]: mockDataSource({ - uid: EXTERNAL_VANILLA_ALERTMANAGER_UID, - name: EXTERNAL_VANILLA_ALERTMANAGER_UID, - type: DataSourceType.Alertmanager, - jsonData: { - implementation: AlertManagerImplementation.prometheus, - }, - }), +const assertSaveWasSuccessful = async () => { + // TODO: Have a better way to assert that the contact point was saved. This is instead asserting on some + // text that's present on the list page, as there's a lot of overlap in text between the form and the list page + return waitFor(() => expect(screen.getByText(/search by name or type/i)).toBeInTheDocument(), { timeout: 2000 }); +}; + +const saveContactPoint = async () => { + const user = userEvent.setup(); + return user.click(await screen.findByRole('button', { name: /save contact point/i })); }; beforeEach(() => { @@ -37,17 +36,22 @@ beforeEach(() => { AccessControlAction.AlertingNotificationsExternalRead, AccessControlAction.AlertingNotificationsExternalWrite, ]); + + setupVanillaAlertmanagerServer(server); + setupDataSources(mockDataSources[PROVISIONED_MIMIR_ALERTMANAGER_UID]); }); it('can save a contact point with a select dropdown', async () => { - setupVanillaAlertmanagerServer(server); - setupDataSources(mockDataSources[EXTERNAL_VANILLA_ALERTMANAGER_UID]); - const user = userEvent.setup(); render(, { historyOptions: { - initialEntries: [`/alerting/notifications/receivers/new?alertmanager=${EXTERNAL_VANILLA_ALERTMANAGER_UID}`], + initialEntries: [ + { + pathname: `/alerting/notifications/receivers/new`, + search: `?alertmanager=${PROVISIONED_MIMIR_ALERTMANAGER_UID}`, + }, + ], }, }); @@ -66,9 +70,28 @@ it('can save a contact point with a select dropdown', async () => { await user.type(botToken, 'sometoken'); await user.type(chatId, '-123'); - await user.click(await screen.findByRole('button', { name: /save contact point/i })); + await saveContactPoint(); - // TODO: Have a better way to assert that the contact point was saved. This is instead asserting on some - // text that's present on the list page, as there's a lot of overlap in text between the form and the list page - await waitFor(() => expect(screen.getByText(/search by name or type/i)).toBeInTheDocument(), { timeout: 2000 }); + await assertSaveWasSuccessful(); +}); + +it('can save existing Telegram contact point', async () => { + render(, { + historyOptions: { + initialEntries: [ + { + pathname: `/alerting/notifications/receivers/Telegram/edit`, + search: `?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`, + // so opening and trying to save an existing Telegram integration should + // trigger this error if it regresses + await saveContactPoint(); + + await assertSaveWasSuccessful(); }); diff --git a/public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/provisioned/config/api/v1/alerts.json b/public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/provisioned/config/api/v1/alerts.json new file mode 100644 index 00000000000..7d3a346d87d --- /dev/null +++ b/public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/provisioned/config/api/v1/alerts.json @@ -0,0 +1,26 @@ +{ + "template_files": {}, + "alertmanager_config": { + "global": {}, + "receivers": [ + { + "name": "default" + }, + { + "name": "Telegram", + "telegram_configs": [ + { + "bot_token": "abc", + "chat_id": -123, + "disable_notifications": false, + "parse_mode": "MarkdownV2", + "send_resolved": true + } + ] + } + ], + "route": { + "receiver": "default" + } + } +} diff --git a/public/app/features/alerting/unified/components/settings/__mocks__/server.ts b/public/app/features/alerting/unified/components/settings/__mocks__/server.ts index 41ead147008..bb34baac74b 100644 --- a/public/app/features/alerting/unified/components/settings/__mocks__/server.ts +++ b/public/app/features/alerting/unified/components/settings/__mocks__/server.ts @@ -16,6 +16,7 @@ import { DataSourceType } from '../../../utils/datasource'; import internalAlertmanagerConfig from './api/alertmanager/grafana/config/api/v1/alerts.json'; import history from './api/alertmanager/grafana/config/history.json'; +import cloudAlertmanagerConfig from './api/alertmanager/provisioned/config/api/v1/alerts.json'; import vanillaAlertmanagerConfig from './api/alertmanager/vanilla prometheus/api/v2/status.json'; import datasources from './api/datasources.json'; import admin_config from './api/v1/ngalert/admin_config.json'; @@ -37,7 +38,7 @@ const mocks = { getAllDataSources: jest.mocked(config.getAllDataSources), }; -const mockDataSources = { +export const mockDataSources = { [EXTERNAL_VANILLA_ALERTMANAGER_UID]: mockDataSource({ uid: EXTERNAL_VANILLA_ALERTMANAGER_UID, name: EXTERNAL_VANILLA_ALERTMANAGER_UID, @@ -95,7 +96,12 @@ const createAlertmanagerConfigurationHandlers = () => { }; return [ - http.get(`/api/alertmanager/:name/config/api/v1/alerts`, () => HttpResponse.json(internalAlertmanagerConfig)), + http.get<{ name: string }>(`/api/alertmanager/:name/config/api/v1/alerts`, ({ params }) => { + if (params.name === 'grafana') { + return HttpResponse.json(internalAlertmanagerConfig); + } + return HttpResponse.json(cloudAlertmanagerConfig); + }), http.post(`/api/alertmanager/:name/config/api/v1/alerts`, async ({ request }) => { await delay(1000); // simulate some time @@ -108,7 +114,12 @@ const createAlertmanagerConfigurationHandlers = () => { return false; } - return (receiver.telegram_configs || []).some((config) => typeof config.parse_mode === 'object'); + const invalidParseMode = (receiver.telegram_configs || []).some( + (config) => typeof config.parse_mode === 'object' + ); + const invalidChatId = (receiver.telegram_configs || []).some((config) => Number(config.chat_id) >= 0); + + return invalidParseMode || invalidChatId; }); if (invalidConfig) { diff --git a/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts b/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts index 06ee6893057..0ff45bb14e9 100644 --- a/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts +++ b/public/app/features/alerting/unified/utils/cloud-alertmanager-notifier-types.ts @@ -410,7 +410,7 @@ export const cloudNotifierTypes: Array> = [ }), option('chat_id', 'Chat ID', 'ID of the chat where to send the messages', { required: true, - setValueAs: (value) => (typeof value === 'string' ? parseInt(value, 10) : 0), + setValueAs: (value) => (typeof value === 'string' ? parseInt(value, 10) : value), }), option('message', 'Message', 'Message template', { placeholder: '{{ template "webex.default.message" .}}', diff --git a/public/app/features/browse-dashboards/RecentlyDeletedPage.tsx b/public/app/features/browse-dashboards/RecentlyDeletedPage.tsx index 149ca72a03b..ee0579fbead 100644 --- a/public/app/features/browse-dashboards/RecentlyDeletedPage.tsx +++ b/public/app/features/browse-dashboards/RecentlyDeletedPage.tsx @@ -34,7 +34,7 @@ const RecentlyDeletedPage = memo(() => { }, [dispatch, stateManager]); return ( - + ({ ...jest.requireActual('@grafana/runtime'), setPluginExtensionGetter: jest.fn(), getPluginLinkExtensions: jest.fn(), + getBackendSrv: () => { + return { + get: jest.fn().mockResolvedValue({ dashboard: simpleDashboard, meta: { url: '' } }), + }; + }, getDataSourceSrv: () => { return { get: jest.fn().mockResolvedValue({}), @@ -37,12 +44,19 @@ jest.mock('@grafana/runtime', () => ({ const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions); -function setup() { +function setup({ routeProps }: { routeProps?: Partial } = {}) { const context = getGrafanaContextMock(); + const defaultRouteProps = getRouteComponentProps(); const props: Props = { - ...getRouteComponentProps(), + ...defaultRouteProps, + match: { + ...defaultRouteProps.match, + params: { + uid: 'my-dash-uid', + }, + }, + ...routeProps, }; - props.match.params.uid = 'my-dash-uid'; const renderResult = render( @@ -258,14 +272,39 @@ describe('DashboardScenePage', () => { }); describe('home page', () => { - it('should not show controls', async () => { + it('should render the dashboard when the route is home', async () => { + setup({ + routeProps: { + route: { + ...getRouteComponentProps().route, + routeName: DashboardRoutes.Home, + }, + match: { + ...getRouteComponentProps().match, + path: '/', + params: {}, + }, + }, + }); + + await waitForDashbordToRender(); + + expect(await screen.findByTitle('Panel A')).toBeInTheDocument(); + expect(await screen.findByText('Content A')).toBeInTheDocument(); + + expect(await screen.findByTitle('Panel B')).toBeInTheDocument(); + expect(await screen.findByText('Content B')).toBeInTheDocument(); + }); + + it('should show controls', async () => { getDashboardScenePageStateManager().clearDashboardCache(); loadDashboardMock.mockClear(); loadDashboardMock.mockResolvedValue({ dashboard: { panels: [] }, meta: {} }); setup(); - await waitFor(() => expect(screen.queryByText('Refresh')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByText('Refresh')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryByText('Last 6 hours')).toBeInTheDocument()); }); }); }); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index a1b4f0f46f3..7159235f8c1 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -19,7 +19,9 @@ export interface Props extends GrafanaRouteComponentProps { const loader = new DashboardScenePageStateManager({}); await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); - expect(loader.state.dashboard).toBeUndefined(); + expect(loader.state.dashboard).toBeDefined(); expect(loader.state.isLoading).toBe(false); - expect(loader.state.loadError).toBe('Error: Dashboard not found'); - }); - - it('should handle home dashboard redirect', async () => { - setBackendSrv({ - get: () => Promise.resolve({ redirectUri: '/d/asd' }), - } as unknown as BackendSrv); - - const loader = new DashboardScenePageStateManager({}); - await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home }); - - expect(loader.state.dashboard).toBeUndefined(); - expect(loader.state.loadError).toBeUndefined(); + expect(loader.state.loadError).toBe('Dashboard not found'); }); it('shoud fetch dashboard from local storage and remove it after if it exists', async () => { @@ -94,6 +82,37 @@ describe('DashboardScenePageStateManager', () => { expect(loader.state.isLoading).toBe(false); }); + describe('Home dashboard', () => { + it('should handle home dashboard redirect', async () => { + setBackendSrv({ + get: () => Promise.resolve({ redirectUri: '/d/asd' }), + } as unknown as BackendSrv); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home }); + + expect(loader.state.dashboard).toBeUndefined(); + expect(loader.state.loadError).toBeUndefined(); + }); + + it('should handle invalid home dashboard request', async () => { + setBackendSrv({ + get: () => + Promise.reject({ + status: 500, + data: { message: 'Failed to load home dashboard' }, + }), + } as unknown as BackendSrv); + + const loader = new DashboardScenePageStateManager({}); + await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home }); + + expect(loader.state.dashboard).toBeDefined(); + expect(loader.state.dashboard?.state.title).toEqual('Failed to load home dashboard'); + expect(loader.state.loadError).toEqual('Failed to load home dashboard'); + }); + }); + describe('New dashboards', () => { it('Should have new empty model with meta.isNew and should not be cached', async () => { const loader = new DashboardScenePageStateManager({}); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 30e7064eb2a..551d4208308 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -1,10 +1,13 @@ import { locationUtil } from '@grafana/data'; import { config, getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; +import { defaultDashboard } from '@grafana/schema'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; import { default as localStorageStore } from 'app/core/store'; +import { getMessageFromError } from 'app/core/utils/errors'; import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { DashboardModel } from 'app/features/dashboard/state'; import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor'; import { DASHBOARD_FROM_LS_KEY, @@ -16,7 +19,10 @@ import { DashboardDTO, DashboardRoutes } from 'app/types'; import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; import { buildNewDashboardSaveModel } from '../serialization/buildNewDashboardSaveModel'; -import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { + createDashboardSceneFromDashboardModel, + transformSaveModelToScene, +} from '../serialization/transformSaveModelToScene'; import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState'; import { updateNavModel } from './utils'; @@ -143,7 +149,6 @@ export class DashboardScenePageStateManager extends StateManagerBase

${msg}

`, + mode: 'html', + }, + title: '', + transparent: true, + type: 'text', + }, + ], + }, + { canSave: false, canEdit: false } + ) + ); +} diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.test.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.test.tsx index 52b46d49c99..30b391e0aba 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.test.tsx @@ -104,17 +104,29 @@ describe('DashboardControls', () => { expect(scene.state.hideTimeControls).toBeTruthy(); expect(scene.state.hideVariableControls).toBeTruthy(); expect(scene.state.hideLinksControls).toBeTruthy(); + }); + + it('should not override state if no new state comes from url', () => { + const scene = buildTestScene({ hideTimeControls: true, hideVariableControls: true, hideLinksControls: true }); scene.updateFromUrl({}); - expect(scene.state.hideTimeControls).toBeFalsy(); - expect(scene.state.hideVariableControls).toBeFalsy(); - expect(scene.state.hideLinksControls).toBeFalsy(); + expect(scene.state.hideTimeControls).toBeTruthy(); + expect(scene.state.hideVariableControls).toBeTruthy(); + expect(scene.state.hideLinksControls).toBeTruthy(); }); it('should not call setState if no changes', () => { const scene = buildTestScene(); const setState = jest.spyOn(scene, 'setState'); - scene.updateFromUrl({}); - scene.updateFromUrl({}); + scene.updateFromUrl({ + '_dash.hideTimePicker': 'true', + '_dash.hideVariables': 'true', + '_dash.hideLinks': 'true', + }); + scene.updateFromUrl({ + '_dash.hideTimePicker': 'true', + '_dash.hideVariables': 'true', + '_dash.hideLinks': 'true', + }); expect(setState).toHaveBeenCalledTimes(1); }); }); diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx index 39db075bc44..5614c57bfd4 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx @@ -54,9 +54,14 @@ export class DashboardControls extends SceneObjectBase { updateFromUrl(values: SceneObjectUrlValues) { const update: Partial = {}; - update.hideTimeControls = values['_dash.hideTimePicker'] === 'true' || values['_dash.hideTimePicker'] === ''; - update.hideVariableControls = values['_dash.hideVariables'] === 'true' || values['_dash.hideVariables'] === ''; - update.hideLinksControls = values['_dash.hideLinks'] === 'true' || values['_dash.hideLinks'] === ''; + update.hideTimeControls = + values['_dash.hideTimePicker'] === 'true' || values['_dash.hideTimePicker'] === '' || this.state.hideTimeControls; + update.hideVariableControls = + values['_dash.hideVariables'] === 'true' || + values['_dash.hideVariables'] === '' || + this.state.hideVariableControls; + update.hideLinksControls = + values['_dash.hideLinks'] === 'true' || values['_dash.hideLinks'] === '' || this.state.hideLinksControls; if (Object.entries(update).some(([k, v]) => v !== this.state[k as keyof DashboardControlsState])) { this.setState(update); diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx index f8e77d21856..553710923cf 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx @@ -57,7 +57,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps {scopes && } - {!isHomePage && controls && hasControls && ( + {controls && hasControls && (
diff --git a/public/app/features/logs/components/LogRow.tsx b/public/app/features/logs/components/LogRow.tsx index fe161e3111e..a06edb4bdf1 100644 --- a/public/app/features/logs/components/LogRow.tsx +++ b/public/app/features/logs/components/LogRow.tsx @@ -17,7 +17,7 @@ import { reportInteraction } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; import { withTheme2, Themeable2, Icon, Tooltip, PopoverContent } from '@grafana/ui'; -import { checkLogsError, escapeUnescapedString } from '../utils'; +import { checkLogsError, escapeUnescapedString, checkLogsSampled } from '../utils'; import { LogDetails } from './LogDetails'; import { LogLabels } from './LogLabels'; @@ -222,6 +222,7 @@ class UnThemedLogRow extends PureComponent { const { showDetails, showingContext, permalinked } = this.state; const levelStyles = getLogLevelStyles(theme, row.logLevel); const { errorMessage, hasError } = checkLogsError(row); + const { sampleMessage, isSampled } = checkLogsSampled(row); const logRowBackground = cx(styles.logsRow, { [styles.errorLogRow]: hasError, [styles.highlightBackground]: showingContext || permalinked, @@ -255,13 +256,22 @@ class UnThemedLogRow extends PureComponent { )} {hasError && ( )} + {isSampled && ( + + + + )} { if (document.getSelection()?.toString()) { return; } + this.closePopoverMenu(); }; closePopoverMenu = () => { diff --git a/public/app/features/logs/components/getLogRowStyles.ts b/public/app/features/logs/components/getLogRowStyles.ts index e2142cdf592..e3609dae455 100644 --- a/public/app/features/logs/components/getLogRowStyles.ts +++ b/public/app/features/logs/components/getLogRowStyles.ts @@ -117,9 +117,16 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => { width: 4em; cursor: default; `, - logIconError: css` - color: ${theme.colors.warning.main}; - `, + logIconError: css({ + color: theme.colors.warning.main, + position: 'relative', + top: '-2px', + }), + logIconInfo: css({ + color: theme.colors.info.main, + position: 'relative', + top: '-2px', + }), logsRowToggleDetails: css` label: logs-row-toggle-details__level; font-size: 9px; diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index 150caee3740..4d8f88c5641 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -154,6 +154,22 @@ export const checkLogsError = (logRow: LogRowModel): { hasError: boolean; errorM }; }; +export const checkLogsSampled = (logRow: LogRowModel): { isSampled: boolean; sampleMessage?: string } => { + if (logRow.labels.__adaptive_logs_sampled__) { + let msg = + logRow.labels.__adaptive_logs_sampled__ === 'true' + ? 'Logs like this one have been dropped by Adaptive Logs' + : `${logRow.labels.__adaptive_logs_sampled__}% of logs like this one have been dropped by Adaptive Logs`; + return { + isSampled: true, + sampleMessage: msg, + }; + } + return { + isSampled: false, + }; +}; + export const escapeUnescapedString = (string: string) => string.replace(/\\r\\n|\\n|\\t|\\r/g, (match: string) => (match.slice(1) === 't' ? '\t' : '\n')); diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 87729a69432..34d3ade4622 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -436,7 +436,7 @@ export function getAppRoutes(): RouteDescriptor[] { ), }, config.featureToggles.dashboardRestore && { - path: '/dashboard/recentlyDeleted', + path: '/dashboard/recently-deleted', roles: () => contextSrv.evaluatePermission([AccessControlAction.DashboardsDelete]), component: SafeDynamicImport( () => import(/* webpackChunkName: "RecentlyDeletedPage" */ 'app/features/browse-dashboards/RecentlyDeletedPage') diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 36fb784122a..0f00b256579 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1184,7 +1184,7 @@ "public": { "title": "Public dashboards" }, - "recentlyDeleted": { + "recently-deleted": { "subtitle": "Any items listed here for more than 30 days will be automatically deleted.", "title": "Recently Deleted" }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index bfe2a139e08..065ea9ab9b5 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1184,7 +1184,7 @@ "public": { "title": "Pūþľįč đäşĥþőäřđş" }, - "recentlyDeleted": { + "recently-deleted": { "subtitle": "Åʼny įŧęmş ľįşŧęđ ĥęřę ƒőř mőřę ŧĥäʼn 30 đäyş ŵįľľ þę äūŧőmäŧįčäľľy đęľęŧęđ.", "title": "Ŗęčęʼnŧľy Đęľęŧęđ" }, diff --git a/public/sass/_grafana.scss b/public/sass/_grafana.scss index f19901dc014..4137058f724 100644 --- a/public/sass/_grafana.scss +++ b/public/sass/_grafana.scss @@ -20,7 +20,6 @@ @import 'utils/widths'; // COMPONENTS -@import 'components/scrollbar'; @import 'components/buttons'; @import 'components/alerts'; @import 'components/tags'; diff --git a/public/sass/components/_scrollbar.scss b/public/sass/components/_scrollbar.scss deleted file mode 100644 index 1d2f90f130f..00000000000 --- a/public/sass/components/_scrollbar.scss +++ /dev/null @@ -1,72 +0,0 @@ -// Scrollbars -// Note, this is not applied by default if the `betterPageScrolling` feature flag is applied -.no-overlay-scrollbar { - ::-webkit-scrollbar { - width: 8px; - height: 8px; - } - - ::-webkit-scrollbar:hover { - height: 8px; - } - - ::-webkit-scrollbar-button:start:decrement, - ::-webkit-scrollbar-button:end:increment { - display: none; - } - ::-webkit-scrollbar-button:horizontal:decrement { - display: none; - } - ::-webkit-scrollbar-button:horizontal:increment { - display: none; - } - ::-webkit-scrollbar-button:vertical:decrement { - display: none; - } - ::-webkit-scrollbar-button:vertical:increment { - display: none; - } - ::-webkit-scrollbar-button:horizontal:decrement:active { - background-image: none; - } - ::-webkit-scrollbar-button:horizontal:increment:active { - background-image: none; - } - ::-webkit-scrollbar-button:vertical:decrement:active { - background-image: none; - } - ::-webkit-scrollbar-button:vertical:increment:active { - background-image: none; - } - ::-webkit-scrollbar-track-piece { - background-color: transparent; - } - - ::-webkit-scrollbar-thumb:vertical { - height: 50px; - background: -webkit-gradient( - linear, - left top, - right top, - color-stop(0%, $scrollbarBackground), - color-stop(100%, $scrollbarBackground2) - ); - border: 1px solid $scrollbarBorder; - border-top: 1px solid $scrollbarBorder; - border-left: 1px solid $scrollbarBorder; - } - - ::-webkit-scrollbar-thumb:horizontal { - width: 50px; - background: -webkit-gradient( - linear, - left top, - left bottom, - color-stop(0%, $scrollbarBackground), - color-stop(100%, $scrollbarBackground2) - ); - border: 1px solid $scrollbarBorder; - border-top: 1px solid $scrollbarBorder; - border-left: 1px solid $scrollbarBorder; - } -} diff --git a/public/test/jest-setup.ts b/public/test/jest-setup.ts index 4d34bb4a7fd..640db119ccd 100644 --- a/public/test/jest-setup.ts +++ b/public/test/jest-setup.ts @@ -67,6 +67,8 @@ global.IntersectionObserver = mockIntersectionObserver; global.TextEncoder = TextEncoder; global.TextDecoder = TextDecoder; +// add scrollTo interface since it's not implemented in jsdom +Element.prototype.scrollTo = () => {}; jest.mock('../app/core/core', () => ({ ...jest.requireActual('../app/core/core'), diff --git a/yarn.lock b/yarn.lock index 9fe63d38e1b..4529834161a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30951,8 +30951,8 @@ __metadata: linkType: hard "ws@npm:^7.2.0, ws@npm:^7.3.1": - version: 7.5.6 - resolution: "ws@npm:7.5.6" + version: 7.5.10 + resolution: "ws@npm:7.5.10" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ^5.0.2 @@ -30961,13 +30961,13 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10/745fc1a1cdac7e91f2c7340f7006aea06454b6ca4f24615a1e81124f46d99a7200839895c088a30c1d8d92dd1a9d349046bd4bc3475447aaf13b1f5cb48a18b7 + checksum: 10/9c796b84ba80ffc2c2adcdfc9c8e9a219ba99caa435c9a8d45f9ac593bba325563b3f83edc5eb067cc6d21b9a6bf2c930adf76dd40af5f58a5ca6859e81858f0 languageName: node linkType: hard "ws@npm:^8.16.0, ws@npm:^8.2.3, ws@npm:^8.9.0": - version: 8.16.0 - resolution: "ws@npm:8.16.0" + version: 8.17.1 + resolution: "ws@npm:8.17.1" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -30976,7 +30976,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10/7c511c59e979bd37b63c3aea4a8e4d4163204f00bd5633c053b05ed67835481995f61a523b0ad2b603566f9a89b34cb4965cb9fab9649fbfebd8f740cea57f17 + checksum: 10/4264ae92c0b3e59c7e309001e93079b26937aab181835fb7af79f906b22cd33b6196d96556dafb4e985742dd401e99139572242e9847661fdbc96556b9e6902d languageName: node linkType: hard