Merge remote-tracking branch 'origin/main' into resource-store

This commit is contained in:
Ryan McKinley 2024-06-18 21:17:30 +03:00
commit 9f6709c167
49 changed files with 656 additions and 233 deletions

View File

@ -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 <Trans />", "0"]

36
.github/CODEOWNERS vendored
View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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=

View File

@ -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;
}

View File

@ -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)

View File

@ -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...)
}

9
pkg/infra/db/dbrepl.go Normal file
View File

@ -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
}

View File

@ -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)

View File

@ -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

View File

@ -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) {

View File

@ -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
},
}
)

View File

@ -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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
150 kubernetesAggregator experimental @grafana/grafana-app-platform-squad false true false
151 expressionParser experimental @grafana/grafana-app-platform-squad false true false
152 groupByVariable experimental @grafana/dashboards-squad false false false
betterPageScrolling GA @grafana/grafana-frontend-platform false false true
153 authAPIAccessTokenAuth experimental @grafana/identity-access-team false false false
154 scopeFilters experimental @grafana/dashboards-squad false false false
155 ssoSettingsSAML preview @grafana/identity-access-team false false false
176 authZGRPCServer experimental @grafana/identity-access-team false false false
177 openSearchBackendFlowEnabled preview @grafana/aws-datasources false false false
178 ssoSettingsLDAP experimental @grafana/identity-access-team false false false
179 databaseReadReplica experimental @grafana/grafana-backend-services-squad false false false

View File

@ -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"
)

View File

@ -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",

View File

@ -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",
})
}
}

View File

@ -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

View File

