mirror of
https://github.com/grafana/grafana.git
synced 2025-02-10 15:45:43 -06:00
Merge remote-tracking branch 'origin/main' into resource-store
This commit is contained in:
commit
9f6709c167
@ -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
36
.github/CODEOWNERS
vendored
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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=
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
9
pkg/infra/db/dbrepl.go
Normal 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
|
||||
}
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
194
pkg/services/sqlstore/replstore.go
Normal file
194
pkg/services/sqlstore/replstore.go
Normal 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
|
||||
}
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
|
@ -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}
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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':
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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" .}}',
|
||||
|
@ -34,7 +34,7 @@ const RecentlyDeletedPage = memo(() => {
|
||||
}, [dispatch, stateManager]);
|
||||
|
||||
return (
|
||||
<Page navId="dashboards/recentlyDeleted">
|
||||
<Page navId="dashboards/recently-deleted">
|
||||
<Page.Contents>
|
||||
<ActionRow
|
||||
state={searchState}
|
||||
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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({});
|
||||
|
@ -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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
|
@ -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)}
|
||||
>
|
||||
|
@ -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') : ''}
|
||||
|
@ -147,6 +147,7 @@ class UnThemedLogRows extends PureComponent<Props, State> {
|
||||
if (document.getSelection()?.toString()) {
|
||||
return;
|
||||
}
|
||||
this.closePopoverMenu();
|
||||
};
|
||||
|
||||
closePopoverMenu = () => {
|
||||
|
@ -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;
|
||||
|
@ -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'));
|
||||
|
||||
|
@ -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')
|
||||
|
@ -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"
|
||||
},
|
||||
|
@ -1184,7 +1184,7 @@
|
||||
"public": {
|
||||
"title": "Pūþľįč đäşĥþőäřđş"
|
||||
},
|
||||
"recentlyDeleted": {
|
||||
"recently-deleted": {
|
||||
"subtitle": "Åʼny įŧęmş ľįşŧęđ ĥęřę ƒőř mőřę ŧĥäʼn 30 đäyş ŵįľľ þę äūŧőmäŧįčäľľy đęľęŧęđ.",
|
||||
"title": "Ŗęčęʼnŧľy Đęľęŧęđ"
|
||||
},
|
||||
|
@ -20,7 +20,6 @@
|
||||
@import 'utils/widths';
|
||||
|
||||
// COMPONENTS
|
||||
@import 'components/scrollbar';
|
||||
@import 'components/buttons';
|
||||
@import 'components/alerts';
|
||||
@import 'components/tags';
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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'),
|
||||
|
12
yarn.lock
12
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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user