@ -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
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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<typeof CustomScrollbar>[0];
export default function FlaggedScrollbar(props: FlaggedScrollerProps) {
if (config.featureToggles.betterPageScrolling) {
return <NativeScrollbar {...props}>{props.children}</NativeScrollbar>;
}
return <CustomScrollbar {...props} />;
}
type Props = Parameters<typeof CustomScrollbar>[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<HTMLDivElement>(null);

View File

@ -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 (
<div className={cx(styles.wrapper, className)} {...otherProps}>
{layout === PageLayoutType.Standard && (
<FlaggedScrollbar
<NativeScrollbar
// This id is used by the image renderer to scroll through the dashboard
divId="page-scrollbar"
autoHeightMin={'100%'}
@ -74,11 +74,11 @@ export const Page: PageType = ({
{pageNav && pageNav.children && <PageTabs navItem={pageNav} />}
<div className={styles.pageContent}>{children}</div>
</div>
</FlaggedScrollbar>
</NativeScrollbar>
)}
{layout === PageLayoutType.Canvas && (
<FlaggedScrollbar
<NativeScrollbar
// This id is used by the image renderer to scroll through the dashboard
divId="page-scrollbar"
autoHeightMin={'100%'}
@ -86,7 +86,7 @@ export const Page: PageType = ({
scrollRefCallback={scrollRef}
>
<div className={styles.canvasContent}>{children}</div>
</FlaggedScrollbar>
</NativeScrollbar>
)}
{layout === PageLayoutType.Custom && children}

View File

@ -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';

View File

@ -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':

View File

@ -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<AlertManagerDataSourceJsonData>({
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(<ContactPoints />, {
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(<ContactPoints />, {
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();
});

View File

@ -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"
}
}
}

View File

@ -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<AlertManagerDataSourceJsonData>({
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<never, AlertManagerCortexConfig>(`/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) {

View File

@ -410,7 +410,7 @@ export const cloudNotifierTypes: Array<NotifierDTO<CloudNotifierType>> = [
}),
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" .}}',

View File

@ -34,7 +34,7 @@ const RecentlyDeletedPage = memo(() => {
}, [dispatch, stateManager]);
return (
<Page navId="dashboards/recentlyDeleted">
<Page navId="dashboards/recently-deleted">
<Page.Contents>
<ActionRow
state={searchState}

View File

@ -11,9 +11,11 @@ import { config, getPluginLinkExtensions, locationService, setPluginImportUtils
import { VizPanel } from '@grafana/scenes';
import { Dashboard } from '@grafana/schema';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import store from 'app/core/store';
import { DashboardLoaderSrv, setDashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { DASHBOARD_FROM_LS_KEY } from 'app/features/dashboard/state/initDashboard';
import { DashboardRoutes } from 'app/types';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
@ -24,6 +26,11 @@ jest.mock('@grafana/runtime', () => ({
...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<GrafanaRouteComponentProps> } = {}) {
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(
<TestProvider grafanaContext={context}>
@ -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());
});
});
});

View File

@ -19,7 +19,9 @@ export interface Props extends GrafanaRouteComponentProps<DashboardPageRoutePara
export function DashboardScenePage({ match, route, queryParams, history }: Props) {
const stateManager = getDashboardScenePageStateManager();
const { dashboard, isLoading, loadError } = stateManager.useState();
// After scene migration is complete and we get rid of old dashboard we should refactor dashboardWatcher so this route reload is not need
const routeReloadCounter = (history.location.state as any)?.routeReloadCounter;
@ -74,7 +76,12 @@ export function DashboardScenePage({ match, route, queryParams, history }: Props
}
// Do not render anything when transitioning from one dashboard to another
if (match.params.type !== 'snapshot' && dashboard.state.uid && dashboard.state.uid !== match.params.uid) {
if (
match.params.type !== 'snapshot' &&
dashboard.state.uid &&
dashboard.state.uid !== match.params.uid &&
route.routeName !== DashboardRoutes.Home
) {
return null;
}

View File

@ -35,21 +35,9 @@ describe('DashboardScenePageStateManager', () => {
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({});

View File

@ -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<DashboardSc
return null;
}
console.error(e);
throw e;
}
@ -196,7 +201,12 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
});
}
} catch (err) {
this.setState({ isLoading: false, loadError: String(err) });
const msg = getMessageFromError(err);
this.setState({
isLoading: false,
loadError: msg,
dashboard: getErrorScene(msg),
});
}
}
@ -282,3 +292,42 @@ export function getDashboardScenePageStateManager(): DashboardScenePageStateMana
return stateManager;
}
function getErrorScene(msg: string) {
return createDashboardSceneFromDashboardModel(
new DashboardModel(
{
...defaultDashboard,
title: msg,
panels: [
{
fieldConfig: {
defaults: {},
overrides: [],
},
gridPos: {
h: 6,
w: 12,
x: 7,
y: 0,
},
id: 1,
options: {
code: {
language: 'plaintext',
showLineNumbers: false,
showMiniMap: false,
},
content: `<br/><br/><center><h1>${msg}</h1></center>`,
mode: 'html',
},
title: '',
transparent: true,
type: 'text',
},
],
},
{ canSave: false, canEdit: false }
)
);
}

View File

@ -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);
});
});

View File

@ -54,9 +54,14 @@ export class DashboardControls extends SceneObjectBase<DashboardControlsState> {
updateFromUrl(values: SceneObjectUrlValues) {
const update: Partial<DashboardControlsState> = {};
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);

View File

@ -57,7 +57,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
>
{scopes && <scopes.Component model={scopes} />}
<NavToolbarActions dashboard={model} />
{!isHomePage && controls && hasControls && (
{controls && hasControls && (
<div
className={cx(styles.controlsWrapper, scopes && !isScopesExpanded && styles.controlsWrapperWithScopes)}
>

View File

@ -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<Props, State> {
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<Props, State> {
</td>
)}
<td
className={hasError ? styles.logsRowWithError : `${levelStyles.logsRowLevelColor} ${styles.logsRowLevel}`}
className={
hasError || isSampled
? styles.logsRowWithError
: `${levelStyles.logsRowLevelColor} ${styles.logsRowLevel}`
}
>
{hasError && (
<Tooltip content={`Error: ${errorMessage}`} placement="right" theme="error">
<Icon className={styles.logIconError} name="exclamation-triangle" size="xs" />
</Tooltip>
)}
{isSampled && (
<Tooltip content={`${sampleMessage}`} placement="right" theme="info">
<Icon className={styles.logIconInfo} name="info-circle" size="xs" />
</Tooltip>
)}
</td>
<td
title={enableLogDetails ? (showDetails ? 'Hide log details' : 'See log details') : ''}

View File

@ -147,6 +147,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
if (document.getSelection()?.toString()) {
return;
}
this.closePopoverMenu();
};
closePopoverMenu = () => {

View File

@ -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;

View File

@ -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'));

View File

@ -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')

View File

@ -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"
},

View File

@ -1184,7 +1184,7 @@
"public": {
"title": "Pūþľįč đäşĥþőäřđş"
},
"recentlyDeleted": {
"recently-deleted": {
"subtitle": "Åʼny įŧęmş ľįşŧęđ ĥęřę ƒőř mőřę ŧĥäʼn 30 đäyş ŵįľľ þę äūŧőmäŧįčäľľy đęľęŧęđ.",
"title": "Ŗęčęʼnŧľy Đęľęŧęđ"
},

View File

@ -20,7 +20,6 @@
@import 'utils/widths';
// COMPONENTS
@import 'components/scrollbar';
@import 'components/buttons';
@import 'components/alerts';
@import 'components/tags';

View File

@ -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;
}
}

View File

@ -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'),

View File

@ -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