From 4e3039e4bd34ac086300bc59fac330054cb4a993 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Mon, 5 Jan 2026 11:02:52 +0000 Subject: [PATCH 01/79] Chore: Bump qs (#115815) Bumps qs package in transitive dependencies to version 6.14.1 --- yarn.lock | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 64561ce4dca..6d0b057e2e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28037,7 +28037,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:6.14.0, qs@npm:^6.11.2, qs@npm:^6.14.0, qs@npm:^6.4.0": +"qs@npm:6.14.0": version: 6.14.0 resolution: "qs@npm:6.14.0" dependencies: @@ -28046,6 +28046,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.11.2, qs@npm:^6.14.0, qs@npm:^6.4.0": + version: 6.14.1 + resolution: "qs@npm:6.14.1" + dependencies: + side-channel: "npm:^1.1.0" + checksum: 10/34b5ab00a910df432d55180ef39c1d1375e550f098b5ec153b41787f1a6a6d7e5f9495593c3b112b77dbc6709d0ae18e55b82847a4c2bbbb0de1e8ccbb1794c5 + languageName: node + linkType: hard + "querystringify@npm:^2.1.1": version: 2.2.0 resolution: "querystringify@npm:2.2.0" From 70b1053ad1914df400b2289ef0cdc0b87196f4cf Mon Sep 17 00:00:00 2001 From: Will Browne Date: Mon, 5 Jan 2026 11:12:31 +0000 Subject: [PATCH 02/79] Plugins: Remove `pkg/infra/fs`, `pkg/infra/tracing` and `pkg/infra/process` dependencies from pkg/plugins (#115798) * remove dependency on packages * update tests * trigger --- pkg/api/pluginproxy/ds_proxy.go | 4 +-- pkg/api/plugins.go | 9 +++--- .../http_client_provider_test.go | 11 ++++--- .../http_logger_middleware.go | 9 +++--- .../http_logger_middleware_test.go | 12 ++++--- .../coreplugin/core_plugin_test.go | 10 +++--- .../backendplugin/coreplugin/registry.go | 12 +++---- .../backendplugin/coreplugin/registry_test.go | 7 ++--- pkg/plugins/config/config.go | 11 +++---- pkg/{util => plugins}/filepath.go | 2 +- pkg/{util => plugins}/filepath_test.go | 2 +- pkg/plugins/localfiles.go | 6 ++-- pkg/plugins/manager/client/client.go | 7 +++-- pkg/plugins/manager/client/client_test.go | 8 ++--- .../manager/sources/source_local_disk.go | 5 ++- pkg/plugins/plugins.go | 3 +- pkg/server/wire_gen.go | 18 +++++------ .../dashboards/filestore.go | 3 +- .../pluginsintegration/pipeline/steps_test.go | 3 +- .../pluginassets/pluginassets_test.go | 23 +++++++------- .../pluginsintegration/pluginconfig/config.go | 6 ++-- .../pluginexternal/check_test.go | 3 +- .../pluginsintegration/pluginsintegration.go | 5 +-- .../pluginsources/pluginsources.go} | 15 ++++----- .../pluginsources/pluginsources_test.go} | 31 ++++++++++++------- .../plugintest/plugins_test.go | 2 +- .../pluginsintegration/test_helper.go | 4 +-- pkg/setting/setting.go | 3 +- pkg/setting/setting_plugins.go | 8 ++--- pkg/tsdb/mssql/mssql.go | 8 ++--- pkg/tsdb/mssql/standalone/main.go | 5 ++- pkg/util/proxyutil/proxyutil.go | 7 +---- pkg/util/proxyutil/reverse_proxy.go | 3 +- 33 files changed, 131 insertions(+), 134 deletions(-) rename pkg/{util => plugins}/filepath.go (99%) rename pkg/{util => plugins}/filepath_test.go (98%) rename pkg/{plugins/manager/sources/sources.go => services/pluginsintegration/pluginsources/pluginsources.go} (78%) rename pkg/{plugins/manager/sources/sources_test.go => services/pluginsintegration/pluginsources/pluginsources_test.go} (83%) diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index 00ccb0a665c..de38697f613 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -300,11 +300,11 @@ func (proxy *DataSourceProxy) validateRequest() error { } // route match - r1, err := util.CleanRelativePath(proxy.proxyPath) + r1, err := plugins.CleanRelativePath(proxy.proxyPath) if err != nil { return err } - r2, err := util.CleanRelativePath(route.Path) + r2, err := plugins.CleanRelativePath(route.Path) if err != nil { return err } diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 4fa7292dcb5..50e32326565 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -13,7 +13,6 @@ import ( "sort" "strings" - "github.com/grafana/grafana/pkg/plugins/auth" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -22,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/auth" "github.com/grafana/grafana/pkg/plugins/repo" ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -32,7 +32,6 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -355,7 +354,7 @@ func (hs *HTTPServer) getPluginAssets(c *contextmodel.ReqContext) { } // prepend slash for cleaning relative paths - requestedFile, err := util.CleanRelativePath(web.Params(c.Req)["*"]) + requestedFile, err := plugins.CleanRelativePath(web.Params(c.Req)["*"]) if err != nil { // slash is prepended above therefore this is not expected to fail c.JsonApiErr(500, "Failed to clean relative file path", err) @@ -598,9 +597,9 @@ func mdFilepath(mdFilename string) (string, error) { fileExt := filepath.Ext(mdFilename) switch fileExt { case "md": - return util.CleanRelativePath(mdFilename) + return plugins.CleanRelativePath(mdFilename) case "": - return util.CleanRelativePath(fmt.Sprintf("%s.md", mdFilename)) + return plugins.CleanRelativePath(fmt.Sprintf("%s.md", mdFilename)) default: return "", ErrUnexpectedFileExtension } diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go index 449959c0d68..9ba6aede494 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go +++ b/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go @@ -3,13 +3,14 @@ package httpclientprovider import ( "testing" - "github.com/grafana/grafana/pkg/services/validations" - "github.com/grafana/grafana-aws-sdk/pkg/awsauth" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/services/validations" + "github.com/grafana/grafana/pkg/setting" ) func TestHTTPClientProvider(t *testing.T) { @@ -77,7 +78,7 @@ func TestHTTPClientProvider(t *testing.T) { newProviderFunc = origNewProviderFunc }) tracer := tracing.InitializeTracerForTest() - _ = New(&setting.Cfg{PluginSettings: setting.PluginSettings{"example": {"har_log_enabled": "true"}}}, &validations.OSSDataSourceRequestURLValidator{}, tracer) + _ = New(&setting.Cfg{PluginSettings: config.PluginSettings{"example": {"har_log_enabled": "true"}}}, &validations.OSSDataSourceRequestURLValidator{}, tracer) require.Len(t, providerOpts, 1) o := providerOpts[0] require.Len(t, o.Middlewares, 10) diff --git a/pkg/infra/httpclient/httpclientprovider/http_logger_middleware.go b/pkg/infra/httpclient/httpclientprovider/http_logger_middleware.go index fceafbf06a5..daf248f384e 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_logger_middleware.go +++ b/pkg/infra/httpclient/httpclientprovider/http_logger_middleware.go @@ -5,12 +5,13 @@ import ( sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" httplogger "github.com/grafana/grafana-plugin-sdk-go/experimental/http_logger" - "github.com/grafana/grafana/pkg/setting" + + "github.com/grafana/grafana/pkg/plugins/config" ) const HTTPLoggerMiddlewareName = "http-logger" -func HTTPLoggerMiddleware(cfg setting.PluginSettings) sdkhttpclient.Middleware { +func HTTPLoggerMiddleware(cfg config.PluginSettings) sdkhttpclient.Middleware { return sdkhttpclient.NamedMiddlewareFunc(HTTPLoggerMiddlewareName, func(opts sdkhttpclient.Options, next http.RoundTripper) http.RoundTripper { datasourceType, exists := opts.Labels["datasource_type"] if !exists { @@ -29,7 +30,7 @@ func HTTPLoggerMiddleware(cfg setting.PluginSettings) sdkhttpclient.Middleware { }) } -func httpLoggingEnabled(cfg setting.PluginSettings) bool { +func httpLoggingEnabled(cfg config.PluginSettings) bool { for _, settings := range cfg { if enabled := settings["har_log_enabled"]; enabled == "true" { return true @@ -38,7 +39,7 @@ func httpLoggingEnabled(cfg setting.PluginSettings) bool { return false } -func getLoggerSettings(datasourceType string, cfg setting.PluginSettings) (enabled bool, path string) { +func getLoggerSettings(datasourceType string, cfg config.PluginSettings) (enabled bool, path string) { settings, ok := cfg[datasourceType] if !ok { return diff --git a/pkg/infra/httpclient/httpclientprovider/http_logger_middleware_test.go b/pkg/infra/httpclient/httpclientprovider/http_logger_middleware_test.go index 006c87411e9..7ef5b17b08a 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_logger_middleware_test.go +++ b/pkg/infra/httpclient/httpclientprovider/http_logger_middleware_test.go @@ -9,15 +9,17 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/experimental/e2e/storage" - "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/plugins/config" ) func TestHTTPLoggerMiddleware(t *testing.T) { t.Run("Should return middleware name", func(t *testing.T) { - mw := HTTPLoggerMiddleware(setting.PluginSettings{}) + mw := HTTPLoggerMiddleware(config.PluginSettings{}) middlewareName, ok := mw.(httpclient.MiddlewareName) require.True(t, ok) require.Equal(t, HTTPLoggerMiddlewareName, middlewareName.MiddlewareName()) @@ -27,7 +29,7 @@ func TestHTTPLoggerMiddleware(t *testing.T) { tempPath := path.Join(os.TempDir(), fmt.Sprintf("http_logger_test_%d.har", time.Now().UnixMilli())) ctx := &testContext{} finalRoundTripper := ctx.createRoundTripper("finalrt") - mw := HTTPLoggerMiddleware(setting.PluginSettings{"example-datasource": {"har_log_enabled": "false", "har_log_path": tempPath}}) + mw := HTTPLoggerMiddleware(config.PluginSettings{"example-datasource": {"har_log_enabled": "false", "har_log_path": tempPath}}) rt := mw.CreateMiddleware(httpclient.Options{Labels: map[string]string{"datasource_type": "example-datasource"}}, finalRoundTripper) require.NotNil(t, rt) @@ -54,7 +56,7 @@ func TestHTTPLoggerMiddleware(t *testing.T) { }() ctx := &testContext{} finalRoundTripper := ctx.createRoundTripper("finalrt") - mw := HTTPLoggerMiddleware(setting.PluginSettings{"example-datasource": {"har_log_enabled": "true", "har_log_path": f.Name()}}) + mw := HTTPLoggerMiddleware(config.PluginSettings{"example-datasource": {"har_log_enabled": "true", "har_log_path": f.Name()}}) rt := mw.CreateMiddleware(httpclient.Options{Labels: map[string]string{"datasource_type": "example-datasource"}}, finalRoundTripper) require.NotNil(t, rt) diff --git a/pkg/plugins/backendplugin/coreplugin/core_plugin_test.go b/pkg/plugins/backendplugin/coreplugin/core_plugin_test.go index e4a6cecfef9..4f9ae0ffa29 100644 --- a/pkg/plugins/backendplugin/coreplugin/core_plugin_test.go +++ b/pkg/plugins/backendplugin/coreplugin/core_plugin_test.go @@ -1,20 +1,20 @@ -package coreplugin_test +package coreplugin import ( "context" "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" - "github.com/stretchr/testify/require" ) func TestCorePlugin(t *testing.T) { t.Run("New core plugin with empty opts should return expected values", func(t *testing.T) { - factory := coreplugin.New(backend.ServeOpts{}) + factory := New(backend.ServeOpts{}) p, err := factory("plugin", log.New("test"), pluginfakes.InitializeNoopTracerForTest(), nil) require.NoError(t, err) require.NotNil(t, p) @@ -36,7 +36,7 @@ func TestCorePlugin(t *testing.T) { t.Run("New core plugin with handlers set in opts should return expected values", func(t *testing.T) { checkHealthCalled := false callResourceCalled := false - factory := coreplugin.New(backend.ServeOpts{ + factory := New(backend.ServeOpts{ CheckHealthHandler: backend.CheckHealthHandlerFunc(func(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { checkHealthCalled = true diff --git a/pkg/plugins/backendplugin/coreplugin/registry.go b/pkg/plugins/backendplugin/coreplugin/registry.go index fb17fd279b8..47c59216646 100644 --- a/pkg/plugins/backendplugin/coreplugin/registry.go +++ b/pkg/plugins/backendplugin/coreplugin/registry.go @@ -5,18 +5,16 @@ import ( "errors" "fmt" + "go.opentelemetry.io/otel/trace" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" sdklog "github.com/grafana/grafana-plugin-sdk-go/backend/log" sdktracing "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" - "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" cloudmonitoring "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring" "github.com/grafana/grafana/pkg/tsdb/cloudwatch" @@ -204,7 +202,7 @@ var ErrCorePluginNotFound = errors.New("core plugin not found") // NewPlugin factory for creating and initializing a single core plugin. // Note: cfg only needed for mssql connection pooling defaults. -func NewPlugin(pluginID string, cfg *setting.Cfg, httpClientProvider *httpclient.Provider, tracer trace.Tracer, features featuremgmt.FeatureToggles) (*plugins.Plugin, error) { +func NewPlugin(pluginID string, httpClientProvider *httpclient.Provider, tracer trace.Tracer) (*plugins.Plugin, error) { jsonData := plugins.JSONData{ ID: pluginID, AliasIDs: []string{}, @@ -241,7 +239,7 @@ func NewPlugin(pluginID string, cfg *setting.Cfg, httpClientProvider *httpclient case MySQL: svc = mysql.ProvideService() case MSSQL: - svc = mssql.ProvideService(cfg) + svc = mssql.ProvideService() case Pyroscope: svc = pyroscope.ProvideService(httpClientProvider) case Parca: diff --git a/pkg/plugins/backendplugin/coreplugin/registry_test.go b/pkg/plugins/backendplugin/coreplugin/registry_test.go index 76f531a25b7..a413ad73b3b 100644 --- a/pkg/plugins/backendplugin/coreplugin/registry_test.go +++ b/pkg/plugins/backendplugin/coreplugin/registry_test.go @@ -4,11 +4,10 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/tracing" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/require" ) func TestNewPlugin(t *testing.T) { @@ -46,7 +45,7 @@ func TestNewPlugin(t *testing.T) { tc.ExpectedID = tc.ID } - p, err := NewPlugin(tc.ID, setting.NewCfg(), httpclient.NewProvider(), tracing.NoopTracer(), featuremgmt.WithFeatures()) + p, err := NewPlugin(tc.ID, httpclient.NewProvider(), tracing.NoopTracer()) if tc.ExpectedNotFoundErr { require.ErrorIs(t, err, ErrCorePluginNotFound) require.Nil(t, p) diff --git a/pkg/plugins/config/config.go b/pkg/plugins/config/config.go index 40e744147bb..c84e70233b3 100644 --- a/pkg/plugins/config/config.go +++ b/pkg/plugins/config/config.go @@ -1,9 +1,5 @@ package config -import ( - "github.com/grafana/grafana/pkg/setting" -) - // PluginManagementCfg is the configuration for the plugin management system. // It includes settings which are used to configure different components of plugin management. type PluginManagementCfg struct { @@ -11,7 +7,7 @@ type PluginManagementCfg struct { PluginsPath string - PluginSettings setting.PluginSettings + PluginSettings PluginSettings PluginsAllowUnsigned []string DisablePlugins []string ForwardHostEnvVars []string @@ -35,8 +31,11 @@ type Features struct { TempoAlertingEnabled bool } +// PluginSettings maps plugin id to map of key/value settings. +type PluginSettings map[string]map[string]string + // NewPluginManagementCfg returns a new PluginManagementCfg. -func NewPluginManagementCfg(devMode bool, pluginsPath string, pluginSettings setting.PluginSettings, pluginsAllowUnsigned []string, +func NewPluginManagementCfg(devMode bool, pluginsPath string, pluginSettings PluginSettings, pluginsAllowUnsigned []string, pluginsCDNURLTemplate string, appURL string, features Features, grafanaComAPIURL string, disablePlugins []string, forwardHostEnvVars []string, grafanaComAPIToken string, ) *PluginManagementCfg { diff --git a/pkg/util/filepath.go b/pkg/plugins/filepath.go similarity index 99% rename from pkg/util/filepath.go rename to pkg/plugins/filepath.go index fc760058407..613c15699af 100644 --- a/pkg/util/filepath.go +++ b/pkg/plugins/filepath.go @@ -1,4 +1,4 @@ -package util +package plugins import ( "errors" diff --git a/pkg/util/filepath_test.go b/pkg/plugins/filepath_test.go similarity index 98% rename from pkg/util/filepath_test.go rename to pkg/plugins/filepath_test.go index dcaf10a53f9..efcea7b5653 100644 --- a/pkg/util/filepath_test.go +++ b/pkg/plugins/filepath_test.go @@ -1,4 +1,4 @@ -package util +package plugins import ( "path/filepath" diff --git a/pkg/plugins/localfiles.go b/pkg/plugins/localfiles.go index 073ab0e844d..c1f3e3753c4 100644 --- a/pkg/plugins/localfiles.go +++ b/pkg/plugins/localfiles.go @@ -7,8 +7,6 @@ import ( "os" "path/filepath" "strings" - - "github.com/grafana/grafana/pkg/util" ) var ( @@ -110,7 +108,7 @@ func (f LocalFS) walkFunc(basePath string, acc map[string]struct{}) filepath.Wal // If a nil error is returned, the caller should take care of calling Close() the returned fs.File. // If the file does not exist, ErrFileNotExist is returned. func (f LocalFS) Open(name string) (fs.File, error) { - cleanPath, err := util.CleanRelativePath(name) + cleanPath, err := CleanRelativePath(name) if err != nil { return nil, err } @@ -155,7 +153,7 @@ func (f LocalFS) Files() ([]string, error) { if err != nil { return nil, err } - clenRelPath, err := util.CleanRelativePath(relPath) + clenRelPath, err := CleanRelativePath(relPath) if err != nil { continue } diff --git a/pkg/plugins/manager/client/client.go b/pkg/plugins/manager/client/client.go index 5cc8f44c5b8..ec903ae46cd 100644 --- a/pkg/plugins/manager/client/client.go +++ b/pkg/plugins/manager/client/client.go @@ -14,7 +14,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/manager/registry" - "github.com/grafana/grafana/pkg/util/proxyutil" ) const ( @@ -124,7 +123,7 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq res.Headers = map[string][]string{} } - proxyutil.SetProxyResponseHeaders(res.Headers) + SetCSPHeader(res.Headers) ensureContentTypeHeader(res) } @@ -281,6 +280,10 @@ func (s *Service) ValidateAdmission(ctx context.Context, req *backend.AdmissionR return plugin.ValidateAdmission(ctx, req) } +func SetCSPHeader(header http.Header) { + header.Set("Content-Security-Policy", "sandbox") +} + // plugin finds a plugin with `pluginID` from the registry that is not decommissioned func (s *Service) plugin(ctx context.Context, pluginID, pluginVersion string) (*plugins.Plugin, bool) { p, exists := s.pluginRegistry.Plugin(ctx, pluginID, pluginVersion) diff --git a/pkg/plugins/manager/client/client_test.go b/pkg/plugins/manager/client/client_test.go index 1a11f330de9..f1ce6b2576f 100644 --- a/pkg/plugins/manager/client/client_test.go +++ b/pkg/plugins/manager/client/client_test.go @@ -8,11 +8,11 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" - "github.com/grafana/grafana/pkg/util/testutil" - "github.com/stretchr/testify/require" ) func TestQueryData(t *testing.T) { @@ -157,9 +157,7 @@ func TestCheckHealth(t *testing.T) { }) } -func TestIntegrationCallResource(t *testing.T) { - testutil.SkipIntegrationTestInShortMode(t) - +func TestCallResource(t *testing.T) { registry := pluginfakes.NewFakePluginRegistry() p := &plugins.Plugin{ JSONData: plugins.JSONData{ diff --git a/pkg/plugins/manager/sources/source_local_disk.go b/pkg/plugins/manager/sources/source_local_disk.go index 22830b69734..fc0cf3efc7a 100644 --- a/pkg/plugins/manager/sources/source_local_disk.go +++ b/pkg/plugins/manager/sources/source_local_disk.go @@ -13,10 +13,9 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" - "github.com/grafana/grafana/pkg/util" ) -var walk = util.Walk +var walk = plugins.Walk var ( ErrInvalidPluginJSONFilePath = errors.New("invalid plugin.json filepath was provided") @@ -215,7 +214,7 @@ func (s *LocalSource) getAbsPluginJSONPaths(path string) ([]string, error) { } if fi.Name() == "node_modules" { - return util.ErrWalkSkipDir + return plugins.ErrWalkSkipDir } if fi.IsDir() { diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 950dbfe7849..7f010565c9c 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -19,7 +19,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/util" ) var ( @@ -414,7 +413,7 @@ func (p *Plugin) ConvertObjects(ctx context.Context, req *backend.ConversionRequ } func (p *Plugin) File(name string) (fs.File, error) { - cleanPath, err := util.CleanRelativePath(name) + cleanPath, err := CleanRelativePath(name) if err != nil { // CleanRelativePath should clean and make the path relative so this is not expected to fail return nil, err diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 6569066fcdf..a235e71ec10 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -44,7 +44,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/process" "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" - "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/pluginassets" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/repo" @@ -196,6 +195,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginexternal" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller" service6 "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsources" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsso" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" @@ -370,7 +370,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } cacheService := localcache.ProvideService() ossDataSourceRequestValidator := validations.ProvideValidator() - sourcesService := sources.ProvideService(cfg, pluginManagementCfg) + pluginsourcesService := pluginsources.ProvideService(cfg, pluginManagementCfg) discovery := pipeline.ProvideDiscoveryStage(pluginManagementCfg, inMemory) keystoreService := keystore.ProvideService(kvStore) keyRetriever := dynamic.ProvideService(cfg, keystoreService) @@ -406,7 +406,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api testdatasourceService := testdatasource.ProvideService() postgresService := postgres.ProvideService() mysqlService := mysql.ProvideService() - mssqlService := mssql.ProvideService(cfg) + mssqlService := mssql.ProvideService() entityEventsService := store.ProvideEntityEventsService(cfg, sqlStore, featureToggles) configProvider, err := configprovider.ProvideService(cfg) if err != nil { @@ -580,7 +580,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } errorRegistry := pluginerrs.ProvideErrorTracker() loaderLoader := loader.ProvideService(pluginManagementCfg, discovery, bootstrap, validate, initialize, terminate, errorRegistry) - pluginstoreService, err := pluginstore.ProvideService(inMemory, sourcesService, loaderLoader, featureToggles) + pluginstoreService, err := pluginstore.ProvideService(inMemory, pluginsourcesService, loaderLoader, featureToggles) if err != nil { return nil, err } @@ -876,7 +876,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive) dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel, configProvider) - dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService) + dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, pluginsourcesService) if err != nil { return nil, err } @@ -1030,7 +1030,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } cacheService := localcache.ProvideService() ossDataSourceRequestValidator := validations.ProvideValidator() - sourcesService := sources.ProvideService(cfg, pluginManagementCfg) + pluginsourcesService := pluginsources.ProvideService(cfg, pluginManagementCfg) discovery := pipeline.ProvideDiscoveryStage(pluginManagementCfg, inMemory) keystoreService := keystore.ProvideService(kvStore) keyRetriever := dynamic.ProvideService(cfg, keystoreService) @@ -1066,7 +1066,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac testdatasourceService := testdatasource.ProvideService() postgresService := postgres.ProvideService() mysqlService := mysql.ProvideService() - mssqlService := mssql.ProvideService(cfg) + mssqlService := mssql.ProvideService() entityEventsService := store.ProvideEntityEventsService(cfg, sqlStore, featureToggles) configProvider, err := configprovider.ProvideService(cfg) if err != nil { @@ -1240,7 +1240,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } errorRegistry := pluginerrs.ProvideErrorTracker() loaderLoader := loader.ProvideService(pluginManagementCfg, discovery, bootstrap, validate, initialize, terminate, errorRegistry) - pluginstoreService, err := pluginstore.ProvideService(inMemory, sourcesService, loaderLoader, featureToggles) + pluginstoreService, err := pluginstore.ProvideService(inMemory, pluginsourcesService, loaderLoader, featureToggles) if err != nil { return nil, err } @@ -1538,7 +1538,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive) dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel, configProvider) - dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService) + dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, pluginsourcesService) if err != nil { return nil, err } diff --git a/pkg/services/pluginsintegration/dashboards/filestore.go b/pkg/services/pluginsintegration/dashboards/filestore.go index e854e6e1340..9e1545ed051 100644 --- a/pkg/services/pluginsintegration/dashboards/filestore.go +++ b/pkg/services/pluginsintegration/dashboards/filestore.go @@ -7,7 +7,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" - "github.com/grafana/grafana/pkg/util" ) var _ FileStore = (*FileStoreManager)(nil) @@ -87,7 +86,7 @@ func (m *FileStoreManager) GetPluginDashboardFileContents(ctx context.Context, a return nil, errors.New("plugin dashboard file not found") } - cleanPath, err := util.CleanRelativePath(includedFile.Path) + cleanPath, err := plugins.CleanRelativePath(includedFile.Path) if err != nil { // CleanRelativePath should clean and make the path relative so this is not expected to fail return nil, err diff --git a/pkg/services/pluginsintegration/pipeline/steps_test.go b/pkg/services/pluginsintegration/pipeline/steps_test.go index 009d489d2ba..4c9cf07e577 100644 --- a/pkg/services/pluginsintegration/pipeline/steps_test.go +++ b/pkg/services/pluginsintegration/pipeline/steps_test.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/manager/registry" - "github.com/grafana/grafana/pkg/setting" ) func TestSkipPlugins(t *testing.T) { @@ -68,7 +67,7 @@ func TestAsExternal(t *testing.T) { t.Run("should skip a core plugin", func(t *testing.T) { cfg := &config.PluginManagementCfg{ - PluginSettings: setting.PluginSettings{ + PluginSettings: config.PluginSettings{ "plugin1": map[string]string{ "as_external": "true", }, diff --git a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go index 28f9d015fff..192717c34ff 100644 --- a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go +++ b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go @@ -17,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" - "github.com/grafana/grafana/pkg/setting" ) func TestService_Calculate(t *testing.T) { @@ -31,7 +30,7 @@ func TestService_Calculate(t *testing.T) { tcs := []struct { name string - pluginSettings setting.PluginSettings + pluginSettings config.PluginSettings plugin pluginstore.Plugin expected plugins.LoadingStrategy }{ @@ -81,7 +80,7 @@ func TestService_Calculate(t *testing.T) { }, { name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is configured as CDN enabled and plugin is not angular", - pluginSettings: setting.PluginSettings{ + pluginSettings: config.PluginSettings{ "parent-datasource": { "cdn": "true", }, @@ -94,7 +93,7 @@ func TestService_Calculate(t *testing.T) { }, { name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is configured as CDN enabled and plugin is angular", - pluginSettings: setting.PluginSettings{ + pluginSettings: config.PluginSettings{ "parent-datasource": { "cdn": "true", }, @@ -107,7 +106,7 @@ func TestService_Calculate(t *testing.T) { }, { name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is not configured as CDN enabled and plugin is angular", - pluginSettings: setting.PluginSettings{}, + pluginSettings: config.PluginSettings{}, plugin: newPlugin(pluginID, withAngular(true), withFS(plugins.NewFakeFS()), func(p pluginstore.Plugin) pluginstore.Plugin { p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"} return p @@ -375,9 +374,9 @@ func TestService_ModuleHash(t *testing.T) { } t.Run(tc.name, func(t *testing.T) { - var pluginSettings setting.PluginSettings + var pluginSettings config.PluginSettings if tc.cdn { - pluginSettings = setting.PluginSettings{ + pluginSettings = config.PluginSettings{ pluginID: { "cdn": "true", }, @@ -412,7 +411,7 @@ func TestService_ModuleHash(t *testing.T) { func TestService_ModuleHash_Cache(t *testing.T) { pCfg := &config.PluginManagementCfg{ - PluginSettings: setting.PluginSettings{}, + PluginSettings: config.PluginSettings{}, Features: config.Features{SriChecksEnabled: true}, } svc := ProvideService( @@ -448,7 +447,7 @@ func TestService_ModuleHash_Cache(t *testing.T) { pCfg = &config.PluginManagementCfg{ PluginsCDNURLTemplate: "https://cdn.grafana.com", - PluginSettings: setting.PluginSettings{ + PluginSettings: config.PluginSettings{ pluginID: { "cdn": "true", }, @@ -577,14 +576,14 @@ func withClass(class plugins.Class) func(p pluginstore.Plugin) pluginstore.Plugi } } -func newCfg(ps setting.PluginSettings) *config.PluginManagementCfg { +func newCfg(ps config.PluginSettings) *config.PluginManagementCfg { return &config.PluginManagementCfg{ PluginSettings: ps, } } -func newPluginSettings(pluginID string, kv map[string]string) setting.PluginSettings { - return setting.PluginSettings{ +func newPluginSettings(pluginID string, kv map[string]string) config.PluginSettings { + return config.PluginSettings{ pluginID: kv, } } diff --git a/pkg/services/pluginsintegration/pluginconfig/config.go b/pkg/services/pluginsintegration/pluginconfig/config.go index 3207c2b3bd4..288f204cd12 100644 --- a/pkg/services/pluginsintegration/pluginconfig/config.go +++ b/pkg/services/pluginsintegration/pluginconfig/config.go @@ -48,7 +48,7 @@ type PluginInstanceCfg struct { Tracing config.Tracing - PluginSettings setting.PluginSettings + PluginSettings config.PluginSettings AWSAllowedAuthProviders []string AWSAssumeRoleEnabled bool @@ -127,8 +127,8 @@ func ProvidePluginInstanceConfig(cfg *setting.Cfg, settingProvider setting.Provi }, nil } -func extractPluginSettings(settingProvider setting.Provider) setting.PluginSettings { - ps := setting.PluginSettings{} +func extractPluginSettings(settingProvider setting.Provider) config.PluginSettings { + ps := config.PluginSettings{} for sectionName, sectionCopy := range settingProvider.Current() { if !strings.HasPrefix(sectionName, "plugin.") { continue diff --git a/pkg/services/pluginsintegration/pluginexternal/check_test.go b/pkg/services/pluginsintegration/pluginexternal/check_test.go index 68cfb68dcfb..9d3316b51d0 100644 --- a/pkg/services/pluginsintegration/pluginexternal/check_test.go +++ b/pkg/services/pluginsintegration/pluginexternal/check_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" @@ -12,7 +13,7 @@ import ( func TestService_validateExternal(t *testing.T) { cfg := setting.NewCfg() - cfg.PluginSettings = setting.PluginSettings{ + cfg.PluginSettings = config.PluginSettings{ "grafana-testdata-datasource": map[string]string{ "as_external": "true", }, diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index 9c0cf4d10c6..387d9884f9a 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -55,6 +55,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsources" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsso" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" @@ -154,8 +155,8 @@ var WireExtensionSet = wire.NewSet( wire.Bind(new(managedplugins.Manager), new(*managedplugins.Noop)), provisionedplugins.NewNoop, wire.Bind(new(provisionedplugins.Manager), new(*provisionedplugins.Noop)), - sources.ProvideService, - wire.Bind(new(sources.Registry), new(*sources.Service)), + pluginsources.ProvideService, + wire.Bind(new(sources.Registry), new(*pluginsources.Service)), checkregistry.ProvideService, wire.Bind(new(checkregistry.CheckService), new(*checkregistry.Service)), pluginassets2.NewLocalProvider, diff --git a/pkg/plugins/manager/sources/sources.go b/pkg/services/pluginsintegration/pluginsources/pluginsources.go similarity index 78% rename from pkg/plugins/manager/sources/sources.go rename to pkg/services/pluginsintegration/pluginsources/pluginsources.go index addeede1dd7..1d47d0cd1aa 100644 --- a/pkg/plugins/manager/sources/sources.go +++ b/pkg/services/pluginsintegration/pluginsources/pluginsources.go @@ -1,4 +1,4 @@ -package sources +package pluginsources import ( "context" @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/setting" ) @@ -27,7 +28,7 @@ func ProvideService(cfg *setting.Cfg, pCcfg *config.PluginManagementCfg) *Servic func (s *Service) List(_ context.Context) []plugins.PluginSource { r := []plugins.PluginSource{ - NewLocalSource( + sources.NewLocalSource( plugins.ClassCore, s.corePluginPaths(), ), @@ -38,7 +39,7 @@ func (s *Service) List(_ context.Context) []plugins.PluginSource { } func (s *Service) externalPluginSources() []plugins.PluginSource { - localSrcs, err := DirAsLocalSources(s.cfg, s.cfg.PluginsPath, plugins.ClassExternal) + localSrcs, err := sources.DirAsLocalSources(s.cfg, s.cfg.PluginsPath, plugins.ClassExternal) if err != nil { s.log.Error("Failed to load external plugins", "error", err) return []plugins.PluginSource{} @@ -53,20 +54,20 @@ func (s *Service) externalPluginSources() []plugins.PluginSource { } func (s *Service) pluginSettingSources() []plugins.PluginSource { - sources := make([]plugins.PluginSource, 0, len(s.cfg.PluginSettings)) + srcs := make([]plugins.PluginSource, 0, len(s.cfg.PluginSettings)) for _, ps := range s.cfg.PluginSettings { path, exists := ps["path"] if !exists || path == "" { continue } if s.cfg.DevMode { - sources = append(sources, NewUnsafeLocalSource(plugins.ClassExternal, []string{path})) + srcs = append(srcs, sources.NewUnsafeLocalSource(plugins.ClassExternal, []string{path})) } else { - sources = append(sources, NewLocalSource(plugins.ClassExternal, []string{path})) + srcs = append(srcs, sources.NewLocalSource(plugins.ClassExternal, []string{path})) } } - return sources + return srcs } // corePluginPaths provides a list of the Core plugin file system paths diff --git a/pkg/plugins/manager/sources/sources_test.go b/pkg/services/pluginsintegration/pluginsources/pluginsources_test.go similarity index 83% rename from pkg/plugins/manager/sources/sources_test.go rename to pkg/services/pluginsintegration/pluginsources/pluginsources_test.go index f56fbffa52b..ba9e77bbfa2 100644 --- a/pkg/plugins/manager/sources/sources_test.go +++ b/pkg/services/pluginsintegration/pluginsources/pluginsources_test.go @@ -1,4 +1,4 @@ -package sources +package pluginsources import ( "context" @@ -9,13 +9,13 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/setting" ) func TestSources_List(t *testing.T) { t.Run("Plugin sources are populated by default and listed in specific order", func(t *testing.T) { - testdata, err := filepath.Abs("../testdata") - require.NoError(t, err) + testdata := testDataDir(t) cfg := &setting.Cfg{ StaticRootPath: testdata, @@ -23,7 +23,7 @@ func TestSources_List(t *testing.T) { pCfg := &config.PluginManagementCfg{ PluginsPath: filepath.Join(testdata, "pluginRootWithDist"), - PluginSettings: setting.PluginSettings{ + PluginSettings: config.PluginSettings{ "foo": map[string]string{ "path": filepath.Join(testdata, "test-app"), }, @@ -41,7 +41,7 @@ func TestSources_List(t *testing.T) { require.Len(t, srcs, 5) require.Equal(t, srcs[0].PluginClass(ctx), plugins.ClassCore) - if localSrc, ok := srcs[0].(*LocalSource); ok { + if localSrc, ok := srcs[0].(*sources.LocalSource); ok { require.Equal(t, localSrc.Paths(), []string{ filepath.Join(testdata, "app", "plugins", "datasource"), filepath.Join(testdata, "app", "plugins", "panel"), @@ -56,7 +56,7 @@ func TestSources_List(t *testing.T) { require.Equal(t, "", sig.SigningOrg) require.Equal(t, srcs[1].PluginClass(ctx), plugins.ClassExternal) - if localSrc, ok := srcs[1].(*LocalSource); ok { + if localSrc, ok := srcs[1].(*sources.LocalSource); ok { require.Equal(t, localSrc.Paths(), []string{ filepath.Join(testdata, "pluginRootWithDist", "datasource"), }) @@ -68,7 +68,7 @@ func TestSources_List(t *testing.T) { require.Equal(t, plugins.Signature{}, sig) require.Equal(t, srcs[2].PluginClass(ctx), plugins.ClassExternal) - if localSrc, ok := srcs[2].(*LocalSource); ok { + if localSrc, ok := srcs[2].(*sources.LocalSource); ok { require.Equal(t, localSrc.Paths(), []string{ filepath.Join(testdata, "pluginRootWithDist", "dist"), }) @@ -80,7 +80,7 @@ func TestSources_List(t *testing.T) { require.Equal(t, plugins.Signature{}, sig) require.Equal(t, srcs[3].PluginClass(ctx), plugins.ClassExternal) - if localSrc, ok := srcs[3].(*LocalSource); ok { + if localSrc, ok := srcs[3].(*sources.LocalSource); ok { require.Equal(t, localSrc.Paths(), []string{ filepath.Join(testdata, "pluginRootWithDist", "panel"), }) @@ -93,9 +93,7 @@ func TestSources_List(t *testing.T) { }) t.Run("Plugin sources are populated with symbolic links", func(t *testing.T) { - testdata, err := filepath.Abs("../testdata") - require.NoError(t, err) - + testdata := testDataDir(t) cfg := &setting.Cfg{ StaticRootPath: testdata, } @@ -113,7 +111,7 @@ func TestSources_List(t *testing.T) { if _, exists := uris[class]; !exists { uris[class] = map[string]struct{}{} } - if localSrc, ok := src.(*LocalSource); ok { + if localSrc, ok := src.(*sources.LocalSource); ok { for _, path := range localSrc.Paths() { uris[class][path] = struct{}{} } @@ -130,3 +128,12 @@ func TestSources_List(t *testing.T) { }, "should include external symlinked plugin") }) } + +func testDataDir(t *testing.T) string { + dir, err := filepath.Abs("../../../plugins/manager/testdata") + if err != nil { + t.Errorf("could not construct absolute path of current dir") + return "" + } + return dir +} diff --git a/pkg/services/pluginsintegration/plugintest/plugins_test.go b/pkg/services/pluginsintegration/plugintest/plugins_test.go index df4a0fd7b75..de1afb67710 100644 --- a/pkg/services/pluginsintegration/plugintest/plugins_test.go +++ b/pkg/services/pluginsintegration/plugintest/plugins_test.go @@ -163,7 +163,7 @@ func TestIntegrationPluginManager(t *testing.T) { td := testdatasource.ProvideService() pg := postgres.ProvideService() my := mysql.ProvideService() - ms := mssql.ProvideService(cfg) + ms := mssql.ProvideService() db := db.InitTestDB(t, sqlstore.InitTestDBOpt{Cfg: cfg}) sv2 := searchV2.ProvideService(cfg, db, nil, nil, tracer, features, nil, nil, nil) graf := grafanads.ProvideService(sv2, nil, features) diff --git a/pkg/services/pluginsintegration/test_helper.go b/pkg/services/pluginsintegration/test_helper.go index d89f9e393ba..018305a71ad 100644 --- a/pkg/services/pluginsintegration/test_helper.go +++ b/pkg/services/pluginsintegration/test_helper.go @@ -24,12 +24,12 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" - "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/pluginassets" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsources" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" "github.com/grafana/grafana/pkg/setting" @@ -64,7 +64,7 @@ func CreateIntegrationTestCtx(t *testing.T, cfg *setting.Cfg, coreRegistry *core Terminator: term, }) - ps, err := pluginstore.NewPluginStoreForTest(reg, l, sources.ProvideService(cfg, pCfg)) + ps, err := pluginstore.NewPluginStoreForTest(reg, l, pluginsources.ProvideService(cfg, pCfg)) require.NoError(t, err) return &IntegrationTestCtx{ diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 995a01ad825..aafc9167ce4 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -29,6 +29,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/osutil" ) @@ -208,7 +209,7 @@ type Cfg struct { // Plugins PluginsEnableAlpha bool PluginsAppsSkipVerifyTLS bool - PluginSettings PluginSettings + PluginSettings config.PluginSettings PluginsAllowUnsigned []string PluginCatalogURL string PluginCatalogHiddenPlugins []string diff --git a/pkg/setting/setting_plugins.go b/pkg/setting/setting_plugins.go index 5a83c3b1f5a..a901f35088b 100644 --- a/pkg/setting/setting_plugins.go +++ b/pkg/setting/setting_plugins.go @@ -7,6 +7,7 @@ import ( "gopkg.in/ini.v1" + "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/util" ) @@ -15,11 +16,8 @@ const ( PluginUpdateStrategyMinor = "minor" ) -// PluginSettings maps plugin id to map of key/value settings. -type PluginSettings map[string]map[string]string - -func extractPluginSettings(sections []*ini.Section) PluginSettings { - psMap := PluginSettings{} +func extractPluginSettings(sections []*ini.Section) config.PluginSettings { + psMap := config.PluginSettings{} for _, section := range sections { sectionName := section.Name() if !strings.HasPrefix(sectionName, "plugin.") { diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index 77de0b82603..1b286684747 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -13,7 +13,7 @@ import ( _ "github.com/microsoft/go-mssqldb/integratedauth/krb5" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/mssql/sqleng" ) @@ -22,10 +22,10 @@ type Service struct { logger log.Logger } -func ProvideService(cfg *setting.Cfg) *Service { +func ProvideService() *Service { logger := backend.NewLoggerWith("logger", "tsdb.mssql") return &Service{ - im: datasource.NewInstanceManager(NewInstanceSettings(cfg, logger)), + im: datasource.NewInstanceManager(NewInstanceSettings(logger)), logger: logger, } } @@ -48,7 +48,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return dsHandler.QueryData(azusercontext.WithUserFromQueryReq(ctx, req), req) } -func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.InstanceFactoryFunc { +func NewInstanceSettings(logger log.Logger) datasource.InstanceFactoryFunc { return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { grafCfg := backend.GrafanaConfigFromContext(ctx) sqlCfg, err := grafCfg.SQL() diff --git a/pkg/tsdb/mssql/standalone/main.go b/pkg/tsdb/mssql/standalone/main.go index a4fafe05f1a..3f28d9cc131 100644 --- a/pkg/tsdb/mssql/standalone/main.go +++ b/pkg/tsdb/mssql/standalone/main.go @@ -6,7 +6,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/mssql" ) @@ -20,8 +20,7 @@ func main() { // ID). When datasource configuration changed Dispose method will be called and // new datasource instance created using NewSampleDatasource factory. logger := backend.NewLoggerWith("logger", "tsdb.mssql") - cfg := setting.NewCfg() - if err := datasource.Manage("mssql", mssql.NewInstanceSettings(cfg, logger), datasource.ManageOpts{}); err != nil { + if err := datasource.Manage("mssql", mssql.NewInstanceSettings(logger), datasource.ManageOpts{}); err != nil { log.DefaultLogger.Error(err.Error()) os.Exit(1) } diff --git a/pkg/util/proxyutil/proxyutil.go b/pkg/util/proxyutil/proxyutil.go index eeb640c0854..df4f48be970 100644 --- a/pkg/util/proxyutil/proxyutil.go +++ b/pkg/util/proxyutil/proxyutil.go @@ -8,6 +8,7 @@ import ( "strings" claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" ) @@ -91,12 +92,6 @@ func ClearCookieHeader(req *http.Request, keepCookiesNames []string, skipCookies } } -// SetProxyResponseHeaders sets proxy response headers. -// Sets Content-Security-Policy: sandbox -func SetProxyResponseHeaders(header http.Header) { - header.Set("Content-Security-Policy", "sandbox") -} - // SetViaHeader adds Grafana's reverse proxy to the proxy chain. // Defined in RFC 9110 7.6.3 https://datatracker.ietf.org/doc/html/rfc9110#name-via func SetViaHeader(header http.Header, major, minor int) { diff --git a/pkg/util/proxyutil/reverse_proxy.go b/pkg/util/proxyutil/reverse_proxy.go index d077d792251..c3c5c942a13 100644 --- a/pkg/util/proxyutil/reverse_proxy.go +++ b/pkg/util/proxyutil/reverse_proxy.go @@ -11,6 +11,7 @@ import ( glog "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/middleware/requestmeta" + "github.com/grafana/grafana/pkg/plugins/manager/client" "github.com/grafana/grafana/pkg/services/contexthandler" ) @@ -102,7 +103,7 @@ func modifyResponse(logger glog.Logger) func(resp *http.Response) error { resp.Header.Del(header) } - SetProxyResponseHeaders(resp.Header) + client.SetCSPHeader(resp.Header) SetViaHeader(resp.Header, resp.ProtoMajor, resp.ProtoMinor) requestmeta.WithStatusSource(resp.Request.Context(), resp.StatusCode) From 872ed5dc9d72418591614bcf9e7fd9479d562d13 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Mon, 5 Jan 2026 13:17:51 +0100 Subject: [PATCH 03/79] Alerting: Add plugins:hide backend rule search parameter (#113706) Alerting: Add plugins filter to list rules API --- .../ngalert/api/api_prometheus_test.go | 75 +++++++++++++++- .../ngalert/api/prometheus/api_prometheus.go | 45 ++++++---- pkg/services/ngalert/models/alert_rule.go | 14 ++- pkg/services/ngalert/store/alert_rule.go | 43 ++++++++- .../ngalert/store/alert_rule_labels.go | 19 +++- .../ngalert/store/alert_rule_labels_test.go | 90 +++++++++++++++++++ pkg/services/ngalert/store/alert_rule_test.go | 48 ++++++++++ pkg/services/ngalert/store/json.go | 28 +++++- pkg/services/ngalert/store/json_test.go | 40 ++++++++- pkg/services/ngalert/tests/fakes/rules.go | 32 +++++++ pkg/tests/api/alerting/api_prometheus_test.go | 81 +++++++++++++++++ pkg/tests/api/alerting/api_ruler_test.go | 82 +++++++++++------ .../alerting/unified/api/prometheusApi.ts | 3 + .../rule-list/hooks/grafanaFilter.test.ts | 32 ++++--- .../unified/rule-list/hooks/grafanaFilter.ts | 3 +- 15 files changed, 567 insertions(+), 68 deletions(-) diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index dc0f6d1f13d..4fdd8e08b92 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -805,9 +805,20 @@ func TestRouteGetRuleStatuses(t *testing.T) { OrgID: orgID, })).GenerateManyRef(3) + // Plugin rule with __grafana_origin label for plugins filter test + pluginRule := gen.With( + gen.WithGroupKey(ngmodels.AlertRuleGroupKey{ + RuleGroup: "plugins-test-plugin", + NamespaceUID: "folder-2", + OrgID: orgID, + }), + gen.WithLabels(map[string]string{"__grafana_origin": "plugin/grafana-slo-app"}), + ).GenerateRef() + ruleStore.PutRule(context.Background(), rulesInGroup1...) ruleStore.PutRule(context.Background(), rulesInGroup2...) ruleStore.PutRule(context.Background(), rulesInGroup3...) + ruleStore.PutRule(context.Background(), pluginRule) api := NewPrometheusSrv( log.NewNopLogger(), @@ -818,7 +829,7 @@ func TestRouteGetRuleStatuses(t *testing.T) { fakes.NewFakeProvisioningStore(), ) - permissions := createPermissionsForRules(slices.Concat(rulesInGroup1, rulesInGroup2, rulesInGroup3), orgID) + permissions := createPermissionsForRules(slices.Concat(rulesInGroup1, rulesInGroup2, rulesInGroup3, []*ngmodels.AlertRule{pluginRule}), orgID) user := &user.SignedInUser{ OrgID: orgID, Permissions: permissions, @@ -948,6 +959,68 @@ func TestRouteGetRuleStatuses(t *testing.T) { require.Equal(t, expectedRuleInGroup3.UID, result.Data.RuleGroups[0].Rules[0].UID) } }) + + t.Run("should filter rules by plugins parameter", func(t *testing.T) { + t.Run("returns all groups when plugins filter not specified", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?folder_uid=folder-2", nil) + require.NoError(t, err) + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 2, "should return all groups including plugin group") + }) + + t.Run("excludes plugin rules when plugins=hide", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?folder_uid=folder-2&plugins=hide", nil) + require.NoError(t, err) + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 1, "should only return non-plugin groups") + for _, group := range result.Data.RuleGroups { + require.NotEqual(t, "plugins-test-plugin", group.Name, "should not include plugin group") + } + }) + + t.Run("returns only plugin rules when plugins=only", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?folder_uid=folder-2&plugins=only", nil) + require.NoError(t, err) + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 1, "should only return plugin group") + require.Equal(t, "plugins-test-plugin", result.Data.RuleGroups[0].Name) + }) + + t.Run("returns all groups when plugins filter has invalid value", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?folder_uid=folder-2&plugins=invalid", nil) + require.NoError(t, err) + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 2, "invalid value should return all groups") + }) + }) }) t.Run("when requesting rules with pagination", func(t *testing.T) { diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index 077761d0caf..16efa84fe26 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -452,17 +452,18 @@ type paginationContext struct { alertStateMutator RuleAlertStateMutator // Query parameters - namespaceUIDs []string - ruleUIDs []string - dashboardUID string - panelID int64 - ruleGroups []string - receiverName string - dataSourceUIDs []string - title string - searchRuleGroup string - ruleType ngmodels.RuleTypeFilter - ruleNamesSet map[string]struct{} + namespaceUIDs []string + ruleUIDs []string + dashboardUID string + panelID int64 + ruleGroups []string + receiverName string + dataSourceUIDs []string + title string + searchRuleGroup string + ruleType ngmodels.RuleTypeFilter + pluginOriginFilter ngmodels.PluginOriginFilter + ruleNamesSet map[string]struct{} // Filters stateFilterSet map[eval.State]struct{} @@ -508,11 +509,12 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert SearchRuleGroup: ctx.searchRuleGroup, LabelMatchers: storeMatchers, }, - RuleType: ctx.ruleType, - Limit: remainingGroups, - RuleLimit: remainingRules, - ContinueToken: token, - Compact: ctx.compact, + RuleType: ctx.ruleType, + PluginOriginFilter: ctx.pluginOriginFilter, + Limit: remainingGroups, + RuleLimit: remainingRules, + ContinueToken: token, + Compact: ctx.compact, } ruleList, newToken, err := store.ListAlertRulesByGroup(ctx.opts.Ctx, &byGroupQuery) @@ -668,6 +670,7 @@ func paginateRuleGroups(log log.Logger, store ListAlertRulesStoreV2, ctx *pagina return allGroups, rulesTotals, continueToken, nil } +// nolint:gocyclo func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceStore ProvenanceStore) apimodels.RuleResponse { ctx, span := tracer.Start(opts.Ctx, "api.prometheus.PrepareRuleGroupStatusesV2") defer span.End() @@ -825,6 +828,15 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ruleType = ngmodels.RuleTypeFilterAll } + pluginOriginFilter := ngmodels.PluginOriginFilterNone + switch opts.Query.Get("plugins") { + case "hide": + pluginOriginFilter = ngmodels.PluginOriginFilterHide + case "only": + pluginOriginFilter = ngmodels.PluginOriginFilterOnly + } + span.SetAttributes(attribute.String("plugins_filter", string(pluginOriginFilter))) + // Pagination limits // // group_limit: Maximum number of rule groups to return @@ -874,6 +886,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt dataSourceUIDs: dataSourceUIDs, searchRuleGroup: searchRuleGroup, ruleType: ruleType, + pluginOriginFilter: pluginOriginFilter, ruleNamesSet: ruleNamesSet, stateFilterSet: stateFilterSet, healthFilterSet: healthFilterSet, diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 7d6e8a1fab5..4b59fcfa164 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -171,6 +171,9 @@ const ( // AutogeneratedRouteSettingsHashLabel a label name that contains the hash of the notification settings that will be used to send notifications for the alert. // This should uniquely identify the notification settings (group_by, group_wait, group_interval, repeat_interval, mute_time_intervals) for the alert. AutogeneratedRouteSettingsHashLabel = "__grafana_route_settings_hash__" + + // PluginGrafanaOriginLabel is a label that indicates that the alert rule originated from a plugin. + PluginGrafanaOriginLabel = "__grafana_origin" ) const ( @@ -966,6 +969,14 @@ const ( RuleTypeFilterRecording ) +type PluginOriginFilter string + +const ( + PluginOriginFilterNone PluginOriginFilter = "" // Show all (default) + PluginOriginFilterHide PluginOriginFilter = "hide" // Exclude plugin rules + PluginOriginFilterOnly PluginOriginFilter = "only" // Only plugin rules +) + type GroupCursor struct { NamespaceUID string `json:"n"` RuleGroup string `json:"g"` @@ -1026,7 +1037,8 @@ type ListAlertRulesQuery struct { type ListAlertRulesExtendedQuery struct { ListAlertRulesQuery - RuleType RuleTypeFilter + RuleType RuleTypeFilter + PluginOriginFilter PluginOriginFilter Limit int64 RuleLimit int64 diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 30856587794..225499dc1d3 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -30,11 +30,13 @@ import ( "github.com/grafana/grafana/pkg/util" ) -// AlertRuleMaxTitleLength is the maximum length of the alert rule title -const AlertRuleMaxTitleLength = 190 +const ( + // AlertRuleMaxTitleLength is the maximum length of the alert rule title + AlertRuleMaxTitleLength = 190 -// AlertRuleMaxRuleGroupNameLength is the maximum length of the alert rule group name -const AlertRuleMaxRuleGroupNameLength = 190 + // AlertRuleMaxRuleGroupNameLength is the maximum length of the alert rule group name + AlertRuleMaxRuleGroupNameLength = 190 +) var ( ErrOptimisticLock = errors.New("version conflict while updating a record in the database with optimistic locking") @@ -970,6 +972,13 @@ func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.Lis } } + if query.PluginOriginFilter != ngmodels.PluginOriginFilterNone { + q, err = st.filterByPluginOrigin(query.PluginOriginFilter, q) + if err != nil { + return nil, groupsSet, err + } + } + // FIXME: record is nullable but we don't save it as null when it's nil switch query.RuleType { case ngmodels.RuleTypeFilterAlerting: @@ -1427,6 +1436,32 @@ func (st DBstore) filterByLabelMatchers(matchers labels.Matchers, sess *xorm.Ses return sess, nil } +// filterByPluginOrigin adds filtering for plugin-originated rules based on the __grafana_origin label. +func (st DBstore) filterByPluginOrigin(filter ngmodels.PluginOriginFilter, sess *xorm.Session) (*xorm.Session, error) { + if filter == ngmodels.PluginOriginFilterNone { + return sess, nil + } + + var sql string + var args []any + var err error + + switch filter { + case ngmodels.PluginOriginFilterHide: + sql, args, err = buildLabelKeyMissingCondition(st.SQLStore.GetDialect(), "labels", ngmodels.PluginGrafanaOriginLabel) + case ngmodels.PluginOriginFilterOnly: + sql, args, err = buildLabelKeyExistsCondition(st.SQLStore.GetDialect(), "labels", ngmodels.PluginGrafanaOriginLabel) + default: + return nil, fmt.Errorf("unknown plugin origin filter %q", filter) + } + + if err != nil { + return nil, err + } + + return sess.And(sql, args...), nil +} + func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string, validateProvenance func(ngmodels.Provenance) bool, dryRun bool) ([]ngmodels.AlertRuleKey, []ngmodels.AlertRuleKey, error) { // fetch entire rules because Update method requires it because it copies rules to version table rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{ diff --git a/pkg/services/ngalert/store/alert_rule_labels.go b/pkg/services/ngalert/store/alert_rule_labels.go index 721071d5219..9c35d194d37 100644 --- a/pkg/services/ngalert/store/alert_rule_labels.go +++ b/pkg/services/ngalert/store/alert_rule_labels.go @@ -18,6 +18,20 @@ func buildLabelMatcherCondition(dialect migrator.Dialect, column string, m *labe return buildLabelMatcherJSON(dialect, column, m) } +func buildLabelKeyExistsCondition(dialect migrator.Dialect, column string, key string) (string, []any, error) { + if dialect.DriverName() == migrator.SQLite { + return globKeyExists(column, key) + } + return jsonKeyExists(dialect, column, key) +} + +func buildLabelKeyMissingCondition(dialect migrator.Dialect, column string, key string) (string, []any, error) { + if dialect.DriverName() == migrator.SQLite { + return globKeyMissing(column, key) + } + return jsonKeyMissing(dialect, column, key) +} + func buildLabelMatcherGlob(column string, m *labels.Matcher) (string, []any, error) { switch { case m.Type == labels.MatchEqual && m.Value == "": @@ -37,7 +51,10 @@ func buildLabelMatcherJSON(dialect migrator.Dialect, column string, m *labels.Ma switch { case m.Type == labels.MatchEqual && m.Value == "": eqSQL, eqArgs := jsonEquals(dialect, column, m.Name, "") - missingSQL, missingArgs := jsonKeyMissing(dialect, column, m.Name) + missingSQL, missingArgs, err := jsonKeyMissing(dialect, column, m.Name) + if err != nil { + return "", nil, err + } return "(" + eqSQL + " OR " + missingSQL + ")", append(eqArgs, missingArgs...), nil case m.Type == labels.MatchEqual: sql, args := jsonEquals(dialect, column, m.Name, m.Value) diff --git a/pkg/services/ngalert/store/alert_rule_labels_test.go b/pkg/services/ngalert/store/alert_rule_labels_test.go index 2006c49fd7a..1e6eb2b04e6 100644 --- a/pkg/services/ngalert/store/alert_rule_labels_test.go +++ b/pkg/services/ngalert/store/alert_rule_labels_test.go @@ -134,3 +134,93 @@ func TestBuildLabelMatcherJSON(t *testing.T) { }) } } + +func TestBuildLabelKeyExistsCondition(t *testing.T) { + tests := []struct { + name string + dialect migrator.Dialect + column string + key string + wantSQL string + wantArgs []any + }{ + { + name: "MySQL", + dialect: migrator.NewMysqlDialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NOT NULL", + wantArgs: []any{"__grafana_origin"}, + }, + { + name: "PostgreSQL", + dialect: migrator.NewPostgresDialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NOT NULL", + wantArgs: []any{"__grafana_origin"}, + }, + { + name: "SQLite", + dialect: migrator.NewSQLite3Dialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "labels GLOB ?", + wantArgs: []any{`*"__grafana_origin":*`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args, err := buildLabelKeyExistsCondition(tt.dialect, tt.column, tt.key) + require.NoError(t, err) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestBuildLabelKeyMissingCondition(t *testing.T) { + tests := []struct { + name string + dialect migrator.Dialect + column string + key string + wantSQL string + wantArgs []any + }{ + { + name: "MySQL", + dialect: migrator.NewMysqlDialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL", + wantArgs: []any{"__grafana_origin"}, + }, + { + name: "PostgreSQL", + dialect: migrator.NewPostgresDialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL", + wantArgs: []any{"__grafana_origin"}, + }, + { + name: "SQLite", + dialect: migrator.NewSQLite3Dialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "labels NOT GLOB ?", + wantArgs: []any{`*"__grafana_origin":*`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args, err := buildLabelKeyMissingCondition(tt.dialect, tt.column, tt.key) + require.NoError(t, err) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index f7497c7b05a..e38a7052dff 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -2592,6 +2592,54 @@ func TestIntegration_ListAlertRules(t *testing.T) { require.ErrorContains(t, err, "is not supported") }) }) + + t.Run("filter by PluginOriginFilter", func(t *testing.T) { + sqlStore := db.InitTestDB(t) + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b) + testOrgID := int64(12345) + testRuleGen := ruleGen.With(models.RuleMuts.WithOrgID(testOrgID)) + + regularRule := createRule(t, store, testRuleGen) + pluginRule := createRule(t, store, testRuleGen.With( + models.RuleMuts.WithLabel(models.PluginGrafanaOriginLabel, "plugin/grafana-slo-app"), + )) + + tc := []struct { + name string + filter models.PluginOriginFilter + expectedRules []*models.AlertRule + }{ + { + name: "should return all rules when PluginOriginFilterNone", + filter: models.PluginOriginFilterNone, + expectedRules: []*models.AlertRule{regularRule, pluginRule}, + }, + { + name: "should filter out plugin rules when PluginOriginFilterHide", + filter: models.PluginOriginFilterHide, + expectedRules: []*models.AlertRule{regularRule}, + }, + { + name: "should return only plugin rules when PluginOriginFilterOnly", + filter: models.PluginOriginFilterOnly, + expectedRules: []*models.AlertRule{pluginRule}, + }, + } + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + query := &models.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: models.ListAlertRulesQuery{ + OrgID: testOrgID, + }, + PluginOriginFilter: tt.filter, + } + result, _, err := store.ListAlertRulesByGroup(context.Background(), query) + require.NoError(t, err) + require.ElementsMatch(t, tt.expectedRules, result) + }) + } + }) } func TestIntegration_ListAlertRulesPaginated(t *testing.T) { diff --git a/pkg/services/ngalert/store/json.go b/pkg/services/ngalert/store/json.go index e0c71975a76..ac38d2f4ca5 100644 --- a/pkg/services/ngalert/store/json.go +++ b/pkg/services/ngalert/store/json.go @@ -34,14 +34,26 @@ func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string, return fmt.Sprintf("(%s IS NULL OR %s != ?)", jx, jx), []any{key, key, value} } -func jsonKeyMissing(dialect migrator.Dialect, column, key string) (string, []any) { +func jsonKeyMissing(dialect migrator.Dialect, column, key string) (string, []any, error) { + return jsonKeyCondition(dialect, column, key, false) +} + +func jsonKeyExists(dialect migrator.Dialect, column, key string) (string, []any, error) { + return jsonKeyCondition(dialect, column, key, true) +} + +func jsonKeyCondition(dialect migrator.Dialect, column, key string, exists bool) (string, []any, error) { + nullCheck := "IS NULL" + if exists { + nullCheck = "IS NOT NULL" + } switch dialect.DriverName() { case migrator.MySQL: - return fmt.Sprintf("JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)) IS NULL", column), []any{key} + return fmt.Sprintf("JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)) %s", column, nullCheck), []any{key}, nil case migrator.Postgres: - return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) IS NULL", column), []any{key} + return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) %s", column, nullCheck), []any{key}, nil default: - return "", nil + return "", nil, fmt.Errorf("unsupported dialect for JSON key condition: %s", dialect.DriverName()) } } @@ -71,6 +83,14 @@ func globKeyMissing(column, key string) (string, []any, error) { return column + " NOT GLOB ?", []any{"*" + pattern + "*"}, nil } +func globKeyExists(column, key string) (string, []any, error) { + pattern, err := buildGlobKeyPattern(key) + if err != nil { + return "", nil, err + } + return column + " GLOB ?", []any{"*" + pattern + "*"}, nil +} + // Search for `"key":"value"` func buildGlobPattern(key, value string) (string, error) { keyJSON, err := json.Marshal(key) diff --git a/pkg/services/ngalert/store/json_test.go b/pkg/services/ngalert/store/json_test.go index d09d3741c4b..93ca1531f61 100644 --- a/pkg/services/ngalert/store/json_test.go +++ b/pkg/services/ngalert/store/json_test.go @@ -114,7 +114,45 @@ func TestJsonKeyMissing(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - sql, args := jsonKeyMissing(tt.dialect, tt.column, tt.key) + sql, args, err := jsonKeyMissing(tt.dialect, tt.column, tt.key) + require.NoError(t, err) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestJsonKeyExists(t *testing.T) { + tests := []struct { + name string + dialect migrator.Dialect + column string + key string + wantSQL string + wantArgs []any + }{ + { + name: "MySQL", + dialect: migrator.NewMysqlDialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NOT NULL", + wantArgs: []any{"__grafana_origin"}, + }, + { + name: "PostgreSQL", + dialect: migrator.NewPostgresDialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NOT NULL", + wantArgs: []any{"__grafana_origin"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args, err := jsonKeyExists(tt.dialect, tt.column, tt.key) + require.NoError(t, err) require.Equal(t, tt.wantSQL, sql) require.Equal(t, tt.wantArgs, args) }) diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index f4b5afb282d..654eaac471d 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -228,6 +228,9 @@ func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlert return nil, "", err } + // Filter by PluginOriginFilter if specified + ruleList = applyPluginOriginFilter(ruleList, q.PluginOriginFilter) + // < group limit logic > // sort rules to ensure order is consistent, pagination depends on this @@ -304,6 +307,10 @@ func (f *RuleStore) ListAlertRulesPaginated(_ context.Context, q *models.ListAle if err != nil { return nil, "", err } + + // Filter by PluginOriginFilter if specified + rules = applyPluginOriginFilter(rules, q.PluginOriginFilter) + return rules, "", nil } @@ -648,3 +655,28 @@ func (f *RuleStore) ListDeletedRules(_ context.Context, orgID int64) ([]*models. } return f.Deleted[orgID], nil } + +// applyPluginOriginFilter filters rules based on the presence of the __grafana_origin label. +func applyPluginOriginFilter(rules []*models.AlertRule, filter models.PluginOriginFilter) []*models.AlertRule { + if filter == models.PluginOriginFilterNone { + return rules + } + + filteredList := make([]*models.AlertRule, 0, len(rules)) + for _, r := range rules { + _, hasOriginLabel := r.Labels[models.PluginGrafanaOriginLabel] + switch filter { + case models.PluginOriginFilterHide: + if !hasOriginLabel { + filteredList = append(filteredList, r) + } + case models.PluginOriginFilterOnly: + if hasOriginLabel { + filteredList = append(filteredList, r) + } + default: + filteredList = append(filteredList, r) + } + } + return filteredList +} diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index 067372a3470..1695e267903 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -910,6 +910,87 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) { } } +func TestIntegrationPrometheusPluginsFilter(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Password: "password", + Login: "grafana", + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") + + apiClient.CreateFolder(t, "folder1", "folder1") + + // Create a regular alert rule + createRule(t, apiClient, "folder1", withRuleGroup("group1")) + // Create a rule from plugin + createRule(t, apiClient, "folder1", withRuleGroup("group2"), withLabels(map[string]string{"__grafana_origin": "plugin/grafana-slo-app"})) + + verifyRulesResponse := func(t *testing.T, b []byte, expectedGroupName string, shouldHaveOriginLabel bool) { + t.Helper() + + var result apimodels.RuleResponse + require.NoError(t, json.Unmarshal(b, &result)) + require.Equal(t, "success", result.Status) + + require.Len(t, result.Data.RuleGroups, 1) + group := result.Data.RuleGroups[0] + require.Equal(t, expectedGroupName, group.Name) + + require.Len(t, group.Rules, 1) + rule := group.Rules[0] + _, hasOriginLabel := rule.Labels.Map()["__grafana_origin"] + require.Equal(t, shouldHaveOriginLabel, hasOriginLabel) + } + + t.Run("plugins=hide returns only non-plugin rules", func(t *testing.T) { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?plugins=hide", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + require.Equal(t, http.StatusOK, resp.StatusCode) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + verifyRulesResponse(t, b, "group1", false) + }) + + t.Run("plugins=only returns only plugin rules", func(t *testing.T) { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?plugins=only", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + require.Equal(t, http.StatusOK, resp.StatusCode) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + verifyRulesResponse(t, b, "group2", true) + }) +} + func TestIntegrationPrometheusRulesPermissions(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index a05829976f4..f25fb859299 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -5105,43 +5105,71 @@ func rulesNamespaceWithoutVariableValues(t *testing.T, b []byte) (string, map[st return string(json), m } -func createRule(t *testing.T, client apiClient, folder string) (apimodels.PostableRuleGroupConfig, string) { +type ruleOption func(*ruleConfig) + +type ruleConfig struct { + rule *apimodels.PostableExtendedRuleNode + groupName string +} + +func withLabels(labels map[string]string) ruleOption { + return func(cfg *ruleConfig) { + cfg.rule.Labels = labels + } +} + +func withRuleGroup(groupName string) ruleOption { + return func(cfg *ruleConfig) { + cfg.groupName = groupName + } +} + +func createRule(t *testing.T, client apiClient, folder string, opts ...ruleOption) (apimodels.PostableRuleGroupConfig, string) { t.Helper() interval, err := model.ParseDuration("1m") require.NoError(t, err) doubleInterval := 2 * interval - rules := apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Interval: interval, - Rules: []apimodels.PostableExtendedRuleNode{ - { - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &doubleInterval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: fmt.Sprintf("rule under folder %s", folder), - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, + + rule := apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &doubleInterval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: fmt.Sprintf("rule under folder %s", folder), + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), }, }, }, } + + cfg := &ruleConfig{ + rule: &rule, + groupName: "arulegroup", + } + for _, opt := range opts { + opt(cfg) + } + + rules := apimodels.PostableRuleGroupConfig{ + Name: cfg.groupName, + Interval: interval, + Rules: []apimodels.PostableExtendedRuleNode{*cfg.rule}, + } resp, status, _ := client.PostRulesGroupWithStatus(t, folder, &rules, false) require.Equal(t, http.StatusAccepted, status) require.Len(t, resp.Created, 1) diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index 12fa3aae223..9432da368b6 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -48,6 +48,7 @@ export type GrafanaPromRulesOptions = Omit ({ url: `api/prometheus/grafana/api/v1/rules`, params: { @@ -123,6 +125,7 @@ export const prometheusApi = alertingApi.injectEndpoints({ 'search.rule_group': searchGroupName, dashboard_uid: dashboardUid, rule_matcher: ruleMatchers, + plugins: plugins, }, }), providesTags: (_result, _error, { folderUid, groupName, ruleName }) => { diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts index ab9404d4e52..f97b1195a61 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts @@ -429,7 +429,7 @@ describe('grafana-managed rules', () => { ]); }); - it('should still apply other frontend filters', () => { + it('should include plugins in backend filter and skip frontend filtering', () => { // Set up test plugin as installed config.apps[SupportedPlugin.Slo] = pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Slo]); @@ -445,14 +445,15 @@ describe('grafana-managed rules', () => { alerts: [], }); - // Plugins filter should still work on frontend - const { frontendFilter } = getGrafanaFilter(getFilter({ plugins: 'hide' })); + // Plugins filter should be handled by backend + const { backendFilter, frontendFilter } = getGrafanaFilter(getFilter({ plugins: 'hide' })); - // Non-plugin rules should pass through + // Backend filter should include plugins parameter + expect(backendFilter.plugins).toBe('hide'); + + // Frontend filter should pass through all rules (no filtering) expect(frontendFilter.ruleMatches(regularRule)).toBe(true); - - // Plugin-provided rules should be filtered out - expect(frontendFilter.ruleMatches(pluginRule)).toBe(false); + expect(frontendFilter.ruleMatches(pluginRule)).toBe(true); }); }); @@ -808,7 +809,10 @@ describe('grafana-managed rules', () => { it('should return true for client-side only filters', () => { expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true); + }); + + it('should return false for plugins filter (handled by backend when feature toggle is enabled)', () => { + expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(false); }); it('should return false for backend-only filters (state, health, contactPoint)', () => { @@ -831,13 +835,15 @@ describe('grafana-managed rules', () => { expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); - // Should return true for: frontend-handled filters (labels, namespace, plugins) + // Should return true for: frontend-handled filters (labels, namespace) expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); - expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true); + + // plugins is backend-handled when alertingUIUseFullyCompatBackendFilters is enabled + expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(false); }); }); @@ -856,9 +862,11 @@ describe('grafana-managed rules', () => { expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); - // Should return true for: always-frontend filters only (namespace, plugins) + // Should return true for: always-frontend filters only (namespace) expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true); + + // plugins is backend-handled when both feature toggles are enabled + expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(false); // Should return false for: backend-handled filters when both feature toggles are enabled expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false); diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts index 96ee951ee37..e8c4cf3c44a 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts @@ -95,6 +95,7 @@ export function getGrafanaFilter(filterState: Partial) { searchGroupName: groupFilterConfig.groupName ? undefined : normalizedFilterState.groupName, datasources: ruleFilterConfig.dataSourceNames ? undefined : datasourceUids, ruleMatchers: ruleMatchersBackendFilter, + plugins: ruleFilterConfig.plugins ? undefined : normalizedFilterState.plugins, }; return { @@ -128,7 +129,7 @@ function buildGrafanaFilterConfigs() { labels: useBackendFilters ? null : labelsFilter, ruleHealth: null, dashboardUid: useBackendFilters || useFullyCompatibleBackendFilters ? null : dashboardUidFilter, - plugins: pluginsFilter, + plugins: useBackendFilters || useFullyCompatibleBackendFilters ? null : pluginsFilter, contactPoint: null, }; From b4a65ac5ac0d5c89eb6b86f0794e337110a6b7be Mon Sep 17 00:00:00 2001 From: Atharv Mudse <163705624+attu0@users.noreply.github.com> Date: Mon, 5 Jan 2026 18:44:30 +0530 Subject: [PATCH 04/79] Prometheus Dashboards: Use $__rate_interval instead of hardcoded value (#111899) * Prometheus Dashboards: Use __rate_interval #110370 fix(prometheus): use in stats dashboard * Added required changes to F:\grafana\public\app\plugins\datasource\prometheus\dashboards\prometheus_2_stats.json file * removed empty line * removed all steps --- .../src/dashboards/prometheus_2_stats.json | 112 +++++------------- .../dashboards/prometheus_2_stats.json | 112 +++++------------- 2 files changed, 62 insertions(+), 162 deletions(-) diff --git a/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json b/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json index 5a6fbdf8518..063e4af2c8c 100644 --- a/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json +++ b/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json @@ -180,15 +180,12 @@ "pluginVersion": "8.1.0-pre", "targets": [ { - "expr": "sum(irate(prometheus_tsdb_head_samples_appended_total{job=\"prometheus\"}[5m]))", + "expr": "sum(irate(prometheus_tsdb_head_samples_appended_total{job=\"prometheus\"}[$__rate_interval]))", "format": "time_series", "hide": false, - "interval": "", - "intervalFactor": 2, "legendFormat": "samples", "metric": "", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -273,12 +270,9 @@ { "expr": "topk(5, max(scrape_duration_seconds) by (job))", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "{{job}}", "metric": "", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -366,20 +360,15 @@ "expr": "sum(process_resident_memory_bytes{job=\"prometheus\"})", "format": "time_series", "hide": false, - "interval": "", - "intervalFactor": 2, "legendFormat": "p8s process resident memory", - "refId": "D", - "step": 20 + "refId": "D" }, { "expr": "process_virtual_memory_bytes{job=\"prometheus\"}", "format": "time_series", "hide": false, - "intervalFactor": 2, "legendFormat": "virtual memory", - "refId": "C", - "step": 20 + "refId": "C" } ], "timeFrom": null, @@ -454,10 +443,8 @@ { "expr": "prometheus_tsdb_wal_corruptions_total{job=\"prometheus\"}", "format": "time_series", - "intervalFactor": 2, "legendFormat": "", - "refId": "A", - "step": 60 + "refId": "A" } ], "title": "WAL Corruptions", @@ -540,21 +527,15 @@ { "expr": "sum(prometheus_tsdb_head_active_appenders{job=\"prometheus\"})", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "active_appenders", "metric": "", - "refId": "A", - "step": 20 + "refId": "A" }, { "expr": "sum(process_open_fds{job=\"prometheus\"})", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "open_fds", - "refId": "B", - "step": 20 + "refId": "B" } ], "timeFrom": null, @@ -670,10 +651,8 @@ { "expr": "prometheus_tsdb_blocks_loaded{job=\"prometheus\"}", "format": "time_series", - "intervalFactor": 2, "legendFormat": "blocks", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -759,11 +738,8 @@ { "expr": "prometheus_tsdb_head_chunks{job=\"prometheus\"}", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "chunks", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -862,18 +838,14 @@ { "expr": "prometheus_tsdb_head_gc_duration_seconds{job=\"prometheus\",quantile=\"0.99\"}", "format": "time_series", - "intervalFactor": 2, "legendFormat": "duration-p99", - "refId": "A", - "step": 20 + "refId": "A" }, { - "expr": "irate(prometheus_tsdb_head_gc_duration_seconds_count{job=\"prometheus\"}[5m])", + "expr": "irate(prometheus_tsdb_head_gc_duration_seconds_count{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "collections", - "refId": "B", - "step": 20 + "refId": "B" } ], "timeFrom": null, @@ -971,38 +943,29 @@ "pluginVersion": "8.1.0-pre", "targets": [ { - "expr": "histogram_quantile(0.99, sum(rate(prometheus_tsdb_compaction_duration_bucket{job=\"prometheus\"}[5m])) by (le))", + "expr": "histogram_quantile(0.99, sum(rate(prometheus_tsdb_compaction_duration_bucket{job=\"prometheus\"}[$__rate_interval])) by (le))", "format": "time_series", "hide": false, - "interval": "", - "intervalFactor": 2, "legendFormat": "duration-{{p99}}", - "refId": "A", - "step": 20 + "refId": "A" }, { - "expr": "irate(prometheus_tsdb_compactions_total{job=\"prometheus\"}[5m])", + "expr": "irate(prometheus_tsdb_compactions_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "compactions", - "refId": "B", - "step": 20 + "refId": "B" }, { - "expr": "irate(prometheus_tsdb_compactions_failed_total{job=\"prometheus\"}[5m])", + "expr": "irate(prometheus_tsdb_compactions_failed_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "failed", - "refId": "C", - "step": 20 + "refId": "C" }, { - "expr": "irate(prometheus_tsdb_compactions_triggered_total{job=\"prometheus\"}[5m])", + "expr": "irate(prometheus_tsdb_compactions_triggered_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "triggered", - "refId": "D", - "step": 20 + "refId": "D" } ], "timeFrom": null, @@ -1085,21 +1048,17 @@ "pluginVersion": "8.1.0-pre", "targets": [ { - "expr": "rate(prometheus_tsdb_reloads_total{job=\"prometheus\"}[5m])", + "expr": "rate(prometheus_tsdb_reloads_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "reloads", - "refId": "A", - "step": 20 + "refId": "A" }, { - "expr": "rate(prometheus_tsdb_reloads_failures_total{job=\"prometheus\"}[5m])", + "expr": "rate(prometheus_tsdb_reloads_failures_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", "hide": false, - "intervalFactor": 2, "legendFormat": "failures", - "refId": "B", - "step": 20 + "refId": "B" } ], "timeFrom": null, @@ -1184,10 +1143,8 @@ { "expr": "prometheus_engine_query_duration_seconds{job=\"prometheus\", quantile=\"0.99\"}", "format": "time_series", - "intervalFactor": 2, "legendFormat": "{{slice}}_p99", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -1272,11 +1229,8 @@ { "expr": "max(prometheus_rule_group_duration_seconds{job=\"prometheus\"}) by (quantile)", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "{{quantile}}", - "refId": "A", - "step": 10 + "refId": "A" } ], "timeFrom": null, @@ -1359,20 +1313,16 @@ "pluginVersion": "8.1.0-pre", "targets": [ { - "expr": "rate(prometheus_rule_group_iterations_missed_total{job=\"prometheus\"}[5m])", + "expr": "rate(prometheus_rule_group_iterations_missed_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "missed", - "refId": "B", - "step": 10 + "refId": "B" }, { - "expr": "rate(prometheus_rule_group_iterations_total{job=\"prometheus\"}[5m])", + "expr": "rate(prometheus_rule_group_iterations_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "iterations", - "refId": "A", - "step": 10 + "refId": "A" } ], "timeFrom": null, diff --git a/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json b/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json index 57e2bb5e47d..6c9831a5d2f 100644 --- a/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json +++ b/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json @@ -179,15 +179,12 @@ "pluginVersion": "8.1.0-pre", "targets": [ { - "expr": "sum(irate(prometheus_tsdb_head_samples_appended_total{job=\"prometheus\"}[5m]))", + "expr": "sum(irate(prometheus_tsdb_head_samples_appended_total{job=\"prometheus\"}[$__rate_interval]))", "format": "time_series", "hide": false, - "interval": "", - "intervalFactor": 2, "legendFormat": "samples", "metric": "", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -272,12 +269,9 @@ { "expr": "topk(5, max(scrape_duration_seconds) by (job))", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "{{job}}", "metric": "", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -365,20 +359,15 @@ "expr": "sum(process_resident_memory_bytes{job=\"prometheus\"})", "format": "time_series", "hide": false, - "interval": "", - "intervalFactor": 2, "legendFormat": "p8s process resident memory", - "refId": "D", - "step": 20 + "refId": "D" }, { "expr": "process_virtual_memory_bytes{job=\"prometheus\"}", "format": "time_series", "hide": false, - "intervalFactor": 2, "legendFormat": "virtual memory", - "refId": "C", - "step": 20 + "refId": "C" } ], "timeFrom": null, @@ -453,10 +442,8 @@ { "expr": "prometheus_tsdb_wal_corruptions_total{job=\"prometheus\"}", "format": "time_series", - "intervalFactor": 2, "legendFormat": "", - "refId": "A", - "step": 60 + "refId": "A" } ], "title": "WAL Corruptions", @@ -539,21 +526,15 @@ { "expr": "sum(prometheus_tsdb_head_active_appenders{job=\"prometheus\"})", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "active_appenders", "metric": "", - "refId": "A", - "step": 20 + "refId": "A" }, { "expr": "sum(process_open_fds{job=\"prometheus\"})", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "open_fds", - "refId": "B", - "step": 20 + "refId": "B" } ], "timeFrom": null, @@ -669,10 +650,8 @@ { "expr": "prometheus_tsdb_blocks_loaded{job=\"prometheus\"}", "format": "time_series", - "intervalFactor": 2, "legendFormat": "blocks", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -758,11 +737,8 @@ { "expr": "prometheus_tsdb_head_chunks{job=\"prometheus\"}", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "chunks", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -861,18 +837,14 @@ { "expr": "prometheus_tsdb_head_gc_duration_seconds{job=\"prometheus\",quantile=\"0.99\"}", "format": "time_series", - "intervalFactor": 2, "legendFormat": "duration-p99", - "refId": "A", - "step": 20 + "refId": "A" }, { - "expr": "irate(prometheus_tsdb_head_gc_duration_seconds_count{job=\"prometheus\"}[5m])", + "expr": "irate(prometheus_tsdb_head_gc_duration_seconds_count{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "collections", - "refId": "B", - "step": 20 + "refId": "B" } ], "timeFrom": null, @@ -970,38 +942,29 @@ "pluginVersion": "8.1.0-pre", "targets": [ { - "expr": "histogram_quantile(0.99, sum(rate(prometheus_tsdb_compaction_duration_bucket{job=\"prometheus\"}[5m])) by (le))", + "expr": "histogram_quantile(0.99, sum(rate(prometheus_tsdb_compaction_duration_bucket{job=\"prometheus\"}[$__rate_interval])) by (le))", "format": "time_series", "hide": false, - "interval": "", - "intervalFactor": 2, "legendFormat": "duration-{{p99}}", - "refId": "A", - "step": 20 + "refId": "A" }, { - "expr": "irate(prometheus_tsdb_compactions_total{job=\"prometheus\"}[5m])", + "expr": "irate(prometheus_tsdb_compactions_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "compactions", - "refId": "B", - "step": 20 + "refId": "B" }, { - "expr": "irate(prometheus_tsdb_compactions_failed_total{job=\"prometheus\"}[5m])", + "expr": "irate(prometheus_tsdb_compactions_failed_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "failed", - "refId": "C", - "step": 20 + "refId": "C" }, { - "expr": "irate(prometheus_tsdb_compactions_triggered_total{job=\"prometheus\"}[5m])", + "expr": "irate(prometheus_tsdb_compactions_triggered_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "triggered", - "refId": "D", - "step": 20 + "refId": "D" } ], "timeFrom": null, @@ -1084,21 +1047,17 @@ "pluginVersion": "8.1.0-pre", "targets": [ { - "expr": "rate(prometheus_tsdb_reloads_total{job=\"prometheus\"}[5m])", + "expr": "rate(prometheus_tsdb_reloads_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "reloads", - "refId": "A", - "step": 20 + "refId": "A" }, { - "expr": "rate(prometheus_tsdb_reloads_failures_total{job=\"prometheus\"}[5m])", + "expr": "rate(prometheus_tsdb_reloads_failures_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", "hide": false, - "intervalFactor": 2, "legendFormat": "failures", - "refId": "B", - "step": 20 + "refId": "B" } ], "timeFrom": null, @@ -1183,10 +1142,8 @@ { "expr": "prometheus_engine_query_duration_seconds{job=\"prometheus\", quantile=\"0.99\"}", "format": "time_series", - "intervalFactor": 2, "legendFormat": "{{slice}}_p99", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -1271,11 +1228,8 @@ { "expr": "max(prometheus_rule_group_duration_seconds{job=\"prometheus\"}) by (quantile)", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "{{quantile}}", - "refId": "A", - "step": 10 + "refId": "A" } ], "timeFrom": null, @@ -1358,20 +1312,16 @@ "pluginVersion": "8.1.0-pre", "targets": [ { - "expr": "rate(prometheus_rule_group_iterations_missed_total{job=\"prometheus\"}[5m])", + "expr": "rate(prometheus_rule_group_iterations_missed_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "missed", - "refId": "B", - "step": 10 + "refId": "B" }, { - "expr": "rate(prometheus_rule_group_iterations_total{job=\"prometheus\"}[5m])", + "expr": "rate(prometheus_rule_group_iterations_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "iterations", - "refId": "A", - "step": 10 + "refId": "A" } ], "timeFrom": null, From 99cabcb8bef3066b8d1f6d9539f5d03eb2cf0c00 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Mon, 5 Jan 2026 14:10:44 +0000 Subject: [PATCH 05/79] Plugins: Remove `pkg/services/org` and `pkg/services/pluginsintegration/pluginerrs` dependencies (#115820) * remove deps from pkg/plugins * fmt * lint fix --- apps/plugins/go.mod | 2 +- apps/plugins/kinds/meta.cue | 2 +- .../apis/plugins/v0alpha1/meta_spec_gen.go | 1 + apps/plugins/pkg/apis/plugins_manifest.go | 2 +- apps/plugins/pkg/app/meta/converter.go | 9 ++++-- apps/plugins/pkg/app/meta/core.go | 2 +- pkg/api/plugins_test.go | 2 +- pkg/plugins/manager/loader/loader.go | 2 +- pkg/plugins/manager/loader/loader_test.go | 16 +++++----- pkg/plugins/models.go | 24 +++++++-------- .../pluginerrs/errors.go | 0 pkg/plugins/plugins.go | 29 +++++++++---------- pkg/plugins/plugins_test.go | 13 +++++---- pkg/server/wire_gen.go | 2 +- .../pluginsintegration/loader/loader.go | 2 +- .../pluginsintegration/loader/loader_test.go | 2 +- .../pluginsintegration/pluginsintegration.go | 2 +- .../pluginsintegration/renderer/renderer.go | 2 +- .../pluginsintegration/test_helper.go | 2 +- 19 files changed, 60 insertions(+), 56 deletions(-) rename pkg/{services/pluginsintegration => plugins}/pluginerrs/errors.go (100%) diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index 9a3e3776efb..0b9ba53e76a 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -13,6 +13,7 @@ require ( github.com/grafana/grafana v0.0.0-00010101000000-000000000000 github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 + github.com/grafana/grafana/pkg/apimachinery v0.0.0 github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.3 k8s.io/apiserver v0.34.3 @@ -99,7 +100,6 @@ require ( github.com/grafana/grafana-aws-sdk v1.3.0 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana-plugin-sdk-go v0.284.0 // indirect - github.com/grafana/grafana/pkg/apimachinery v0.0.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect diff --git a/apps/plugins/kinds/meta.cue b/apps/plugins/kinds/meta.cue index d29308e2ca2..b3b506e6101 100644 --- a/apps/plugins/kinds/meta.cue +++ b/apps/plugins/kinds/meta.cue @@ -137,7 +137,7 @@ metaV0Alpha1: { type?: "dashboard" | "page" | "panel" | "datasource" name?: string component?: string - role?: "Admin" | "Editor" | "Viewer" + role?: "Admin" | "Editor" | "Viewer" | "None" action?: string path?: string addToNav?: bool diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go index f3671a040db..c88d9b7ff8a 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go @@ -504,6 +504,7 @@ const ( MetaIncludeRoleAdmin MetaIncludeRole = "Admin" MetaIncludeRoleEditor MetaIncludeRole = "Editor" MetaIncludeRoleViewer MetaIncludeRole = "Viewer" + MetaIncludeRoleNone MetaIncludeRole = "None" ) // +k8s:openapi-gen=true diff --git a/apps/plugins/pkg/apis/plugins_manifest.go b/apps/plugins/pkg/apis/plugins_manifest.go index 7b87dcf0ea7..1cabb46a611 100644 --- a/apps/plugins/pkg/apis/plugins_manifest.go +++ b/apps/plugins/pkg/apis/plugins_manifest.go @@ -23,7 +23,7 @@ var ( rawSchemaPluginv0alpha1 = []byte(`{"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"Plugin":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"id":{"type":"string"},"url":{"type":"string"},"version":{"type":"string"}},"required":["id","version"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) versionSchemaPluginv0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaPluginv0alpha1, &versionSchemaPluginv0alpha1) - rawSchemaMetav0alpha1 = []byte(`{"Dependencies":{"additionalProperties":false,"properties":{"extensions":{"additionalProperties":false,"properties":{"exposedComponents":{"description":"+listType=set","items":{"type":"string"},"type":"array"}},"type":"object"},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"enum":["app","datasource","panel"],"type":"string"}},"required":["id","type","name"],"type":"object"},"type":"array"}},"required":["grafanaDependency"],"type":"object"},"EnterpriseFeatures":{"additionalProperties":false,"properties":{"healthDiagnosticsErrors":{"default":false,"description":"Allow additional properties","type":"boolean"}},"type":"object"},"Extensions":{"additionalProperties":false,"properties":{"addedComponents":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedLinks":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"}},"type":"object"},"IAM":{"additionalProperties":false,"properties":{"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"Include":{"additionalProperties":false,"properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"enum":["Admin","Editor","Viewer"],"type":"string"},"type":{"enum":["dashboard","page","panel","datasource"],"type":"string"},"uid":{"type":"string"}},"type":"object"},"Info":{"additionalProperties":false,"properties":{"author":{"additionalProperties":false,"description":"Optional fields","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","items":{"type":"string"},"type":"array"},"links":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"type":"array"},"logos":{"additionalProperties":false,"properties":{"large":{"type":"string"},"small":{"type":"string"}},"required":["small","large"],"type":"object"},"screenshots":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"path":{"type":"string"}},"type":"object"},"type":"array"},"updated":{"type":"string"},"version":{"type":"string"}},"required":["keywords","logos","updated","version"],"type":"object"},"JSONData":{"additionalProperties":false,"description":"JSON configuration schema for Grafana plugins\nConverted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json","properties":{"alerting":{"description":"Optional fields","type":"boolean"},"annotations":{"type":"boolean"},"autoEnabled":{"type":"boolean"},"backend":{"type":"boolean"},"buildMode":{"type":"string"},"builtIn":{"type":"boolean"},"category":{"enum":["tsdb","logging","cloud","tracing","profiling","sql","enterprise","iot","other"],"type":"string"},"dependencies":{"$ref":"#/components/schemas/Dependencies","description":"Dependency information"},"enterpriseFeatures":{"$ref":"#/components/schemas/EnterpriseFeatures"},"executable":{"type":"string"},"extensions":{"$ref":"#/components/schemas/Extensions"},"hideFromList":{"type":"boolean"},"iam":{"$ref":"#/components/schemas/IAM"},"id":{"description":"Unique name of the plugin","type":"string"},"includes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Include"},"type":"array"},"info":{"$ref":"#/components/schemas/Info","description":"Metadata for the plugin"},"logs":{"type":"boolean"},"metrics":{"type":"boolean"},"multiValueFilterOperators":{"type":"boolean"},"name":{"description":"Human-readable name of the plugin","type":"string"},"pascalName":{"type":"string"},"preload":{"type":"boolean"},"queryOptions":{"$ref":"#/components/schemas/QueryOptions"},"roles":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Role"},"type":"array"},"routes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Route"},"type":"array"},"skipDataQuery":{"type":"boolean"},"state":{"enum":["alpha","beta"],"type":"string"},"streaming":{"type":"boolean"},"suggestions":{"type":"boolean"},"tracing":{"type":"boolean"},"type":{"description":"Plugin type","enum":["app","datasource","panel","renderer"],"type":"string"}},"required":["id","type","name","info","dependencies"],"type":"object"},"Meta":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"QueryOptions":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"type":"object"},"Role":{"additionalProperties":false,"properties":{"grants":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"role":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"}},"type":"object"},"Route":{"additionalProperties":false,"properties":{"body":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"headers":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"jwtTokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"content":{"type":"string"},"name":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"spec":{"additionalProperties":false,"properties":{"angular":{"additionalProperties":false,"properties":{"detected":{"type":"boolean"}},"required":["detected"],"type":"object"},"baseURL":{"type":"string"},"children":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"class":{"enum":["core","external"],"type":"string"},"module":{"additionalProperties":false,"properties":{"hash":{"type":"string"},"loadingStrategy":{"enum":["fetch","script"],"type":"string"},"path":{"type":"string"}},"required":["path"],"type":"object"},"pluginJson":{"$ref":"#/components/schemas/JSONData"},"signature":{"additionalProperties":false,"properties":{"org":{"type":"string"},"status":{"enum":["internal","valid","invalid","modified","unsigned"],"type":"string"},"type":{"enum":["grafana","commercial","community","private","private-glob"],"type":"string"}},"required":["status"],"type":"object"},"translations":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["pluginJson","class"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + rawSchemaMetav0alpha1 = []byte(`{"Dependencies":{"additionalProperties":false,"properties":{"extensions":{"additionalProperties":false,"properties":{"exposedComponents":{"description":"+listType=set","items":{"type":"string"},"type":"array"}},"type":"object"},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"enum":["app","datasource","panel"],"type":"string"}},"required":["id","type","name"],"type":"object"},"type":"array"}},"required":["grafanaDependency"],"type":"object"},"EnterpriseFeatures":{"additionalProperties":false,"properties":{"healthDiagnosticsErrors":{"default":false,"description":"Allow additional properties","type":"boolean"}},"type":"object"},"Extensions":{"additionalProperties":false,"properties":{"addedComponents":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedLinks":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"}},"type":"object"},"IAM":{"additionalProperties":false,"properties":{"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"Include":{"additionalProperties":false,"properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"enum":["Admin","Editor","Viewer","None"],"type":"string"},"type":{"enum":["dashboard","page","panel","datasource"],"type":"string"},"uid":{"type":"string"}},"type":"object"},"Info":{"additionalProperties":false,"properties":{"author":{"additionalProperties":false,"description":"Optional fields","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","items":{"type":"string"},"type":"array"},"links":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"type":"array"},"logos":{"additionalProperties":false,"properties":{"large":{"type":"string"},"small":{"type":"string"}},"required":["small","large"],"type":"object"},"screenshots":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"path":{"type":"string"}},"type":"object"},"type":"array"},"updated":{"type":"string"},"version":{"type":"string"}},"required":["keywords","logos","updated","version"],"type":"object"},"JSONData":{"additionalProperties":false,"description":"JSON configuration schema for Grafana plugins\nConverted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json","properties":{"alerting":{"description":"Optional fields","type":"boolean"},"annotations":{"type":"boolean"},"autoEnabled":{"type":"boolean"},"backend":{"type":"boolean"},"buildMode":{"type":"string"},"builtIn":{"type":"boolean"},"category":{"enum":["tsdb","logging","cloud","tracing","profiling","sql","enterprise","iot","other"],"type":"string"},"dependencies":{"$ref":"#/components/schemas/Dependencies","description":"Dependency information"},"enterpriseFeatures":{"$ref":"#/components/schemas/EnterpriseFeatures"},"executable":{"type":"string"},"extensions":{"$ref":"#/components/schemas/Extensions"},"hideFromList":{"type":"boolean"},"iam":{"$ref":"#/components/schemas/IAM"},"id":{"description":"Unique name of the plugin","type":"string"},"includes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Include"},"type":"array"},"info":{"$ref":"#/components/schemas/Info","description":"Metadata for the plugin"},"logs":{"type":"boolean"},"metrics":{"type":"boolean"},"multiValueFilterOperators":{"type":"boolean"},"name":{"description":"Human-readable name of the plugin","type":"string"},"pascalName":{"type":"string"},"preload":{"type":"boolean"},"queryOptions":{"$ref":"#/components/schemas/QueryOptions"},"roles":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Role"},"type":"array"},"routes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Route"},"type":"array"},"skipDataQuery":{"type":"boolean"},"state":{"enum":["alpha","beta"],"type":"string"},"streaming":{"type":"boolean"},"suggestions":{"type":"boolean"},"tracing":{"type":"boolean"},"type":{"description":"Plugin type","enum":["app","datasource","panel","renderer"],"type":"string"}},"required":["id","type","name","info","dependencies"],"type":"object"},"Meta":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"QueryOptions":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"type":"object"},"Role":{"additionalProperties":false,"properties":{"grants":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"role":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"}},"type":"object"},"Route":{"additionalProperties":false,"properties":{"body":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"headers":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"jwtTokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"content":{"type":"string"},"name":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"spec":{"additionalProperties":false,"properties":{"angular":{"additionalProperties":false,"properties":{"detected":{"type":"boolean"}},"required":["detected"],"type":"object"},"baseURL":{"type":"string"},"children":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"class":{"enum":["core","external"],"type":"string"},"module":{"additionalProperties":false,"properties":{"hash":{"type":"string"},"loadingStrategy":{"enum":["fetch","script"],"type":"string"},"path":{"type":"string"}},"required":["path"],"type":"object"},"pluginJson":{"$ref":"#/components/schemas/JSONData"},"signature":{"additionalProperties":false,"properties":{"org":{"type":"string"},"status":{"enum":["internal","valid","invalid","modified","unsigned"],"type":"string"},"type":{"enum":["grafana","commercial","community","private","private-glob"],"type":"string"}},"required":["status"],"type":"object"},"translations":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["pluginJson","class"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) versionSchemaMetav0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaMetav0alpha1, &versionSchemaMetav0alpha1) ) diff --git a/apps/plugins/pkg/app/meta/converter.go b/apps/plugins/pkg/app/meta/converter.go index 28180a263bd..2af699f6f9e 100644 --- a/apps/plugins/pkg/app/meta/converter.go +++ b/apps/plugins/pkg/app/meta/converter.go @@ -5,6 +5,7 @@ import ( "time" pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" ) @@ -253,12 +254,14 @@ func jsonDataToMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.MetaJSOND if include.Role != "" { var role pluginsv0alpha1.MetaIncludeRole switch include.Role { - case "Admin": + case identity.RoleAdmin: role = pluginsv0alpha1.MetaIncludeRoleAdmin - case "Editor": + case identity.RoleEditor: role = pluginsv0alpha1.MetaIncludeRoleEditor - case "Viewer": + case identity.RoleViewer: role = pluginsv0alpha1.MetaIncludeRoleViewer + case identity.RoleNone: + role = pluginsv0alpha1.MetaIncludeRoleNone } v0Include.Role = &role } diff --git a/apps/plugins/pkg/app/meta/core.go b/apps/plugins/pkg/app/meta/core.go index 9fb167d5578..1837386f7a3 100644 --- a/apps/plugins/pkg/app/meta/core.go +++ b/apps/plugins/pkg/app/meta/core.go @@ -20,7 +20,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/pipeline/termination" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation" "github.com/grafana/grafana/pkg/plugins/manager/sources" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" ) const ( diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index bda739c5020..342c6293d7e 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -30,6 +30,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/plugins/pluginscdn" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" @@ -44,7 +45,6 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" diff --git a/pkg/plugins/manager/loader/loader.go b/pkg/plugins/manager/loader/loader.go index d4943cee865..0bbaa0db015 100644 --- a/pkg/plugins/manager/loader/loader.go +++ b/pkg/plugins/manager/loader/loader.go @@ -15,7 +15,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/pipeline/initialization" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/termination" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" ) type Loader struct { diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index 6e6021bc021..7613392f497 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/bootstrap" @@ -20,8 +21,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation" "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" "github.com/grafana/grafana/pkg/plugins/manager/sources" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" ) var compareOpts = []cmp.Option{cmpopts.IgnoreFields(plugins.Plugin{}, "client", "log", "mu"), fsComparer} @@ -180,7 +180,7 @@ func TestLoader_Load(t *testing.T) { Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", - Role: org.RoleViewer, + Role: identity.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-connections", }, @@ -188,21 +188,21 @@ func TestLoader_Load(t *testing.T) { Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", - Role: org.RoleViewer, + Role: identity.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-memory", }, { Name: "Nginx Panel", Type: string(plugins.TypePanel), - Role: org.RoleViewer, + Role: identity.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-panel", }, { Name: "Nginx Datasource", Type: string(plugins.TypeDataSource), - Role: org.RoleViewer, + Role: identity.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-datasource", }, @@ -413,8 +413,8 @@ func TestLoader_Load(t *testing.T) { }, }, Includes: []*plugins.Includes{ - {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-memory"}, - {Name: "Root Page (react)", Type: "page", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"}, + {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: identity.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-memory"}, + {Name: "Root Page (react)", Type: "page", Role: identity.RoleViewer, Action: plugins.ActionAppAccess, Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"}, }, Extensions: plugins.Extensions{ AddedLinks: []plugins.AddedLink{}, diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 8356a652793..dfad38bcaa1 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/apimachinery/identity" ) const ( @@ -148,17 +148,17 @@ type ExtensionsDependencies struct { } type Includes struct { - Name string `json:"name"` - Path string `json:"path"` - Type string `json:"type"` - Component string `json:"component"` - Role org.RoleType `json:"role"` - Action string `json:"action,omitempty"` - AddToNav bool `json:"addToNav"` - DefaultNav bool `json:"defaultNav"` - Slug string `json:"slug"` - Icon string `json:"icon"` - UID string `json:"uid"` + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Component string `json:"component"` + Role identity.RoleType `json:"role"` + Action string `json:"action,omitempty"` + AddToNav bool `json:"addToNav"` + DefaultNav bool `json:"defaultNav"` + Slug string `json:"slug"` + Icon string `json:"icon"` + UID string `json:"uid"` ID string `json:"-"` } diff --git a/pkg/services/pluginsintegration/pluginerrs/errors.go b/pkg/plugins/pluginerrs/errors.go similarity index 100% rename from pkg/services/pluginsintegration/pluginerrs/errors.go rename to pkg/plugins/pluginerrs/errors.go diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 7f010565c9c..bf1b23a35b0 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -14,11 +14,11 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/plugins/auth" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2" "github.com/grafana/grafana/pkg/plugins/log" - "github.com/grafana/grafana/pkg/services/org" ) var ( @@ -26,7 +26,6 @@ var ( ErrPluginFileRead = errors.New("file could not be read") ErrUninstallInvalidPluginDir = errors.New("cannot recognize as plugin folder") ErrInvalidPluginJSON = errors.New("did not find valid type or id properties in plugin.json") - ErrUnsupportedAlias = errors.New("can not set alias in plugin.json") ) type Plugin struct { @@ -186,11 +185,11 @@ func ReadPluginJSON(reader io.Reader) (JSONData, error) { for _, include := range plugin.Includes { if include.Role == "" { - include.Role = org.RoleViewer + include.Role = identity.RoleViewer } // Default to app access for app plugins - if plugin.Type == TypeApp && include.Role == org.RoleViewer && include.Action == "" { + if plugin.Type == TypeApp && include.Role == identity.RoleViewer && include.Action == "" { include.Action = ActionAppAccess } } @@ -219,17 +218,17 @@ func (d JSONData) DashboardIncludes() []*Includes { // Route describes a plugin route that is defined in // the plugin.json file for a plugin. type Route struct { - Path string `json:"path"` - Method string `json:"method"` - ReqRole org.RoleType `json:"reqRole"` - ReqAction string `json:"reqAction"` - URL string `json:"url"` - URLParams []URLParam `json:"urlParams"` - Headers []Header `json:"headers"` - AuthType string `json:"authType"` - TokenAuth *JWTTokenAuth `json:"tokenAuth"` - JwtTokenAuth *JWTTokenAuth `json:"jwtTokenAuth"` - Body json.RawMessage `json:"body"` + Path string `json:"path"` + Method string `json:"method"` + ReqRole identity.RoleType `json:"reqRole"` + ReqAction string `json:"reqAction"` + URL string `json:"url"` + URLParams []URLParam `json:"urlParams"` + Headers []Header `json:"headers"` + AuthType string `json:"authType"` + TokenAuth *JWTTokenAuth `json:"tokenAuth"` + JwtTokenAuth *JWTTokenAuth `json:"jwtTokenAuth"` + Body json.RawMessage `json:"body"` } // Header describes an HTTP header that is forwarded with diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go index 3ee1f1a2dab..ef77045dcc4 100644 --- a/pkg/plugins/plugins_test.go +++ b/pkg/plugins/plugins_test.go @@ -8,8 +8,9 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/grafana/grafana/pkg/services/org" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/apimachinery/identity" ) func Test_ReadPluginJSON(t *testing.T) { @@ -73,10 +74,10 @@ func Test_ReadPluginJSON(t *testing.T) { }, Includes: []*Includes{ - {Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer, Action: ActionAppAccess}, - {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Action: ActionAppAccess}, - {Name: "Nginx Panel", Type: "panel", Role: org.RoleViewer, Action: ActionAppAccess}, - {Name: "Nginx Datasource", Type: "datasource", Role: org.RoleViewer, Action: ActionAppAccess}, + {Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: identity.RoleViewer, Action: ActionAppAccess}, + {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: identity.RoleViewer, Action: ActionAppAccess}, + {Name: "Nginx Panel", Type: "panel", Role: identity.RoleViewer, Action: ActionAppAccess}, + {Name: "Nginx Datasource", Type: "datasource", Role: identity.RoleViewer, Action: ActionAppAccess}, }, Backend: false, }, @@ -126,7 +127,7 @@ func Test_ReadPluginJSON(t *testing.T) { }, Includes: []*Includes{ - {Name: "Pie Charts", Path: "dashboards/demo.json", Type: "dashboard", Role: org.RoleViewer}, + {Name: "Pie Charts", Path: "dashboards/demo.json", Type: "dashboard", Role: identity.RoleViewer}, }, }, }, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index a235e71ec10..d396dcfdd02 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -45,6 +45,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/repo" "github.com/grafana/grafana/pkg/registry/apis" @@ -191,7 +192,6 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginexternal" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller" service6 "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" diff --git a/pkg/services/pluginsintegration/loader/loader.go b/pkg/services/pluginsintegration/loader/loader.go index b3a03885752..986151041ff 100644 --- a/pkg/services/pluginsintegration/loader/loader.go +++ b/pkg/services/pluginsintegration/loader/loader.go @@ -11,7 +11,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/pipeline/initialization" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/termination" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" ) var _ pluginsLoader.Service = (*Loader)(nil) diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index 60f5ac757a1..a5a7a7fd8db 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -25,9 +25,9 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" ) diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index 387d9884f9a..fbfa3379afd 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/sources" pluginassets2 "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/repo" "github.com/grafana/grafana/pkg/services/caching" @@ -50,7 +51,6 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginexternal" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" diff --git a/pkg/services/pluginsintegration/renderer/renderer.go b/pkg/services/pluginsintegration/renderer/renderer.go index c061693879b..6d02768f79e 100644 --- a/pkg/services/pluginsintegration/renderer/renderer.go +++ b/pkg/services/pluginsintegration/renderer/renderer.go @@ -22,8 +22,8 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/sources" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/rendering" ) diff --git a/pkg/services/pluginsintegration/test_helper.go b/pkg/services/pluginsintegration/test_helper.go index 018305a71ad..d6cff58d313 100644 --- a/pkg/services/pluginsintegration/test_helper.go +++ b/pkg/services/pluginsintegration/test_helper.go @@ -25,10 +25,10 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsources" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" From ab4ccef3198049d0a782f1f9ba4805096d515069 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Mon, 5 Jan 2026 07:37:57 -0700 Subject: [PATCH 06/79] Dashboard Conversion: Set panel level datasource when converting from v2 to v1 (#115777) * set panel level ds when converting from v2 to v1 * remove comment * lint * always set ds in sqr * improve * Apply suggestions from code review Co-authored-by: Ivan Ortega Alba --------- Co-authored-by: Ivan Ortega Alba --- ...v2beta1.v10.table_thresholds.v0alpha1.json | 12 ++++ .../v2beta1.v10.table_thresholds.v1beta1.json | 12 ++++ .../v2beta1.v11.no-op-migration.v0alpha1.json | 8 +++ .../v2beta1.v11.no-op-migration.v1beta1.json | 8 +++ ...v2beta1.v13.graph_thresholds.v0alpha1.json | 20 ++++++ .../v2beta1.v13.graph_thresholds.v1beta1.json | 20 ++++++ ...ta1.v13.minimal_graph_config.v0alpha1.json | 4 ++ ...eta1.v13.minimal_graph_config.v1beta1.json | 4 ++ ...d_crosshair_to_graph_tooltip.v0alpha1.json | 8 +++ ...ed_crosshair_to_graph_tooltip.v1beta1.json | 8 +++ ....v15.mimir_rollout_debugging.v0alpha1.json | 18 +++++ ...1.v15.mimir_rollout_debugging.v1beta1.json | 18 +++++ .../v2beta1.v15.no-op-migration.v0alpha1.json | 8 +++ .../v2beta1.v15.no-op-migration.v1beta1.json | 8 +++ ....empty-rows-and-panels-array.v0alpha1.json | 30 ++++++++ ...6.empty-rows-and-panels-array.v1beta1.json | 30 ++++++++ ...eta1.v16.grid_layout_upgrade.v0alpha1.json | 32 +++++++++ ...beta1.v16.grid_layout_upgrade.v1beta1.json | 32 +++++++++ .../v2beta1.v16.span_zero_demo.v0alpha1.json | 72 +++++++++++++++++++ .../v2beta1.v16.span_zero_demo.v1beta1.json | 72 +++++++++++++++++++ ...ta1.v17.minspan_to_maxperrow.v0alpha1.json | 40 +++++++++++ ...eta1.v17.minspan_to_maxperrow.v1beta1.json | 40 +++++++++++ .../v2beta1.v18.gauge_options.v0alpha1.json | 20 ++++++ .../v2beta1.v18.gauge_options.v1beta1.json | 20 ++++++ .../v2beta1.v19.panel_links.v0alpha1.json | 24 +++++++ .../v2beta1.v19.panel_links.v1beta1.json | 24 +++++++ ...beta1.v2.panels-and-services.v0alpha1.json | 16 +++++ ...2beta1.v2.panels-and-services.v1beta1.json | 16 +++++ ...a1.v20.variable_syntax_links.v0alpha1.json | 20 ++++++ ...ta1.v20.variable_syntax_links.v1beta1.json | 20 ++++++ ...1.data_links_series_to_field.v0alpha1.json | 20 ++++++ ...21.data_links_series_to_field.v1beta1.json | 20 ++++++ ...2beta1.v22.table_panel_align.v0alpha1.json | 4 ++ ...v2beta1.v22.table_panel_align.v1beta1.json | 4 ++ ...v23.multi_variable_alignment.v0alpha1.json | 4 ++ ....v23.multi_variable_alignment.v1beta1.json | 4 ++ .../v2beta1.v24.table-angular.v0alpha1.json | 68 ++++++++++++++++++ .../v2beta1.v24.table-angular.v1beta1.json | 68 ++++++++++++++++++ .../v2beta1.v25.no-op-migration.v0alpha1.json | 8 +++ .../v2beta1.v25.no-op-migration.v1beta1.json | 8 +++ .../v2beta1.v26.text2_to_text.v0alpha1.json | 12 ++++ .../v2beta1.v26.text2_to_text.v1beta1.json | 12 ++++ ...panels_and_constant_variable.v0alpha1.json | 4 ++ ..._panels_and_constant_variable.v1beta1.json | 4 ++ ...stat_and_variable_properties.v0alpha1.json | 16 +++++ ...estat_and_variable_properties.v1beta1.json | 16 +++++ ...ta1.v28.singlestat_migration.v0alpha1.json | 32 +++++++++ ...eta1.v28.singlestat_migration.v1beta1.json | 32 +++++++++ ...ariables_refresh_and_options.v0alpha1.json | 4 ++ ...variables_refresh_and_options.v1beta1.json | 4 ++ .../v2beta1.v3.no-op.v0alpha1.json | 16 +++++ .../v2beta1.v3.no-op.v1beta1.json | 16 +++++ ...mappings_and_tooltip_options.v0alpha1.json | 28 ++++++++ ..._mappings_and_tooltip_options.v1beta1.json | 28 ++++++++ ...1.v31.labels_to_fields_merge.v0alpha1.json | 24 +++++++ ...a1.v31.labels_to_fields_merge.v1beta1.json | 24 +++++++ .../v2beta1.v32.no_op_migration.v0alpha1.json | 8 +++ .../v2beta1.v32.no_op_migration.v1beta1.json | 8 +++ ...ta1.v33.panel_ds_name_to_ref.v0alpha1.json | 20 ++++++ ...eta1.v33.panel_ds_name_to_ref.v1beta1.json | 20 ++++++ ...34.multiple_stats_cloudwatch.v0alpha1.json | 52 ++++++++++++++ ...v34.multiple_stats_cloudwatch.v1beta1.json | 52 ++++++++++++++ ...v35.ensure_x_axis_visibility.v0alpha1.json | 32 +++++++++ ....v35.ensure_x_axis_visibility.v1beta1.json | 32 +++++++++ .../v2beta1.v36.ds_name_to_ref.v0alpha1.json | 40 +++++++++++ .../v2beta1.v36.ds_name_to_ref.v1beta1.json | 40 +++++++++++ .../v2beta1.v4.no-op.v0alpha1.json | 12 ++++ .../v2beta1.v4.no-op.v1beta1.json | 12 ++++ .../v2beta1.v5.no-op.v0alpha1.json | 12 ++++ .../v2beta1.v5.no-op.v1beta1.json | 12 ++++ ....v6.pulldowns_and_templating.v0alpha1.json | 8 +++ ...1.v6.pulldowns_and_templating.v1beta1.json | 8 +++ .../v2beta1.v7.timepicker.v0alpha1.json | 4 ++ .../v2beta1.v7.timepicker.v1beta1.json | 4 ++ .../v2beta1.v9.no-op.v0alpha1.json | 12 ++++ .../v2beta1.v9.no-op.v1beta1.json | 12 ++++ .../output/v2alpha1.complete.v0alpha1.json | 4 ++ .../output/v2alpha1.complete.v1beta1.json | 4 ++ .../v2alpha1.ds-data-query.v0alpha1.json | 14 ++++ .../v2alpha1.ds-data-query.v1beta1.json | 14 ++++ .../output/v2alpha1.viz-config.v0alpha1.json | 4 ++ .../output/v2alpha1.viz-config.v1beta1.json | 4 ++ .../output/v2beta1.complete.v0alpha1.json | 4 ++ .../output/v2beta1.complete.v1beta1.json | 4 ++ ...2beta1.datasource-resolution.v0alpha1.json | 7 ++ ...v2beta1.datasource-resolution.v1beta1.json | 7 ++ .../v2beta1.ds-data-query.v0alpha1.json | 14 ++++ .../output/v2beta1.ds-data-query.v1beta1.json | 14 ++++ ...2beta1.rows-with-nested-tabs.v0alpha1.json | 12 ++++ ...v2beta1.rows-with-nested-tabs.v1beta1.json | 12 ++++ ...ta1.tab-with-multiple-panels.v0alpha1.json | 4 ++ ...eta1.tab-with-multiple-panels.v1beta1.json | 4 ++ ...beta1.tabs-and-rows-repeated.v0alpha1.json | 20 ++++++ ...2beta1.tabs-and-rows-repeated.v1beta1.json | 20 ++++++ ...2beta1.tabs-with-nested-rows.v0alpha1.json | 16 +++++ ...v2beta1.tabs-with-nested-rows.v1beta1.json | 16 +++++ ....value-mapping-and-overrides.v0alpha1.json | 24 +++++++ ...1.value-mapping-and-overrides.v1beta1.json | 24 +++++++ .../output/v2beta1.viz-config.v0alpha1.json | 4 ++ .../output/v2beta1.viz-config.v1beta1.json | 4 ++ .../conversion/v2alpha1_to_v1beta1.go | 38 +++++++--- .../panel-gauge/gauge_tests.v42.json | 4 +- .../panel-gauge/gauge_tests_new.v42.json | 66 ++++++++--------- .../gauge_tests_old_to_new.v42.json | 2 +- .../utils/createPanelDataProvider.ts | 18 ++++- 105 files changed, 1880 insertions(+), 46 deletions(-) diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v0alpha1.json index 5a6cebb8a91..e181c66eaf1 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v1beta1.json index 122c6772dfe..6c7da8a3864 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v0alpha1.json index 0442d15c222..ed6e158988b 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v1beta1.json index e1548f77853..dab8e4d166c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v0alpha1.json index 7aadfcf8535..0d943be33e1 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -93,6 +105,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -115,6 +131,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v1beta1.json index 7f7f61699e5..7d51fccb5f4 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -93,6 +105,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -115,6 +131,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v0alpha1.json index e768e617055..d26aeb546b0 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v1beta1.json index 2c88ea03447..1dc3f1743cd 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v0alpha1.json index 6f6b04077da..cefd07bd3b0 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -50,6 +54,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v1beta1.json index d83cb315419..765bf5993d3 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -50,6 +54,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v0alpha1.json index cc366a688e3..556e8ee5040 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v0alpha1.json @@ -65,6 +65,9 @@ "type": "row" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Versions running\nShows the versions reported by each running pod.\n\nThe rollout will fail if any pod is not running the expected version.\n\nPods in green are running the expected version, while pods running other versions are shown in orange.\n\n", "fieldConfig": { "defaults": { @@ -185,6 +188,9 @@ "type": "barchart" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Deployment rollout progress\nShows the number of pods for each `Deployment` that match the desired configuration, as a proportion of the desired number of pods.\n\nThe rollout will fail if insufficient pods match the desired configuration for any `Deployment`.\n\nPods in green match the desired configuration, while pods that do not match the desired configuration are shown in orange.\n\n", "fieldConfig": { "defaults": { @@ -286,6 +292,9 @@ "type": "barchart" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### StatefulSet rollout progress\nShows the number of pods for each `StatefulSet` that match the desired configuration, as a proportion of the desired number of pods.\n\nThe rollout will fail if insufficient pods match the desired configuration for any `StatefulSet`.\n\nPods in green match the desired configuration, while pods that do not match the desired configuration are shown in orange.\n\n", "fieldConfig": { "defaults": { @@ -399,6 +408,9 @@ "type": "row" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Aggregator lag\nShows the consumption lag of each aggregator pod.\n\nThis panel may show no data if aggregators are not deployed to this cell.\n\nThe rollout will fail if any pod's consumption lag is both:\n* greater than 30s (red area on graph), and\n* trending upwards compared to 1 minute earlier\n\n", "fieldConfig": { "defaults": { @@ -468,6 +480,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Unhealthy Deployment replicas\nShows the number of unavailable pods for each `Deployment`.\n\nThe rollout will fail if any `Deployment` has an unavailable pod.\n\nBoth this panel and the rollout check ignore any `Deployment`s that require spot nodes, as these are expected to be unavailable from time to time.\n\n`Deployment`s shown in green do not have any unavailable pods, while `Deployment`s shown in orange have one or more unavailable pods.\n\n", "fieldConfig": { "defaults": { @@ -569,6 +584,9 @@ "type": "barchart" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Unhealthy StatefulSet replicas\nShows the number of pods for each `StatefulSet` that are not ready.\n\nThe rollout will fail if any `StatefulSet` has fewer ready pods than requested.\n\nBoth this panel and the rollout check ignore any `StatefulSets`s that require spot nodes, as these are expected to be unavailable from time to time.\n\n`StatefulSets`s shown in green do not have any pods that are not ready, while `StatefulSet`s shown in orange have one or more pods that are not ready.\n\n", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v1beta1.json index cbe61ee0ed7..0b50d42fbe7 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v1beta1.json @@ -65,6 +65,9 @@ "type": "row" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Versions running\nShows the versions reported by each running pod.\n\nThe rollout will fail if any pod is not running the expected version.\n\nPods in green are running the expected version, while pods running other versions are shown in orange.\n\n", "fieldConfig": { "defaults": { @@ -185,6 +188,9 @@ "type": "barchart" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Deployment rollout progress\nShows the number of pods for each `Deployment` that match the desired configuration, as a proportion of the desired number of pods.\n\nThe rollout will fail if insufficient pods match the desired configuration for any `Deployment`.\n\nPods in green match the desired configuration, while pods that do not match the desired configuration are shown in orange.\n\n", "fieldConfig": { "defaults": { @@ -286,6 +292,9 @@ "type": "barchart" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### StatefulSet rollout progress\nShows the number of pods for each `StatefulSet` that match the desired configuration, as a proportion of the desired number of pods.\n\nThe rollout will fail if insufficient pods match the desired configuration for any `StatefulSet`.\n\nPods in green match the desired configuration, while pods that do not match the desired configuration are shown in orange.\n\n", "fieldConfig": { "defaults": { @@ -399,6 +408,9 @@ "type": "row" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Aggregator lag\nShows the consumption lag of each aggregator pod.\n\nThis panel may show no data if aggregators are not deployed to this cell.\n\nThe rollout will fail if any pod's consumption lag is both:\n* greater than 30s (red area on graph), and\n* trending upwards compared to 1 minute earlier\n\n", "fieldConfig": { "defaults": { @@ -468,6 +480,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Unhealthy Deployment replicas\nShows the number of unavailable pods for each `Deployment`.\n\nThe rollout will fail if any `Deployment` has an unavailable pod.\n\nBoth this panel and the rollout check ignore any `Deployment`s that require spot nodes, as these are expected to be unavailable from time to time.\n\n`Deployment`s shown in green do not have any unavailable pods, while `Deployment`s shown in orange have one or more unavailable pods.\n\n", "fieldConfig": { "defaults": { @@ -569,6 +584,9 @@ "type": "barchart" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Unhealthy StatefulSet replicas\nShows the number of pods for each `StatefulSet` that are not ready.\n\nThe rollout will fail if any `StatefulSet` has fewer ready pods than requested.\n\nBoth this panel and the rollout check ignore any `StatefulSets`s that require spot nodes, as these are expected to be unavailable from time to time.\n\n`StatefulSets`s shown in green do not have any pods that are not ready, while `StatefulSet`s shown in orange have one or more pods that are not ready.\n\n", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v0alpha1.json index dd68f978199..720fff258cc 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v0alpha1.json @@ -37,6 +37,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -59,6 +63,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v1beta1.json index 6efa2fd1853..370eafaad0c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v1beta1.json @@ -37,6 +37,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -59,6 +63,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v0alpha1.json index 3d8d1b11ad3..99a2102204c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v0alpha1.json @@ -44,6 +44,9 @@ "liveNow": false, "panels": [ { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample metric showing connection count.", "fieldConfig": { "defaults": { @@ -134,6 +137,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample counter metric.", "fieldConfig": { "defaults": { @@ -224,6 +230,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample error metric.", "fieldConfig": { "defaults": { @@ -314,6 +323,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample event rate metric.", "fieldConfig": { "defaults": { @@ -404,6 +416,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample resource utilization metric.", "fieldConfig": { "defaults": { @@ -493,6 +508,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample memory allocation metrics.", "fieldConfig": { "defaults": { @@ -593,6 +611,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample utilization percentage.", "fieldConfig": { "defaults": { @@ -662,6 +683,9 @@ "type": "row" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample latency metric for primary operations.", "fieldConfig": { "defaults": { @@ -748,6 +772,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample latency metric for secondary operations.", "fieldConfig": { "defaults": { @@ -834,6 +861,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample expansion events metric.", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v1beta1.json index e93a8eb3132..58f46289f57 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v1beta1.json @@ -44,6 +44,9 @@ "liveNow": false, "panels": [ { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample metric showing connection count.", "fieldConfig": { "defaults": { @@ -134,6 +137,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample counter metric.", "fieldConfig": { "defaults": { @@ -224,6 +230,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample error metric.", "fieldConfig": { "defaults": { @@ -314,6 +323,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample event rate metric.", "fieldConfig": { "defaults": { @@ -404,6 +416,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample resource utilization metric.", "fieldConfig": { "defaults": { @@ -493,6 +508,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample memory allocation metrics.", "fieldConfig": { "defaults": { @@ -593,6 +611,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample utilization percentage.", "fieldConfig": { "defaults": { @@ -662,6 +683,9 @@ "type": "row" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample latency metric for primary operations.", "fieldConfig": { "defaults": { @@ -748,6 +772,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample latency metric for secondary operations.", "fieldConfig": { "defaults": { @@ -834,6 +861,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample expansion events metric.", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v0alpha1.json index 286cc75e2c0..025ec384a0c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v0alpha1.json @@ -39,6 +39,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 12, @@ -61,6 +65,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 12, @@ -151,6 +159,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -173,6 +185,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -195,6 +211,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -229,6 +249,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 16, @@ -251,6 +275,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 8, @@ -286,6 +314,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 24, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v1beta1.json index a4d76abe265..1334aad64b4 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v1beta1.json @@ -39,6 +39,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 12, @@ -61,6 +65,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 12, @@ -151,6 +159,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -173,6 +185,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -195,6 +211,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -229,6 +249,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 16, @@ -251,6 +275,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 8, @@ -286,6 +314,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 24, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v0alpha1.json index a57a713ba61..0279b19424c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v0alpha1.json @@ -41,6 +41,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 24, @@ -78,6 +82,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -103,6 +111,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -128,6 +140,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "fieldConfig": { "defaults": { "thresholds": { @@ -174,6 +190,10 @@ "type": "stat" }, { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, "gridPos": { "h": 7, "w": 8, @@ -202,6 +222,10 @@ "type": "logs" }, { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, "gridPos": { "h": 7, "w": 8, @@ -230,6 +254,10 @@ "type": "logs" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -255,6 +283,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Number of concurrent processing threads available for handling operations", "gridPos": { "h": 7, @@ -280,6 +312,10 @@ "type": "stat" }, { + "datasource": { + "type": "tempo", + "uid": "${tempo}" + }, "gridPos": { "h": 7, "w": 8, @@ -324,6 +360,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Histogram showing p99, p95, p50, and p10 percentiles for job processing duration based on number of resources changed", "fieldConfig": { "defaults": { @@ -425,6 +465,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Histogram showing p99, p95, p50, and p10 percentiles for job processing duration based on number of resources changed", "gridPos": { "h": 7, @@ -477,6 +521,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Total number of jobs waiting to be processed", "gridPos": { "h": 7, @@ -502,6 +550,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "fieldConfig": { "defaults": { "unit": "s" @@ -532,6 +584,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "How long a job is in the queue before being picked up", "gridPos": { "h": 7, @@ -584,6 +640,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -609,6 +669,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "gridPos": { "h": 7, "w": 8, @@ -633,6 +697,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "gridPos": { "h": 7, "w": 8, @@ -675,6 +743,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "gridPos": { "h": 7, "w": 8, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v1beta1.json index 61094ee5dbd..5597a468c53 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v1beta1.json @@ -41,6 +41,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 24, @@ -78,6 +82,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -103,6 +111,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -128,6 +140,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "fieldConfig": { "defaults": { "thresholds": { @@ -174,6 +190,10 @@ "type": "stat" }, { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, "gridPos": { "h": 7, "w": 8, @@ -202,6 +222,10 @@ "type": "logs" }, { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, "gridPos": { "h": 7, "w": 8, @@ -230,6 +254,10 @@ "type": "logs" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -255,6 +283,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Number of concurrent processing threads available for handling operations", "gridPos": { "h": 7, @@ -280,6 +312,10 @@ "type": "stat" }, { + "datasource": { + "type": "tempo", + "uid": "${tempo}" + }, "gridPos": { "h": 7, "w": 8, @@ -324,6 +360,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Histogram showing p99, p95, p50, and p10 percentiles for job processing duration based on number of resources changed", "fieldConfig": { "defaults": { @@ -425,6 +465,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Histogram showing p99, p95, p50, and p10 percentiles for job processing duration based on number of resources changed", "gridPos": { "h": 7, @@ -477,6 +521,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Total number of jobs waiting to be processed", "gridPos": { "h": 7, @@ -502,6 +550,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "fieldConfig": { "defaults": { "unit": "s" @@ -532,6 +584,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "How long a job is in the queue before being picked up", "gridPos": { "h": 7, @@ -584,6 +640,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -609,6 +669,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "gridPos": { "h": 7, "w": 8, @@ -633,6 +697,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "gridPos": { "h": 7, "w": 8, @@ -675,6 +743,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "gridPos": { "h": 7, "w": 8, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v0alpha1.json index e169d2bfa64..ae598c96c0e 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -71,6 +79,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, @@ -93,6 +105,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 4, @@ -115,6 +131,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -137,6 +157,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, @@ -159,6 +183,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 24, @@ -181,6 +209,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 24, @@ -203,6 +235,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, @@ -225,6 +261,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v1beta1.json index b5a64a7634f..6b5a09f65ab 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -71,6 +79,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, @@ -93,6 +105,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 4, @@ -115,6 +131,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -137,6 +157,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, @@ -159,6 +183,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 24, @@ -181,6 +209,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 24, @@ -203,6 +235,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, @@ -225,6 +261,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v0alpha1.json index cc57f1b1914..a34b41d8bb6 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +75,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -98,6 +106,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -126,6 +138,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -160,6 +176,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v1beta1.json index 1fcfe058203..269085d6048 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +75,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -98,6 +106,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -126,6 +138,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -160,6 +176,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v0alpha1.json index 44bdfd7c1a0..d71f8e881d2 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -55,6 +59,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -83,6 +91,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -111,6 +123,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -140,6 +156,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -168,6 +188,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v1beta1.json index 432355ba671..288d4f1c8aa 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -55,6 +59,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -83,6 +91,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -111,6 +123,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -140,6 +156,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -168,6 +188,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v0alpha1.json index ec7aab7bc7a..ea66d446c51 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -50,6 +54,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -73,6 +81,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -95,6 +107,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v1beta1.json index 5aaa48878d6..bd99678a8ff 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -50,6 +54,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -73,6 +81,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -95,6 +107,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v0alpha1.json index 40a06e400a4..7e234377108 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -62,6 +66,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -102,6 +110,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 24, @@ -144,6 +156,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -186,6 +202,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v1beta1.json index a914c1f29b6..4f519fb3d9c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -62,6 +66,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -102,6 +110,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 24, @@ -144,6 +156,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -186,6 +202,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v0alpha1.json index 25823b0fd99..c4240b5dd20 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -60,6 +64,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -97,6 +105,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -136,6 +148,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -165,6 +181,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v1beta1.json index 6f5594d57f0..90783f28c4d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -60,6 +64,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -97,6 +105,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -136,6 +148,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -165,6 +181,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v0alpha1.json index ab9f271d900..a6e6e123cdb 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v1beta1.json index b17a18593b8..19143fd0ac0 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v0alpha1.json index 6bf600c5014..f55f52af9cf 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v1beta1.json index 4f174d6742a..dd7c60c0e90 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v0alpha1.json index 876be802767..aeec2f8300d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests basic migration with default style pattern (/.*/) containing thresholds and colors. Should convert styles to fieldConfig.defaults with threshold steps.", "gridPos": { "h": 3, @@ -57,6 +61,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests comprehensive migration including: default style with thresholds/colors/unit/decimals/align/colorMode, column overrides with exact name and regex patterns, date formatting, hidden columns, and links with tooltips.", "gridPos": { "h": 3, @@ -80,6 +88,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of timeseries_aggregations transform to reduce transformation with column mappings (avg-\u003emean, max-\u003emax, min-\u003emin, total-\u003esum, current-\u003elastNotNull, count-\u003ecount).", "gridPos": { "h": 3, @@ -103,6 +115,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of timeseries_to_rows transform to seriesToRows transformation.", "gridPos": { "h": 3, @@ -126,6 +142,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of timeseries_to_columns transform to seriesToColumns transformation.", "gridPos": { "h": 3, @@ -149,6 +169,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of table transform to merge transformation. Also tests auto alignment conversion to empty string.", "gridPos": { "h": 3, @@ -172,6 +196,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests that existing transformations are preserved and new transformation from old format is appended to the list.", "gridPos": { "h": 3, @@ -208,6 +236,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests handling of mixed numeric and string threshold values (int, string, float) with proper type conversion.", "gridPos": { "h": 3, @@ -231,6 +263,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests all color mode mappings: cell-\u003ecolor-background, row-\u003ecolor-background, value-\u003ecolor-text.", "gridPos": { "h": 3, @@ -254,6 +290,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests all alignment options: left, center, right, and auto (should convert to empty string).", "gridPos": { "h": 3, @@ -277,6 +317,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests both field matcher types: byName for exact matches and byRegexp for regex patterns.", "gridPos": { "h": 3, @@ -300,6 +344,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests various link configurations: with and without tooltip, with and without target blank.", "gridPos": { "h": 3, @@ -323,6 +371,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests various date format patterns and aliases.", "gridPos": { "h": 3, @@ -346,6 +398,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "React table (table2) should not be migrated. Properties should remain unchanged.", "gridPos": { "h": 3, @@ -369,6 +425,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Angular table without styles property should not be migrated.", "gridPos": { "h": 3, @@ -392,6 +452,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Non-table panels should remain completely unchanged.", "gridPos": { "h": 3, @@ -415,6 +479,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Other panel types should not be affected by table migration.", "gridPos": { "h": 3, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v1beta1.json index d79faf07932..f9a126e5f34 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests basic migration with default style pattern (/.*/) containing thresholds and colors. Should convert styles to fieldConfig.defaults with threshold steps.", "gridPos": { "h": 3, @@ -57,6 +61,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests comprehensive migration including: default style with thresholds/colors/unit/decimals/align/colorMode, column overrides with exact name and regex patterns, date formatting, hidden columns, and links with tooltips.", "gridPos": { "h": 3, @@ -80,6 +88,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of timeseries_aggregations transform to reduce transformation with column mappings (avg-\u003emean, max-\u003emax, min-\u003emin, total-\u003esum, current-\u003elastNotNull, count-\u003ecount).", "gridPos": { "h": 3, @@ -103,6 +115,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of timeseries_to_rows transform to seriesToRows transformation.", "gridPos": { "h": 3, @@ -126,6 +142,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of timeseries_to_columns transform to seriesToColumns transformation.", "gridPos": { "h": 3, @@ -149,6 +169,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of table transform to merge transformation. Also tests auto alignment conversion to empty string.", "gridPos": { "h": 3, @@ -172,6 +196,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests that existing transformations are preserved and new transformation from old format is appended to the list.", "gridPos": { "h": 3, @@ -208,6 +236,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests handling of mixed numeric and string threshold values (int, string, float) with proper type conversion.", "gridPos": { "h": 3, @@ -231,6 +263,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests all color mode mappings: cell-\u003ecolor-background, row-\u003ecolor-background, value-\u003ecolor-text.", "gridPos": { "h": 3, @@ -254,6 +290,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests all alignment options: left, center, right, and auto (should convert to empty string).", "gridPos": { "h": 3, @@ -277,6 +317,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests both field matcher types: byName for exact matches and byRegexp for regex patterns.", "gridPos": { "h": 3, @@ -300,6 +344,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests various link configurations: with and without tooltip, with and without target blank.", "gridPos": { "h": 3, @@ -323,6 +371,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests various date format patterns and aliases.", "gridPos": { "h": 3, @@ -346,6 +398,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "React table (table2) should not be migrated. Properties should remain unchanged.", "gridPos": { "h": 3, @@ -369,6 +425,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Angular table without styles property should not be migrated.", "gridPos": { "h": 3, @@ -392,6 +452,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Non-table panels should remain completely unchanged.", "gridPos": { "h": 3, @@ -415,6 +479,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Other panel types should not be affected by table migration.", "gridPos": { "h": 3, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v0alpha1.json index 704035dffdf..92c8eaf2b14 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v0alpha1.json @@ -33,6 +33,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -55,6 +59,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v1beta1.json index 7daaf9fa380..2e0d3dbe0ef 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v1beta1.json @@ -33,6 +33,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -55,6 +59,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v0alpha1.json index 4ddc375e3d0..c36b9aaa1b9 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v1beta1.json index ed958f0fc45..028ecd9792b 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v0alpha1.json index 99bdb079082..883875f9227 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v1beta1.json index e2a7c8d360f..2775106705e 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v0alpha1.json index 549d23f389c..baaa4bd3d9f 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -56,6 +60,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -78,6 +86,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -107,6 +119,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v1beta1.json index 8fdfe74a0ca..da477bdc06d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -56,6 +60,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -78,6 +86,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -107,6 +119,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v0alpha1.json index f47146292f4..103dc503a7c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -56,6 +60,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -85,6 +93,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -107,6 +119,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -136,6 +152,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -159,6 +179,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "PD8C576611E62080A" + }, "gridPos": { "h": 8, "w": 8, @@ -182,6 +206,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "PD8C576611E62080A" + }, "fieldConfig": { "defaults": { "mappings": [ @@ -249,6 +277,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "PD8C576611E62080A" + }, "gridPos": { "h": 8, "w": 8, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v1beta1.json index 9e27ce9b373..fc357d68563 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -56,6 +60,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -85,6 +93,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -107,6 +119,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -136,6 +152,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -159,6 +179,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "PD8C576611E62080A" + }, "gridPos": { "h": 8, "w": 8, @@ -182,6 +206,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "PD8C576611E62080A" + }, "fieldConfig": { "defaults": { "mappings": [ @@ -249,6 +277,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "PD8C576611E62080A" + }, "gridPos": { "h": 8, "w": 8, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v0alpha1.json index 46a5be539cf..846bb168d12 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v1beta1.json index 797aa7aa93b..da06dcbea48 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v0alpha1.json index 811f9a00d89..f1b00a18973 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -93,6 +105,10 @@ "type": "barchart" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v1beta1.json index 63203ed95de..4703aad237e 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -93,6 +105,10 @@ "type": "barchart" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v0alpha1.json index e6ec721a7f3..a7847652883 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "mappings": [ @@ -125,6 +129,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -151,6 +159,10 @@ "type": "xychart" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -178,6 +190,10 @@ "type": "xychart2" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -204,6 +220,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "mappings": [ @@ -305,6 +325,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "unit": "bytes" @@ -337,6 +361,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v1beta1.json index 240ee83acfe..21fb00e8845 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "mappings": [ @@ -125,6 +129,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -151,6 +159,10 @@ "type": "xychart" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -178,6 +190,10 @@ "type": "xychart2" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -204,6 +220,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "mappings": [ @@ -305,6 +325,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "unit": "bytes" @@ -337,6 +361,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v0alpha1.json index b3261291c34..00817bd05b6 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -59,6 +63,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -99,6 +107,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -149,6 +161,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -171,6 +187,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -259,6 +279,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v1beta1.json index 74ddec4f78c..6f3fb009a55 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -59,6 +63,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -99,6 +107,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -149,6 +161,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -171,6 +187,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -259,6 +279,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v0alpha1.json index bde81f8e5b9..3389666f1ef 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v0alpha1.json @@ -33,6 +33,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +75,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v1beta1.json index 18aee40e8eb..ecf3f1bbd52 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v1beta1.json @@ -33,6 +33,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +75,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v0alpha1.json index a860cf2dd06..142d01d9ffb 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "loki", + "uid": "non-default-test-ds-uid" + }, "description": "Tests v33 migration behavior when panel datasource is explicitly null. Should remain null after migration (returnDefaultAsNull: true).", "gridPos": { "h": 3, @@ -51,6 +55,10 @@ "type": "stat" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests v33 migration behavior when panel datasource is already a proper object reference. Should remain unchanged.", "gridPos": { "h": 3, @@ -75,6 +83,10 @@ "type": "stat" }, { + "datasource": { + "type": "loki", + "uid": "non-default-test-ds-uid" + }, "description": "Tests v33 migration when panel datasource is a string name. Should convert to proper object with uid, type, apiVersion.", "gridPos": { "h": 3, @@ -98,6 +110,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests v33 migration when panel has datasource string but empty targets array. Panel datasource should still migrate.", "gridPos": { "h": 3, @@ -283,6 +299,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Nested panel with string datasource should migrate to proper object reference, proving row panel recursion works.", "gridPos": { "h": 3, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v1beta1.json index 36b1c55fb41..d9af9bca3d7 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "loki", + "uid": "non-default-test-ds-uid" + }, "description": "Tests v33 migration behavior when panel datasource is explicitly null. Should remain null after migration (returnDefaultAsNull: true).", "gridPos": { "h": 3, @@ -51,6 +55,10 @@ "type": "stat" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests v33 migration behavior when panel datasource is already a proper object reference. Should remain unchanged.", "gridPos": { "h": 3, @@ -75,6 +83,10 @@ "type": "stat" }, { + "datasource": { + "type": "loki", + "uid": "non-default-test-ds-uid" + }, "description": "Tests v33 migration when panel datasource is a string name. Should convert to proper object with uid, type, apiVersion.", "gridPos": { "h": 3, @@ -98,6 +110,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests v33 migration when panel has datasource string but empty targets array. Panel datasource should still migrate.", "gridPos": { "h": 3, @@ -283,6 +299,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Nested panel with string datasource should migrate to proper object reference, proving row panel recursion works.", "gridPos": { "h": 3, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v0alpha1.json index af5a9eb4fb2..7755aa29cf7 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v0alpha1.json @@ -289,6 +289,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -355,6 +359,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -386,6 +394,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -492,6 +504,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -522,6 +538,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -626,6 +646,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -689,6 +713,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -738,6 +766,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -769,6 +801,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -818,6 +854,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -849,6 +889,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -927,6 +971,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -1006,6 +1054,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v1beta1.json index 8a6009228aa..1568231f000 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v1beta1.json @@ -289,6 +289,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -355,6 +359,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -386,6 +394,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -492,6 +504,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -522,6 +538,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -626,6 +646,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -689,6 +713,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -738,6 +766,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -769,6 +801,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -818,6 +854,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -849,6 +889,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -927,6 +971,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -1006,6 +1054,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v0alpha1.json index 83e0d94fd78..9aef1000c89 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -70,6 +74,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -125,6 +133,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -154,6 +166,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -183,6 +199,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -205,6 +225,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -227,6 +251,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "unit": "bytes" @@ -254,6 +282,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v1beta1.json index 59a03171c59..5b383957bd3 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -70,6 +74,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -125,6 +133,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -154,6 +166,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -183,6 +199,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -205,6 +225,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -227,6 +251,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "unit": "bytes" @@ -254,6 +282,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v0alpha1.json index 6cfba3afdaf..4678a84253d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v0alpha1.json @@ -77,6 +77,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests null panel datasource migration with targets - should fallback to default", "gridPos": { "h": 3, @@ -100,6 +104,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests null panel datasource with empty targets array - should create default target", "gridPos": { "h": 3, @@ -123,6 +131,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests null panel datasource with missing targets - should create default target array", "gridPos": { "h": 3, @@ -180,6 +192,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel with already migrated datasource object - should preserve existing refs", "gridPos": { "h": 3, @@ -203,6 +219,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "unknown-target-datasource" + }, "description": "Tests panel with unknown datasource - should preserve as UID-only reference", "gridPos": { "h": 3, @@ -260,6 +280,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel inheriting datasource from target when panel datasource was default", "gridPos": { "h": 3, @@ -283,6 +307,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel with datasource referenced by name - should migrate to full object", "gridPos": { "h": 3, @@ -306,6 +334,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel with datasource referenced by UID - should migrate to full object", "gridPos": { "h": 3, @@ -345,6 +377,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Nested panel in collapsed row with default datasource", "gridPos": { "h": 3, @@ -368,6 +404,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Nested panel in collapsed row with unknown datasource", "gridPos": { "h": 3, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v1beta1.json index 535ea1447f0..a1b5270382b 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v1beta1.json @@ -77,6 +77,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests null panel datasource migration with targets - should fallback to default", "gridPos": { "h": 3, @@ -100,6 +104,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests null panel datasource with empty targets array - should create default target", "gridPos": { "h": 3, @@ -123,6 +131,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests null panel datasource with missing targets - should create default target array", "gridPos": { "h": 3, @@ -180,6 +192,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel with already migrated datasource object - should preserve existing refs", "gridPos": { "h": 3, @@ -203,6 +219,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "unknown-target-datasource" + }, "description": "Tests panel with unknown datasource - should preserve as UID-only reference", "gridPos": { "h": 3, @@ -260,6 +280,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel inheriting datasource from target when panel datasource was default", "gridPos": { "h": 3, @@ -283,6 +307,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel with datasource referenced by name - should migrate to full object", "gridPos": { "h": 3, @@ -306,6 +334,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel with datasource referenced by UID - should migrate to full object", "gridPos": { "h": 3, @@ -345,6 +377,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Nested panel in collapsed row with default datasource", "gridPos": { "h": 3, @@ -368,6 +404,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Nested panel in collapsed row with unknown datasource", "gridPos": { "h": 3, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v0alpha1.json index 5daafe44636..5be318447ec 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v1beta1.json index e5b057639fb..8e1436e850a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v0alpha1.json index 4872dd21894..07538e41a8f 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v1beta1.json index aeb12a936e1..88148baa546 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v0alpha1.json index 7f61b3e7b90..5cdc2d5e424 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v0alpha1.json @@ -49,6 +49,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -72,6 +76,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v1beta1.json index cb826c382bd..558e765f719 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v1beta1.json @@ -49,6 +49,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -72,6 +76,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v0alpha1.json index 100c239dc08..db8c34264b4 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v1beta1.json index 2a2541d7861..18283a762cb 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v0alpha1.json index 3f575f7f5fc..8ffe55cd0d9 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v1beta1.json index fd2231c5509..d2ea95d4a03 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json index e3e0747bfc4..ecb9021b96c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json @@ -66,6 +66,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "description": "This panel demonstrates conditional rendering features", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json index 7c37ba8f8ab..35fc204793d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json @@ -66,6 +66,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "description": "This panel demonstrates conditional rendering features", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json index e648923e684..20f70a0a647 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json @@ -150,6 +150,9 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -245,6 +248,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -341,6 +348,9 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource" + }, "fieldConfig": { "defaults": { "color": { @@ -400,6 +410,10 @@ "type": "stat" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json index d2a4e93982e..9956ad6962f 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json @@ -150,6 +150,9 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -245,6 +248,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -341,6 +348,9 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource" + }, "fieldConfig": { "defaults": { "color": { @@ -400,6 +410,10 @@ "type": "stat" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json index 37bbba541cd..d57c8d086a9 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json @@ -14,6 +14,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json index 464c1d4c240..b2e7bd78dba 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json @@ -14,6 +14,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v0alpha1.json index b942305c98d..f3b6ab864fb 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v0alpha1.json @@ -66,6 +66,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "description": "This panel demonstrates conditional rendering features", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v1beta1.json index 06f8c45bdad..e3236448c9d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v1beta1.json @@ -66,6 +66,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "description": "This panel demonstrates conditional rendering features", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v0alpha1.json index c1066edc211..33fd022e0db 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v0alpha1.json @@ -80,6 +80,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "description": "This should resolve to the grafana-testdata-datasource datasource", "fieldConfig": { "defaults": { @@ -170,6 +174,9 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch" + }, "description": "This should resolve to the first elasticsearch datasource", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v1beta1.json index 9bbd2f8f555..05d6df3537a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v1beta1.json @@ -80,6 +80,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "description": "This should resolve to the grafana-testdata-datasource datasource", "fieldConfig": { "defaults": { @@ -170,6 +174,9 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch" + }, "description": "This should resolve to the first elasticsearch datasource", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json index 6a418f1e21d..4494023eb13 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json @@ -150,6 +150,9 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -245,6 +248,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -341,6 +348,9 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource" + }, "fieldConfig": { "defaults": { "color": { @@ -400,6 +410,10 @@ "type": "stat" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json index 212dbdfd0f3..bc8d90d796a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json @@ -150,6 +150,9 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -245,6 +248,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -341,6 +348,9 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource" + }, "fieldConfig": { "defaults": { "color": { @@ -400,6 +410,10 @@ "type": "stat" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v0alpha1.json index f81abd723dc..bd7c8c392ee 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v0alpha1.json @@ -67,6 +67,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -219,6 +223,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -309,6 +317,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v1beta1.json index 2ade48c620d..a81de0ed319 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v1beta1.json @@ -67,6 +67,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -219,6 +223,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -309,6 +317,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v0alpha1.json index 0357c22536a..98791135a12 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v0alpha1.json @@ -54,6 +54,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v1beta1.json index 7a9ea77ba65..38c93eb1cc8 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v1beta1.json @@ -54,6 +54,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json index ef513d48d97..716b476f825 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json @@ -54,6 +54,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -167,6 +171,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -271,6 +279,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -361,6 +373,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -467,6 +483,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json index f94a49fc420..f915142fd14 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json @@ -54,6 +54,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -167,6 +171,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -271,6 +279,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -361,6 +373,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -467,6 +483,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v0alpha1.json index 6ed7076e121..ebc40675495 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v0alpha1.json @@ -54,6 +54,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -167,6 +171,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -271,6 +279,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -361,6 +373,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v1beta1.json index 9444d6b8eef..b5c74ce6a26 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v1beta1.json @@ -54,6 +54,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -167,6 +171,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -271,6 +279,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -361,6 +373,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v0alpha1.json index bd8e440efb2..916f2442b71 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v0alpha1.json @@ -36,6 +36,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with ValueMap mapping type - maps specific text values to colors and display text", "fieldConfig": { "defaults": { @@ -104,6 +108,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text", "fieldConfig": { "defaults": { @@ -188,6 +196,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors", "fieldConfig": { "defaults": { @@ -267,6 +279,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text", "fieldConfig": { "defaults": { @@ -380,6 +396,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers", "fieldConfig": { "defaults": { @@ -567,6 +587,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with override that has empty properties array - tests conversion of overrides without any property modifications", "fieldConfig": { "overrides": [ diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v1beta1.json index 07d899db46c..9c96e15fbc3 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v1beta1.json @@ -36,6 +36,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with ValueMap mapping type - maps specific text values to colors and display text", "fieldConfig": { "defaults": { @@ -104,6 +108,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text", "fieldConfig": { "defaults": { @@ -188,6 +196,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors", "fieldConfig": { "defaults": { @@ -267,6 +279,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text", "fieldConfig": { "defaults": { @@ -380,6 +396,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers", "fieldConfig": { "defaults": { @@ -567,6 +587,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with override that has empty properties array - tests conversion of overrides without any property modifications", "fieldConfig": { "overrides": [ diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v0alpha1.json index 0502ebf6b4c..083dfb5347c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v0alpha1.json @@ -14,6 +14,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v1beta1.json index 2187a3406e7..69cec2d4e7b 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v1beta1.json @@ -14,6 +14,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go index e2a3e17843c..46a2a533d41 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go @@ -1072,10 +1072,14 @@ func convertPanelKindToV1(panelKind *dashv2alpha1.DashboardPanelKind, panel map[ } panel["targets"] = targets - // Detect mixed datasource - set panel.datasource to "mixed" if queries use different datasources - // This matches the frontend behavior in getPanelDataSource (layoutSerializers/utils.ts) - if mixedDS := detectMixedDatasource(spec.Data.Spec.Queries); mixedDS != nil { - panel["datasource"] = mixedDS + // Set panel-level datasource from queries. + // - If queries use different datasources, set to "mixed" + // - If all queries use the same datasource, set to that datasource + // This is required because the frontend's legacy PanelModel.PanelQueryRunner.run uses panel.datasource + // and some components like CSVExportPage rely on it to resolve the datasource. If undefined, it falls back to the default datasource + // which would overwrite the query's datasource. + if panelDS := getPanelDatasource(spec.Data.Spec.Queries); panelDS != nil { + panel["datasource"] = panelDS } // Convert transformations @@ -1188,12 +1192,15 @@ func getDataSourceForQuery(explicitDS *dashv2alpha1.DashboardDataSourceRef, quer return nil } -// detectMixedDatasource checks if panel queries use different datasources. -// Returns a mixed datasource reference if queries use different datasources, nil otherwise. +// getPanelDatasource determines the panel-level datasource for V1. +// Returns: +// - Mixed datasource reference if queries use different datasources +// - First query's datasource if all queries use the same datasource +// - nil if no queries exist // Compares based on V2 input without runtime resolution: // - If query has explicit datasource.uid → use that UID and type // - Else → use query.Kind as type (empty UID) -func detectMixedDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[string]interface{} { +func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[string]interface{} { if len(queries) == 0 { return nil } @@ -1232,8 +1239,21 @@ func detectMixedDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[s } } - // Not mixed - don't set panel-level datasource - return nil + // Not mixed - return the first query's datasource so the panel has a datasource set. + // This is required because the frontend's legacy PanelModel.PanelQueryRunner.run uses panel.datasource + // to resolve the datasource, and if undefined, it falls back to the default datasource + // which then overwrites the query's datasource. + if firstType == "" && firstUID == "" { + return nil + } + result := make(map[string]interface{}) + if firstType != "" { + result["type"] = firstType + } + if firstUID != "" { + result["uid"] = firstUID + } + return result } func convertLibraryPanelKindToV1(libPanelKind *dashv2alpha1.DashboardLibraryPanelKind, panel map[string]interface{}) (map[string]interface{}, error) { diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests.v42.json index a705218273e..635103053bf 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests.v42.json @@ -631,7 +631,7 @@ "transformations": [ { "id": "convertFieldType", - "options": { + "options": { "conversions": [ { "destinationType": "number", @@ -1193,4 +1193,4 @@ "title": "Panel Tests - Gauge", "uid": "_5rDmaQiz", "weekStart": "" -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json index f24ec9a61a0..61f092d491e 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json @@ -71,13 +71,13 @@ "id": 1, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -149,13 +149,13 @@ "id": 4, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -227,13 +227,13 @@ "id": 3, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -305,13 +305,13 @@ "id": 8, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -383,13 +383,13 @@ "id": 22, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -461,13 +461,13 @@ "id": 23, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -552,13 +552,13 @@ "id": 18, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.1, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -630,13 +630,13 @@ "id": 19, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.32, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -708,13 +708,13 @@ "id": 20, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.57, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -786,13 +786,13 @@ "id": 21, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.8, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -881,13 +881,13 @@ "id": 25, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, "gradient": false }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -959,13 +959,13 @@ "id": 26, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, "gradient": false }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1037,13 +1037,13 @@ "id": 29, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, "gradient": true }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1115,13 +1115,13 @@ "id": 30, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, "gradient": false }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1193,13 +1193,13 @@ "id": 28, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, "gradient": false }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1288,13 +1288,13 @@ "id": 32, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, "gradient": true }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1370,13 +1370,13 @@ "id": 34, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, "gradient": true }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1452,13 +1452,13 @@ "id": 33, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, "gradient": true }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1541,9 +1541,9 @@ "id": 9, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, - "barShape": "rounded", "effects": { "barGlow": true, "centerGlow": true, @@ -1624,6 +1624,7 @@ "id": 11, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { @@ -1631,7 +1632,6 @@ "centerGlow": true, "gradient": true }, - "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1720,6 +1720,7 @@ "id": 13, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { @@ -1727,7 +1728,6 @@ "centerGlow": true, "gradient": true }, - "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1760,6 +1760,7 @@ "startValue": 0 } ], + "title": "Active gateways", "transformations": [ { "id": "calculateField", @@ -1770,13 +1771,12 @@ }, "replaceFields": true, "unary": { - "operator": "round", - "fieldName": "A-series" + "fieldName": "A-series", + "operator": "round" } } } ], - "title": "Active gateways", "type": "radialbar" }, { @@ -1819,6 +1819,7 @@ "id": 14, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { @@ -1826,7 +1827,6 @@ "centerGlow": true, "gradient": true }, - "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1859,6 +1859,7 @@ "startValue": 0 } ], + "title": "Active pods", "transformations": [ { "id": "calculateField", @@ -1869,13 +1870,12 @@ }, "replaceFields": true, "unary": { - "operator": "round", - "fieldName": "A-series" + "fieldName": "A-series", + "operator": "round" } } } ], - "title": "Active pods", "type": "radialbar" }, { @@ -1917,6 +1917,7 @@ "id": 15, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.84, "effects": { @@ -1924,7 +1925,6 @@ "centerGlow": true, "gradient": true }, - "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1999,6 +1999,7 @@ "id": 16, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.66, "effects": { @@ -2006,7 +2007,6 @@ "centerGlow": true, "gradient": true }, - "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2086,13 +2086,13 @@ }, "id": 36, "options": { + "barShape": "flat", "barWidthFactor": 0.5, "effects": { "barGlow": false, "centerGlow": false, "gradient": true }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -2153,13 +2153,13 @@ }, "id": 37, "options": { + "barShape": "flat", "barWidthFactor": 0.5, "effects": { "barGlow": false, "centerGlow": false, "gradient": true }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -2201,4 +2201,4 @@ "title": "Panel tests - Gauge (new)", "uid": "panel-tests-gauge-new", "weekStart": "" -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json index a75a58b7530..449955a9cc8 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json @@ -1161,4 +1161,4 @@ "title": "Panel tests - Old gauge to new", "uid": "panel-tests-old-gauge-to-new", "weekStart": "" -} +} \ No newline at end of file diff --git a/public/app/features/dashboard-scene/utils/createPanelDataProvider.ts b/public/app/features/dashboard-scene/utils/createPanelDataProvider.ts index 94bfc7912fa..340d512fc8f 100644 --- a/public/app/features/dashboard-scene/utils/createPanelDataProvider.ts +++ b/public/app/features/dashboard-scene/utils/createPanelDataProvider.ts @@ -1,5 +1,6 @@ import { config } from '@grafana/runtime'; import { SceneDataProvider, SceneDataTransformer, SceneQueryRunner } from '@grafana/scenes'; +import { DataQuery, DataSourceRef } from '@grafana/schema/dist/esm/index'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehaviour'; @@ -18,7 +19,8 @@ export function createPanelDataProvider(panel: PanelModel): SceneDataProvider | let dataProvider: SceneDataProvider | undefined = undefined; dataProvider = new SceneQueryRunner({ - datasource: panel.datasource ?? undefined, + // If panel.datasource is not defined, we use the first datasource from the targets (queries) + datasource: panel.datasource ?? findFirstDatasource(panel.targets), queries: panel.targets, maxDataPoints: panel.maxDataPoints ?? undefined, maxDataPointsFromWidth: true, @@ -37,3 +39,17 @@ export function createPanelDataProvider(panel: PanelModel): SceneDataProvider | transformations: panel.transformations || [], }); } + +function findFirstDatasource(targets: DataQuery[]): DataSourceRef | undefined { + const datasource = targets.find((t) => Boolean(t.datasource))?.datasource; + if (!datasource) { + return undefined; + } + + const dsRef: DataSourceRef = { + ...(datasource?.type && { type: datasource?.type }), + ...(datasource?.uid && { uid: datasource?.uid }), + }; + + return dsRef; +} From e8e2f95637297c81c7ae090ef15a108fac43cc62 Mon Sep 17 00:00:00 2001 From: Deyan Halachliyski <119334180+dhalachliyski@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:42:56 +0100 Subject: [PATCH 07/79] Alerting: Add Alert activity card to alerting home page (#115822) --- .../alerting/unified/home/GettingStarted.tsx | 17 ++++++++++++++++- public/locales/en-US/grafana.json | 3 +++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/public/app/features/alerting/unified/home/GettingStarted.tsx b/public/app/features/alerting/unified/home/GettingStarted.tsx index e25e3da07e8..9440902374d 100644 --- a/public/app/features/alerting/unified/home/GettingStarted.tsx +++ b/public/app/features/alerting/unified/home/GettingStarted.tsx @@ -4,6 +4,7 @@ import SVG from 'react-inlinesvg'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { Stack, Text, TextLink, useStyles2, useTheme2 } from '@grafana/ui'; import atAGlanceDarkSvg from 'img/alerting/at_a_glance_dark.svg'; import atAGlanceLightSvg from 'img/alerting/at_a_glance_light.svg'; @@ -123,6 +124,20 @@ export function WelcomeHeader({ className }: { className?: string }) { return ( + {config.featureToggles.alertingTriage && ( + <> + +
+ + )} ({ container: css({ color: theme.colors.text.primary, flex: 1, - minWidth: '240px', + minWidth: '160px', display: 'grid', rowGap: theme.spacing(1), gridTemplateColumns: 'min-content 1fr 1fr 1fr', diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 10967a7331c..d4274aa98cd 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3135,9 +3135,12 @@ "table": "Table" }, "welcome-header": { + "description-alert-activity": "See what is currently alerting and explore historical data to investigate current or past issues.", "description-alert-rules": "Define the condition that must be met before an alert rule fires", "description-configure-firing-alert-instances-routed-contact": "Configure how firing alert instances are routed to contact points", "description-configure-receives-notifications": "Configure who receives notifications and how they are sent", + "href-text-alert-activity": "View alert activity", + "title-alert-activity": "Alert activity", "title-alert-rules": "Alert rules", "title-contact-points": "Contact points", "title-notification-policies": "Notification policies" From d8cdee80f00d7025062f7fa1c2ddb62f45189a6f Mon Sep 17 00:00:00 2001 From: Jesse David Peterson Date: Mon, 5 Jan 2026 09:48:16 -0500 Subject: [PATCH 08/79] Canvas: Fix image loading when icon element SVG defined by field mappings (#115748) * chore(gdev-dashboard): minimal repro of escalation #19939 bug report * fix(canvas): add branching logic to handle field mapping to icons case * test(canvas): validate integration of canvas icon mappings * refactor(resource-dimension): defensive against JS `undefined` in paths --- .../panel-canvas/canvas_kitchen_sink.v42.json | 598 ++++++++++++++++++ .../panel-canvas/canvas_kitchen_sink.json | 582 +++++++++++++++++ devenv/jsonnet/dev-dashboards.libsonnet | 1 + .../panels-suite/canvas-icon-mappings.spec.ts | 99 +++ .../app/features/dimensions/resource.test.ts | 99 +++ public/app/features/dimensions/resource.ts | 12 +- 6 files changed, 1388 insertions(+), 3 deletions(-) create mode 100644 apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-canvas/canvas_kitchen_sink.v42.json create mode 100644 devenv/dev-dashboards/panel-canvas/canvas_kitchen_sink.json create mode 100644 e2e-playwright/panels-suite/canvas-icon-mappings.spec.ts diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-canvas/canvas_kitchen_sink.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-canvas/canvas_kitchen_sink.v42.json new file mode 100644 index 00000000000..cf4db2fb1b6 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-canvas/canvas_kitchen_sink.v42.json @@ -0,0 +1,598 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Mixed --" + }, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "1": { + "color": "green", + "icon": "img/icons/unicons/check-circle.svg", + "index": 0, + "text": "Success" + }, + "2": { + "color": "orange", + "icon": "img/icons/unicons/exclamation-triangle.svg", + "index": 1, + "text": "Warning" + }, + "3": { + "color": "red", + "icon": "img/icons/unicons/times-circle.svg", + "index": 2, + "text": "Error" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text" + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "success" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "1": { + "color": "green", + "icon": "img/icons/unicons/check-circle.svg", + "index": 0, + "text": "Success" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "warning" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "2": { + "color": "orange", + "icon": "img/icons/unicons/exclamation-triangle.svg", + "index": 1, + "text": "Warning" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "error" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "3": { + "color": "red", + "icon": "img/icons/unicons/times-circle.svg", + "index": 2, + "text": "Error" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "unmapped" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "1": { + "color": "green", + "icon": "img/icons/unicons/check-circle.svg", + "index": 0, + "text": "Success" + }, + "2": { + "color": "orange", + "icon": "img/icons/unicons/exclamation-triangle.svg", + "index": 1, + "text": "Warning" + }, + "3": { + "color": "red", + "icon": "img/icons/unicons/times-circle.svg", + "index": 2, + "text": "Error" + } + }, + "type": "value" + } + ] + } + ] + } + ] + }, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "inlineEditing": true, + "root": { + "background": { + "color": { + "fixed": "transparent" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "elements": [ + { + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 16, + "text": { + "fixed": "Field-based Icons (from value mappings):", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Header", + "placement": { + "height": 40, + "left": 20, + "top": 10, + "width": 400 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "success", + "fixed": "green" + }, + "path": { + "field": "success", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Success Icon", + "placement": { + "height": 50, + "left": 50, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "success", + "fixed": "text" + }, + "size": 12, + "text": { + "field": "success", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Success Text", + "placement": { + "height": 25, + "left": 30, + "top": 115, + "width": 90 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "warning", + "fixed": "orange" + }, + "path": { + "field": "warning", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Warning Icon", + "placement": { + "height": 50, + "left": 180, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "warning", + "fixed": "text" + }, + "size": 12, + "text": { + "field": "warning", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Warning Text", + "placement": { + "height": 25, + "left": 160, + "top": 115, + "width": 90 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "error", + "fixed": "red" + }, + "path": { + "field": "error", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Error Icon", + "placement": { + "height": 50, + "left": 310, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "error", + "fixed": "text" + }, + "size": 12, + "text": { + "field": "error", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Error Text", + "placement": { + "height": 25, + "left": 290, + "top": 115, + "width": 90 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "unmapped", + "fixed": "#808080" + }, + "path": { + "field": "unmapped", + "fixed": "img/icons/unicons/question-circle.svg", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Unmapped Icon", + "placement": { + "height": 50, + "left": 440, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "unmapped", + "fixed": "text" + }, + "size": 12, + "text": { + "fixed": "No mapping (14)", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Unmapped Text", + "placement": { + "height": 25, + "left": 410, + "top": 115, + "width": 110 + }, + "type": "text" + }, + { + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 14, + "text": { + "fixed": "Fixed Relative Path:", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Relative Label", + "placement": { + "height": 30, + "left": 50, + "top": 170, + "width": 200 + }, + "type": "text" + }, + { + "config": { + "fill": { + "fixed": "blue" + }, + "path": { + "fixed": "img/icons/unicons/cloud.svg", + "mode": "fixed" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Relative Icon", + "placement": { + "height": 50, + "left": 260, + "top": 165, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 14, + "text": { + "fixed": "Fixed Absolute URL:", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Absolute Label", + "placement": { + "height": 30, + "left": 50, + "top": 240, + "width": 200 + }, + "type": "text" + }, + { + "config": { + "fill": { + "fixed": "purple" + }, + "path": { + "fixed": "https://grafana.com/static/assets/img/grafana_icon.svg", + "mode": "fixed" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Absolute Icon", + "placement": { + "height": 50, + "left": 260, + "top": 235, + "width": 50 + }, + "type": "icon" + } + ], + "name": "Canvas Root", + "placement": { + "height": 100, + "left": 0, + "top": 0, + "width": 100 + }, + "type": "frame" + } + }, + "pluginVersion": "12.1.0", + "targets": [ + { + "csvContent": "success\n1", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_content" + }, + { + "csvContent": "warning\n2", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "B", + "scenarioId": "csv_content" + }, + { + "csvContent": "error\n3", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "C", + "scenarioId": "csv_content" + }, + { + "csvContent": "unmapped\n14", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "D", + "scenarioId": "csv_content" + } + ], + "title": "Various SVG icons", + "type": "canvas" + } + ], + "refresh": "", + "schemaVersion": 42, + "tags": [ + "canvas", + "icons", + "test", + "v2" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Panel tests - Canvas - Kitchen sink", + "uid": "canvas-icon-fix-test-v2", + "weekStart": "" +} \ No newline at end of file diff --git a/devenv/dev-dashboards/panel-canvas/canvas_kitchen_sink.json b/devenv/dev-dashboards/panel-canvas/canvas_kitchen_sink.json new file mode 100644 index 00000000000..069a43c0888 --- /dev/null +++ b/devenv/dev-dashboards/panel-canvas/canvas_kitchen_sink.json @@ -0,0 +1,582 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Mixed --" + }, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "1": { + "color": "green", + "icon": "img/icons/unicons/check-circle.svg", + "index": 0, + "text": "Success" + }, + "2": { + "color": "orange", + "icon": "img/icons/unicons/exclamation-triangle.svg", + "index": 1, + "text": "Warning" + }, + "3": { + "color": "red", + "icon": "img/icons/unicons/times-circle.svg", + "index": 2, + "text": "Error" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "success" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "1": { + "color": "green", + "icon": "img/icons/unicons/check-circle.svg", + "index": 0, + "text": "Success" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "warning" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "2": { + "color": "orange", + "icon": "img/icons/unicons/exclamation-triangle.svg", + "index": 1, + "text": "Warning" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "error" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "3": { + "color": "red", + "icon": "img/icons/unicons/times-circle.svg", + "index": 2, + "text": "Error" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "unmapped" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "1": { + "color": "green", + "icon": "img/icons/unicons/check-circle.svg", + "index": 0, + "text": "Success" + }, + "2": { + "color": "orange", + "icon": "img/icons/unicons/exclamation-triangle.svg", + "index": 1, + "text": "Warning" + }, + "3": { + "color": "red", + "icon": "img/icons/unicons/times-circle.svg", + "index": 2, + "text": "Error" + } + }, + "type": "value" + } + ] + } + ] + } + ] + }, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "inlineEditing": true, + "root": { + "background": { + "color": { + "fixed": "transparent" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "elements": [ + { + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 16, + "text": { + "fixed": "Field-based Icons (from value mappings):", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Header", + "placement": { + "height": 40, + "left": 20, + "top": 10, + "width": 400 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "success", + "fixed": "green" + }, + "path": { + "field": "success", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Success Icon", + "placement": { + "height": 50, + "left": 50, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "success", + "fixed": "text" + }, + "size": 12, + "text": { + "field": "success", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Success Text", + "placement": { + "height": 25, + "left": 30, + "top": 115, + "width": 90 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "warning", + "fixed": "orange" + }, + "path": { + "field": "warning", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Warning Icon", + "placement": { + "height": 50, + "left": 180, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "warning", + "fixed": "text" + }, + "size": 12, + "text": { + "field": "warning", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Warning Text", + "placement": { + "height": 25, + "left": 160, + "top": 115, + "width": 90 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "error", + "fixed": "red" + }, + "path": { + "field": "error", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Error Icon", + "placement": { + "height": 50, + "left": 310, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "error", + "fixed": "text" + }, + "size": 12, + "text": { + "field": "error", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Error Text", + "placement": { + "height": 25, + "left": 290, + "top": 115, + "width": 90 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "unmapped", + "fixed": "#808080" + }, + "path": { + "field": "unmapped", + "fixed": "img/icons/unicons/question-circle.svg", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Unmapped Icon", + "placement": { + "height": 50, + "left": 440, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "unmapped", + "fixed": "text" + }, + "size": 12, + "text": { + "fixed": "No mapping (14)", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Unmapped Text", + "placement": { + "height": 25, + "left": 410, + "top": 115, + "width": 110 + }, + "type": "text" + }, + { + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 14, + "text": { + "fixed": "Fixed Relative Path:", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Relative Label", + "placement": { + "height": 30, + "left": 50, + "top": 170, + "width": 200 + }, + "type": "text" + }, + { + "config": { + "fill": { + "fixed": "blue" + }, + "path": { + "fixed": "img/icons/unicons/cloud.svg", + "mode": "fixed" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Relative Icon", + "placement": { + "height": 50, + "left": 260, + "top": 165, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 14, + "text": { + "fixed": "Fixed Absolute URL:", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Absolute Label", + "placement": { + "height": 30, + "left": 50, + "top": 240, + "width": 200 + }, + "type": "text" + }, + { + "config": { + "fill": { + "fixed": "purple" + }, + "path": { + "fixed": "https://grafana.com/static/assets/img/grafana_icon.svg", + "mode": "fixed" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Absolute Icon", + "placement": { + "height": 50, + "left": 260, + "top": 235, + "width": 50 + }, + "type": "icon" + } + ], + "name": "Canvas Root", + "placement": { + "height": 100, + "left": 0, + "top": 0, + "width": 100 + }, + "type": "frame" + } + }, + "pluginVersion": "12.1.0", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_content", + "csvContent": "success\n1" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "B", + "scenarioId": "csv_content", + "csvContent": "warning\n2" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "C", + "scenarioId": "csv_content", + "csvContent": "error\n3" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "D", + "scenarioId": "csv_content", + "csvContent": "unmapped\n14" + } + ], + "title": "Various SVG icons", + "type": "canvas" + } + ], + "schemaVersion": 39, + "tags": ["canvas", "icons", "test", "v2"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Panel tests - Canvas - Kitchen sink", + "uid": "canvas-icon-fix-test-v2", + "version": 1, + "weekStart": "" +} diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet index cac117cd4e7..98eb14e9217 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -24,6 +24,7 @@ "canvas-connection-examples": (import '../dev-dashboards/panel-canvas/canvas-connection-examples.json'), "canvas-datalinks": (import '../dev-dashboards/panel-canvas/canvas-datalinks.json'), "canvas-examples": (import '../dev-dashboards/panel-canvas/canvas-examples.json'), + "canvas_kitchen_sink": (import '../dev-dashboards/panel-canvas/canvas_kitchen_sink.json'), "color_modes": (import '../dev-dashboards/panel-common/color_modes.json'), "config-from-query": (import '../dev-dashboards/transforms/config-from-query.json'), "dashlist": (import '../dev-dashboards/panel-dashlist/dashlist.json'), diff --git a/e2e-playwright/panels-suite/canvas-icon-mappings.spec.ts b/e2e-playwright/panels-suite/canvas-icon-mappings.spec.ts new file mode 100644 index 00000000000..72de7a6c62c --- /dev/null +++ b/e2e-playwright/panels-suite/canvas-icon-mappings.spec.ts @@ -0,0 +1,99 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +const DASHBOARD_UID = 'canvas-icon-fix-test-v2'; +const PANEL_TITLE = 'Various SVG icons'; + +test.describe('Canvas Panel - Icon Mappings', () => { + test('should render field-based icons from value mappings correctly', async ({ gotoDashboardPage, page }) => { + await test.step('Navigate to dashboard and wait for panel to load', async () => { + await gotoDashboardPage({ uid: DASHBOARD_UID }); + await page.waitForSelector('svg', { timeout: 10000 }); + }); + + await test.step('Verify value mapping text values are displayed', async () => { + await expect(page.getByText('Success')).toBeVisible(); + await expect(page.getByText('Warning')).toBeVisible(); + await expect(page.getByText('Error')).toBeVisible(); + }); + + await test.step('Verify SVG icons rendered for mapped values', async () => { + const svgCount = await page.locator('svg').count(); + expect(svgCount).toBeGreaterThanOrEqual(3); + }); + }); + + test('should render fixed path icons correctly', async ({ gotoDashboardPage, page }) => { + await test.step('Set up network interception for absolute URL icon', async () => { + await page.route('https://grafana.com/static/assets/img/grafana_icon.svg', async (route) => { + const dummySvg = ` + + TEST + `; + await route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: dummySvg, + }); + }); + }); + + await test.step('Navigate to dashboard and wait for SVGs to load', async () => { + await gotoDashboardPage({ uid: DASHBOARD_UID }); + await page.waitForSelector('svg:not([aria-hidden="true"])', { timeout: 10000 }); + await page.waitForLoadState('networkidle', { timeout: 10000 }); + }); + + await test.step('Verify at least 5 visible SVG icons are rendered (3 mapped + 2 fixed)', async () => { + const visibleSvgs = page.locator('svg:not([aria-hidden="true"])'); + const svgCount = await visibleSvgs.count(); + expect(svgCount).toBeGreaterThanOrEqual(5); + }); + + await test.step('Verify visible SVG icons have content', async () => { + const visibleSvgs = page.locator('svg:not([aria-hidden="true"])'); + const count = await visibleSvgs.count(); + + for (let i = 0; i < Math.min(count, 5); i++) { + const svg = visibleSvgs.nth(i); + await expect(svg).toBeAttached(); + const svgContent = await svg.innerHTML(); + expect(svgContent.length).toBeGreaterThan(0); + } + }); + }); + + test('should not make invalid requests for unmapped numeric values', async ({ gotoDashboardPage, page }) => { + const failedRequests: string[] = []; + + await test.step('Set up network request monitoring', async () => { + page.on('requestfailed', (request) => { + const url = request.url(); + if (url.match(/\/build\/\d+$/)) { + failedRequests.push(url); + } + }); + }); + + await test.step('Navigate to dashboard and wait for loading', async () => { + await gotoDashboardPage({ uid: DASHBOARD_UID }); + await page.waitForTimeout(2000); + }); + + await test.step('Verify no invalid numeric path requests were made', async () => { + expect(failedRequests).toHaveLength(0); + }); + }); + + test('should display text values from value mappings correctly', async ({ gotoDashboardPage, page }) => { + await test.step('Navigate to dashboard', async () => { + await gotoDashboardPage({ uid: DASHBOARD_UID }); + }); + + await test.step('Verify mapped text values are displayed', async () => { + await expect(page.getByText('Success')).toBeVisible(); + await expect(page.getByText('Warning')).toBeVisible(); + await expect(page.getByText('Error')).toBeVisible(); + await expect(page.getByText('No mapping (14)')).toBeVisible(); + }); + }); +}); diff --git a/public/app/features/dimensions/resource.test.ts b/public/app/features/dimensions/resource.test.ts index ad644b61b4f..ac4d1c349dc 100644 --- a/public/app/features/dimensions/resource.test.ts +++ b/public/app/features/dimensions/resource.test.ts @@ -103,6 +103,84 @@ describe('getResourceDimension', () => { expect(getResourceDimension(frame, config).value()).toEqual(''); }); + it('should handle numeric field values with icon from value mapping', () => { + const publicPath = 'https://grafana.fake/public/'; + const frame = createDataFrame({ + fields: [ + { + name: 'status_field', + values: [1, 2, 3], + display: (v) => ({ + text: v === 1 ? 'Success' : v === 2 ? 'Warning' : 'Error', + numeric: Number(v), + icon: + v === 1 + ? 'img/icons/unicons/check-circle.svg' + : v === 2 + ? 'img/icons/unicons/exclamation-triangle.svg' + : 'img/icons/unicons/times-circle.svg', + }), + }, + ], + }); + const config = { mode: ResourceDimensionMode.Field, field: 'status_field', fixed: '' }; + + expect(getResourceDimension(frame, config).get(0)).toEqual(`${publicPath}build/img/icons/unicons/check-circle.svg`); + expect(getResourceDimension(frame, config).get(1)).toEqual( + `${publicPath}build/img/icons/unicons/exclamation-triangle.svg` + ); + expect(getResourceDimension(frame, config).get(2)).toEqual(`${publicPath}build/img/icons/unicons/times-circle.svg`); + }); + + it('should return empty string for unmapped numeric values without icon', () => { + const frame = createDataFrame({ + fields: [ + { + name: 'status_field', + values: [14], + display: (v) => ({ + text: String(v), + numeric: Number(v), + icon: undefined, + }), + }, + ], + }); + const config = { mode: ResourceDimensionMode.Field, field: 'status_field', fixed: '' }; + + expect(getResourceDimension(frame, config).get(0)).toEqual(''); + expect(getResourceDimension(frame, config).value()).toEqual(''); + }); + + it('should handle mixed numeric values with partial mappings', () => { + const publicPath = 'https://grafana.fake/public/'; + const frame = createDataFrame({ + fields: [ + { + name: 'status_field', + values: [1, 99, 2], + display: (v) => ({ + text: v === 1 ? 'Success' : v === 2 ? 'Warning' : String(v), + numeric: Number(v), + icon: + v === 1 + ? 'img/icons/unicons/check-circle.svg' + : v === 2 + ? 'img/icons/unicons/exclamation-triangle.svg' + : undefined, + }), + }, + ], + }); + const config = { mode: ResourceDimensionMode.Field, field: 'status_field', fixed: '' }; + + expect(getResourceDimension(frame, config).get(0)).toEqual(`${publicPath}build/img/icons/unicons/check-circle.svg`); + expect(getResourceDimension(frame, config).get(1)).toEqual(''); + expect(getResourceDimension(frame, config).get(2)).toEqual( + `${publicPath}build/img/icons/unicons/exclamation-triangle.svg` + ); + }); + // TODO: write tests for mapping modes }); @@ -125,4 +203,25 @@ describe('getPublicOrAbsoluteUrl', () => { expect(getPublicOrAbsoluteUrl({ path: 'icon.png' })).toEqual(''); expect(getPublicOrAbsoluteUrl(['icon.png'])).toEqual(''); }); + + it('should handle undefined publicPath gracefully', () => { + const originalPath = window.__grafana_public_path__; + + // @ts-ignore - Intentionally testing runtime edge case + window.__grafana_public_path__ = undefined; + + expect(getPublicOrAbsoluteUrl('icon.png')).toEqual('/build/icon.png'); + + window.__grafana_public_path__ = originalPath; + }); + + it('should handle empty string publicPath gracefully', () => { + const originalPath = window.__grafana_public_path__; + + window.__grafana_public_path__ = ''; + + expect(getPublicOrAbsoluteUrl('icon.png')).toEqual('/build/icon.png'); + + window.__grafana_public_path__ = originalPath; + }); }); diff --git a/public/app/features/dimensions/resource.ts b/public/app/features/dimensions/resource.ts index 4eb28242550..822d808b016 100644 --- a/public/app/features/dimensions/resource.ts +++ b/public/app/features/dimensions/resource.ts @@ -15,8 +15,9 @@ export function getPublicOrAbsoluteUrl(path: unknown): string { // NOTE: The value of `path` could be either an URL string or a relative // path to a Grafana CDN asset served from the CDN. const isUrl = path.indexOf(':/') > 0; + const publicPath = window.__grafana_public_path__ || '/'; - return isUrl ? path : `${window.__grafana_public_path__}build/${path}`; + return isUrl ? path : `${publicPath}build/${path}`; } export function getResourceDimension( @@ -56,11 +57,11 @@ export function getResourceDimension( // mode === ResourceDimensionMode.Field case const getImageOrIcon = (value: unknown): string => { - if (typeof value !== 'string') { + if (typeof value !== 'string' && typeof value !== 'number') { return ''; } - let url = value; + let url = typeof value === 'string' ? value : ''; if (field && field.display) { const displayValue = field.display(value); if (displayValue.icon) { @@ -68,6 +69,11 @@ export function getResourceDimension( } } + const noIconFound = !url; + if (noIconFound) { + return ''; + } + return getPublicOrAbsoluteUrl(url); }; From 5698f2d03994ea2e5531e8be9a0f464b354c7420 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Mon, 5 Jan 2026 08:56:21 -0600 Subject: [PATCH 09/79] Tooltips: Prevent dismissing with keyboard shortcuts (#115802) --- e2e-playwright/panels-suite/state-timeline.spec.ts | 10 ++++++++++ e2e-playwright/panels-suite/status-history.spec.ts | 10 ++++++++++ .../src/components/uPlot/plugins/TooltipPlugin2.tsx | 9 +++++++++ 3 files changed, 29 insertions(+) diff --git a/e2e-playwright/panels-suite/state-timeline.spec.ts b/e2e-playwright/panels-suite/state-timeline.spec.ts index ebdeb8d5398..d851a25f8af 100644 --- a/e2e-playwright/panels-suite/state-timeline.spec.ts +++ b/e2e-playwright/panels-suite/state-timeline.spec.ts @@ -78,6 +78,16 @@ test.describe('Panels test: StateTimeine', { tag: ['@panels', '@state-timeline'] await dashboardPage.getByGrafanaSelector(selectors.components.Portal.container).getByLabel('Close').click(); await expect(tooltip, 'tooltip closed on "x" click').toBeHidden(); + // test that CMD/CTRL+C doesn't dismiss the tooltip + await stateTimelineUplot.click({ position: { x: 100, y: 50 } }); + await expect(tooltip, 'tooltip appears on click').toBeVisible(); + await page.keyboard.press('Meta+C'); + await expect(tooltip, 'tooltip persists after CMD/CTRL+C').toBeVisible(); + + // test that Escape key dismisses the tooltip + await page.keyboard.press('Escape'); + await expect(tooltip, 'tooltip closed on Escape key').toBeHidden(); + // disable tooltips await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Tooltip Tooltip mode')) diff --git a/e2e-playwright/panels-suite/status-history.spec.ts b/e2e-playwright/panels-suite/status-history.spec.ts index bece3aa47c7..b2e628c0795 100644 --- a/e2e-playwright/panels-suite/status-history.spec.ts +++ b/e2e-playwright/panels-suite/status-history.spec.ts @@ -76,6 +76,16 @@ test.describe('Panels test: StatusHistory', { tag: ['@panels', '@status-history' await dashboardPage.getByGrafanaSelector(selectors.components.Portal.container).getByLabel('Close').click(); await expect(tooltip, 'tooltip closed on "x" click').toBeHidden(); + // test that CMD/CTRL+C doesn't dismiss the tooltip + await statusHistoryUplot.click({ position: { x: 100, y: 50 } }); + await expect(tooltip, 'tooltip appears on click').toBeVisible(); + await page.keyboard.press('Meta+C'); + await expect(tooltip, 'tooltip persists after CMD/CTRL+C').toBeVisible(); + + // test that Escape key dismisses the tooltip + await page.keyboard.press('Escape'); + await expect(tooltip, 'tooltip closed on Escape key').toBeHidden(); + // disable tooltips await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Tooltip Tooltip mode')) diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx index 606839aacad..138ec2bea42 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx @@ -240,6 +240,15 @@ export const TooltipPlugin2 = ({ // in some ways this is similar to ClickOutsideWrapper.tsx const downEventOutside = (e: Event) => { + if (e instanceof KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + dismiss(); + } + return; + } + // this tooltip is Portaled, but actions inside it create forms in Modals const isModalOrPortaled = '[role="dialog"], #grafana-portal-container'; From e310d5e8ee249d7424876de8758b425c7161af62 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 5 Jan 2026 15:27:35 +0000 Subject: [PATCH 10/79] FS: Only attempt session rotation if expiration cookie exists (#115824) don't attempt rotation if no expiration cookie exists --- pkg/services/frontend/index.html | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/services/frontend/index.html b/pkg/services/frontend/index.html index 198b8216189..777513358c1 100644 --- a/pkg/services/frontend/index.html +++ b/pkg/services/frontend/index.html @@ -250,11 +250,14 @@ } } - return null; + return undefined; } function getSessionExpiration() { - const value = getCookie("grafana_session_expiry") || "0"; + const value = getCookie("grafana_session_expiry"); + if (!value) { + return undefined; + } const realExpiresSeconds = parseInt(value, 10); const expiresSeconds = Math.max(realExpiresSeconds - 10, 0); // Rotate 10s before the real expiration const expiration = new Date(expiresSeconds * 1000); @@ -332,7 +335,7 @@ const now = new Date(); // If the session has expired, don't continue trying to fetch boot data - if (now >= sessionExpiration) { + if (sessionExpiration && now >= sessionExpiration) { await rotateSession(); } } catch (error) { From 6adc45bf30ba6ef3aa2835ad45baf0abc11df514 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 5 Jan 2026 15:27:49 +0000 Subject: [PATCH 11/79] FS: Allow anonymous access to snapshot route (#115829) allow anonymous access to snapshot route --- public/app/core/navigation/GrafanaRoute.tsx | 8 ++++++-- public/app/core/navigation/types.ts | 3 ++- public/app/routes/routes.tsx | 8 +++++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/public/app/core/navigation/GrafanaRoute.tsx b/public/app/core/navigation/GrafanaRoute.tsx index e17a3bf6a4a..3820f0a7d9b 100644 --- a/public/app/core/navigation/GrafanaRoute.tsx +++ b/public/app/core/navigation/GrafanaRoute.tsx @@ -1,5 +1,5 @@ import { Suspense, useEffect, useLayoutEffect } from 'react'; -import { Navigate, useLocation } from 'react-router-dom-v5-compat'; +import { Navigate, useLocation, useParams } from 'react-router-dom-v5-compat'; import { config, locationSearchToObject, navigationLogger, reportPageview } from '@grafana/runtime'; import { ErrorBoundary } from '@grafana/ui'; @@ -63,10 +63,14 @@ export function GrafanaRoute(props: Props) { export function GrafanaRouteWrapper({ route }: Pick) { const location = useLocation(); + const params = useParams(); + + const allowAnonymous = + typeof route.allowAnonymous === 'function' ? route.allowAnonymous(params) : route.allowAnonymous; // Perform login check in the frontend now if (isFrontendService()) { - const routeRequiresSignin = !route.allowAnonymous && !config.anonymousEnabled; + const routeRequiresSignin = !allowAnonymous && !config.anonymousEnabled; if (routeRequiresSignin && !contextSrv.isSignedIn) { contextSrv.setRedirectToUrl(); diff --git a/public/app/core/navigation/types.ts b/public/app/core/navigation/types.ts index 757eeefb725..2188cf3935b 100644 --- a/public/app/core/navigation/types.ts +++ b/public/app/core/navigation/types.ts @@ -1,5 +1,6 @@ import { Location } from 'history'; import { ComponentType } from 'react'; +import { Params } from 'react-router-dom-v5-compat'; import { UrlQueryMap } from '@grafana/data'; @@ -25,5 +26,5 @@ export interface RouteDescriptor { * Allow the route to be access by anonymous users. * Currently only used when using the frontend-service. */ - allowAnonymous?: boolean; + allowAnonymous?: boolean | ((params: Readonly>) => boolean); } diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 78cf632a1b1..8b8b3213004 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -30,7 +30,7 @@ const isDevEnv = config.buildInfo.env === 'development'; export const extraRoutes: RouteDescriptor[] = []; export function getAppRoutes(): RouteDescriptor[] { - return [ + const routes: Array = [ // Based on the Grafana configuration standalone plugin pages can even override and extend existing core pages, or they can register new routes under existing ones. // In order to make it possible we need to register them first due to how `` is evaluating routes. (This will be unnecessary once/when we upgrade to React Router v6 and start using `` instead.) ...getAppPluginRoutes(), @@ -77,6 +77,7 @@ export function getAppRoutes(): RouteDescriptor[] { }, { path: '/dashboard/:type/:slug', + allowAnonymous: (params) => params.type === 'snapshot', pageClass: 'page-dashboard', routeName: DashboardRoutes.Normal, component: SafeDynamicImport( @@ -223,7 +224,6 @@ export function getAppRoutes(): RouteDescriptor[] { }, { path: '/admin/extensions', - navId: 'extensions', roles: () => contextSrv.evaluatePermission([AccessControlAction.PluginsInstall, AccessControlAction.PluginsWrite]), component: @@ -560,7 +560,9 @@ export function getAppRoutes(): RouteDescriptor[] { path: '/*', component: PageNotFound, }, - ].filter(isTruthy); + ]; + + return routes.filter(isTruthy); } export function getSupportBundleRoutes(cfg = config): RouteDescriptor[] { From dc992b62b617d1af311aacc63a5a082bed0f390a Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Mon, 5 Jan 2026 08:47:51 -0700 Subject: [PATCH 12/79] Zanzana: Only increment reconciliation metric if successful across all namespaces (#115807) --- .../accesscontrol/dualwrite/reconciler.go | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/pkg/services/accesscontrol/dualwrite/reconciler.go b/pkg/services/accesscontrol/dualwrite/reconciler.go index ab27972e86e..ff6637219a4 100644 --- a/pkg/services/accesscontrol/dualwrite/reconciler.go +++ b/pkg/services/accesscontrol/dualwrite/reconciler.go @@ -201,20 +201,20 @@ func (r *ZanzanaReconciler) waitForBasicRolesSeeded(ctx context.Context) { } func (r *ZanzanaReconciler) reconcile(ctx context.Context) { - run := func(ctx context.Context, namespace string) { + run := func(ctx context.Context, namespace string) (ok bool) { now := time.Now() r.log.Debug("Started reconciliation") + ok = true for _, reconciler := range r.reconcilers { r.log.Debug("Performing zanzana reconciliation", "reconciler", reconciler.name) if err := reconciler.reconcile(ctx, namespace); err != nil { r.log.Warn("Failed to perform reconciliation for resource", "err", err) + ok = false } } - if r.metrics.lastSuccess != nil { - r.metrics.lastSuccess.SetToCurrentTime() - } r.log.Debug("Finished reconciliation", "elapsed", time.Since(now)) + return ok } var namespaces []string @@ -239,16 +239,28 @@ func (r *ZanzanaReconciler) reconcile(ctx context.Context) { } if r.lock == nil { + allOK := true for _, ns := range namespaces { - run(ctx, ns) + if !run(ctx, ns) { + allOK = false + } + } + if r.metrics.lastSuccess != nil && allOK { + r.metrics.lastSuccess.SetToCurrentTime() } return } // We ignore the error for now err := r.lock.LockExecuteAndRelease(ctx, "zanzana-reconciliation", 10*time.Hour, func(ctx context.Context) { + allOK := true for _, ns := range namespaces { - run(ctx, ns) + if !run(ctx, ns) { + allOK = false + } + } + if r.metrics.lastSuccess != nil && allOK { + r.metrics.lastSuccess.SetToCurrentTime() } }) if err != nil { From e9e507a88774984de08452e17ce55e55e2ca33e3 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Mon, 5 Jan 2026 08:48:00 -0700 Subject: [PATCH 13/79] Zanzana: Add reconcilation verbs (#115772) --- pkg/services/authz/zanzana/zanzana.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/services/authz/zanzana/zanzana.go b/pkg/services/authz/zanzana/zanzana.go index 261ea78830d..e9d7eecba41 100644 --- a/pkg/services/authz/zanzana/zanzana.go +++ b/pkg/services/authz/zanzana/zanzana.go @@ -45,8 +45,16 @@ const ( ) var ( - RelationsFolder = common.RelationsTyped - RelationsResouce = common.RelationsResource + // RelationsFolder is used by reconciliation to list tuples for folder objects. + // It must include both verb relations (get/update/delete/...) and the permission-set relations (view/edit/admin) + RelationsFolder = append(append([]string{}, common.RelationsTyped...), + common.RelationSetView, common.RelationSetEdit, common.RelationSetAdmin, + ) + // RelationsResouce is used by reconciliation to list tuples for resource objects. + // Include permission-set relations for the same reason as RelationsFolder. + RelationsResouce = append(append([]string{}, common.RelationsResource...), + common.RelationSetView, common.RelationSetEdit, common.RelationSetAdmin, + ) RelationsSubresource = common.RelationsSubresource ) From 158fc09015e578756bdd0118a1cca7055438eca0 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Mon, 5 Jan 2026 08:55:26 -0700 Subject: [PATCH 14/79] Zanzana: Reset on migration failures (#115806) --- go.mod | 3 +- go.sum | 5 +- go.work.sum | 5 +- .../authz/zanzana/store/migration/migrator.go | 69 ++++++++++++++++- .../zanzana/store/migration/migrator_test.go | 74 +++++++++++++++++++ 5 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 pkg/services/authz/zanzana/store/migration/migrator_test.go diff --git a/go.mod b/go.mod index 83d82e3af5d..fa38e9ec99d 100644 --- a/go.mod +++ b/go.mod @@ -154,6 +154,7 @@ require ( github.com/openzipkin/zipkin-go v0.4.3 // @grafana/oss-big-tent github.com/patrickmn/go-cache v2.1.0+incompatible // @grafana/alerting-backend github.com/phpdave11/gofpdi v1.0.14 // @grafana/sharing-squad + github.com/pressly/goose/v3 v3.26.0 // @grafana/identity-access-team github.com/prometheus/alertmanager v0.28.2 // @grafana/alerting-backend github.com/prometheus/client_golang v1.23.2 // @grafana/alerting-backend github.com/prometheus/client_model v0.6.2 // @grafana/grafana-backend-group @@ -557,7 +558,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/pressly/goose/v3 v3.26.0 // indirect github.com/prometheus/common/sigv4 v0.1.0 // indirect github.com/prometheus/exporter-toolkit v0.14.0 // indirect github.com/prometheus/procfs v0.19.2 // indirect @@ -681,6 +681,7 @@ require ( github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/gophercloud/gophercloud/v2 v2.9.0 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/moby/go-archive v0.1.0 // indirect diff --git a/go.sum b/go.sum index 2b3b2cb4e3f..7d2582cf711 100644 --- a/go.sum +++ b/go.sum @@ -1607,9 +1607,8 @@ github.com/googleapis/gnostic v0.3.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTV github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gophercloud/gophercloud v0.3.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= -github.com/gophercloud/gophercloud v1.13.0 h1:8iY9d1DAbzMW6Vok1AxbbK5ZaUjzMp0tdyt4fX9IeJ0= -github.com/gophercloud/gophercloud/v2 v2.6.0 h1:XJKQ0in3iHOZHVAFMXq/OhjCuvvG+BKR0unOqRfG1EI= -github.com/gophercloud/gophercloud/v2 v2.6.0/go.mod h1:Ki/ILhYZr/5EPebrPL9Ej+tUg4lqx71/YH2JWVeU+Qk= +github.com/gophercloud/gophercloud/v2 v2.9.0 h1:Y9OMrwKF9EDERcHFSOTpf/6XGoAI0yOxmsLmQki4LPM= +github.com/gophercloud/gophercloud/v2 v2.9.0/go.mod h1:Ki/ILhYZr/5EPebrPL9Ej+tUg4lqx71/YH2JWVeU+Qk= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= diff --git a/go.work.sum b/go.work.sum index ca22b546c86..f676971746a 100644 --- a/go.work.sum +++ b/go.work.sum @@ -533,12 +533,12 @@ github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= +github.com/centrifugal/centrifuge v0.37.2/go.mod h1:aj4iRJGhzi3SlL8iUtVezxway1Xf8g+hmNQkLLO7sS8= +github.com/centrifugal/protocol v0.16.2/go.mod h1:Q7OpS/8HMXDnL7f9DpNx24IhG96MP88WPpVTTCdrokI= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/centrifugal/centrifuge v0.37.2/go.mod h1:aj4iRJGhzi3SlL8iUtVezxway1Xf8g+hmNQkLLO7sS8= -github.com/centrifugal/protocol v0.16.2/go.mod h1:Q7OpS/8HMXDnL7f9DpNx24IhG96MP88WPpVTTCdrokI= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= @@ -875,6 +875,7 @@ github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQ github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/gophercloud/gophercloud v1.13.0 h1:8iY9d1DAbzMW6Vok1AxbbK5ZaUjzMp0tdyt4fX9IeJ0= github.com/gophercloud/gophercloud v1.13.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= diff --git a/pkg/services/authz/zanzana/store/migration/migrator.go b/pkg/services/authz/zanzana/store/migration/migrator.go index b3e1f9a4d89..0bd475475f0 100644 --- a/pkg/services/authz/zanzana/store/migration/migrator.go +++ b/pkg/services/authz/zanzana/store/migration/migrator.go @@ -1,6 +1,9 @@ package migration import ( + "context" + "database/sql" + "errors" "fmt" "strings" @@ -11,6 +14,11 @@ import ( "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/xorm" "github.com/openfga/openfga/pkg/storage/migrate" + "github.com/pressly/goose/v3" +) + +var ( + openFGATables = []string{"tuple", "authorization_model", "store", "assertion", "changelog", "goose_db_version"} ) func Run(cfg *setting.Cfg, dbType string, grafanaDBConfig *sqlstore.DatabaseConfig, logger log.Logger) error { @@ -43,7 +51,7 @@ func Run(cfg *setting.Cfg, dbType string, grafanaDBConfig *sqlstore.DatabaseConf Engine: dbType, } - if err := migrate.RunMigrations(migrationConfig); err != nil { + if err := runOpenFGAMigrations(migrationConfig, logger); err != nil { return fmt.Errorf("failed to run openfga migrations: %w", err) } @@ -54,9 +62,53 @@ func Run(cfg *setting.Cfg, dbType string, grafanaDBConfig *sqlstore.DatabaseConf return nil } +func runOpenFGAMigrations(migrationConfig migrate.MigrationConfig, logger log.Logger) error { + err := migrate.RunMigrations(migrationConfig) + if err == nil { + return nil + } + + // if an error occurs during migrations, it means that the goose schema is inconsistent with the openfga schema. + // since zanzana is a derived state, we can reset the schema state and retry. + logger.Warn("openfga migrations failed due to inconsistent goose schema/version state; resetting and retrying migrations", "error", err) + + if resetErr := resetOpenFGASchema(migrationConfig.Engine, migrationConfig.URI); resetErr != nil { + return fmt.Errorf("schema reset failed: %w", errors.Join(err, resetErr)) + } + + if retryErr := migrate.RunMigrations(migrationConfig); retryErr != nil { + return retryErr + } + + return nil +} + +// resetOpenFGASchema drops the openfga tables to ensure migrations will run from a clean state. +// openfga tables are derived state and state will be rebuilt from reconciliation. +func resetOpenFGASchema(engine, uri string) (retErr error) { + db, err := openDB(engine, uri) + if err != nil { + return fmt.Errorf("failed to open db for openfga schema reset: %w", err) + } + defer func() { + if err := db.Close(); err != nil && retErr == nil { + retErr = fmt.Errorf("failed to close db: %w", err) + } + }() + + for _, table := range openFGATables { + // strings are hard-coded, so this is safe. + // #nosec G201 nosemgrep: gosec.G201 + if _, err := db.ExecContext(context.Background(), fmt.Sprintf("DROP TABLE IF EXISTS %s", table)); err != nil { + return fmt.Errorf("failed to drop openfga table %s: %w", table, err) + } + } + + return nil +} + func RunWithMigrator(m *migrator.Migrator, cfg *setting.Cfg) error { - openfgaTables := []string{"tuple", "authorization_model", "store", "assertion", "changelog"} - for _, table := range openfgaTables { + for _, table := range openFGATables { m.AddMigration(fmt.Sprintf("Drop existing openfga table %s", table), migrator.NewDropTableMigration(table)) } @@ -68,6 +120,17 @@ func RunWithMigrator(m *migrator.Migrator, cfg *setting.Cfg) error { ) } +func openDB(engine, uri string) (*sql.DB, error) { + db, err := goose.OpenDBWithDriver(engine, uri) + if err == nil { + return db, nil + } + if engine == "sqlite" { + return goose.OpenDBWithDriver("sqlite3", uri) + } + return nil, err +} + // constructPostgresConnStrForOpenFGA parses a PostgreSQL connection string into a map of key-value pairs // parses into a format like // postgresql://grafana:password@127.0.0.1:5432/grafana?sslmode=disable&lock_timeout=2s&statement_timeout=10s diff --git a/pkg/services/authz/zanzana/store/migration/migrator_test.go b/pkg/services/authz/zanzana/store/migration/migrator_test.go new file mode 100644 index 00000000000..ff41a1456f6 --- /dev/null +++ b/pkg/services/authz/zanzana/store/migration/migrator_test.go @@ -0,0 +1,74 @@ +package migration + +import ( + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/openfga/openfga/pkg/storage/migrate" + "github.com/pressly/goose/v3" + "github.com/stretchr/testify/require" +) + +func TestRunOpenFGAMigrations_ResetsGooseVersionTableOnErrNoNextVersion(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + dbPath := tmpDir + "/openfga-test.db" + + // intentionally corrupt the goose version table + db, err := goose.OpenDBWithDriver("sqlite3", dbPath) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = goose.EnsureDBVersion(db) + require.NoError(t, err) + + _, err = db.Exec("UPDATE goose_db_version SET is_applied = 0") + require.NoError(t, err) + _, err = goose.GetDBVersion(db) + require.ErrorIs(t, err, goose.ErrNoNextVersion) + + cfg := migrate.MigrationConfig{ + Engine: "sqlite", + URI: dbPath, + } + require.NoError(t, runOpenFGAMigrations(cfg, log.NewNopLogger())) + + // openFGA migrations should have established a valid current version. + db2, err := goose.OpenDBWithDriver("sqlite3", dbPath) + require.NoError(t, err) + t.Cleanup(func() { _ = db2.Close() }) + v, err := goose.GetDBVersion(db2) + require.NoError(t, err) + require.GreaterOrEqual(t, v, int64(0)) +} + +func TestRunOpenFGAMigrations_ResetsSchemaWhenGooseVersionInconsistentButSchemaExists(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + dbPath := tmpDir + "/openfga-test.db" + + cfg := migrate.MigrationConfig{ + Engine: "sqlite", + URI: dbPath, + } + require.NoError(t, runOpenFGAMigrations(cfg, log.NewNopLogger())) + + db, err := goose.OpenDBWithDriver("sqlite3", dbPath) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + _, err = db.Exec("UPDATE goose_db_version SET is_applied = 0") + require.NoError(t, err) + _, err = goose.GetDBVersion(db) + require.ErrorIs(t, err, goose.ErrNoNextVersion) + + require.NoError(t, runOpenFGAMigrations(cfg, log.NewNopLogger())) + + db2, err := goose.OpenDBWithDriver("sqlite3", dbPath) + require.NoError(t, err) + t.Cleanup(func() { _ = db2.Close() }) + _, err = goose.GetDBVersion(db2) + require.NoError(t, err) +} From c1f95a27130d26fe941683b41439cecf9e55182c Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Mon, 5 Jan 2026 17:04:17 +0100 Subject: [PATCH 15/79] Graphite: Fix series naming convention in backend mode (#115588) Fix series naming convention --- pkg/tsdb/graphite/healthcheck.go | 2 +- pkg/tsdb/graphite/query.go | 10 ++++-- pkg/tsdb/graphite/query_test.go | 52 +++++++++++++++++++++++++++----- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/pkg/tsdb/graphite/healthcheck.go b/pkg/tsdb/graphite/healthcheck.go index e6c3f005095..fd595fee2a1 100644 --- a/pkg/tsdb/graphite/healthcheck.go +++ b/pkg/tsdb/graphite/healthcheck.go @@ -81,7 +81,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque } }() - _, err = s.toDataFrames(res, healthCheckQuery.RefID) + _, err = s.toDataFrames(res, healthCheckQuery.RefID, false) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) diff --git a/pkg/tsdb/graphite/query.go b/pkg/tsdb/graphite/query.go index c06c6d5af08..1a3d9b1beb6 100644 --- a/pkg/tsdb/graphite/query.go +++ b/pkg/tsdb/graphite/query.go @@ -27,6 +27,8 @@ func (s *Service) RunQuery(ctx context.Context, req *backend.QueryDataRequest, d req *http.Request formData url.Values }{} + // FromAlert header is defined in pkg/services/ngalert/models/constants.go + fromAlert := req.Headers["FromAlert"] == "true" result := backend.NewQueryDataResponse() for _, query := range req.Queries { @@ -97,7 +99,7 @@ func (s *Service) RunQuery(ctx context.Context, req *backend.QueryDataRequest, d } }() - queryFrames, err := s.toDataFrames(res, refId) + queryFrames, err := s.toDataFrames(res, refId, fromAlert) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) @@ -192,7 +194,7 @@ func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQ return graphiteReq, formData, emptyQuery, nil } -func (s *Service) toDataFrames(response *http.Response, refId string) (frames data.Frames, error error) { +func (s *Service) toDataFrames(response *http.Response, refId string, fromAlert bool) (frames data.Frames, error error) { responseData, err := s.parseResponse(response) if err != nil { return nil, err @@ -215,7 +217,9 @@ func (s *Service) toDataFrames(response *http.Response, refId string) (frames da tags := make(map[string]string) for name, value := range series.Tags { if name == "name" { - value = series.Target + if fromAlert { + value = series.Target + } } switch value := value.(type) { case string: diff --git a/pkg/tsdb/graphite/query_test.go b/pkg/tsdb/graphite/query_test.go index 036f5401194..761e62112ba 100644 --- a/pkg/tsdb/graphite/query_test.go +++ b/pkg/tsdb/graphite/query_test.go @@ -182,7 +182,7 @@ func TestConvertResponses(t *testing.T) { expectedFrames := data.Frames{expectedFrame} httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))} - dataFrames, err := service.toDataFrames(httpResponse, refId) + dataFrames, err := service.toDataFrames(httpResponse, refId, false) require.NoError(t, err) if !reflect.DeepEqual(expectedFrames, dataFrames) { @@ -196,8 +196,8 @@ func TestConvertResponses(t *testing.T) { body := ` [ { - "target": "target", - "tags": { "fooTag": "fooValue", "barTag": "barValue", "int": 100, "float": 3.14 }, + "target": "aliasedTarget(target)", + "tags": { "name": "target", "fooTag": "fooValue", "barTag": "barValue", "int": 100, "float": 3.14 }, "datapoints": [[50, 1], [null, 2], [100, 3]] } ]` @@ -211,18 +211,19 @@ func TestConvertResponses(t *testing.T) { "barTag": "barValue", "int": "100", "float": "3.14", - }, []*float64{&a, nil, &b}).SetConfig(&data.FieldConfig{DisplayNameFromDS: "target"}), + "name": "target", + }, []*float64{&a, nil, &b}).SetConfig(&data.FieldConfig{DisplayNameFromDS: "aliasedTarget(target)"}), ).SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}) expectedFrames := data.Frames{expectedFrame} httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))} - dataFrames, err := service.toDataFrames(httpResponse, refId) + dataFrames, err := service.toDataFrames(httpResponse, refId, false) require.NoError(t, err) if !reflect.DeepEqual(expectedFrames, dataFrames) { expectedFramesJSON, _ := json.Marshal(expectedFrames) dataFramesJSON, _ := json.Marshal(dataFrames) - t.Errorf("Data frames should have been equal but was, expected:\n%s\nactual:\n%s", expectedFramesJSON, dataFramesJSON) + t.Errorf("Data frames should have been equal but were not, expected:\n%s\nactual:\n%s", expectedFramesJSON, dataFramesJSON) } }) @@ -239,7 +240,7 @@ func TestConvertResponses(t *testing.T) { expectedFrames := data.Frames{} httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))} - dataFrames, err := service.toDataFrames(httpResponse, refId) + dataFrames, err := service.toDataFrames(httpResponse, refId, false) require.NoError(t, err) if !reflect.DeepEqual(expectedFrames, dataFrames) { @@ -280,7 +281,42 @@ func TestConvertResponses(t *testing.T) { expectedFrames := data.Frames{expectedFrame} httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))} - dataFrames, err := service.toDataFrames(httpResponse, refId) + dataFrames, err := service.toDataFrames(httpResponse, refId, false) + + require.NoError(t, err) + if !reflect.DeepEqual(expectedFrames, dataFrames) { + expectedFramesJSON, _ := json.Marshal(expectedFrames) + dataFramesJSON, _ := json.Marshal(dataFrames) + t.Errorf("Data frames should have been equal but was, expected:\n%s\nactual:\n%s", expectedFramesJSON, dataFramesJSON) + } + }) + + t.Run("Uses target as series name for alerts", func(*testing.T) { + body := ` + [ + { + "target": "aliasedTarget(target)", + "tags": { "name": "target", "fooTag": "fooValue", "barTag": "barValue", "int": 100, "float": 3.14 }, + "datapoints": [[50, 1], [null, 2], [100, 3]] + } + ]` + a := 50.0 + b := 100.0 + refId := "A" + expectedFrame := data.NewFrame("A", + data.NewField("time", nil, []time.Time{time.Unix(1, 0).UTC(), time.Unix(2, 0).UTC(), time.Unix(3, 0).UTC()}), + data.NewField("value", data.Labels{ + "fooTag": "fooValue", + "barTag": "barValue", + "int": "100", + "float": "3.14", + "name": "aliasedTarget(target)", + }, []*float64{&a, nil, &b}).SetConfig(&data.FieldConfig{DisplayNameFromDS: "aliasedTarget(target)"}), + ).SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti}) + expectedFrames := data.Frames{expectedFrame} + + httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))} + dataFrames, err := service.toDataFrames(httpResponse, refId, true) require.NoError(t, err) if !reflect.DeepEqual(expectedFrames, dataFrames) { From 7ba2c559c4ba08cade994fbf1f939532ad47d132 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Mon, 5 Jan 2026 11:19:29 -0500 Subject: [PATCH 16/79] Alerting: Add support for client certificate authentication and TLS options to External Alertmanager (#115716) * add support for skip TLS verify * extract constructor for ExternalAMcfg and tests * extract constructor for AlertmanagerConfig and tests * add support for client cert auth --- pkg/services/ngalert/sender/router.go | 65 +++-- pkg/services/ngalert/sender/router_test.go | 294 +++++++++++++++++++++ pkg/services/ngalert/sender/sender.go | 107 +++++--- pkg/services/ngalert/sender/sender_test.go | 240 +++++++++++++++++ 4 files changed, 657 insertions(+), 49 deletions(-) diff --git a/pkg/services/ngalert/sender/router.go b/pkg/services/ngalert/sender/router.go index 7f1f8f3a897..1a051539443 100644 --- a/pkg/services/ngalert/sender/router.go +++ b/pkg/services/ngalert/sender/router.go @@ -250,34 +250,67 @@ func (d *AlertsRouter) alertmanagersFromDatasources(orgID int64) ([]ExternalAMcf if !ds.JsonData.Get(definitions.HandleGrafanaManagedAlerts).MustBool(false) { continue } - amURL, err := d.buildExternalURL(ds) + + cfg, err := d.datasourceToExternalAMcfg(ds) if err != nil { - d.logger.Error("Failed to build external alertmanager URL", - "org", ds.OrgID, - "uid", ds.UID, - "error", err) - continue - } - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) - headers, err := d.datasourceService.CustomHeaders(ctx, ds) - cancel() - if err != nil { - d.logger.Error("Failed to get headers for external alertmanager", + d.logger.Error("Failed to convert datasource to external alertmanager config", "org", ds.OrgID, "uid", ds.UID, "error", err) continue } - alertmanagers = append(alertmanagers, ExternalAMcfg{ - URL: amURL, - Headers: headers, - }) + alertmanagers = append(alertmanagers, cfg) } return alertmanagers, nil } +// datasourceToExternalAMcfg converts a datasource to an ExternalAMcfg. +func (d *AlertsRouter) datasourceToExternalAMcfg(ds *datasources.DataSource) (ExternalAMcfg, error) { + amURL, err := d.buildExternalURL(ds) + if err != nil { + return ExternalAMcfg{}, fmt.Errorf("failed to build external alertmanager URL: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + headers, err := d.datasourceService.CustomHeaders(ctx, ds) + cancel() + if err != nil { + return ExternalAMcfg{}, fmt.Errorf("failed to get custom headers: %w", err) + } + + insecureSkipVerify := false + + var tlsAuthEnabled bool + if ds.JsonData != nil { + insecureSkipVerify = ds.JsonData.Get("tlsSkipVerify").MustBool(false) + tlsAuthEnabled = ds.JsonData.Get("tlsAuth").MustBool(false) + } + + var tlsClientCert, tlsClientKey string + if tlsAuthEnabled { + if ds.SecureJsonData == nil { + return ExternalAMcfg{}, errors.New("tlsAuth is enabled but TLS client certificate and key are not configured") + } + + tlsClientKey = d.secretService.GetDecryptedValue(context.Background(), ds.SecureJsonData, "tlsClientKey", "") + tlsClientCert = d.secretService.GetDecryptedValue(context.Background(), ds.SecureJsonData, "tlsClientCert", "") + + if tlsClientCert == "" || tlsClientKey == "" { + return ExternalAMcfg{}, errors.New("tlsAuth is enabled but TLS client certificate or key is empty") + } + } + + return ExternalAMcfg{ + URL: amURL, + Headers: headers, + InsecureSkipVerify: insecureSkipVerify, + TLSClientCert: tlsClientCert, + TLSClientKey: tlsClientKey, + }, nil +} + func (d *AlertsRouter) buildExternalURL(ds *datasources.DataSource) (string, error) { // We re-use the same parsing logic as the datasource to make sure it matches whatever output the user received // when doing the healthcheck. diff --git a/pkg/services/ngalert/sender/router_test.go b/pkg/services/ngalert/sender/router_test.go index 3eb6aa884d1..f1e355fce1f 100644 --- a/pkg/services/ngalert/sender/router_test.go +++ b/pkg/services/ngalert/sender/router_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math/rand" + "net/http" "net/url" "testing" "time" @@ -744,3 +745,296 @@ func TestAlertManagers_buildRedactedAMs(t *testing.T) { }) } } + +func TestDatasourceToExternalAMcfg(t *testing.T) { + tests := []struct { + name string + datasource *datasources.DataSource + expected ExternalAMcfg + expectError bool + }{ + { + name: "datasource with tlsSkipVerify enabled", + datasource: &datasources.DataSource{ + URL: "https://localhost:9093", + OrgID: 1, + Type: datasources.DS_ALERTMANAGER, + JsonData: simplejson.NewFromAny(map[string]any{ + "tlsSkipVerify": true, + }), + }, + expected: ExternalAMcfg{ + URL: "https://localhost:9093", + InsecureSkipVerify: true, + }, + }, + { + name: "datasource with tlsSkipVerify disabled", + datasource: &datasources.DataSource{ + URL: "https://localhost:9093", + OrgID: 1, + Type: datasources.DS_ALERTMANAGER, + JsonData: simplejson.NewFromAny(map[string]any{ + "tlsSkipVerify": false, + }), + }, + expected: ExternalAMcfg{ + URL: "https://localhost:9093", + InsecureSkipVerify: false, + }, + }, + { + name: "datasource without tlsSkipVerify (defaults to false)", + datasource: &datasources.DataSource{ + URL: "https://localhost:9093", + OrgID: 1, + Type: datasources.DS_ALERTMANAGER, + JsonData: simplejson.NewFromAny(map[string]any{}), + }, + expected: ExternalAMcfg{ + URL: "https://localhost:9093", + InsecureSkipVerify: false, + }, + }, + { + name: "mimir datasource with tlsSkipVerify", + datasource: &datasources.DataSource{ + URL: "https://localhost:9093", + OrgID: 1, + Type: datasources.DS_ALERTMANAGER, + JsonData: simplejson.NewFromAny(map[string]any{ + "implementation": "mimir", + "tlsSkipVerify": true, + }), + }, + expected: ExternalAMcfg{ + URL: "https://localhost:9093/alertmanager", + InsecureSkipVerify: true, + }, + }, + { + name: "datasource with basic auth and tlsSkipVerify", + datasource: &datasources.DataSource{ + URL: "https://localhost:9093", + OrgID: 1, + Type: datasources.DS_ALERTMANAGER, + BasicAuth: true, + BasicAuthUser: "user", + SecureJsonData: map[string][]byte{ + "basicAuthPassword": []byte("password"), + }, + JsonData: simplejson.NewFromAny(map[string]any{ + "tlsSkipVerify": true, + }), + }, + expected: ExternalAMcfg{ + URL: "https://user:password@localhost:9093", + InsecureSkipVerify: true, + }, + }, + { + name: "datasource with TLS client auth", + datasource: &datasources.DataSource{ + URL: "https://localhost:9093", + OrgID: 1, + Type: datasources.DS_ALERTMANAGER, + JsonData: simplejson.NewFromAny(map[string]any{ + "tlsAuth": true, + }), + SecureJsonData: map[string][]byte{ + "tlsClientCert": []byte("client-cert-content"), + "tlsClientKey": []byte("client-key-content"), + }, + }, + expected: ExternalAMcfg{ + URL: "https://localhost:9093", + TLSClientCert: "client-cert-content", + TLSClientKey: "client-key-content", + }, + }, + { + name: "datasource with TLS client auth and skip verify", + datasource: &datasources.DataSource{ + URL: "https://localhost:9093", + OrgID: 1, + Type: datasources.DS_ALERTMANAGER, + JsonData: simplejson.NewFromAny(map[string]any{ + "tlsSkipVerify": true, + "tlsAuth": true, + }), + SecureJsonData: map[string][]byte{ + "tlsClientCert": []byte("client-cert-content"), + "tlsClientKey": []byte("client-key-content"), + }, + }, + expected: ExternalAMcfg{ + URL: "https://localhost:9093", + InsecureSkipVerify: true, + TLSClientCert: "client-cert-content", + TLSClientKey: "client-key-content", + }, + }, + { + name: "tlsAuth enabled but SecureJsonData is nil - should error", + datasource: &datasources.DataSource{ + URL: "https://localhost:9093", + OrgID: 1, + Type: datasources.DS_ALERTMANAGER, + JsonData: simplejson.NewFromAny(map[string]any{ + "tlsAuth": true, + }), + SecureJsonData: nil, + }, + expectError: true, + }, + { + name: "tlsAuth enabled but tlsClientCert is empty - should error", + datasource: &datasources.DataSource{ + URL: "https://localhost:9093", + OrgID: 1, + Type: datasources.DS_ALERTMANAGER, + JsonData: simplejson.NewFromAny(map[string]any{ + "tlsAuth": true, + }), + SecureJsonData: map[string][]byte{ + "tlsClientKey": []byte("client-key-content"), + }, + }, + expectError: true, + }, + { + name: "tlsAuth enabled but tlsClientKey is empty - should error", + datasource: &datasources.DataSource{ + URL: "https://localhost:9093", + OrgID: 1, + Type: datasources.DS_ALERTMANAGER, + JsonData: simplejson.NewFromAny(map[string]any{ + "tlsAuth": true, + }), + SecureJsonData: map[string][]byte{ + "tlsClientCert": []byte("client-cert-content"), + }, + }, + expectError: true, + }, + { + name: "tlsAuth enabled but both cert and key are empty - should error", + datasource: &datasources.DataSource{ + URL: "https://localhost:9093", + OrgID: 1, + Type: datasources.DS_ALERTMANAGER, + JsonData: simplejson.NewFromAny(map[string]any{ + "tlsAuth": true, + }), + SecureJsonData: map[string][]byte{}, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := &AlertsRouter{ + logger: log.New("test"), + datasourceService: &fake_ds.FakeDataSourceService{}, + secretService: fake_secrets.NewFakeSecretsService(), + } + + cfg, err := router.datasourceToExternalAMcfg(tt.datasource) + + if tt.expectError { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tt.expected, cfg) + }) + } +} + +func TestExternalAMcfg_SHA256(t *testing.T) { + // Golden config with all fields populated + goldenCfg := ExternalAMcfg{ + URL: "https://localhost:9093", + Headers: http.Header{ + "X-Custom-Header": []string{"value1"}, + "Authorization": []string{"Bearer token"}, + }, + Timeout: 30 * time.Second, + InsecureSkipVerify: true, + TLSClientCert: "client-cert-content", + TLSClientKey: "client-key-content", + } + goldenHash := goldenCfg.SHA256() + + tests := []struct { + name string + mutateFn func(ExternalAMcfg) ExternalAMcfg + shouldDiffer bool + }{ + { + name: "mutate URL - hash should change", + mutateFn: func(cfg ExternalAMcfg) ExternalAMcfg { + cfg.URL = "https://different-host:9093" + return cfg + }, + shouldDiffer: true, + }, + { + name: "mutate Headers - hash should change", + mutateFn: func(cfg ExternalAMcfg) ExternalAMcfg { + cfg.Headers = http.Header{ + "X-Different-Header": []string{"different-value"}, + } + return cfg + }, + shouldDiffer: true, + }, + { + name: "mutate Timeout - hash should NOT change", + mutateFn: func(cfg ExternalAMcfg) ExternalAMcfg { + cfg.Timeout = 60 * time.Second + return cfg + }, + shouldDiffer: false, + }, + { + name: "mutate InsecureSkipVerify - hash should change", + mutateFn: func(cfg ExternalAMcfg) ExternalAMcfg { + cfg.InsecureSkipVerify = false + return cfg + }, + shouldDiffer: true, + }, + { + name: "mutate TLSClientCert - hash should change", + mutateFn: func(cfg ExternalAMcfg) ExternalAMcfg { + cfg.TLSClientCert = "different-cert" + return cfg + }, + shouldDiffer: true, + }, + { + name: "mutate TLSClientKey - hash should change", + mutateFn: func(cfg ExternalAMcfg) ExternalAMcfg { + cfg.TLSClientKey = "different-key" + return cfg + }, + shouldDiffer: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mutatedCfg := tt.mutateFn(goldenCfg) + mutatedHash := mutatedCfg.SHA256() + + if tt.shouldDiffer { + require.NotEqual(t, goldenHash, mutatedHash, "Expected hash to change after mutation") + } else { + require.Equal(t, goldenHash, mutatedHash, "Expected hash to remain the same after mutation") + } + }) + } +} diff --git a/pkg/services/ngalert/sender/sender.go b/pkg/services/ngalert/sender/sender.go index eb708a5d810..00293640736 100644 --- a/pkg/services/ngalert/sender/sender.go +++ b/pkg/services/ngalert/sender/sender.go @@ -47,6 +47,12 @@ type ExternalAMcfg struct { URL string Headers http.Header Timeout time.Duration + // InsecureSkipVerify determines whether the server's TLS certificate should be verified. + InsecureSkipVerify bool + // TLSClientCert specifies the TLS client certificate used for secure communication. + TLSClientCert string + // TLSClientKey specifies the private key associated with the TLS client certificate for secure communication. + TLSClientKey string } type ExternalAMOptions struct { @@ -94,7 +100,17 @@ func WithMaxBatchSize(size int) Option { } func (cfg *ExternalAMcfg) SHA256() string { - return asSHA256([]string{cfg.headerString(), cfg.URL}) + skipVerify := "false" + if cfg.InsecureSkipVerify { + skipVerify = "true" + } + return asSHA256([]string{ + cfg.headerString(), + cfg.URL, + skipVerify, + cfg.TLSClientCert, + cfg.TLSClientKey, + }) } // headersString transforms all the headers in a sorted way as a @@ -250,48 +266,17 @@ func buildNotifierConfig(alertmanagers []ExternalAMcfg) (*config.Config, map[str amConfigs := make([]*config.AlertmanagerConfig, 0, len(alertmanagers)) headers := map[string]http.Header{} for i, am := range alertmanagers { - u, err := url.Parse(am.URL) + amConfig, err := externalAMcfgToAlertmanagerConfig(am) if err != nil { return nil, nil, err } - sdConfig := discovery.Configs{ - discovery.StaticConfig{ - { - Targets: []model.LabelSet{{model.AddressLabel: model.LabelValue(u.Host)}}, - }, - }, - } - - timeout := am.Timeout - if timeout == 0 { - timeout = defaultTimeout - } - - amConfig := &config.AlertmanagerConfig{ - APIVersion: config.AlertmanagerAPIVersionV2, - Scheme: u.Scheme, - PathPrefix: u.Path, - Timeout: model.Duration(timeout), - ServiceDiscoveryConfigs: sdConfig, - } - if am.Headers != nil { // The key has the same format as the AlertmanagerConfigs.ToMap() would generate // so we can use it later on when working with the alertmanager config map. headers[fmt.Sprintf("config-%d", i)] = am.Headers } - // Check the URL for basic authentication information first - if u.User != nil { - amConfig.HTTPClientConfig.BasicAuth = &common_config.BasicAuth{ - Username: u.User.Username(), - } - - if password, isSet := u.User.Password(); isSet { - amConfig.HTTPClientConfig.BasicAuth.Password = common_config.Secret(password) - } - } amConfigs = append(amConfigs, amConfig) } @@ -304,6 +289,62 @@ func buildNotifierConfig(alertmanagers []ExternalAMcfg) (*config.Config, map[str return notifierConfig, headers, nil } +// externalAMcfgToAlertmanagerConfig converts an ExternalAMcfg to a Prometheus AlertmanagerConfig. +func externalAMcfgToAlertmanagerConfig(am ExternalAMcfg) (*config.AlertmanagerConfig, error) { + u, err := url.Parse(am.URL) + if err != nil { + return nil, fmt.Errorf("failed to parse alertmanager URL: %w", err) + } + + sdConfig := discovery.Configs{ + discovery.StaticConfig{ + { + Targets: []model.LabelSet{{model.AddressLabel: model.LabelValue(u.Host)}}, + }, + }, + } + + timeout := am.Timeout + if timeout == 0 { + timeout = defaultTimeout + } + + amConfig := &config.AlertmanagerConfig{ + APIVersion: config.AlertmanagerAPIVersionV2, + Scheme: u.Scheme, + PathPrefix: u.Path, + Timeout: model.Duration(timeout), + ServiceDiscoveryConfigs: sdConfig, + } + + // Check the URL for basic authentication information first + if u.User != nil { + amConfig.HTTPClientConfig.BasicAuth = &common_config.BasicAuth{ + Username: u.User.Username(), + } + + if password, isSet := u.User.Password(); isSet { + amConfig.HTTPClientConfig.BasicAuth.Password = common_config.Secret(password) + } + } + + // Validate that if TLS client cert is provided, key must also be provided (and vice versa) + if (am.TLSClientCert != "" && am.TLSClientKey == "") || (am.TLSClientCert == "" && am.TLSClientKey != "") { + return nil, fmt.Errorf("TLS client certificate and key must both be provided or both be empty") + } + + // Set TLS configuration if any TLS options are provided + if am.InsecureSkipVerify || am.TLSClientCert != "" { + amConfig.HTTPClientConfig.TLSConfig = common_config.TLSConfig{ + InsecureSkipVerify: am.InsecureSkipVerify, + Cert: am.TLSClientCert, + Key: common_config.Secret(am.TLSClientKey), + } + } + + return amConfig, nil +} + func (s *ExternalAlertmanager) alertToNotifierAlert(alert models.PostableAlert) *Alert { // Prometheus alertmanager has stricter rules for annotations/labels than grafana's internal alertmanager, so we sanitize invalid keys. return &Alert{ diff --git a/pkg/services/ngalert/sender/sender_test.go b/pkg/services/ngalert/sender/sender_test.go index 1f51aeaf857..0f24640066c 100644 --- a/pkg/services/ngalert/sender/sender_test.go +++ b/pkg/services/ngalert/sender/sender_test.go @@ -3,9 +3,14 @@ package sender import ( "fmt" "testing" + "time" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/client_golang/prometheus" + common_config "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + "github.com/prometheus/prometheus/config" + "github.com/prometheus/prometheus/discovery" "github.com/prometheus/prometheus/model/labels" "github.com/stretchr/testify/require" @@ -227,3 +232,238 @@ func TestWithUTF8Labels(t *testing.T) { require.Equal(t, "fire", result.Labels.Get("_0x1f525")) }) } + +func TestExternalAMcfgToAlertmanagerConfig(t *testing.T) { + tests := []struct { + name string + cfg ExternalAMcfg + expected *config.AlertmanagerConfig + expectError bool + }{ + { + name: "basic configuration without TLS skip verify", + cfg: ExternalAMcfg{ + URL: "https://alertmanager.example.com:9093/alertmanager", + InsecureSkipVerify: false, + }, + expected: &config.AlertmanagerConfig{ + APIVersion: config.AlertmanagerAPIVersionV2, + Scheme: "https", + PathPrefix: "/alertmanager", + Timeout: model.Duration(defaultTimeout), + ServiceDiscoveryConfigs: discovery.Configs{ + discovery.StaticConfig{ + { + Targets: []model.LabelSet{{model.AddressLabel: "alertmanager.example.com:9093"}}, + }, + }, + }, + }, + expectError: false, + }, + { + name: "configuration with TLS skip verify enabled", + cfg: ExternalAMcfg{ + URL: "https://alertmanager.example.com:9093", + InsecureSkipVerify: true, + }, + expected: &config.AlertmanagerConfig{ + APIVersion: config.AlertmanagerAPIVersionV2, + Scheme: "https", + PathPrefix: "", + Timeout: model.Duration(defaultTimeout), + ServiceDiscoveryConfigs: discovery.Configs{ + discovery.StaticConfig{ + { + Targets: []model.LabelSet{{model.AddressLabel: "alertmanager.example.com:9093"}}, + }, + }, + }, + HTTPClientConfig: common_config.HTTPClientConfig{ + TLSConfig: common_config.TLSConfig{ + InsecureSkipVerify: true, + }, + }, + }, + expectError: false, + }, + { + name: "configuration with basic auth in URL", + cfg: ExternalAMcfg{ + URL: "https://user:password@alertmanager.example.com:9093", + InsecureSkipVerify: false, + }, + expected: &config.AlertmanagerConfig{ + APIVersion: config.AlertmanagerAPIVersionV2, + Scheme: "https", + PathPrefix: "", + Timeout: model.Duration(defaultTimeout), + ServiceDiscoveryConfigs: discovery.Configs{ + discovery.StaticConfig{ + { + Targets: []model.LabelSet{{model.AddressLabel: "alertmanager.example.com:9093"}}, + }, + }, + }, + HTTPClientConfig: common_config.HTTPClientConfig{ + BasicAuth: &common_config.BasicAuth{ + Username: "user", + Password: "password", + }, + }, + }, + expectError: false, + }, + { + name: "configuration with basic auth and TLS skip verify", + cfg: ExternalAMcfg{ + URL: "https://user:password@alertmanager.example.com:9093", + InsecureSkipVerify: true, + }, + expected: &config.AlertmanagerConfig{ + APIVersion: config.AlertmanagerAPIVersionV2, + Scheme: "https", + PathPrefix: "", + Timeout: model.Duration(defaultTimeout), + ServiceDiscoveryConfigs: discovery.Configs{ + discovery.StaticConfig{ + { + Targets: []model.LabelSet{{model.AddressLabel: "alertmanager.example.com:9093"}}, + }, + }, + }, + HTTPClientConfig: common_config.HTTPClientConfig{ + BasicAuth: &common_config.BasicAuth{ + Username: "user", + Password: "password", + }, + TLSConfig: common_config.TLSConfig{ + InsecureSkipVerify: true, + }, + }, + }, + expectError: false, + }, + { + name: "configuration with custom timeout", + cfg: ExternalAMcfg{ + URL: "https://alertmanager.example.com:9093", + Timeout: 30 * time.Second, + InsecureSkipVerify: false, + }, + expected: &config.AlertmanagerConfig{ + APIVersion: config.AlertmanagerAPIVersionV2, + Scheme: "https", + PathPrefix: "", + Timeout: model.Duration(30 * time.Second), + ServiceDiscoveryConfigs: discovery.Configs{ + discovery.StaticConfig{ + { + Targets: []model.LabelSet{{model.AddressLabel: "alertmanager.example.com:9093"}}, + }, + }, + }, + }, + expectError: false, + }, + { + name: "invalid URL should return error", + cfg: ExternalAMcfg{ + URL: "://invalid-url", + InsecureSkipVerify: false, + }, + expected: nil, + expectError: true, + }, + { + name: "configuration with TLS client auth", + cfg: ExternalAMcfg{ + URL: "https://alertmanager.example.com:9093", + TLSClientCert: "client-cert-content", + TLSClientKey: "client-key-content", + }, + expected: &config.AlertmanagerConfig{ + APIVersion: config.AlertmanagerAPIVersionV2, + Scheme: "https", + PathPrefix: "", + Timeout: model.Duration(defaultTimeout), + ServiceDiscoveryConfigs: discovery.Configs{ + discovery.StaticConfig{ + { + Targets: []model.LabelSet{{model.AddressLabel: "alertmanager.example.com:9093"}}, + }, + }, + }, + HTTPClientConfig: common_config.HTTPClientConfig{ + TLSConfig: common_config.TLSConfig{ + Cert: "client-cert-content", + Key: "client-key-content", + }, + }, + }, + expectError: false, + }, + { + name: "configuration with TLS client auth and skip verify", + cfg: ExternalAMcfg{ + URL: "https://alertmanager.example.com:9093", + InsecureSkipVerify: true, + TLSClientCert: "client-cert-content", + TLSClientKey: "client-key-content", + }, + expected: &config.AlertmanagerConfig{ + APIVersion: config.AlertmanagerAPIVersionV2, + Scheme: "https", + PathPrefix: "", + Timeout: model.Duration(defaultTimeout), + ServiceDiscoveryConfigs: discovery.Configs{ + discovery.StaticConfig{ + { + Targets: []model.LabelSet{{model.AddressLabel: "alertmanager.example.com:9093"}}, + }, + }, + }, + HTTPClientConfig: common_config.HTTPClientConfig{ + TLSConfig: common_config.TLSConfig{ + InsecureSkipVerify: true, + Cert: "client-cert-content", + Key: "client-key-content", + }, + }, + }, + expectError: false, + }, + { + name: "TLS client cert provided but key missing - should error", + cfg: ExternalAMcfg{ + URL: "https://alertmanager.example.com:9093", + TLSClientCert: "client-cert-content", + }, + expected: nil, + expectError: true, + }, + { + name: "TLS client key provided but cert missing - should error", + cfg: ExternalAMcfg{ + URL: "https://alertmanager.example.com:9093", + TLSClientKey: "client-key-content", + }, + expected: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + amConfig, err := externalAMcfgToAlertmanagerConfig(tt.cfg) + + if tt.expectError { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tt.expected, amConfig) + }) + } +} From 52c035defc63476b76a0b39f16b80c4a4a72a540 Mon Sep 17 00:00:00 2001 From: Isabella Siu Date: Mon, 5 Jan 2026 11:25:41 -0500 Subject: [PATCH 17/79] Cloudwatch: fix aws authentication doc links (#115805) --- .../datasources/aws-cloudwatch/configure/index.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/sources/datasources/aws-cloudwatch/configure/index.md b/docs/sources/datasources/aws-cloudwatch/configure/index.md index 7e80433756b..3ae774d9d4e 100644 --- a/docs/sources/datasources/aws-cloudwatch/configure/index.md +++ b/docs/sources/datasources/aws-cloudwatch/configure/index.md @@ -55,11 +55,11 @@ refs: destination: /docs/grafana//administration/data-source-management/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana//administration/data-source-management/ - CloudWatch-aws-authentication: + cloudwatch-aws-authentication: - pattern: /docs/grafana/ - destination: /docs/grafana//datasources/aws-CloudWatch/aws-authentication/ + destination: /docs/grafana//datasources/aws-cloudwatch/aws-authentication/ - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//datasources/aws-CloudWatch/aws-authentication/ + destination: /docs/grafana//datasources/aws-cloudwatch/aws-authentication/ private-data-source-connect: - pattern: /docs/grafana/ destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ @@ -108,7 +108,7 @@ The following are configuration options for the CloudWatch data source. Grafana plugin requests to AWS are made on behalf of an AWS Identity and Access Management (IAM) role or IAM user. The IAM user or IAM role must have the associated policies to perform certain API actions. -For authentication options and configuration details, refer to [AWS authentication](aws-authentication/). +For authentication options and configuration details, refer to [AWS authentication](ref:cloudwatch-aws-authentication). | Setting | Description | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -172,7 +172,7 @@ To troubleshoot issues while setting up the CloudWatch data source, check the `/ ### IAM policy examples To read CloudWatch metrics and EC2 tags, instances, regions, and alarms, you must grant Grafana permissions via IAM. -You can attach these permissions to the IAM role or IAM user you configured in [AWS authentication](aws-authentication/). +You can attach these permissions to the IAM role or IAM user you configured in [AWS authentication](ref:cloudwatch-aws-authentication). **Metrics-only permissions:** @@ -323,7 +323,7 @@ You can attach these permissions to the IAM role or IAM user you configured in [ Cross-account observability lets you retrieve metrics and logs across different accounts in a single region, but you can't query EC2 Instance Attributes across accounts because those come from the EC2 API and not the CloudWatch API. {{< /admonition >}} -For more information on configuring authentication, refer to [Configure AWS authentication](ref:CloudWatch-aws-authentication). +For more information on configuring authentication, refer to [Configure AWS authentication](ref:cloudwatch-aws-authentication). ### CloudWatch Logs data protection From bc31a768f762694bfbcd5bb446c62a154c4387e7 Mon Sep 17 00:00:00 2001 From: "renovate-sh-app[bot]" <219655108+renovate-sh-app[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 16:40:56 +0000 Subject: [PATCH 18/79] chore(deps): update dependency nodemailer to v7.0.11 [security] (#115182) | datasource | package | from | to | | ---------- | ---------- | ----- | ------ | | npm | nodemailer | 7.0.7 | 7.0.11 | Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 40 +++++++--------------------------------- 2 files changed, 8 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 73dec9dbc90..41389e135e7 100644 --- a/package.json +++ b/package.json @@ -460,7 +460,7 @@ "tmp@npm:^0.0.33": "~0.2.1", "js-yaml@npm:4.1.0": "^4.1.0", "js-yaml@npm:=4.1.0": "^4.1.0", - "nodemailer": "7.0.7", + "nodemailer": "7.0.11", "@storybook/core@npm:8.6.2": "patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch" }, "workspaces": { diff --git a/yarn.lock b/yarn.lock index 6d0b057e2e7..f9e4168eed8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12247,19 +12247,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.6.3, ajv@npm:^8.9.0": - version: 8.12.0 - resolution: "ajv@npm:8.12.0" - dependencies: - fast-deep-equal: "npm:^3.1.1" - json-schema-traverse: "npm:^1.0.0" - require-from-string: "npm:^2.0.2" - uri-js: "npm:^4.2.2" - checksum: 10/b406f3b79b5756ac53bfe2c20852471b08e122bc1ee4cde08ae4d6a800574d9cd78d60c81c69c63ff81e4da7cd0b638fafbb2303ae580d49cf1600b9059efb85 - languageName: node - linkType: hard - -"ajv@npm:^8.17.1": +"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.17.1, ajv@npm:^8.6.3, ajv@npm:^8.9.0": version: 8.17.1 resolution: "ajv@npm:8.17.1" dependencies: @@ -17755,20 +17743,13 @@ __metadata: languageName: node linkType: hard -"eventsource-parser@npm:^3.0.0": +"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.1": version: 3.0.6 resolution: "eventsource-parser@npm:3.0.6" checksum: 10/febf7058b9c2168ecbb33e92711a1646e06bd1568f60b6eb6a01a8bf9f8fcd29cc8320d57247059cacf657a296280159f21306d2e3ff33309a9552b2ef889387 languageName: node linkType: hard -"eventsource-parser@npm:^3.0.1": - version: 3.0.2 - resolution: "eventsource-parser@npm:3.0.2" - checksum: 10/a42b0c494eb8026a88e9a3d313f5cc3efc4b81bdf59e64a13f69972ed71b7a4317f3c5d36410128e2c23193364ae8d851afda12738bb71fa40946c82c5bb3027 - languageName: node - linkType: hard - "eventsource@npm:^3.0.2": version: 3.0.7 resolution: "eventsource@npm:3.0.7" @@ -25251,10 +25232,10 @@ __metadata: languageName: node linkType: hard -"nodemailer@npm:7.0.7": - version: 7.0.7 - resolution: "nodemailer@npm:7.0.7" - checksum: 10/903d4e0a8320c0e4a2bede6737a9b4996048ddc2e010befc406c8953dcec96ef0e2c17e8b7639654e8bf46844cf7d26f017d8bf9fd629588637b699e09547222 +"nodemailer@npm:7.0.11": + version: 7.0.11 + resolution: "nodemailer@npm:7.0.11" + checksum: 10/2ad4dd56a4caf84a83aa6f4378ded26d5ef8a644ca3be09c3b4fb2255d861369e620f29be6c3c97148ac4a50aa5fdff6240b9d60805362bd99ca15f2ea62e8a2 languageName: node linkType: hard @@ -34958,20 +34939,13 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.25 || ^4.0": +"zod@npm:^3.25 || ^4.0, zod@npm:^4.0.0": version: 4.1.13 resolution: "zod@npm:4.1.13" checksum: 10/0679190318928f69fcb07751063719de232c663b13955fcdb55db59839569d39f3f29b955cb0cba7af0b724233f88c06b3e84c550397ad4e68f8088fa6799d88 languageName: node linkType: hard -"zod@npm:^4.0.0": - version: 4.0.15 - resolution: "zod@npm:4.0.15" - checksum: 10/a91e998d519b697a82e0f5ceea8b9c1e3a2ebc80ef6a275fc71b7f7b052cd4ab45140525c4ba93ad60fa28e0c72dc6f6c326be954aa3f621699b9a2d05fbdf1c - languageName: node - linkType: hard - "zstddec@npm:^0.1.0": version: 0.1.0 resolution: "zstddec@npm:0.1.0" From a9c2117aa7af9289afd08369d0c12280d8774d94 Mon Sep 17 00:00:00 2001 From: vesalaakso-oura Date: Mon, 5 Jan 2026 18:53:45 +0200 Subject: [PATCH 19/79] Transformers: Add smoothing transformer (#111077) * Transformers: Add smoothing transformer Added a smoothing transformer to help clean up noisy time series data. It uses the ASAP algorithm to pick the most important data points while keeping the overall shape and trends intact. The transformer always keeps the first and last points so you get the complete time range. I also added a test for it. * Change category Change category from Reformat to CalculateNewFields * Remove first/last point preservation * Fix operator recreation * Simplify ASAP code Include performance optimization as well * Refactor interpolateFromSmoothedCurve Break function into smaller focused functions and lift functions to the top level * Add isApplicable Check Make sure the transformer is applicable for timeseries data * Add tests for isApplicable check * UI/UX improvements: Display effective resolution when limited by data points Show "Effective: X" indicator when resolution is capped by the 2x data points multiplier. Includes tooltip explaining the limit. Memoizes calculation to prevent unnecessary recalculation on re-renders. Example: With 72 data points and resolution set to 150, displays "Effective: 144" since the limit is 72 x 2 = 144. Plus added tests * Improve discoverability by adding tags * Preserve Original Data Let's preserve original data as well, makes the UX so much better. Changed from appending (smoothed) to frame names to use Smoothed frame name. This should match the pattern used by other transformers (e.g,. regression) Updated tests accordingly Updated tooltip note * Add asap tests Basic functionality: * returns valid DataPoint objects * Maintain x-axis ordering Edge cases: * Empty array * single data point * filter NaN values * all NaN values * sort unsorted data * negative values * Update dark and light images * Clear state cache * Add feature toggle * Conditionally add new transformation to the registry * chore: update and regenerate feature toggles * chore: update yarn.lock * chore: fix transformers and imports --- package.json | 1 + .../src/transformations/transformers/ids.ts | 1 + .../src/types/featureToggles.gen.ts | 4 + pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.json | 13 + .../app/features/transformers/docs/content.ts | 47 ++ .../transformers/images/dark/smoothing.svg | 72 ++ .../transformers/images/light/smoothing.svg | 72 ++ .../transformers/smoothing/asap.test.ts | 130 +++ .../features/transformers/smoothing/asap.ts | 40 + .../transformers/smoothing/smoothing.test.ts | 744 ++++++++++++++++++ .../transformers/smoothing/smoothing.ts | 267 +++++++ .../smoothing/smoothingEditor.tsx | 93 +++ .../transformers/standardTransformers.ts | 3 + public/locales/en-US/grafana.json | 11 + yarn.lock | 8 + 17 files changed, 1514 insertions(+) create mode 100644 public/app/features/transformers/images/dark/smoothing.svg create mode 100644 public/app/features/transformers/images/light/smoothing.svg create mode 100644 public/app/features/transformers/smoothing/asap.test.ts create mode 100644 public/app/features/transformers/smoothing/asap.ts create mode 100644 public/app/features/transformers/smoothing/smoothing.test.ts create mode 100644 public/app/features/transformers/smoothing/smoothing.ts create mode 100644 public/app/features/transformers/smoothing/smoothingEditor.tsx diff --git a/package.json b/package.json index 41389e135e7..d36b4bfc5f8 100644 --- a/package.json +++ b/package.json @@ -347,6 +347,7 @@ "date-fns": "4.1.0", "debounce-promise": "3.1.2", "diff": "^8.0.0", + "downsample": "1.4.0", "fast-deep-equal": "^3.1.3", "fast-json-patch": "3.1.1", "file-saver": "2.0.5", diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts index cc2b76fae69..a3d5536c670 100644 --- a/packages/grafana-data/src/transformations/transformers/ids.ts +++ b/packages/grafana-data/src/transformations/transformers/ids.ts @@ -42,5 +42,6 @@ export enum DataTransformerID { formatTime = 'formatTime', formatString = 'formatString', regression = 'regression', + smoothing = 'smoothing', groupToNestedTable = 'groupToNestedTable', } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index aebbab8c6f9..06aa45d2275 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1255,4 +1255,8 @@ export interface FeatureToggles { * Enables support for variables whose values can have multiple properties */ multiPropsVariables?: boolean; + /** + * Enables the ASAP smoothing transformation for time series data + */ + smoothingTransformation?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 3748db8e6b4..2933551d1ad 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2075,6 +2075,13 @@ var ( FrontendOnly: true, Owner: grafanaDashboardsSquad, }, + { + Name: "smoothingTransformation", + Description: "Enables the ASAP smoothing transformation for time series data", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDataProSquad, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 0c85021cff8..e2d15a8466b 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -281,3 +281,4 @@ rudderstackUpgrade,experimental,@grafana/grafana-frontend-platform,false,false,t kubernetesAlertingHistorian,experimental,@grafana/alerting-squad,false,true,false useMTPlugins,experimental,@grafana/plugins-platform-backend,false,false,true multiPropsVariables,experimental,@grafana/dashboards-squad,false,false,true +smoothingTransformation,experimental,@grafana/datapro,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 6d55a6ca617..66910dc9d1c 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3293,6 +3293,19 @@ "codeowner": "@grafana/dashboards-squad" } }, + { + "metadata": { + "name": "smoothingTransformation", + "resourceVersion": "1767349656275", + "creationTimestamp": "2026-01-02T10:27:36Z" + }, + "spec": { + "description": "Enables the ASAP smoothing transformation for time series data", + "stage": "experimental", + "codeowner": "@grafana/datapro", + "frontend": true + } + }, { "metadata": { "name": "sqlExpressions", diff --git a/public/app/features/transformers/docs/content.ts b/public/app/features/transformers/docs/content.ts index b41b14b57c8..cde7f209b19 100644 --- a/public/app/features/transformers/docs/content.ts +++ b/public/app/features/transformers/docs/content.ts @@ -1612,6 +1612,53 @@ ${buildImageContent( `; }, }, + smoothing: { + name: 'Smoothing', + getHelperDocs: function (imageRenderType: ImageRenderType = ImageRenderType.ShortcodeFigure) { + return ` +Use this transformation to reduce noise in time series data through adaptive smoothing. This transformation creates smoother, cleaner visualizations while preserving all original time points and important trends and patterns in your data. + +The smoothing transformation uses the ASAP (Automatic Smoothing for Attention Prioritization) algorithm internally to generate a smoothed curve, which is then interpolated back onto all original time points. This ensures your visualization maintains continuous lines without gaps while reducing noise. + +#### Available options + +- **Resolution** - Controls smoothing intensity (1-1000). Lower values create more aggressive smoothing, while higher values preserve more detail. The output preserves all original time points. + +#### When to use smoothing + +This transformation is useful for: + +- Noisy time series data that obscures underlying trends +- Clearer trend analysis and pattern recognition + +#### Example + +Consider noisy sensor data with thousands of points: + +**Before smoothing:** + +| Time | Temperature | +| ------------------- | ----------- | +| 2020-07-07 10:00:00 | 23.1 | +| 2020-07-07 10:00:01 | 23.3 | +| 2020-07-07 10:00:02 | 22.9 | +| 2020-07-07 10:00:03 | 23.2 | +| ... (thousands more) | ... | + +**After smoothing (Resolution: 100):** + +| Time | Temperature (smoothed) | +| ------------------- | ---------------------- | +| 2020-07-07 10:00:00 | 23.1 | +| 2020-07-07 10:00:01 | 23.1 | +| 2020-07-07 10:00:02 | 23.0 | +| 2020-07-07 10:00:03 | 23.0 | +| ... (same count) | ... | + +The transformation preserves all original time points while reducing noise, resulting in smoother curves that maintain continuous lines without gaps. + `; + }, + }, }; function buildImageContent(source: string, imageRenderType: ImageRenderType, imageAltText: string) { diff --git a/public/app/features/transformers/images/dark/smoothing.svg b/public/app/features/transformers/images/dark/smoothing.svg new file mode 100644 index 00000000000..e95ddc2f840 --- /dev/null +++ b/public/app/features/transformers/images/dark/smoothing.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/features/transformers/images/light/smoothing.svg b/public/app/features/transformers/images/light/smoothing.svg new file mode 100644 index 00000000000..49651ec4b1e --- /dev/null +++ b/public/app/features/transformers/images/light/smoothing.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/features/transformers/smoothing/asap.test.ts b/public/app/features/transformers/smoothing/asap.test.ts new file mode 100644 index 00000000000..195da8a7565 --- /dev/null +++ b/public/app/features/transformers/smoothing/asap.test.ts @@ -0,0 +1,130 @@ +import { asapSmooth, DataPoint, ASAPOptions } from './asap'; + +describe('asapSmooth', () => { + describe('Basic functionality', () => { + it('should return smoothed data with valid DataPoint objects', () => { + const data: DataPoint[] = [ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + { x: 2, y: 2 }, + { x: 3, y: 3 }, + { x: 4, y: 4 }, + ]; + + const options: ASAPOptions = { resolution: 3 }; + const result = asapSmooth(data, options); + + expect(result.length).toBeGreaterThan(0); + result.forEach((point) => { + expect(point).toHaveProperty('x'); + expect(point).toHaveProperty('y'); + expect(typeof point.x).toBe('number'); + expect(typeof point.y).toBe('number'); + }); + }); + + it('should maintain x-axis ordering', () => { + const data: DataPoint[] = Array.from({ length: 20 }, (_, i) => ({ + x: i, + y: Math.random() * 100, + })); + + const options: ASAPOptions = { resolution: 10 }; + const result = asapSmooth(data, options); + + // check that x values are in ascending order + for (let i = 1; i < result.length; i++) { + expect(result[i].x).toBeGreaterThanOrEqual(result[i - 1].x); + } + }); + }); + + describe('Edge cases', () => { + it('should handle empty array', () => { + const data: DataPoint[] = []; + const options: ASAPOptions = { resolution: 10 }; + + const result = asapSmooth(data, options); + + expect(result).toEqual([]); + }); + + it('should handle single data point', () => { + const data: DataPoint[] = [{ x: 1, y: 42 }]; + const options: ASAPOptions = { resolution: 10 }; + + const result = asapSmooth(data, options); + + expect(result.length).toBeGreaterThan(0); + expect(result[0].x).toBe(1); + expect(result[0].y).toBe(42); + }); + + it('should filter out NaN values', () => { + const data: DataPoint[] = [ + { x: 0, y: 0 }, + { x: 1, y: NaN }, + { x: 2, y: 2 }, + { x: 3, y: NaN }, + { x: 4, y: 4 }, + ]; + + const options: ASAPOptions = { resolution: 3 }; + const result = asapSmooth(data, options); + + expect(result.length).toBeGreaterThan(0); + result.forEach((point) => { + expect(isNaN(point.x)).toBe(false); + expect(isNaN(point.y)).toBe(false); + }); + }); + + it('should return empty array when all values are NaN', () => { + const data: DataPoint[] = [ + { x: 0, y: NaN }, + { x: 1, y: NaN }, + { x: 2, y: NaN }, + ]; + + const options: ASAPOptions = { resolution: 3 }; + const result = asapSmooth(data, options); + + expect(result).toEqual([]); + }); + + it('should sort unsorted data', () => { + const data: DataPoint[] = [ + { x: 3, y: 3 }, + { x: 1, y: 1 }, + { x: 4, y: 4 }, + { x: 0, y: 0 }, + { x: 2, y: 2 }, + ]; + + const options: ASAPOptions = { resolution: 3 }; + const result = asapSmooth(data, options); + + expect(result.length).toBeGreaterThan(0); + + // result should be sorted by x + for (let i = 1; i < result.length; i++) { + expect(result[i].x).toBeGreaterThanOrEqual(result[i - 1].x); + } + }); + + it('should handle negative values', () => { + const data: DataPoint[] = Array.from({ length: 10 }, (_, i) => ({ + x: i, + y: -i * 2, + })); + + const options: ASAPOptions = { resolution: 5 }; + const result = asapSmooth(data, options); + + expect(result.length).toBeGreaterThan(0); + result.forEach((point) => { + expect(isFinite(point.y)).toBe(true); + }); + }); + }); +}); diff --git a/public/app/features/transformers/smoothing/asap.ts b/public/app/features/transformers/smoothing/asap.ts new file mode 100644 index 00000000000..93e28c3dbec --- /dev/null +++ b/public/app/features/transformers/smoothing/asap.ts @@ -0,0 +1,40 @@ +import { ASAP } from 'downsample'; + +export interface DataPoint { + x: number; + y: number; +} + +export interface ASAPOptions { + resolution: number; +} + +export function asapSmooth(data: DataPoint[], options: ASAPOptions): DataPoint[] { + const { resolution } = options; + + if (!data || data.length === 0) { + return []; + } + + // Filter invalid points and convert to tuple format for ASAP library + const inputData: Array<[number, number]> = data + .filter((point) => point != null && !isNaN(point.x) && !isNaN(point.y)) + .map((point) => [point.x, point.y]); + + if (inputData.length === 0) { + return []; + } + + // this prevents O(m×n) degradation if inputData is unsorted data + inputData.sort((a, b) => a[0] - b[0]); + + // ASAP always returns objects with x and y properties + const smoothedData = ASAP(inputData, resolution); + + // Convert back to DataPoint format + const result: DataPoint[] = Array.from(smoothedData).filter( + (item): item is DataPoint => item !== null && typeof item === 'object' && 'x' in item && 'y' in item + ); + + return result; +} diff --git a/public/app/features/transformers/smoothing/smoothing.test.ts b/public/app/features/transformers/smoothing/smoothing.test.ts new file mode 100644 index 00000000000..859ea75c7be --- /dev/null +++ b/public/app/features/transformers/smoothing/smoothing.test.ts @@ -0,0 +1,744 @@ +import { + DataFrame, + DataTransformContext, + FieldType, + toDataFrame, + TransformationApplicabilityLevels, +} from '@grafana/data'; + +import { calculateMaxSourcePoints, getSmoothingTransformer, SmoothingTransformerOptions } from './smoothing'; + +describe('Smoothing transformer', () => { + const smoothingTransformer = getSmoothingTransformer(); + const ctx: DataTransformContext = { + interpolate: (v: string) => v, + }; + + describe('isApplicable', () => { + it('should return Applicable for time series frames', () => { + const frames = [ + toDataFrame({ + name: 'time series', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [10, 20, 15] }, + ], + }), + ]; + + expect(smoothingTransformer.isApplicable!(frames)).toBe(TransformationApplicabilityLevels.Applicable); + }); + + it('should return NotApplicable for frames without time field', () => { + const frames = [ + toDataFrame({ + name: 'no time field', + fields: [ + { name: 'category', type: FieldType.string, values: ['A', 'B', 'C'] }, + { name: 'value', type: FieldType.number, values: [10, 20, 15] }, + ], + }), + ]; + + expect(smoothingTransformer.isApplicable!(frames)).toBe(TransformationApplicabilityLevels.NotApplicable); + }); + + it('should return Applicable if at least one frame is a time series', () => { + const frames = [ + toDataFrame({ + name: 'not time series', + fields: [ + { name: 'category', type: FieldType.string, values: ['A', 'B', 'C'] }, + { name: 'label', type: FieldType.string, values: ['X', 'Y', 'Z'] }, + ], + }), + toDataFrame({ + name: 'time series', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [10, 20, 15] }, + ], + }), + ]; + + expect(smoothingTransformer.isApplicable!(frames)).toBe(TransformationApplicabilityLevels.Applicable); + }); + + it('should return NotApplicable for empty data', () => { + const frames: DataFrame[] = []; + + expect(smoothingTransformer.isApplicable!(frames)).toBe(TransformationApplicabilityLevels.NotApplicable); + }); + }); + + describe('Basic functionality', () => { + it('should smooth time series data with default settings', () => { + const source = [ + toDataFrame({ + name: 'test data', + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000, 5000] }, + { name: 'value', type: FieldType.number, values: [10, 20, 15, 25, 18] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = {}; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return both original and smoothed frames + expect(result).toHaveLength(2); + + // first frame should be the original, unchanged + expect(result[0].name).toBe('test data'); + expect(result[0].fields).toHaveLength(2); + expect(result[0].fields[0].name).toBe('time'); + expect(result[0].fields[1].name).toBe('value'); + expect(result[0].fields[1].values).toEqual([10, 20, 15, 25, 18]); + + // second frame should be the smoothed version + expect(result[1].name).toBe('Smoothed'); + expect(result[1].fields).toHaveLength(2); + expect(result[1].fields[0].name).toBe('time'); + expect(result[1].fields[1].name).toBe('value'); + + // should preserve original time points + expect(result[1].fields[0].values).toEqual([1000, 2000, 3000, 4000, 5000]); + // should have corresponding smoothed values + expect(result[1].fields[1].values.length).toBe(5); + }); + + it('should handle multiple numeric fields', () => { + const source = [ + toDataFrame({ + name: 'multi field data', + refId: 'B', + fields: [ + { name: 'timestamp', type: FieldType.time, values: [1000, 2000, 3000, 4000] }, + { name: 'cpu', type: FieldType.number, values: [50, 75, 60, 80] }, + { name: 'memory', type: FieldType.number, values: [40, 55, 45, 65] }, + { name: 'label', type: FieldType.string, values: ['a', 'b', 'c', 'd'] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = { resolution: 3 }; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return both original and smoothed frames + expect(result).toHaveLength(2); + + // first frame is original + expect(result[0].name).toBe('multi field data'); + expect(result[0].fields[1].name).toBe('cpu'); + expect(result[0].fields[2].name).toBe('memory'); + + // second frame is smoothed + expect(result[1].fields).toHaveLength(4); + expect(result[1].fields[0].name).toBe('timestamp'); + expect(result[1].fields[1].name).toBe('cpu'); + expect(result[1].fields[2].name).toBe('memory'); + expect(result[1].fields[3].name).toBe('label'); + + // all numeric fields should be smoothed and preserve original time points + expect(result[1].fields[0].values.length).toBe(4); + expect(result[1].fields[1].values.length).toBe(4); + expect(result[1].fields[2].values.length).toBe(4); + }); + + it('should preserve non-numeric and non-time fields', () => { + const source = [ + toDataFrame({ + name: 'mixed data', + refId: 'C', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [10, 20, 15] }, + { name: 'category', type: FieldType.string, values: ['A', 'B', 'C'] }, + { name: 'active', type: FieldType.boolean, values: [true, false, true] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = { resolution: 2 }; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return both original and smoothed frames + expect(result).toHaveLength(2); + + // smoothed frame should preserve non-numeric fields + expect(result[1].fields[2].name).toBe('category'); + expect(result[1].fields[2].type).toBe(FieldType.string); + expect(result[1].fields[3].name).toBe('active'); + expect(result[1].fields[3].type).toBe(FieldType.boolean); + }); + }); + + describe('Configuration options', () => { + it('should use default resolution when not specified', () => { + const source = [ + toDataFrame({ + name: 'default test', + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: Array.from({ length: 200 }, (_, i) => i * 1000) }, + { name: 'value', type: FieldType.number, values: Array.from({ length: 200 }, () => Math.random() * 100) }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = {}; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return both original and smoothed frames + expect(result).toHaveLength(2); + + // smoothed frame should preserve all original time points + expect(result[1].fields[0].values.length).toBe(200); + expect(result[1].fields[1].values.length).toBe(200); + }); + + it('should respect custom resolution settings', () => { + const source = [ + toDataFrame({ + name: 'resolution test', + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: Array.from({ length: 100 }, (_, i) => i * 1000) }, + { name: 'value', type: FieldType.number, values: Array.from({ length: 100 }, () => Math.random() * 100) }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = { resolution: 25 }; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return both original and smoothed frames + expect(result).toHaveLength(2); + + // smoothed frame should preserve all original time points regardless of resolution + expect(result[1].fields[0].values.length).toBe(100); + expect(result[1].fields[1].values.length).toBe(100); + }); + + it('should clamp resolution to minimum value', () => { + const source = [ + toDataFrame({ + name: 'small resolution test', + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000, 5000] }, + { name: 'value', type: FieldType.number, values: [10, 20, 15, 25, 18] }, + ], + }), + ]; + + // request resolution below minimum, it should be clamped to 1 + const config: SmoothingTransformerOptions = { resolution: 2 }; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return both original and smoothed frames + expect(result).toHaveLength(2); + + // smoothed frame should preserve all original time points and clamp resolution to minimum + expect(result[1].fields[0].values.length).toBe(5); + expect(result[1].fields[1].values.length).toBe(5); + }); + }); + + describe('Edge cases', () => { + it('should handle empty data frames', () => { + const source: DataFrame[] = []; + + const config: SmoothingTransformerOptions = {}; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + expect(result).toEqual([]); + }); + + it('should handle frames without time fields', () => { + const source = [ + toDataFrame({ + name: 'no time field', + refId: 'A', + fields: [ + { name: 'category', type: FieldType.string, values: ['A', 'B', 'C'] }, + { name: 'value', type: FieldType.number, values: [10, 20, 15] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = {}; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return original frame unchanged + expect(result).toHaveLength(1); + expect(result[0]).toEqual(source[0]); + }); + + it('should handle frames without numeric fields', () => { + const source = [ + toDataFrame({ + name: 'no numeric fields', + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'category', type: FieldType.string, values: ['A', 'B', 'C'] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = {}; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return original frame unchanged + expect(result).toHaveLength(1); + expect(result[0]).toEqual(source[0]); + }); + + it('should filter out NaN values when smoothing', () => { + const source = [ + toDataFrame({ + name: 'data with NaN', + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000, 5000] }, + { name: 'value', type: FieldType.number, values: [10, NaN, 15, 25, NaN] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = { resolution: 3 }; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return both original and smoothed frames + expect(result).toHaveLength(2); + + // smoothed frame should preserve all time points + expect(result[1].fields[0].values.length).toBe(5); + expect(result[1].fields[1].values.length).toBe(5); + + // all values should be interpolated from smoothed curve (no nulls) + const values = result[1].fields[1].values; + values.forEach((value) => { + expect(value).not.toBeNull(); + expect(typeof value).toBe('number'); + expect(isNaN(value)).toBe(false); + }); + }); + + it('should handle data with all NaN values', () => { + const source = [ + toDataFrame({ + name: 'all NaN data', + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [NaN, NaN, NaN] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = {}; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // When all values are NaN, only original frame should be returned (no smoothed frame) + expect(result).toHaveLength(1); + expect(result[0].fields[1].name).toBe('value'); // No "(smoothed)" suffix + expect(result[0].fields[1].values).toEqual([NaN, NaN, NaN]); + expect(result[0].name).toBe('all NaN data'); // Original name preserved + }); + + it('should handle data with null values', () => { + const source = [ + toDataFrame({ + name: 'data with nulls', + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] }, + { name: 'value', type: FieldType.number, values: [10, null, 15, 25] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = { resolution: 3 }; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return both original and smoothed frames + expect(result).toHaveLength(2); + + // smoothed frame should preserve all time points + expect(result[1].fields[0].values.length).toBe(4); + expect(result[1].fields[1].values.length).toBe(4); + + // all values should be interpolated (no nulls in output) + const values = result[1].fields[1].values; + values.forEach((value) => { + expect(value).not.toBeNull(); + expect(typeof value).toBe('number'); + expect(isNaN(value)).toBe(false); + }); + }); + + it('should handle single data point', () => { + const source = [ + toDataFrame({ + name: 'single point', + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000] }, + { name: 'value', type: FieldType.number, values: [42] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = {}; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return both original and smoothed frames + expect(result).toHaveLength(2); + + expect(result[1].fields[0].values).toHaveLength(1); + expect(result[1].fields[1].values).toHaveLength(1); + expect(result[1].fields[1].values[0]).toBe(42); + }); + + it('should handle empty numeric field values', () => { + const source = [ + toDataFrame({ + name: 'empty values', + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = {}; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return original frame since no numeric data to smooth + expect(result[0]).toEqual(source[0]); + }); + }); + + describe('Data integrity', () => { + it('should maintain time ordering in smoothed data', () => { + const source = [ + toDataFrame({ + name: 'ordered data', + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000, 5000] }, + { name: 'value', type: FieldType.number, values: [10, 20, 15, 25, 18] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = { resolution: 4 }; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // check smoothed frame's time values + const timeValues = result[1].fields[0].values as number[]; + + // check that time values are in ascending order + for (let i = 1; i < timeValues.length; i++) { + expect(timeValues[i]).toBeGreaterThanOrEqual(timeValues[i - 1]); + } + }); + + it('should preserve original frame metadata', () => { + const source = [ + toDataFrame({ + name: 'original name', + refId: 'TEST', + meta: { custom: { test: 'value' } }, + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [10, 20, 15] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = {}; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return both original and smoothed frames + expect(result).toHaveLength(2); + + // original frame unchanged + expect(result[0].refId).toBe('TEST'); + expect(result[0].meta).toEqual(source[0].meta); + expect(result[0].name).toBe('original name'); + + // smoothed frame preserves metadata + expect(result[1].refId).toBe('TEST'); + expect(result[1].meta).toEqual(source[0].meta); + expect(result[1].name).toBe('Smoothed'); + }); + + it('should handle frames with no name', () => { + const source = [ + toDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [10, 20, 15] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = {}; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return both original and smoothed frames + expect(result).toHaveLength(2); + expect(result[1].name).toBe('Smoothed'); + }); + }); + + describe('Real-world scenarios', () => { + it('should handle sparse data with irregular intervals', () => { + // based on real user data with ~10 points over 30 minutes + const source = [ + toDataFrame({ + name: 'temperature', + refId: 'A', + fields: [ + { + name: 'time', + type: FieldType.time, + values: [ + 1733999700000, 1733999790000, 1734000000000, 1734000210000, 1734000420000, 1734000630000, 1734000840000, + 1734001050000, 1734001260000, 1734001470000, + ], + }, + { + name: 'value', + type: FieldType.number, + values: [31.1, 31.1, 30.2, 30.8, 29.8, 30.0, 29.3, 28.6, 29.6, 30.5], + }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = { resolution: 20 }; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return both original and smoothed frames + expect(result).toHaveLength(2); + expect(result[1].fields[0].values.length).toBe(10); + expect(result[1].fields[1].values.length).toBe(10); + + // all values should be non-null numbers + const values = result[1].fields[1].values; + values.forEach((value) => { + expect(value).not.toBeNull(); + expect(typeof value).toBe('number'); + expect(isNaN(value)).toBe(false); + }); + }); + }); + + describe('Multiple frames', () => { + it('should process multiple frames independently', () => { + const source = [ + toDataFrame({ + name: 'frame1', + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [10, 20, 15] }, + ], + }), + toDataFrame({ + name: 'frame2', + refId: 'B', + fields: [ + { name: 'timestamp', type: FieldType.time, values: [4000, 5000, 6000] }, + { name: 'metric', type: FieldType.number, values: [30, 40, 35] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = { resolution: 2 }; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return original frames + smoothed frames (2 original + 2 smoothed = 4 total) + expect(result).toHaveLength(4); + + // original frames first + expect(result[0].name).toBe('frame1'); + expect(result[0].refId).toBe('A'); + expect(result[1].name).toBe('frame2'); + expect(result[1].refId).toBe('B'); + + // smoothed frames after + expect(result[2].name).toBe('Smoothed'); + expect(result[2].refId).toBe('A'); + expect(result[3].name).toBe('Smoothed'); + expect(result[3].refId).toBe('B'); + }); + + it('should handle mixed frame types', () => { + const source = [ + toDataFrame({ + name: 'valid frame', + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [10, 20, 15] }, + ], + }), + toDataFrame({ + name: 'invalid frame', + refId: 'B', + fields: [ + { name: 'category', type: FieldType.string, values: ['A', 'B', 'C'] }, + { name: 'label', type: FieldType.string, values: ['X', 'Y', 'Z'] }, + ], + }), + ]; + + const config: SmoothingTransformerOptions = {}; + + const result = smoothingTransformer.transformer(config, ctx)(source); + + // should return 2 original frames + 1 smoothed frame (only valid frame gets smoothed) + expect(result).toHaveLength(3); + + // original frames first + expect(result[0].name).toBe('valid frame'); + expect(result[1]).toEqual(source[1]); + + // smoothed frame after + expect(result[2].name).toBe('Smoothed'); + }); + }); + + describe('calculateMaxSourcePoints', () => { + it('should return 0 for empty frames', () => { + expect(calculateMaxSourcePoints([])).toBe(0); + }); + + it('should return 0 for frames without time fields', () => { + const frames = [ + toDataFrame({ + fields: [ + { name: 'category', type: FieldType.string, values: ['A', 'B', 'C'] }, + { name: 'value', type: FieldType.number, values: [10, 20, 15] }, + ], + }), + ]; + + expect(calculateMaxSourcePoints(frames)).toBe(0); + }); + + it('should return 0 for frames without numeric fields', () => { + const frames = [ + toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'category', type: FieldType.string, values: ['A', 'B', 'C'] }, + ], + }), + ]; + + expect(calculateMaxSourcePoints(frames)).toBe(0); + }); + + it('should count valid data points, filtering out null and NaN', () => { + const frames = [ + toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000, 5000] }, + { name: 'value', type: FieldType.number, values: [10, null, 15, NaN, 18] }, + ], + }), + ]; + + // Only 3 valid points: 10, 15, 18 + expect(calculateMaxSourcePoints(frames)).toBe(3); + }); + + it('should return maximum across multiple numeric fields', () => { + const frames = [ + toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000, 5000] }, + { name: 'cpu', type: FieldType.number, values: [10, null, 15] }, // 2 valid points + { name: 'memory', type: FieldType.number, values: [20, 25, 30, 35] }, // 4 valid points + ], + }), + ]; + + expect(calculateMaxSourcePoints(frames)).toBe(4); + }); + + it('should return maximum across multiple frames', () => { + const frames = [ + toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [10, 20, 15] }, + ], + }), + toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000, 5000] }, + { name: 'metric', type: FieldType.number, values: [30, 40, 35, 45, 50] }, + ], + }), + ]; + + expect(calculateMaxSourcePoints(frames)).toBe(5); + }); + + it('should handle frames with all valid points', () => { + const frames = [ + toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000, 4000] }, + { name: 'value', type: FieldType.number, values: [10, 20, 30, 40] }, + ], + }), + ]; + + expect(calculateMaxSourcePoints(frames)).toBe(4); + }); + + it('should handle frames with all null values', () => { + const frames = [ + toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [null, null, null] }, + ], + }), + ]; + + expect(calculateMaxSourcePoints(frames)).toBe(0); + }); + }); +}); diff --git a/public/app/features/transformers/smoothing/smoothing.ts b/public/app/features/transformers/smoothing/smoothing.ts new file mode 100644 index 00000000000..ad829f8cc91 --- /dev/null +++ b/public/app/features/transformers/smoothing/smoothing.ts @@ -0,0 +1,267 @@ +import { map } from 'rxjs'; + +import { + DataFrame, + DataTransformerID, + FieldType, + SynchronousDataTransformerInfo, + isTimeSeriesFrame, + TransformationApplicabilityLevels, +} from '@grafana/data'; +import { t } from '@grafana/i18n'; + +import { asapSmooth, DataPoint } from './asap'; + +export interface SmoothingTransformerOptions { + resolution?: number; +} + +export const DEFAULTS = { + resolution: 100, +}; + +export const RESOLUTION_LIMITS = { + min: 1, + max: 1000, +}; + +const MAX_RESOLUTION_MULTIPLIER = 2; + +// converts time and value arrays into valid DataPoints, filtering out null/NaN values +export const createDataPoints = (timeValues: number[], sourceField: Array): DataPoint[] => { + return timeValues + .map((time, index) => ({ + x: time, + y: sourceField[index], + })) + .filter((point): point is DataPoint => point.y != null && !isNaN(point.y)); +}; + +// calculates effective resolution capped at 2x source points +export const calculateEffectiveResolution = (resolution: number, sourcePointCount: number): number => { + return Math.min(resolution, sourcePointCount * MAX_RESOLUTION_MULTIPLIER); +}; + +// calculates the maximum number of source points across all numeric fields in all frames +export const calculateMaxSourcePoints = (frames: DataFrame[]): number => { + let maxSourcePoints = 0; + + for (const frame of frames) { + const timeField = frame.fields.find((f) => f.type === FieldType.time); + if (!timeField) { + continue; + } + + for (const field of frame.fields) { + if (field.type === FieldType.number) { + const sourcePoints = createDataPoints(timeField.values, field.values); + if (sourcePoints.length > maxSourcePoints) { + maxSourcePoints = sourcePoints.length; + } + } + } + } + + return maxSourcePoints; +}; + +// performs linear interpolation between two points +export const linearInterpolate = (leftPoint: DataPoint, rightPoint: DataPoint, targetTime: number): number => { + // exact match + if (leftPoint.x === targetTime) { + return leftPoint.y; + } + if (rightPoint.x === targetTime) { + return rightPoint.y; + } + + // same point (shouldn't happen but handle gracefully) + if (leftPoint.x === rightPoint.x) { + return leftPoint.y; + } + + // linear interpolation + const ratio = (targetTime - leftPoint.x) / (rightPoint.x - leftPoint.x); + return leftPoint.y + ratio * (rightPoint.y - leftPoint.y); +}; + +// finds the two points in smoothedData that bracket the targetTime +export const findBracketingPoints = ( + smoothedData: DataPoint[], + targetTime: number, + lastIndex: number +): { leftPoint: DataPoint; rightPoint: DataPoint; newIndex: number } => { + // find the two points to interpolate between, starting from last known position + // if target is before our current search position, reset to beginning + let searchStart = Math.min(lastIndex, smoothedData.length - 2); + if (targetTime < smoothedData[searchStart].x) { + searchStart = 0; + } + + let leftPoint = smoothedData[searchStart]; + let rightPoint = smoothedData[searchStart + 1]; + let newIndex = searchStart; + + for (let i = searchStart; i < smoothedData.length - 1; i++) { + if (smoothedData[i].x <= targetTime && smoothedData[i + 1].x >= targetTime) { + leftPoint = smoothedData[i]; + rightPoint = smoothedData[i + 1]; + newIndex = i; + break; + } + } + + return { leftPoint, rightPoint, newIndex }; +}; + +// interpolates smoothed data back to original time points +export const interpolateToTimePoints = (smoothedData: DataPoint[], timeValues: number[]): number[] => { + const firstPoint = smoothedData[0]; + const lastPoint = smoothedData[smoothedData.length - 1]; + + let lastIndex = 0; + return timeValues.map((targetTime) => { + // handle out of bounds, use edge values instead of null + if (targetTime <= firstPoint.x) { + return firstPoint.y; + } + if (targetTime >= lastPoint.x) { + return lastPoint.y; + } + + const { leftPoint, rightPoint, newIndex } = findBracketingPoints(smoothedData, targetTime, lastIndex); + lastIndex = newIndex; + + return linearInterpolate(leftPoint, rightPoint, targetTime); + }); +}; + +// smooths a time series by creating a smoothed curve and interpolating back to original time points +export const interpolateFromSmoothedCurve = ( + sourceField: Array, + timeValues: number[], + resolution: number +): Array | null => { + const sourcePoints = createDataPoints(timeValues, sourceField); + + // if no valid source points, return null to signal this field should not be smoothed + if (sourcePoints.length === 0) { + return null; + } + + // smooth the source field's data with effective resolution + const effectiveFieldResolution = calculateEffectiveResolution(resolution, sourcePoints.length); + const smoothedData = asapSmooth(sourcePoints, { resolution: effectiveFieldResolution }); + + if (smoothedData.length === 0) { + return timeValues.map(() => null); + } + + // handle single point case - return the same value for all time points + if (smoothedData.length === 1) { + const singleValue = smoothedData[0].y; + return timeValues.map(() => singleValue); + } + + // this prevents O(m×n) degradation if asapSmooth returns unsorted data + smoothedData.sort((a, b) => a.x - b.x); + + // interpolate smoothed data back to original time points + return interpolateToTimePoints(smoothedData, timeValues); +}; + +export const getSmoothingTransformer: () => SynchronousDataTransformerInfo = () => ({ + id: DataTransformerID.smoothing, + name: t('transformers.smoothing.name', 'Smoothing'), + description: t( + 'transformers.smoothing.description', + 'Reduce noise in time series data through adaptive downsampling.' + ), + isApplicable: (data) => { + for (const frame of data) { + if (isTimeSeriesFrame(frame)) { + return TransformationApplicabilityLevels.Applicable; + } + } + + return TransformationApplicabilityLevels.NotApplicable; + }, + isApplicableDescription: t( + 'transformers.smoothing.is-applicable-description', + 'The Smoothing transformation requires at least one time series frame to function. You currently have none.' + ), + operator: (options, ctx) => { + const transformer = getSmoothingTransformer().transformer(options, ctx); + return (source) => source.pipe(map(transformer)); + }, + transformer: (options, ctx) => { + return (frames: DataFrame[]) => { + // clamp resolution to valid range to handle edge cases from API/plugins + const rawResolution = options.resolution ?? DEFAULTS.resolution; + const resolution = Math.max(RESOLUTION_LIMITS.min, Math.min(RESOLUTION_LIMITS.max, rawResolution)); + + if (frames.length === 0) { + return frames; + } + + const smoothedFrames: DataFrame[] = []; + + for (const frame of frames) { + const timeField = frame.fields.find((f) => f.type === FieldType.time); + if (!timeField) { + continue; + } + + // check if there's at least one numeric field with valid data + const hasValidNumericField = frame.fields.some((f) => { + if (f.type !== FieldType.number || f.values.length === 0) { + return false; + } + return f.values.some((v) => v != null && !isNaN(v)); + }); + + if (!hasValidNumericField) { + continue; + } + + // create smoothed fields for all numeric fields + const smoothedFields = [timeField]; // keep original time field + let anyFieldSmoothed = false; + + for (const field of frame.fields) { + if (field.type === FieldType.number) { + const smoothedValues = interpolateFromSmoothedCurve(field.values, timeField.values, resolution); + + // if smoothing returned null (no valid data), skip this field + if (smoothedValues === null) { + continue; + } + + anyFieldSmoothed = true; + smoothedFields.push({ + ...field, + values: smoothedValues, + state: undefined, + }); + } else if (field.type !== FieldType.time) { + // include other non-numeric, non-time fields (like labels) + smoothedFields.push(field); + } + } + + // only create a smoothed frame if at least one field was smoothed + if (anyFieldSmoothed) { + const smoothedFrame: DataFrame = { + ...frame, + name: 'Smoothed', + fields: smoothedFields, + }; + smoothedFrames.push(smoothedFrame); + } + } + + // return original frames followed by smoothed frames + return [...frames, ...smoothedFrames]; + }; + }, +}); diff --git a/public/app/features/transformers/smoothing/smoothingEditor.tsx b/public/app/features/transformers/smoothing/smoothingEditor.tsx new file mode 100644 index 00000000000..2f9ad2586d2 --- /dev/null +++ b/public/app/features/transformers/smoothing/smoothingEditor.tsx @@ -0,0 +1,93 @@ +import { css } from '@emotion/css'; +import { useMemo } from 'react'; + +import { DataTransformerID, TransformerRegistryItem, TransformerUIProps, TransformerCategory } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { InlineField, InlineFieldRow, Tooltip, useTheme2 } from '@grafana/ui'; +import { NumberInput } from 'app/core/components/OptionsUI/NumberInput'; + +import { getTransformationContent } from '../docs/getTransformationContent'; +import darkImage from '../images/dark/smoothing.svg'; +import lightImage from '../images/light/smoothing.svg'; + +import { + DEFAULTS, + RESOLUTION_LIMITS, + SmoothingTransformerOptions, + getSmoothingTransformer, + calculateEffectiveResolution, + calculateMaxSourcePoints, +} from './smoothing'; + +export const SmoothingTransformerEditor = ({ + input, + options, + onChange, +}: TransformerUIProps) => { + const theme = useTheme2(); + const resolution = options.resolution ?? DEFAULTS.resolution; + + const maxSourcePoints = useMemo(() => calculateMaxSourcePoints(input), [input]); + const effectiveResolution = maxSourcePoints > 0 ? calculateEffectiveResolution(resolution, maxSourcePoints) : null; + const showEffectiveResolution = effectiveResolution !== null && effectiveResolution < resolution; + + return ( + + + onChange({ ...options, resolution: v })} + min={RESOLUTION_LIMITS.min} + max={RESOLUTION_LIMITS.max} + width={20} + suffix={ + showEffectiveResolution ? ( + + + {t('transformers.smoothing.effective-resolution', 'Effective: {{value}}', { + value: effectiveResolution, + })} + + + ) : undefined + } + /> + + + ); +}; + +export const getSmoothingTransformerRegistryItem: () => TransformerRegistryItem = () => { + const smoothingTransformer = getSmoothingTransformer(); + return { + id: DataTransformerID.smoothing, + editor: SmoothingTransformerEditor, + transformation: smoothingTransformer, + name: smoothingTransformer.name, + description: smoothingTransformer.description, + categories: new Set([TransformerCategory.CalculateNewFields]), + imageDark: darkImage, + imageLight: lightImage, + help: getTransformationContent(DataTransformerID.smoothing).helperDocs, + tags: new Set(['ASAP', 'Autosmooth']), + }; +}; diff --git a/public/app/features/transformers/standardTransformers.ts b/public/app/features/transformers/standardTransformers.ts index 3dbe886bab3..5cebf190082 100644 --- a/public/app/features/transformers/standardTransformers.ts +++ b/public/app/features/transformers/standardTransformers.ts @@ -1,4 +1,5 @@ import { TransformerRegistryItem } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { getFilterByValueTransformRegistryItem } from './FilterByValueTransformer/FilterByValueTransformerEditor'; import { getHeatmapTransformRegistryItem } from './calculateHeatmap/HeatmapTransformerEditor'; @@ -31,6 +32,7 @@ import { getPartitionByValuesTransformRegistryItem } from './partitionByValues/P import { getPrepareTimeseriesTransformerRegistryItem } from './prepareTimeSeries/PrepareTimeSeriesEditor'; import { getRegressionTransformerRegistryItem } from './regression/regressionEditor'; import { getRowsToFieldsTransformRegistryItem } from './rowsToFields/RowsToFieldsTransformerEditor'; +import { getSmoothingTransformerRegistryItem } from './smoothing/smoothingEditor'; import { getSpatialTransformRegistryItem } from './spatial/SpatialTransformerEditor'; import { getTimeSeriesTableTransformRegistryItem } from './timeSeriesTable/TimeSeriesTableTransformEditor'; @@ -66,6 +68,7 @@ export const getStandardTransformers = (): TransformerRegistryItem[] => { getPartitionByValuesTransformRegistryItem(), getFormatStringTransformerRegistryItem(), getGroupToNestedTableTransformRegistryItem(), + ...(config.featureToggles.smoothingTransformation ? [getSmoothingTransformerRegistryItem()] : []), getFormatTimeTransformerRegistryItem(), getTimeSeriesTableTransformRegistryItem(), getTransposeTransformerRegistryItem(), diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d4274aa98cd..99ed9b512a6 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -14399,6 +14399,17 @@ "series-to-rows": "Series to rows" } }, + "smoothing": { + "description": "Reduce noise in time series data through adaptive downsampling.", + "effective-resolution": "Effective: {{value}}", + "effective-resolution-tooltip": "Resolution is limited to 2× the number of data points ({{points}}).", + "is-applicable-description": "The Smoothing transformation requires at least one time series frame to function. You currently have none.", + "name": "Smoothing", + "resolution": { + "label": "Resolution", + "tooltip": "Controls smoothing intensity. Lower values create more aggressive smoothing. Both original and smoothed data are displayed." + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Sort fields in a frame." diff --git a/yarn.lock b/yarn.lock index f9e4168eed8..afa76953435 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16497,6 +16497,13 @@ __metadata: languageName: node linkType: hard +"downsample@npm:1.4.0": + version: 1.4.0 + resolution: "downsample@npm:1.4.0" + checksum: 10/ad0ab937e368546b577b564b13d7f39cd85a92bf29d56562aaa6ed10bac19e91ee75ab58f38050a9e8bf601c1abcfda942541880a84c89ba78d1775a229636d1 + languageName: node + linkType: hard + "downshift@npm:^9.0.6": version: 9.0.10 resolution: "downshift@npm:9.0.10" @@ -19629,6 +19636,7 @@ __metadata: date-fns: "npm:4.1.0" debounce-promise: "npm:3.1.2" diff: "npm:^8.0.0" + downsample: "npm:1.4.0" enquirer: "npm:^2.4.1" esbuild: "npm:0.25.8" esbuild-loader: "npm:4.3.0" From 618316a2f701ab9edd3b77d65f8bdb85f2638149 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Mon, 5 Jan 2026 17:04:07 +0000 Subject: [PATCH 20/79] Revert "App Plugins: Allow to define experimental pages" (#115841) Revert "App Plugins: Allow to define experimental pages (#114232)" This reverts commit e1a2f178e7459986d8c10bb04d5fffc67d5652fc. --- pkg/middleware/auth.go | 29 ------ pkg/middleware/auth_test.go | 96 -------------------- pkg/services/navtree/navtreeimpl/applinks.go | 5 - 3 files changed, 130 deletions(-) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index f013d9d2bfa..719d2ab5cb5 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -1,7 +1,6 @@ package middleware import ( - "context" "errors" "net/http" "net/url" @@ -22,13 +21,6 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" - "github.com/open-feature/go-sdk/openfeature" -) - -var openfeatureClient = openfeature.NewDefaultClient() - -const ( - pluginPageFeatureFlagPrefix = "plugin-page-visible." ) type AuthOptions struct { @@ -154,12 +146,6 @@ func RoleAppPluginAuth(accessControl ac.AccessControl, ps pluginstore.Store, log return } - if !PageIsFeatureToggleEnabled(c.Req.Context(), c.Req.URL.Path) { - logger.Debug("Forbidden experimental plugin page", "plugin", pluginID, "path", c.Req.URL.Path) - accessForbidden(c) - return - } - permitted := true path := normalizeIncludePath(c.Req.URL.Path) hasAccess := ac.HasAccess(accessControl, c) @@ -308,18 +294,3 @@ func shouldForceLogin(c *contextmodel.ReqContext) bool { return forceLogin } - -// PageIsFeatureToggleEnabled checks if a page is enabled via OpenFeature feature flags. -// It returns false if the feature flag is set and set to false. -// The feature flag key format is: "plugin-page-visible." -func PageIsFeatureToggleEnabled(ctx context.Context, path string) bool { - flagKey := pluginPageFeatureFlagPrefix + filepath.Clean(path) - enabled := openfeatureClient.Boolean( - ctx, - flagKey, - true, - openfeature.TransactionContext(ctx), - ) - - return enabled -} diff --git a/pkg/middleware/auth_test.go b/pkg/middleware/auth_test.go index 19a7d68559e..fdca1d04ee3 100644 --- a/pkg/middleware/auth_test.go +++ b/pkg/middleware/auth_test.go @@ -1,17 +1,12 @@ package middleware import ( - "context" "errors" "fmt" "net/http" "net/http/httptest" - "sync" "testing" - "github.com/open-feature/go-sdk/openfeature" - "github.com/open-feature/go-sdk/openfeature/memprovider" - oftesting "github.com/open-feature/go-sdk/openfeature/testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -33,8 +28,6 @@ import ( "github.com/grafana/grafana/pkg/web" ) -var openfeatureTestMutex sync.Mutex - func setupAuthMiddlewareTest(t *testing.T, identity *authn.Identity, authErr error) *contexthandler.ContextHandler { return contexthandler.ProvideService(setting.NewCfg(), &authntest.FakeService{ ExpectedErr: authErr, @@ -429,60 +422,6 @@ func TestCanAdminPlugin(t *testing.T) { } } -func TestPageIsFeatureToggleEnabled(t *testing.T) { - type testCase struct { - desc string - path string - flags map[string]bool - expectedResult bool - } - - tests := []testCase{ - { - desc: "returns true when feature flag is enabled", - path: "/a/my-plugin/settings", - flags: map[string]bool{ - pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": true, - }, - expectedResult: true, - }, - { - desc: "returns false when feature flag is disabled", - path: "/a/my-plugin/settings", - flags: map[string]bool{ - pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": false, - }, - expectedResult: false, - }, - { - desc: "returns false when feature flag is disabled with trailing slash", - path: "/a/my-plugin/settings/", - flags: map[string]bool{ - pluginPageFeatureFlagPrefix + "/a/my-plugin/settings": false, - }, - expectedResult: false, - }, - { - desc: "returns true when feature flag does not exist", - path: "/a/my-plugin/settings", - flags: map[string]bool{}, - expectedResult: true, - }, - } - - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - ctx := context.Background() - - setupTestProvider(t, tt.flags) - - result := PageIsFeatureToggleEnabled(ctx, tt.path) - - assert.Equal(t, tt.expectedResult, result) - }) - } -} - func contextProvider(modifiers ...func(c *contextmodel.ReqContext)) web.Handler { return func(c *web.Context) { reqCtx := &contextmodel.ReqContext{ @@ -498,38 +437,3 @@ func contextProvider(modifiers ...func(c *contextmodel.ReqContext)) web.Handler c.Req = c.Req.WithContext(ctxkey.Set(c.Req.Context(), reqCtx)) } } - -// setupTestProvider creates a test OpenFeature provider with the given flags. -// Uses a global lock to prevent concurrent provider changes across tests. -func setupTestProvider(t *testing.T, flags map[string]bool) oftesting.TestProvider { - t.Helper() - - // Lock to prevent concurrent provider changes - openfeatureTestMutex.Lock() - - testProvider := oftesting.NewTestProvider() - flagsMap := map[string]memprovider.InMemoryFlag{} - - for key, value := range flags { - flagsMap[key] = memprovider.InMemoryFlag{ - DefaultVariant: "defaultVariant", - Variants: map[string]any{ - "defaultVariant": value, - }, - } - } - - testProvider.UsingFlags(t, flagsMap) - - err := openfeature.SetProviderAndWait(testProvider) - require.NoError(t, err) - - t.Cleanup(func() { - testProvider.Cleanup() - _ = openfeature.SetProviderAndWait(openfeature.NoopProvider{}) - // Unlock after cleanup to allow other tests to run - openfeatureTestMutex.Unlock() - }) - - return testProvider -} diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index 0b03357b5a8..e061b71e684 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -6,7 +6,6 @@ import ( "strconv" "strings" - "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -129,10 +128,6 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode } if include.Type == "page" { - if !middleware.PageIsFeatureToggleEnabled(c.Req.Context(), include.Path) { - s.log.Debug("Skipping page", "plugin", plugin.ID, "path", include.Path) - continue - } link := &navtree.NavLink{ Text: include.Name, Icon: include.Icon, From 658a1c82287d1522cace88fe6b6d88c2101027ab Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Mon, 5 Jan 2026 10:46:14 -0700 Subject: [PATCH 21/79] Dashboards: Allow editing provisioned dashboards if AllowUIUpdates is set (#115804) --- pkg/services/dashboards/models.go | 3 + .../dashboards/service/dashboard_service.go | 1 + .../provisioning/dashboards/file_reader.go | 2 + public/app/features/dashboard/api/v1.test.ts | 67 ++++++++++++++++++- public/app/features/dashboard/api/v1.ts | 6 +- 5 files changed, 77 insertions(+), 2 deletions(-) diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index c68263db693..c1a5ecec1c4 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -294,6 +294,9 @@ type DashboardProvisioning struct { ExternalID string `xorm:"external_id"` CheckSum string Updated int64 + + // note: only used when writing metadata to unified storage resources - not saved in legacy table. + AllowUIUpdates bool `xorm:"-"` } type DeleteDashboardCommand struct { diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index e105aaa3325..44698054157 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -1942,6 +1942,7 @@ func (dr *DashboardServiceImpl) saveProvisionedDashboardThroughK8s(ctx context.C // HOWEVER, maybe OK to leave this for now and "fix" it by using file provisioning for mode 4 m.Kind = utils.ManagerKindClassicFP // nolint:staticcheck m.Identity = provisioning.Name + m.AllowsEdits = provisioning.AllowUIUpdates s.Path = provisioning.ExternalID s.Checksum = provisioning.CheckSum s.TimestampMillis = time.Unix(provisioning.Updated, 0).UnixMilli() diff --git a/pkg/services/provisioning/dashboards/file_reader.go b/pkg/services/provisioning/dashboards/file_reader.go index 9a11eae0a9b..8f5f7741d8c 100644 --- a/pkg/services/provisioning/dashboards/file_reader.go +++ b/pkg/services/provisioning/dashboards/file_reader.go @@ -358,6 +358,8 @@ func (fr *FileReader) saveDashboard(ctx context.Context, path string, folderID i Name: fr.Cfg.Name, Updated: resolvedFileInfo.ModTime().Unix(), CheckSum: jsonFile.checkSum, + // adds `grafana.app/managerAllowsEdits` to the provisioned dashboards in unified storage. not used if in legacy. + AllowUIUpdates: fr.Cfg.AllowUIUpdates, } _, err := fr.dashboardProvisioningService.SaveProvisionedDashboard(ctx, dash, dp) if err != nil { diff --git a/public/app/features/dashboard/api/v1.test.ts b/public/app/features/dashboard/api/v1.test.ts index 433c74b99c0..7be87e3f4fd 100644 --- a/public/app/features/dashboard/api/v1.test.ts +++ b/public/app/features/dashboard/api/v1.test.ts @@ -1,7 +1,15 @@ import { GrafanaConfig, locationUtil } from '@grafana/data'; import * as folderHooks from 'app/api/clients/folder/v1beta1/hooks'; import { backendSrv } from 'app/core/services/backend_srv'; -import { AnnoKeyFolder, AnnoKeyMessage, AnnoReloadOnParamsChange } from 'app/features/apiserver/types'; +import { + AnnoKeyFolder, + AnnoKeyManagerAllowsEdits, + AnnoKeyManagerKind, + AnnoKeyMessage, + AnnoKeySourcePath, + AnnoReloadOnParamsChange, + ManagerKind, +} from 'app/features/apiserver/types'; import { DashboardDataDTO } from 'app/types/dashboard'; import { DashboardWithAccessInfo } from './types'; @@ -215,6 +223,63 @@ describe('v1 dashboard API', () => { expect(result.meta.reloadOnParamsChange).toBe(true); }); + describe('managed/provisioned dashboards', () => { + it('should not mark dashboard as provisioned when manager allows UI edits', async () => { + mockGet.mockResolvedValueOnce({ + ...mockDashboardDto, + metadata: { + ...mockDashboardDto.metadata, + annotations: { + [AnnoKeyManagerKind]: ManagerKind.Terraform, + [AnnoKeyManagerAllowsEdits]: 'true', + [AnnoKeySourcePath]: 'dashboards/test.json', + }, + }, + }); + + const api = new K8sDashboardAPI(); + const result = await api.getDashboardDTO('test'); + expect(result.meta.provisioned).toBe(false); + expect(result.meta.provisionedExternalId).toBe('dashboards/test.json'); + }); + + it('should mark dashboard as provisioned when manager does not allow UI edits', async () => { + mockGet.mockResolvedValueOnce({ + ...mockDashboardDto, + metadata: { + ...mockDashboardDto.metadata, + annotations: { + [AnnoKeyManagerKind]: ManagerKind.Terraform, + [AnnoKeySourcePath]: 'dashboards/test.json', + }, + }, + }); + + const api = new K8sDashboardAPI(); + const result = await api.getDashboardDTO('test'); + expect(result.meta.provisioned).toBe(true); + expect(result.meta.provisionedExternalId).toBe('dashboards/test.json'); + }); + + it('should not mark repository-managed dashboard as provisioned (locked)', async () => { + mockGet.mockResolvedValueOnce({ + ...mockDashboardDto, + metadata: { + ...mockDashboardDto.metadata, + annotations: { + [AnnoKeyManagerKind]: ManagerKind.Repo, + [AnnoKeySourcePath]: 'dashboards/test.json', + }, + }, + }); + + const api = new K8sDashboardAPI(); + const result = await api.getDashboardDTO('test'); + expect(result.meta.provisioned).toBe(false); + expect(result.meta.provisionedExternalId).toBe('dashboards/test.json'); + }); + }); + describe('saveDashboard', () => { beforeEach(() => { locationUtil.initialize({ diff --git a/public/app/features/dashboard/api/v1.ts b/public/app/features/dashboard/api/v1.ts index e43b8944079..d906ceaf317 100644 --- a/public/app/features/dashboard/api/v1.ts +++ b/public/app/features/dashboard/api/v1.ts @@ -164,7 +164,11 @@ export class K8sDashboardAPI implements DashboardAPI { const managerKind = annotations[AnnoKeyManagerKind]; if (managerKind) { - result.meta.provisioned = annotations[AnnoKeyManagerAllowsEdits] === 'true' || managerKind === ManagerKind.Repo; + // `meta.provisioned` is used by the save/delete UI to decide if a dashboard is locked + // (i.e. it can't be saved from the UI). This should match the legacy behavior where + // `allowUiUpdates: true` keeps the dashboard editable/savable. + const allowsEdits = annotations[AnnoKeyManagerAllowsEdits] === 'true'; + result.meta.provisioned = !allowsEdits && managerKind !== ManagerKind.Repo; result.meta.provisionedExternalId = annotations[AnnoKeySourcePath]; } From 0acb030f4607b3b0af06e675cbcdab9755068e3b Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Mon, 5 Jan 2026 11:33:55 -0700 Subject: [PATCH 22/79] Revert: OSS Seeding (115729) (#115839) --- .../acimpl/basic_role_db_seed.go | 44 -- .../acimpl/basic_role_db_seed_test.go | 128 ---- pkg/services/accesscontrol/acimpl/service.go | 64 +- pkg/services/accesscontrol/database/seeder.go | 623 ------------------ .../accesscontrol/dualwrite/reconciler.go | 55 -- .../dualwrite/reconciler_test.go | 67 -- pkg/services/accesscontrol/models.go | 16 - pkg/services/accesscontrol/seeding/seeder.go | 451 ------------- pkg/tests/apis/folder/folder_tree_test.go | 2 + 9 files changed, 4 insertions(+), 1446 deletions(-) delete mode 100644 pkg/services/accesscontrol/acimpl/basic_role_db_seed.go delete mode 100644 pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go delete mode 100644 pkg/services/accesscontrol/database/seeder.go delete mode 100644 pkg/services/accesscontrol/dualwrite/reconciler_test.go delete mode 100644 pkg/services/accesscontrol/seeding/seeder.go diff --git a/pkg/services/accesscontrol/acimpl/basic_role_db_seed.go b/pkg/services/accesscontrol/acimpl/basic_role_db_seed.go deleted file mode 100644 index c6128790d1a..00000000000 --- a/pkg/services/accesscontrol/acimpl/basic_role_db_seed.go +++ /dev/null @@ -1,44 +0,0 @@ -package acimpl - -import ( - "context" - "time" - - "github.com/grafana/grafana/pkg/services/accesscontrol" -) - -const ( - ossBasicRoleSeedLockName = "oss-ac-basic-role-seeder" - ossBasicRoleSeedTimeout = 2 * time.Minute -) - -// refreshBasicRolePermissionsInDB ensures basic role permissions are fully derived from in-memory registrations -func (s *Service) refreshBasicRolePermissionsInDB(ctx context.Context, rolesSnapshot map[string][]accesscontrol.Permission) error { - if s.sql == nil || s.seeder == nil { - return nil - } - - run := func(ctx context.Context) error { - desired := map[accesscontrol.SeedPermission]struct{}{} - for role, permissions := range rolesSnapshot { - for _, permission := range permissions { - desired[accesscontrol.SeedPermission{BuiltInRole: role, Action: permission.Action, Scope: permission.Scope}] = struct{}{} - } - } - s.seeder.SetDesiredPermissions(desired) - return s.seeder.Seed(ctx) - } - - if s.serverLock == nil { - return run(ctx) - } - - var err error - errLock := s.serverLock.LockExecuteAndRelease(ctx, ossBasicRoleSeedLockName, ossBasicRoleSeedTimeout, func(ctx context.Context) { - err = run(ctx) - }) - if errLock != nil { - return errLock - } - return err -} diff --git a/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go b/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go deleted file mode 100644 index 986a32b66fc..00000000000 --- a/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package acimpl - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/database" - "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" - "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util/testutil" -) - -func TestIntegration_OSSBasicRolePermissions_PersistAndRefreshOnRegisterFixedRoles(t *testing.T) { - testutil.SkipIntegrationTestInShortMode(t) - - ctx := context.Background() - sql := db.InitTestDB(t) - store := database.ProvideService(sql) - - svc := ProvideOSSService( - setting.NewCfg(), - store, - &resourcepermissions.FakeActionSetSvc{}, - localcache.ProvideService(), - featuremgmt.WithFeatures(), - tracing.InitializeTracerForTest(), - sql, - permreg.ProvidePermissionRegistry(), - nil, - ) - - require.NoError(t, svc.DeclareFixedRoles(accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ - Name: "fixed:test:role", - Permissions: []accesscontrol.Permission{ - {Action: "test:read", Scope: ""}, - }, - }, - Grants: []string{string(org.RoleViewer)}, - })) - - require.NoError(t, svc.RegisterFixedRoles(ctx)) - - // verify permission is persisted to DB for basic:viewer - require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { - var role accesscontrol.Role - ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) - require.NoError(t, err) - require.True(t, ok) - - var count int64 - count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count() - require.NoError(t, err) - require.Equal(t, int64(1), count) - return nil - })) - - // ensure RegisterFixedRoles refreshes it back to defaults - require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { - ts := time.Now() - var role accesscontrol.Role - ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) - require.NoError(t, err) - require.True(t, ok) - - _, err = sess.Exec("DELETE FROM permission WHERE role_id = ?", role.ID) - require.NoError(t, err) - p := accesscontrol.Permission{ - RoleID: role.ID, - Action: "custom:keep", - Scope: "", - Created: ts, - Updated: ts, - } - p.Kind, p.Attribute, p.Identifier = accesscontrol.SplitScope(p.Scope) - _, err = sess.Table("permission").Insert(&p) - return err - })) - - svc2 := ProvideOSSService( - setting.NewCfg(), - store, - &resourcepermissions.FakeActionSetSvc{}, - localcache.ProvideService(), - featuremgmt.WithFeatures(), - tracing.InitializeTracerForTest(), - sql, - permreg.ProvidePermissionRegistry(), - nil, - ) - require.NoError(t, svc2.DeclareFixedRoles(accesscontrol.RoleRegistration{ - Role: accesscontrol.RoleDTO{ - Name: "fixed:test:role", - Permissions: []accesscontrol.Permission{ - {Action: "test:read", Scope: ""}, - }, - }, - Grants: []string{string(org.RoleViewer)}, - })) - require.NoError(t, svc2.RegisterFixedRoles(ctx)) - - require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { - var role accesscontrol.Role - ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) - require.NoError(t, err) - require.True(t, ok) - - var count int64 - count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count() - require.NoError(t, err) - require.Equal(t, int64(1), count) - - count, err = sess.Table("permission").Where("role_id = ? AND action = ?", role.ID, "custom:keep").Count() - require.NoError(t, err) - require.Equal(t, int64(0), count) - return nil - })) -} diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index 3fd419b1f6c..1ea8bf95f77 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -30,7 +30,6 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/migrator" "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" "github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils" - "github.com/grafana/grafana/pkg/services/accesscontrol/seeding" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -97,12 +96,6 @@ func ProvideOSSService( roles: accesscontrol.BuildBasicRoleDefinitions(), store: store, permRegistry: permRegistry, - sql: db, - serverLock: lock, - } - - if backend, ok := store.(*database.AccessControlStore); ok { - s.seeder = seeding.New(log.New("accesscontrol.seeder"), backend, backend) } return s @@ -119,11 +112,8 @@ type Service struct { rolesMu sync.RWMutex roles map[string]*accesscontrol.RoleDTO store accesscontrol.Store - seeder *seeding.Seeder permRegistry permreg.PermissionRegistry isInitialized bool - sql db.DB - serverLock *serverlock.ServerLockService } func (s *Service) GetUsageStats(_ context.Context) map[string]any { @@ -441,54 +431,17 @@ func (s *Service) RegisterFixedRoles(ctx context.Context) error { defer span.End() s.rolesMu.Lock() - registrations := s.registrations.Slice() + defer s.rolesMu.Unlock() + s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool { s.registerRolesLocked(registration) return true }) s.isInitialized = true - - rolesSnapshot := s.getBasicRolePermissionsLocked() - s.rolesMu.Unlock() - - if s.seeder != nil { - if err := s.seeder.SeedRoles(ctx, registrations); err != nil { - return err - } - if err := s.seeder.RemoveAbsentRoles(ctx); err != nil { - return err - } - } - - if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil { - return err - } - return nil } -// getBasicRolePermissionsSnapshotFromRegistrationsLocked computes the desired basic role permissions from the -// current registration list, using the shared seeding registration logic. -// -// it has to be called while holding the roles lock -func (s *Service) getBasicRolePermissionsLocked() map[string][]accesscontrol.Permission { - desired := map[accesscontrol.SeedPermission]struct{}{} - s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool { - seeding.AppendDesiredPermissions(desired, s.log, ®istration.Role, registration.Grants, registration.Exclude, true) - return true - }) - - out := make(map[string][]accesscontrol.Permission) - for sp := range desired { - out[sp.BuiltInRole] = append(out[sp.BuiltInRole], accesscontrol.Permission{ - Action: sp.Action, - Scope: sp.Scope, - }) - } - return out -} - // registerRolesLocked processes a single role registration and adds permissions to basic roles. // Must be called with s.rolesMu locked. func (s *Service) registerRolesLocked(registration accesscontrol.RoleRegistration) { @@ -521,7 +474,6 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs defer span.End() acRegs := pluginutils.ToRegistrations(ID, name, regs) - updatedBasicRoles := false for _, r := range acRegs { if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil { return err @@ -548,23 +500,11 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs if initialized { s.rolesMu.Lock() s.registerRolesLocked(r) - updatedBasicRoles = true s.rolesMu.Unlock() s.cache.Flush() } } - if updatedBasicRoles { - s.rolesMu.RLock() - rolesSnapshot := s.getBasicRolePermissionsLocked() - s.rolesMu.RUnlock() - - // plugin roles can be declared after startup - keep DB in sync - if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil { - return err - } - } - return nil } diff --git a/pkg/services/accesscontrol/database/seeder.go b/pkg/services/accesscontrol/database/seeder.go deleted file mode 100644 index 2f53d20b514..00000000000 --- a/pkg/services/accesscontrol/database/seeder.go +++ /dev/null @@ -1,623 +0,0 @@ -package database - -import ( - "context" - "strings" - "time" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/seeding" - "github.com/grafana/grafana/pkg/services/sqlstore/migrator" - "github.com/grafana/grafana/pkg/util/xorm/core" -) - -const basicRolePermBatchSize = 500 - -// LoadRoles returns all fixed and plugin roles (global org) with permissions, indexed by role name. -func (s *AccessControlStore) LoadRoles(ctx context.Context) (map[string]*accesscontrol.RoleDTO, error) { - out := map[string]*accesscontrol.RoleDTO{} - - err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { - type roleRow struct { - ID int64 `xorm:"id"` - OrgID int64 `xorm:"org_id"` - Version int64 `xorm:"version"` - UID string `xorm:"uid"` - Name string `xorm:"name"` - DisplayName string `xorm:"display_name"` - Description string `xorm:"description"` - Group string `xorm:"group_name"` - Hidden bool `xorm:"hidden"` - Updated time.Time `xorm:"updated"` - Created time.Time `xorm:"created"` - } - - roles := []roleRow{} - if err := sess.Table("role"). - Where("org_id = ?", accesscontrol.GlobalOrgID). - Where("(name LIKE ? OR name LIKE ?)", accesscontrol.FixedRolePrefix+"%", accesscontrol.PluginRolePrefix+"%"). - Find(&roles); err != nil { - return err - } - - if len(roles) == 0 { - return nil - } - - roleIDs := make([]any, 0, len(roles)) - roleByID := make(map[int64]*accesscontrol.RoleDTO, len(roles)) - for _, r := range roles { - dto := &accesscontrol.RoleDTO{ - ID: r.ID, - OrgID: r.OrgID, - Version: r.Version, - UID: r.UID, - Name: r.Name, - DisplayName: r.DisplayName, - Description: r.Description, - Group: r.Group, - Hidden: r.Hidden, - Updated: r.Updated, - Created: r.Created, - } - out[dto.Name] = dto - roleByID[dto.ID] = dto - roleIDs = append(roleIDs, dto.ID) - } - - type permRow struct { - RoleID int64 `xorm:"role_id"` - Action string `xorm:"action"` - Scope string `xorm:"scope"` - } - perms := []permRow{} - if err := sess.Table("permission").In("role_id", roleIDs...).Find(&perms); err != nil { - return err - } - - for _, p := range perms { - dto := roleByID[p.RoleID] - if dto == nil { - continue - } - dto.Permissions = append(dto.Permissions, accesscontrol.Permission{ - RoleID: p.RoleID, - Action: p.Action, - Scope: p.Scope, - }) - } - - return nil - }) - - return out, err -} - -func (s *AccessControlStore) SetRole(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error { - if existingRole == nil { - return nil - } - - return s.sql.WithDbSession(ctx, func(sess *db.Session) error { - _, err := sess.Table("role"). - Where("id = ? AND org_id = ?", existingRole.ID, accesscontrol.GlobalOrgID). - Update(map[string]any{ - "display_name": wantedRole.DisplayName, - "description": wantedRole.Description, - "group_name": wantedRole.Group, - "hidden": wantedRole.Hidden, - "updated": time.Now(), - }) - return err - }) -} - -func (s *AccessControlStore) SetPermissions(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error { - if existingRole == nil { - return nil - } - - type key struct{ Action, Scope string } - existing := map[key]struct{}{} - for _, p := range existingRole.Permissions { - existing[key{p.Action, p.Scope}] = struct{}{} - } - desired := map[key]struct{}{} - for _, p := range wantedRole.Permissions { - desired[key{p.Action, p.Scope}] = struct{}{} - } - - toAdd := make([]accesscontrol.Permission, 0) - toRemove := make([]accesscontrol.SeedPermission, 0) - - now := time.Now() - for k := range desired { - if _, ok := existing[k]; ok { - continue - } - perm := accesscontrol.Permission{ - RoleID: existingRole.ID, - Action: k.Action, - Scope: k.Scope, - Created: now, - Updated: now, - } - perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) - toAdd = append(toAdd, perm) - } - - for k := range existing { - if _, ok := desired[k]; ok { - continue - } - toRemove = append(toRemove, accesscontrol.SeedPermission{Action: k.Action, Scope: k.Scope}) - } - - if len(toAdd) == 0 && len(toRemove) == 0 { - return nil - } - - return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - if len(toRemove) > 0 { - if err := DeleteRolePermissionTuples(sess, s.sql.GetDBType(), existingRole.ID, toRemove); err != nil { - return err - } - } - - if len(toAdd) > 0 { - _, err := sess.InsertMulti(toAdd) - return err - } - - return nil - }) -} - -func (s *AccessControlStore) CreateRole(ctx context.Context, role accesscontrol.RoleDTO) error { - now := time.Now() - uid := role.UID - if uid == "" && (strings.HasPrefix(role.Name, accesscontrol.FixedRolePrefix) || strings.HasPrefix(role.Name, accesscontrol.PluginRolePrefix)) { - uid = accesscontrol.PrefixedRoleUID(role.Name) - } - r := accesscontrol.Role{ - OrgID: accesscontrol.GlobalOrgID, - Version: role.Version, - UID: uid, - Name: role.Name, - DisplayName: role.DisplayName, - Description: role.Description, - Group: role.Group, - Hidden: role.Hidden, - Created: now, - Updated: now, - } - if r.Version == 0 { - r.Version = 1 - } - - return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - if _, err := sess.Insert(&r); err != nil { - return err - } - - if len(role.Permissions) == 0 { - return nil - } - - // De-duplicate permissions on (action, scope) to avoid unique constraint violations. - // Some role definitions may accidentally include duplicates. - type permKey struct{ Action, Scope string } - seen := make(map[permKey]struct{}, len(role.Permissions)) - - perms := make([]accesscontrol.Permission, 0, len(role.Permissions)) - for _, p := range role.Permissions { - k := permKey{Action: p.Action, Scope: p.Scope} - if _, ok := seen[k]; ok { - continue - } - seen[k] = struct{}{} - - perm := accesscontrol.Permission{ - RoleID: r.ID, - Action: p.Action, - Scope: p.Scope, - Created: now, - Updated: now, - } - perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) - perms = append(perms, perm) - } - _, err := sess.InsertMulti(perms) - return err - }) -} - -func (s *AccessControlStore) DeleteRoles(ctx context.Context, roleUIDs []string) error { - if len(roleUIDs) == 0 { - return nil - } - - uids := make([]any, 0, len(roleUIDs)) - for _, uid := range roleUIDs { - uids = append(uids, uid) - } - - return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - type row struct { - ID int64 `xorm:"id"` - UID string `xorm:"uid"` - } - rows := []row{} - if err := sess.Table("role"). - Where("org_id = ?", accesscontrol.GlobalOrgID). - In("uid", uids...). - Find(&rows); err != nil { - return err - } - if len(rows) == 0 { - return nil - } - - roleIDs := make([]any, 0, len(rows)) - for _, r := range rows { - roleIDs = append(roleIDs, r.ID) - } - - // Remove permissions and assignments first to avoid FK issues (if enabled). - { - args := append([]any{"DELETE FROM permission WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) - if _, err := sess.Exec(args...); err != nil { - return err - } - } - { - args := append([]any{"DELETE FROM user_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) - if _, err := sess.Exec(args...); err != nil { - return err - } - } - { - args := append([]any{"DELETE FROM team_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) - if _, err := sess.Exec(args...); err != nil { - return err - } - } - { - args := append([]any{"DELETE FROM builtin_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) - if _, err := sess.Exec(args...); err != nil { - return err - } - } - - args := append([]any{"DELETE FROM role WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")", accesscontrol.GlobalOrgID}, uids...) - _, err := sess.Exec(args...) - return err - }) -} - -// OSS basic-role permission refresh uses seeding.Seeder.Seed() with a desired set computed in memory. -// These methods implement the permission seeding part of seeding.SeedingBackend against the current permission table. -func (s *AccessControlStore) LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { - var out map[accesscontrol.SeedPermission]struct{} - err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { - rows, err := LoadBasicRoleSeedPermissions(sess) - if err != nil { - return err - } - - out = make(map[accesscontrol.SeedPermission]struct{}, len(rows)) - for _, r := range rows { - r.Origin = "" - out[r] = struct{}{} - } - return nil - }) - return out, err -} - -func (s *AccessControlStore) Apply(ctx context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error { - rolesToUpgrade := seeding.RolesToUpgrade(added, removed) - - // Run the same OSS apply logic as ossBasicRoleSeedBackend.Apply inside a single transaction. - return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - defs := accesscontrol.BuildBasicRoleDefinitions() - builtinToRoleID, err := EnsureBasicRolesExist(sess, defs) - if err != nil { - return err - } - - backend := &ossBasicRoleSeedBackend{ - sess: sess, - now: time.Now(), - builtinToRoleID: builtinToRoleID, - desired: nil, - dbType: s.sql.GetDBType(), - } - if err := backend.Apply(ctx, added, removed, updated); err != nil { - return err - } - - return BumpBasicRoleVersions(sess, rolesToUpgrade) - }) -} - -// EnsureBasicRolesExist ensures the built-in basic roles exist in the role table and are bound in builtin_role. -// It returns a mapping from builtin role name (for example "Admin") to role ID. -func EnsureBasicRolesExist(sess *db.Session, defs map[string]*accesscontrol.RoleDTO) (map[string]int64, error) { - uidToBuiltin := make(map[string]string, len(defs)) - uids := make([]any, 0, len(defs)) - for builtin, def := range defs { - uidToBuiltin[def.UID] = builtin - uids = append(uids, def.UID) - } - - type roleRow struct { - ID int64 `xorm:"id"` - UID string `xorm:"uid"` - } - - rows := []roleRow{} - if err := sess.Table("role"). - Where("org_id = ?", accesscontrol.GlobalOrgID). - In("uid", uids...). - Find(&rows); err != nil { - return nil, err - } - - ts := time.Now() - - builtinToRoleID := make(map[string]int64, len(defs)) - for _, r := range rows { - br, ok := uidToBuiltin[r.UID] - if !ok { - continue - } - builtinToRoleID[br] = r.ID - } - - for builtin, def := range defs { - roleID, ok := builtinToRoleID[builtin] - if !ok { - role := accesscontrol.Role{ - OrgID: def.OrgID, - Version: def.Version, - UID: def.UID, - Name: def.Name, - DisplayName: def.DisplayName, - Description: def.Description, - Group: def.Group, - Hidden: def.Hidden, - Created: ts, - Updated: ts, - } - if _, err := sess.Insert(&role); err != nil { - return nil, err - } - roleID = role.ID - builtinToRoleID[builtin] = roleID - } - - has, err := sess.Table("builtin_role"). - Where("role_id = ? AND role = ? AND org_id = ?", roleID, builtin, accesscontrol.GlobalOrgID). - Exist() - if err != nil { - return nil, err - } - if !has { - br := accesscontrol.BuiltinRole{ - RoleID: roleID, - OrgID: accesscontrol.GlobalOrgID, - Role: builtin, - Created: ts, - Updated: ts, - } - if _, err := sess.Table("builtin_role").Insert(&br); err != nil { - return nil, err - } - } - } - - return builtinToRoleID, nil -} - -// DeleteRolePermissionTuples deletes permissions for a single role by (action, scope) pairs. -// -// It uses a row-constructor IN clause where supported (MySQL, Postgres, SQLite) and falls back -// to a WHERE ... OR ... form for MSSQL. -func DeleteRolePermissionTuples(sess *db.Session, dbType core.DbType, roleID int64, perms []accesscontrol.SeedPermission) error { - if len(perms) == 0 { - return nil - } - - if dbType == migrator.MSSQL { - // MSSQL doesn't support (action, scope) IN ((?,?),(?,?)) row constructors. - where := make([]string, 0, len(perms)) - args := make([]any, 0, 1+len(perms)*2) - args = append(args, roleID) - for _, p := range perms { - where = append(where, "(action = ? AND scope = ?)") - args = append(args, p.Action, p.Scope) - } - _, err := sess.Exec( - append([]any{ - "DELETE FROM permission WHERE role_id = ? AND (" + strings.Join(where, " OR ") + ")", - }, args...)..., - ) - return err - } - - args := make([]any, 0, 1+len(perms)*2) - args = append(args, roleID) - for _, p := range perms { - args = append(args, p.Action, p.Scope) - } - sql := "DELETE FROM permission WHERE role_id = ? AND (action, scope) IN (" + - strings.Repeat("(?, ?),", len(perms)-1) + "(?, ?))" - _, err := sess.Exec(append([]any{sql}, args...)...) - return err -} - -type ossBasicRoleSeedBackend struct { - sess *db.Session - now time.Time - builtinToRoleID map[string]int64 - desired map[accesscontrol.SeedPermission]struct{} - dbType core.DbType -} - -func (b *ossBasicRoleSeedBackend) LoadPrevious(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { - rows, err := LoadBasicRoleSeedPermissions(b.sess) - if err != nil { - return nil, err - } - - out := make(map[accesscontrol.SeedPermission]struct{}, len(rows)) - for _, r := range rows { - // Ensure the key matches what OSS seeding uses (Origin is always empty for basic role refresh). - r.Origin = "" - out[r] = struct{}{} - } - return out, nil -} - -func (b *ossBasicRoleSeedBackend) LoadDesired(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { - return b.desired, nil -} - -func (b *ossBasicRoleSeedBackend) Apply(_ context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error { - // Delete removed permissions (this includes user-defined permissions that aren't in desired). - if len(removed) > 0 { - permsByRoleID := map[int64][]accesscontrol.SeedPermission{} - for _, p := range removed { - roleID, ok := b.builtinToRoleID[p.BuiltInRole] - if !ok { - continue - } - permsByRoleID[roleID] = append(permsByRoleID[roleID], p) - } - - for roleID, perms := range permsByRoleID { - // Chunk to keep statement sizes and parameter counts bounded. - if err := batch(len(perms), basicRolePermBatchSize, func(start, end int) error { - return DeleteRolePermissionTuples(b.sess, b.dbType, roleID, perms[start:end]) - }); err != nil { - return err - } - } - } - - // Insert added permissions and updated-target permissions. - toInsertSeed := make([]accesscontrol.SeedPermission, 0, len(added)+len(updated)) - toInsertSeed = append(toInsertSeed, added...) - for _, v := range updated { - toInsertSeed = append(toInsertSeed, v) - } - if len(toInsertSeed) == 0 { - return nil - } - - // De-duplicate on (role_id, action, scope). This avoids unique constraint violations when: - // - the same permission appears in both added and updated - // - multiple plugin origins grant the same permission (Origin is not persisted in permission table) - type permKey struct { - RoleID int64 - Action string - Scope string - } - seen := make(map[permKey]struct{}, len(toInsertSeed)) - - toInsert := make([]accesscontrol.Permission, 0, len(toInsertSeed)) - for _, p := range toInsertSeed { - roleID, ok := b.builtinToRoleID[p.BuiltInRole] - if !ok { - continue - } - k := permKey{RoleID: roleID, Action: p.Action, Scope: p.Scope} - if _, ok := seen[k]; ok { - continue - } - seen[k] = struct{}{} - - perm := accesscontrol.Permission{ - RoleID: roleID, - Action: p.Action, - Scope: p.Scope, - Created: b.now, - Updated: b.now, - } - perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) - toInsert = append(toInsert, perm) - } - - return batch(len(toInsert), basicRolePermBatchSize, func(start, end int) error { - // MySQL: ignore conflicts to make seeding idempotent under retries/concurrency. - // Conflicts can happen if the same permission already exists (unique on role_id, action, scope). - if b.dbType == migrator.MySQL { - args := make([]any, 0, (end-start)*8) - for i := start; i < end; i++ { - p := toInsert[i] - args = append(args, p.RoleID, p.Action, p.Scope, p.Kind, p.Attribute, p.Identifier, p.Updated, p.Created) - } - sql := append([]any{`INSERT IGNORE INTO permission (role_id, action, scope, kind, attribute, identifier, updated, created) VALUES ` + - strings.Repeat("(?, ?, ?, ?, ?, ?, ?, ?),", end-start-1) + "(?, ?, ?, ?, ?, ?, ?, ?)"}, args...) - _, err := b.sess.Exec(sql...) - return err - } - - _, err := b.sess.InsertMulti(toInsert[start:end]) - return err - }) -} - -func batch(count, size int, eachFn func(start, end int) error) error { - for i := 0; i < count; { - end := i + size - if end > count { - end = count - } - if err := eachFn(i, end); err != nil { - return err - } - i = end - } - return nil -} - -// BumpBasicRoleVersions increments the role version for the given builtin basic roles (Viewer/Editor/Admin/Grafana Admin). -// Unknown role names are ignored. -func BumpBasicRoleVersions(sess *db.Session, basicRoles []string) error { - if len(basicRoles) == 0 { - return nil - } - - defs := accesscontrol.BuildBasicRoleDefinitions() - uids := make([]any, 0, len(basicRoles)) - for _, br := range basicRoles { - def, ok := defs[br] - if !ok { - continue - } - uids = append(uids, def.UID) - } - if len(uids) == 0 { - return nil - } - - sql := "UPDATE role SET version = version + 1 WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")" - _, err := sess.Exec(append([]any{sql, accesscontrol.GlobalOrgID}, uids...)...) - return err -} - -// LoadBasicRoleSeedPermissions returns the current (builtin_role, action, scope) permissions granted to basic roles. -// It sets Origin to empty. -func LoadBasicRoleSeedPermissions(sess *db.Session) ([]accesscontrol.SeedPermission, error) { - rows := []accesscontrol.SeedPermission{} - err := sess.SQL( - `SELECT role.display_name AS builtin_role, p.action, p.scope, '' AS origin - FROM role INNER JOIN permission AS p ON p.role_id = role.id - WHERE role.org_id = ? AND role.name LIKE 'basic:%'`, - accesscontrol.GlobalOrgID, - ).Find(&rows) - return rows, err -} diff --git a/pkg/services/accesscontrol/dualwrite/reconciler.go b/pkg/services/accesscontrol/dualwrite/reconciler.go index ff6637219a4..a0f2f47b77d 100644 --- a/pkg/services/accesscontrol/dualwrite/reconciler.go +++ b/pkg/services/accesscontrol/dualwrite/reconciler.go @@ -15,7 +15,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/serverlock" - "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -131,9 +130,6 @@ func (r *ZanzanaReconciler) Run(ctx context.Context) error { // Reconcile schedules as job that will run and reconcile resources between // legacy access control and zanzana. func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error { - // Ensure we don't reconcile an empty/partial RBAC state before OSS has seeded basic role permissions. - // This matters most during startup where fixed-role loading + basic-role permission refresh runs as another background service. - r.waitForBasicRolesSeeded(ctx) r.reconcile(ctx) // FIXME: @@ -149,57 +145,6 @@ func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error { } } -func (r *ZanzanaReconciler) hasBasicRolePermissions(ctx context.Context) bool { - var count int64 - // Basic role permissions are stored on "basic:%" roles in the global org (0). - // In a fresh DB, this will be empty until fixed roles are registered and the basic role permission refresh runs. - type row struct { - Count int64 `xorm:"count"` - } - _ = r.store.WithDbSession(ctx, func(sess *db.Session) error { - var rr row - _, err := sess.SQL( - `SELECT COUNT(*) AS count - FROM role INNER JOIN permission AS p ON p.role_id = role.id - WHERE role.org_id = ? AND role.name LIKE ?`, - accesscontrol.GlobalOrgID, - accesscontrol.BasicRolePrefix+"%", - ).Get(&rr) - if err != nil { - return err - } - count = rr.Count - return nil - }) - return count > 0 -} - -func (r *ZanzanaReconciler) waitForBasicRolesSeeded(ctx context.Context) { - // Best-effort: don't block forever. If we can't observe basic roles, proceed anyway. - const ( - maxWait = 15 * time.Second - interval = 1 * time.Second - ) - - deadline := time.NewTimer(maxWait) - defer deadline.Stop() - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for { - if r.hasBasicRolePermissions(ctx) { - return - } - select { - case <-ctx.Done(): - return - case <-deadline.C: - return - case <-ticker.C: - } - } -} - func (r *ZanzanaReconciler) reconcile(ctx context.Context) { run := func(ctx context.Context, namespace string) (ok bool) { now := time.Now() diff --git a/pkg/services/accesscontrol/dualwrite/reconciler_test.go b/pkg/services/accesscontrol/dualwrite/reconciler_test.go deleted file mode 100644 index 0defea011a0..00000000000 --- a/pkg/services/accesscontrol/dualwrite/reconciler_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package dualwrite - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/accesscontrol" -) - -func TestZanzanaReconciler_hasBasicRolePermissions(t *testing.T) { - env := setupTestEnv(t) - - r := &ZanzanaReconciler{ - store: env.db, - } - - ctx := context.Background() - require.False(t, r.hasBasicRolePermissions(ctx)) - - err := env.db.WithDbSession(ctx, func(sess *db.Session) error { - now := time.Now() - - _, err := sess.Exec( - `INSERT INTO role (org_id, uid, name, display_name, group_name, description, hidden, version, created, updated) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - accesscontrol.GlobalOrgID, - "basic_viewer_uid_test", - accesscontrol.BasicRolePrefix+"viewer", - "Viewer", - "Basic", - "Viewer role", - false, - 1, - now, - now, - ) - if err != nil { - return err - } - - var roleID int64 - if _, err := sess.SQL(`SELECT id FROM role WHERE org_id = ? AND uid = ?`, accesscontrol.GlobalOrgID, "basic_viewer_uid_test").Get(&roleID); err != nil { - return err - } - - _, err = sess.Exec( - `INSERT INTO permission (role_id, action, scope, kind, attribute, identifier, created, updated) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - roleID, - "dashboards:read", - "dashboards:*", - "", - "", - "", - now, - now, - ) - return err - }) - require.NoError(t, err) - - require.True(t, r.hasBasicRolePermissions(ctx)) -} diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 85df44750d2..b18fb4134f3 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -1,7 +1,6 @@ package accesscontrol import ( - "context" "encoding/json" "errors" "fmt" @@ -595,18 +594,3 @@ type QueryWithOrg struct { OrgId *int64 `json:"orgId"` Global bool `json:"global"` } - -type SeedPermission struct { - BuiltInRole string `xorm:"builtin_role"` - Action string `xorm:"action"` - Scope string `xorm:"scope"` - Origin string `xorm:"origin"` -} - -type RoleStore interface { - LoadRoles(ctx context.Context) (map[string]*RoleDTO, error) - SetRole(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error - SetPermissions(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error - CreateRole(ctx context.Context, role RoleDTO) error - DeleteRoles(ctx context.Context, roleUIDs []string) error -} diff --git a/pkg/services/accesscontrol/seeding/seeder.go b/pkg/services/accesscontrol/seeding/seeder.go deleted file mode 100644 index cad31b7a5d2..00000000000 --- a/pkg/services/accesscontrol/seeding/seeder.go +++ /dev/null @@ -1,451 +0,0 @@ -package seeding - -import ( - "context" - "fmt" - "regexp" - "slices" - "strings" - - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" -) - -type Seeder struct { - log log.Logger - roleStore accesscontrol.RoleStore - backend SeedingBackend - builtinsPermissions map[accesscontrol.SeedPermission]struct{} - seededFixedRoles map[string]bool - seededPluginRoles map[string]bool - seededPlugins map[string]bool - hasSeededAlready bool -} - -// SeedingBackend provides the seed-set specific operations needed to seed. -type SeedingBackend interface { - // LoadPrevious returns the currently stored permissions for previously seeded roles. - LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error) - - // Apply updates the database to match the desired permissions. - Apply(ctx context.Context, - added, removed []accesscontrol.SeedPermission, - updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission, - ) error -} - -func New(log log.Logger, roleStore accesscontrol.RoleStore, backend SeedingBackend) *Seeder { - return &Seeder{ - log: log, - roleStore: roleStore, - backend: backend, - builtinsPermissions: map[accesscontrol.SeedPermission]struct{}{}, - seededFixedRoles: map[string]bool{}, - seededPluginRoles: map[string]bool{}, - seededPlugins: map[string]bool{}, - hasSeededAlready: false, - } -} - -// SetDesiredPermissions replaces the in-memory desired permission set used by Seed(). -func (s *Seeder) SetDesiredPermissions(desired map[accesscontrol.SeedPermission]struct{}) { - if desired == nil { - s.builtinsPermissions = map[accesscontrol.SeedPermission]struct{}{} - return - } - s.builtinsPermissions = desired -} - -// Seed loads current and desired permissions, diffs them (including scope updates), applies changes, and bumps versions. -func (s *Seeder) Seed(ctx context.Context) error { - previous, err := s.backend.LoadPrevious(ctx) - if err != nil { - return err - } - - // - Do not remove plugin permissions when the plugin didn't register this run (Origin set but not in seededPlugins). - // - Preserve legacy plugin app access permissions in the persisted seed set (these are granted by default). - if len(previous) > 0 { - filtered := make(map[accesscontrol.SeedPermission]struct{}, len(previous)) - for p := range previous { - if p.Action == pluginaccesscontrol.ActionAppAccess { - continue - } - if p.Origin != "" && !s.seededPlugins[p.Origin] { - continue - } - filtered[p] = struct{}{} - } - previous = filtered - } - - added, removed, updated := s.permissionDiff(previous, s.builtinsPermissions) - - if err := s.backend.Apply(ctx, added, removed, updated); err != nil { - return err - } - return nil -} - -// SeedRoles populates the database with the roles and their assignments -// It will create roles that do not exist and update roles that have changed -// Do not use for provisioning. Validation is not enforced. -func (s *Seeder) SeedRoles(ctx context.Context, registrationList []accesscontrol.RoleRegistration) error { - roleMap, err := s.roleStore.LoadRoles(ctx) - if err != nil { - return err - } - - missingRoles := make([]accesscontrol.RoleRegistration, 0, len(registrationList)) - - // Diff existing roles with the ones we want to seed. - // If a role is missing, we add it to the missingRoles list - for _, registration := range registrationList { - registration := registration - role, ok := roleMap[registration.Role.Name] - switch { - case registration.Role.IsFixed(): - s.seededFixedRoles[registration.Role.Name] = true - case registration.Role.IsPlugin(): - s.seededPluginRoles[registration.Role.Name] = true - // To be resilient to failed plugin loadings, we remember the plugins that have registered, - // later we'll ignore permissions and roles of other plugins - s.seededPlugins[pluginutils.PluginIDFromName(registration.Role.Name)] = true - } - - s.rememberPermissionAssignments(®istration.Role, registration.Grants, registration.Exclude) - - if !ok { - missingRoles = append(missingRoles, registration) - continue - } - - if needsRoleUpdate(role, registration.Role) { - if err := s.roleStore.SetRole(ctx, role, registration.Role); err != nil { - return err - } - } - - if needsPermissionsUpdate(role, registration.Role) { - if err := s.roleStore.SetPermissions(ctx, role, registration.Role); err != nil { - return err - } - } - } - - for _, registration := range missingRoles { - if err := s.roleStore.CreateRole(ctx, registration.Role); err != nil { - return err - } - } - - return nil -} - -func needsPermissionsUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool { - if existingRole == nil { - return true - } - - if len(existingRole.Permissions) != len(wantedRole.Permissions) { - return true - } - - for _, p := range wantedRole.Permissions { - found := false - for _, ep := range existingRole.Permissions { - if ep.Action == p.Action && ep.Scope == p.Scope { - found = true - break - } - } - if !found { - return true - } - } - - return false -} - -func needsRoleUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool { - if existingRole == nil { - return true - } - - if existingRole.Name != wantedRole.Name { - return false - } - - if existingRole.DisplayName != wantedRole.DisplayName { - return true - } - - if existingRole.Description != wantedRole.Description { - return true - } - - if existingRole.Group != wantedRole.Group { - return true - } - - if existingRole.Hidden != wantedRole.Hidden { - return true - } - - return false -} - -// Deprecated: SeedRole is deprecated and should not be used. -// SeedRoles only does boot up seeding and should not be used for runtime seeding. -func (s *Seeder) SeedRole(ctx context.Context, role accesscontrol.RoleDTO, builtInRoles []string) error { - addedPermissions := make(map[string]struct{}, len(role.Permissions)) - permissions := make([]accesscontrol.Permission, 0, len(role.Permissions)) - for _, p := range role.Permissions { - key := fmt.Sprintf("%s:%s", p.Action, p.Scope) - if _, ok := addedPermissions[key]; !ok { - addedPermissions[key] = struct{}{} - permissions = append(permissions, accesscontrol.Permission{Action: p.Action, Scope: p.Scope}) - } - } - - wantedRole := accesscontrol.RoleDTO{ - OrgID: accesscontrol.GlobalOrgID, - Version: role.Version, - UID: role.UID, - Name: role.Name, - DisplayName: role.DisplayName, - Description: role.Description, - Group: role.Group, - Permissions: permissions, - Hidden: role.Hidden, - } - roleMap, err := s.roleStore.LoadRoles(ctx) - if err != nil { - return err - } - - existingRole := roleMap[wantedRole.Name] - if existingRole == nil { - if err := s.roleStore.CreateRole(ctx, wantedRole); err != nil { - return err - } - } else { - if needsRoleUpdate(existingRole, wantedRole) { - if err := s.roleStore.SetRole(ctx, existingRole, wantedRole); err != nil { - return err - } - } - if needsPermissionsUpdate(existingRole, wantedRole) { - if err := s.roleStore.SetPermissions(ctx, existingRole, wantedRole); err != nil { - return err - } - } - } - - // Remember seeded roles - if wantedRole.IsFixed() { - s.seededFixedRoles[wantedRole.Name] = true - } - isPluginRole := wantedRole.IsPlugin() - if isPluginRole { - s.seededPluginRoles[wantedRole.Name] = true - - // To be resilient to failed plugin loadings, we remember the plugins that have registered, - // later we'll ignore permissions and roles of other plugins - s.seededPlugins[pluginutils.PluginIDFromName(role.Name)] = true - } - - s.rememberPermissionAssignments(&wantedRole, builtInRoles, []string{}) - return nil -} - -func (s *Seeder) rememberPermissionAssignments(role *accesscontrol.RoleDTO, builtInRoles []string, excludedRoles []string) { - AppendDesiredPermissions(s.builtinsPermissions, s.log, role, builtInRoles, excludedRoles, true) -} - -// AppendDesiredPermissions accumulates permissions from a role registration onto basic roles (Viewer/Editor/Admin/Grafana Admin). -// - It expands parents via accesscontrol.BuiltInRolesWithParents. -// - It can optionally ignore plugin app access permissions (which are granted by default). -func AppendDesiredPermissions( - out map[accesscontrol.SeedPermission]struct{}, - logger log.Logger, - role *accesscontrol.RoleDTO, - builtInRoles []string, - excludedRoles []string, - ignorePluginAppAccess bool, -) { - if out == nil || role == nil { - return - } - - for builtInRole := range accesscontrol.BuiltInRolesWithParents(builtInRoles) { - // Skip excluded grants - if slices.Contains(excludedRoles, builtInRole) { - continue - } - - for _, perm := range role.Permissions { - if ignorePluginAppAccess && perm.Action == pluginaccesscontrol.ActionAppAccess { - logger.Debug("Role is attempting to grant access permission, but this permission is already granted by default and will be ignored", - "role", role.Name, "permission", perm.Action, "scope", perm.Scope) - continue - } - - sp := accesscontrol.SeedPermission{ - BuiltInRole: builtInRole, - Action: perm.Action, - Scope: perm.Scope, - } - - if role.IsPlugin() { - sp.Origin = pluginutils.PluginIDFromName(role.Name) - } - - out[sp] = struct{}{} - } - } -} - -// permissionDiff returns: -// - added: present in desired permissions, not in previous permissions -// - removed: present in previous permissions, not in desired permissions -// - updated: same role + action, but scope changed -func (s *Seeder) permissionDiff(previous, desired map[accesscontrol.SeedPermission]struct{}) (added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) { - addedSet := make(map[accesscontrol.SeedPermission]struct{}, 0) - for n := range desired { - if _, already := previous[n]; !already { - addedSet[n] = struct{}{} - } else { - delete(previous, n) - } - } - - // Check if any of the new permissions is actually an old permission with an updated scope - updated = make(map[accesscontrol.SeedPermission]accesscontrol.SeedPermission, 0) - for n := range addedSet { - for p := range previous { - if n.BuiltInRole == p.BuiltInRole && n.Action == p.Action { - updated[p] = n - delete(addedSet, n) - } - } - } - - for p := range addedSet { - added = append(added, p) - } - - for p := range previous { - if p.Action == pluginaccesscontrol.ActionAppAccess && - p.Scope != pluginaccesscontrol.ScopeProvider.GetResourceAllScope() { - // Allows backward compatibility with plugins that have been seeded before the grant ignore rule was added - s.log.Info("This permission already existed so it will not be removed", - "role", p.BuiltInRole, "permission", p.Action, "scope", p.Scope) - continue - } - - removed = append(removed, p) - } - - return added, removed, updated -} - -func (s *Seeder) ClearBasicRolesPluginPermissions(ID string) { - removable := []accesscontrol.SeedPermission{} - - for key := range s.builtinsPermissions { - if matchPermissionByPluginID(key, ID) { - removable = append(removable, key) - } - } - - for _, perm := range removable { - delete(s.builtinsPermissions, perm) - } -} - -func matchPermissionByPluginID(perm accesscontrol.SeedPermission, pluginID string) bool { - if perm.Origin != pluginID { - return false - } - actionTemplate := regexp.MustCompile(fmt.Sprintf("%s[.:]", pluginID)) - scopeTemplate := fmt.Sprintf(":%s", pluginID) - return actionTemplate.MatchString(perm.Action) || strings.HasSuffix(perm.Scope, scopeTemplate) -} - -// RolesToUpgrade returns the unique basic roles that should have their version incremented. -func RolesToUpgrade(added, removed []accesscontrol.SeedPermission) []string { - set := map[string]struct{}{} - for _, p := range added { - set[p.BuiltInRole] = struct{}{} - } - for _, p := range removed { - set[p.BuiltInRole] = struct{}{} - } - out := make([]string, 0, len(set)) - for r := range set { - out = append(out, r) - } - return out -} - -func (s *Seeder) ClearPluginRoles(ID string) { - expectedPrefix := fmt.Sprintf("%s%s:", accesscontrol.PluginRolePrefix, ID) - - for roleName := range s.seededPluginRoles { - if strings.HasPrefix(roleName, expectedPrefix) { - delete(s.seededPluginRoles, roleName) - } - } -} - -func (s *Seeder) MarkSeededAlready() { - s.hasSeededAlready = true -} - -func (s *Seeder) HasSeededAlready() bool { - return s.hasSeededAlready -} - -func (s *Seeder) RemoveAbsentRoles(ctx context.Context) error { - roleMap, errGet := s.roleStore.LoadRoles(ctx) - if errGet != nil { - s.log.Error("failed to get fixed roles from store", "err", errGet) - return errGet - } - - toRemove := []string{} - for _, r := range roleMap { - if r == nil { - continue - } - if r.IsFixed() { - if !s.seededFixedRoles[r.Name] { - s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name) - toRemove = append(toRemove, r.UID) - } - continue - } - - if r.IsPlugin() { - if !s.seededPlugins[pluginutils.PluginIDFromName(r.Name)] { - // To be resilient to failed plugin loadings - // ignore stored roles related to plugins that have not registered this time - s.log.Debug("plugin role has not been registered on this run skipping its removal", "role", r.Name) - continue - } - if !s.seededPluginRoles[r.Name] { - s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name) - toRemove = append(toRemove, r.UID) - } - } - } - - if errDelete := s.roleStore.DeleteRoles(ctx, toRemove); errDelete != nil { - s.log.Error("failed to delete absent fixed and plugin roles", "err", errDelete) - return errDelete - } - return nil -} diff --git a/pkg/tests/apis/folder/folder_tree_test.go b/pkg/tests/apis/folder/folder_tree_test.go index 613d021b236..4d95d64b024 100644 --- a/pkg/tests/apis/folder/folder_tree_test.go +++ b/pkg/tests/apis/folder/folder_tree_test.go @@ -33,6 +33,8 @@ import ( ) func TestIntegrationFolderTreeZanzana(t *testing.T) { + // TODO: Add back OSS seeding and enable this test + t.Skip("Skipping folder tree test with Zanzana") testutil.SkipIntegrationTestInShortMode(t) runIntegrationFolderTree(t, testinfra.GrafanaOpts{ From 2947d41ea8b0d53f4fb4d8c86e01b27d9c722f80 Mon Sep 17 00:00:00 2001 From: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:56:50 -0600 Subject: [PATCH 23/79] Docs: Fixed broken links for Cloudwatch (#115848) * updates broken links and aliases * fixed query editor links --- .../aws-cloudwatch/configure/index.md | 30 +++++-------------- .../aws-cloudwatch/query-editor/index.md | 13 +++----- 2 files changed, 12 insertions(+), 31 deletions(-) diff --git a/docs/sources/datasources/aws-cloudwatch/configure/index.md b/docs/sources/datasources/aws-cloudwatch/configure/index.md index 3ae774d9d4e..242b3513d47 100644 --- a/docs/sources/datasources/aws-cloudwatch/configure/index.md +++ b/docs/sources/datasources/aws-cloudwatch/configure/index.md @@ -1,11 +1,12 @@ --- aliases: - - ../data-sources/aws-CloudWatch/ - - ../data-sources/aws-CloudWatch/preconfig-CloudWatch-dashboards/ - - ../data-sources/aws-CloudWatch/provision-CloudWatch/ - - CloudWatch/ - - preconfig-CloudWatch-dashboards/ - - provision-CloudWatch/ + - ../../data-sources/aws-cloudwatch/configure/ + - ../../data-sources/aws-cloudwatch/ + - ../../data-sources/aws-cloudwatch/preconfig-cloudwatch-dashboards/ + - ../../data-sources/aws-cloudwatch/provision-cloudwatch/ + - ../cloudwatch/ + - ../preconfig-cloudwatch-dashboards/ + - ../provision-cloudwatch/ description: This document provides configuration instructions for the CloudWatch data source. keywords: - grafana @@ -25,11 +26,6 @@ refs: destination: /docs/grafana//panels-visualizations/visualizations/logs/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana//panels-visualizations/visualizations/logs/ - explore: - - pattern: /docs/grafana/ - destination: /docs/grafana//explore/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//explore/ provisioning-data-sources: - pattern: /docs/grafana/ destination: /docs/grafana//administration/provisioning/#data-sources @@ -40,16 +36,6 @@ refs: destination: /docs/grafana//setup-grafana/configure-grafana/#aws - pattern: /docs/grafana-cloud/ destination: /docs/grafana//setup-grafana/configure-grafana/#aws - alerting: - - pattern: /docs/grafana/ - destination: /docs/grafana//alerting/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/alerting-and-irm/alerting/ - build-dashboards: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/build-dashboards/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//dashboards/build-dashboards/ data-source-management: - pattern: /docs/grafana/ destination: /docs/grafana//administration/data-source-management/ @@ -153,7 +139,7 @@ You must use both an access key ID and a secret access key to authenticate. Grafana automatically creates a link to a trace in X-Ray data source if logs contain the `@xrayTraceId` field. To use this feature, you must already have an X-Ray data source configured. For details, see the [X-Ray data source docs](/grafana/plugins/grafana-X-Ray-datasource/). To view the X-Ray link, select the log row in either the Explore view or dashboard [Logs panel](ref:logs) to view the log details section. -To log the `@xrayTraceId`, refer to the [AWS X-Ray documentation](https://docs.amazonaws.cn/en_us/xray/latest/devguide/xray-services.html). To provide the field to Grafana, your log queries must also contain the `@xrayTraceId` field, for example by using the query `fields @message, @xrayTraceId`. +To log the `@xrayTraceId`, refer to the [AWS X-Ray documentation](https://docs.aws.amazon.com/xray/latest/devguide/xray-services.html). To provide the field to Grafana, your log queries must also contain the `@xrayTraceId` field, for example by using the query `fields @message, @xrayTraceId`. **Private data source connect** - _Only for Grafana Cloud users._ diff --git a/docs/sources/datasources/aws-cloudwatch/query-editor/index.md b/docs/sources/datasources/aws-cloudwatch/query-editor/index.md index 9bc7ab64047..9288a750742 100644 --- a/docs/sources/datasources/aws-cloudwatch/query-editor/index.md +++ b/docs/sources/datasources/aws-cloudwatch/query-editor/index.md @@ -34,11 +34,6 @@ refs: destination: /docs/grafana//panels-visualizations/query-transform-data/#navigate-the-query-tab - pattern: /docs/grafana-cloud/ destination: /docs/grafana//panels-visualizations/query-transform-data/#navigate-the-query-tab - explore: - - pattern: /docs/grafana/ - destination: /docs/grafana//explore/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//explore/ alerting: - pattern: /docs/grafana/ destination: /docs/grafana//alerting/ @@ -183,7 +178,7 @@ If you use the expression field to reference another query, such as `queryA * 2` When you select `Builder` mode within the Metric search editor, a new Account field is displayed. Use the `Account` field to specify which of the linked monitoring accounts to target for the given query. By default, the `All` option is specified, which will target all linked accounts. While in `Code` mode, you can specify any math expression. If the Monitoring account badge displays in the query editor header, all `SEARCH` expressions entered in this field will be cross-account by default and can query metrics from linked accounts. Note that while queries run cross-account, the autocomplete feature currently doesn't fetch cross-account resources, so you'll need to manually specify resource names when writing cross-account queries. -You can limit the search to one or a set of accounts, as documented in the [AWS documentation](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html). +You can limit the search to one or a set of accounts, as documented in the [AWS documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html). ### Period macro @@ -198,7 +193,7 @@ The link provided is valid for any account but displays the expected metrics onl {{< figure src="/media/docs/cloudwatch/cloudwatch-deep-link-v12.1.png" caption="CloudWatch deep linking" >}} -This feature is not available for metrics based on [metric math expressions](#metric-math-expressions). +This feature is not available for metrics based on [metric math expressions](#use-metric-math-expressions). ### Use Metric Insights syntax @@ -319,9 +314,9 @@ The CloudWatch plugin monitors and troubleshoots applications that span multiple To enable cross-account observability, complete the following steps: -1. Go to the [Amazon CloudWatch documentation](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html) and follow the instructions for enabling cross-account observability. +1. Go to the [Amazon CloudWatch documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html) and follow the instructions for enabling cross-account observability. -1. Add [two API actions](https://grafana.com//docs/grafana/latest/datasources/aws-cloudwatch/configure/#cross-account-observability-permissions) to the IAM policy attached to the role/user running the plugin. +1. Add [two API actions](https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/configure/#cross-account-observability-permissions) to the IAM policy attached to the role/user running the plugin. Cross-account querying is available in the plugin through the **Logs**, **Metric search**, and **Metric Insights** modes. After you have configured it, you'll see a **Monitoring account** badge in the query editor header. From 3d3b4dd2130686e687a5f7d8aab32552b26ce403 Mon Sep 17 00:00:00 2001 From: Saurabh Yadav <116506457+saurabh007007@users.noreply.github.com> Date: Tue, 6 Jan 2026 14:56:04 +0530 Subject: [PATCH 24/79] Clean up packages/grafana-prometheus/src/dashboards (#115861) * remove:Dashboard json files * removed: dashboards from packages/grafana-prometheus/src/dashboards --- .../src/dashboards/grafana_stats.json | 1187 --------------- .../src/dashboards/prometheus_2_stats.json | 1353 ----------------- .../src/dashboards/prometheus_stats.json | 834 ---------- 3 files changed, 3374 deletions(-) delete mode 100644 packages/grafana-prometheus/src/dashboards/grafana_stats.json delete mode 100644 packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json delete mode 100644 packages/grafana-prometheus/src/dashboards/prometheus_stats.json diff --git a/packages/grafana-prometheus/src/dashboards/grafana_stats.json b/packages/grafana-prometheus/src/dashboards/grafana_stats.json deleted file mode 100644 index 292f93394f3..00000000000 --- a/packages/grafana-prometheus/src/dashboards/grafana_stats.json +++ /dev/null @@ -1,1187 +0,0 @@ -{ - "_comment": "Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/dashboards/grafana_stats.json", - "__inputs": [ - { - "name": "DS_PROMETHEUS", - "label": "Prometheus", - "description": "", - "type": "datasource", - "pluginId": "prometheus", - "pluginName": "Prometheus" - } - ], - "__requires": [ - { - "type": "grafana", - "id": "grafana", - "name": "Grafana", - "version": "8.1.0-pre" - }, - { - "type": "datasource", - "id": "prometheus", - "name": "Prometheus", - "version": "1.0.0" - }, - { - "type": "panel", - "id": "stat", - "name": "Stat", - "version": "" - }, - { - "type": "panel", - "id": "table-old", - "name": "Table (old)", - "version": "" - }, - { - "type": "panel", - "id": "timeseries", - "name": "Time series", - "version": "" - } - ], - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": "-- Grafana --", - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "description": "Metrics about Grafana", - "editable": true, - "gnetId": null, - "graphTooltip": 0, - "id": null, - "links": [ - { - "icon": "external link", - "tags": [], - "targetBlank": true, - "title": "Available metrics", - "type": "link", - "url": "/metrics" - }, - { - "icon": "external link", - "tags": [], - "targetBlank": true, - "title": "Grafana docs", - "type": "link", - "url": "https://grafana.com/docs/grafana/latest/" - }, - { - "icon": "external link", - "tags": [], - "targetBlank": true, - "title": "Prometheus docs", - "type": "link", - "url": "http://prometheus.io/docs/introduction/overview/" - } - ], - "panels": [ - { - "cacheTimeout": null, - "datasource": "${DS_PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 0, - "mappings": [ - { - "options": { - "0": { - "text": ":(" - } - }, - "type": "value" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(222, 3, 3, 0.9)", - "value": null - }, - { - "color": "rgb(234, 245, 234)", - "value": 1 - }, - { - "color": "rgb(235, 244, 235)", - "value": 10000 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 5, - "x": 0, - "y": 0 - }, - "id": 4, - "interval": null, - "links": [], - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": ["mean"], - "fields": "", - "values": false - }, - "text": {}, - "textMode": "auto" - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "up{job=\"grafana\"}", - "format": "time_series", - "instant": true, - "intervalFactor": 2, - "refId": "A", - "step": 60 - } - ], - "title": "Active instances", - "type": "stat" - }, - { - "cacheTimeout": null, - "datasource": "${DS_PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 5, - "x": 5, - "y": 0 - }, - "id": 8, - "interval": null, - "links": [], - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": ["mean"], - "fields": "", - "values": false - }, - "text": {}, - "textMode": "auto" - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "grafana_stat_totals_dashboard", - "format": "time_series", - "instant": true, - "intervalFactor": 2, - "refId": "A", - "step": 60 - } - ], - "title": "Dashboard count", - "type": "stat" - }, - { - "cacheTimeout": null, - "datasource": "${DS_PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 5, - "x": 10, - "y": 0 - }, - "id": 9, - "interval": null, - "links": [], - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": ["mean"], - "fields": "", - "values": false - }, - "text": {}, - "textMode": "auto" - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "grafana_stat_total_users", - "format": "time_series", - "instant": true, - "intervalFactor": 2, - "refId": "A", - "step": 60 - } - ], - "title": "User count", - "type": "stat" - }, - { - "cacheTimeout": null, - "datasource": "${DS_PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 5, - "x": 15, - "y": 0 - }, - "id": 10, - "interval": null, - "links": [], - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": ["mean"], - "fields": "", - "values": false - }, - "text": {}, - "textMode": "auto" - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "grafana_stat_total_playlists", - "format": "time_series", - "instant": true, - "intervalFactor": 2, - "refId": "A", - "step": 60 - } - ], - "title": "Playlist count", - "type": "stat" - }, - { - "columns": [], - "datasource": "${DS_PROMETHEUS}", - "fontSize": "100%", - "gridPos": { - "h": 5, - "w": 4, - "x": 20, - "y": 0 - }, - "id": 17, - "links": [], - "pageSize": null, - "scroll": false, - "showHeader": true, - "sort": { - "col": 0, - "desc": true - }, - "styles": [ - { - "alias": "Time", - "align": "auto", - "dateFormat": "YYYY-MM-DD HH:mm:ss", - "link": false, - "pattern": "Time", - "type": "hidden" - }, - { - "alias": "", - "align": "auto", - "colorMode": null, - "colors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"], - "decimals": 0, - "pattern": "/.*/", - "thresholds": [], - "type": "number", - "unit": "short" - } - ], - "targets": [ - { - "expr": "topk(1, grafana_info or grafana_build_info)", - "format": "time_series", - "instant": true, - "intervalFactor": 2, - "legendFormat": "{{version}}", - "refId": "A", - "step": 20 - } - ], - "title": "Grafana version", - "transform": "timeseries_to_rows", - "type": "table-old" - }, - { - "datasource": "${DS_PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "400" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#447EBC", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "500" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 10, - "x": 0, - "y": 5 - }, - "id": 15, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "sum by (status_code) (irate(grafana_http_request_duration_seconds_count[5m]))", - "format": "time_series", - "intervalFactor": 3, - "legendFormat": "{{status_code}}", - "refId": "B", - "step": 15, - "target": "dev.grafana.cb-office.alerting.active_alerts" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "http status codes", - "type": "timeseries" - }, - { - "datasource": "${DS_PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "400" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#447EBC", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "500" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 10, - "w": 10, - "x": 10, - "y": 5 - }, - "id": 11, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "sum(irate(grafana_api_response_status_total[5m]))", - "format": "time_series", - "intervalFactor": 4, - "legendFormat": "api", - "refId": "A", - "step": 20 - }, - { - "expr": "sum(irate(grafana_proxy_response_status_total[5m]))", - "format": "time_series", - "intervalFactor": 4, - "legendFormat": "proxy", - "refId": "B", - "step": 20 - }, - { - "expr": "sum(irate(grafana_page_response_status_total[5m]))", - "format": "time_series", - "intervalFactor": 4, - "legendFormat": "web", - "refId": "C", - "step": 20 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Requests by routing group", - "type": "timeseries" - }, - { - "columns": [], - "datasource": "${DS_PROMETHEUS}", - "fontSize": "100%", - "gridPos": { - "h": 10, - "w": 4, - "x": 20, - "y": 5 - }, - "height": "", - "id": 12, - "links": [], - "pageSize": null, - "scroll": true, - "showHeader": true, - "sort": { - "col": 0, - "desc": true - }, - "styles": [ - { - "alias": "Time", - "align": "auto", - "dateFormat": "YYYY-MM-DD HH:mm:ss", - "link": false, - "pattern": "Time", - "type": "hidden" - }, - { - "alias": "", - "align": "auto", - "colorMode": null, - "colors": ["rgba(245, 54, 54, 0.9)", "rgba(237, 129, 40, 0.89)", "rgba(50, 172, 45, 0.97)"], - "decimals": 0, - "pattern": "/.*/", - "thresholds": [], - "type": "number", - "unit": "short" - } - ], - "targets": [ - { - "expr": "sort(topk(8, sum by (handler) (grafana_http_request_duration_seconds_count)))", - "format": "time_series", - "instant": true, - "intervalFactor": 10, - "legendFormat": "{{handler}}", - "refId": "A", - "step": 100 - } - ], - "title": "Most used handlers", - "transform": "timeseries_to_rows", - "type": "table-old" - }, - { - "datasource": "${DS_PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "alerting" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "ok" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 5, - "w": 12, - "x": 0, - "y": 15 - }, - "id": 6, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "increase(grafana_alerting_active_alerts[1m])", - "format": "time_series", - "intervalFactor": 3, - "legendFormat": "{{state}}", - "refId": "A", - "step": 15 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Grafana active alerts", - "type": "timeseries" - }, - { - "datasource": "${DS_PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "alerting" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#890F02", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "alertname" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "firing alerts" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#BF1B00", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "ok" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#7EB26D", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 5, - "w": 12, - "x": 12, - "y": 15 - }, - "id": 18, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": " sum (ALERTS)", - "format": "time_series", - "intervalFactor": 3, - "legendFormat": "firing alerts", - "refId": "A", - "step": 15 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Prometheus alerts", - "type": "timeseries" - }, - { - "datasource": "${DS_PROMETHEUS}", - "description": "Aggregated over all Grafana nodes.", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "avg gc duration" - }, - "properties": [ - { - "id": "unit", - "value": "decbytes" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "allocated memory" - }, - "properties": [ - { - "id": "unit", - "value": "decbytes" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "used memory" - }, - "properties": [ - { - "id": "unit", - "value": "decbytes" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "memory usage" - }, - "properties": [ - { - "id": "unit", - "value": "decbytes" - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 24, - "x": 0, - "y": 20 - }, - "id": 7, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "sum(go_goroutines{job=\"grafana\"})", - "format": "time_series", - "hide": false, - "intervalFactor": 4, - "legendFormat": "go routines", - "refId": "A", - "step": 8, - "target": "select metric", - "type": "timeserie" - }, - { - "expr": "sum(process_resident_memory_bytes{job=\"grafana\"})", - "format": "time_series", - "intervalFactor": 4, - "legendFormat": "memory usage", - "refId": "B", - "step": 8 - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Grafana performance", - "type": "timeseries" - } - ], - "revision": "1.0", - "schemaVersion": 30, - "tags": ["grafana", "prometheus"], - "templating": { - "list": [] - }, - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": { - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] - }, - "timezone": "", - "title": "Grafana metrics", - "uid": "isFoa0z7k", - "version": 3 -} diff --git a/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json b/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json deleted file mode 100644 index 063e4af2c8c..00000000000 --- a/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json +++ /dev/null @@ -1,1353 +0,0 @@ -{ - "_comment": "Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json", - "__inputs": [ - { - "name": "DS_GDEV-PROMETHEUS", - "label": "gdev-prometheus", - "description": "", - "type": "datasource", - "pluginId": "prometheus", - "pluginName": "Prometheus" - } - ], - "__requires": [ - { - "type": "grafana", - "id": "grafana", - "name": "Grafana", - "version": "8.1.0-pre" - }, - { - "type": "datasource", - "id": "prometheus", - "name": "Prometheus", - "version": "1.0.0" - }, - { - "type": "panel", - "id": "stat", - "name": "Stat", - "version": "" - }, - { - "type": "panel", - "id": "timeseries", - "name": "Time series", - "version": "" - } - ], - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": "-- Grafana --", - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "gnetId": null, - "graphTooltip": 1, - "id": null, - "links": [ - { - "icon": "info", - "tags": [], - "targetBlank": true, - "title": "Grafana Docs", - "tooltip": "", - "type": "link", - "url": "https://grafana.com/docs/grafana/latest/" - }, - { - "icon": "info", - "tags": [], - "targetBlank": true, - "title": "Prometheus Docs", - "type": "link", - "url": "http://prometheus.io/docs/introduction/overview/" - } - ], - "panels": [ - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "prometheus" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "{instance=\"localhost:9090\",job=\"prometheus\"}" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#CCA300", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 0, - "y": 0 - }, - "id": 3, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "sum(irate(prometheus_tsdb_head_samples_appended_total{job=\"prometheus\"}[$__rate_interval]))", - "format": "time_series", - "hide": false, - "legendFormat": "samples", - "metric": "", - "refId": "A" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Samples Appended", - "type": "timeseries" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 6, - "y": 0 - }, - "id": 14, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "topk(5, max(scrape_duration_seconds) by (job))", - "format": "time_series", - "legendFormat": "{{job}}", - "metric": "", - "refId": "A" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Scrape Duration", - "type": "timeseries" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "bytes" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 12, - "y": 0 - }, - "id": 16, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "sum(process_resident_memory_bytes{job=\"prometheus\"})", - "format": "time_series", - "hide": false, - "legendFormat": "p8s process resident memory", - "refId": "D" - }, - { - "expr": "process_virtual_memory_bytes{job=\"prometheus\"}", - "format": "time_series", - "hide": false, - "legendFormat": "virtual memory", - "refId": "C" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Memory Profile", - "type": "timeseries" - }, - { - "cacheTimeout": null, - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [ - { - "options": { - "0": { - "text": "None" - } - }, - "type": "value" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 0.1 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 1 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 18, - "y": 0 - }, - "id": 37, - "interval": null, - "links": [], - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": ["max"], - "fields": "", - "values": false - }, - "text": {}, - "textMode": "auto" - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "prometheus_tsdb_wal_corruptions_total{job=\"prometheus\"}", - "format": "time_series", - "legendFormat": "", - "refId": "A" - } - ], - "title": "WAL Corruptions", - "type": "stat" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 0, - "y": 6 - }, - "id": 29, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "sum(prometheus_tsdb_head_active_appenders{job=\"prometheus\"})", - "format": "time_series", - "legendFormat": "active_appenders", - "metric": "", - "refId": "A" - }, - { - "expr": "sum(process_open_fds{job=\"prometheus\"})", - "format": "time_series", - "legendFormat": "open_fds", - "refId": "B" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Active Appenders", - "type": "timeseries" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "prometheus" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9BA8F", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "{instance=\"localhost:9090\",interval=\"5s\",job=\"prometheus\"}" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9BA8F", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 6, - "y": 6 - }, - "id": 2, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "prometheus_tsdb_blocks_loaded{job=\"prometheus\"}", - "format": "time_series", - "legendFormat": "blocks", - "refId": "A" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Blocks Loaded", - "type": "timeseries" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 12, - "y": 6 - }, - "id": 33, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "prometheus_tsdb_head_chunks{job=\"prometheus\"}", - "format": "time_series", - "legendFormat": "chunks", - "refId": "A" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Head Chunks", - "type": "timeseries" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "duration-p99" - }, - "properties": [ - { - "id": "unit", - "value": "s" - } - ] - } - ] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 18, - "y": 6 - }, - "id": 36, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "prometheus_tsdb_head_gc_duration_seconds{job=\"prometheus\",quantile=\"0.99\"}", - "format": "time_series", - "legendFormat": "duration-p99", - "refId": "A" - }, - { - "expr": "irate(prometheus_tsdb_head_gc_duration_seconds_count{job=\"prometheus\"}[$__rate_interval])", - "format": "time_series", - "legendFormat": "collections", - "refId": "B" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Head Block GC Activity", - "type": "timeseries" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "duration-p99" - }, - "properties": [ - { - "id": "unit", - "value": "s" - } - ] - } - ] - }, - "gridPos": { - "h": 6, - "w": 8, - "x": 0, - "y": 12 - }, - "id": 20, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "histogram_quantile(0.99, sum(rate(prometheus_tsdb_compaction_duration_bucket{job=\"prometheus\"}[$__rate_interval])) by (le))", - "format": "time_series", - "hide": false, - "legendFormat": "duration-{{p99}}", - "refId": "A" - }, - { - "expr": "irate(prometheus_tsdb_compactions_total{job=\"prometheus\"}[$__rate_interval])", - "format": "time_series", - "legendFormat": "compactions", - "refId": "B" - }, - { - "expr": "irate(prometheus_tsdb_compactions_failed_total{job=\"prometheus\"}[$__rate_interval])", - "format": "time_series", - "legendFormat": "failed", - "refId": "C" - }, - { - "expr": "irate(prometheus_tsdb_compactions_triggered_total{job=\"prometheus\"}[$__rate_interval])", - "format": "time_series", - "legendFormat": "triggered", - "refId": "D" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Compaction Activity", - "type": "timeseries" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 8, - "x": 8, - "y": 12 - }, - "id": 32, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "rate(prometheus_tsdb_reloads_total{job=\"prometheus\"}[$__rate_interval])", - "format": "time_series", - "legendFormat": "reloads", - "refId": "A" - }, - { - "expr": "rate(prometheus_tsdb_reloads_failures_total{job=\"prometheus\"}[$__rate_interval])", - "format": "time_series", - "hide": false, - "legendFormat": "failures", - "refId": "B" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Reload Count", - "type": "timeseries" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 8, - "x": 16, - "y": 12 - }, - "id": 38, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "prometheus_engine_query_duration_seconds{job=\"prometheus\", quantile=\"0.99\"}", - "format": "time_series", - "legendFormat": "{{slice}}_p99", - "refId": "A" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Query Durations", - "type": "timeseries" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 12, - "x": 0, - "y": 18 - }, - "id": 35, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "max(prometheus_rule_group_duration_seconds{job=\"prometheus\"}) by (quantile)", - "format": "time_series", - "legendFormat": "{{quantile}}", - "refId": "A" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Rule Group Eval Duration", - "type": "timeseries" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": true, - "stacking": { - "group": "A", - "mode": "normal" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 12, - "x": 12, - "y": 18 - }, - "id": 39, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "rate(prometheus_rule_group_iterations_missed_total{job=\"prometheus\"}[$__rate_interval])", - "format": "time_series", - "legendFormat": "missed", - "refId": "B" - }, - { - "expr": "rate(prometheus_rule_group_iterations_total{job=\"prometheus\"}[$__rate_interval])", - "format": "time_series", - "legendFormat": "iterations", - "refId": "A" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Rule Group Eval Activity", - "type": "timeseries" - } - ], - "refresh": "1m", - "revision": "1.0", - "schemaVersion": 30, - "tags": ["prometheus"], - "templating": { - "list": [] - }, - "time": { - "from": "now-1h", - "to": "now" - }, - "timepicker": { - "now": true, - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] - }, - "timezone": "browser", - "title": "Prometheus 2.0 Stats", - "uid": "UDdpyzz7z", - "version": 1 -} diff --git a/packages/grafana-prometheus/src/dashboards/prometheus_stats.json b/packages/grafana-prometheus/src/dashboards/prometheus_stats.json deleted file mode 100644 index 42ea6e7a4d5..00000000000 --- a/packages/grafana-prometheus/src/dashboards/prometheus_stats.json +++ /dev/null @@ -1,834 +0,0 @@ -{ - "_comment": "Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/dashboards/prometheus_stats.json", - "__inputs": [ - { - "name": "DS_GDEV-PROMETHEUS", - "label": "gdev-prometheus", - "description": "", - "type": "datasource", - "pluginId": "prometheus", - "pluginName": "Prometheus" - } - ], - "__requires": [ - { - "type": "grafana", - "id": "grafana", - "name": "Grafana", - "version": "8.1.0-pre" - }, - { - "type": "datasource", - "id": "prometheus", - "name": "Prometheus", - "version": "1.0.0" - }, - { - "type": "panel", - "id": "stat", - "name": "Stat", - "version": "" - }, - { - "type": "panel", - "id": "text", - "name": "Text", - "version": "" - }, - { - "type": "panel", - "id": "timeseries", - "name": "Time series", - "version": "" - } - ], - "annotations": { - "list": [ - { - "builtIn": 1, - "datasource": "-- Grafana --", - "enable": true, - "hide": true, - "iconColor": "rgba(0, 211, 255, 1)", - "name": "Annotations & Alerts", - "type": "dashboard" - } - ] - }, - "editable": true, - "gnetId": null, - "graphTooltip": 0, - "id": null, - "iteration": 1624859749459, - "links": [ - { - "icon": "info", - "tags": [], - "targetBlank": true, - "title": "Grafana Docs", - "tooltip": "", - "type": "link", - "url": "https://grafana.com/docs/grafana/latest/" - }, - { - "icon": "info", - "tags": [], - "targetBlank": true, - "title": "Prometheus Docs", - "type": "link", - "url": "http://prometheus.io/docs/introduction/overview/" - } - ], - "panels": [ - { - "cacheTimeout": null, - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "decimals": 1, - "mappings": [ - { - "options": { - "match": "null", - "result": { - "text": "N/A" - } - }, - "type": "special" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 0, - "y": 0 - }, - "id": 5, - "interval": null, - "links": [], - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "text": {}, - "textMode": "auto" - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "(time() - process_start_time_seconds{job=\"prometheus\", instance=~\"$node\"})", - "intervalFactor": 2, - "refId": "A" - } - ], - "title": "Uptime", - "type": "stat" - }, - { - "cacheTimeout": null, - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "rgb(31, 120, 193)", - "mode": "fixed" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 1 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 5 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 6, - "y": 0 - }, - "id": 6, - "interval": null, - "links": [], - "maxDataPoints": 100, - "options": { - "colorMode": "none", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "text": {}, - "textMode": "auto" - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "prometheus_local_storage_memory_series{instance=~\"$node\"}", - "intervalFactor": 2, - "refId": "A" - } - ], - "title": "Local Storage Memory Series", - "type": "stat" - }, - { - "cacheTimeout": null, - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [ - { - "options": { - "0": { - "text": "Empty" - } - }, - "type": "value" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "rgba(50, 172, 45, 0.97)", - "value": null - }, - { - "color": "rgba(237, 129, 40, 0.89)", - "value": 500 - }, - { - "color": "rgba(245, 54, 54, 0.9)", - "value": 4000 - } - ] - }, - "unit": "none" - }, - "overrides": [] - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 12, - "y": 0 - }, - "id": 7, - "interval": null, - "links": [], - "maxDataPoints": 100, - "options": { - "colorMode": "value", - "graphMode": "area", - "justifyMode": "auto", - "orientation": "horizontal", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "text": {}, - "textMode": "auto" - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "prometheus_local_storage_indexing_queue_length{instance=~\"$node\"}", - "intervalFactor": 2, - "refId": "A" - } - ], - "title": "Internal Storage Queue Length", - "type": "stat" - }, - { - "datasource": null, - "editable": true, - "error": false, - "gridPos": { - "h": 5, - "w": 6, - "x": 18, - "y": 0 - }, - "id": 9, - "links": [], - "options": { - "content": "Prometheus\n\n

You're using Prometheus, an open-source systems monitoring and alerting toolkit originally built at SoundCloud. For more information, check out the Grafana and Prometheus projects.

", - "mode": "html" - }, - "pluginVersion": "8.1.0-pre", - "style": {}, - "transparent": true, - "type": "text" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "prometheus" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "{instance=\"localhost:9090\",job=\"prometheus\"}" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#C15C17", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 6, - "w": 18, - "x": 0, - "y": 5 - }, - "id": 3, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "rate(prometheus_local_storage_ingested_samples_total{instance=~\"$node\"}[5m])", - "interval": "", - "intervalFactor": 2, - "legendFormat": "{{job}}", - "metric": "", - "refId": "A" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Samples ingested (rate-5m)", - "type": "timeseries" - }, - { - "datasource": null, - "editable": true, - "error": false, - "gridPos": { - "h": 6, - "w": 4, - "x": 18, - "y": 5 - }, - "id": 8, - "links": [], - "options": { - "content": "#### Samples Ingested\nThis graph displays the count of samples ingested by the Prometheus server, as measured over the last 5 minutes, per time series in the range vector. When troubleshooting an issue on IRC or GitHub, this is often the first stat requested by the Prometheus team. ", - "mode": "markdown" - }, - "pluginVersion": "8.1.0-pre", - "style": {}, - "transparent": true, - "type": "text" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "prometheus" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9BA8F", - "mode": "fixed" - } - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "{instance=\"localhost:9090\",interval=\"5s\",job=\"prometheus\"}" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "#F9BA8F", - "mode": "fixed" - } - } - ] - } - ] - }, - "gridPos": { - "h": 7, - "w": 10, - "x": 0, - "y": 11 - }, - "id": 2, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "rate(prometheus_target_interval_length_seconds_count{instance=~\"$node\"}[5m])", - "intervalFactor": 2, - "legendFormat": "{{job}}", - "refId": "A" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Target Scrapes (last 5m)", - "type": "timeseries" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 8, - "x": 10, - "y": 11 - }, - "id": 14, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "prometheus_target_interval_length_seconds{quantile!=\"0.01\", quantile!=\"0.05\",instance=~\"$node\"}", - "interval": "", - "intervalFactor": 2, - "legendFormat": "{{quantile}} ({{interval}})", - "metric": "", - "refId": "A" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Scrape Duration", - "type": "timeseries" - }, - { - "datasource": null, - "editable": true, - "error": false, - "gridPos": { - "h": 7, - "w": 6, - "x": 18, - "y": 11 - }, - "id": 11, - "links": [], - "options": { - "content": "#### Scrapes\nPrometheus scrapes metrics from instrumented jobs, either directly or via an intermediary push gateway for short-lived jobs. Target scrapes will show how frequently targets are scraped, as measured over the last 5 minutes, per time series in the range vector. Scrape Duration will show how long the scrapes are taking, with percentiles available as series. ", - "mode": "markdown" - }, - "pluginVersion": "8.1.0-pre", - "style": {}, - "transparent": true, - "type": "text" - }, - { - "datasource": "${DS_GDEV-PROMETHEUS}", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "links": [], - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - }, - "unit": "percentunit" - }, - "overrides": [] - }, - "gridPos": { - "h": 7, - "w": 18, - "x": 0, - "y": 18 - }, - "id": 12, - "links": [], - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom" - }, - "tooltip": { - "mode": "single" - } - }, - "pluginVersion": "8.1.0-pre", - "targets": [ - { - "expr": "prometheus_evaluator_duration_seconds{quantile!=\"0.01\", quantile!=\"0.05\",instance=~\"$node\"}", - "interval": "", - "intervalFactor": 2, - "legendFormat": "{{quantile}}", - "refId": "A" - } - ], - "timeFrom": null, - "timeShift": null, - "title": "Rule Eval Duration", - "type": "timeseries" - }, - { - "datasource": null, - "editable": true, - "error": false, - "gridPos": { - "h": 7, - "w": 6, - "x": 18, - "y": 18 - }, - "id": 15, - "links": [], - "options": { - "content": "#### Rule Evaluation Duration\nThis graph panel plots the duration for all evaluations to execute. The 50th percentile, 90th percentile and 99th percentile are shown as three separate series to help identify outliers that may be skewing the data.", - "mode": "markdown" - }, - "pluginVersion": "8.1.0-pre", - "style": {}, - "transparent": true, - "type": "text" - } - ], - "refresh": false, - "revision": "1.0", - "schemaVersion": 30, - "tags": ["prometheus"], - "templating": { - "list": [ - { - "allValue": null, - "current": {}, - "datasource": "${DS_GDEV-PROMETHEUS}", - "definition": "", - "description": null, - "error": null, - "hide": 0, - "includeAll": false, - "label": "HOST:", - "multi": false, - "name": "node", - "options": [], - "query": { - "query": "label_values(prometheus_build_info, instance)", - "refId": "gdev-prometheus-node-Variable-Query" - }, - "refresh": 1, - "regex": "", - "skipUrlSync": false, - "sort": 1, - "tagValuesQuery": "", - "tagsQuery": "", - "type": "query", - "useTags": false - } - ] - }, - "time": { - "from": "now-5m", - "to": "now" - }, - "timepicker": { - "now": true, - "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] - }, - "timezone": "browser", - "title": "Prometheus Stats", - "uid": "rpfmFFz7z", - "version": 2 -} From d44cab9eafd9b63cba6da7b9e8a24d4c6e378884 Mon Sep 17 00:00:00 2001 From: Juan Cabanas Date: Tue, 6 Jan 2026 06:38:15 -0300 Subject: [PATCH 25/79] DashboardLibrary: Add validations to visualize community dashboards (#114562) * dashboard library check added * community dashboard section tests in progress * tests added * translations added * pagination removed * total pages removed * test updated. pagination removed * filters applied * tracking event removed to be created in another pr * slug added so url is correclty generated * ui fix * improvements after review * improvements after review * more tests added. new logic created * fix * changes applied * tests removed. pattern updated * preset of 6 elements applied * Improve code comments and adjust variable name based on PR feedback * Fix unit test and add extra case for regex pattern * Fix interaction event, we were missing contentKind on BasicProvisioned flow and datasources types were not being send --------- Co-authored-by: nmarrs Co-authored-by: alexandra vargas --- .../BasicProvisionedDashboardsEmptyPage.tsx | 1 + .../CommunityDashboardSection.test.tsx | 125 ++++++ .../CommunityDashboardSection.tsx | 172 ++++----- .../DashboardLibrary/DashboardCard.test.tsx | 35 +- .../DashboardLibrarySection.test.tsx | 273 ++++++++++++++ .../SuggestedDashboards.test.tsx | 186 +++++++++ .../DashboardLibrary/SuggestedDashboards.tsx | 80 ++-- .../SuggestedDashboardsModal.test.tsx | 101 +++++ .../api/dashboardLibraryApi.test.ts | 89 +++-- .../api/dashboardLibraryApi.ts | 54 ++- .../dashgrid/DashboardLibrary/interactions.ts | 1 + .../dashgrid/DashboardLibrary/types.ts | 2 + .../utils/communityDashboardHelpers.test.ts | 357 ++++++++++++++++-- .../utils/communityDashboardHelpers.ts | 137 ++++++- .../DashboardLibrary/utils/test-utils.ts | 34 ++ public/locales/en-US/grafana.json | 3 +- 16 files changed, 1423 insertions(+), 227 deletions(-) create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboardsModal.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/utils/test-utils.ts diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx index bcfb61ef823..7e041280b04 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx @@ -78,6 +78,7 @@ export const BasicProvisionedDashboardsEmptyPage = ({ datasourceUid }: Props) => sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, libraryItemId: dashboard.uid, creationOrigin: CREATION_ORIGINS.DASHBOARD_LIBRARY_DATASOURCE_DASHBOARD, + contentKind: CONTENT_KINDS.DATASOURCE_DASHBOARD, }); const templateUrl = `${DASHBOARD_LIBRARY_ROUTES.Template}?${params.toString()}`; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.test.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.test.tsx new file mode 100644 index 00000000000..d4821d96899 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.test.tsx @@ -0,0 +1,125 @@ +import { screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { render } from 'test/test-utils'; + +import { CommunityDashboardSection } from './CommunityDashboardSection'; +import { fetchCommunityDashboards } from './api/dashboardLibraryApi'; +import { GnetDashboard } from './types'; +import { onUseCommunityDashboard } from './utils/communityDashboardHelpers'; + +jest.mock('./api/dashboardLibraryApi', () => ({ + fetchCommunityDashboards: jest.fn(), +})); + +jest.mock('./utils/communityDashboardHelpers', () => ({ + ...jest.requireActual('./utils/communityDashboardHelpers'), + onUseCommunityDashboard: jest.fn(), +})); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getInstanceSettings: jest.fn((uid: string) => ({ + uid, + name: `DataSource ${uid}`, + type: 'test', + })), + }), +})); + +const mockFetchCommunityDashboards = fetchCommunityDashboards as jest.MockedFunction; +const mockOnUseCommunityDashboard = onUseCommunityDashboard as jest.MockedFunction; + +const createMockGnetDashboard = (overrides: Partial = {}): GnetDashboard => ({ + id: 1, + name: 'Test Dashboard', + description: 'Test Description', + downloads: 2000, + datasource: 'Prometheus', + slug: 'test-dashboard', + ...overrides, +}); + +const setup = async ( + props: Partial> = {}, + successScenario = true +) => { + const renderResult = render( + , + { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-datasource-uid'], + }, + } + ); + + if (successScenario) { + await waitFor(() => { + expect(screen.getByText('Test Dashboard')).toBeInTheDocument(); + }); + } + + return renderResult; +}; + +describe('CommunityDashboardSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render', async () => { + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 5, + items: [ + createMockGnetDashboard(), + createMockGnetDashboard({ id: 2, name: 'Test Dashboard 2' }), + createMockGnetDashboard({ id: 3, name: 'Test Dashboard 3' }), + ], + }); + + await setup(); + + await waitFor(() => { + expect(screen.getByText('Test Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Test Dashboard 2')).toBeInTheDocument(); + expect(screen.getByText('Test Dashboard 3')).toBeInTheDocument(); + }); + }); + + it('should show error when fetching a specific community dashboard after clicking use dashboard button fails', async () => { + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 5, + items: [createMockGnetDashboard()], + }); + + mockOnUseCommunityDashboard.mockRejectedValue(new Error('Failed to use community dashboard')); + + const { user } = await setup(); + await waitFor(() => { + expect(screen.getByText('Test Dashboard')).toBeInTheDocument(); + }); + + const useDashboardButton = screen.getByRole('button', { name: 'Use dashboard' }); + await user.click(useDashboardButton); + + await waitFor(() => { + expect(screen.getByText('Error loading community dashboard')).toBeInTheDocument(); + }); + }); + + it('should show error when fetching community dashboards list fails', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockFetchCommunityDashboards.mockRejectedValue(new Error('Failed to fetch community dashboards')); + + await setup(undefined, false); + + await waitFor(() => { + expect(screen.getByText('Error loading community dashboards')).toBeInTheDocument(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboards', expect.any(Error)); + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx index d914a31f1bc..bd42428564b 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx @@ -1,12 +1,12 @@ import { css } from '@emotion/css'; import { useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom-v5-compat'; -import { useAsync, useDebounce } from 'react-use'; +import { useAsyncFn, useAsyncRetry, useDebounce } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { getDataSourceSrv } from '@grafana/runtime'; -import { Button, useStyles2, Stack, Grid, EmptyState, Alert, Pagination, FilterInput } from '@grafana/ui'; +import { Button, useStyles2, Stack, Grid, EmptyState, Alert, FilterInput, Box } from '@grafana/ui'; import { DashboardCard } from './DashboardCard'; import { MappingContext } from './SuggestedDashboardsModal'; @@ -24,6 +24,8 @@ import { getLogoUrl, buildDashboardDetails, onUseCommunityDashboard, + COMMUNITY_PAGE_SIZE_QUERY, + COMMUNITY_RESULT_SIZE, } from './utils/communityDashboardHelpers'; interface Props { @@ -31,8 +33,6 @@ interface Props { datasourceType?: string; } -// Constants for community dashboard pagination and API params -const COMMUNITY_PAGE_SIZE = 9; const SEARCH_DEBOUNCE_MS = 500; const DEFAULT_SORT_ORDER = 'downloads'; const DEFAULT_SORT_DIRECTION = 'desc'; @@ -42,7 +42,6 @@ const INCLUDE_SCREENSHOTS = true; export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Props) => { const [searchParams] = useSearchParams(); const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid'); - const [currentPage, setCurrentPage] = useState(1); const [searchQuery, setSearchQuery] = useState(''); const hasTrackedLoaded = useRef(false); @@ -55,18 +54,12 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro [searchQuery] ); - // Reset to page 1 when debounced search query changes - useEffect(() => { - if (debouncedSearchQuery) { - setCurrentPage(1); - } - }, [debouncedSearchQuery]); - const { value: response, loading, error, - } = useAsync(async () => { + retry, + } = useAsyncRetry(async () => { if (!datasourceUid) { return null; } @@ -80,8 +73,8 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro const apiResponse = await fetchCommunityDashboards({ orderBy: DEFAULT_SORT_ORDER, direction: DEFAULT_SORT_DIRECTION, - page: currentPage, - pageSize: COMMUNITY_PAGE_SIZE, + page: 1, + pageSize: COMMUNITY_PAGE_SIZE_QUERY, includeLogo: INCLUDE_LOGO, includeScreenshots: INCLUDE_SCREENSHOTS, dataSourceSlugIn: ds.type, @@ -100,15 +93,14 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro } return { - dashboards: apiResponse.items, - pages: apiResponse.pages, + dashboards: apiResponse.items.slice(0, COMMUNITY_RESULT_SIZE), datasourceType: ds.type, }; } catch (err) { console.error('Error loading community dashboards', err); throw err; } - }, [datasourceUid, currentPage, debouncedSearchQuery]); + }, [datasourceUid, debouncedSearchQuery]); // Track analytics only once on first successful load useEffect(() => { @@ -128,37 +120,49 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro // Determine what to show in results area const dashboards = Array.isArray(response?.dashboards) ? response.dashboards : []; - const totalPages = response?.pages || 1; const showEmptyState = !loading && (!response?.dashboards || response.dashboards.length === 0); const showError = !loading && error; - const onPreviewCommunityDashboard = (dashboard: GnetDashboard) => { - if (!response) { - return; - } + const [{ error: isPreviewDashboardError }, onPreviewCommunityDashboard] = useAsyncFn( + async (dashboard: GnetDashboard) => { + if (!response) { + return; + } - // Track item click - DashboardLibraryInteractions.itemClicked({ - contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD, - datasourceTypes: [response.datasourceType], - libraryItemId: String(dashboard.id), - libraryItemTitle: dashboard.name, - sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, - eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB, - discoveryMethod: debouncedSearchQuery.trim() ? DISCOVERY_METHODS.SEARCH : DISCOVERY_METHODS.BROWSE, - }); + // Track item click + DashboardLibraryInteractions.itemClicked({ + contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD, + datasourceTypes: [response.datasourceType], + libraryItemId: String(dashboard.id), + libraryItemTitle: dashboard.name, + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB, + discoveryMethod: debouncedSearchQuery.trim() ? DISCOVERY_METHODS.SEARCH : DISCOVERY_METHODS.BROWSE, + }); - onUseCommunityDashboard({ - dashboard, - datasourceUid: datasourceUid || '', - datasourceType: response.datasourceType, - eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB, - onShowMapping, - }); - }; + await onUseCommunityDashboard({ + dashboard, + datasourceUid: datasourceUid || '', + datasourceType: response.datasourceType, + eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB, + onShowMapping, + }); + }, + [response, datasourceUid, debouncedSearchQuery, onShowMapping] + ); return ( + {isPreviewDashboardError && ( +
+ + Failed to load community dashboard. + +
+ )} - {Array.from({ length: COMMUNITY_PAGE_SIZE }).map((_, i) => ( + {Array.from({ length: COMMUNITY_RESULT_SIZE }).map((_, i) => ( ))} @@ -197,7 +201,7 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro Failed to load community dashboards. Please try again. -
@@ -233,42 +237,47 @@ export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Pro )} ) : ( - = 2 ? 2 : 1, - lg: dashboards.length >= 3 ? 3 : dashboards.length >= 2 ? 2 : 1, - }} - > - {dashboards.map((dashboard) => { - const thumbnailUrl = getThumbnailUrl(dashboard); - const logoUrl = getLogoUrl(dashboard); - const imageUrl = thumbnailUrl || logoUrl; - const isLogo = !thumbnailUrl; - const details = buildDashboardDetails(dashboard); + + = 2 ? 2 : 1, + lg: dashboards.length >= 3 ? 3 : dashboards.length >= 2 ? 2 : 1, + }} + > + {dashboards.map((dashboard) => { + const thumbnailUrl = getThumbnailUrl(dashboard); + const logoUrl = getLogoUrl(dashboard); + const imageUrl = thumbnailUrl || logoUrl; + const isLogo = !thumbnailUrl; + const details = buildDashboardDetails(dashboard); - return ( - onPreviewCommunityDashboard(dashboard)} - isLogo={isLogo} - details={details} - kind="suggested_dashboard" - /> - ); - })} - + return ( + onPreviewCommunityDashboard(dashboard)} + isLogo={isLogo} + details={details} + kind="suggested_dashboard" + /> + ); + })} + + + + + )}
- {totalPages > 1 && ( -
- -
- )}
); }; @@ -277,18 +286,9 @@ function getStyles(theme: GrafanaTheme2) { return { resultsContainer: css({ width: '100%', - position: 'relative', flex: 1, overflow: 'auto', - }), - paginationWrapper: css({ - position: 'sticky', - bottom: 0, - backgroundColor: theme.colors.background.primary, - padding: theme.spacing(2), - display: 'flex', - justifyContent: 'flex-end', - zIndex: 2, + paddingBottom: theme.spacing(2), }), }; } diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.test.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.test.tsx index 939f1bcdb89..5af933ceec1 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.test.tsx @@ -1,41 +1,8 @@ import { screen } from '@testing-library/react'; import { render } from 'test/test-utils'; -import { PluginDashboard } from 'app/types/plugins'; - import { DashboardCard } from './DashboardCard'; -import { GnetDashboard } from './types'; - -// Helper functions for creating mock objects -const createMockPluginDashboard = (overrides: Partial = {}): PluginDashboard => ({ - dashboardId: 1, - description: 'Test description', - imported: false, - importedRevision: 0, - importedUri: '', - importedUrl: '', - path: '', - pluginId: 'test-plugin', - removed: false, - revision: 1, - slug: 'test-dashboard', - title: 'Test Dashboard', - uid: 'test-uid', - ...overrides, -}); - -const createMockGnetDashboard = (overrides: Partial = {}): GnetDashboard => ({ - id: 123, - name: 'Test Dashboard', - description: 'Test description', - datasource: 'Prometheus', - orgName: 'Test Org', - userName: 'testuser', - publishedAt: '', - updatedAt: '', - downloads: 0, - ...overrides, -}); +import { createMockGnetDashboard, createMockPluginDashboard } from './utils/test-utils'; const createMockDetails = (overrides = {}) => ({ id: '123', diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.test.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.test.tsx new file mode 100644 index 00000000000..1147967acd1 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.test.tsx @@ -0,0 +1,273 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import { render } from 'test/test-utils'; + +import { locationService } from '@grafana/runtime'; + +import { DashboardLibrarySection } from './DashboardLibrarySection'; +import { fetchProvisionedDashboards } from './api/dashboardLibraryApi'; +import { DashboardLibraryInteractions } from './interactions'; +import { createMockPluginDashboard } from './utils/test-utils'; + +jest.mock('./api/dashboardLibraryApi', () => ({ + fetchProvisionedDashboards: jest.fn(), +})); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getInstanceSettings: jest.fn((uid?: string) => { + if (uid) { + return { + uid, + name: `DataSource ${uid}`, + type: 'test-datasource', + }; + } + return null; + }), + }), + locationService: { + push: jest.fn(), + getHistory: jest.fn(() => ({ + listen: jest.fn(() => jest.fn()), + })), + }, +})); + +jest.mock('./interactions', () => ({ + ...jest.requireActual('./interactions'), + DashboardLibraryInteractions: { + loaded: jest.fn(), + itemClicked: jest.fn(), + }, +})); + +jest.mock('./DashboardCard', () => { + const DashboardCardComponent = ({ title, onClick }: { title: string; onClick: () => void }) => ( +
+ {title} +
+ ); + + const DashboardCardSkeleton = () =>
Skeleton
; + + return { + DashboardCard: Object.assign(DashboardCardComponent, { + Skeleton: DashboardCardSkeleton, + }), + }; +}); + +const mockFetchProvisionedDashboards = fetchProvisionedDashboards as jest.MockedFunction< + typeof fetchProvisionedDashboards +>; +const mockLocationServicePush = locationService.push as jest.MockedFunction; +const mockDashboardLibraryInteractionsLoaded = DashboardLibraryInteractions.loaded as jest.MockedFunction< + typeof DashboardLibraryInteractions.loaded +>; +const mockDashboardLibraryInteractionsItemClicked = DashboardLibraryInteractions.itemClicked as jest.MockedFunction< + typeof DashboardLibraryInteractions.itemClicked +>; + +describe('DashboardLibrarySection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render dashboards when they are available', async () => { + const dashboards = [ + createMockPluginDashboard({ title: 'Dashboard 1', uid: 'uid-1' }), + createMockPluginDashboard({ title: 'Dashboard 2', uid: 'uid-2' }), + ]; + + mockFetchProvisionedDashboards.mockResolvedValue(dashboards); + + render(, { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'], + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-card-Dashboard 1')).toBeInTheDocument(); + expect(screen.getByTestId('dashboard-card-Dashboard 2')).toBeInTheDocument(); + }); + }); + + it('should show empty state when there are no dashboards', async () => { + mockFetchProvisionedDashboards.mockResolvedValue([]); + + render(, { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'], + }, + }); + + await waitFor(() => { + expect(screen.getByText('No test-datasource provisioned dashboards found')).toBeInTheDocument(); + expect( + screen.getByText( + 'Provisioned dashboards are provided by data source plugins. You can find more plugins on Grafana.com.' + ) + ).toBeInTheDocument(); + const browseButton = screen.getByRole('button', { name: 'Browse plugins' }); + expect(browseButton).toBeInTheDocument(); + }); + }); + + it('should show empty state without datasource type when datasourceUid is not provided', async () => { + mockFetchProvisionedDashboards.mockResolvedValue([]); + + render(, { + historyOptions: { + initialEntries: ['/test'], + }, + }); + + await waitFor(() => { + expect(screen.getByText('No provisioned dashboards found')).toBeInTheDocument(); + }); + }); + + it('should render pagination when there are more than 9 dashboards', async () => { + const dashboards = Array.from({ length: 18 }, (_, i) => + createMockPluginDashboard({ title: `Dashboard ${i + 1}`, uid: `uid-${i + 1}` }) + ); + + mockFetchProvisionedDashboards.mockResolvedValue(dashboards); + + render(, { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'], + }, + }); + + await waitFor(() => { + const pagination = screen.getByRole('navigation'); + expect(pagination).toBeInTheDocument(); + expect(within(pagination).getByText('1')).toBeInTheDocument(); + expect(within(pagination).getByText('2')).toBeInTheDocument(); + }); + }); + + it('should not render pagination when there are 9 or fewer dashboards', async () => { + const dashboards = Array.from({ length: 9 }, (_, i) => + createMockPluginDashboard({ title: `Dashboard ${i + 1}`, uid: `uid-${i + 1}` }) + ); + + mockFetchProvisionedDashboards.mockResolvedValue(dashboards); + + render(, { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'], + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-card-Dashboard 1')).toBeInTheDocument(); + }); + + const pagination = screen.queryByRole('navigation'); + expect(pagination).not.toBeInTheDocument(); + }); + + it('should navigate to template route when clicking on a dashboard', async () => { + const dashboard = createMockPluginDashboard({ + title: 'Test Dashboard', + uid: 'test-uid-123', + pluginId: 'test-plugin', + path: 'test/path.json', + }); + + mockFetchProvisionedDashboards.mockResolvedValue([dashboard]); + + render(, { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'], + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-card-Test Dashboard')).toBeInTheDocument(); + }); + + const dashboardCard = screen.getByTestId('dashboard-card-Test Dashboard'); + dashboardCard.click(); + + await waitFor(() => { + expect(mockLocationServicePush).toHaveBeenCalled(); + const callArgs = mockLocationServicePush.mock.calls[0][0]; + expect(callArgs).toContain('/dashboard/template'); + expect(callArgs).toContain('datasource=test-uid'); + + expect(callArgs).toContain('title=Test+Dashboard'); + expect(callArgs).toContain('pluginId=test-plugin'); + expect(callArgs).toContain('path=test%2Fpath.json'); + expect(callArgs).toContain('libraryItemId=test-uid-123'); + }); + }); + + it('should track analytics when dashboards are loaded', async () => { + const dashboards = [ + createMockPluginDashboard({ title: 'Dashboard 1', uid: 'uid-1' }), + createMockPluginDashboard({ title: 'Dashboard 2', uid: 'uid-2' }), + ]; + + mockFetchProvisionedDashboards.mockResolvedValue(dashboards); + + render(, { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'], + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-card-Dashboard 1')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(mockDashboardLibraryInteractionsLoaded).toHaveBeenCalledWith({ + numberOfItems: 2, + contentKinds: ['datasource_dashboard'], + datasourceTypes: ['test-datasource'], + sourceEntryPoint: 'datasource_page', + eventLocation: 'suggested_dashboards_modal_provisioned_tab', + }); + }); + }); + + it('should track analytics when a dashboard is clicked', async () => { + const dashboard = createMockPluginDashboard({ + title: 'Test Dashboard', + uid: 'test-uid-123', + pluginId: 'test-plugin', + }); + + mockFetchProvisionedDashboards.mockResolvedValue([dashboard]); + + render(, { + historyOptions: { + initialEntries: ['/test?dashboardLibraryDatasourceUid=test-uid'], + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-card-Test Dashboard')).toBeInTheDocument(); + }); + + const dashboardCard = screen.getByTestId('dashboard-card-Test Dashboard'); + dashboardCard.click(); + + await waitFor(() => { + expect(mockDashboardLibraryInteractionsItemClicked).toHaveBeenCalledWith({ + contentKind: 'datasource_dashboard', + datasourceTypes: ['test-plugin'], + libraryItemId: 'test-uid-123', + libraryItemTitle: 'Test Dashboard', + sourceEntryPoint: 'datasource_page', + eventLocation: 'suggested_dashboards_modal_provisioned_tab', + discoveryMethod: 'browse', + }); + }); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.test.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.test.tsx new file mode 100644 index 00000000000..4109a198f05 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.test.tsx @@ -0,0 +1,186 @@ +import { screen, waitFor } from '@testing-library/react'; +import { render } from 'test/test-utils'; + +import { SuggestedDashboards } from './SuggestedDashboards'; +import { fetchCommunityDashboards, fetchProvisionedDashboards } from './api/dashboardLibraryApi'; +import { createMockGnetDashboard, createMockPluginDashboard } from './utils/test-utils'; + +jest.mock('./api/dashboardLibraryApi', () => ({ + fetchProvisionedDashboards: jest.fn(), + fetchCommunityDashboards: jest.fn(), +})); + +jest.mock('./utils/communityDashboardHelpers', () => ({ + ...jest.requireActual('./utils/communityDashboardHelpers'), + onUseCommunityDashboard: jest.fn(), +})); + +jest.mock('./SuggestedDashboardsModal', () => ({ + SuggestedDashboardsModal: () =>
Modal
, +})); + +jest.mock('./DashboardCard', () => { + const DashboardCardComponent = ({ title, onClick }: { title: string; onClick: () => void }) => ( +
+ {title} +
+ ); + + const DashboardCardSkeleton = () =>
Skeleton
; + + return { + DashboardCard: Object.assign(DashboardCardComponent, { + Skeleton: DashboardCardSkeleton, + }), + }; +}); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getInstanceSettings: jest.fn((uid?: string) => { + if (uid) { + return { + uid, + name: `DataSource ${uid}`, + type: 'test-datasource', + }; + } + return null; + }), + }), +})); + +jest.mock('./interactions', () => ({ + ...jest.requireActual('./interactions'), + DashboardLibraryInteractions: { + loaded: jest.fn(), + itemClicked: jest.fn(), + }, +})); + +const mockFetchProvisionedDashboards = fetchProvisionedDashboards as jest.MockedFunction< + typeof fetchProvisionedDashboards +>; +const mockFetchCommunityDashboards = fetchCommunityDashboards as jest.MockedFunction; + +describe('SuggestedDashboards', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render when there are dashboards', async () => { + mockFetchProvisionedDashboards.mockResolvedValue([createMockPluginDashboard()]); + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 1, + items: [createMockGnetDashboard()], + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('suggested-dashboards')).toBeInTheDocument(); + }); + }); + + it('should not render when there are no dashboards', async () => { + mockFetchProvisionedDashboards.mockResolvedValue([]); + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 1, + items: [], + }); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('suggested-dashboards')).not.toBeInTheDocument(); + }); + }); + + it('should render provisioned dashboard cards', async () => { + const provisionedDashboard = createMockPluginDashboard({ title: 'Provisioned Dashboard 1' }); + mockFetchProvisionedDashboards.mockResolvedValue([provisionedDashboard]); + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 1, + items: [], + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-card-Provisioned Dashboard 1')).toBeInTheDocument(); + }); + }); + + it('should render community dashboard cards', async () => { + const communityDashboard = createMockGnetDashboard({ name: 'Community Dashboard 1' }); + mockFetchProvisionedDashboards.mockResolvedValue([]); + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 1, + items: [communityDashboard], + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-card-Community Dashboard 1')).toBeInTheDocument(); + }); + }); + + it('should show "View all" button when hasMoreDashboards is true', async () => { + mockFetchProvisionedDashboards.mockResolvedValue([ + createMockPluginDashboard(), + createMockPluginDashboard({ title: 'Provisioned Dashboard 2' }), + ]); + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 1, + items: [], + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'View all' })).toBeInTheDocument(); + }); + }); + + it('should not show "View all" button when hasMoreDashboards is false', async () => { + mockFetchProvisionedDashboards.mockResolvedValue([createMockPluginDashboard()]); + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 1, + items: [createMockGnetDashboard()], + }); + + render(); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'View all' })).not.toBeInTheDocument(); + }); + }); + + it('should render title and subtitle with datasource type when datasourceUid is provided', async () => { + mockFetchProvisionedDashboards.mockResolvedValue([createMockPluginDashboard()]); + mockFetchCommunityDashboards.mockResolvedValue({ + page: 1, + pages: 1, + items: [], + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText('Build a dashboard using suggested options for your test-datasource data source') + ).toBeInTheDocument(); + expect( + screen.getByText('Browse and select from data-source provided or community dashboards') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx index d0384e9746d..2a4a051b7cf 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx @@ -1,12 +1,12 @@ import { css } from '@emotion/css'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom-v5-compat'; -import { useAsync } from 'react-use'; +import { useAsync, useAsyncFn } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { getDataSourceSrv, locationService } from '@grafana/runtime'; -import { Button, useStyles2, Grid } from '@grafana/ui'; +import { Button, useStyles2, Grid, Alert } from '@grafana/ui'; import { PluginDashboard } from 'app/types/plugins'; import { DashboardCard } from './DashboardCard'; @@ -26,6 +26,8 @@ import { getLogoUrl, buildDashboardDetails, onUseCommunityDashboard, + COMMUNITY_PAGE_SIZE_QUERY, + COMMUNITY_RESULT_SIZE, } from './utils/communityDashboardHelpers'; import { getProvisionedDashboardImageUrl } from './utils/provisionedDashboardHelpers'; @@ -43,7 +45,7 @@ type SuggestedDashboardsResult = { }; // Constants for suggested dashboards API params -const SUGGESTED_COMMUNITY_PAGE_SIZE = 2; +const MAX_SUGGESTED_DASHBOARDS_PREVIEW = 2; const DEFAULT_SORT_ORDER = 'downloads'; const DEFAULT_SORT_DIRECTION = 'desc'; const INCLUDE_SCREENSHOTS = true; @@ -91,14 +93,14 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => { orderBy: DEFAULT_SORT_ORDER, direction: DEFAULT_SORT_DIRECTION, page: 1, - pageSize: SUGGESTED_COMMUNITY_PAGE_SIZE, + pageSize: COMMUNITY_PAGE_SIZE_QUERY, includeScreenshots: INCLUDE_SCREENSHOTS, dataSourceSlugIn: ds.type, includeLogo: INCLUDE_LOGO, }), ]); - const community = communityResponse.items; + const community = communityResponse.items.slice(0, COMMUNITY_RESULT_SIZE); // Mix: 1 provisioned + 2 community const mixed: MixedDashboard[] = []; @@ -130,7 +132,7 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => { // Determine if there are more dashboards available beyond what we're showing // Show "View all" if: more than 1 provisioned exists OR we got the full page size of community dashboards - const hasMoreDashboards = provisioned.length > 1 || community.length >= SUGGESTED_COMMUNITY_PAGE_SIZE; + const hasMoreDashboards = provisioned.length > 1 || community.length > MAX_SUGGESTED_DASHBOARDS_PREVIEW; return { dashboards: mixed, hasMoreDashboards }; } catch (error) { @@ -233,35 +235,38 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => { locationService.push(`/dashboard/template?${params.toString()}`); }; - const onPreviewCommunityDashboard = (dashboard: GnetDashboard) => { - if (!datasourceUid) { - return; - } + const [{ error: isPreviewCommunityDashboardError }, onPreviewCommunityDashboard] = useAsyncFn( + async (dashboard: GnetDashboard) => { + if (!datasourceUid) { + return; + } - const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); - if (!ds) { - return; - } + const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); + if (!ds) { + return; + } - // Track item click - DashboardLibraryInteractions.itemClicked({ - contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD, - datasourceTypes: [ds.type], - libraryItemId: String(dashboard.id), - libraryItemTitle: dashboard.name, - sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, - eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD, - discoveryMethod: DISCOVERY_METHODS.BROWSE, - }); + // Track item click + DashboardLibraryInteractions.itemClicked({ + contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD, + datasourceTypes: [ds.type], + libraryItemId: String(dashboard.id), + libraryItemTitle: dashboard.name, + sourceEntryPoint: SOURCE_ENTRY_POINTS.DATASOURCE_PAGE, + eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD, + discoveryMethod: DISCOVERY_METHODS.BROWSE, + }); - onUseCommunityDashboard({ - dashboard, - datasourceUid, - datasourceType: ds.type, - eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD, - onShowMapping: onShowMapping, - }); - }; + await onUseCommunityDashboard({ + dashboard, + datasourceUid, + datasourceType: ds.type, + eventLocation: EVENT_LOCATIONS.EMPTY_DASHBOARD, + onShowMapping: onShowMapping, + }); + }, + [datasourceUid, onShowMapping] + ); // Don't render if no dashboards or still loading if (!loading && (!result || result.dashboards.length === 0)) { @@ -297,7 +302,16 @@ export const SuggestedDashboards = ({ datasourceUid }: Props) => { )} - + {isPreviewCommunityDashboardError && ( +
+ + Failed to load community dashboard. + +
+ )} ({ + DashboardLibrarySection: () =>
Dashboard Library Section
, +})); + +jest.mock('./CommunityDashboardSection', () => ({ + CommunityDashboardSection: () =>
Community Dashboard Section
, +})); + +jest.mock('./CommunityDashboardMappingForm', () => ({ + CommunityDashboardMappingForm: () => ( +
Community Dashboard Mapping Form
+ ), +})); + +describe('SuggestedDashboardsModal', () => { + const defaultProps = { + isOpen: true, + onDismiss: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render when isOpen is true', () => { + render(); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('should not render when isOpen is false', () => { + render(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should render both tabs: Data-source provided and Community', () => { + render(); + + expect(screen.getByRole('tab', { name: 'Data-source provided' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Community' })).toBeInTheDocument(); + }); + + it('should render tablist with both tabs', () => { + render(); + + const tablist = screen.getByRole('tablist'); + expect(tablist).toBeInTheDocument(); + + const tabs = screen.getAllByRole('tab'); + expect(tabs).toHaveLength(2); + expect(tabs[0]).toHaveTextContent('Data-source provided'); + expect(tabs[1]).toHaveTextContent('Community'); + }); + + it('should render DashboardLibrarySection when activeView is datasource', () => { + render(); + + expect(screen.getByTestId('dashboard-library-section')).toBeInTheDocument(); + expect(screen.queryByTestId('community-dashboard-section')).not.toBeInTheDocument(); + expect(screen.queryByTestId('community-dashboard-mapping-form')).not.toBeInTheDocument(); + }); + + it('should render CommunityDashboardSection when activeView is community', () => { + render(); + + expect(screen.getByTestId('community-dashboard-section')).toBeInTheDocument(); + expect(screen.queryByTestId('dashboard-library-section')).not.toBeInTheDocument(); + expect(screen.queryByTestId('community-dashboard-mapping-form')).not.toBeInTheDocument(); + }); + + it('should render CommunityDashboardMappingForm when activeView is mapping', () => { + render( + + ); + + expect(screen.getByTestId('community-dashboard-mapping-form')).toBeInTheDocument(); + expect(screen.queryByTestId('dashboard-library-section')).not.toBeInTheDocument(); + expect(screen.queryByTestId('community-dashboard-section')).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.test.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.test.ts index c662b90e372..4341758358d 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.test.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.test.ts @@ -3,6 +3,7 @@ import { DashboardJson } from 'app/features/manage-dashboards/types'; import { PluginDashboard } from 'app/types/plugins'; import { GnetDashboard } from '../types'; +import { createMockGnetDashboard, createMockPluginDashboard } from '../utils/test-utils'; import { fetchCommunityDashboard, @@ -14,8 +15,16 @@ import { jest.mock('@grafana/runtime', () => ({ getBackendSrv: jest.fn(), + reportInteraction: jest.fn(), })); +jest.mock('../interactions', () => ({ + ...jest.requireActual('../interactions'), + DashboardLibraryInteractions: { + ...jest.requireActual('../interactions').DashboardLibraryInteractions, + communityDashboardFiltered: jest.fn(), + }, +})); const mockGetBackendSrv = getBackendSrv as jest.MockedFunction; // Helper to create mock BackendSrv @@ -26,31 +35,9 @@ const createMockBackendSrv = (overrides: Partial = {}): BackendSrv = }) as unknown as BackendSrv; // Helper functions for creating mock objects -const createMockGnetDashboard = (overrides: Partial = {}): GnetDashboard => ({ - id: 1, - name: 'Test Dashboard', - description: 'Test Description', - downloads: 100, - datasource: 'Prometheus', - ...overrides, -}); - -const createMockPluginDashboard = (overrides: Partial = {}): PluginDashboard => ({ - dashboardId: 1, - uid: 'dash-uid', - title: 'Test Dashboard', - pluginId: 'prometheus', - path: 'dashboards/test.json', - description: 'Test plugin dashboard', - imported: false, - importedRevision: 0, - importedUri: '', - importedUrl: '', - removed: false, - revision: 1, - slug: 'test-dashboard', - ...overrides, -}); +const createMockGnetDashboardWithDownloads = (overrides: Partial = {}): GnetDashboard => { + return createMockGnetDashboard({ ...overrides, downloads: 10000 }); +}; const defaultFetchParams: FetchCommunityDashboardsParams = { orderBy: 'downloads', @@ -80,8 +67,54 @@ describe('dashboardLibraryApi', () => { }); describe('fetchCommunityDashboards', () => { + describe('filterNotSafeDashboards', () => { + it('should filter out dashboards with panel types that can contain JavaScript code', async () => { + const safeDashboard = createMockGnetDashboardWithDownloads({ id: 1 }); + const mockDashboards = [ + safeDashboard, + createMockGnetDashboardWithDownloads({ id: 2, panelTypeSlugs: ['ae3e-plotly-panel'] }), + ]; + const mockResponse = { + page: 1, + pages: 5, + items: mockDashboards, + }; + mockGet.mockResolvedValue(mockResponse); + + const result = await fetchCommunityDashboards(defaultFetchParams); + + expect(result).toEqual({ + page: 1, + pages: 5, + items: [safeDashboard], + }); + }); + + it('should filter out dashboards with low downloads', async () => { + const safeDashboard = createMockGnetDashboardWithDownloads({ id: 1 }); + const mockDashboards = [safeDashboard, createMockGnetDashboard({ id: 2, downloads: 999 })]; + const mockResponse = { + page: 1, + pages: 5, + items: mockDashboards, + }; + mockGet.mockResolvedValue(mockResponse); + + const result = await fetchCommunityDashboards(defaultFetchParams); + + expect(result).toEqual({ + page: 1, + pages: 5, + items: [safeDashboard], + }); + }); + }); + it('should fetch community dashboards with correct query parameters', async () => { - const mockDashboards = [createMockGnetDashboard({ id: 1 }), createMockGnetDashboard({ id: 2 })]; + const mockDashboards = [ + createMockGnetDashboardWithDownloads({ id: 1 }), + createMockGnetDashboardWithDownloads({ id: 2 }), + ]; const mockResponse = { page: 1, pages: 5, @@ -93,7 +126,7 @@ describe('dashboardLibraryApi', () => { const result = await fetchCommunityDashboards(defaultFetchParams); expect(mockGet).toHaveBeenCalledWith( - '/api/gnet/dashboards?orderBy=downloads&direction=desc&page=1&pageSize=10&includeLogo=1&includeScreenshots=true', + '/api/gnet/dashboards?orderBy=downloads&direction=desc&page=1&pageSize=10&includeLogo=1&includeScreenshots=true&includePanelTypeSlugs=true', undefined, undefined, { showErrorAlert: false } @@ -154,7 +187,7 @@ describe('dashboardLibraryApi', () => { }); it('should use fallback values when page/pages are missing', async () => { - const items = [createMockGnetDashboard()]; + const items = [createMockGnetDashboardWithDownloads()]; mockGet.mockResolvedValue({ items, diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts index ac74a089f66..3563033a33e 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts @@ -2,7 +2,35 @@ import { getBackendSrv } from '@grafana/runtime'; import { DashboardJson } from 'app/features/manage-dashboards/types'; import { PluginDashboard } from 'app/types/plugins'; -import { GnetDashboardsResponse, Link } from '../types'; +import { GnetDashboard, GnetDashboardsResponse, Link } from '../types'; + +/** + * Panel types that are known to allow JavaScript code execution. + * These panels are filtered out due to security concerns. + */ +const UNSAFE_PANEL_TYPE_SLUGS = [ + 'aceiot-svg-panel', + 'ae3e-plotly-panel', + 'gapit-htmlgraphics-panel', + 'marcusolsson-dynamictext-panel', + 'volkovlabs-echarts-panel', + 'volkovlabs-form-panel', +]; + +/** + * Minimum number of downloads required for a community dashboard to be shown as a suggestion. + * + * Rationale: + * - Dashboards with higher download counts have been vetted by a larger community + * - This acts as a heuristic for quality and trustworthiness + * - Reduces risk of malicious or poorly-maintained dashboards + * + * Trade-offs: + * - May filter out legitimate but less popular dashboards + * - Newer dashboards with good content but low download counts won't be shown + * - The threshold of 10,000 is somewhat arbitrary and may need tuning based on ecosystem growth + */ +const MIN_DOWNLOADS_FILTER = 10000; /** * Parameters for fetching community dashboards from Grafana.com @@ -56,6 +84,7 @@ export async function fetchCommunityDashboards( pageSize: params.pageSize.toString(), includeLogo: params.includeLogo ? '1' : '0', includeScreenshots: params.includeScreenshots ? 'true' : 'false', + includePanelTypeSlugs: 'true', }); if (params.dataSourceSlugIn) { @@ -69,13 +98,13 @@ export async function fetchCommunityDashboards( showErrorAlert: false, }); - // Grafana.com API returns format: { page: number, pages: number, items: GnetDashboard[] } - // We normalize it to use "dashboards" instead of "items" for consistency if (result && Array.isArray(result.items)) { + const dashboards = filterNonSafeDashboards(result.items); + return { page: result.page || params.page, pages: result.pages || 1, - items: result.items, + items: dashboards, }; } @@ -109,3 +138,20 @@ export async function fetchProvisionedDashboards(datasourceType: string): Promis return []; } } + +// We only show dashboards with at least MIN_DOWNLOADS_FILTER downloads +// They are already ordered by downloads amount +const filterNonSafeDashboards = (dashboards: GnetDashboard[]): GnetDashboard[] => { + return dashboards.filter((item: GnetDashboard) => { + const hasUnsafePanelTypes = item.panelTypeSlugs?.some((slug: string) => UNSAFE_PANEL_TYPE_SLUGS.includes(slug)); + const hasLowDownloads = typeof item.downloads === 'number' && item.downloads < MIN_DOWNLOADS_FILTER; + + if (hasUnsafePanelTypes || hasLowDownloads) { + console.warn( + `Community dashboard ${item.id} ${item.name} filtered out due to low downloads ${item.downloads} or panel types ${item.panelTypeSlugs?.join(', ')} that can embed JavaScript` + ); + return false; + } + return true; + }); +}; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts index 804ef885d61..079dab20b69 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts @@ -8,6 +8,7 @@ export const EVENT_LOCATIONS = { MODAL_PROVISIONED_TAB: 'suggested_dashboards_modal_provisioned_tab', MODAL_COMMUNITY_TAB: 'suggested_dashboards_modal_community_tab', BROWSE_DASHBOARDS_PAGE: 'browse_dashboards_page', + COMMUNITY_DASHBOARD_LOADED: 'community_dashboard_loaded', } as const; export const CONTENT_KINDS = { diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts index 784e4f2d924..ac50627398e 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts @@ -24,6 +24,7 @@ export interface GnetDashboard { id: number; name: string; description: string; + slug: string; downloads: number; datasource: string; screenshots?: Screenshot[]; @@ -38,6 +39,7 @@ export interface GnetDashboard { orgSlug?: string; userId?: number; userName?: string; + panelTypeSlugs?: string[]; } export interface GnetDashboardsResponse { diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.test.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.test.ts index 58e77b7d13f..8a4c2c3c695 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.test.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.test.ts @@ -11,7 +11,6 @@ import { InputMapping, tryAutoMapDatasources, parseConstantInputs } from './auto import { buildDashboardDetails, buildGrafanaComUrl, - createSlug, getLogoUrl, navigateToTemplate, onUseCommunityDashboard, @@ -27,6 +26,14 @@ jest.mock('./autoMapDatasources', () => ({ parseConstantInputs: jest.fn(), })); +jest.mock('../interactions', () => ({ + ...jest.requireActual('../interactions'), + DashboardLibraryInteractions: { + ...jest.requireActual('../interactions').DashboardLibraryInteractions, + communityDashboardFiltered: jest.fn(), + }, +})); + // Mock function references const mockFetchCommunityDashboard = fetchCommunityDashboard as jest.MockedFunction; const mockTryAutoMapDatasources = tryAutoMapDatasources as jest.MockedFunction; @@ -43,6 +50,7 @@ const createMockGnetDashboard = (overrides: Partial = {}): GnetDa publishedAt: '', updatedAt: '2025-11-05T16:55:41.000Z', downloads: 0, + slug: 'test-dashboard', ...overrides, }); @@ -61,25 +69,11 @@ const createMockDashboardJson = (overrides: Partial = {}): Dashbo }) as DashboardJson; describe('communityDashboardHelpers', () => { - describe('createSlug', () => { - it('should convert to lower case', () => { - expect(createSlug('Test')).toBe('test'); - }); - - it('should replace non-alphanumeric characters with hyphens', () => { - expect(createSlug('Test@#example')).toBe('test-example'); - }); - - it('should remove leading and trailing hyphens', () => { - expect(createSlug('-test-')).toBe('test'); - }); - }); - describe('buildGrafanaComUrl', () => { it('should build a valid URL', () => { const gnetDashboard = createMockGnetDashboard({ id: 1, - name: 'Test', + slug: 'test', }); expect(buildGrafanaComUrl(gnetDashboard)).toBe('https://grafana.com/grafana/dashboards/1-test/'); @@ -91,6 +85,7 @@ describe('communityDashboardHelpers', () => { const gnetDashboard = createMockGnetDashboard({ id: 1, name: 'Test', + slug: 'test', datasource: 'Test', orgName: 'Org', updatedAt: '2025-11-05T16:55:41.000Z', @@ -170,6 +165,10 @@ describe('communityDashboardHelpers', () => { }); describe('onUseCommunityDashboard', () => { + let consoleWarnSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + let locationServicePushSpy: jest.SpyInstance; + async function setup(options?: { dashboard?: Partial; dashboardJson?: Partial; @@ -206,7 +205,16 @@ describe('communityDashboardHelpers', () => { } beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + locationServicePushSpy = jest.spyOn(locationService, 'push').mockImplementation(); + }); + + afterEach(() => { jest.clearAllMocks(); + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + locationServicePushSpy.mockRestore(); }); it('should navigate directly when all datasources are auto-mapped and no constants', async () => { @@ -218,8 +226,8 @@ describe('communityDashboardHelpers', () => { }, }); - expect(locationService.push).toHaveBeenCalled(); - expect(locationService.push).toHaveBeenCalledWith( + expect(locationServicePushSpy).toHaveBeenCalled(); + expect(locationServicePushSpy).toHaveBeenCalledWith( expect.objectContaining({ pathname: expect.any(String), search: expect.stringContaining('gnetId=123'), @@ -249,7 +257,7 @@ describe('communityDashboardHelpers', () => { }); expect(mockOnShowMapping).toHaveBeenCalled(); - expect(locationService.push).not.toHaveBeenCalled(); + expect(locationServicePushSpy).not.toHaveBeenCalled(); expect(mockOnShowMapping).toHaveBeenCalledWith( expect.objectContaining({ dashboardName: 'Test Dashboard', @@ -281,7 +289,7 @@ describe('communityDashboardHelpers', () => { }); expect(mockOnShowMapping).toHaveBeenCalled(); - expect(locationService.push).not.toHaveBeenCalled(); + expect(locationServicePushSpy).not.toHaveBeenCalled(); expect(mockOnShowMapping).toHaveBeenCalledWith( expect.objectContaining({ dashboardName: 'Test Dashboard', @@ -294,17 +302,312 @@ describe('communityDashboardHelpers', () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); mockFetchCommunityDashboard.mockRejectedValue(new Error('API failed')); - await onUseCommunityDashboard({ - dashboard: createMockGnetDashboard(), - datasourceUid: 'test-ds-uid', - datasourceType: 'prometheus', - eventLocation: 'empty_dashboard', - }); + await expect( + onUseCommunityDashboard({ + dashboard: createMockGnetDashboard(), + datasourceUid: 'test-ds-uid', + datasourceType: 'prometheus', + eventLocation: 'empty_dashboard', + }) + ).rejects.toThrow('API failed'); expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); - expect(locationService.push).not.toHaveBeenCalled(); + expect(locationServicePushSpy).not.toHaveBeenCalled(); consoleErrorSpy.mockRestore(); }); + + describe('when the dashboard contains JavaScript code', () => { + it('should throw an error if the dashboard contains JavaScript code in options', async () => { + const dashboardJson = createMockDashboardJson({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + panels: [{ type: 'panel', options: { template: '{{ javascript:alert("XSS") }}' } } as any], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains JavaScript code in targets/queries', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: {}, + targets: [ + { + expr: 'function() { return bad(); }', + refId: 'A', + }, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains JavaScript code in transformations', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: {}, + transformations: [ + { + id: 'calculateField', + options: { + mode: 'binary', + binary: { + reducer: 'sum', + left: 'A', + right: 'B', + }, + replaceFields: false, + alias: 'function() { alert("XSS"); }', + }, + }, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains JavaScript code in fieldConfig', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: {}, + fieldConfig: { + defaults: { + custom: { + displayMode: 'function() { return "bad"; }', + }, + }, + overrides: [], + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains javascript: URLs in links', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: {}, + links: [ + { + title: 'Bad Link', + url: 'javascript:alert("XSS")', + targetBlank: false, + }, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains ', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains arrow functions', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: { + customCode: '() => { alert("XSS"); }', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains setTimeout or setInterval', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: { + handler: 'setTimeout(() => alert("XSS"), 1000)', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains suspicious key names like beforeRender', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: {}, + beforeRender: 'alert("XSS")', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains suspicious key names like afterRender', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: {}, + afterRender: 'alert("XSS")', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains suspicious key names like handler', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: {}, + handler: 'alert("XSS")', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains return statements', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: { + customLogic: 'function test() { return malicious(); }', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + + it('should throw an error if the dashboard contains event handlers like onclick', async () => { + const dashboardJson = createMockDashboardJson({ + panels: [ + { + type: 'panel', + options: { + html: '
Click me
', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ], + }); + + await expect(setup({ dashboardJson })).rejects.toThrow( + 'Community dashboard 123 "Test Dashboard" might contain JavaScript code' + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationServicePushSpy).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts index 44c579d27b5..05c20ee1d9f 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts @@ -1,5 +1,11 @@ +import { PanelModel } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { locationService } from '@grafana/runtime'; +import { notifyApp } from 'app/core/actions'; +import { createErrorNotification } from 'app/core/copy/appNotification'; import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers'; +import { DashboardJson } from 'app/features/manage-dashboards/types'; +import { dispatch } from 'app/types/store'; import { DASHBOARD_LIBRARY_ROUTES } from '../../types'; import { MappingContext } from '../SuggestedDashboardsModal'; @@ -9,6 +15,12 @@ import { GnetDashboard, Link } from '../types'; import { InputMapping, tryAutoMapDatasources, parseConstantInputs, isDataSourceInput } from './autoMapDatasources'; +// Constants for community dashboard pagination and API params +// We want to get the most 6 downloaded dashboards, but we first query 12 +// to be sure the next filters we apply to that list doesn not reduce it below 6 +export const COMMUNITY_PAGE_SIZE_QUERY = 12; +export const COMMUNITY_RESULT_SIZE = 6; + /** * Extract thumbnail URL from dashboard screenshots */ @@ -39,21 +51,11 @@ export function formatDate(dateString?: string): string { return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } -/** - * Create URL-friendly slug from dashboard name - */ -export function createSlug(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - /** * Build Grafana.com URL for a dashboard */ export function buildGrafanaComUrl(dashboard: GnetDashboard): string { - return `https://grafana.com/grafana/dashboards/${dashboard.id}-${createSlug(dashboard.name)}/`; + return `https://grafana.com/grafana/dashboards/${dashboard.id}-${dashboard.slug}/`; } /** @@ -121,12 +123,110 @@ interface UseCommunityDashboardParams { onShowMapping?: (context: MappingContext) => void; } +/** + * Check if a panel contains JavaScript code using heuristic pattern matching. + * + * IMPORTANT: This is a heuristic-based detection, not a perfect mechanism. + * + * Patterns checked: + * - HTML/Script tags: Direct XSS attack vectors + * - Event handlers: Common JS injection points (onclick, onload, etc.) + * - Function declarations: Actual executable code patterns + * - eval/Function constructor: Dynamic code execution + * - setTimeout/setInterval: Deferred code execution + * + * What we DON'T check: + * - Panel title and description are excluded (already sanitized by Grafana's rendering layer) + * - Only the panel's options and configuration are scanned + * + * @param panel - The panel model to check + * @returns true if the panel might contain JavaScript code, false otherwise + */ +function canPanelContainJS(panel: PanelModel): boolean { + // Create a copy of the panel without title and description, as they are already sanitized + // This reduces false positives while still checking all other properties for JavaScript code + const { title, description, ...panelWithoutSanitizedFields } = panel; + + let panelJson: string; + try { + panelJson = JSON.stringify(panelWithoutSanitizedFields); + } catch (e) { + console.warn('Failed to stringify panel', e); + return true; + } + + // Patterns that indicate actual JavaScript code in values + const valuePatterns = [ + /\s*\{[^}]*\breturn\b/, // Arrow function with return statement: () => { return ... } + /\beval\s*\(/i, // eval() calls + /\bnew\s+Function\s*\(/i, // new Function() constructor + /\bsetTimeout\s*\(/i, // setTimeout calls + /\bsetInterval\s*\(/i, // setInterval calls + ]; + + // Patterns for suspicious JSON keys that might indicate JS hooks + const keyPatterns = [ + /"on[a-zA-Z]+"\s*:/, // Event handlers as keys (both camelCase and lowercase): "onClick": or "onclick": + /"beforeRender"\s*:/i, // beforeRender hook as JSON key + /"afterRender"\s*:/i, // afterRender hook as JSON key + /"javascript"\s*:/i, // "javascript" as a key + /"customCode"\s*:/i, // Common pattern for custom code injection + /"script"\s*:/i, // "script" as a JSON key + /"handler"\s*:/i, // "handler" as a JSON key - common for event handlers + ]; + + const hasSuspiciousValue = valuePatterns.some((pattern) => { + if (pattern.test(panelJson)) { + console.warn('Panel contains JavaScript code in value'); + return true; + } + return false; + }); + + const hasSuspiciousKey = keyPatterns.some((pattern) => { + if (pattern.test(panelJson)) { + console.warn('Panel contains JavaScript code in key'); + return true; + } + return false; + }); + + return hasSuspiciousValue || hasSuspiciousKey; +} + +function isPanelModel(panel: unknown): panel is PanelModel { + if (!panel || typeof panel !== 'object') { + return false; + } + return 'options' in panel && 'type' in panel; +} + +/** + * Check if a dashboard contains JavaScript code. This is not a perfect check, but good enough + * Used as a second filter after the first filter of panel types (see api/dashboardLibraryApi.ts) + */ +const canDashboardContainJS = (dashboard: DashboardJson): boolean => { + return dashboard.panels?.some((panel) => { + // Skip library panels - they don't have options/type and are already validated + if (isPanelModel(panel)) { + return canPanelContainJS(panel); + } + return false; + }); +}; + /** * Handles the flow when a user selects a community dashboard: * 1. Tracks analytics * 2. Fetches full dashboard JSON with __inputs - * 3. Attempts auto-mapping of datasources - * 4. Either navigates directly or shows mapping form + * 3. Filters out dashboards that contain JavaScript code due to security reasons + * 4. Attempts auto-mapping of datasources + * 5. Either navigates directly or shows mapping form */ export async function onUseCommunityDashboard({ dashboard, @@ -142,6 +242,10 @@ export async function onUseCommunityDashboard({ const fullDashboard = await fetchCommunityDashboard(dashboard.id); const dashboardJson = fullDashboard.json; + if (canDashboardContainJS(dashboardJson)) { + throw new Error(`Community dashboard ${dashboard.id} "${dashboard.name}" might contain JavaScript code`); + } + // Parse datasource requirements from __inputs const dsInputs: DataSourceInput[] = dashboardJson.__inputs?.filter(isDataSourceInput) || []; @@ -199,6 +303,11 @@ export async function onUseCommunityDashboard({ } } catch (err) { console.error('Error loading community dashboard:', err); - // TODO: Show error notification + dispatch( + notifyApp( + createErrorNotification(t('dashboard-library.community-error-title', 'Error loading community dashboard')) + ) + ); + throw err; } } diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/test-utils.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/test-utils.ts new file mode 100644 index 00000000000..351fd4ab438 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/test-utils.ts @@ -0,0 +1,34 @@ +import { PluginDashboard } from 'app/types/plugins'; + +import { GnetDashboard } from '../types'; + +export const createMockPluginDashboard = (overrides: Partial = {}): PluginDashboard => ({ + dashboardId: 1, + uid: 'dash-uid', + title: 'Test Provisioned Dashboard', + description: 'Test plugin dashboard', + path: 'dashboards/test.json', + pluginId: 'prometheus', + imported: false, + importedRevision: 0, + importedUri: '', + importedUrl: '', + removed: false, + revision: 1, + slug: 'test-dashboard', + ...overrides, +}); + +export const createMockGnetDashboard = (overrides: Partial = {}): GnetDashboard => ({ + id: 123, + name: 'Test Dashboard', + description: 'Test description', + datasource: 'Prometheus', + orgName: 'Test Org', + userName: 'testuser', + publishedAt: '', + updatedAt: '', + downloads: 0, + slug: 'test-dashboard', + ...overrides, +}); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 99ed9b512a6..3883da6e44e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5799,7 +5799,8 @@ "community-empty-title": "No community dashboards found", "community-empty-title-with-datasource": "No {{datasourceType}} community dashboards found", "community-error": "Failed to load community dashboards. Please try again.", - "community-error-title": "Error loading community dashboards", + "community-error-description": "Failed to load community dashboard.", + "community-error-title": "Error loading community dashboard", "community-mapping-form": { "auto-mapped_one": "{{count}} datasources were automatically configured:", "auto-mapped_other": "{{count}} datasources were automatically configured:", From fccece3ca050a8f7f1c37818ef4eb185e9f9d9cf Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Tue, 6 Jan 2026 09:58:42 +0000 Subject: [PATCH 26/79] Refactor: Remove jQuery from AppWrapper (#115842) --- public/app/AppWrapper.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index d7e87d7f5c3..6b46365149f 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -57,7 +57,7 @@ export class AppWrapper extends Component { async componentDidMount() { this.setState({ ready: true }); - $('.preloader').remove(); + this.removePreloader(); // clear any old icon caches const cacheKeys = (await window.caches?.keys()) ?? []; @@ -68,6 +68,15 @@ export class AppWrapper extends Component { } } + removePreloader() { + const preloader = document.querySelector('.preloader'); + if (preloader) { + preloader.remove(); + } else { + console.warn('Preloader element not found'); + } + } + renderRoute = (route: RouteDescriptor) => { return ( Date: Tue, 6 Jan 2026 10:01:38 +0000 Subject: [PATCH 27/79] I18n: Download translations from Crowdin (#115860) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 14 ++++++++++++++ public/locales/de-DE/grafana.json | 14 ++++++++++++++ public/locales/es-ES/grafana.json | 14 ++++++++++++++ public/locales/fr-FR/grafana.json | 14 ++++++++++++++ public/locales/hu-HU/grafana.json | 14 ++++++++++++++ public/locales/id-ID/grafana.json | 14 ++++++++++++++ public/locales/it-IT/grafana.json | 14 ++++++++++++++ public/locales/ja-JP/grafana.json | 14 ++++++++++++++ public/locales/ko-KR/grafana.json | 14 ++++++++++++++ public/locales/nl-NL/grafana.json | 14 ++++++++++++++ public/locales/pl-PL/grafana.json | 14 ++++++++++++++ public/locales/pt-BR/grafana.json | 14 ++++++++++++++ public/locales/pt-PT/grafana.json | 14 ++++++++++++++ public/locales/ru-RU/grafana.json | 14 ++++++++++++++ public/locales/sv-SE/grafana.json | 14 ++++++++++++++ public/locales/tr-TR/grafana.json | 14 ++++++++++++++ public/locales/zh-Hans/grafana.json | 14 ++++++++++++++ public/locales/zh-Hant/grafana.json | 14 ++++++++++++++ 18 files changed, 252 insertions(+) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 39d34f617a9..b0b4979512a 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -3157,9 +3157,12 @@ "table": "Tabulka" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "Definujte podmínku, která musí být splněna před spuštěním pravidla výstrahy", "description-configure-firing-alert-instances-routed-contact": "Nakonfigurujte způsob přesměrování instancí spouštění výstrah do kontaktních bodů", "description-configure-receives-notifications": "Nakonfigurujte, kdo obdrží oznámení a jak jsou odesílána", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "Pravidla výstrah", "title-contact-points": "Kontaktní body", "title-notification-policies": "Zásady oznamování" @@ -14508,6 +14511,17 @@ "series-to-rows": "Řady na řádky" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Seřadit pole v rámci." diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index a2a5dcbb9b4..7fb0e91e7ca 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -3135,9 +3135,12 @@ "table": "Tabelle" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "Legen Sie die Bedingung fest, die erfüllt sein muss, bevor eine Warnregel ausgelöst wird", "description-configure-firing-alert-instances-routed-contact": "Konfigurieren Sie, wie ausgelöste Warnungsinstanzen an Kontaktpunkte weitergeleitet werden", "description-configure-receives-notifications": "Konfigurieren Sie, wer Benachrichtigungen erhält und wie sie gesendet werden", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "Warnregeln", "title-contact-points": "Kontaktpunkte", "title-notification-policies": "Benachrichtigungsrichtlinien" @@ -14396,6 +14399,17 @@ "series-to-rows": "Reihen zu Zeilen" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Sortieren Sie Felder in einem Frame." diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 9a9cce82f5f..511736e9fa1 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -3135,9 +3135,12 @@ "table": "Tabla" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "Define la condición que debe cumplirse antes de que se active una regla de alerta", "description-configure-firing-alert-instances-routed-contact": "Configurar cómo se enrutan las instancias de alerta de activación a los puntos de contacto", "description-configure-receives-notifications": "Configurar quién recibe las notificaciones y cómo se envían", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "Reglas de alerta", "title-contact-points": "Puntos de contacto", "title-notification-policies": "Políticas de notificación" @@ -14396,6 +14399,17 @@ "series-to-rows": "Series a filas" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Ordenar campos en un marco." diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 11411891f38..3a0d45af0e3 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -3135,9 +3135,12 @@ "table": "Tableau" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "Définir la condition qui doit être remplie avant qu’une règle d’alerte ne se déclenche", "description-configure-firing-alert-instances-routed-contact": "Configurer la façon dont les instances d’alerte de déclenchement sont acheminées vers les points de contact", "description-configure-receives-notifications": "Configurer les destinataires des notifications et la manière dont elles sont envoyées", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "Règles d'alerte", "title-contact-points": "Points de contact", "title-notification-policies": "Règles de notification" @@ -14396,6 +14399,17 @@ "series-to-rows": "Convertir la série en lignes" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Triez les champs dans une trame." diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 7a95606822e..545dc263a0a 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -3135,9 +3135,12 @@ "table": "Táblázat" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "Határozza meg azt a feltételt, amelynek teljesülnie kell, mielőtt egy riasztási szabály aktiválódik", "description-configure-firing-alert-instances-routed-contact": "Konfigurálja, hogyan történjen az aktív riasztáspéldányok továbbítása a kapcsolattartási pontokhoz", "description-configure-receives-notifications": "Állítsa be, hogy ki kapjon értesítéseket, és hogyan legyenek elküldve", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "Riasztási szabályok", "title-contact-points": "Kapcsolattartási pontok", "title-notification-policies": "Értesítési irányelvek" @@ -14396,6 +14399,17 @@ "series-to-rows": "Sorozatok sorokká alakítása" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Mezők rendezése egy keretben." diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 250ee7778c1..42b5e9c0a40 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -3124,9 +3124,12 @@ "table": "Tabel" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "Tentukan syarat yang harus dipenuhi sebelum aturan peringatan menyala", "description-configure-firing-alert-instances-routed-contact": "Konfigurasikan cara instans peringatan yang menyala dirutekan ke titik kontak", "description-configure-receives-notifications": "Konfigurasikan penerima pemberitahuan dan cara pemberitahuan dikirim", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "Aturan peringatan", "title-contact-points": "Titik kontak", "title-notification-policies": "Kebijakan pemberitahuan" @@ -14340,6 +14343,17 @@ "series-to-rows": "Deret ke baris" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Urutkan bidang dalam bingkai." diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 9db051b6aa6..c454be8c2e7 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -3135,9 +3135,12 @@ "table": "Tabella" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "Definisci la condizione che deve essere soddisfatta prima che venga attivata una regola di avviso", "description-configure-firing-alert-instances-routed-contact": "Configura il modo in cui le istanze di avviso attivate vengono instradate ai punti di contatto", "description-configure-receives-notifications": "Configura chi riceve le notifiche e come vengono inviate", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "Regole di avviso", "title-contact-points": "Punti di contatto", "title-notification-policies": "Politiche di notifica" @@ -14396,6 +14399,17 @@ "series-to-rows": "Serie a righe" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Ordina i campi in un frame." diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index c968ce753bc..75e65166c66 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -3124,9 +3124,12 @@ "table": "テーブル" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "アラートルールが発生される前に満たすべき条件を定義します", "description-configure-firing-alert-instances-routed-contact": "発生中のアラートインスタンスを連絡先にルーティングする方法を設定", "description-configure-receives-notifications": "通知の受信者と送信方法を設定", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "アラートルール", "title-contact-points": "コンタクトポイント", "title-notification-policies": "通知ポリシー" @@ -14340,6 +14343,17 @@ "series-to-rows": "系列を行に変換" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "フレーム内のフィールドを並べ替えます。" diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index f7c6e29f8f4..a521be3d291 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -3124,9 +3124,12 @@ "table": "표" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "경고 규칙이 발동되기 전에 충족되어야 하는 조건을 정의합니다", "description-configure-firing-alert-instances-routed-contact": "경고 발생 인스턴스가 연락처로 라우팅되는 방식을 구성합니다", "description-configure-receives-notifications": "알림을 받는 사람과 전송 방식을 구성합니다", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "경고 규칙", "title-contact-points": "연락처", "title-notification-policies": "알림 정책" @@ -14340,6 +14343,17 @@ "series-to-rows": "계열에서 행으로" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "프레임에서 필드를 정렬합니다." diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index e2c53a858f1..38ed302c7d5 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -3135,9 +3135,12 @@ "table": "Tabel" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "Definieer de voorwaarde waaraan moet worden voldaan voordat een waarschuwingsregel geactiveerd wordt", "description-configure-firing-alert-instances-routed-contact": "Configureer hoe geactiveerde waarschuwingsinstanties worden gerouteerd naar contactpunten", "description-configure-receives-notifications": "Configureer wie meldingen ontvangt en hoe deze worden verzonden", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "Waarschuwingsregels", "title-contact-points": "Contactpunten", "title-notification-policies": "Meldingsbeleid" @@ -14396,6 +14399,17 @@ "series-to-rows": "Reeksen naar rijen" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Velden in een frame sorteren." diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index d174debb2cb..e01bc75658a 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -3157,9 +3157,12 @@ "table": "Tabela" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "Zdefiniuj warunek, który musi zostać spełniony przed uruchomieniem reguły alertu", "description-configure-firing-alert-instances-routed-contact": "Skonfiguruj, w jaki sposób uruchamiane instancje alertów są kierowane do punktów kontaktu", "description-configure-receives-notifications": "Skonfiguruj, kto otrzymuje powiadomienia i jak są one wysyłane", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "Reguły alertu", "title-contact-points": "Punkty kontaktowe", "title-notification-policies": "Zasady powiadamiania" @@ -14508,6 +14511,17 @@ "series-to-rows": "Szeregi do wierszy" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Sortuj pola w ramce." diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 550ffe30a1f..763f391e7fd 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -3135,9 +3135,12 @@ "table": "Tabela" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "Defina a condição que deve ser atendida antes que uma regra de alerta seja acionada", "description-configure-firing-alert-instances-routed-contact": "Configure como as instâncias de alertas ativos são encaminhadas para os pontos de contato", "description-configure-receives-notifications": "Configure quem recebe notificações e como elas são enviadas", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "Regras de alerta", "title-contact-points": "Pontos de contato", "title-notification-policies": "Política de notificações" @@ -14396,6 +14399,17 @@ "series-to-rows": "Série para fileiras" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Classificar os campos em um quadro." diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 841d71ca037..4060c44f8a8 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -3135,9 +3135,12 @@ "table": "Tabela" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "Definir a condição que deve ser cumprida antes de uma regra de alerta ser acionada", "description-configure-firing-alert-instances-routed-contact": "Configurar como as instâncias de alerta de ativação são encaminhadas para os pontos de contacto", "description-configure-receives-notifications": "Configurar quem recebe notificações e como são enviadas", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "Regras de alerta", "title-contact-points": "Pontos de contacto", "title-notification-policies": "Políticas de notificação" @@ -14396,6 +14399,17 @@ "series-to-rows": "Série para linhas" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Ordenar campos num quadro." diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 19f0c4563d4..b4978ede2aa 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -3157,9 +3157,12 @@ "table": "Таблица" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "Задайте условие, которое должно быть выполнено до того, как автивируется правило оповещения.", "description-configure-firing-alert-instances-routed-contact": "Установите способ направления активных экземпляров оповещений в точки контакта.", "description-configure-receives-notifications": "Установите получателей уведомлений и способы их отправки.", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "Правила оповещения", "title-contact-points": "Точки контакта", "title-notification-policies": "Политики уведомления" @@ -14508,6 +14511,17 @@ "series-to-rows": "Ряды в строки" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Сортируйте поля в фрейме." diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 383543aed5e..556b41364b7 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -3135,9 +3135,12 @@ "table": "Tabell" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "Definiera villkoret som måste uppfyllas innan en larmregel utlöses", "description-configure-firing-alert-instances-routed-contact": "Konfigurera hur utlösta larminstanser dirigeras till kontaktpunkter", "description-configure-receives-notifications": "Konfigurera vem som tar emot aviseringar och hur de skickas", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "Varningsregler", "title-contact-points": "Kontaktpunkter", "title-notification-policies": "Aviseringspolicyer" @@ -14396,6 +14399,17 @@ "series-to-rows": "Serie till rader" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Sortera fält i en ram." diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index ff539a2a51f..3927c1ac81b 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -3135,9 +3135,12 @@ "table": "Tablo" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "Bir uyarı kuralının tetiklenmesi için karşılanması gereken koşulu tanımlayın", "description-configure-firing-alert-instances-routed-contact": "Tetiklenen uyarı örneklerinin iletişim noktalarına nasıl yönlendirileceğini yapılandırın", "description-configure-receives-notifications": "Bildirimlerin kime gönderileceğini ve nasıl gönderileceğini yapılandırın", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "Uyarı kuralları", "title-contact-points": "İletişim noktaları", "title-notification-policies": "Bildirim politikaları" @@ -14396,6 +14399,17 @@ "series-to-rows": "Seriden satırlara" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "Alanları bir çerçevede sıralayın." diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 0c3a6206bd7..6fbc093280b 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -3124,9 +3124,12 @@ "table": "表格" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "定义警报规则触发前必须满足的条件", "description-configure-firing-alert-instances-routed-contact": "配置如何将触发的警报实例路由到联络点", "description-configure-receives-notifications": "配置接收通知的人员以及通知发送方式", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "警报规则", "title-contact-points": "联络点", "title-notification-policies": "通知策略" @@ -14340,6 +14343,17 @@ "series-to-rows": "序列到行" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "对帧中的字段进行排序。" diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 215ad1f150f..0e4a7e461ba 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -3124,9 +3124,12 @@ "table": "表格" }, "welcome-header": { + "description-alert-activity": "", "description-alert-rules": "定義警報規則觸發前必須滿足的條件", "description-configure-firing-alert-instances-routed-contact": "設定如何將觸發的警報執行個體傳送至聯絡點", "description-configure-receives-notifications": "設定接收通知的對象以及傳送方式", + "href-text-alert-activity": "", + "title-alert-activity": "", "title-alert-rules": "警報規則", "title-contact-points": "聯絡點", "title-notification-policies": "通知政策" @@ -14340,6 +14343,17 @@ "series-to-rows": "將序列轉為列" } }, + "smoothing": { + "description": "", + "effective-resolution": "", + "effective-resolution-tooltip": "", + "is-applicable-description": "", + "name": "", + "resolution": { + "label": "", + "tooltip": "" + } + }, "sort-by-transformer-editor": { "description": { "sort-fields": "對框架中的欄位進行排序。" From 217427e072cde0a81641a0b2b7d7c893fce7f638 Mon Sep 17 00:00:00 2001 From: Peter Nguyen Date: Tue, 6 Jan 2026 02:29:51 -0800 Subject: [PATCH 28/79] Loki Language Provider: Add missing interpolation to fetchLabelsByLabelsEndpoint (#114608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Plugins: Implement bug fix for loki label selectors w/ variable interpolation * Chore: Add test to ensure result is interpolated --------- Co-authored-by: Zoltán Bedi --- .../datasource/loki/LanguageProvider.test.ts | 17 +++++++++++++++++ .../plugins/datasource/loki/LanguageProvider.ts | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/public/app/plugins/datasource/loki/LanguageProvider.test.ts b/public/app/plugins/datasource/loki/LanguageProvider.test.ts index 447911c70a5..fb2e483a888 100644 --- a/public/app/plugins/datasource/loki/LanguageProvider.test.ts +++ b/public/app/plugins/datasource/loki/LanguageProvider.test.ts @@ -560,6 +560,23 @@ describe('Language completion provider', () => { start: 1560153109000, }); }); + + it('should interpolate variables in stream selector', async () => { + const datasource = setup({}); + jest.spyOn(datasource, 'getTimeRangeParams').mockReturnValue({ start: 0, end: 1 }); + jest + .spyOn(datasource, 'interpolateString') + .mockImplementation((string: string) => string.replace(/\$test_var/g, 'age')); + + const languageProvider = new LanguageProvider(datasource); + languageProvider.request = jest.fn().mockResolvedValue([]); + await languageProvider.fetchLabels({ streamSelector: '{age="new", $test_var="new"}' }); + expect(languageProvider.request).toHaveBeenCalledWith('labels', { + end: 1, + query: '{age="new", age="new"}', + start: 0, + }); + }); }); it('should filter internal labels', async () => { diff --git a/public/app/plugins/datasource/loki/LanguageProvider.ts b/public/app/plugins/datasource/loki/LanguageProvider.ts index a6851e474d4..2670ee99dcb 100644 --- a/public/app/plugins/datasource/loki/LanguageProvider.ts +++ b/public/app/plugins/datasource/loki/LanguageProvider.ts @@ -175,7 +175,8 @@ export default class LokiLanguageProvider extends LanguageProvider { const { start, end } = this.datasource.getTimeRangeParams(range); const params: Record = { start, end }; if (options?.streamSelector && options?.streamSelector !== EMPTY_SELECTOR) { - params['query'] = options.streamSelector; + const interpolatedStreamSelector = this.datasource.interpolateString(options.streamSelector); + params['query'] = interpolatedStreamSelector; } const res = await this.request(url, params); if (Array.isArray(res)) { From 380154707b04b8fc2935c113e271caa9236429bb Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Tue, 6 Jan 2026 12:39:28 +0100 Subject: [PATCH 29/79] Alerting: Fix hyphen escaping in rule labels filter (#115869) --- pkg/services/ngalert/store/alert_rule_labels_test.go | 10 +++++----- pkg/services/ngalert/store/alert_rule_test.go | 4 ++-- pkg/services/ngalert/store/json.go | 6 +++--- pkg/services/ngalert/store/json_test.go | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pkg/services/ngalert/store/alert_rule_labels_test.go b/pkg/services/ngalert/store/alert_rule_labels_test.go index 1e6eb2b04e6..694189ffbc7 100644 --- a/pkg/services/ngalert/store/alert_rule_labels_test.go +++ b/pkg/services/ngalert/store/alert_rule_labels_test.go @@ -73,21 +73,21 @@ func TestBuildLabelMatcherJSON(t *testing.T) { name: "MySQL MatchEqual with non-empty value", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"}, - wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ?", + wantSQL: `JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"'))) = ?`, wantArgs: []any{"team", "alerting"}, }, { name: "MySQL MatchEqual with empty value", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""}, - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ? OR JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL)", + wantSQL: `(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"'))) = ? OR JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"')) IS NULL)`, wantArgs: []any{"team", "", "team"}, }, { name: "MySQL MatchNotEqual", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"}, - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) != ?)", + wantSQL: `(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"'))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"'))) != ?)`, wantArgs: []any{"team", "team", "alerting"}, }, { @@ -149,7 +149,7 @@ func TestBuildLabelKeyExistsCondition(t *testing.T) { dialect: migrator.NewMysqlDialect(), column: "labels", key: "__grafana_origin", - wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NOT NULL", + wantSQL: `JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"')) IS NOT NULL`, wantArgs: []any{"__grafana_origin"}, }, { @@ -194,7 +194,7 @@ func TestBuildLabelKeyMissingCondition(t *testing.T) { dialect: migrator.NewMysqlDialect(), column: "labels", key: "__grafana_origin", - wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL", + wantSQL: `JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"')) IS NULL`, wantArgs: []any{"__grafana_origin"}, }, { diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index e38a7052dff..cef60939924 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -2454,7 +2454,7 @@ func TestIntegration_ListAlertRules(t *testing.T) { ruleGen.WithLabels(map[string]string{"glob": "*[?]"}), ruleGen.WithTitle("rule_glob"))) ruleSpecialChars := createRule(t, store, ruleGen.With( - ruleGen.WithLabels(map[string]string{"json": "line1\nline2\\end\"quote"}), + ruleGen.WithLabels(map[string]string{"label-with-hyphen": "line1\nline2\\end\"quote"}), ruleGen.WithTitle("rule_special_chars"))) ruleEmpty := createRule(t, store, ruleGen.With( ruleGen.WithLabels(map[string]string{"empty": ""}), @@ -2531,7 +2531,7 @@ func TestIntegration_ListAlertRules(t *testing.T) { name: "JSON escape characters are handled correctly", labelMatchers: labels.Matchers{ func() *labels.Matcher { - m, _ := labels.NewMatcher(labels.MatchEqual, "json", "line1\nline2\\end\"quote") + m, _ := labels.NewMatcher(labels.MatchEqual, "label-with-hyphen", "line1\nline2\\end\"quote") return m }(), }, diff --git a/pkg/services/ngalert/store/json.go b/pkg/services/ngalert/store/json.go index ac38d2f4ca5..88e118b50d0 100644 --- a/pkg/services/ngalert/store/json.go +++ b/pkg/services/ngalert/store/json.go @@ -13,7 +13,7 @@ import ( func jsonEquals(dialect migrator.Dialect, column, key, value string) (string, []any) { switch dialect.DriverName() { case migrator.MySQL: - return fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?))) = ?", column), []any{key, value} + return fmt.Sprintf(`JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$."', ?, '"'))) = ?`, column), []any{key, value} case migrator.Postgres: return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) = ?", column), []any{key, value} default: @@ -25,7 +25,7 @@ func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string, var jx string switch dialect.DriverName() { case migrator.MySQL: - jx = fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)))", column) + jx = fmt.Sprintf(`JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$."', ?, '"')))`, column) case migrator.Postgres: jx = fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?)", column) default: @@ -49,7 +49,7 @@ func jsonKeyCondition(dialect migrator.Dialect, column, key string, exists bool) } switch dialect.DriverName() { case migrator.MySQL: - return fmt.Sprintf("JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)) %s", column, nullCheck), []any{key}, nil + return fmt.Sprintf(`JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$."', ?, '"')) %s`, column, nullCheck), []any{key}, nil case migrator.Postgres: return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) %s", column, nullCheck), []any{key}, nil default: diff --git a/pkg/services/ngalert/store/json_test.go b/pkg/services/ngalert/store/json_test.go index 93ca1531f61..3a790f81a3a 100644 --- a/pkg/services/ngalert/store/json_test.go +++ b/pkg/services/ngalert/store/json_test.go @@ -23,7 +23,7 @@ func TestJsonEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ?", + wantSQL: `JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"'))) = ?`, wantArgs: []any{"team", "alerting"}, }, { @@ -62,7 +62,7 @@ func TestJsonNotEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) != ?)", + wantSQL: `(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"'))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"'))) != ?)`, wantArgs: []any{"team", "team", "alerting"}, }, { @@ -99,7 +99,7 @@ func TestJsonKeyMissing(t *testing.T) { dialect: migrator.NewMysqlDialect(), column: "labels", key: "team", - wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL", + wantSQL: `JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"')) IS NULL`, wantArgs: []any{"team"}, }, { @@ -136,7 +136,7 @@ func TestJsonKeyExists(t *testing.T) { dialect: migrator.NewMysqlDialect(), column: "labels", key: "__grafana_origin", - wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NOT NULL", + wantSQL: `JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"')) IS NOT NULL`, wantArgs: []any{"__grafana_origin"}, }, { From 5fe192a893e24fe7350762795b3a66b07711cb28 Mon Sep 17 00:00:00 2001 From: Joe Elliott Date: Tue, 6 Jan 2026 07:47:52 -0500 Subject: [PATCH 30/79] Tempo: Fix multiple streaming TraceQL metrics queries being conflated into one (#114360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Correctly stream multiple metrics series Signed-off-by: Joe Elliott * cleanup Signed-off-by: Joe Elliott * prettier fix --------- Signed-off-by: Joe Elliott Co-authored-by: Andre Pereira Co-authored-by: Zoltán Bedi --- public/app/plugins/datasource/tempo/datasource.ts | 4 ++-- public/app/plugins/datasource/tempo/streaming.ts | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index bccd697c1db..67b7e708a19 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -811,7 +811,7 @@ export class TempoDatasource extends DataSourceWithBackend doTempoSearchStreaming( - { ...target, query }, + { ...target, query: this.applyVariables(target, options.scopedVars).query }, this, // the datasource options, this.instanceSettings @@ -857,7 +857,7 @@ export class TempoDatasource extends DataSourceWithBackend doTempoMetricsStreaming( - { ...target, query }, + { ...target, query: this.applyVariables(target, options.scopedVars).query }, this, // the datasource options ) diff --git a/public/app/plugins/datasource/tempo/streaming.ts b/public/app/plugins/datasource/tempo/streaming.ts index 3ea011d3185..ef72768a6d1 100644 --- a/public/app/plugins/datasource/tempo/streaming.ts +++ b/public/app/plugins/datasource/tempo/streaming.ts @@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid'; import { DataFrame, dataFrameFromJSON, + DataFrameJSON, DataQueryRequest, DataQueryResponse, DataSourceInstanceSettings, @@ -165,7 +166,15 @@ export function doTempoMetricsStreaming( } newResult = { - data: data?.map(dataFrameFromJSON) ?? [], + data: + data?.map((frame: DataFrameJSON) => { + const df = dataFrameFromJSON(frame); + // preserve the query's refId to prevent conflation of series from different queries + if (query.refId) { + df.refId = query.refId; + } + return df; + }) ?? [], state, }; } From 92464b2dc80ec5a7b5283bcf6745c1972f35d9ff Mon Sep 17 00:00:00 2001 From: Ayush Kaithwas Date: Tue, 6 Jan 2026 18:55:57 +0530 Subject: [PATCH 31/79] Dynamic Dashboards: Fix Content outline not being scrollable (#115827) Enhancement: Add ScrollContainer to DashboardOutline for improved scrolling experience --- .../dashboard-scene/edit-pane/DashboardOutline.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx index b543b04537c..d368de82c65 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx @@ -5,7 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { SceneObject } from '@grafana/scenes'; -import { Box, Icon, Sidebar, Text, useElementSelection, useStyles2 } from '@grafana/ui'; +import { Box, Icon, ScrollContainer, Sidebar, Text, useElementSelection, useStyles2 } from '@grafana/ui'; import { isRepeatCloneOrChildOf } from '../utils/clone'; import { DashboardInteractions } from '../utils/interactions'; @@ -24,12 +24,14 @@ export function DashboardOutline({ editPane, isEditing }: Props) { const dashboard = getDashboardSceneFor(editPane); return ( - <> + - - - - + + + + + + ); } From bbaf91ed9c4e2a34de5f3b582737a3375152a3f2 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Tue, 6 Jan 2026 15:25:36 +0100 Subject: [PATCH 32/79] Alerting: Move alerting RTKQ client to api-clients package (#114546) --- packages/grafana-alerting/package.json | 3 +- packages/grafana-alerting/scripts/README.md | 23 - packages/grafana-alerting/scripts/codegen.ts | 55 -- .../grafana/api/notifications/v0alpha1/api.ts | 18 - .../api/notifications/v0alpha1/const.ts | 2 - .../v0alpha1/mocks/fakes/Receivers.ts | 7 +- .../ReceiverHandlers/createReceiverHandler.ts | 10 +- .../ReceiverHandlers/deleteReceiverHandler.ts | 10 +- .../deletecollectionReceiverHandler.ts | 10 +- .../ReceiverHandlers/getReceiverHandler.ts | 10 +- .../ReceiverHandlers/listReceiverHandler.ts | 5 +- .../replaceReceiverHandler.ts | 10 +- .../ReceiverHandlers/updateReceiverHandler.ts | 10 +- .../api/notifications/v0alpha1/types.ts | 6 +- .../src/grafana/api/rules/v0alpha1/api.ts | 18 - .../src/grafana/api/rules/v0alpha1/const.ts | 2 - .../hooks/v0alpha1/useContactPoints.tsx | 31 +- .../src/grafana/contactPoints/utils.ts | 3 +- .../src/grafana/matchers/types.ts | 2 +- .../hooks/useMatchPolicies.test.ts | 10 +- .../hooks/useMatchPolicies.ts | 15 +- .../src/grafana/notificationPolicies/types.ts | 3 +- .../src/grafana/notificationPolicies/utils.ts | 3 +- packages/grafana-alerting/src/unstable.ts | 4 +- packages/grafana-alerting/tests/provider.tsx | 18 +- .../grafana-alerting/tests/test-utils.tsx | 9 +- packages/grafana-api-clients/package.json | 8 + .../src/clients/rtkq/index.ts | 6 + .../v0alpha1/baseAPI.ts | 16 + .../v0alpha1/endpoints.gen.ts} | 479 ++---------------- .../notifications.alerting/v0alpha1/index.ts | 5 + .../rtkq/rules.alerting/v0alpha1/baseAPI.ts | 16 + .../rules.alerting/v0alpha1/endpoints.gen.ts} | 36 +- .../rtkq/rules.alerting/v0alpha1/index.ts | 5 + packages/grafana-api-clients/src/index.ts | 3 + .../src/scripts/generate-rtk-apis.ts | 2 + .../src/utils/backendSrv.mock.ts | 46 ++ public/app/core/reducers/root.ts | 3 - .../rule-viewer/ContactPointLink.test.tsx | 6 + .../rule-viewer/tabs/Details.test.tsx | 2 + .../app/features/alerting/unified/mockApi.ts | 6 +- .../alerting/unified/utils/routeAdapter.ts | 3 +- public/app/store/configureStore.ts | 4 - yarn.lock | 4 +- 44 files changed, 324 insertions(+), 623 deletions(-) delete mode 100644 packages/grafana-alerting/scripts/README.md delete mode 100644 packages/grafana-alerting/scripts/codegen.ts delete mode 100644 packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/api.ts delete mode 100644 packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/const.ts delete mode 100644 packages/grafana-alerting/src/grafana/api/rules/v0alpha1/api.ts delete mode 100644 packages/grafana-alerting/src/grafana/api/rules/v0alpha1/const.ts create mode 100644 packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/baseAPI.ts rename packages/{grafana-alerting/src/grafana/api/notifications/v0alpha1/notifications.api.gen.ts => grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/endpoints.gen.ts} (82%) create mode 100644 packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/index.ts create mode 100644 packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/baseAPI.ts rename packages/{grafana-alerting/src/grafana/api/rules/v0alpha1/rules.api.gen.ts => grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/endpoints.gen.ts} (98%) create mode 100644 packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/index.ts create mode 100644 packages/grafana-api-clients/src/utils/backendSrv.mock.ts diff --git a/packages/grafana-alerting/package.json b/packages/grafana-alerting/package.json index d64f1c01893..10b6e54448b 100644 --- a/packages/grafana-alerting/package.json +++ b/packages/grafana-alerting/package.json @@ -58,14 +58,12 @@ "bundle": "rollup -c rollup.config.ts --configPlugin esbuild", "clean": "rimraf ./dist ./compiled ./unstable ./testing ./package.tgz", "typecheck": "tsc --emitDeclarationOnly false --noEmit", - "codegen": "rtk-query-codegen-openapi ./scripts/codegen.ts", "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js", "postpack": "mv package.json.bak package.json", "i18n-extract": "i18next-cli extract --sync-primary" }, "devDependencies": { "@grafana/test-utils": "workspace:*", - "@rtk-query/codegen-openapi": "^2.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -96,6 +94,7 @@ "dependencies": { "@emotion/css": "11.13.5", "@faker-js/faker": "^9.8.0", + "@grafana/api-clients": "12.4.0-pre", "@grafana/i18n": "12.4.0-pre", "@reduxjs/toolkit": "^2.9.0", "fishery": "^2.3.1", diff --git a/packages/grafana-alerting/scripts/README.md b/packages/grafana-alerting/scripts/README.md deleted file mode 100644 index 32730a22a13..00000000000 --- a/packages/grafana-alerting/scripts/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Re-generate the clients - -⚠️ This guide assumes the Backend definitions have been updated in `apps/alerting`. - -## Re-create OpenAPI specification - -Start with re-generating the OpenAPI snapshots by running the test in `pkg/tests/apis/openapi_test.go`. - -This will output the OpenAPI JSON spec file(s) in `pkg/tests/apis/openapi_snapshots`. - -## Process OpenAPI specifications - -Next up run the post-processing of the snapshots with `yarn run process-specs`, this will copy processed specifications to `./data/openapi/`. - -## Generate RTKQ files - -These files are built using the `yarn run codegen` command, make sure to run that in the Grafana Alerting package working directory. - -`yarn --cwd ./packages/grafana-alerting run codegen`. - -API clients will be written to `src/grafana/api//api.gen.ts`. - -Make sure to create a versioned API client for each API version – see `src/grafana/api/v0alpha1/api.ts` as an example. diff --git a/packages/grafana-alerting/scripts/codegen.ts b/packages/grafana-alerting/scripts/codegen.ts deleted file mode 100644 index 59c9a7e5cb8..00000000000 --- a/packages/grafana-alerting/scripts/codegen.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * This script will generate TypeScript type definitions and a RTKQ clients for the alerting k8s APIs. - * - * Run `yarn run codegen` from the "grafana-alerting" package to invoke this script. - * - * API clients will be placed in "src/grafana/api//api.gen.ts" - */ -import type { ConfigFile } from '@rtk-query/codegen-openapi'; - -// ℹ️ append API groups and versions here to generate additional API clients -const SPECS = [ - ['notifications.alerting.grafana.app', ['v0alpha1']], - ['rules.alerting.grafana.app', ['v0alpha1']], - // keep this in Grafana Enterprise - // ['alertenrichment.grafana.app', ['v1beta1']], -] as const; - -type OutputFile = Omit; -type OutputFiles = Record; - -const outputFiles = SPECS.reduce((groupAcc, [group, versions]) => { - return versions.reduce((versionAcc, version) => { - // Create a unique export name based on the group - const groupName = group.split('.')[0]; // e.g., 'notifications', 'rules', 'alertenrichment' - const exportName = `${groupName}API`; - - // ℹ️ these snapshots are generated by running "go test pkg/tests/apis/openapi_test.go" and "scripts/process-specs.ts", - // see the README in the "openapi_snapshots" directory - const schemaFile = `../../../data/openapi/${group}-${version}.json`; - - // ℹ️ make sure there is a API file in each versioned directory - const apiFile = `../src/grafana/api/${groupName}/${version}/api.ts`; - - // output each api client into a versioned directory with group-specific naming - const outputPath = `../src/grafana/api/${groupName}/${version}/${groupName}.api.gen.ts`; - - versionAcc[outputPath] = { - exportName, - schemaFile, - apiFile, - tag: true, // generate tags for cache invalidation - } satisfies OutputFile; - - return versionAcc; - }, groupAcc); -}, {}); - -export default { - // these are intentionally empty but will be set for each versioned config file - exportName: '', - schemaFile: '', - apiFile: '', - - outputFiles, -} satisfies ConfigFile; diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/api.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/api.ts deleted file mode 100644 index 5b45157954f..00000000000 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/api.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; - -import { getAPIBaseURL, getAPIReducerPath } from '../../util'; - -import { GROUP, VERSION } from './const'; - -const baseUrl = getAPIBaseURL(GROUP, VERSION); -const reducerPath = getAPIReducerPath(GROUP, VERSION); - -export const api = createApi({ - reducerPath, - baseQuery: fetchBaseQuery({ - // Set URL correctly so MSW can intercept requests - // https://mswjs.io/docs/runbook#rtk-query-requests-are-not-intercepted - baseUrl: new URL(baseUrl, globalThis.location.origin).href, - }), - endpoints: () => ({}), -}); diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/const.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/const.ts deleted file mode 100644 index a196bb9516e..00000000000 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/const.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const VERSION = 'v0alpha1' as const; -export const GROUP = 'notifications.alerting.grafana.app' as const; diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/fakes/Receivers.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/fakes/Receivers.ts index fbd88a5972d..693e2a05f30 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/fakes/Receivers.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/fakes/Receivers.ts @@ -1,8 +1,9 @@ import { faker } from '@faker-js/faker'; import { Factory } from 'fishery'; +import { API_GROUP, API_VERSION } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { DEFAULT_NAMESPACE, generateResourceVersion, generateTitle, generateUID } from '../../../../../mocks/util'; -import { GROUP, VERSION } from '../../const'; import { ContactPoint, ContactPointMetadataAnnotations, @@ -14,7 +15,7 @@ import { AlertingEntityMetadataAnnotationsFactory } from './common'; export const ListReceiverApiResponseFactory = Factory.define(() => ({ kind: 'ReceiverList', - apiVersion: `${GROUP}/${VERSION}`, + apiVersion: `${API_GROUP}/${API_VERSION}`, metadata: { resourceVersion: generateResourceVersion(), }, @@ -26,7 +27,7 @@ export const ContactPointFactory = Factory.define(() => { return { kind: 'Receiver', - apiVersion: `${GROUP}/${VERSION}`, + apiVersion: `${API_GROUP}/${API_VERSION}`, metadata: { name: btoa(title), namespace: DEFAULT_NAMESPACE, diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/createReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/createReceiverHandler.ts index 2c21893b6d3..7502d4da000 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/createReceiverHandler.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/createReceiverHandler.ts @@ -1,13 +1,17 @@ import { HttpResponse, http } from 'msw'; +import { + API_GROUP, + API_VERSION, + CreateReceiverApiResponse, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { getAPIBaseURLForMocks } from '../../../../../../mocks/util'; -import { CreateReceiverApiResponse } from '../../../../v0alpha1/notifications.api.gen'; -import { GROUP, VERSION } from '../../../const'; export function createReceiverHandler( data: CreateReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) ) { - return http.post(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers'), function handler(info) { + return http.post(getAPIBaseURLForMocks(API_GROUP, API_VERSION, '/receivers'), function handler(info) { if (typeof data === 'function') { return data(info); } diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deleteReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deleteReceiverHandler.ts index d3ff5c660f8..fc859d30043 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deleteReceiverHandler.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deleteReceiverHandler.ts @@ -1,13 +1,17 @@ import { HttpResponse, http } from 'msw'; +import { + API_GROUP, + API_VERSION, + DeleteReceiverApiResponse, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { getAPIBaseURLForMocks } from '../../../../../../mocks/util'; -import { DeleteReceiverApiResponse } from '../../../../v0alpha1/notifications.api.gen'; -import { GROUP, VERSION } from '../../../const'; export function deleteReceiverHandler( data: DeleteReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) ) { - return http.delete(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers/:name'), function handler(info) { + return http.delete(getAPIBaseURLForMocks(API_GROUP, API_VERSION, '/receivers/:name'), function handler(info) { if (typeof data === 'function') { return data(info); } diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deletecollectionReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deletecollectionReceiverHandler.ts index f3871112e66..27f68e67834 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deletecollectionReceiverHandler.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deletecollectionReceiverHandler.ts @@ -1,13 +1,17 @@ import { HttpResponse, http } from 'msw'; +import { + API_GROUP, + API_VERSION, + DeletecollectionReceiverApiResponse, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { getAPIBaseURLForMocks } from '../../../../../../mocks/util'; -import { DeletecollectionReceiverApiResponse } from '../../../../v0alpha1/notifications.api.gen'; -import { GROUP, VERSION } from '../../../const'; export function deletecollectionReceiverHandler( data: DeletecollectionReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) ) { - return http.delete(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers'), function handler(info) { + return http.delete(getAPIBaseURLForMocks(API_GROUP, API_VERSION, '/receivers'), function handler(info) { if (typeof data === 'function') { return data(info); } diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/getReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/getReceiverHandler.ts index d90d1fbecba..c7e533d47dc 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/getReceiverHandler.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/getReceiverHandler.ts @@ -1,13 +1,17 @@ import { HttpResponse, http } from 'msw'; +import { + API_GROUP, + API_VERSION, + GetReceiverApiResponse, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { getAPIBaseURLForMocks } from '../../../../../../mocks/util'; -import { GetReceiverApiResponse } from '../../../../v0alpha1/notifications.api.gen'; -import { GROUP, VERSION } from '../../../const'; export function getReceiverHandler( data: GetReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) ) { - return http.get(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers/:name'), function handler(info) { + return http.get(getAPIBaseURLForMocks(API_GROUP, API_VERSION, '/receivers/:name'), function handler(info) { if (typeof data === 'function') { return data(info); } diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/listReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/listReceiverHandler.ts index fde49565388..b6f577bcca5 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/listReceiverHandler.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/listReceiverHandler.ts @@ -1,13 +1,14 @@ import { HttpResponse, http } from 'msw'; +import { API_GROUP, API_VERSION } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { getAPIBaseURLForMocks } from '../../../../../../mocks/util'; -import { GROUP, VERSION } from '../../../const'; import { EnhancedListReceiverApiResponse } from '../../../types'; export function listReceiverHandler( data: EnhancedListReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) ) { - return http.get(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers'), function handler(info) { + return http.get(getAPIBaseURLForMocks(API_GROUP, API_VERSION, '/receivers'), function handler(info) { if (typeof data === 'function') { return data(info); } diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/replaceReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/replaceReceiverHandler.ts index c054de71882..1ed5bb7b50b 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/replaceReceiverHandler.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/replaceReceiverHandler.ts @@ -1,13 +1,17 @@ import { HttpResponse, http } from 'msw'; +import { + API_GROUP, + API_VERSION, + ReplaceReceiverApiResponse, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { getAPIBaseURLForMocks } from '../../../../../../mocks/util'; -import { ReplaceReceiverApiResponse } from '../../../../v0alpha1/notifications.api.gen'; -import { GROUP, VERSION } from '../../../const'; export function replaceReceiverHandler( data: ReplaceReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) ) { - return http.put(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers/:name'), function handler(info) { + return http.put(getAPIBaseURLForMocks(API_GROUP, API_VERSION, '/receivers/:name'), function handler(info) { if (typeof data === 'function') { return data(info); } diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/updateReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/updateReceiverHandler.ts index ce8498331cd..3b2c7536ec2 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/updateReceiverHandler.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/updateReceiverHandler.ts @@ -1,13 +1,17 @@ import { HttpResponse, http } from 'msw'; +import { + API_GROUP, + API_VERSION, + UpdateReceiverApiResponse, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { getAPIBaseURLForMocks } from '../../../../../../mocks/util'; -import { UpdateReceiverApiResponse } from '../../../../v0alpha1/notifications.api.gen'; -import { GROUP, VERSION } from '../../../const'; export function updateReceiverHandler( data: UpdateReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) ) { - return http.patch(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers/:name'), function handler(info) { + return http.patch(getAPIBaseURLForMocks(API_GROUP, API_VERSION, '/receivers/:name'), function handler(info) { if (typeof data === 'function') { return data(info); } diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/types.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/types.ts index 1212fc74b92..b5913c3ab38 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/types.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/types.ts @@ -3,7 +3,11 @@ */ import { MergeDeep, MergeExclusive, OverrideProperties } from 'type-fest'; -import type { ListReceiverApiResponse, Receiver, ReceiverIntegration } from './notifications.api.gen'; +import type { + ListReceiverApiResponse, + Receiver, + ReceiverIntegration, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; type GenericIntegration = OverrideProperties< ReceiverIntegration, diff --git a/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/api.ts b/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/api.ts deleted file mode 100644 index 5b45157954f..00000000000 --- a/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/api.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; - -import { getAPIBaseURL, getAPIReducerPath } from '../../util'; - -import { GROUP, VERSION } from './const'; - -const baseUrl = getAPIBaseURL(GROUP, VERSION); -const reducerPath = getAPIReducerPath(GROUP, VERSION); - -export const api = createApi({ - reducerPath, - baseQuery: fetchBaseQuery({ - // Set URL correctly so MSW can intercept requests - // https://mswjs.io/docs/runbook#rtk-query-requests-are-not-intercepted - baseUrl: new URL(baseUrl, globalThis.location.origin).href, - }), - endpoints: () => ({}), -}); diff --git a/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/const.ts b/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/const.ts deleted file mode 100644 index 823560db3fb..00000000000 --- a/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/const.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const VERSION = 'v0alpha1' as const; -export const GROUP = 'rules.alerting.grafana.app' as const; diff --git a/packages/grafana-alerting/src/grafana/contactPoints/hooks/v0alpha1/useContactPoints.tsx b/packages/grafana-alerting/src/grafana/contactPoints/hooks/v0alpha1/useContactPoints.tsx index 7b6ec11f2b2..60a661ac749 100644 --- a/packages/grafana-alerting/src/grafana/contactPoints/hooks/v0alpha1/useContactPoints.tsx +++ b/packages/grafana-alerting/src/grafana/contactPoints/hooks/v0alpha1/useContactPoints.tsx @@ -7,9 +7,10 @@ import { OverrideProperties } from 'type-fest'; import { CreateReceiverApiArg, - type ListReceiverApiArg, - notificationsAPI, -} from '../../../api/notifications/v0alpha1/notifications.api.gen'; + ListReceiverApiArg, + generatedAPI as notificationsAPIv0alpha1, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import type { ContactPoint, EnhancedListReceiverApiResponse } from '../../../api/notifications/v0alpha1/types'; // this is a workaround for the fact that the generated types are not narrow enough @@ -22,17 +23,17 @@ type ListContactPointsHookResult = TypedUseQueryHookResult< // Type for the options that can be passed to the hook // Based on the pattern used for mutation options in this file type ListContactPointsQueryArgs = Parameters< - typeof notificationsAPI.endpoints.listReceiver.useQuery + typeof notificationsAPIv0alpha1.endpoints.listReceiver.useQuery >[0]; type ListContactPointsQueryOptions = Parameters< - typeof notificationsAPI.endpoints.listReceiver.useQuery + typeof notificationsAPIv0alpha1.endpoints.listReceiver.useQuery >[1]; /** * useListContactPoints is a hook that fetches a list of contact points * - * This function wraps the notificationsAPI.useListReceiverQuery with proper typing + * This function wraps the notificationsAPIv0alpha1.useListReceiverQuery with proper typing * to ensure that the returned ContactPoints are correctly typed in the data.items array. * * It automatically uses the configured namespace for the query. @@ -43,8 +44,8 @@ type ListContactPointsQueryOptions = Parameters< export function useListContactPoints( queryArgs: ListContactPointsQueryArgs = {}, queryOptions: ListContactPointsQueryOptions = {} -) { - return notificationsAPI.useListReceiverQuery(queryArgs, queryOptions); +): ListContactPointsHookResult { + return notificationsAPIv0alpha1.useListReceiverQuery(queryArgs, queryOptions); } // type narrowing mutations requires us to define a few helper types @@ -60,7 +61,7 @@ type CreateContactPointMutation = TypedUseMutationResult< >; type UseCreateContactPointOptions = Parameters< - typeof notificationsAPI.endpoints.createReceiver.useMutation + typeof notificationsAPIv0alpha1.endpoints.createReceiver.useMutation >[0]; /** @@ -69,8 +70,16 @@ type UseCreateContactPointOptions = Parameters< * This function wraps the notificationsAPI.useCreateReceiverMutation with proper typing * to ensure that the payload supports type narrowing. */ -export function useCreateContactPoint(options?: UseCreateContactPointOptions) { - const [updateFn, result] = notificationsAPI.endpoints.createReceiver.useMutation(options); +export function useCreateContactPoint( + options?: UseCreateContactPointOptions +): readonly [ + ( + args: CreateContactPointArgs + ) => ReturnType[0]>, + ReturnType>[1], +] { + const [updateFn, result] = + notificationsAPIv0alpha1.endpoints.createReceiver.useMutation(options); const typedUpdateFn = (args: CreateContactPointArgs) => { // @ts-expect-error this one is just impossible for me to figure out diff --git a/packages/grafana-alerting/src/grafana/contactPoints/utils.ts b/packages/grafana-alerting/src/grafana/contactPoints/utils.ts index ac4242bd1b1..e6748c657ff 100644 --- a/packages/grafana-alerting/src/grafana/contactPoints/utils.ts +++ b/packages/grafana-alerting/src/grafana/contactPoints/utils.ts @@ -1,6 +1,7 @@ import { countBy, isEmpty } from 'lodash'; -import { Receiver } from '../api/notifications/v0alpha1/notifications.api.gen'; +import { Receiver } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { ContactPoint } from '../api/notifications/v0alpha1/types'; /** diff --git a/packages/grafana-alerting/src/grafana/matchers/types.ts b/packages/grafana-alerting/src/grafana/matchers/types.ts index 3cf3103fd1c..f2d20ace7e9 100644 --- a/packages/grafana-alerting/src/grafana/matchers/types.ts +++ b/packages/grafana-alerting/src/grafana/matchers/types.ts @@ -1,4 +1,4 @@ -import { RoutingTreeMatcher } from '../api/notifications/v0alpha1/notifications.api.gen'; +import { RoutingTreeMatcher } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; export type Label = [string, string]; diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.test.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.test.ts index dc650db546b..655b4571309 100644 --- a/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.test.ts +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.test.ts @@ -1,6 +1,6 @@ -import { VERSION } from '../../api/notifications/v0alpha1/const'; +import { API_VERSION, RoutingTree } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { LabelMatcherFactory, RouteFactory } from '../../api/notifications/v0alpha1/mocks/fakes/Routes'; -import { RoutingTree } from '../../api/notifications/v0alpha1/notifications.api.gen'; import { Label } from '../../matchers/types'; import { matchInstancesToRouteTrees } from './useMatchPolicies'; @@ -16,7 +16,7 @@ describe('matchInstancesToRouteTrees', () => { const trees: RoutingTree[] = [ { kind: 'RoutingTree', - apiVersion: VERSION, + apiVersion: API_VERSION, metadata: { name: treeName }, spec: { defaults: { @@ -24,7 +24,6 @@ describe('matchInstancesToRouteTrees', () => { }, routes: [route], }, - status: {}, }, ]; @@ -51,7 +50,7 @@ describe('matchInstancesToRouteTrees', () => { const trees: RoutingTree[] = [ { kind: 'RoutingTree', - apiVersion: VERSION, + apiVersion: API_VERSION, metadata: { name: treeName }, spec: { defaults: { @@ -59,7 +58,6 @@ describe('matchInstancesToRouteTrees', () => { }, routes: [route], }, - status: {}, }, ]; diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts index d23efaae967..305671601f7 100644 --- a/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts @@ -1,6 +1,10 @@ import { useCallback } from 'react'; -import { RoutingTree, notificationsAPI } from '../../api/notifications/v0alpha1/notifications.api.gen'; +import { + RoutingTree, + generatedAPI as notificationsAPIv0alpha1, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { Label } from '../../matchers/types'; import { USER_DEFINED_TREE_NAME } from '../consts'; import { Route, RouteWithID } from '../types'; @@ -24,6 +28,11 @@ export type InstanceMatchResult = { matchedRoutes: RouteMatch[]; }; +interface UseMatchInstancesToRouteTreesReturnType + extends ReturnType { + matchInstancesToRouteTrees: (instances: Label[][]) => InstanceMatchResult[]; +} + /** * React hook that finds notification policy routes in all routing trees that match the provided set of alert instances. * @@ -35,8 +44,8 @@ export type InstanceMatchResult = { * @returns An object containing a `matchInstancesToRoutingTrees` function that takes alert instances * and returns an array of InstanceMatchResult objects, each containing the matched routes and matching details */ -export function useMatchInstancesToRouteTrees() { - const { data, ...rest } = notificationsAPI.endpoints.listRoutingTree.useQuery( +export function useMatchInstancesToRouteTrees(): UseMatchInstancesToRouteTreesReturnType { + const { data, ...rest } = notificationsAPIv0alpha1.endpoints.listRoutingTree.useQuery( {}, { refetchOnFocus: true, diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/types.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/types.ts index 56465daf613..84f0e07d3b7 100644 --- a/packages/grafana-alerting/src/grafana/notificationPolicies/types.ts +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/types.ts @@ -1,6 +1,7 @@ import { OverrideProperties } from 'type-fest'; -import { RoutingTreeRoute } from '../api/notifications/v0alpha1/notifications.api.gen'; +import { RoutingTreeRoute } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { LabelMatcher } from '../matchers/types'; // type-narrow the route tree diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts index 1fc7608a76f..6f7fb7d2ade 100644 --- a/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts @@ -1,6 +1,7 @@ import { groupBy, isArray, pick, reduce, uniqueId } from 'lodash'; -import { RoutingTree, RoutingTreeRoute } from '../api/notifications/v0alpha1/notifications.api.gen'; +import { RoutingTree, RoutingTreeRoute } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { Label } from '../matchers/types'; import { LabelMatchDetails, matchLabels } from '../matchers/utils'; diff --git a/packages/grafana-alerting/src/unstable.ts b/packages/grafana-alerting/src/unstable.ts index e033b55437c..15bce11bebf 100644 --- a/packages/grafana-alerting/src/unstable.ts +++ b/packages/grafana-alerting/src/unstable.ts @@ -19,5 +19,5 @@ export { type LabelMatcher, type Label } from './grafana/matchers/types'; export { matchLabelsSet, matchLabels, isLabelMatch, type LabelMatchDetails } from './grafana/matchers/utils'; // API endpoints -export { notificationsAPI as notificationsAPIv0alpha1 } from './grafana/api/notifications/v0alpha1/notifications.api.gen'; -export { rulesAPI as rulesAPIv0alpha1 } from './grafana/api/rules/v0alpha1/rules.api.gen'; +export { generatedAPI as notificationsAPIv0alpha1 } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; +export { generatedAPI as rulesAPIv0alpha1 } from '@grafana/api-clients/rtkq/rules.alerting/v0alpha1'; diff --git a/packages/grafana-alerting/tests/provider.tsx b/packages/grafana-alerting/tests/provider.tsx index b3caa8679b2..7a3ac8bc241 100644 --- a/packages/grafana-alerting/tests/provider.tsx +++ b/packages/grafana-alerting/tests/provider.tsx @@ -2,13 +2,25 @@ import { configureStore } from '@reduxjs/toolkit'; import { useEffect } from 'react'; import { Provider } from 'react-redux'; -import { notificationsAPIv0alpha1 } from '../src/unstable'; +import { MockBackendSrv } from '@grafana/api-clients'; +import { generatedAPI as notificationsAPIv0alpha1 } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; +import { generatedAPI as rulesAPIv0alpha1 } from '@grafana/api-clients/rtkq/rules.alerting/v0alpha1'; +import { setBackendSrv } from '@grafana/runtime'; + +// Initialize BackendSrv for tests - this allows RTKQ to make HTTP requests +// The actual HTTP requests will be intercepted by MSW (setupMockServer) +// We only need to implement fetch() which is what RTKQ uses +// we could remove this once @grafana/api-client no longer uses the BackendSrv +// @ts-ignore +setBackendSrv(new MockBackendSrv()); // create an empty store -export const store = configureStore({ - middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(notificationsAPIv0alpha1.middleware), +export const store: ReturnType = configureStore({ + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(notificationsAPIv0alpha1.middleware).concat(rulesAPIv0alpha1.middleware), reducer: { [notificationsAPIv0alpha1.reducerPath]: notificationsAPIv0alpha1.reducer, + [rulesAPIv0alpha1.reducerPath]: rulesAPIv0alpha1.reducer, }, }); diff --git a/packages/grafana-alerting/tests/test-utils.tsx b/packages/grafana-alerting/tests/test-utils.tsx index e0f0e12ac9c..5c7de34c9c3 100644 --- a/packages/grafana-alerting/tests/test-utils.tsx +++ b/packages/grafana-alerting/tests/test-utils.tsx @@ -13,7 +13,14 @@ import '@testing-library/jest-dom'; * method which wraps the passed element in all of the necessary Providers, * so it can render correctly in the context of the application */ -const customRender = (ui: React.ReactNode, renderOptions: RenderOptions = {}) => { +const customRender = ( + ui: React.ReactNode, + renderOptions: RenderOptions = {} +): { + renderResult: ReturnType; + user: ReturnType; + store: typeof store; +} => { const user = userEvent.setup(); const Providers = renderOptions.wrapper || getDefaultWrapper(); diff --git a/packages/grafana-api-clients/package.json b/packages/grafana-api-clients/package.json index 39accb04535..1135df02dab 100644 --- a/packages/grafana-api-clients/package.json +++ b/packages/grafana-api-clients/package.json @@ -116,6 +116,14 @@ "import": "./dist/esm/clients/rtkq/shorturl/v1beta1/index.mjs", "require": "./dist/cjs/clients/rtkq/shorturl/v1beta1/index.cjs" }, + "./rtkq/notifications.alerting/v0alpha1": { + "import": "./src/clients/rtkq/notifications.alerting/v0alpha1/index.ts", + "require": "./src/clients/rtkq/notifications.alerting/v0alpha1/index.ts" + }, + "./rtkq/rules.alerting/v0alpha1": { + "import": "./src/clients/rtkq/rules.alerting/v0alpha1/index.ts", + "require": "./src/clients/rtkq/rules.alerting/v0alpha1/index.ts" + }, "./rtkq/historian.alerting/v0alpha1": { "@grafana-app/source": "./src/clients/rtkq/historian.alerting/v0alpha1/index.ts", "types": "./dist/types/clients/rtkq/historian.alerting/v0alpha1/index.d.ts", diff --git a/packages/grafana-api-clients/src/clients/rtkq/index.ts b/packages/grafana-api-clients/src/clients/rtkq/index.ts index 17a471862f8..f7e6e3772c9 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/index.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/index.ts @@ -10,10 +10,12 @@ import { generatedAPI as historianAlertingAPIv0alpha1 } from './historian.alerti import { generatedAPI as iamAPIv0alpha1 } from './iam/v0alpha1'; import { generatedAPI as logsdrilldownAPIv1alpha1 } from './logsdrilldown/v1alpha1'; import { generatedAPI as migrateToCloudAPI } from './migrate-to-cloud'; +import { generatedAPI as notificationsAlertingAPIv0alpha1 } from './notifications.alerting/v0alpha1'; import { generatedAPI as playlistAPIv0alpha1 } from './playlist/v0alpha1'; import { generatedAPI as preferencesUserAPI } from './preferences/user'; import { generatedAPI as preferencesAPIv1alpha1 } from './preferences/v1alpha1'; import { generatedAPI as provisioningAPIv0alpha1 } from './provisioning/v0alpha1'; +import { generatedAPI as rulesAlertingAPIv0alpha1 } from './rules.alerting/v0alpha1'; import { generatedAPI as shortURLAPIv1beta1 } from './shorturl/v1beta1'; import { generatedAPI as legacyUserAPI } from './user'; // PLOP_INJECT_IMPORT @@ -33,6 +35,8 @@ export const allMiddleware = [ shortURLAPIv1beta1.middleware, correlationsAPIv0alpha1.middleware, legacyUserAPI.middleware, + notificationsAlertingAPIv0alpha1.middleware, + rulesAlertingAPIv0alpha1.middleware, historianAlertingAPIv0alpha1.middleware, logsdrilldownAPIv1alpha1.middleware, // PLOP_INJECT_MIDDLEWARE @@ -53,6 +57,8 @@ export const allReducers = { [shortURLAPIv1beta1.reducerPath]: shortURLAPIv1beta1.reducer, [correlationsAPIv0alpha1.reducerPath]: correlationsAPIv0alpha1.reducer, [legacyUserAPI.reducerPath]: legacyUserAPI.reducer, + [notificationsAlertingAPIv0alpha1.reducerPath]: notificationsAlertingAPIv0alpha1.reducer, + [rulesAlertingAPIv0alpha1.reducerPath]: rulesAlertingAPIv0alpha1.reducer, [historianAlertingAPIv0alpha1.reducerPath]: historianAlertingAPIv0alpha1.reducer, [logsdrilldownAPIv1alpha1.reducerPath]: logsdrilldownAPIv1alpha1.reducer, // PLOP_INJECT_REDUCER diff --git a/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/baseAPI.ts b/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/baseAPI.ts new file mode 100644 index 00000000000..f302218e275 --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/baseAPI.ts @@ -0,0 +1,16 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; + +import { getAPIBaseURL } from '../../../../utils/utils'; +import { createBaseQuery } from '../../createBaseQuery'; + +export const API_GROUP = 'notifications.alerting.grafana.app' as const; +export const API_VERSION = 'v0alpha1' as const; +export const BASE_URL = getAPIBaseURL(API_GROUP, API_VERSION); + +export const api = createApi({ + reducerPath: 'notificationsAlertingAPIv0alpha1', + baseQuery: createBaseQuery({ + baseURL: BASE_URL, + }), + endpoints: () => ({}), +}); diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/notifications.api.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/endpoints.gen.ts similarity index 82% rename from packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/notifications.api.gen.ts rename to packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/endpoints.gen.ts index 66b145753a6..35301a132fc 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/notifications.api.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/endpoints.gen.ts @@ -1,4 +1,4 @@ -import { api } from './api'; +import { api } from './baseAPI'; export const addTagTypes = ['API Discovery', 'Receiver', 'RoutingTree', 'TemplateGroup', 'TimeInterval'] as const; const injectedRtkApi = api .enhanceEndpoints({ @@ -7,7 +7,7 @@ const injectedRtkApi = api .injectEndpoints({ endpoints: (build) => ({ getApiResources: build.query({ - query: () => ({ url: `/apis/notifications.alerting.grafana.app/v0alpha1/` }), + query: () => ({ url: `/` }), providesTags: ['API Discovery'], }), listReceiver: build.query({ @@ -119,44 +119,6 @@ const injectedRtkApi = api }), invalidatesTags: ['Receiver'], }), - getReceiverStatus: build.query({ - query: (queryArg) => ({ - url: `/receivers/${queryArg.name}/status`, - params: { - pretty: queryArg.pretty, - }, - }), - providesTags: ['Receiver'], - }), - replaceReceiverStatus: build.mutation({ - query: (queryArg) => ({ - url: `/receivers/${queryArg.name}/status`, - method: 'PUT', - body: queryArg.receiver, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - }, - }), - invalidatesTags: ['Receiver'], - }), - updateReceiverStatus: build.mutation({ - query: (queryArg) => ({ - url: `/receivers/${queryArg.name}/status`, - method: 'PATCH', - body: queryArg.patch, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - force: queryArg.force, - }, - }), - invalidatesTags: ['Receiver'], - }), listRoutingTree: build.query({ query: (queryArg) => ({ url: `/routingtrees`, @@ -269,44 +231,6 @@ const injectedRtkApi = api }), invalidatesTags: ['RoutingTree'], }), - getRoutingTreeStatus: build.query({ - query: (queryArg) => ({ - url: `/routingtrees/${queryArg.name}/status`, - params: { - pretty: queryArg.pretty, - }, - }), - providesTags: ['RoutingTree'], - }), - replaceRoutingTreeStatus: build.mutation({ - query: (queryArg) => ({ - url: `/routingtrees/${queryArg.name}/status`, - method: 'PUT', - body: queryArg.routingTree, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - }, - }), - invalidatesTags: ['RoutingTree'], - }), - updateRoutingTreeStatus: build.mutation({ - query: (queryArg) => ({ - url: `/routingtrees/${queryArg.name}/status`, - method: 'PATCH', - body: queryArg.patch, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - force: queryArg.force, - }, - }), - invalidatesTags: ['RoutingTree'], - }), listTemplateGroup: build.query({ query: (queryArg) => ({ url: `/templategroups`, @@ -419,47 +343,6 @@ const injectedRtkApi = api }), invalidatesTags: ['TemplateGroup'], }), - getTemplateGroupStatus: build.query({ - query: (queryArg) => ({ - url: `/templategroups/${queryArg.name}/status`, - params: { - pretty: queryArg.pretty, - }, - }), - providesTags: ['TemplateGroup'], - }), - replaceTemplateGroupStatus: build.mutation< - ReplaceTemplateGroupStatusApiResponse, - ReplaceTemplateGroupStatusApiArg - >({ - query: (queryArg) => ({ - url: `/templategroups/${queryArg.name}/status`, - method: 'PUT', - body: queryArg.templateGroup, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - }, - }), - invalidatesTags: ['TemplateGroup'], - }), - updateTemplateGroupStatus: build.mutation({ - query: (queryArg) => ({ - url: `/templategroups/${queryArg.name}/status`, - method: 'PATCH', - body: queryArg.patch, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - force: queryArg.force, - }, - }), - invalidatesTags: ['TemplateGroup'], - }), listTimeInterval: build.query({ query: (queryArg) => ({ url: `/timeintervals`, @@ -572,48 +455,10 @@ const injectedRtkApi = api }), invalidatesTags: ['TimeInterval'], }), - getTimeIntervalStatus: build.query({ - query: (queryArg) => ({ - url: `/timeintervals/${queryArg.name}/status`, - params: { - pretty: queryArg.pretty, - }, - }), - providesTags: ['TimeInterval'], - }), - replaceTimeIntervalStatus: build.mutation({ - query: (queryArg) => ({ - url: `/timeintervals/${queryArg.name}/status`, - method: 'PUT', - body: queryArg.timeInterval, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - }, - }), - invalidatesTags: ['TimeInterval'], - }), - updateTimeIntervalStatus: build.mutation({ - query: (queryArg) => ({ - url: `/timeintervals/${queryArg.name}/status`, - method: 'PATCH', - body: queryArg.patch, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - force: queryArg.force, - }, - }), - invalidatesTags: ['TimeInterval'], - }), }), overrideExisting: false, }); -export { injectedRtkApi as notificationsAPI }; +export { injectedRtkApi as generatedAPI }; export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList; export type GetApiResourcesApiArg = void; export type ListReceiverApiResponse = /** status 200 OK */ ReceiverList; @@ -781,43 +626,6 @@ export type UpdateReceiverApiArg = { force?: boolean; patch: Patch; }; -export type GetReceiverStatusApiResponse = /** status 200 OK */ Receiver; -export type GetReceiverStatusApiArg = { - /** name of the Receiver */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; -}; -export type ReplaceReceiverStatusApiResponse = /** status 200 OK */ Receiver | /** status 201 Created */ Receiver; -export type ReplaceReceiverStatusApiArg = { - /** name of the Receiver */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - receiver: Receiver; -}; -export type UpdateReceiverStatusApiResponse = /** status 200 OK */ Receiver | /** status 201 Created */ Receiver; -export type UpdateReceiverStatusApiArg = { - /** name of the Receiver */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ - force?: boolean; - patch: Patch; -}; export type ListRoutingTreeApiResponse = /** status 200 OK */ RoutingTreeList; export type ListRoutingTreeApiArg = { /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ @@ -983,47 +791,6 @@ export type UpdateRoutingTreeApiArg = { force?: boolean; patch: Patch; }; -export type GetRoutingTreeStatusApiResponse = /** status 200 OK */ RoutingTree; -export type GetRoutingTreeStatusApiArg = { - /** name of the RoutingTree */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; -}; -export type ReplaceRoutingTreeStatusApiResponse = /** status 200 OK */ - | RoutingTree - | /** status 201 Created */ RoutingTree; -export type ReplaceRoutingTreeStatusApiArg = { - /** name of the RoutingTree */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - routingTree: RoutingTree; -}; -export type UpdateRoutingTreeStatusApiResponse = /** status 200 OK */ - | RoutingTree - | /** status 201 Created */ RoutingTree; -export type UpdateRoutingTreeStatusApiArg = { - /** name of the RoutingTree */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ - force?: boolean; - patch: Patch; -}; export type ListTemplateGroupApiResponse = /** status 200 OK */ TemplateGroupList; export type ListTemplateGroupApiArg = { /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ @@ -1193,47 +960,6 @@ export type UpdateTemplateGroupApiArg = { force?: boolean; patch: Patch; }; -export type GetTemplateGroupStatusApiResponse = /** status 200 OK */ TemplateGroup; -export type GetTemplateGroupStatusApiArg = { - /** name of the TemplateGroup */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; -}; -export type ReplaceTemplateGroupStatusApiResponse = /** status 200 OK */ - | TemplateGroup - | /** status 201 Created */ TemplateGroup; -export type ReplaceTemplateGroupStatusApiArg = { - /** name of the TemplateGroup */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - templateGroup: TemplateGroup; -}; -export type UpdateTemplateGroupStatusApiResponse = /** status 200 OK */ - | TemplateGroup - | /** status 201 Created */ TemplateGroup; -export type UpdateTemplateGroupStatusApiArg = { - /** name of the TemplateGroup */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ - force?: boolean; - patch: Patch; -}; export type ListTimeIntervalApiResponse = /** status 200 OK */ TimeIntervalList; export type ListTimeIntervalApiArg = { /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ @@ -1399,47 +1125,6 @@ export type UpdateTimeIntervalApiArg = { force?: boolean; patch: Patch; }; -export type GetTimeIntervalStatusApiResponse = /** status 200 OK */ TimeInterval; -export type GetTimeIntervalStatusApiArg = { - /** name of the TimeInterval */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; -}; -export type ReplaceTimeIntervalStatusApiResponse = /** status 200 OK */ - | TimeInterval - | /** status 201 Created */ TimeInterval; -export type ReplaceTimeIntervalStatusApiArg = { - /** name of the TimeInterval */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - timeInterval: TimeInterval; -}; -export type UpdateTimeIntervalStatusApiResponse = /** status 200 OK */ - | TimeInterval - | /** status 201 Created */ TimeInterval; -export type UpdateTimeIntervalStatusApiArg = { - /** name of the TimeInterval */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ - force?: boolean; - patch: Patch; -}; export type ApiResource = { /** categories is a list of the grouped resources this resource belongs to (e.g. 'all') */ categories?: string[]; @@ -1572,34 +1257,6 @@ export type ReceiverSpec = { integrations: ReceiverIntegration[]; title: string; }; -export type ReceiverOperatorState = { - /** descriptiveState is an optional more descriptive state field which has no requirements on format */ - descriptiveState?: string; - /** details contains any extra information that is operator-specific */ - details?: { - [key: string]: { - [key: string]: any; - }; - }; - /** lastEvaluation is the ResourceVersion last evaluated */ - lastEvaluation: string; - /** state describes the state of the lastEvaluation. - It is limited to three possible states for machine evaluation. */ - state: 'success' | 'in_progress' | 'failed'; -}; -export type ReceiverStatus = { - /** additionalFields is reserved for future use */ - additionalFields?: { - [key: string]: { - [key: string]: any; - }; - }; - /** operatorStates is a map of operator ID to operator state evaluations. - Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ - operatorStates?: { - [key: string]: ReceiverOperatorState; - }; -}; export type Receiver = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion: string; @@ -1607,7 +1264,6 @@ export type Receiver = { kind: string; metadata: ObjectMeta; spec: ReceiverSpec; - status?: ReceiverStatus; }; export type ListMeta = { /** continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. */ @@ -1700,34 +1356,6 @@ export type RoutingTreeSpec = { defaults: RoutingTreeRouteDefaults; routes: RoutingTreeRoute[]; }; -export type RoutingTreeOperatorState = { - /** descriptiveState is an optional more descriptive state field which has no requirements on format */ - descriptiveState?: string; - /** details contains any extra information that is operator-specific */ - details?: { - [key: string]: { - [key: string]: any; - }; - }; - /** lastEvaluation is the ResourceVersion last evaluated */ - lastEvaluation: string; - /** state describes the state of the lastEvaluation. - It is limited to three possible states for machine evaluation. */ - state: 'success' | 'in_progress' | 'failed'; -}; -export type RoutingTreeStatus = { - /** additionalFields is reserved for future use */ - additionalFields?: { - [key: string]: { - [key: string]: any; - }; - }; - /** operatorStates is a map of operator ID to operator state evaluations. - Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ - operatorStates?: { - [key: string]: RoutingTreeOperatorState; - }; -}; export type RoutingTree = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion: string; @@ -1735,7 +1363,6 @@ export type RoutingTree = { kind: string; metadata: ObjectMeta; spec: RoutingTreeSpec; - status?: RoutingTreeStatus; }; export type RoutingTreeList = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ @@ -1745,38 +1372,12 @@ export type RoutingTreeList = { kind?: string; metadata: ListMeta; }; +export type TemplateGroupTemplateKind = 'grafana' | 'mimir'; export type TemplateGroupSpec = { content: string; + kind: TemplateGroupTemplateKind; title: string; }; -export type TemplateGroupOperatorState = { - /** descriptiveState is an optional more descriptive state field which has no requirements on format */ - descriptiveState?: string; - /** details contains any extra information that is operator-specific */ - details?: { - [key: string]: { - [key: string]: any; - }; - }; - /** lastEvaluation is the ResourceVersion last evaluated */ - lastEvaluation: string; - /** state describes the state of the lastEvaluation. - It is limited to three possible states for machine evaluation. */ - state: 'success' | 'in_progress' | 'failed'; -}; -export type TemplateGroupStatus = { - /** additionalFields is reserved for future use */ - additionalFields?: { - [key: string]: { - [key: string]: any; - }; - }; - /** operatorStates is a map of operator ID to operator state evaluations. - Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ - operatorStates?: { - [key: string]: TemplateGroupOperatorState; - }; -}; export type TemplateGroup = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion: string; @@ -1784,7 +1385,6 @@ export type TemplateGroup = { kind: string; metadata: ObjectMeta; spec: TemplateGroupSpec; - status?: TemplateGroupStatus; }; export type TemplateGroupList = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ @@ -1810,34 +1410,6 @@ export type TimeIntervalSpec = { name: string; time_intervals: TimeIntervalInterval[]; }; -export type TimeIntervalOperatorState = { - /** descriptiveState is an optional more descriptive state field which has no requirements on format */ - descriptiveState?: string; - /** details contains any extra information that is operator-specific */ - details?: { - [key: string]: { - [key: string]: any; - }; - }; - /** lastEvaluation is the ResourceVersion last evaluated */ - lastEvaluation: string; - /** state describes the state of the lastEvaluation. - It is limited to three possible states for machine evaluation. */ - state: 'success' | 'in_progress' | 'failed'; -}; -export type TimeIntervalStatus = { - /** additionalFields is reserved for future use */ - additionalFields?: { - [key: string]: { - [key: string]: any; - }; - }; - /** operatorStates is a map of operator ID to operator state evaluations. - Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ - operatorStates?: { - [key: string]: TimeIntervalOperatorState; - }; -}; export type TimeInterval = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion: string; @@ -1845,7 +1417,6 @@ export type TimeInterval = { kind: string; metadata: ObjectMeta; spec: TimeIntervalSpec; - status?: TimeIntervalStatus; }; export type TimeIntervalList = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ @@ -1855,3 +1426,43 @@ export type TimeIntervalList = { kind?: string; metadata: ListMeta; }; +export const { + useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, + useListReceiverQuery, + useLazyListReceiverQuery, + useCreateReceiverMutation, + useDeletecollectionReceiverMutation, + useGetReceiverQuery, + useLazyGetReceiverQuery, + useReplaceReceiverMutation, + useDeleteReceiverMutation, + useUpdateReceiverMutation, + useListRoutingTreeQuery, + useLazyListRoutingTreeQuery, + useCreateRoutingTreeMutation, + useDeletecollectionRoutingTreeMutation, + useGetRoutingTreeQuery, + useLazyGetRoutingTreeQuery, + useReplaceRoutingTreeMutation, + useDeleteRoutingTreeMutation, + useUpdateRoutingTreeMutation, + useListTemplateGroupQuery, + useLazyListTemplateGroupQuery, + useCreateTemplateGroupMutation, + useDeletecollectionTemplateGroupMutation, + useGetTemplateGroupQuery, + useLazyGetTemplateGroupQuery, + useReplaceTemplateGroupMutation, + useDeleteTemplateGroupMutation, + useUpdateTemplateGroupMutation, + useListTimeIntervalQuery, + useLazyListTimeIntervalQuery, + useCreateTimeIntervalMutation, + useDeletecollectionTimeIntervalMutation, + useGetTimeIntervalQuery, + useLazyGetTimeIntervalQuery, + useReplaceTimeIntervalMutation, + useDeleteTimeIntervalMutation, + useUpdateTimeIntervalMutation, +} = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/index.ts b/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/index.ts new file mode 100644 index 00000000000..d80fd6d553a --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/index.ts @@ -0,0 +1,5 @@ +export { BASE_URL, API_GROUP, API_VERSION } from './baseAPI'; +import { generatedAPI as rawAPI } from './endpoints.gen'; + +export * from './endpoints.gen'; +export const generatedAPI = rawAPI.enhanceEndpoints({}); diff --git a/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/baseAPI.ts b/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/baseAPI.ts new file mode 100644 index 00000000000..34762fa1b86 --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/baseAPI.ts @@ -0,0 +1,16 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; + +import { getAPIBaseURL } from '../../../../utils/utils'; +import { createBaseQuery } from '../../createBaseQuery'; + +export const API_GROUP = 'rules.alerting.grafana.app' as const; +export const API_VERSION = 'v0alpha1' as const; +export const BASE_URL = getAPIBaseURL(API_GROUP, API_VERSION); + +export const api = createApi({ + reducerPath: 'rulesAlertingAPIv0alpha1', + baseQuery: createBaseQuery({ + baseURL: BASE_URL, + }), + endpoints: () => ({}), +}); diff --git a/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/rules.api.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/endpoints.gen.ts similarity index 98% rename from packages/grafana-alerting/src/grafana/api/rules/v0alpha1/rules.api.gen.ts rename to packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/endpoints.gen.ts index 361362d5699..cfd62d68def 100644 --- a/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/rules.api.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/endpoints.gen.ts @@ -1,4 +1,4 @@ -import { api } from './api'; +import { api } from './baseAPI'; export const addTagTypes = ['API Discovery', 'AlertRule', 'RecordingRule'] as const; const injectedRtkApi = api .enhanceEndpoints({ @@ -7,7 +7,7 @@ const injectedRtkApi = api .injectEndpoints({ endpoints: (build) => ({ getApiResources: build.query({ - query: () => ({ url: `/apis/rules.alerting.grafana.app/v0alpha1/` }), + query: () => ({ url: `/` }), providesTags: ['API Discovery'], }), listAlertRule: build.query({ @@ -313,7 +313,7 @@ const injectedRtkApi = api }), overrideExisting: false, }); -export { injectedRtkApi as rulesAPI }; +export { injectedRtkApi as generatedAPI }; export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList; export type GetApiResourcesApiArg = void; export type ListAlertRuleApiResponse = /** status 200 OK */ AlertRuleList; @@ -1085,3 +1085,33 @@ export type RecordingRuleList = { kind?: string; metadata: ListMeta; }; +export const { + useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, + useListAlertRuleQuery, + useLazyListAlertRuleQuery, + useCreateAlertRuleMutation, + useDeletecollectionAlertRuleMutation, + useGetAlertRuleQuery, + useLazyGetAlertRuleQuery, + useReplaceAlertRuleMutation, + useDeleteAlertRuleMutation, + useUpdateAlertRuleMutation, + useGetAlertRuleStatusQuery, + useLazyGetAlertRuleStatusQuery, + useReplaceAlertRuleStatusMutation, + useUpdateAlertRuleStatusMutation, + useListRecordingRuleQuery, + useLazyListRecordingRuleQuery, + useCreateRecordingRuleMutation, + useDeletecollectionRecordingRuleMutation, + useGetRecordingRuleQuery, + useLazyGetRecordingRuleQuery, + useReplaceRecordingRuleMutation, + useDeleteRecordingRuleMutation, + useUpdateRecordingRuleMutation, + useGetRecordingRuleStatusQuery, + useLazyGetRecordingRuleStatusQuery, + useReplaceRecordingRuleStatusMutation, + useUpdateRecordingRuleStatusMutation, +} = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/index.ts b/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/index.ts new file mode 100644 index 00000000000..d80fd6d553a --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/index.ts @@ -0,0 +1,5 @@ +export { BASE_URL, API_GROUP, API_VERSION } from './baseAPI'; +import { generatedAPI as rawAPI } from './endpoints.gen'; + +export * from './endpoints.gen'; +export const generatedAPI = rawAPI.enhanceEndpoints({}); diff --git a/packages/grafana-api-clients/src/index.ts b/packages/grafana-api-clients/src/index.ts index 96e34be4f87..3fb772884a1 100644 --- a/packages/grafana-api-clients/src/index.ts +++ b/packages/grafana-api-clients/src/index.ts @@ -1 +1,4 @@ export { getAPINamespace, getAPIBaseURL, normalizeError, handleRequestError } from './utils/utils'; + +/* @TODO figure out how to automatically set the MockBackendSrv when consumers of this package write tests using the exported clients */ +export { MockBackendSrv } from './utils/backendSrv.mock'; diff --git a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts index a353b4e311f..5f529bfbf3d 100644 --- a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts +++ b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts @@ -108,6 +108,8 @@ const config: ConfigFile = { ...createAPIConfig('preferences', 'v1alpha1'), ...createAPIConfig('provisioning', 'v0alpha1'), ...createAPIConfig('shorturl', 'v1beta1'), + ...createAPIConfig('notifications.alerting', 'v0alpha1'), + ...createAPIConfig('rules.alerting', 'v0alpha1'), ...createAPIConfig('historian.alerting', 'v0alpha1'), ...createAPIConfig('logsdrilldown', 'v1alpha1'), // PLOP_INJECT_API_CLIENT - Used by the API client generator diff --git a/packages/grafana-api-clients/src/utils/backendSrv.mock.ts b/packages/grafana-api-clients/src/utils/backendSrv.mock.ts new file mode 100644 index 00000000000..ae7f1129913 --- /dev/null +++ b/packages/grafana-api-clients/src/utils/backendSrv.mock.ts @@ -0,0 +1,46 @@ +import { Observable } from 'rxjs'; +import { fromFetch } from 'rxjs/fetch'; + +import { BackendSrv, BackendSrvRequest, FetchResponse } from '@grafana/runtime'; + +/** + * Minimal mock implementation of BackendSrv for testing. + * Only implements the fetch() method which is used by RTKQ. + * HTTP requests are intercepted by MSW in tests. + */ +export class MockBackendSrv implements Partial { + fetch(options: BackendSrvRequest): Observable> { + const init: RequestInit = { + method: options.method || 'GET', + headers: options.headers, + body: options.data ? JSON.stringify(options.data) : undefined, + credentials: options.credentials, + signal: options.abortSignal, + }; + + return new Observable((observer) => { + fromFetch(options.url, init).subscribe({ + next: async (response) => { + try { + const data = await response.json(); + observer.next({ + data, + status: response.status, + statusText: response.statusText, + ok: response.ok, + headers: response.headers, + redirected: response.redirected, + type: response.type, + url: response.url, + config: options, + }); + observer.complete(); + } catch (error) { + observer.error(error); + } + }, + error: (error) => observer.error(error), + }); + }); + } +} diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index b73e12e2f22..d22835ac69e 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -1,7 +1,6 @@ import { ReducersMapObject } from '@reduxjs/toolkit'; import { AnyAction, combineReducers } from 'redux'; -import { notificationsAPIv0alpha1, rulesAPIv0alpha1 } from '@grafana/alerting/unstable'; import { allReducers as allApiClientReducers } from '@grafana/api-clients/rtkq'; import { generatedAPI as legacyAPI } from '@grafana/api-clients/rtkq/legacy'; import sharedReducers from 'app/core/reducers'; @@ -51,8 +50,6 @@ const rootReducers = { [legacyAPI.reducerPath]: legacyAPI.reducer, plugins: pluginsReducer, [alertingApi.reducerPath]: alertingApi.reducer, - [notificationsAPIv0alpha1.reducerPath]: notificationsAPIv0alpha1.reducer, - [rulesAPIv0alpha1.reducerPath]: rulesAPIv0alpha1.reducer, [publicDashboardApi.reducerPath]: publicDashboardApi.reducer, [browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer, ...allApiClientReducers, diff --git a/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.test.tsx index 634a3e6b809..e1e7aa8df4a 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/ContactPointLink.test.tsx @@ -2,6 +2,8 @@ import { render, screen } from 'test/test-utils'; import { setupMockServer } from '@grafana/test-utils/server'; +import { setupBackendSrv } from '../../mockApi'; + import { ContactPointLink } from './ContactPointLink'; import { RECEIVER_NAME, @@ -11,6 +13,10 @@ import { const server = setupMockServer(); +beforeAll(() => { + setupBackendSrv(); +}); + describe('render contact point link', () => { it('should render correctly', async () => { server.use(...listContactPointsScenario); diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.test.tsx index c445eaa027b..7e1b3826080 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from 'test/test-utils'; import { setupMockServer } from '@grafana/test-utils/server'; +import { setupBackendSrv } from '../../../mockApi'; import { mockCombinedRule } from '../../../mocks'; import { alertingFactory } from '../../../mocks/server/db'; import { setupDataSources } from '../../../testSetup/datasources'; @@ -12,6 +13,7 @@ import { Details } from './Details'; const server = setupMockServer(); beforeAll(() => { + setupBackendSrv(); setupDataSources(); }); diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts index 67053ade5f6..f89e0a18098 100644 --- a/public/app/features/alerting/unified/mockApi.ts +++ b/public/app/features/alerting/unified/mockApi.ts @@ -242,6 +242,10 @@ export function mockDashboardApi(server: SetupServer) { }; } +export function setupBackendSrv() { + setBackendSrv(backendSrv); +} + /** * Sets up MSW server with additional handlers for Alerting tests */ @@ -249,7 +253,7 @@ export function setupMswServer() { setupMockServer(allHandlers); beforeAll(() => { - setBackendSrv(backendSrv); + setupBackendSrv(); }); afterEach(() => { diff --git a/public/app/features/alerting/unified/utils/routeAdapter.ts b/public/app/features/alerting/unified/utils/routeAdapter.ts index c5dda65a3fa..4d9f5c2fd0b 100644 --- a/public/app/features/alerting/unified/utils/routeAdapter.ts +++ b/public/app/features/alerting/unified/utils/routeAdapter.ts @@ -143,8 +143,7 @@ function convertToMatcherOperator(type: LabelMatcher['type']): MatcherOperator { case '!~': return MatcherOperator.notRegex; default: - const exhaustiveCheck: never = type; - throw new Error(`Unknown matcher type: ${exhaustiveCheck}`); + throw new Error(`Unknown matcher type: ${type}`); } } diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 34a5500268e..3b7551c2766 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -2,7 +2,6 @@ import { configureStore as reduxConfigureStore, createListenerMiddleware } from import { setupListeners } from '@reduxjs/toolkit/query'; import { Middleware } from 'redux'; -import { notificationsAPIv0alpha1, rulesAPIv0alpha1 } from '@grafana/alerting/unstable'; import { allMiddleware as allApiClientMiddleware } from '@grafana/api-clients/rtkq'; import { legacyAPI } from 'app/api/clients/legacy'; import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; @@ -37,9 +36,6 @@ export function configureStore(initialState?: Partial) { listenerMiddleware.middleware, // older internal alerting API client alertingApi.middleware, - // @grafana/alerting clients for managing (Alertmanager) notification entities and rules - notificationsAPIv0alpha1.middleware, - rulesAPIv0alpha1.middleware, // other Grafana core APIs publicDashboardApi.middleware, browseDashboardsAPI.middleware, diff --git a/yarn.lock b/yarn.lock index afa76953435..e8d36b659dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3006,10 +3006,10 @@ __metadata: dependencies: "@emotion/css": "npm:11.13.5" "@faker-js/faker": "npm:^9.8.0" + "@grafana/api-clients": "npm:12.4.0-pre" "@grafana/i18n": "npm:12.4.0-pre" "@grafana/test-utils": "workspace:*" "@reduxjs/toolkit": "npm:^2.9.0" - "@rtk-query/codegen-openapi": "npm:^2.0.0" "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/react": "npm:^16.3.0" "@testing-library/user-event": "npm:^14.6.1" @@ -3041,7 +3041,7 @@ __metadata: languageName: unknown linkType: soft -"@grafana/api-clients@workspace:*, @grafana/api-clients@workspace:packages/grafana-api-clients": +"@grafana/api-clients@npm:12.4.0-pre, @grafana/api-clients@workspace:*, @grafana/api-clients@workspace:packages/grafana-api-clients": version: 0.0.0-use.local resolution: "@grafana/api-clients@workspace:packages/grafana-api-clients" dependencies: From 7698970f22190653a0ea10d6d806c8df7a39c0a5 Mon Sep 17 00:00:00 2001 From: Bruno Date: Tue, 6 Jan 2026 11:30:04 -0300 Subject: [PATCH 33/79] Secrets: changes to allow a 3rd party keeper / secret references (#115156) * Secrets: changes to allow a 3rd party keeper / secret references * fix test * make gofmt * lint * fix tests * assign aws secrets manager to @grafana/grafana-operator-experience-squad * rename Keeper.Reference to Keeper.RetrieveReference * rename ModelSecretsManager to ModelAWSSecretsManager * validator: ensure that only one of keeper.Spec.Aws.AccessKey or keeper.Spec.Aws.AssumeRole are set * move secrets manager dep / go mod tidy * move secrets manager dep * keeper validator: move 3rd party secret stores validation to their own functions * add github.com/aws/aws-sdk-go-v2/service/secretsmanager pkg/extensions/enterprise_imports * make update-workspace * undo go.mod changes in /apps * make update-workspace * fix test * add github.com/aws/aws-sdk-go-v2/service/secretsmanager to enterprise_imports * make update-workspace * gcworker: handle refs * make update-workspace * create toggle: FeatureStageExperimental * allow features.IsEnabled for now * format --- apps/advisor/go.mod | 8 +- apps/advisor/go.sum | 16 +- apps/iam/go.mod | 8 +- apps/iam/go.sum | 20 +- apps/plugins/go.mod | 8 +- apps/plugins/go.sum | 16 +- apps/secret/kinds/v1beta1/keeper.cue | 15 +- .../apis/secret/v1beta1/keeper_spec_gen.go | 31 +- .../pkg/apis/secret/v1beta1/keeper_type.go | 25 +- .../pkg/apis/secret/v1beta1/zz_openapi_gen.go | 67 ++- go.mod | 9 +- go.sum | 18 +- go.work.sum | 4 + .../src/types/featureToggles.gen.ts | 4 + pkg/extensions/enterprise_imports.go | 1 + pkg/registry/apis/secret/contracts/keeper.go | 7 +- .../apis/secret/contracts/secure_value.go | 6 +- .../secret/garbagecollectionworker/worker.go | 9 +- .../garbagecollectionworker/worker_test.go | 162 +++---- .../secret/secretkeeper/sqlkeeper/keeper.go | 28 +- .../secretkeeper/sqlkeeper/keeper_test.go | 27 +- .../apis/secret/service/secure_value.go | 51 +- .../apis/secret/service/secure_value_test.go | 146 ++++++ .../apis/secret/testutils/generators.go | 96 ++++ .../apis/secret/testutils/model_gsm.go | 321 ++++++++++++ .../apis/secret/testutils/testutils.go | 165 ++++++- pkg/registry/apis/secret/validator/keeper.go | 134 +++-- .../apis/secret/validator/keeper_test.go | 66 ++- pkg/services/featuremgmt/registry.go | 8 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 16 + pkg/setting/setting_secrets_manager.go | 7 + pkg/storage/secret/metadata/decrypt_store.go | 8 + .../secret/metadata/decrypt_store_test.go | 62 +++ pkg/storage/secret/metadata/keeper_model.go | 50 +- pkg/storage/secret/metadata/keeper_store.go | 13 +- .../secret/metadata/keeper_store_test.go | 46 +- .../metadata/secure_value_store_test.go | 27 +- .../secret/metadata/secure_value_test.go | 457 +++++------------- 40 files changed, 1485 insertions(+), 682 deletions(-) create mode 100644 pkg/registry/apis/secret/testutils/generators.go create mode 100644 pkg/registry/apis/secret/testutils/model_gsm.go diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 314726c5ecb..8200cad9e13 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -68,14 +68,14 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/at-wat/mqtt-go v0.19.6 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect - github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect - github.com/aws/smithy-go v1.23.1 // indirect + github.com/aws/smithy-go v1.23.2 // indirect github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 112228d6ed8..3f15ad1534c 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -173,8 +173,8 @@ github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= -github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= +github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY= github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= @@ -185,10 +185,10 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQU github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs= @@ -209,8 +209,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= -github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= -github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index aed406c5434..d9e0db56519 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -106,14 +106,14 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/at-wat/mqtt-go v0.19.6 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect - github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect @@ -124,7 +124,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect - github.com/aws/smithy-go v1.23.1 // indirect + github.com/aws/smithy-go v1.23.2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/benbjohnson/clock v1.3.5 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 35997e0d1ec..8ddbe4d3b9f 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -238,8 +238,8 @@ github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= -github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= +github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY= github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= @@ -250,10 +250,10 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQU github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs= @@ -280,14 +280,16 @@ github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 h1:Pwbxovp github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6/go.mod h1:Z4xLt5mXspLKjBV92i165wAJ/3T6TIv4n7RtIS8pWV0= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZgtAXTND1Kk9OXg= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 h1:w6a0H79HrHf3lr+zrw+pSzR5B+caiQFAKiNHlrUcnoc= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1/go.mod h1:c6Vg0BRiU7v0MVhHupw90RyL120QBwAMLbDCzptGeMk= github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= -github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= -github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 h1:60m4tnanN1ctzIu4V3bfCNJ39BiOPSm1gHFlFjTkRE0= github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= @@ -2321,6 +2323,8 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index 0b9ba53e76a..d2e00f4e823 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -30,14 +30,14 @@ require ( github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apache/arrow-go/v18 v18.4.1 // indirect github.com/armon/go-metrics v0.4.1 // indirect - github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect - github.com/aws/smithy-go v1.23.1 // indirect + github.com/aws/smithy-go v1.23.2 // indirect github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index 3a7e9849fad..34b430536ea 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -28,22 +28,22 @@ github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc= github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= -github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= -github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= +github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= -github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= -github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= diff --git a/apps/secret/kinds/v1beta1/keeper.cue b/apps/secret/kinds/v1beta1/keeper.cue index 36e20198ab7..4ade465d1b5 100644 --- a/apps/secret/kinds/v1beta1/keeper.cue +++ b/apps/secret/kinds/v1beta1/keeper.cue @@ -30,11 +30,22 @@ KeeperSpec: { } #AWSConfig: { - accessKeyID: #CredentialValue - secretAccessKey: #CredentialValue + region: string + accessKey?: #AWSAccessKey + assumeRole?: #AWSAssumeRole kmsKeyID?: string } +#AWSAccessKey: { + accessKeyID: #CredentialValue + secretAccessKey: #CredentialValue +} + +#AWSAssumeRole: { + assumeRoleArn: string + externalID: string +} + #AzureConfig: { keyVaultName: string tenantID: string diff --git a/apps/secret/pkg/apis/secret/v1beta1/keeper_spec_gen.go b/apps/secret/pkg/apis/secret/v1beta1/keeper_spec_gen.go index 76d232465c8..806a3df8294 100644 --- a/apps/secret/pkg/apis/secret/v1beta1/keeper_spec_gen.go +++ b/apps/secret/pkg/apis/secret/v1beta1/keeper_spec_gen.go @@ -4,14 +4,26 @@ package v1beta1 // +k8s:openapi-gen=true type KeeperAWSConfig struct { - AccessKeyID KeeperCredentialValue `json:"accessKeyID"` - SecretAccessKey KeeperCredentialValue `json:"secretAccessKey"` - KmsKeyID *string `json:"kmsKeyID,omitempty"` + Region string `json:"region"` + AccessKey *KeeperAWSAccessKey `json:"accessKey,omitempty"` + AssumeRole *KeeperAWSAssumeRole `json:"assumeRole,omitempty"` + KmsKeyID *string `json:"kmsKeyID,omitempty"` } // NewKeeperAWSConfig creates a new KeeperAWSConfig object. func NewKeeperAWSConfig() *KeeperAWSConfig { - return &KeeperAWSConfig{ + return &KeeperAWSConfig{} +} + +// +k8s:openapi-gen=true +type KeeperAWSAccessKey struct { + AccessKeyID KeeperCredentialValue `json:"accessKeyID"` + SecretAccessKey KeeperCredentialValue `json:"secretAccessKey"` +} + +// NewKeeperAWSAccessKey creates a new KeeperAWSAccessKey object. +func NewKeeperAWSAccessKey() *KeeperAWSAccessKey { + return &KeeperAWSAccessKey{ AccessKeyID: *NewKeeperCredentialValue(), SecretAccessKey: *NewKeeperCredentialValue(), } @@ -36,6 +48,17 @@ func NewKeeperCredentialValue() *KeeperCredentialValue { return &KeeperCredentialValue{} } +// +k8s:openapi-gen=true +type KeeperAWSAssumeRole struct { + AssumeRoleArn string `json:"assumeRoleArn"` + ExternalID string `json:"externalID"` +} + +// NewKeeperAWSAssumeRole creates a new KeeperAWSAssumeRole object. +func NewKeeperAWSAssumeRole() *KeeperAWSAssumeRole { + return &KeeperAWSAssumeRole{} +} + // +k8s:openapi-gen=true type KeeperAzureConfig struct { KeyVaultName string `json:"keyVaultName"` diff --git a/apps/secret/pkg/apis/secret/v1beta1/keeper_type.go b/apps/secret/pkg/apis/secret/v1beta1/keeper_type.go index 0feeac75dd1..6f61acf1a77 100644 --- a/apps/secret/pkg/apis/secret/v1beta1/keeper_type.go +++ b/apps/secret/pkg/apis/secret/v1beta1/keeper_type.go @@ -12,6 +12,7 @@ const ( AzureKeeperType KeeperType = "azure" GCPKeeperType KeeperType = "gcp" HashiCorpKeeperType KeeperType = "hashicorp" + SystemKeeperType KeeperType = "system" ) func (kt KeeperType) String() string { @@ -20,9 +21,31 @@ func (kt KeeperType) String() string { // KeeperConfig is an interface that all keeper config types must implement. type KeeperConfig interface { + // Returns the name of the keeper + GetName() string Type() KeeperType } +type NamedKeeperConfig[T interface { + Type() KeeperType +}] struct { + Name string + Cfg T +} + +func NewNamedKeeperConfig[T interface { + Type() KeeperType +}](keeperName string, cfg T) *NamedKeeperConfig[T] { + return &NamedKeeperConfig[T]{Name: keeperName, Cfg: cfg} +} + +func (c *NamedKeeperConfig[T]) GetName() string { + return c.Name +} +func (c *NamedKeeperConfig[T]) Type() KeeperType { + return c.Cfg.Type() +} + func (s *KeeperSpec) GetType() KeeperType { if s.Aws != nil { return AWSKeeperType @@ -43,7 +66,7 @@ func (s *KeeperSpec) GetType() KeeperType { type SystemKeeperConfig struct{} func (*SystemKeeperConfig) Type() KeeperType { - return "system" + return SystemKeeperType } func (s *KeeperAWSConfig) Type() KeeperType { diff --git a/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go b/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go index 101e4bb55b3..4d295874224 100644 --- a/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go +++ b/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go @@ -14,6 +14,8 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.Keeper": schema_pkg_apis_secret_v1beta1_Keeper(ref), + "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAWSAccessKey": schema_pkg_apis_secret_v1beta1_KeeperAWSAccessKey(ref), + "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAWSAssumeRole": schema_pkg_apis_secret_v1beta1_KeeperAWSAssumeRole(ref), "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAWSConfig": schema_pkg_apis_secret_v1beta1_KeeperAWSConfig(ref), "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAzureConfig": schema_pkg_apis_secret_v1beta1_KeeperAzureConfig(ref), "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperCredentialValue": schema_pkg_apis_secret_v1beta1_KeeperCredentialValue(ref), @@ -79,7 +81,7 @@ func schema_pkg_apis_secret_v1beta1_Keeper(ref common.ReferenceCallback) common. } } -func schema_pkg_apis_secret_v1beta1_KeeperAWSConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_secret_v1beta1_KeeperAWSAccessKey(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -97,6 +99,65 @@ func schema_pkg_apis_secret_v1beta1_KeeperAWSConfig(ref common.ReferenceCallback Ref: ref("github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperCredentialValue"), }, }, + }, + Required: []string{"accessKeyID", "secretAccessKey"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperCredentialValue"}, + } +} + +func schema_pkg_apis_secret_v1beta1_KeeperAWSAssumeRole(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "assumeRoleArn": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "externalID": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"assumeRoleArn", "externalID"}, + }, + }, + } +} + +func schema_pkg_apis_secret_v1beta1_KeeperAWSConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "region": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "accessKey": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAWSAccessKey"), + }, + }, + "assumeRole": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAWSAssumeRole"), + }, + }, "kmsKeyID": { SchemaProps: spec.SchemaProps{ Type: []string{"string"}, @@ -104,11 +165,11 @@ func schema_pkg_apis_secret_v1beta1_KeeperAWSConfig(ref common.ReferenceCallback }, }, }, - Required: []string{"accessKeyID", "secretAccessKey"}, + Required: []string{"region"}, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperCredentialValue"}, + "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAWSAccessKey", "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAWSAssumeRole"}, } } diff --git a/go.mod b/go.mod index fa38e9ec99d..bba74f9b656 100644 --- a/go.mod +++ b/go.mod @@ -32,13 +32,14 @@ require ( github.com/apache/arrow-go/v18 v18.4.1 // @grafana/plugins-platform-backend github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad github.com/aws/aws-sdk-go v1.55.7 // @grafana/aws-datasources - github.com/aws/aws-sdk-go-v2 v1.39.1 // @grafana/aws-datasources + github.com/aws/aws-sdk-go-v2 v1.40.0 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/oam v1.18.3 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 // @grafana/aws-datasources - github.com/aws/smithy-go v1.23.1 // @grafana/aws-datasources + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 // @grafana/grafana-operator-experience-squad + github.com/aws/smithy-go v1.23.2 // @grafana/aws-datasources github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend github.com/blang/semver/v4 v4.0.0 // indirect; @grafana/grafana-developer-enablement-squad @@ -344,8 +345,8 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect diff --git a/go.sum b/go.sum index 7d2582cf711..32ef27a6559 100644 --- a/go.sum +++ b/go.sum @@ -850,8 +850,8 @@ github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2z github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= -github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= +github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY= github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= @@ -862,10 +862,10 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQU github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs= @@ -892,14 +892,16 @@ github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 h1:Pwbxovp github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6/go.mod h1:Z4xLt5mXspLKjBV92i165wAJ/3T6TIv4n7RtIS8pWV0= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZgtAXTND1Kk9OXg= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 h1:w6a0H79HrHf3lr+zrw+pSzR5B+caiQFAKiNHlrUcnoc= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1/go.mod h1:c6Vg0BRiU7v0MVhHupw90RyL120QBwAMLbDCzptGeMk= github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= -github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= -github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/axiomhq/hyperloglog v0.0.0-20191112132149-a4c4c47bc57f/go.mod h1:2stgcRjl6QmW+gU2h5E7BQXg4HU0gzxKWDuT5HviN9s= github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 h1:60m4tnanN1ctzIu4V3bfCNJ39BiOPSm1gHFlFjTkRE0= github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= diff --git a/go.work.sum b/go.work.sum index f676971746a..f7c59731300 100644 --- a/go.work.sum +++ b/go.work.sum @@ -423,6 +423,7 @@ github.com/aws/aws-msk-iam-sasl-signer-go v1.0.1 h1:nMp7diZObd4XEVUR0pEvn7/E13JI github.com/aws/aws-msk-iam-sasl-signer-go v1.0.1/go.mod h1:MVYeeOhILFFemC/XlYTClvBjYZrg/EPd3ts885KrNTI= github.com/aws/aws-sdk-go-v2 v1.36.5/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0= github.com/aws/aws-sdk-go-v2 v1.38.1/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= +github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= github.com/aws/aws-sdk-go-v2/config v1.29.17/go.mod h1:9P4wwACpbeXs9Pm9w1QTh6BwWwJjwYvJ1iCt5QbCXh8= github.com/aws/aws-sdk-go-v2/config v1.31.2/go.mod h1:17ft42Yb2lF6OigqSYiDAiUcX4RIkEMY6XxEMJsrAes= github.com/aws/aws-sdk-go-v2/credentials v1.17.70/go.mod h1:M+lWhhmomVGgtuPOhO85u4pEa3SmssPTdcYpP/5J/xc= @@ -436,8 +437,10 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4/go.mod h1:9xzb8/SV62W6gHQG github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69/go.mod h1:GJj8mmO6YT6EqgduWocwhMoxTLFitkhIrK+owzrYL2I= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36/go.mod h1:Q1lnJArKRXkenyog6+Y+zr7WDpk4e6XlR6gs20bbeNo= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4/go.mod h1:l4bdfCD7XyyZA9BolKBo1eLqgaJxl0/x91PL4Yqe0ao= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36/go.mod h1:UdyGa7Q91id/sdyHPwth+043HhmP6yP9MBHgbZM0xo8= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4/go.mod h1:yDmJgqOiH4EA8Hndnv4KwAo8jCGTSnM5ASG1nBI+toA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.0 h1:A99gjqZDbdhjtjJVZrmVzVKO2+p3MSg35bDWtbMQVxw= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.0/go.mod h1:mWB0GE1bqcVSvpW7OtFA0sKuHk52+IqtnsYU2jUfYAs= @@ -491,6 +494,7 @@ github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/awslabs/aws-lambda-go-api-proxy v0.16.2 h1:CJyGEyO1CIwOnXTU40urf0mchf6t3voxpvUDikOU9LY= github.com/awslabs/aws-lambda-go-api-proxy v0.16.2/go.mod h1:vxxjwBHe/KbgFeNlAP/Tvp4SsVRL3WQamcWRxqVh0z0= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 06aa45d2275..ec009948a9b 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1259,4 +1259,8 @@ export interface FeatureToggles { * Enables the ASAP smoothing transformation for time series data */ smoothingTransformation?: boolean; + /** + * Enables the creation of keepers that manage secrets stored on AWS secrets manager + */ + secretsManagementAppPlatformAwsKeeper?: boolean; } diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index 472652cc103..feaf1755c94 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -46,6 +46,7 @@ import ( _ "sigs.k8s.io/randfill" _ "xorm.io/builder" + _ "github.com/aws/aws-sdk-go-v2/service/secretsmanager" _ "github.com/grafana/authlib/authn" _ "github.com/grafana/authlib/authz" _ "github.com/grafana/authlib/cache" diff --git a/pkg/registry/apis/secret/contracts/keeper.go b/pkg/registry/apis/secret/contracts/keeper.go index 1e8f5e2acfb..584deea009e 100644 --- a/pkg/registry/apis/secret/contracts/keeper.go +++ b/pkg/registry/apis/secret/contracts/keeper.go @@ -9,6 +9,11 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" ) +const ( + // This constant can be used as a key in resource tags + GrafanaSecretsManagerName = "grafana-secrets-manager" +) + var ( // The name used to refer to the system keeper SystemKeeperName = "system" @@ -102,8 +107,8 @@ func (s ExternalID) String() string { // Keeper is the interface for secret keepers. type Keeper interface { Store(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64, exposedValueOrRef string) (ExternalID, error) - Update(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64, exposedValueOrRef string) error Expose(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64) (secretv1beta1.ExposedSecureValue, error) + RetrieveReference(ctx context.Context, cfg secretv1beta1.KeeperConfig, ref string) (secretv1beta1.ExposedSecureValue, error) Delete(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64) error } diff --git a/pkg/registry/apis/secret/contracts/secure_value.go b/pkg/registry/apis/secret/contracts/secure_value.go index 09702fa7f5f..8c3d8988433 100644 --- a/pkg/registry/apis/secret/contracts/secure_value.go +++ b/pkg/registry/apis/secret/contracts/secure_value.go @@ -21,8 +21,10 @@ type DecryptSecureValue struct { } var ( - ErrSecureValueNotFound = errors.New("secure value not found") - ErrSecureValueAlreadyExists = errors.New("secure value already exists") + ErrSecureValueNotFound = errors.New("secure value not found") + ErrSecureValueAlreadyExists = errors.New("secure value already exists") + ErrReferenceWithSystemKeeper = errors.New("tried to create secure value using reference with system keeper, references can only be used with 3rd party keepers") + ErrSecureValueMissingSecretAndRef = errors.New("secure value spec doesn't have neither a secret or reference") ) type ReadOpts struct { diff --git a/pkg/registry/apis/secret/garbagecollectionworker/worker.go b/pkg/registry/apis/secret/garbagecollectionworker/worker.go index b967b1f21a1..2fab7562163 100644 --- a/pkg/registry/apis/secret/garbagecollectionworker/worker.go +++ b/pkg/registry/apis/secret/garbagecollectionworker/worker.go @@ -103,9 +103,12 @@ func (w *Worker) Cleanup(ctx context.Context, sv *secretv1beta1.SecureValue) err return fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", sv.Namespace, sv.Status.Keeper, err) } - // Keeper deletion is idempotent - if err := keeper.Delete(ctx, keeperCfg, xkube.Namespace(sv.Namespace), sv.Name, sv.Status.Version); err != nil { - return fmt.Errorf("deleting secure value from keeper: %w", err) + // If the secure value doesn't use a reference, delete the secret + if sv.Spec.Ref == nil { + // Keeper deletion is idempotent + if err := keeper.Delete(ctx, keeperCfg, xkube.Namespace(sv.Namespace), sv.Name, sv.Status.Version); err != nil { + return fmt.Errorf("deleting secure value from keeper: %w", err) + } } // Metadata deletion is not idempotent but not found errors are ignored diff --git a/pkg/registry/apis/secret/garbagecollectionworker/worker_test.go b/pkg/registry/apis/secret/garbagecollectionworker/worker_test.go index 9b27aceefc1..d8ef76b50a0 100644 --- a/pkg/registry/apis/secret/garbagecollectionworker/worker_test.go +++ b/pkg/registry/apis/secret/garbagecollectionworker/worker_test.go @@ -1,7 +1,6 @@ package garbagecollectionworker_test import ( - "slices" "testing" "time" @@ -97,27 +96,33 @@ func TestBasic(t *testing.T) { require.NoError(t, sut.GarbageCollectionWorker.Cleanup(t.Context(), sv)) require.NoError(t, sut.GarbageCollectionWorker.Cleanup(t.Context(), sv)) }) -} -var ( - decryptersGen = rapid.SampledFrom([]string{"svc1", "svc2", "svc3", "svc4", "svc5"}) - nameGen = rapid.SampledFrom([]string{"n1", "n2", "n3", "n4", "n5"}) - namespaceGen = rapid.SampledFrom([]string{"ns1", "ns2", "ns3", "ns4", "ns5"}) - anySecureValueGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.SecureValue { - return &secretv1beta1.SecureValue{ + t.Run("cleaning up secure values that use references", func(t *testing.T) { + sut := testutils.Setup(t) + + keeper, err := sut.CreateAWSKeeper(t.Context()) + require.NoError(t, err) + + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(keeper.Namespace), keeper.Name)) + + sv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(&secretv1beta1.SecureValue{ ObjectMeta: metav1.ObjectMeta{ - Name: nameGen.Draw(t, "name"), - Namespace: namespaceGen.Draw(t, "ns"), + Namespace: keeper.Namespace, + Name: "sv1", }, Spec: secretv1beta1.SecureValueSpec{ - Description: rapid.SampledFrom([]string{"d1", "d2", "d3", "d4", "d5"}).Draw(t, "description"), - Value: ptr.To(secretv1beta1.NewExposedSecureValue(rapid.SampledFrom([]string{"v1", "v2", "v3", "v4", "v5"}).Draw(t, "value"))), - Decrypters: rapid.SliceOfDistinct(decryptersGen, func(v string) string { return v }).Draw(t, "decrypters"), + Description: "desc1", + Ref: ptr.To("ref1"), + Decrypters: []string{"decrypter1"}, }, - Status: secretv1beta1.SecureValueStatus{}, - } + })) + require.NoError(t, err) + + _, err = sut.DeleteSv(t.Context(), sv.Namespace, sv.Name) + require.NoError(t, err) + require.NoError(t, sut.GarbageCollectionWorker.Cleanup(t.Context(), sv)) }) -) +} func TestProperty(t *testing.T) { t.Parallel() @@ -126,26 +131,59 @@ func TestProperty(t *testing.T) { rapid.Check(t, func(t *rapid.T) { sut := testutils.Setup(tt) - model := newModel() + model := testutils.NewModelGsm(nil) t.Repeat(map[string]func(*rapid.T){ "create": func(t *rapid.T) { - sv := anySecureValueGen.Draw(t, "sv") + var sv *secretv1beta1.SecureValue + if rapid.Bool().Draw(t, "withRef") { + sv = testutils.AnySecureValueWithRefGen.Draw(t, "sv") + } else { + sv = testutils.AnySecureValueGen.Draw(t, "sv") + } + svCopy := sv.DeepCopy() createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv)) - svCopy.UID = createdSv.UID - modelErr := model.create(sut.Clock.Now(), svCopy) + if err == nil { + svCopy.UID = createdSv.UID + } + _, modelErr := model.Create(sut.Clock.Now(), svCopy) require.ErrorIs(t, err, modelErr) }, + "createKeeper": func(t *rapid.T) { + input := testutils.AnyKeeperGen.Draw(t, "keeper") + modelKeeper, modelErr := model.CreateKeeper(input) + keeper, err := sut.KeeperMetadataStorage.Create(t.Context(), input, "actor-uid") + if err != nil || modelErr != nil { + require.ErrorIs(t, err, modelErr) + return + } + require.Equal(t, modelKeeper.Name, keeper.Name) + }, + "setKeeperAsActive": func(t *rapid.T) { + namespace := testutils.NamespaceGen.Draw(t, "namespace") + var keeper string + if rapid.Bool().Draw(t, "systemKeeper") { + keeper = contracts.SystemKeeperName + } else { + keeper = testutils.KeeperNameGen.Draw(t, "keeper") + } + modelErr := model.SetKeeperAsActive(namespace, keeper) + err := sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(namespace), keeper) + if err != nil || modelErr != nil { + require.ErrorIs(t, err, modelErr) + return + } + }, "delete": func(t *rapid.T) { - if len(model.items) == 0 { + if len(model.SecureValues) == 0 { return } - i := rapid.IntRange(0, len(model.items)-1).Draw(t, "index") - sv := model.items[i] - modelErr := model.delete(sv.Namespace, sv.Name) + i := rapid.IntRange(0, len(model.SecureValues)-1).Draw(t, "index") + sv := model.SecureValues[i] + _, modelErr := model.Delete(sv.Namespace, sv.Name) _, err := sut.DeleteSv(t.Context(), sv.Namespace, sv.Name) require.ErrorIs(t, err, modelErr) }, @@ -153,7 +191,7 @@ func TestProperty(t *testing.T) { // Taken from secureValueMetadataStorage.acquireLeases minAge := 300 * time.Second maxBatchSize := sut.GarbageCollectionWorker.Cfg.SecretsManagement.GCWorkerMaxBatchSize - modelDeleted, modelErr := model.cleanupInactiveSecureValues(sut.Clock.Now(), minAge, maxBatchSize) + modelDeleted, modelErr := model.CleanupInactiveSecureValues(sut.Clock.Now(), minAge, maxBatchSize) deleted, err := sut.GarbageCollectionWorker.CleanupInactiveSecureValues(t.Context()) require.ErrorIs(t, err, modelErr) @@ -174,77 +212,3 @@ func TestProperty(t *testing.T) { }) }) } - -type model struct { - items []*modelSecureValue -} - -type modelSecureValue struct { - *secretv1beta1.SecureValue - active bool - created time.Time -} - -func newModel() *model { - return &model{ - items: make([]*modelSecureValue, 0), - } -} - -func (m *model) create(now time.Time, sv *secretv1beta1.SecureValue) error { - created := now - for _, item := range m.items { - if item.active && item.Namespace == sv.Namespace && item.Name == sv.Name { - item.active = false - created = item.created - break - } - } - m.items = append(m.items, &modelSecureValue{SecureValue: sv, active: true, created: created}) - return nil -} - -func (m *model) delete(ns string, name string) error { - for _, sv := range m.items { - if sv.active && sv.Namespace == ns && sv.Name == name { - sv.active = false - return nil - } - } - - return contracts.ErrSecureValueNotFound -} - -func (m *model) cleanupInactiveSecureValues(now time.Time, minAge time.Duration, maxBatchSize uint16) ([]*modelSecureValue, error) { - // Using a slice to allow duplicates - toDelete := make([]*modelSecureValue, 0) - - // The implementation query sorts by created time ascending - slices.SortFunc(m.items, func(a, b *modelSecureValue) int { - if a.created.Before(b.created) { - return -1 - } else if a.created.After(b.created) { - return 1 - } - return 0 - }) - - for _, sv := range m.items { - if len(toDelete) >= int(maxBatchSize) { - break - } - - if !sv.active && now.Sub(sv.created) > minAge { - toDelete = append(toDelete, sv) - } - } - - // PERF: The slices are always small - m.items = slices.DeleteFunc(m.items, func(v1 *modelSecureValue) bool { - return slices.ContainsFunc(toDelete, func(v2 *modelSecureValue) bool { - return v2.UID == v1.UID - }) - }) - - return toDelete, nil -} diff --git a/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go index 294eff1a7af..952c9cd9da6 100644 --- a/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go +++ b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go @@ -107,6 +107,10 @@ func (s *SQLKeeper) Expose(ctx context.Context, cfg secretv1beta1.KeeperConfig, return exposedValue, nil } +func (s *SQLKeeper) RetrieveReference(ctx context.Context, cfg secretv1beta1.KeeperConfig, ref string) (secretv1beta1.ExposedSecureValue, error) { + return "", fmt.Errorf("reference is not implemented by the SQLKeeper") +} + func (s *SQLKeeper) Delete(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64) error { ctx, span := s.tracer.Start(ctx, "SQLKeeper.Delete", trace.WithAttributes( attribute.String("namespace", namespace.String()), @@ -125,27 +129,3 @@ func (s *SQLKeeper) Delete(ctx context.Context, cfg secretv1beta1.KeeperConfig, return nil } - -func (s *SQLKeeper) Update(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64, exposedValueOrRef string) error { - ctx, span := s.tracer.Start(ctx, "SQLKeeper.Update", trace.WithAttributes( - attribute.String("namespace", namespace.String()), - attribute.String("name", name), - attribute.Int64("version", version), - )) - defer span.End() - - start := time.Now() - encryptedData, err := s.encryptionManager.Encrypt(ctx, namespace, []byte(exposedValueOrRef)) - if err != nil { - return fmt.Errorf("unable to encrypt value: %w", err) - } - - err = s.store.Update(ctx, namespace, name, version, encryptedData) - if err != nil { - return fmt.Errorf("failed to update encrypted value: %w", err) - } - - s.metrics.UpdateDuration.WithLabelValues(string(cfg.Type())).Observe(time.Since(start).Seconds()) - - return nil -} diff --git a/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go index 34249227790..251f2b1a9f8 100644 --- a/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go +++ b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go @@ -26,7 +26,7 @@ func Test_SQLKeeperSetup(t *testing.T) { plaintext1 := "very secret string in namespace 1" plaintext2 := "very secret string in namespace 2" - keeperCfg := &secretv1beta1.SystemKeeperConfig{} + keeperCfg := secretv1beta1.NewNamedKeeperConfig("k1", &secretv1beta1.SystemKeeperConfig{}) t.Run("storing an encrypted value returns no error", func(t *testing.T) { sut := testutils.Setup(t) @@ -123,31 +123,6 @@ func Test_SQLKeeperSetup(t *testing.T) { require.NoError(t, err) }) - t.Run("updating an existent encrypted value returns no error", func(t *testing.T) { - sut := testutils.Setup(t) - - _, err := sut.SQLKeeper.Store(t.Context(), keeperCfg, namespace1, name1, version1, plaintext1) - require.NoError(t, err) - - err = sut.SQLKeeper.Update(t.Context(), keeperCfg, namespace1, name1, version1, plaintext2) - require.NoError(t, err) - - exposedVal, err := sut.SQLKeeper.Expose(t.Context(), keeperCfg, namespace1, name1, version1) - require.NoError(t, err) - assert.NotNil(t, exposedVal) - assert.Equal(t, plaintext2, exposedVal.DangerouslyExposeAndConsumeValue()) - }) - - t.Run("updating a non existent encrypted value returns error", func(t *testing.T) { - sut := testutils.Setup(t) - - _, err := sut.SQLKeeper.Store(t.Context(), keeperCfg, namespace1, name1, version1, plaintext1) - require.NoError(t, err) - - err = sut.SQLKeeper.Update(t.Context(), nil, namespace1, "non_existing_name", version1, plaintext2) - require.Error(t, err) - }) - t.Run("data key migration only runs if both secrets db migrations are enabled", func(t *testing.T) { t.Parallel() diff --git a/pkg/registry/apis/secret/service/secure_value.go b/pkg/registry/apis/secret/service/secure_value.go index 30b7f4a625d..c3a01293fae 100644 --- a/pkg/registry/apis/secret/service/secure_value.go +++ b/pkg/registry/apis/secret/service/secure_value.go @@ -141,7 +141,7 @@ func (s *SecureValueService) Update(ctx context.Context, newSecureValue *secretv return nil, false, fmt.Errorf("fetching keeper config: namespace=%+v keeper: %q %w", newSecureValue.Namespace, currentVersion.Status.Keeper, err) } - if newSecureValue.Spec.Value == nil { + if newSecureValue.Spec.Value == nil && newSecureValue.Spec.Ref == nil { keeper, err := s.keeperService.KeeperForConfig(keeperCfg) if err != nil { return nil, false, fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", newSecureValue.Namespace, newSecureValue.Status.Keeper, err) @@ -150,7 +150,7 @@ func (s *SecureValueService) Update(ctx context.Context, newSecureValue *secretv secret, err := keeper.Expose(ctx, keeperCfg, xkube.Namespace(newSecureValue.Namespace), newSecureValue.Name, currentVersion.Status.Version) if err != nil { - return nil, false, fmt.Errorf("reading secret value from keeper: %w", err) + return nil, false, fmt.Errorf("reading secret value from keeper: %w %w", contracts.ErrSecureValueMissingSecretAndRef, err) } newSecureValue.Spec.Value = &secret @@ -174,6 +174,10 @@ func (s *SecureValueService) createNewVersion(ctx context.Context, keeperName st return nil, contracts.NewErrValidateSecureValue(errorList) } + if sv.Spec.Ref != nil && keeperCfg.Type() == secretv1beta1.SystemKeeperType { + return nil, contracts.ErrReferenceWithSystemKeeper + } + createdSv, err := s.secureValueMetadataStorage.Create(ctx, keeperName, sv, actorUID) if err != nil { return nil, fmt.Errorf("creating secure value: %w", err) @@ -189,18 +193,28 @@ func (s *SecureValueService) createNewVersion(ctx context.Context, keeperName st return nil, fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", createdSv.Namespace, keeperName, err) } logging.FromContext(ctx).Debug("retrieved keeper", "namespace", createdSv.Namespace, "type", keeperCfg.Type()) - // TODO: can we stop using external id? // TODO: store uses only the namespace and returns and id. It could be a kv instead. // TODO: check that the encrypted store works with multiple versions - externalID, err := keeper.Store(ctx, keeperCfg, xkube.Namespace(createdSv.Namespace), createdSv.Name, createdSv.Status.Version, sv.Spec.Value.DangerouslyExposeAndConsumeValue()) - if err != nil { - return nil, fmt.Errorf("storing secure value in keeper: %w", err) - } - createdSv.Status.ExternalID = string(externalID) + switch { + case sv.Spec.Value != nil: + externalID, err := keeper.Store(ctx, keeperCfg, xkube.Namespace(createdSv.Namespace), createdSv.Name, createdSv.Status.Version, sv.Spec.Value.DangerouslyExposeAndConsumeValue()) + if err != nil { + return nil, fmt.Errorf("storing secure value in keeper: %w", err) + } + createdSv.Status.ExternalID = string(externalID) - if err := s.secureValueMetadataStorage.SetExternalID(ctx, xkube.Namespace(createdSv.Namespace), createdSv.Name, createdSv.Status.Version, externalID); err != nil { - return nil, fmt.Errorf("setting secure value external id: %w", err) + if err := s.secureValueMetadataStorage.SetExternalID(ctx, xkube.Namespace(createdSv.Namespace), createdSv.Name, createdSv.Status.Version, externalID); err != nil { + return nil, fmt.Errorf("setting secure value external id: %w", err) + } + + case sv.Spec.Ref != nil: + // No-op, there's nothing to store in the keeper since the + // secret is already stored in the 3rd party secret store + // and it's being referenced. + + default: + return nil, fmt.Errorf("secure value doesn't specify either a secret value or a reference") } if err := s.secureValueMetadataStorage.SetVersionToActive(ctx, xkube.Namespace(createdSv.Namespace), createdSv.Name, createdSv.Status.Version); err != nil { @@ -366,3 +380,20 @@ func (s *SecureValueService) Delete(ctx context.Context, namespace xkube.Namespa return sv, nil } + +func (s *SecureValueService) SetKeeperAsActive(ctx context.Context, namespace xkube.Namespace, name string) error { + // The system keeper is not in the database, so skip checking it exists. + // TODO: should the system keeper be in the database? + if name != contracts.SystemKeeperName { + // Check keeper exists. No need to worry about time of check to time of use + // since trying to activate a just deleted keeper will result in all + // keepers being inactive and defaulting to the system keeper. + if _, err := s.keeperMetadataStorage.Read(ctx, namespace, name, contracts.ReadOpts{}); err != nil { + return fmt.Errorf("reading keeper before setting as active: %w", err) + } + } + if err := s.keeperMetadataStorage.SetAsActive(ctx, namespace, name); err != nil { + return fmt.Errorf("calling keeper metadata storage to set keeper as active: %w", err) + } + return nil +} diff --git a/pkg/registry/apis/secret/service/secure_value_test.go b/pkg/registry/apis/secret/service/secure_value_test.go index 1304e5b0ce1..40458ac5e22 100644 --- a/pkg/registry/apis/secret/service/secure_value_test.go +++ b/pkg/registry/apis/secret/service/secure_value_test.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/secret/testutils" "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" ) @@ -93,4 +94,149 @@ func TestCrud(t *testing.T) { _, err = sut.SecureValueMetadataStorage.Read(t.Context(), xkube.Namespace(sv1.Namespace), sv1.Name, contracts.ReadOpts{}) require.ErrorIs(t, err, contracts.ErrSecureValueNotFound) }) + + t.Run("secret can be referenced only when the active keeper is a 3rd party keeper", func(t *testing.T) { + t.Parallel() + + sut := testutils.Setup(t) + + ref := "path-to-secret" + sv := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sv1", + Namespace: "ns1", + }, + Spec: secretv1beta1.SecureValueSpec{ + Description: "desc1", + Ref: &ref, + Decrypters: []string{"decrypter1"}, + }, + Status: secretv1beta1.SecureValueStatus{}, + } + + // Creating a secure value using ref with the system keeper + createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv)) + require.NotNil(t, err) + require.Nil(t, createdSv) + require.Contains(t, err.Error(), "tried to create secure value using reference with system keeper, references can only be used with 3rd party keepers") + + // Create a 3rd party keeper + keeper := &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k1", + Namespace: "ns1", + }, + Spec: secretv1beta1.KeeperSpec{ + Description: "desc", + Aws: &secretv1beta1.KeeperAWSConfig{ + Region: "us-east-1", + AssumeRole: &secretv1beta1.KeeperAWSAssumeRole{ + AssumeRoleArn: "arn", + ExternalID: "id", + }, + }, + }, + } + + // Create a 3rd party keeper + _, err = sut.KeeperMetadataStorage.Create(t.Context(), keeper, "actor-uid") + require.NoError(t, err) + + // Set the new keeper as active + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(keeper.Namespace), keeper.Name)) + + // Create a secure value using a ref + createdSv, err = sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv)) + require.NoError(t, err) + require.Equal(t, keeper.Name, createdSv.Status.Keeper) + }) + + t.Run("creating secure value with reference", func(t *testing.T) { + t.Parallel() + + sut := testutils.Setup(t) + + // Create a keeper because references cannot be used with the system keeper + keeper, err := sut.KeeperMetadataStorage.Create(t.Context(), &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "k1", + }, + Spec: secretv1beta1.KeeperSpec{ + Aws: &secretv1beta1.KeeperAWSConfig{}, + }, + }, "actor-uid") + require.NoError(t, err) + + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(keeper.Namespace), keeper.Name)) + + sv, err := sut.CreateSv(t.Context()) + require.NoError(t, err) + require.NotNil(t, sv) + }) +} + +func Test_SetAsActive(t *testing.T) { + t.Parallel() + + t.Run("setting the system keeper as the active keeper", func(t *testing.T) { + t.Parallel() + + sut := testutils.Setup(t) + + namespace := "ns" + + // Create a new keeper + keeper, err := sut.KeeperMetadataStorage.Create(t.Context(), &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "k1", + }, + Spec: secretv1beta1.KeeperSpec{ + Description: "description", + Aws: &secretv1beta1.KeeperAWSConfig{}, + }, + }, "actor-uid") + require.NoError(t, err) + + // Set the new keeper as active + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(keeper.Namespace), keeper.Name)) + keeperName, _, err := sut.KeeperMetadataStorage.GetActiveKeeperConfig(t.Context(), namespace) + require.NoError(t, err) + require.Equal(t, keeper.Name, keeperName) + + // Set the system keeper as active + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(namespace), contracts.SystemKeeperName)) + keeperName, _, err = sut.KeeperMetadataStorage.GetActiveKeeperConfig(t.Context(), namespace) + require.NoError(t, err) + require.Equal(t, contracts.SystemKeeperName, keeperName) + }) + + t.Run("each namespace can have one active keeper", func(t *testing.T) { + t.Parallel() + + sut := testutils.Setup(t) + + k1, err := sut.CreateKeeper(t.Context(), func(ckc *testutils.CreateKeeperConfig) { + ckc.Keeper.Namespace = "ns1" + ckc.Keeper.Name = "k1" + }) + require.NoError(t, err) + k2, err := sut.CreateKeeper(t.Context(), func(ckc *testutils.CreateKeeperConfig) { + ckc.Keeper.Namespace = "ns2" + ckc.Keeper.Name = "k2" + }) + require.NoError(t, err) + + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(k1.Namespace), k1.Name)) + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(k2.Namespace), k2.Name)) + + keeperName, _, err := sut.KeeperMetadataStorage.GetActiveKeeperConfig(t.Context(), k1.Namespace) + require.NoError(t, err) + require.Equal(t, k1.Name, keeperName) + + keeperName, _, err = sut.KeeperMetadataStorage.GetActiveKeeperConfig(t.Context(), k2.Namespace) + require.NoError(t, err) + require.Equal(t, k2.Name, keeperName) + }) } diff --git a/pkg/registry/apis/secret/testutils/generators.go b/pkg/registry/apis/secret/testutils/generators.go new file mode 100644 index 00000000000..34686ccbf6e --- /dev/null +++ b/pkg/registry/apis/secret/testutils/generators.go @@ -0,0 +1,96 @@ +package testutils + +import ( + "fmt" + + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "pgregory.net/rapid" +) + +var ( + DecryptersGen = rapid.SampledFrom([]string{"svc1", "svc2", "svc3", "svc4", "svc5"}) + SecureValueNameGen = rapid.SampledFrom([]string{"n1", "n2", "n3", "n4", "n5"}) + KeeperNameGen = rapid.SampledFrom([]string{"k1", "k2", "k3", "k4", "k5"}) + NamespaceGen = rapid.SampledFrom([]string{"ns1", "ns2", "ns3", "ns4", "ns5"}) + SecretsToRefGen = rapid.SampledFrom([]string{"ref1", "ref2", "ref3", "ref4", "ref5"}) + // Generator for secure values that specify a secret value + AnySecureValueGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.SecureValue { + return &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: SecureValueNameGen.Draw(t, "name"), + Namespace: NamespaceGen.Draw(t, "ns"), + }, + Spec: secretv1beta1.SecureValueSpec{ + Description: rapid.SampledFrom([]string{"d1", "d2", "d3", "d4", "d5"}).Draw(t, "description"), + Value: ptr.To(secretv1beta1.NewExposedSecureValue(rapid.SampledFrom([]string{"v1", "v2", "v3", "v4", "v5"}).Draw(t, "value"))), + Decrypters: rapid.SliceOfDistinct(DecryptersGen, func(v string) string { return v }).Draw(t, "decrypters"), + }, + Status: secretv1beta1.SecureValueStatus{}, + } + }) + // Generator for secure values that reference values from 3rd party stores + AnySecureValueWithRefGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.SecureValue { + return &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: SecureValueNameGen.Draw(t, "name"), + Namespace: NamespaceGen.Draw(t, "ns"), + }, + Spec: secretv1beta1.SecureValueSpec{ + Description: rapid.SampledFrom([]string{"d1", "d2", "d3", "d4", "d5"}).Draw(t, "description"), + Ref: ptr.To(SecretsToRefGen.Draw(t, "ref")), + Decrypters: rapid.SliceOfDistinct(DecryptersGen, func(v string) string { return v }).Draw(t, "decrypters"), + }, + Status: secretv1beta1.SecureValueStatus{}, + } + }) + UpdateSecureValueGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.SecureValue { + sv := AnySecureValueGen.Draw(t, "sv") + // Maybe update the secret value, maybe not + if !rapid.Bool().Draw(t, "should_update_value") { + sv.Spec.Value = nil + } + return sv + }) + DecryptGen = rapid.Custom(func(t *rapid.T) DecryptInput { + return DecryptInput{ + Namespace: NamespaceGen.Draw(t, "ns"), + Name: SecureValueNameGen.Draw(t, "name"), + Decrypter: DecryptersGen.Draw(t, "decrypter"), + } + }) + AnyKeeperGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.Keeper { + spec := secretv1beta1.KeeperSpec{ + Description: rapid.String().Draw(t, "description"), + } + + keeperType := rapid.SampledFrom([]string{"isAwsKeeper", "isAzureKeeper", "isGcpKeeper", "isVaultKeeper"}).Draw(t, "keeperType") + switch keeperType { + case "isAwsKeeper": + spec.Aws = &secretv1beta1.KeeperAWSConfig{} + case "isAzureKeeper": + spec.Azure = &secretv1beta1.KeeperAzureConfig{} + case "isGcpKeeper": + spec.Gcp = &secretv1beta1.KeeperGCPConfig{} + case "isVaultKeeper": + spec.HashiCorpVault = &secretv1beta1.KeeperHashiCorpConfig{} + default: + panic(fmt.Sprintf("unhandled keeper type '%+v', did you forget a switch case?", keeperType)) + } + + return &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Name: KeeperNameGen.Draw(t, "name"), + Namespace: NamespaceGen.Draw(t, "ns"), + }, + Spec: spec, + } + }) +) + +type DecryptInput struct { + Namespace string + Name string + Decrypter string +} diff --git a/pkg/registry/apis/secret/testutils/model_gsm.go b/pkg/registry/apis/secret/testutils/model_gsm.go new file mode 100644 index 00000000000..d2cefde6ee0 --- /dev/null +++ b/pkg/registry/apis/secret/testutils/model_gsm.go @@ -0,0 +1,321 @@ +package testutils + +import ( + "context" + "fmt" + "slices" + "time" + + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + "github.com/grafana/grafana/apps/secret/pkg/decrypt" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" +) + +type ModelSecureValue struct { + *secretv1beta1.SecureValue + active bool + created time.Time + leaseCreated time.Time +} + +type ModelKeeper struct { + namespace string + name string + active bool + keeperType secretv1beta1.KeeperType +} + +// A simplified in memoruy model of the grafana secrets manager +type ModelGsm struct { + SecureValues []*ModelSecureValue + Keepers []*ModelKeeper + modelSecretsManager *ModelAWSSecretsManager +} + +func NewModelGsm(modelSecretsManager *ModelAWSSecretsManager) *ModelGsm { + return &ModelGsm{modelSecretsManager: modelSecretsManager} +} + +func (m *ModelGsm) getNewVersionNumber(namespace, name string) int64 { + latestVersion := int64(0) + for _, sv := range m.SecureValues { + if sv.Namespace == namespace && sv.Name == name { + latestVersion = max(latestVersion, sv.Status.Version) + } + } + return latestVersion + 1 +} + +func (m *ModelGsm) SetVersionToActive(namespace, name string, version int64) { + for _, sv := range m.SecureValues { + if sv.Namespace == namespace && sv.Name == name { + sv.active = sv.Status.Version == version + } + } +} + +func (m *ModelGsm) SetVersionToInactive(namespace, name string, version int64) { + for _, sv := range m.SecureValues { + if sv.Namespace == namespace && sv.Name == name && sv.Status.Version == version { + sv.active = false + return + } + } +} + +func (m *ModelGsm) ReadActiveVersion(namespace, name string) *ModelSecureValue { + for _, sv := range m.SecureValues { + if sv.Namespace == namespace && sv.Name == name && sv.active { + return sv + } + } + + return nil +} + +func (m *ModelGsm) Create(now time.Time, sv *secretv1beta1.SecureValue) (*secretv1beta1.SecureValue, error) { + keeper := m.getActiveKeeper(sv.Namespace) + + if sv.Spec.Ref != nil && keeper.keeperType == secretv1beta1.SystemKeeperType { + return nil, contracts.ErrReferenceWithSystemKeeper + } + + sv = sv.DeepCopy() + + // Preserve the original creation time if this secure value already exists + created := now + if sv := m.ReadActiveVersion(sv.Namespace, sv.Name); sv != nil { + created = sv.created + } + + modelSv := &ModelSecureValue{SecureValue: sv, active: false, created: created} + modelSv.Status.Version = m.getNewVersionNumber(modelSv.Namespace, modelSv.Name) + modelSv.Status.ExternalID = fmt.Sprintf("%d", modelSv.Status.Version) + modelSv.Status.Keeper = keeper.name + m.SecureValues = append(m.SecureValues, modelSv) + m.SetVersionToActive(modelSv.Namespace, modelSv.Name, modelSv.Status.Version) + return modelSv.SecureValue, nil +} + +func (m *ModelGsm) getActiveKeeper(namespace string) *ModelKeeper { + for _, k := range m.Keepers { + if k.namespace == namespace && k.active { + return k + } + } + + // Default to the system keeper when there are no active keepers in the namespace + return &ModelKeeper{ + namespace: namespace, + name: contracts.SystemKeeperName, + active: true, + keeperType: secretv1beta1.SystemKeeperType, + } +} + +func (m *ModelGsm) keeperExists(namespace, name string) bool { + return m.findKeeper(namespace, name) != nil +} + +func (m *ModelGsm) findKeeper(namespace, name string) *ModelKeeper { + // The system keeper is not in the list of keepers + if name == contracts.SystemKeeperName { + return &ModelKeeper{namespace: namespace, name: contracts.SystemKeeperName, active: true, keeperType: secretv1beta1.SystemKeeperType} + } + for _, k := range m.Keepers { + if k.namespace == namespace && k.name == name { + return k + } + } + return nil +} + +func (m *ModelGsm) CreateKeeper(keeper *secretv1beta1.Keeper) (*secretv1beta1.Keeper, error) { + if m.keeperExists(keeper.Namespace, keeper.Name) { + return nil, contracts.ErrKeeperAlreadyExists + } + + var keeperType secretv1beta1.KeeperType + switch { + case keeper.Spec.Aws != nil: + keeperType = secretv1beta1.AWSKeeperType + case keeper.Spec.Gcp != nil: + keeperType = secretv1beta1.GCPKeeperType + case keeper.Spec.Azure != nil: + keeperType = secretv1beta1.AzureKeeperType + case keeper.Spec.HashiCorpVault != nil: + keeperType = secretv1beta1.HashiCorpKeeperType + default: + keeperType = secretv1beta1.SystemKeeperType + } + + m.Keepers = append(m.Keepers, &ModelKeeper{namespace: keeper.Namespace, name: keeper.Name, keeperType: keeperType}) + + return keeper.DeepCopy(), nil +} + +func (m *ModelGsm) SetKeeperAsActive(namespace, keeperName string) error { + // Set every other keeper in the namespace as inactive + for _, k := range m.Keepers { + if k.namespace == namespace { + k.active = k.name == keeperName + } + } + + return nil +} + +func (m *ModelGsm) Update(now time.Time, newSecureValue *secretv1beta1.SecureValue) (*secretv1beta1.SecureValue, bool, error) { + sv := m.ReadActiveVersion(newSecureValue.Namespace, newSecureValue.Name) + if sv == nil { + return nil, false, contracts.ErrSecureValueNotFound + } + + // If the keeper doesn't exist, return an error + if !m.keeperExists(sv.Namespace, sv.Status.Keeper) { + return nil, false, contracts.ErrKeeperNotFound + } + + // If the payload doesn't contain a value and it's not using a reference, get the value from current version + if newSecureValue.Spec.Value == nil && newSecureValue.Spec.Ref == nil { + // Tried to update a secure value without providing a new value or a ref + if sv.Spec.Value == nil { + return nil, false, contracts.ErrSecureValueMissingSecretAndRef + } + newSecureValue.Spec.Value = sv.Spec.Value + } + + createdSv, err := m.Create(now, newSecureValue) + + return createdSv, true, err +} + +func (m *ModelGsm) Delete(namespace, name string) (*secretv1beta1.SecureValue, error) { + modelSv := m.ReadActiveVersion(namespace, name) + if modelSv == nil { + return nil, contracts.ErrSecureValueNotFound + } + m.SetVersionToInactive(namespace, name, modelSv.Status.Version) + return modelSv.SecureValue, nil +} + +func (m *ModelGsm) List(namespace string) (*secretv1beta1.SecureValueList, error) { + out := make([]secretv1beta1.SecureValue, 0) + + for _, v := range m.SecureValues { + if v.Namespace == namespace && v.active { + out = append(out, *v.SecureValue) + } + } + + return &secretv1beta1.SecureValueList{Items: out}, nil +} + +func (m *ModelGsm) Decrypt(ctx context.Context, decrypter, namespace, name string) (map[string]decrypt.DecryptResult, error) { + for _, v := range m.SecureValues { + if v.Namespace == namespace && + v.Name == name && + v.active { + if slices.ContainsFunc(v.Spec.Decrypters, func(d string) bool { return d == decrypter }) { + switch { + // It's a secure value that specifies the secret + case v.Spec.Value != nil: + return map[string]decrypt.DecryptResult{ + name: decrypt.NewDecryptResultValue(v.DeepCopy().Spec.Value), + }, nil + + // It's a secure value that references a secret on a 3rd party store + case v.Spec.Ref != nil: + keeper := m.findKeeper(v.Namespace, v.Status.Keeper) + switch keeper.keeperType { + case secretv1beta1.AWSKeeperType: + exposedValue, err := m.modelSecretsManager.RetrieveReference(ctx, nil, *v.Spec.Ref) + if err != nil { + return map[string]decrypt.DecryptResult{ + name: decrypt.NewDecryptResultErr(fmt.Errorf("%w: %w", contracts.ErrDecryptFailed, err)), + }, nil + } + return map[string]decrypt.DecryptResult{ + name: decrypt.NewDecryptResultValue(&exposedValue), + }, nil + + // Other keepers are not implemented so we default to the system keeper + default: + // The system keeper doesn't implement Reference so decryption always fails + return map[string]decrypt.DecryptResult{ + name: decrypt.NewDecryptResultErr(contracts.ErrDecryptFailed), + }, nil + } + + default: + panic("bug: secure value where Spec.Value and Spec.Ref are nil") + } + } + + return map[string]decrypt.DecryptResult{ + name: decrypt.NewDecryptResultErr(contracts.ErrDecryptNotAuthorized), + }, nil + } + } + return map[string]decrypt.DecryptResult{ + name: decrypt.NewDecryptResultErr(contracts.ErrDecryptNotFound), + }, nil +} + +func (m *ModelGsm) Read(namespace, name string) (*secretv1beta1.SecureValue, error) { + modelSv := m.ReadActiveVersion(namespace, name) + if modelSv == nil { + return nil, contracts.ErrSecureValueNotFound + } + return modelSv.SecureValue, nil +} + +func (m *ModelGsm) LeaseInactiveSecureValues(now time.Time, minAge, leaseTTL time.Duration, maxBatchSize uint16) ([]*ModelSecureValue, error) { + out := make([]*ModelSecureValue, 0) + + for _, sv := range m.SecureValues { + if len(out) >= int(maxBatchSize) { + break + } + if !sv.active && now.Sub(sv.created) > minAge && now.Sub(sv.leaseCreated) > leaseTTL { + sv.leaseCreated = now + out = append(out, sv) + } + } + + return out, nil +} + +func (m *ModelGsm) CleanupInactiveSecureValues(now time.Time, minAge time.Duration, maxBatchSize uint16) ([]*ModelSecureValue, error) { + // Using a slice to allow duplicates + toDelete := make([]*ModelSecureValue, 0) + + // The implementation query sorts by created time ascending + slices.SortFunc(m.SecureValues, func(a, b *ModelSecureValue) int { + if a.created.Before(b.created) { + return -1 + } else if a.created.After(b.created) { + return 1 + } + return 0 + }) + + for _, sv := range m.SecureValues { + if len(toDelete) >= int(maxBatchSize) { + break + } + + if !sv.active && now.Sub(sv.created) > minAge { + toDelete = append(toDelete, sv) + } + } + + // PERF: The slices are always small + m.SecureValues = slices.DeleteFunc(m.SecureValues, func(v1 *ModelSecureValue) bool { + return slices.ContainsFunc(toDelete, func(v2 *ModelSecureValue) bool { + return v2.UID == v1.UID + }) + }) + + return toDelete, nil +} diff --git a/pkg/registry/apis/secret/testutils/testutils.go b/pkg/registry/apis/secret/testutils/testutils.go index 37394905b79..a3b05c3505f 100644 --- a/pkg/registry/apis/secret/testutils/testutils.go +++ b/pkg/registry/apis/secret/testutils/testutils.go @@ -2,6 +2,7 @@ package testutils import ( "context" + "fmt" "testing" "time" @@ -143,7 +144,8 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut { realMigrationExecutor, err := encryptionstorage.ProvideEncryptedValueMigrationExecutor(database, tracer, encryptedValueStorage, globalEncryptedValueStorage) require.NoError(t, err) - var keeperService contracts.KeeperService = newKeeperServiceWrapper(sqlKeeper) + mockAwsKeeper := NewModelSecretsManager() + var keeperService contracts.KeeperService = newKeeperServiceWrapper(sqlKeeper, mockAwsKeeper) if setupCfg.KeeperService != nil { keeperService = setupCfg.KeeperService @@ -190,6 +192,7 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut { Clock: clock, KeeperService: keeperService, KeeperMetadataStorage: keeperMetadataStorage, + ModelSecretsManager: mockAwsKeeper, } } @@ -212,6 +215,8 @@ type Sut struct { Clock *FakeClock KeeperService contracts.KeeperService KeeperMetadataStorage contracts.KeeperMetadataStorage + // A mock of AWS secrets manager that implements contracts.Keeper + ModelSecretsManager *ModelAWSSecretsManager } type CreateSvConfig struct { @@ -260,16 +265,54 @@ func (s *Sut) DeleteSv(ctx context.Context, namespace, name string) (*secretv1be return sv, err } -type keeperServiceWrapper struct { - keeper contracts.Keeper +type CreateKeeperConfig struct { + // The default keeper payload. Mutate it to change which keeper ends up being created + Keeper *secretv1beta1.Keeper } -func newKeeperServiceWrapper(keeper contracts.Keeper) *keeperServiceWrapper { - return &keeperServiceWrapper{keeper: keeper} +func (s *Sut) CreateAWSKeeper(ctx context.Context) (*secretv1beta1.Keeper, error) { + return s.CreateKeeper(ctx, func(cfg *CreateKeeperConfig) { + cfg.Keeper.Spec = secretv1beta1.KeeperSpec{ + Aws: &secretv1beta1.KeeperAWSConfig{}, + } + }) +} + +func (s *Sut) CreateKeeper(ctx context.Context, opts ...func(*CreateKeeperConfig)) (*secretv1beta1.Keeper, error) { + cfg := CreateKeeperConfig{ + Keeper: &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sv1", + Namespace: "ns1", + }, + Spec: secretv1beta1.KeeperSpec{ + Aws: &secretv1beta1.KeeperAWSConfig{}, + }, + }, + } + for _, opt := range opts { + opt(&cfg) + } + + return s.KeeperMetadataStorage.Create(ctx, cfg.Keeper, "actor-uid") +} + +type keeperServiceWrapper struct { + sqlKeeper *sqlkeeper.SQLKeeper + awsKeeper *ModelAWSSecretsManager +} + +func newKeeperServiceWrapper(sqlKeeper *sqlkeeper.SQLKeeper, awsKeeper *ModelAWSSecretsManager) *keeperServiceWrapper { + return &keeperServiceWrapper{sqlKeeper: sqlKeeper, awsKeeper: awsKeeper} } func (wrapper *keeperServiceWrapper) KeeperForConfig(cfg secretv1beta1.KeeperConfig) (contracts.Keeper, error) { - return wrapper.keeper, nil + switch cfg.(type) { + case *secretv1beta1.NamedKeeperConfig[*secretv1beta1.KeeperAWSConfig]: + return wrapper.awsKeeper, nil + default: + return wrapper.sqlKeeper, nil + } } func CreateUserAuthContext(ctx context.Context, namespace string, permissions map[string][]string) context.Context { @@ -390,3 +433,113 @@ type NoopMigrationExecutor struct { func (e *NoopMigrationExecutor) Execute(ctx context.Context) (int, error) { return 0, nil } + +// A mock of AWS secrets manager, used for testing. +type ModelAWSSecretsManager struct { + secrets map[string]entry + alreadyDeleted map[string]bool +} + +type entry struct { + exposedValueOrRef string + externalID string +} + +func NewModelSecretsManager() *ModelAWSSecretsManager { + return &ModelAWSSecretsManager{ + secrets: make(map[string]entry), + alreadyDeleted: make(map[string]bool), + } +} + +func (m *ModelAWSSecretsManager) Store(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64, exposedValueOrRef string) (externalID contracts.ExternalID, err error) { + if exposedValueOrRef == "" { + return "", fmt.Errorf("failed to satisfy constraint: Member must have length greater than or equal to 1") + } + + versionID := buildVersionID(namespace, name, version) + if e, ok := m.secrets[versionID]; ok { + // Ignore duplicated requests + if e.exposedValueOrRef == exposedValueOrRef { + return contracts.ExternalID(e.externalID), nil + } + + // Tried to create a secret that already exists + return "", fmt.Errorf("ResourceExistsException: The operation failed because the secret %+v already exists", versionID) + } + + // First time creating the secret + entry := entry{ + exposedValueOrRef: exposedValueOrRef, + externalID: "external-id", + } + m.secrets[versionID] = entry + + return contracts.ExternalID(entry.externalID), nil +} + +// Used to simulate the creation of secrets in the 3rd party secret store +func (m *ModelAWSSecretsManager) Create(name, value string) { + m.secrets[name] = entry{ + exposedValueOrRef: value, + externalID: fmt.Sprintf("external_id_%+v", value), + } +} + +func (m *ModelAWSSecretsManager) Expose(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64) (exposedValue secretv1beta1.ExposedSecureValue, err error) { + versionID := buildVersionID(namespace, name, version) + + if m.deleted(versionID) { + return "", fmt.Errorf("InvalidRequestException: You can't perform this operation on the secret because it was marked for deletion") + } + + entry, ok := m.secrets[versionID] + if !ok { + return "", fmt.Errorf("ResourceNotFoundException: Secrets Manager can't find the specified secret") + } + + return secretv1beta1.ExposedSecureValue(entry.exposedValueOrRef), nil +} + +// TODO: this could be namespaced to make it more realistic +func (m *ModelAWSSecretsManager) RetrieveReference(ctx context.Context, _ secretv1beta1.KeeperConfig, ref string) (secretv1beta1.ExposedSecureValue, error) { + entry, ok := m.secrets[ref] + if !ok { + return "", fmt.Errorf("ResourceNotFoundException: Secrets Manager can't find the specified secret") + } + return secretv1beta1.ExposedSecureValue(entry.exposedValueOrRef), nil +} + +func (m *ModelAWSSecretsManager) Delete(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64) (err error) { + versionID := buildVersionID(namespace, name, version) + + // Deleting a secret that existed at some point is idempotent + if m.deleted(versionID) { + return nil + } + + // If the secret is being deleted for the first time + if m.exists(versionID) { + m.delete(versionID) + } + + return nil +} + +func (m *ModelAWSSecretsManager) deleted(versionID string) bool { + return m.alreadyDeleted[versionID] +} + +func (m *ModelAWSSecretsManager) exists(versionID string) bool { + _, ok := m.secrets[versionID] + return ok +} + +func (m *ModelAWSSecretsManager) delete(versionID string) { + m.alreadyDeleted[versionID] = true + delete(m.secrets, versionID) +} + +func buildVersionID(namespace xkube.Namespace, name string, version int64) string { + return fmt.Sprintf("%s/%s/%d", namespace, name, version) +} diff --git a/pkg/registry/apis/secret/validator/keeper.go b/pkg/registry/apis/secret/validator/keeper.go index b4220b45a65..0a1700a79c2 100644 --- a/pkg/registry/apis/secret/validator/keeper.go +++ b/pkg/registry/apis/secret/validator/keeper.go @@ -1,6 +1,8 @@ package validator import ( + "context" + "fmt" "strings" "k8s.io/apimachinery/pkg/util/validation" @@ -9,14 +11,17 @@ import ( secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) -type keeperValidator struct{} +type keeperValidator struct { + features featuremgmt.FeatureToggles +} var _ contracts.KeeperValidator = &keeperValidator{} -func ProvideKeeperValidator() contracts.KeeperValidator { - return &keeperValidator{} +func ProvideKeeperValidator(features featuremgmt.FeatureToggles) contracts.KeeperValidator { + return &keeperValidator{features: features} } func (v *keeperValidator) Validate(keeper *secretv1beta1.Keeper, oldKeeper *secretv1beta1.Keeper, operation admission.Operation) field.ErrorList { @@ -57,51 +62,110 @@ func (v *keeperValidator) Validate(keeper *secretv1beta1.Keeper, oldKeeper *secr } if keeper.Spec.Aws != nil { - if err := validateCredentialValue(field.NewPath("spec", "aws", "accessKeyID"), keeper.Spec.Aws.AccessKeyID); err != nil { - errs = append(errs, err) - } - - if err := validateCredentialValue(field.NewPath("spec", "aws", "secretAccessKey"), keeper.Spec.Aws.SecretAccessKey); err != nil { - errs = append(errs, err) + //nolint + if !v.features.IsEnabled(context.Background(), featuremgmt.FlagSecretsManagementAppPlatformAwsKeeper) { + errs = append(errs, + field.Forbidden(field.NewPath("spec", "aws"), + fmt.Sprintf("enable aws keeper feature toggle to create aws keepers: %s", featuremgmt.FlagSecretsManagementAppPlatformAwsKeeper))) + } else { + errs = append(errs, validateAws(keeper.Spec.Aws)...) } } if keeper.Spec.Azure != nil { - if keeper.Spec.Azure.KeyVaultName == "" { - errs = append(errs, field.Required(field.NewPath("spec", "azure", "keyVaultName"), "a `keyVaultName` is required")) - } - - if keeper.Spec.Azure.TenantID == "" { - errs = append(errs, field.Required(field.NewPath("spec", "azure", "tenantID"), "a `tenantID` is required")) - } - - if keeper.Spec.Azure.ClientID == "" { - errs = append(errs, field.Required(field.NewPath("spec", "azure", "clientID"), "a `clientID` is required")) - } - - if err := validateCredentialValue(field.NewPath("spec", "azure", "clientSecret"), keeper.Spec.Azure.ClientSecret); err != nil { - errs = append(errs, err) - } + errs = append(errs, validateAzure(keeper.Spec.Azure)...) } if keeper.Spec.Gcp != nil { - if keeper.Spec.Gcp.ProjectID == "" { - errs = append(errs, field.Required(field.NewPath("spec", "gcp", "projectID"), "a `projectID` is required")) - } - - if keeper.Spec.Gcp.CredentialsFile == "" { - errs = append(errs, field.Required(field.NewPath("spec", "gcp", "credentialsFile"), "a `credentialsFile` is required")) - } + errs = append(errs, validateGcp(keeper.Spec.Gcp)...) } if keeper.Spec.HashiCorpVault != nil { - if keeper.Spec.HashiCorpVault.Address == "" { - errs = append(errs, field.Required(field.NewPath("spec", "hashiCorpVault", "address"), "an `address` is required")) - } + errs = append(errs, validateHashiCorpVault(keeper.Spec.HashiCorpVault)...) + } - if err := validateCredentialValue(field.NewPath("spec", "hashiCorpVault", "token"), keeper.Spec.HashiCorpVault.Token); err != nil { + return errs +} + +func validateAws(cfg *secretv1beta1.KeeperAWSConfig) field.ErrorList { + errs := make(field.ErrorList, 0) + + if cfg.Region == "" { + errs = append(errs, field.Required(field.NewPath("spec", "aws", "region"), "region must be present")) + } + + switch { + case cfg.AccessKey == nil && cfg.AssumeRole == nil: + errs = append(errs, field.Required(field.NewPath("spec", "aws"), "one of `accessKey` or `assumeRole` must be present")) + + case cfg.AccessKey != nil && cfg.AssumeRole != nil: + errs = append(errs, field.Required(field.NewPath("spec", "aws"), "only one of `accessKey` or `assumeRole` can be present")) + + case cfg.AccessKey != nil: + if err := validateCredentialValue(field.NewPath("spec", "aws", "accessKey", "accessKeyID"), cfg.AccessKey.AccessKeyID); err != nil { errs = append(errs, err) } + if err := validateCredentialValue(field.NewPath("spec", "aws", "accessKey", "secretAccessKey"), cfg.AccessKey.SecretAccessKey); err != nil { + errs = append(errs, err) + } + + case cfg.AssumeRole != nil: + if cfg.AssumeRole.AssumeRoleArn == "" { + errs = append(errs, field.Required(field.NewPath("spec", "aws", "assumeRole", "assumeRoleArn"), "arn of the role to assume must be present")) + } + if cfg.AssumeRole.ExternalID == "" { + errs = append(errs, field.Required(field.NewPath("spec", "aws", "assumeRole", "externalId"), "externalId must be present")) + } + } + + return errs +} + +func validateAzure(cfg *secretv1beta1.KeeperAzureConfig) field.ErrorList { + errs := make(field.ErrorList, 0) + + if cfg.KeyVaultName == "" { + errs = append(errs, field.Required(field.NewPath("spec", "azure", "keyVaultName"), "a `keyVaultName` is required")) + } + + if cfg.TenantID == "" { + errs = append(errs, field.Required(field.NewPath("spec", "azure", "tenantID"), "a `tenantID` is required")) + } + + if cfg.ClientID == "" { + errs = append(errs, field.Required(field.NewPath("spec", "azure", "clientID"), "a `clientID` is required")) + } + + if err := validateCredentialValue(field.NewPath("spec", "azure", "clientSecret"), cfg.ClientSecret); err != nil { + errs = append(errs, err) + } + + return errs +} + +func validateGcp(cfg *secretv1beta1.KeeperGCPConfig) field.ErrorList { + errs := make(field.ErrorList, 0) + + if cfg.ProjectID == "" { + errs = append(errs, field.Required(field.NewPath("spec", "gcp", "projectID"), "a `projectID` is required")) + } + + if cfg.CredentialsFile == "" { + errs = append(errs, field.Required(field.NewPath("spec", "gcp", "credentialsFile"), "a `credentialsFile` is required")) + } + + return errs +} + +func validateHashiCorpVault(cfg *secretv1beta1.KeeperHashiCorpConfig) field.ErrorList { + errs := make(field.ErrorList, 0) + + if cfg.Address == "" { + errs = append(errs, field.Required(field.NewPath("spec", "hashiCorpVault", "address"), "an `address` is required")) + } + + if err := validateCredentialValue(field.NewPath("spec", "hashiCorpVault", "token"), cfg.Token); err != nil { + errs = append(errs, err) } return errs diff --git a/pkg/registry/apis/secret/validator/keeper_test.go b/pkg/registry/apis/secret/validator/keeper_test.go index 99b108e480e..c26f1924733 100644 --- a/pkg/registry/apis/secret/validator/keeper_test.go +++ b/pkg/registry/apis/secret/validator/keeper_test.go @@ -10,11 +10,12 @@ import ( "k8s.io/utils/ptr" secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) func TestValidateKeeper(t *testing.T) { objectMeta := metav1.ObjectMeta{Name: "test", Namespace: "test"} - validator := ProvideKeeperValidator() + validator := ProvideKeeperValidator(featuremgmt.WithFeatures(featuremgmt.FlagSecretsManagementAppPlatformAwsKeeper)) t.Run("when creating a new keeper", func(t *testing.T) { t.Run("the `description` must be present", func(t *testing.T) { @@ -22,9 +23,12 @@ func TestValidateKeeper(t *testing.T) { ObjectMeta: objectMeta, Spec: secretv1beta1.KeeperSpec{ Aws: &secretv1beta1.KeeperAWSConfig{ - AccessKeyID: secretv1beta1.KeeperCredentialValue{ValueFromEnv: "some-value"}, - SecretAccessKey: secretv1beta1.KeeperCredentialValue{ValueFromEnv: "some-value"}, - KmsKeyID: ptr.To("kms-key-id"), + Region: "us-east-1", + AccessKey: &secretv1beta1.KeeperAWSAccessKey{ + AccessKeyID: secretv1beta1.KeeperCredentialValue{ValueFromEnv: "some-value"}, + SecretAccessKey: secretv1beta1.KeeperCredentialValue{ValueFromEnv: "some-value"}, + }, + KmsKeyID: ptr.To("kms-key-id"), }, }, } @@ -41,30 +45,42 @@ func TestValidateKeeper(t *testing.T) { Spec: secretv1beta1.KeeperSpec{ Description: "description", Aws: &secretv1beta1.KeeperAWSConfig{ - AccessKeyID: secretv1beta1.KeeperCredentialValue{ - ValueFromEnv: "some-value", - }, - SecretAccessKey: secretv1beta1.KeeperCredentialValue{ - SecureValueName: "some-value", + Region: "us-east-1", + AccessKey: &secretv1beta1.KeeperAWSAccessKey{ + AccessKeyID: secretv1beta1.KeeperCredentialValue{ + ValueFromEnv: "some-value", + }, + SecretAccessKey: secretv1beta1.KeeperCredentialValue{ + SecureValueName: "some-value", + }, }, KmsKeyID: ptr.To("optional"), }, }, } + t.Run("aws keeper feature flag must be enabled", func(t *testing.T) { + // Validator with feature disabled + validator := ProvideKeeperValidator(featuremgmt.WithFeatures()) + errs := validator.Validate(validKeeperAWS.DeepCopy(), nil, admission.Create) + require.Len(t, errs, 1) + require.Equal(t, "spec.aws", errs[0].Field) + require.Contains(t, errs[0].Detail, "secretsManagementAppPlatformAwsKeeper") + }) + t.Run("`accessKeyID` must be present", func(t *testing.T) { t.Run("at least one of the credential value must be present", func(t *testing.T) { keeper := validKeeperAWS.DeepCopy() - keeper.Spec.Aws.AccessKeyID = secretv1beta1.KeeperCredentialValue{} + keeper.Spec.Aws.AccessKey.AccessKeyID = secretv1beta1.KeeperCredentialValue{} errs := validator.Validate(keeper, nil, admission.Create) require.Len(t, errs, 1) - require.Equal(t, "spec.aws.accessKeyID", errs[0].Field) + require.Equal(t, "spec.aws.accessKey.accessKeyID", errs[0].Field) }) t.Run("at most one of the credential value must be present", func(t *testing.T) { keeper := validKeeperAWS.DeepCopy() - keeper.Spec.Aws.AccessKeyID = secretv1beta1.KeeperCredentialValue{ + keeper.Spec.Aws.AccessKey.AccessKeyID = secretv1beta1.KeeperCredentialValue{ SecureValueName: "a", ValueFromEnv: "b", ValueFromConfig: "c", @@ -72,23 +88,23 @@ func TestValidateKeeper(t *testing.T) { errs := validator.Validate(keeper, nil, admission.Create) require.Len(t, errs, 1) - require.Equal(t, "spec.aws.accessKeyID", errs[0].Field) + require.Equal(t, "spec.aws.accessKey.accessKeyID", errs[0].Field) }) }) t.Run("`secretAccessKey` must be present", func(t *testing.T) { t.Run("at least one of the credential value must be present", func(t *testing.T) { keeper := validKeeperAWS.DeepCopy() - keeper.Spec.Aws.SecretAccessKey = secretv1beta1.KeeperCredentialValue{} + keeper.Spec.Aws.AccessKey.SecretAccessKey = secretv1beta1.KeeperCredentialValue{} errs := validator.Validate(keeper, nil, admission.Create) require.Len(t, errs, 1) - require.Equal(t, "spec.aws.secretAccessKey", errs[0].Field) + require.Equal(t, "spec.aws.accessKey.secretAccessKey", errs[0].Field) }) t.Run("at most one of the credential value must be present", func(t *testing.T) { keeper := validKeeperAWS.DeepCopy() - keeper.Spec.Aws.SecretAccessKey = secretv1beta1.KeeperCredentialValue{ + keeper.Spec.Aws.AccessKey.SecretAccessKey = secretv1beta1.KeeperCredentialValue{ SecureValueName: "a", ValueFromEnv: "b", ValueFromConfig: "c", @@ -96,7 +112,23 @@ func TestValidateKeeper(t *testing.T) { errs := validator.Validate(keeper, nil, admission.Create) require.Len(t, errs, 1) - require.Equal(t, "spec.aws.secretAccessKey", errs[0].Field) + require.Equal(t, "spec.aws.accessKey.secretAccessKey", errs[0].Field) + }) + + t.Run("only one of accessKey or assumeRole can be present", func(t *testing.T) { + keeper := validKeeperAWS.DeepCopy() + keeper.Spec.Aws.AccessKey.SecretAccessKey = secretv1beta1.KeeperCredentialValue{ + SecureValueName: "a", + } + keeper.Spec.Aws.AssumeRole = &secretv1beta1.KeeperAWSAssumeRole{ + AssumeRoleArn: "arn", + ExternalID: "id", + } + + errs := validator.Validate(keeper, nil, admission.Create) + require.Len(t, errs, 1) + require.Equal(t, "spec.aws", errs[0].Field) + require.Equal(t, "only one of `accessKey` or `assumeRole` can be present", errs[0].Detail) }) }) }) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 2933551d1ad..d325597a04b 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2082,6 +2082,14 @@ var ( FrontendOnly: true, Owner: grafanaDataProSquad, }, + { + Name: "secretsManagementAppPlatformAwsKeeper", + Description: "Enables the creation of keepers that manage secrets stored on AWS secrets manager", + Stage: FeatureStageExperimental, + HideFromDocs: true, + FrontendOnly: false, + Owner: grafanaOperatorExperienceSquad, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index e2d15a8466b..a5041e1a42d 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -282,3 +282,4 @@ kubernetesAlertingHistorian,experimental,@grafana/alerting-squad,false,true,fals useMTPlugins,experimental,@grafana/plugins-platform-backend,false,false,true multiPropsVariables,experimental,@grafana/dashboards-squad,false,false,true smoothingTransformation,experimental,@grafana/datapro,false,false,true +secretsManagementAppPlatformAwsKeeper,experimental,@grafana/grafana-operator-experience-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 5de71954e2f..5ef42eb6543 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -781,4 +781,8 @@ const ( // FlagKubernetesAlertingHistorian // Adds support for Kubernetes alerting historian APIs FlagKubernetesAlertingHistorian = "kubernetesAlertingHistorian" + + // FlagSecretsManagementAppPlatformAwsKeeper + // Enables the creation of keepers that manage secrets stored on AWS secrets manager + FlagSecretsManagementAppPlatformAwsKeeper = "secretsManagementAppPlatformAwsKeeper" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 66910dc9d1c..6831c7045b3 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3254,6 +3254,22 @@ "codeowner": "@grafana/grafana-operator-experience-squad" } }, + { + "metadata": { + "name": "secretsManagementAppPlatformAwsKeeper", + "resourceVersion": "1767706420889", + "creationTimestamp": "2026-01-06T12:55:50Z", + "annotations": { + "grafana.app/updatedTimestamp": "2026-01-06 13:33:40.889447 +0000 UTC" + } + }, + "spec": { + "description": "Enables the creation of keepers that manage secrets stored on AWS secrets manager", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad", + "hideFromDocs": true + } + }, { "metadata": { "name": "secretsManagementAppPlatformUI", diff --git a/pkg/setting/setting_secrets_manager.go b/pkg/setting/setting_secrets_manager.go index 260b98264d8..5730d27a74f 100644 --- a/pkg/setting/setting_secrets_manager.go +++ b/pkg/setting/setting_secrets_manager.go @@ -40,6 +40,10 @@ type SecretsManagerSettings struct { RunSecretsDBMigrations bool // Whether to run the data key id migration. Requires that RunSecretsDBMigrations is also true. RunDataKeyMigration bool + + // AWS Keeper + AWSKeeperAccessKeyID string + AWSKeeperSecretAccessKey string } func (cfg *Cfg) readSecretsManagerSettings() { @@ -63,6 +67,9 @@ func (cfg *Cfg) readSecretsManagerSettings() { cfg.SecretsManagement.RunSecretsDBMigrations = secretsMgmt.Key("run_secrets_db_migrations").MustBool(true) cfg.SecretsManagement.RunDataKeyMigration = secretsMgmt.Key("run_data_key_migration").MustBool(true) + cfg.SecretsManagement.AWSKeeperAccessKeyID = secretsMgmt.Key("aws_access_key_id").MustString("") + cfg.SecretsManagement.AWSKeeperSecretAccessKey = secretsMgmt.Key("aws_secret_access_key").MustString("") + // Extract available KMS providers from configuration sections providers := make(map[string]map[string]string) for _, section := range cfg.Raw.Sections() { diff --git a/pkg/storage/secret/metadata/decrypt_store.go b/pkg/storage/secret/metadata/decrypt_store.go index 4c0ebabb5e8..0896ab89df4 100644 --- a/pkg/storage/secret/metadata/decrypt_store.go +++ b/pkg/storage/secret/metadata/decrypt_store.go @@ -145,6 +145,14 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, return "", fmt.Errorf("failed to get keeper for config: %v (%w)", err, contracts.ErrDecryptFailed) } + if sv.Spec.Ref != nil { + exposedValue, err := keeper.RetrieveReference(ctx, keeperConfig, *sv.Spec.Ref) + if err != nil { + return "", fmt.Errorf("failed to expose secret using reference: %v (%w)", err, contracts.ErrDecryptFailed) + } + return exposedValue, nil + } + exposedValue, err := keeper.Expose(ctx, keeperConfig, namespace, name, sv.Status.Version) if err != nil { return "", fmt.Errorf("failed to expose secret: %v (%w)", err, contracts.ErrDecryptFailed) diff --git a/pkg/storage/secret/metadata/decrypt_store_test.go b/pkg/storage/secret/metadata/decrypt_store_test.go index c07b65dbf04..36e49df0146 100644 --- a/pkg/storage/secret/metadata/decrypt_store_test.go +++ b/pkg/storage/secret/metadata/decrypt_store_test.go @@ -9,12 +9,14 @@ import ( "github.com/grafana/grafana-app-sdk/logging" "github.com/stretchr/testify/require" grpcmetadata "google.golang.org/grpc/metadata" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/registry/apis/secret/testutils" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" "github.com/grafana/grafana/pkg/util/testutil" ) @@ -324,6 +326,66 @@ func TestIntegrationDecrypt(t *testing.T) { } } }) + + t.Run("happy path, referencing a secret in a 3rd party store", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + tokenSvcIdentity := "svc" + stSvcIdentity := "st-svc" + + // Create auth context with proper permissions that match the decrypters + authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues:decrypt"}, tokenSvcIdentity, types.TypeUser) + + // Needs to be incoming because we are pretending we received the metadata from a gRPC request + ctx = grpcmetadata.NewIncomingContext(authCtx, grpcmetadata.New(map[string]string{ + contracts.HeaderGrafanaServiceIdentityName: stSvcIdentity, + })) + + // Setup service + sut := testutils.Setup(t) + + // Create a secret on the 3rd party secret store + sut.ModelSecretsManager.Create("ref1", "value") + + // Create a 3rd party keeper + keeper, err := sut.KeeperMetadataStorage.Create(t.Context(), &secretv1beta1.Keeper{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "k1", + }, + Spec: secretv1beta1.KeeperSpec{ + Aws: &secretv1beta1.KeeperAWSConfig{}, + }, + }, "actor-uid") + require.NoError(t, err) + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(keeper.Namespace), keeper.Name)) + + // Create a secure value + sv := &secretv1beta1.SecureValue{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "sv-test", + }, + Spec: secretv1beta1.SecureValueSpec{ + Description: "description", + Decrypters: []string{tokenSvcIdentity}, + Ref: ptr.To("ref1"), + }} + + _, err = sut.CreateSv(ctx, testutils.CreateSvWithSv(sv)) + require.NoError(t, err) + + fakeLogger := &mockLogger{} + + loggerCtx := logging.Context(ctx, fakeLogger) + + exposed, err := sut.DecryptStorage.Decrypt(loggerCtx, "default", "sv-test") + require.NoError(t, err) + require.Equal(t, "value", exposed.DangerouslyExposeAndConsumeValue()) + }) } func createAuthContext(ctx context.Context, namespace string, permissions []string, svc string, identityType types.IdentityType) context.Context { diff --git a/pkg/storage/secret/metadata/keeper_model.go b/pkg/storage/secret/metadata/keeper_model.go index 4831ee25a2e..c97f6adf679 100644 --- a/pkg/storage/secret/metadata/keeper_model.go +++ b/pkg/storage/secret/metadata/keeper_model.go @@ -59,16 +59,16 @@ func (kp *keeperDB) toKubernetes() (*secretv1beta1.Keeper, error) { } // Obtain provider configs - provider := toProvider(secretv1beta1.KeeperType(kp.Type), kp.Payload) + provider := parseKeeperConfigJson(kp.Name, secretv1beta1.KeeperType(kp.Type), kp.Payload) switch v := provider.(type) { - case *secretv1beta1.KeeperAWSConfig: - resource.Spec.Aws = v - case *secretv1beta1.KeeperAzureConfig: - resource.Spec.Azure = v - case *secretv1beta1.KeeperGCPConfig: - resource.Spec.Gcp = v - case *secretv1beta1.KeeperHashiCorpConfig: - resource.Spec.HashiCorpVault = v + case *secretv1beta1.NamedKeeperConfig[*secretv1beta1.KeeperAWSConfig]: + resource.Spec.Aws = v.Cfg + case *secretv1beta1.NamedKeeperConfig[*secretv1beta1.KeeperAzureConfig]: + resource.Spec.Azure = v.Cfg + case *secretv1beta1.NamedKeeperConfig[*secretv1beta1.KeeperGCPConfig]: + resource.Spec.Gcp = v.Cfg + case *secretv1beta1.NamedKeeperConfig[*secretv1beta1.KeeperHashiCorpConfig]: + resource.Spec.HashiCorpVault = v.Cfg } // Set all meta fields here for consistency. @@ -214,34 +214,34 @@ func toTypeAndPayload(kp *secretv1beta1.Keeper) (secretv1beta1.KeeperType, strin return "", "", fmt.Errorf("no keeper type found") } -// toProvider maps a KeeperType and payload into a provider config struct. +// parseKeeperConfigJson maps a KeeperType and payload into a provider config struct. // TODO: Move as method of KeeperType -func toProvider(keeperType secretv1beta1.KeeperType, payload string) secretv1beta1.KeeperConfig { +func parseKeeperConfigJson(keeperName string, keeperType secretv1beta1.KeeperType, payload string) secretv1beta1.KeeperConfig { switch keeperType { case secretv1beta1.AWSKeeperType: aws := &secretv1beta1.KeeperAWSConfig{} if err := json.Unmarshal([]byte(payload), aws); err != nil { return nil } - return aws + return secretv1beta1.NewNamedKeeperConfig(keeperName, aws) case secretv1beta1.AzureKeeperType: azure := &secretv1beta1.KeeperAzureConfig{} if err := json.Unmarshal([]byte(payload), azure); err != nil { return nil } - return azure + return secretv1beta1.NewNamedKeeperConfig(keeperName, azure) case secretv1beta1.GCPKeeperType: gcp := &secretv1beta1.KeeperGCPConfig{} if err := json.Unmarshal([]byte(payload), gcp); err != nil { return nil } - return gcp + return secretv1beta1.NewNamedKeeperConfig(keeperName, gcp) case secretv1beta1.HashiCorpKeeperType: hashicorp := &secretv1beta1.KeeperHashiCorpConfig{} if err := json.Unmarshal([]byte(payload), hashicorp); err != nil { return nil } - return hashicorp + return secretv1beta1.NewNamedKeeperConfig(keeperName, hashicorp) default: return nil } @@ -253,12 +253,16 @@ func extractSecureValues(kp *secretv1beta1.Keeper) map[string]struct{} { case kp.Spec.Aws != nil: secureValues := make(map[string]struct{}, 0) - if kp.Spec.Aws.AccessKeyID.SecureValueName != "" { - secureValues[kp.Spec.Aws.AccessKeyID.SecureValueName] = struct{}{} + if kp.Spec.Aws.AccessKey == nil { + return secureValues } - if kp.Spec.Aws.SecretAccessKey.SecureValueName != "" { - secureValues[kp.Spec.Aws.SecretAccessKey.SecureValueName] = struct{}{} + if kp.Spec.Aws.AccessKey.AccessKeyID.SecureValueName != "" { + secureValues[kp.Spec.Aws.AccessKey.AccessKeyID.SecureValueName] = struct{}{} + } + + if kp.Spec.Aws.AccessKey.SecretAccessKey.SecureValueName != "" { + secureValues[kp.Spec.Aws.AccessKey.SecretAccessKey.SecureValueName] = struct{}{} } return secureValues @@ -284,13 +288,13 @@ func extractSecureValues(kp *secretv1beta1.Keeper) map[string]struct{} { func getKeeperConfig(keeper *secretv1beta1.Keeper) secretv1beta1.KeeperConfig { switch keeper.Spec.GetType() { case secretv1beta1.AWSKeeperType: - return keeper.Spec.Aws + return secretv1beta1.NewNamedKeeperConfig(keeper.Name, keeper.Spec.Aws) case secretv1beta1.AzureKeeperType: - return keeper.Spec.Azure + return secretv1beta1.NewNamedKeeperConfig(keeper.Name, keeper.Spec.Azure) case secretv1beta1.GCPKeeperType: - return keeper.Spec.Gcp + return secretv1beta1.NewNamedKeeperConfig(keeper.Name, keeper.Spec.Gcp) case secretv1beta1.HashiCorpKeeperType: - return keeper.Spec.HashiCorpVault + return secretv1beta1.NewNamedKeeperConfig(keeper.Name, keeper.Spec.HashiCorpVault) default: return nil } diff --git a/pkg/storage/secret/metadata/keeper_store.go b/pkg/storage/secret/metadata/keeper_store.go index d4516158c81..fe57816444d 100644 --- a/pkg/storage/secret/metadata/keeper_store.go +++ b/pkg/storage/secret/metadata/keeper_store.go @@ -609,7 +609,7 @@ func (s *keeperMetadataStorage) GetKeeperConfig(ctx context.Context, namespace s // Check if keeper is the systemwide one. if name == contracts.SystemKeeperName { - return &secretv1beta1.SystemKeeperConfig{}, nil + return secretv1beta1.NewNamedKeeperConfig(contracts.SystemKeeperName, &secretv1beta1.SystemKeeperConfig{}), nil } // Load keeper config from metadata store, or TODO: keeper cache. @@ -618,7 +618,7 @@ func (s *keeperMetadataStorage) GetKeeperConfig(ctx context.Context, namespace s return nil, err } - keeperConfig := toProvider(secretv1beta1.KeeperType(kp.Type), kp.Payload) + keeperConfig := parseKeeperConfigJson(kp.Name, secretv1beta1.KeeperType(kp.Type), kp.Payload) // TODO: this would be a good place to check if credentials are secure values and load them. return keeperConfig, nil @@ -636,13 +636,6 @@ func (s *keeperMetadataStorage) SetAsActive(ctx context.Context, namespace xkube return fmt.Errorf("template %q: %w", sqlKeeperSetAsActive.Name(), err) } - // Check keeper exists. No need to worry about time of check to time of use - // since trying to activate a just deleted keeper will result in all - // keepers being inactive and defaulting to the system keeper. - if _, err := s.read(ctx, namespace.String(), name, contracts.ReadOpts{}); err != nil { - return fmt.Errorf("reading keeper before setting as active: %w", err) - } - _, err = s.db.ExecContext(ctx, query, req.GetArgs()...) if err != nil { return fmt.Errorf("setting keeper as active %q: %w", query, err) @@ -726,7 +719,7 @@ func (s *keeperMetadataStorage) GetActiveKeeperConfig(ctx context.Context, names if err != nil { // When there are not active keepers, default to the system keeper if errors.Is(err, contracts.ErrKeeperNotFound) { - return contracts.SystemKeeperName, &secretv1beta1.SystemKeeperConfig{}, nil + return contracts.SystemKeeperName, secretv1beta1.NewNamedKeeperConfig(contracts.SystemKeeperName, &secretv1beta1.SystemKeeperConfig{}), nil } return "", nil, fmt.Errorf("fetching active keeper from db: %w", err) } diff --git a/pkg/storage/secret/metadata/keeper_store_test.go b/pkg/storage/secret/metadata/keeper_store_test.go index a38e0ec85b3..fe7b3cb9eaf 100644 --- a/pkg/storage/secret/metadata/keeper_store_test.go +++ b/pkg/storage/secret/metadata/keeper_store_test.go @@ -43,7 +43,7 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { // get system keeper config keeperConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, defaultKeeperNS, contracts.SystemKeeperName, contracts.ReadOpts{}) require.NoError(t, err) - require.IsType(t, &secretv1beta1.SystemKeeperConfig{}, keeperConfig) + require.IsType(t, &secretv1beta1.NamedKeeperConfig[*secretv1beta1.SystemKeeperConfig]{}, keeperConfig) }) t.Run("get test keeper config", func(t *testing.T) { @@ -188,11 +188,13 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { Spec: secretv1beta1.KeeperSpec{ Description: "initial description", Aws: &secretv1beta1.KeeperAWSConfig{ - AccessKeyID: secretv1beta1.KeeperCredentialValue{ - ValueFromEnv: "AWS_ACCESS_KEY_ID_1", - }, - SecretAccessKey: secretv1beta1.KeeperCredentialValue{ - ValueFromEnv: "AWS_SECRET_ACCESS_KEY_1", + AccessKey: &secretv1beta1.KeeperAWSAccessKey{ + AccessKeyID: secretv1beta1.KeeperCredentialValue{ + ValueFromEnv: "AWS_ACCESS_KEY_ID_1", + }, + SecretAccessKey: secretv1beta1.KeeperCredentialValue{ + ValueFromEnv: "AWS_SECRET_ACCESS_KEY_1", + }, }, KmsKeyID: ptr.To("kms-key-id-1"), }, @@ -208,8 +210,8 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { // Verify initial AWS config keeper, err := keeperMetadataStorage.Read(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest, contracts.ReadOpts{}) require.NoError(t, err) - require.Equal(t, "AWS_ACCESS_KEY_ID_1", keeper.Spec.Aws.AccessKeyID.ValueFromEnv) - require.Equal(t, "AWS_SECRET_ACCESS_KEY_1", keeper.Spec.Aws.SecretAccessKey.ValueFromEnv) + require.Equal(t, "AWS_ACCESS_KEY_ID_1", keeper.Spec.Aws.AccessKey.AccessKeyID.ValueFromEnv) + require.Equal(t, "AWS_SECRET_ACCESS_KEY_1", keeper.Spec.Aws.AccessKey.SecretAccessKey.ValueFromEnv) require.Equal(t, "kms-key-id-1", *keeper.Spec.Aws.KmsKeyID) // Update with new AWS config @@ -217,11 +219,13 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { Spec: secretv1beta1.KeeperSpec{ Description: "updated description", Aws: &secretv1beta1.KeeperAWSConfig{ - AccessKeyID: secretv1beta1.KeeperCredentialValue{ - ValueFromEnv: "AWS_ACCESS_KEY_ID_2", - }, - SecretAccessKey: secretv1beta1.KeeperCredentialValue{ - ValueFromEnv: "AWS_SECRET_ACCESS_KEY_2", + AccessKey: &secretv1beta1.KeeperAWSAccessKey{ + AccessKeyID: secretv1beta1.KeeperCredentialValue{ + ValueFromEnv: "AWS_ACCESS_KEY_ID_2", + }, + SecretAccessKey: secretv1beta1.KeeperCredentialValue{ + ValueFromEnv: "AWS_SECRET_ACCESS_KEY_2", + }, }, KmsKeyID: ptr.To("kms-key-id-2"), }, @@ -237,8 +241,8 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { // Verify updated AWS config updatedKeeper, err = keeperMetadataStorage.Read(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest, contracts.ReadOpts{}) require.NoError(t, err) - require.Equal(t, "AWS_ACCESS_KEY_ID_2", updatedKeeper.Spec.Aws.AccessKeyID.ValueFromEnv) - require.Equal(t, "AWS_SECRET_ACCESS_KEY_2", updatedKeeper.Spec.Aws.SecretAccessKey.ValueFromEnv) + require.Equal(t, "AWS_ACCESS_KEY_ID_2", updatedKeeper.Spec.Aws.AccessKey.AccessKeyID.ValueFromEnv) + require.Equal(t, "AWS_SECRET_ACCESS_KEY_2", updatedKeeper.Spec.Aws.AccessKey.SecretAccessKey.ValueFromEnv) require.Equal(t, "kms-key-id-2", *updatedKeeper.Spec.Aws.KmsKeyID) }) @@ -278,11 +282,13 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { Spec: secretv1beta1.KeeperSpec{ Description: "initial description", Aws: &secretv1beta1.KeeperAWSConfig{ - AccessKeyID: secretv1beta1.KeeperCredentialValue{ - ValueFromEnv: "AWS_ACCESS_KEY_ID", - }, - SecretAccessKey: secretv1beta1.KeeperCredentialValue{ - ValueFromEnv: "AWS_SECRET_ACCESS_KEY", + AccessKey: &secretv1beta1.KeeperAWSAccessKey{ + AccessKeyID: secretv1beta1.KeeperCredentialValue{ + ValueFromEnv: "AWS_ACCESS_KEY_ID", + }, + SecretAccessKey: secretv1beta1.KeeperCredentialValue{ + ValueFromEnv: "AWS_SECRET_ACCESS_KEY", + }, }, }, }, diff --git a/pkg/storage/secret/metadata/secure_value_store_test.go b/pkg/storage/secret/metadata/secure_value_store_test.go index f7551e9e484..9f3a99a2372 100644 --- a/pkg/storage/secret/metadata/secure_value_store_test.go +++ b/pkg/storage/secret/metadata/secure_value_store_test.go @@ -194,12 +194,12 @@ func TestPropertySecureValueMetadataStorage(t *testing.T) { rapid.Check(t, func(t *rapid.T) { sut := testutils.Setup(tt) - model := newModel() + model := testutils.NewModelGsm(nil) t.Repeat(map[string]func(*rapid.T){ "create": func(t *rapid.T) { - sv := anySecureValueGen.Draw(t, "sv") - modelCreatedSv, modelErr := model.create(sut.Clock.Now(), sv.DeepCopy()) + sv := testutils.AnySecureValueGen.Draw(t, "sv") + modelCreatedSv, modelErr := model.Create(sut.Clock.Now(), sv.DeepCopy()) createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv.DeepCopy())) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) @@ -209,10 +209,23 @@ func TestPropertySecureValueMetadataStorage(t *testing.T) { require.Equal(t, modelCreatedSv.Name, createdSv.Name) require.Equal(t, modelCreatedSv.Status.Version, createdSv.Status.Version) }, + "read": func(t *rapid.T) { + ns := testutils.NamespaceGen.Draw(t, "ns") + name := testutils.SecureValueNameGen.Draw(t, "name") + modelSv, modelErr := model.Read(ns, name) + sv, err := sut.SecureValueMetadataStorage.Read(t.Context(), xkube.Namespace(ns), name, contracts.ReadOpts{}) + if err != nil || modelErr != nil { + require.ErrorIs(t, err, modelErr) + return + } + require.Equal(t, modelSv.Namespace, sv.Namespace) + require.Equal(t, modelSv.Name, sv.Name) + require.Equal(t, modelSv.Status.Version, sv.Status.Version) + }, "delete": func(t *rapid.T) { - ns := namespaceGen.Draw(t, "ns") - name := secureValueNameGen.Draw(t, "name") - modelSv, modelErr := model.delete(ns, name) + ns := testutils.NamespaceGen.Draw(t, "ns") + name := testutils.SecureValueNameGen.Draw(t, "name") + modelSv, modelErr := model.Delete(ns, name) sv, err := sut.DeleteSv(t.Context(), ns, name) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) @@ -227,7 +240,7 @@ func TestPropertySecureValueMetadataStorage(t *testing.T) { minAge := 300 * time.Second leaseTTL := 30 * time.Second maxBatchSize := rapid.Uint16Range(1, 10).Draw(t, "maxBatchSize") - modelSvs, modelErr := model.leaseInactiveSecureValues(sut.Clock.Now(), minAge, leaseTTL, maxBatchSize) + modelSvs, modelErr := model.LeaseInactiveSecureValues(sut.Clock.Now(), minAge, leaseTTL, maxBatchSize) svs, err := sut.SecureValueMetadataStorage.LeaseInactiveSecureValues(t.Context(), maxBatchSize) require.ErrorIs(t, err, modelErr) require.Equal(t, len(modelSvs), len(svs)) diff --git a/pkg/storage/secret/metadata/secure_value_test.go b/pkg/storage/secret/metadata/secure_value_test.go index dafd96fc3ad..e12fa9578ab 100644 --- a/pkg/storage/secret/metadata/secure_value_test.go +++ b/pkg/storage/secret/metadata/secure_value_test.go @@ -1,7 +1,6 @@ package metadata_test import ( - "fmt" "slices" "testing" "time" @@ -12,305 +11,11 @@ import ( "pgregory.net/rapid" secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" - "github.com/grafana/grafana/apps/secret/pkg/decrypt" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/registry/apis/secret/testutils" "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" ) -type modelSecureValue struct { - *secretv1beta1.SecureValue - active bool - created time.Time - leaseCreated time.Time -} - -type modelKeeper struct { - namespace string - name string - active bool -} - -// A simplified model of the grafana secrets manager -type model struct { - secureValues []*modelSecureValue - keepers []*modelKeeper -} - -func newModel() *model { - return &model{} -} - -func (m *model) getNewVersionNumber(namespace, name string) int64 { - latestVersion := int64(0) - for _, sv := range m.secureValues { - if sv.Namespace == namespace && sv.Name == name { - latestVersion = max(latestVersion, sv.Status.Version) - } - } - return latestVersion + 1 -} - -func (m *model) setVersionToActive(namespace, name string, version int64) { - for _, sv := range m.secureValues { - if sv.Namespace == namespace && sv.Name == name { - sv.active = sv.Status.Version == version - } - } -} - -func (m *model) setVersionToInactive(namespace, name string, version int64) { - for _, sv := range m.secureValues { - if sv.Namespace == namespace && sv.Name == name && sv.Status.Version == version { - sv.active = false - return - } - } -} - -func (m *model) readActiveVersion(namespace, name string) *modelSecureValue { - for _, sv := range m.secureValues { - if sv.Namespace == namespace && sv.Name == name && sv.active { - return sv - } - } - - return nil -} - -func (m *model) create(now time.Time, sv *secretv1beta1.SecureValue) (*secretv1beta1.SecureValue, error) { - keeper := m.getActiveKeeper(sv.Namespace) - sv = sv.DeepCopy() - - // Preserve the original creation time if this secure value already exists - created := now - if sv := m.readActiveVersion(sv.Namespace, sv.Name); sv != nil { - created = sv.created - } - - modelSv := &modelSecureValue{SecureValue: sv, active: false, created: created} - modelSv.Status.Version = m.getNewVersionNumber(modelSv.Namespace, modelSv.Name) - modelSv.Status.ExternalID = fmt.Sprintf("%d", modelSv.Status.Version) - modelSv.Status.Keeper = keeper.name - m.secureValues = append(m.secureValues, modelSv) - m.setVersionToActive(modelSv.Namespace, modelSv.Name, modelSv.Status.Version) - return modelSv.SecureValue, nil -} - -func (m *model) getActiveKeeper(namespace string) *modelKeeper { - for _, k := range m.keepers { - if k.namespace == namespace && k.active { - return k - } - } - - // Default to the system keeper when there are no active keepers in the namespace - return &modelKeeper{namespace: namespace, name: contracts.SystemKeeperName, active: true} -} - -func (m *model) keeperExists(namespace, name string) bool { - return m.findKeeper(namespace, name) != nil -} - -func (m *model) findKeeper(namespace, name string) *modelKeeper { - // The system keeper is not in the list of keepers - if name == contracts.SystemKeeperName { - return &modelKeeper{namespace: namespace, name: contracts.SystemKeeperName, active: true} - } - for _, k := range m.keepers { - if k.namespace == namespace && k.name == name { - return k - } - } - return nil -} - -func (m *model) createKeeper(keeper *secretv1beta1.Keeper) (*secretv1beta1.Keeper, error) { - if m.keeperExists(keeper.Namespace, keeper.Name) { - return nil, contracts.ErrKeeperAlreadyExists - } - - m.keepers = append(m.keepers, &modelKeeper{namespace: keeper.Namespace, name: keeper.Name}) - - return keeper.DeepCopy(), nil -} - -func (m *model) setKeeperAsActive(namespace, keeperName string) error { - keeper := m.findKeeper(namespace, keeperName) - if keeper == nil { - return contracts.ErrKeeperNotFound - } - // Set the keeper as active - keeper.active = true - - // Set every other keeper in the namespace as inactive - for _, k := range m.keepers { - if k.namespace == namespace && k.name != keeperName { - k.active = false - } - } - - return nil -} - -func (m *model) update(now time.Time, newSecureValue *secretv1beta1.SecureValue) (*secretv1beta1.SecureValue, bool, error) { - sv := m.readActiveVersion(newSecureValue.Namespace, newSecureValue.Name) - if sv == nil { - return nil, false, contracts.ErrSecureValueNotFound - } - - // If the keeper doesn't exist, return an error - if !m.keeperExists(sv.Namespace, sv.Status.Keeper) { - return nil, false, contracts.ErrKeeperNotFound - } - - // If the payload doesn't contain a value, get the value from current version - if newSecureValue.Spec.Value == nil { - newSecureValue.Spec.Value = sv.Spec.Value - } - - createdSv, err := m.create(now, newSecureValue) - - return createdSv, true, err -} - -func (m *model) delete(namespace, name string) (*secretv1beta1.SecureValue, error) { - modelSv := m.readActiveVersion(namespace, name) - if modelSv == nil { - return nil, contracts.ErrSecureValueNotFound - } - m.setVersionToInactive(namespace, name, modelSv.Status.Version) - return modelSv.SecureValue, nil -} - -func (m *model) list(namespace string) (*secretv1beta1.SecureValueList, error) { - out := make([]secretv1beta1.SecureValue, 0) - - for _, v := range m.secureValues { - if v.Namespace == namespace && v.active { - out = append(out, *v.SecureValue) - } - } - - return &secretv1beta1.SecureValueList{Items: out}, nil -} - -func (m *model) decrypt(decrypter, namespace, name string) (map[string]decrypt.DecryptResult, error) { - for _, v := range m.secureValues { - if v.Namespace == namespace && - v.Name == name && - v.active { - if slices.ContainsFunc(v.Spec.Decrypters, func(d string) bool { return d == decrypter }) { - return map[string]decrypt.DecryptResult{ - name: decrypt.NewDecryptResultValue(v.DeepCopy().Spec.Value), - }, nil - } - - return map[string]decrypt.DecryptResult{ - name: decrypt.NewDecryptResultErr(contracts.ErrDecryptNotAuthorized), - }, nil - } - } - return map[string]decrypt.DecryptResult{ - name: decrypt.NewDecryptResultErr(contracts.ErrDecryptNotFound), - }, nil -} - -func (m *model) read(namespace, name string) (*secretv1beta1.SecureValue, error) { - modelSv := m.readActiveVersion(namespace, name) - if modelSv == nil { - return nil, contracts.ErrSecureValueNotFound - } - return modelSv.SecureValue, nil -} - -func (m *model) leaseInactiveSecureValues(now time.Time, minAge, leaseTTL time.Duration, maxBatchSize uint16) ([]*modelSecureValue, error) { - out := make([]*modelSecureValue, 0) - - for _, sv := range m.secureValues { - if len(out) >= int(maxBatchSize) { - break - } - if !sv.active && now.Sub(sv.created) > minAge && now.Sub(sv.leaseCreated) > leaseTTL { - sv.leaseCreated = now - out = append(out, sv) - } - } - - return out, nil -} - -var ( - decryptersGen = rapid.SampledFrom([]string{"svc1", "svc2", "svc3", "svc4", "svc5"}) - secureValueNameGen = rapid.SampledFrom([]string{"n1", "n2", "n3", "n4", "n5"}) - keeperNameGen = rapid.SampledFrom([]string{"k1", "k2", "k3", "k4", "k5"}) - namespaceGen = rapid.SampledFrom([]string{"ns1", "ns2", "ns3", "ns4", "ns5"}) - anySecureValueGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.SecureValue { - return &secretv1beta1.SecureValue{ - ObjectMeta: metav1.ObjectMeta{ - Name: secureValueNameGen.Draw(t, "name"), - Namespace: namespaceGen.Draw(t, "ns"), - }, - Spec: secretv1beta1.SecureValueSpec{ - Description: rapid.SampledFrom([]string{"d1", "d2", "d3", "d4", "d5"}).Draw(t, "description"), - Value: ptr.To(secretv1beta1.NewExposedSecureValue(rapid.SampledFrom([]string{"v1", "v2", "v3", "v4", "v5"}).Draw(t, "value"))), - Decrypters: rapid.SliceOfDistinct(decryptersGen, func(v string) string { return v }).Draw(t, "decrypters"), - }, - Status: secretv1beta1.SecureValueStatus{}, - } - }) - updateSecureValueGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.SecureValue { - sv := anySecureValueGen.Draw(t, "sv") - // Maybe update the secret value, maybe not - if !rapid.Bool().Draw(t, "should_update_value") { - sv.Spec.Value = nil - } - return sv - }) - // Any secure value will do - deleteSecureValueGen = anySecureValueGen - decryptGen = rapid.Custom(func(t *rapid.T) decryptInput { - return decryptInput{ - namespace: namespaceGen.Draw(t, "ns"), - name: secureValueNameGen.Draw(t, "name"), - decrypter: decryptersGen.Draw(t, "decrypter"), - } - }) - anyKeeperGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.Keeper { - spec := secretv1beta1.KeeperSpec{ - Description: rapid.String().Draw(t, "description"), - } - - keeperType := rapid.SampledFrom([]string{"isAwsKeeper", "isAzureKeeper", "isGcpKeeper", "isVaultKeeper"}).Draw(t, "keeperType") - switch keeperType { - case "isAwsKeeper": - spec.Aws = &secretv1beta1.KeeperAWSConfig{} - case "isAzureKeeper": - spec.Azure = &secretv1beta1.KeeperAzureConfig{} - case "isGcpKeeper": - spec.Gcp = &secretv1beta1.KeeperGCPConfig{} - case "isVaultKeeper": - spec.HashiCorpVault = &secretv1beta1.KeeperHashiCorpConfig{} - default: - panic(fmt.Sprintf("unhandled keeper type '%+v', did you forget a switch case?", keeperType)) - } - - return &secretv1beta1.Keeper{ - ObjectMeta: metav1.ObjectMeta{ - Name: keeperNameGen.Draw(t, "name"), - Namespace: namespaceGen.Draw(t, "ns"), - }, - Spec: spec, - } - }) -) - -type decryptInput struct { - namespace string - name string - decrypter string -} - func TestModel(t *testing.T) { t.Parallel() @@ -330,18 +35,18 @@ func TestModel(t *testing.T) { t.Run("creating secure values", func(t *testing.T) { t.Parallel() - m := newModel() + m := testutils.NewModelGsm(nil) now := time.Now() // Create a secure value - sv1, err := m.create(now, sv.DeepCopy()) + sv1, err := m.Create(now, sv.DeepCopy()) require.NoError(t, err) require.Equal(t, sv.Namespace, sv1.Namespace) require.Equal(t, sv.Name, sv1.Name) require.EqualValues(t, 1, sv1.Status.Version) // Create a new version of a secure value - sv2, err := m.create(now, sv.DeepCopy()) + sv2, err := m.Create(now, sv.DeepCopy()) require.NoError(t, err) require.Equal(t, sv.Namespace, sv2.Namespace) require.Equal(t, sv.Name, sv2.Name) @@ -351,15 +56,15 @@ func TestModel(t *testing.T) { t.Run("updating secure values", func(t *testing.T) { t.Parallel() - m := newModel() + m := testutils.NewModelGsm(nil) now := time.Now() - sv1, err := m.create(now, sv.DeepCopy()) + sv1, err := m.Create(now, sv.DeepCopy()) require.NoError(t, err) // Create a new version of a secure value by updating it - sv2, _, err := m.update(now, sv1.DeepCopy()) + sv2, _, err := m.Update(now, sv1.DeepCopy()) require.NoError(t, err) require.Equal(t, sv.Namespace, sv2.Namespace) require.Equal(t, sv.Name, sv2.Name) @@ -369,55 +74,55 @@ func TestModel(t *testing.T) { sv3 := sv2.DeepCopy() sv3.Name = "i_dont_exist" sv3.Spec.Value = nil - _, _, err = m.update(now, sv3) + _, _, err = m.Update(now, sv3) require.ErrorIs(t, err, contracts.ErrSecureValueNotFound) // Updating a value that doesn't exist creates a new version sv4 := sv3.DeepCopy() sv4.Name = "i_dont_exist" sv4.Spec.Value = ptr.To(secretv1beta1.NewExposedSecureValue("sv4")) - _, _, err = m.update(now, sv4) + _, _, err = m.Update(now, sv4) require.ErrorIs(t, err, contracts.ErrSecureValueNotFound) }) t.Run("deleting a secure value", func(t *testing.T) { t.Parallel() - m := newModel() + m := testutils.NewModelGsm(nil) now := time.Now() - sv1, err := m.create(now, sv.DeepCopy()) + sv1, err := m.Create(now, sv.DeepCopy()) require.NoError(t, err) // Deleting a secure value - deletedSv, err := m.delete(sv1.Namespace, sv1.Name) + deletedSv, err := m.Delete(sv1.Namespace, sv1.Name) require.NoError(t, err) require.Equal(t, sv1.Namespace, deletedSv.Namespace) require.Equal(t, sv1.Name, deletedSv.Name) require.EqualValues(t, sv1.Status.Version, deletedSv.Status.Version) // Deleting a secure value that doesn't exist results in an error - _, err = m.delete(sv1.Namespace, sv1.Name) + _, err = m.Delete(sv1.Namespace, sv1.Name) require.ErrorIs(t, err, contracts.ErrSecureValueNotFound) }) t.Run("listing secure values", func(t *testing.T) { t.Parallel() - m := newModel() + m := testutils.NewModelGsm(nil) now := time.Now() // No secure values exist yet - list, err := m.list(sv.Namespace) + list, err := m.List(sv.Namespace) require.NoError(t, err) require.Equal(t, 0, len(list.Items)) // Create a secure value - sv1, err := m.create(now, sv.DeepCopy()) + sv1, err := m.Create(now, sv.DeepCopy()) require.NoError(t, err) // 1 secure value exists and it should be returned - list, err = m.list(sv.Namespace) + list, err = m.List(sv.Namespace) require.NoError(t, err) require.Equal(t, 1, len(list.Items)) require.Equal(t, sv1.Namespace, list.Items[0].Namespace) @@ -428,11 +133,11 @@ func TestModel(t *testing.T) { t.Run("decrypting secure values", func(t *testing.T) { t.Parallel() - m := newModel() + m := testutils.NewModelGsm(nil) now := time.Now() // Decrypting a secure value that does not exist - result, err := m.decrypt("decrypter", "namespace", "name") + result, err := m.Decrypt(t.Context(), "decrypter", "namespace", "name") require.NoError(t, err) require.Equal(t, 1, len(result)) require.Nil(t, result["name"].Value()) @@ -440,16 +145,62 @@ func TestModel(t *testing.T) { // Create a secure value secret := "v1" - sv1, err := m.create(now, sv.DeepCopy()) + sv1, err := m.Create(now, sv.DeepCopy()) require.NoError(t, err) // Decrypt the just created secure value - result, err = m.decrypt(sv1.Spec.Decrypters[0], sv1.Namespace, sv1.Name) + result, err = m.Decrypt(t.Context(), sv1.Spec.Decrypters[0], sv1.Namespace, sv1.Name) require.NoError(t, err) require.Equal(t, 1, len(result)) require.Nil(t, result[sv1.Name].Error()) require.Equal(t, secret, result[sv1.Name].Value().DangerouslyExposeAndConsumeValue()) }) + + t.Run("decrypting with reference", func(t *testing.T) { + t.Parallel() + + secretsManager := testutils.NewModelSecretsManager() + m := testutils.NewModelGsm(secretsManager) + now := time.Now() + + keeper, err := m.CreateKeeper(&secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns1", + Name: "k1", + }, + Spec: secretv1beta1.KeeperSpec{ + Aws: &secretv1beta1.KeeperAWSConfig{}, + }, + }) + require.NoError(t, err) + require.NoError(t, m.SetKeeperAsActive(keeper.Namespace, keeper.Name)) + + // Store the secret on the 3rd party secrets store + secret := "v1" + secretsManager.Create("ref1", secret) + + // Create a secure value that references the secret on the 3rd party secret store + sv, err := m.Create(now, &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sv1", + Namespace: "ns1", + }, + Spec: secretv1beta1.SecureValueSpec{ + Description: "desc1", + Ref: ptr.To("ref1"), + Decrypters: []string{"decrypter1"}, + }, + Status: secretv1beta1.SecureValueStatus{}, + }) + require.NoError(t, err) + + // Decrypt the just created secure value + result, err := m.Decrypt(t.Context(), sv.Spec.Decrypters[0], sv.Namespace, sv.Name) + require.NoError(t, err) + require.Equal(t, 1, len(result)) + require.Nil(t, result[sv.Name].Error()) + require.Equal(t, secret, result[sv.Name].Value().DangerouslyExposeAndConsumeValue()) + }) } func TestStateMachine(t *testing.T) { @@ -459,14 +210,13 @@ func TestStateMachine(t *testing.T) { rapid.Check(t, func(t *rapid.T) { sut := testutils.Setup(tt) - model := newModel() + model := testutils.NewModelGsm(sut.ModelSecretsManager) t.Repeat(map[string]func(*rapid.T){ - "create": func(t *rapid.T) { - sv := anySecureValueGen.Draw(t, "sv") - - modelCreatedSv, modelErr := model.create(sut.Clock.Now(), sv.DeepCopy()) + "createSecureValueWithSecretValue": func(t *rapid.T) { + sv := testutils.AnySecureValueGen.Draw(t, "sv") + modelCreatedSv, modelErr := model.Create(sut.Clock.Now(), sv.DeepCopy()) createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv.DeepCopy())) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) @@ -476,9 +226,27 @@ func TestStateMachine(t *testing.T) { require.Equal(t, modelCreatedSv.Name, createdSv.Name) require.Equal(t, modelCreatedSv.Status.Version, createdSv.Status.Version) }, + "createSecureValueWithRef": func(t *rapid.T) { + sv := testutils.AnySecureValueWithRefGen.Draw(t, "sv") + + modelCreatedSv, modelErr := model.Create(sut.Clock.Now(), sv.DeepCopy()) + createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv.DeepCopy())) + if err != nil || modelErr != nil { + require.ErrorIs(t, err, modelErr) + return + } + require.Equal(t, modelCreatedSv.Namespace, createdSv.Namespace) + require.Equal(t, modelCreatedSv.Name, createdSv.Name) + require.Equal(t, modelCreatedSv.Status.Version, createdSv.Status.Version) + }, + "createSecretOn3rdPartyKeeper": func(t *rapid.T) { + name := testutils.SecretsToRefGen.Draw(t, "name") + value := rapid.String().Draw(t, "value") + sut.ModelSecretsManager.Create(name, value) + }, "update": func(t *rapid.T) { - sv := updateSecureValueGen.Draw(t, "sv") - modelCreatedSv, _, modelErr := model.update(sut.Clock.Now(), sv.DeepCopy()) + sv := testutils.UpdateSecureValueGen.Draw(t, "sv") + modelCreatedSv, _, modelErr := model.Update(sut.Clock.Now(), sv.DeepCopy()) createdSv, err := sut.UpdateSv(t.Context(), sv.DeepCopy()) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) @@ -489,9 +257,10 @@ func TestStateMachine(t *testing.T) { require.Equal(t, modelCreatedSv.Status.Version, createdSv.Status.Version) }, "delete": func(t *rapid.T) { - sv := deleteSecureValueGen.Draw(t, "sv") - modelSv, modelErr := model.delete(sv.Namespace, sv.Name) - deletedSv, err := sut.DeleteSv(t.Context(), sv.Namespace, sv.Name) + ns := testutils.NamespaceGen.Draw(t, "ns") + name := testutils.SecureValueNameGen.Draw(t, "name") + modelSv, modelErr := model.Delete(ns, name) + deletedSv, err := sut.DeleteSv(t.Context(), ns, name) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) return @@ -501,12 +270,12 @@ func TestStateMachine(t *testing.T) { require.Equal(t, modelSv.Status.Version, deletedSv.Status.Version) }, "list": func(t *rapid.T) { - sv := anySecureValueGen.Draw(t, "sv") - authCtx := testutils.CreateUserAuthContext(t.Context(), sv.Namespace, map[string][]string{ + ns := testutils.NamespaceGen.Draw(t, "ns") + authCtx := testutils.CreateUserAuthContext(t.Context(), ns, map[string][]string{ "securevalues:read": {"securevalues:uid:*"}, }) - modelList, modelErr := model.list(sv.Namespace) - list, err := sut.SecureValueService.List(authCtx, xkube.Namespace(sv.Namespace)) + modelList, modelErr := model.List(ns) + list, err := sut.SecureValueService.List(authCtx, xkube.Namespace(ns)) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) return @@ -525,9 +294,10 @@ func TestStateMachine(t *testing.T) { } }, "get": func(t *rapid.T) { - sv := anySecureValueGen.Draw(t, "sv") - modelSv, modelErr := model.read(sv.Namespace, sv.Name) - readSv, err := sut.SecureValueService.Read(t.Context(), xkube.Namespace(sv.Namespace), sv.Name) + ns := testutils.NamespaceGen.Draw(t, "ns") + name := testutils.SecureValueNameGen.Draw(t, "name") + modelSv, modelErr := model.Read(ns, name) + readSv, err := sut.SecureValueService.Read(t.Context(), xkube.Namespace(ns), name) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) return @@ -537,9 +307,9 @@ func TestStateMachine(t *testing.T) { require.Equal(t, modelSv.Status.Version, readSv.Status.Version) }, "decrypt": func(t *rapid.T) { - input := decryptGen.Draw(t, "decryptInput") - modelResult, modelErr := model.decrypt(input.decrypter, input.namespace, input.name) - result, err := sut.DecryptService.Decrypt(t.Context(), input.decrypter, input.namespace, input.name) + input := testutils.DecryptGen.Draw(t, "decryptInput") + modelResult, modelErr := model.Decrypt(t.Context(), input.Decrypter, input.Namespace, input.Name) + result, err := sut.DecryptService.Decrypt(t.Context(), input.Decrypter, input.Namespace, input.Name) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) return @@ -547,13 +317,13 @@ func TestStateMachine(t *testing.T) { require.Equal(t, len(modelResult), len(result)) for name := range modelResult { - require.Equal(t, modelResult[name].Error(), result[name].Error()) + require.ErrorIs(t, modelResult[name].Error(), result[name].Error()) require.Equal(t, modelResult[name].Value(), result[name].Value()) } }, "createKeeper": func(t *rapid.T) { - input := anyKeeperGen.Draw(t, "keeper") - modelKeeper, modelErr := model.createKeeper(input) + input := testutils.AnyKeeperGen.Draw(t, "keeper") + modelKeeper, modelErr := model.CreateKeeper(input) keeper, err := sut.KeeperMetadataStorage.Create(t.Context(), input, "actor-uid") if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) @@ -562,9 +332,14 @@ func TestStateMachine(t *testing.T) { require.Equal(t, modelKeeper.Name, keeper.Name) }, "setKeeperAsActive": func(t *rapid.T) { - namespace := namespaceGen.Draw(t, "namespace") - keeper := keeperNameGen.Draw(t, "keeper") - modelErr := model.setKeeperAsActive(namespace, keeper) + namespace := testutils.NamespaceGen.Draw(t, "namespace") + var keeper string + if rapid.Bool().Draw(t, "systemKeeper") { + keeper = contracts.SystemKeeperName + } else { + keeper = testutils.KeeperNameGen.Draw(t, "keeper") + } + modelErr := model.SetKeeperAsActive(namespace, keeper) err := sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(namespace), keeper) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) From 264131a3909cb49da787c8e4137a278c07b7648a Mon Sep 17 00:00:00 2001 From: Matt Cowley Date: Tue, 6 Jan 2026 14:39:07 +0000 Subject: [PATCH 34/79] OpenFeature: Add OFREP provider type (#115857) Add new OFREP provider for OpenFeature --- go.mod | 2 +- pkg/registry/apis/ofrep/register.go | 4 +- pkg/services/featuremgmt/ofrep_provider.go | 17 ++++++ pkg/services/featuremgmt/openfeature.go | 59 ++++++++++++-------- pkg/services/featuremgmt/openfeature_test.go | 22 ++++++-- pkg/setting/setting_openfeature.go | 3 +- 6 files changed, 75 insertions(+), 32 deletions(-) create mode 100644 pkg/services/featuremgmt/ofrep_provider.go diff --git a/go.mod b/go.mod index bba74f9b656..6df4d73089e 100644 --- a/go.mod +++ b/go.mod @@ -147,6 +147,7 @@ require ( github.com/olekukonko/tablewriter v0.0.5 // @grafana/grafana-backend-group github.com/open-feature/go-sdk v1.16.0 // @grafana/grafana-backend-group github.com/open-feature/go-sdk-contrib/providers/go-feature-flag v0.2.6 // @grafana/grafana-backend-group + github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.6 // @grafana/grafana-backend-group github.com/openfga/api/proto v0.0.0-20250909172242-b4b2a12f5c67 // @grafana/identity-access-team github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20251027165255-0f8f255e5f6c // @grafana/identity-access-team github.com/openfga/openfga v1.11.1 // @grafana/identity-access-team @@ -544,7 +545,6 @@ require ( github.com/oklog/run v1.1.0 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect - github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.6 // indirect github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.124.1 // indirect github.com/open-telemetry/opentelemetry-collector-contrib/pkg/core/xidutils v0.124.1 // indirect github.com/open-telemetry/opentelemetry-collector-contrib/pkg/translator/jaeger v0.124.1 // indirect diff --git a/pkg/registry/apis/ofrep/register.go b/pkg/registry/apis/ofrep/register.go index d6a443cfee7..3dcc6a36adc 100644 --- a/pkg/registry/apis/ofrep/register.go +++ b/pkg/registry/apis/ofrep/register.go @@ -276,7 +276,7 @@ func (b *APIBuilder) oneFlagHandler(w http.ResponseWriter, r *http.Request) { return } - if b.providerType == setting.GOFFProviderType { + if b.providerType == setting.GOFFProviderType || b.providerType == setting.OFREPProviderType { b.proxyFlagReq(ctx, flagKey, isAuthedReq, w, r) return } @@ -304,7 +304,7 @@ func (b *APIBuilder) allFlagsHandler(w http.ResponseWriter, r *http.Request) { isAuthedReq := b.isAuthenticatedRequest(r) span.SetAttributes(attribute.Bool("authenticated", isAuthedReq)) - if b.providerType == setting.GOFFProviderType { + if b.providerType == setting.GOFFProviderType || b.providerType == setting.OFREPProviderType { b.proxyAllFlagReq(ctx, isAuthedReq, w, r) return } diff --git a/pkg/services/featuremgmt/ofrep_provider.go b/pkg/services/featuremgmt/ofrep_provider.go new file mode 100644 index 00000000000..a5197603db4 --- /dev/null +++ b/pkg/services/featuremgmt/ofrep_provider.go @@ -0,0 +1,17 @@ +package featuremgmt + +import ( + "net/http" + + ofrep "github.com/open-feature/go-sdk-contrib/providers/ofrep" + "github.com/open-feature/go-sdk/openfeature" +) + +func newOFREPProvider(url string, client *http.Client) (openfeature.FeatureProvider, error) { + options := []ofrep.Option{} + if client != nil { + options = append(options, ofrep.WithClient(client)) + } + + return ofrep.NewProvider(url, options...), nil +} diff --git a/pkg/services/featuremgmt/openfeature.go b/pkg/services/featuremgmt/openfeature.go index 234739ee59f..a904107bfbd 100644 --- a/pkg/services/featuremgmt/openfeature.go +++ b/pkg/services/featuremgmt/openfeature.go @@ -19,11 +19,11 @@ const ( // OpenFeatureConfig holds configuration for initializing OpenFeature type OpenFeatureConfig struct { - // ProviderType is either "static" or "goff" + // ProviderType is either "static", "goff", or "ofrep" ProviderType string - // URL is the GOFF service URL (required for GOFF provider) + // URL is the GOFF or OFREP service URL (required for GOFF + OFREP providers) URL *url.URL - // HTTPClient is a pre-configured HTTP client (optional, used for GOFF provider) + // HTTPClient is a pre-configured HTTP client (optional, used for GOFF + OFREP providers) HTTPClient *http.Client // StaticFlags are the feature flags to use with static provider StaticFlags map[string]bool @@ -35,9 +35,9 @@ type OpenFeatureConfig struct { // InitOpenFeature initializes OpenFeature with the provided configuration func InitOpenFeature(config OpenFeatureConfig) error { - // For GOFF provider, ensure we have a URL - if config.ProviderType == setting.GOFFProviderType && (config.URL == nil || config.URL.String() == "") { - return fmt.Errorf("URL is required for GOFF provider") + // For GOFF + OFREP providers, ensure we have a URL + if (config.ProviderType == setting.GOFFProviderType || config.ProviderType == setting.OFREPProviderType) && (config.URL == nil || config.URL.String() == "") { + return fmt.Errorf("URL is required for GOFF + OFREP providers") } p, err := createProvider(config.ProviderType, config.URL, config.StaticFlags, config.HTTPClient) @@ -66,13 +66,17 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error { } var httpcli *http.Client - if cfg.OpenFeature.ProviderType == setting.GOFFProviderType { - m, err := clientauthmiddleware.NewTokenExchangeMiddleware(cfg) - if err != nil { - return fmt.Errorf("failed to create token exchange middleware: %w", err) + if cfg.OpenFeature.ProviderType == setting.GOFFProviderType || cfg.OpenFeature.ProviderType == setting.OFREPProviderType { + var m *clientauthmiddleware.TokenExchangeMiddleware + + if cfg.OpenFeature.ProviderType == setting.GOFFProviderType { + m, err = clientauthmiddleware.NewTokenExchangeMiddleware(cfg) + if err != nil { + return fmt.Errorf("failed to create token exchange middleware: %w", err) + } } - httpcli, err = goffHTTPClient(m) + httpcli, err = createHTTPClient(m) if err != nil { return err } @@ -99,28 +103,35 @@ func createProvider( staticFlags map[string]bool, httpClient *http.Client, ) (openfeature.FeatureProvider, error) { - if providerType != setting.GOFFProviderType { - return newStaticProvider(staticFlags) + if providerType == setting.GOFFProviderType || providerType == setting.OFREPProviderType { + if u == nil || u.String() == "" { + return nil, fmt.Errorf("feature provider url is required for GOFFProviderType + OFREPProviderType") + } + + if providerType == setting.GOFFProviderType { + return newGOFFProvider(u.String(), httpClient) + } + + if providerType == setting.OFREPProviderType { + return newOFREPProvider(u.String(), httpClient) + } } - if u == nil || u.String() == "" { - return nil, fmt.Errorf("feature provider url is required for GOFFProviderType") - } - - return newGOFFProvider(u.String(), httpClient) + return newStaticProvider(staticFlags) } -func goffHTTPClient(m *clientauthmiddleware.TokenExchangeMiddleware) (*http.Client, error) { - httpcli, err := sdkhttpclient.NewProvider().New(sdkhttpclient.Options{ +func createHTTPClient(m *clientauthmiddleware.TokenExchangeMiddleware) (*http.Client, error) { + options := sdkhttpclient.Options{ TLS: &sdkhttpclient.TLSOptions{InsecureSkipVerify: true}, Timeouts: &sdkhttpclient.TimeoutOptions{ Timeout: 10 * time.Second, }, - Middlewares: []sdkhttpclient.Middleware{ - m.New([]string{featuresProviderAudience}), - }, - }) + } + if m != nil { + options.Middlewares = append(options.Middlewares, m.New([]string{featuresProviderAudience})) + } + httpcli, err := sdkhttpclient.NewProvider().New(options) if err != nil { return nil, fmt.Errorf("failed to create http client for openfeature: %w", err) } diff --git a/pkg/services/featuremgmt/openfeature_test.go b/pkg/services/featuremgmt/openfeature_test.go index 788b53531d4..152a807f8dc 100644 --- a/pkg/services/featuremgmt/openfeature_test.go +++ b/pkg/services/featuremgmt/openfeature_test.go @@ -7,6 +7,7 @@ import ( "testing" gofeatureflag "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg" + ofrep "github.com/open-feature/go-sdk-contrib/providers/ofrep" "github.com/open-feature/go-sdk/openfeature" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -60,6 +61,15 @@ func TestCreateProvider(t *testing.T) { expectedProvider: setting.GOFFProviderType, failSigning: true, }, + { + name: "ofrep provider", + cfg: setting.OpenFeatureSettings{ + ProviderType: setting.OFREPProviderType, + URL: u, + TargetingKey: "grafana", + }, + expectedProvider: setting.OFREPProviderType, + }, { name: "invalid provider", cfg: setting.OpenFeatureSettings{ @@ -96,20 +106,24 @@ func TestCreateProvider(t *testing.T) { } tokenExchangeMiddleware := middleware.TestingTokenExchangeMiddleware(tokenExchangeClient) - goffClient, err := goffHTTPClient(tokenExchangeMiddleware) + httpClient, err := createHTTPClient(tokenExchangeMiddleware) require.NoError(t, err, "failed to create goff http client") - provider, err := createProvider(tc.cfg.ProviderType, tc.cfg.URL, nil, goffClient) + provider, err := createProvider(tc.cfg.ProviderType, tc.cfg.URL, nil, httpClient) require.NoError(t, err) err = openfeature.SetProviderAndWait(provider) require.NoError(t, err, "failed to set provider") - if tc.expectedProvider == setting.GOFFProviderType { + switch tc.expectedProvider { + case setting.GOFFProviderType: _, ok := provider.(*gofeatureflag.Provider) assert.True(t, ok, "expected provider to be of type goff.Provider") testGoFFProvider(t, tc.failSigning) - } else { + case setting.OFREPProviderType: + _, ok := provider.(*ofrep.Provider) + assert.True(t, ok, "expected provider to be of type ofrep.Provider") + default: _, ok := provider.(*inMemoryBulkProvider) assert.True(t, ok, "expected provider to be of type memprovider.InMemoryProvider") } diff --git a/pkg/setting/setting_openfeature.go b/pkg/setting/setting_openfeature.go index 52966b0f7ba..16eaa72e55c 100644 --- a/pkg/setting/setting_openfeature.go +++ b/pkg/setting/setting_openfeature.go @@ -8,6 +8,7 @@ import ( const ( StaticProviderType = "static" GOFFProviderType = "goff" + OFREPProviderType = "ofrep" ) type OpenFeatureSettings struct { @@ -33,7 +34,7 @@ func (cfg *Cfg) readOpenFeatureSettings() error { cfg.OpenFeature.TargetingKey = config.Key("targetingKey").MustString(defaultTargetingKey) - if strURL != "" && cfg.OpenFeature.ProviderType == GOFFProviderType { + if strURL != "" && (cfg.OpenFeature.ProviderType == GOFFProviderType || cfg.OpenFeature.ProviderType == OFREPProviderType) { u, err := url.Parse(strURL) if err != nil { return fmt.Errorf("invalid feature provider url: %w", err) From 466a27deff4b579eae08ee287ff3132832114df7 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Tue, 6 Jan 2026 14:44:49 +0000 Subject: [PATCH 35/79] Plugins: Remove `pkg/infra/log` as dependency (#115832) * remove pkg/infra/log as dependency * add pluginslog * add slog caching --- pkg/plugins/log/logger.go | 86 +++++++++++++------ .../pluginsintegration/pluginsintegration.go | 1 + .../pluginslog/pluginslog.go | 59 +++++++++++++ 3 files changed, 118 insertions(+), 28 deletions(-) create mode 100644 pkg/services/pluginsintegration/pluginslog/pluginslog.go diff --git a/pkg/plugins/log/logger.go b/pkg/plugins/log/logger.go index f0eed4f3e07..ff21b2a4a7c 100644 --- a/pkg/plugins/log/logger.go +++ b/pkg/plugins/log/logger.go @@ -2,54 +2,84 @@ package log import ( "context" - - "github.com/grafana/grafana/pkg/infra/log" + "log/slog" + "sync" ) +// loggerFactory is a function that creates a Logger given a name. +// It can be set by calling SetLoggerFactory to use a custom logger implementation. +var loggerFactory func(name string) Logger + +// SetLoggerFactory sets the factory function used to create loggers. +// This should be called during initialization to register a custom logger implementation. +// If not set, a default slog-based logger will be used. +func SetLoggerFactory(factory func(name string) Logger) { + loggerFactory = factory +} + +var slogLogManager = &slogLoggerManager{ + cache: sync.Map{}, +} + func New(name string) Logger { - return &grafanaInfraLogWrapper{ - l: log.New(name), + if loggerFactory != nil { + return loggerFactory(name) } + // add a caching layer since slog doesn't perform any caching itself + return slogLogManager.getOrCreate(name) } -type grafanaInfraLogWrapper struct { - l *log.ConcreteLogger +type slogLoggerManager struct { + cache sync.Map } -func (d *grafanaInfraLogWrapper) New(ctx ...any) Logger { +func (m *slogLoggerManager) getOrCreate(name string) Logger { + if cached, ok := m.cache.Load(name); ok { + return cached.(*slogLogger) + } + + logger := &slogLogger{ + logger: slog.Default().With("logger", name), + name: name, + } + actual, _ := m.cache.LoadOrStore(name, logger) + return actual.(*slogLogger) +} + +type slogLogger struct { + logger *slog.Logger + name string +} + +func (l *slogLogger) New(ctx ...any) Logger { if len(ctx) == 0 { - return &grafanaInfraLogWrapper{ - l: d.l.New(), + return &slogLogger{ + logger: l.logger, + name: l.name, } } - - return &grafanaInfraLogWrapper{ - l: d.l.New(ctx...), + return &slogLogger{ + logger: l.logger.With(ctx...), + name: l.name, } } -func (d *grafanaInfraLogWrapper) Debug(msg string, ctx ...any) { - d.l.Debug(msg, ctx...) +func (l *slogLogger) Debug(msg string, ctx ...any) { + l.logger.Debug(msg, ctx...) } -func (d *grafanaInfraLogWrapper) Info(msg string, ctx ...any) { - d.l.Info(msg, ctx...) +func (l *slogLogger) Info(msg string, ctx ...any) { + l.logger.Info(msg, ctx...) } -func (d *grafanaInfraLogWrapper) Warn(msg string, ctx ...any) { - d.l.Warn(msg, ctx...) +func (l *slogLogger) Warn(msg string, ctx ...any) { + l.logger.Warn(msg, ctx...) } -func (d *grafanaInfraLogWrapper) Error(msg string, ctx ...any) { - d.l.Error(msg, ctx...) +func (l *slogLogger) Error(msg string, ctx ...any) { + l.logger.Error(msg, ctx...) } -func (d *grafanaInfraLogWrapper) FromContext(ctx context.Context) Logger { - concreteInfraLogger, ok := d.l.FromContext(ctx).(*log.ConcreteLogger) - if !ok { - return d.New() - } - return &grafanaInfraLogWrapper{ - l: concreteInfraLogger, - } +func (l *slogLogger) FromContext(_ context.Context) Logger { + return l } diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index fbfa3379afd..01a8759172e 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -55,6 +55,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" + _ "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginslog" // Initialize plugin logger "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsources" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsso" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" diff --git a/pkg/services/pluginsintegration/pluginslog/pluginslog.go b/pkg/services/pluginsintegration/pluginslog/pluginslog.go new file mode 100644 index 00000000000..ddce31874f0 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginslog/pluginslog.go @@ -0,0 +1,59 @@ +package pluginslog + +import ( + "context" + + "github.com/grafana/grafana/pkg/infra/log" + pluginslog "github.com/grafana/grafana/pkg/plugins/log" +) + +func init() { + // Register Grafana's logger implementation for pkg/plugins + pluginslog.SetLoggerFactory(func(name string) pluginslog.Logger { + return &grafanaInfraLogWrapper{ + l: log.New(name), + } + }) +} + +type grafanaInfraLogWrapper struct { + l *log.ConcreteLogger +} + +func (d *grafanaInfraLogWrapper) New(ctx ...any) pluginslog.Logger { + if len(ctx) == 0 { + return &grafanaInfraLogWrapper{ + l: d.l.New(), + } + } + + return &grafanaInfraLogWrapper{ + l: d.l.New(ctx...), + } +} + +func (d *grafanaInfraLogWrapper) Debug(msg string, ctx ...any) { + d.l.Debug(msg, ctx...) +} + +func (d *grafanaInfraLogWrapper) Info(msg string, ctx ...any) { + d.l.Info(msg, ctx...) +} + +func (d *grafanaInfraLogWrapper) Warn(msg string, ctx ...any) { + d.l.Warn(msg, ctx...) +} + +func (d *grafanaInfraLogWrapper) Error(msg string, ctx ...any) { + d.l.Error(msg, ctx...) +} + +func (d *grafanaInfraLogWrapper) FromContext(ctx context.Context) pluginslog.Logger { + concreteInfraLogger, ok := d.l.FromContext(ctx).(*log.ConcreteLogger) + if !ok { + return d.New() + } + return &grafanaInfraLogWrapper{ + l: concreteInfraLogger, + } +} From 97b241d4abe6f4e1fe94aa1928a65f48b188181c Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 6 Jan 2026 17:53:38 +0300 Subject: [PATCH 36/79] Stars: Return an error when trying to save non-dashboard stars with legacy storage (#115761) --- pkg/registry/apis/collections/legacy/stars.go | 7 +++++ pkg/tests/apis/collections/stars_test.go | 29 ++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/pkg/registry/apis/collections/legacy/stars.go b/pkg/registry/apis/collections/legacy/stars.go index 39f7a9c088f..3ce900d6e30 100644 --- a/pkg/registry/apis/collections/legacy/stars.go +++ b/pkg/registry/apis/collections/legacy/stars.go @@ -170,6 +170,13 @@ func (s *DashboardStarsStorage) write(ctx context.Context, obj *collections.Star return nil, err } + // Send an error if we try to save a non-dashboard star + for _, res := range obj.Spec.Resource { + if res.Group != "dashboard.grafana.app" || res.Kind != "Dashboard" { + return nil, fmt.Errorf("only dashboard stars are supported until the migration to unified storage is complete") + } + } + user, err := s.users.GetByUID(ctx, &user.GetUserByUIDQuery{ UID: owner.Identifier, }) diff --git a/pkg/tests/apis/collections/stars_test.go b/pkg/tests/apis/collections/stars_test.go index 949e3d6c6ac..960fdc4e5c4 100644 --- a/pkg/tests/apis/collections/stars_test.go +++ b/pkg/tests/apis/collections/stars_test.go @@ -140,7 +140,8 @@ func TestIntegrationStars(t *testing.T) { }, &raw) require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "removed dashboard star") - rspObj, err := starsClient.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{}) + adminStarName := "user-" + starsClient.Args.User.Identity.GetIdentifier() + rspObj, err := starsClient.Resource.Get(ctx, adminStarName, metav1.GetOptions{}) require.NoError(t, err) after := typed(t, rspObj, &collections.Stars{}) @@ -154,7 +155,7 @@ func TestIntegrationStars(t *testing.T) { rspObj, err = starsClient.Resource.Update(ctx, &unstructured.Unstructured{ Object: map[string]any{ "metadata": map[string]any{ - "name": "user-" + starsClient.Args.User.Identity.GetIdentifier(), + "name": adminStarName, "namespace": "default", }, "spec": map[string]any{ @@ -179,7 +180,15 @@ func TestIntegrationStars(t *testing.T) { []string{"test-2", "aaa", "bbb"}, // keeps the requested order, removing duplicates resources[0].Names) - rspObj, err = starsClient.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{}) + // Now add a star with the sub-resource route used by the UI + client := helper.Org1.Admin.RESTClient(t, &collections.GroupVersion) + res := client.Put().AbsPath("apis", "collections.grafana.app", "v1alpha1", + "namespaces", "default", + "stars", adminStarName, + "update", "dashboard.grafana.app", "Dashboard", "xxx").Do(ctx) + require.NoError(t, res.Error()) + + rspObj, err = starsClient.Resource.Get(ctx, adminStarName, metav1.GetOptions{}) require.NoError(t, err) after = typed(t, rspObj, &collections.Stars{}) @@ -197,7 +206,8 @@ func TestIntegrationStars(t *testing.T) { "names": [ "aaa", "bbb", - "test-2" + "test-2", + "xxx" ] } ] @@ -212,6 +222,17 @@ func TestIntegrationStars(t *testing.T) { rspObj, err = starsClientViewer.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{}) require.Error(t, err) require.Nil(t, rspObj) + + // Use the API to star a non-dashboard resource + res = client.Put().AbsPath("apis", "collections.grafana.app", "v1alpha1", + "namespaces", "default", + "stars", adminStarName, + "update", "servicemodel.ext.grafana.com", "Component", "xxx").Do(ctx) + if mode == grafanarest.Mode5 { + require.NoError(t, res.Error()) + } else { + require.Error(t, res.Error(), "only dashboard stars are supported until the migration to unified storage is complete") + } }) } } From 1f20ca5a3d38e19695f931310b1998284ea26ab7 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Tue, 6 Jan 2026 09:57:12 -0500 Subject: [PATCH 37/79] Docs: Add private preview notice for restoring dashboards (#115856) --- .../visualizations/dashboards/manage-dashboards/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/sources/visualizations/dashboards/manage-dashboards/index.md b/docs/sources/visualizations/dashboards/manage-dashboards/index.md index f42892d4f39..b65fcecd758 100644 --- a/docs/sources/visualizations/dashboards/manage-dashboards/index.md +++ b/docs/sources/visualizations/dashboards/manage-dashboards/index.md @@ -124,6 +124,8 @@ For more information about dashboard permissions, refer to [Dashboard permission ## Restore deleted dashboards {{% admonition type="caution" %}} +Restoring deleted dashboards is currently in private preview. Grafana Labs offers support on a best-effort basis, and breaking changes might occur prior to the feature being made generally available. + The feature is only available in Grafana Cloud. {{% /admonition %}} From ee62baea2c44cfd8a001e51a37a61000d3813873 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Tue, 6 Jan 2026 16:40:30 +0100 Subject: [PATCH 38/79] Alerting: Update alerting API client package paths (#115883) --- packages/grafana-api-clients/package.json | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/grafana-api-clients/package.json b/packages/grafana-api-clients/package.json index 1135df02dab..031b1990b04 100644 --- a/packages/grafana-api-clients/package.json +++ b/packages/grafana-api-clients/package.json @@ -117,12 +117,16 @@ "require": "./dist/cjs/clients/rtkq/shorturl/v1beta1/index.cjs" }, "./rtkq/notifications.alerting/v0alpha1": { - "import": "./src/clients/rtkq/notifications.alerting/v0alpha1/index.ts", - "require": "./src/clients/rtkq/notifications.alerting/v0alpha1/index.ts" + "@grafana-app/source": "./src/clients/rtkq/notifications.alerting/v0alpha1/index.ts", + "types": "./dist/types/clients/rtkq/notifications.alerting/v0alpha1/index.d.ts", + "import": "./dist/esm/clients/rtkq/notifications.alerting/v0alpha1/index.mjs", + "require": "./dist/cjs/clients/rtkq/notifications.alerting/v0alpha1/index.cjs" }, "./rtkq/rules.alerting/v0alpha1": { - "import": "./src/clients/rtkq/rules.alerting/v0alpha1/index.ts", - "require": "./src/clients/rtkq/rules.alerting/v0alpha1/index.ts" + "@grafana-app/source": "./src/clients/rtkq/rules.alerting/v0alpha1/index.ts", + "types": "./dist/types/clients/rtkq/rules.alerting/v0alpha1/index.d.ts", + "import": "./dist/esm/clients/rtkq/rules.alerting/v0alpha1/index.mjs", + "require": "./dist/cjs/clients/rtkq/rules.alerting/v0alpha1/index.cjs" }, "./rtkq/historian.alerting/v0alpha1": { "@grafana-app/source": "./src/clients/rtkq/historian.alerting/v0alpha1/index.ts", From 1465b44d5a75232e173655c07024fcbae2a0ea90 Mon Sep 17 00:00:00 2001 From: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com> Date: Tue, 6 Jan 2026 09:56:08 -0600 Subject: [PATCH 39/79] Docs: Created a troubleshooting guide for CloudWatch (#115603) * created new troubleshooting doc * fixed dropdown * fixed another linter issue * ran prettier * updates based on feedback --- .../datasources/aws-cloudwatch/_index.md | 6 + .../aws-cloudwatch/troubleshooting/index.md | 519 ++++++++++++++++++ 2 files changed, 525 insertions(+) create mode 100644 docs/sources/datasources/aws-cloudwatch/troubleshooting/index.md diff --git a/docs/sources/datasources/aws-cloudwatch/_index.md b/docs/sources/datasources/aws-cloudwatch/_index.md index bf3a14c590f..9cd5ed87a09 100644 --- a/docs/sources/datasources/aws-cloudwatch/_index.md +++ b/docs/sources/datasources/aws-cloudwatch/_index.md @@ -105,6 +105,11 @@ refs: destination: /docs/grafana//panels-visualizations/visualizations/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/ + cloudwatch-troubleshooting: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/aws-cloudwatch/troubleshooting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/aws-cloudwatch/troubleshooting/ --- # Amazon CloudWatch data source @@ -119,6 +124,7 @@ The following documents will help you get started working with the CloudWatch da - [CloudWatch query editor](ref:cloudwatch-query-editor) - [Templates and variables](ref:cloudwatch-template-variables) - [Configure AWS authentication](ref:cloudwatch-aws-authentication) +- [Troubleshoot CloudWatch issues](ref:cloudwatch-troubleshooting) ## Import pre-configured dashboards diff --git a/docs/sources/datasources/aws-cloudwatch/troubleshooting/index.md b/docs/sources/datasources/aws-cloudwatch/troubleshooting/index.md new file mode 100644 index 00000000000..77ce4106bf8 --- /dev/null +++ b/docs/sources/datasources/aws-cloudwatch/troubleshooting/index.md @@ -0,0 +1,519 @@ +--- +aliases: + - ../../data-sources/aws-cloudwatch/troubleshooting/ +description: Troubleshooting guide for the Amazon CloudWatch data source in Grafana +keywords: + - grafana + - cloudwatch + - aws + - troubleshooting + - errors + - authentication + - query +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Troubleshooting +title: Troubleshoot Amazon CloudWatch data source issues +weight: 500 +refs: + configure-cloudwatch: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/aws-cloudwatch/configure/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/aws-cloudwatch/configure/ + cloudwatch-aws-authentication: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/aws-cloudwatch/aws-authentication/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/aws-cloudwatch/aws-authentication/ + cloudwatch-template-variables: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/aws-cloudwatch/template-variables/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/aws-cloudwatch/template-variables/ + cloudwatch-query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/aws-cloudwatch/query-editor/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/aws-cloudwatch/query-editor/ + private-data-source-connect: + - pattern: /docs/grafana/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ +--- + +# Troubleshoot Amazon CloudWatch data source issues + +This document provides solutions to common issues you may encounter when configuring or using the Amazon CloudWatch data source. For configuration instructions, refer to [Configure CloudWatch](ref:configure-cloudwatch). + +{{< admonition type="note" >}} +The data source health check validates both metrics and logs permissions. If your IAM policy only grants access to one of these (for example, metrics-only or logs-only), the health check displays a red status. However, the service you have permissions for is still usable—you can query metrics or logs based on whichever permissions are configured. +{{< /admonition >}} + +## Authentication errors + +These errors occur when AWS credentials are invalid, missing, or don't have the required permissions. + +### "Access Denied" or "Not authorized to perform this operation" + +**Symptoms:** + +- Save & test fails with "Access Denied" +- Queries return authorization errors +- Namespaces, metrics, or dimensions don't load + +**Possible causes and solutions:** + +| Cause | Solution | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| IAM policy missing required permissions | Attach the appropriate IAM policy to your user or role. For metrics, you need `cloudwatch:ListMetrics`, `cloudwatch:GetMetricData`, and related permissions. For logs, you need `logs:DescribeLogGroups`, `logs:StartQuery`, `logs:GetQueryResults`, and related permissions. Refer to [Configure CloudWatch](ref:configure-cloudwatch) for complete policy examples. | +| Incorrect access key or secret key | Verify the credentials in the AWS Console under **IAM** > **Users** > your user > **Security credentials**. Generate new credentials if necessary. | +| Credentials have expired | For temporary credentials, generate new ones. For access keys, verify they haven't been deactivated or deleted. | +| Wrong AWS region | Verify the default region in the data source configuration matches where your resources are located. | +| Assume Role ARN is incorrect | Verify the role ARN format: `arn:aws:iam:::role/`. Check that the role exists in the AWS Console. | + +### "Unable to assume role" + +**Symptoms:** + +- Authentication fails when using Assume Role ARN +- Error message references STS or AssumeRole + +**Solutions:** + +1. Verify the trust relationship on the IAM role allows the Grafana credentials to assume it. +1. Check the trust policy includes the correct principal (the user or role running Grafana). +1. If using an external ID, ensure it matches exactly in both the role's trust policy and the Grafana data source configuration. +1. Verify the base credentials have the `sts:AssumeRole` permission. +1. Check that the role ARN is correct and the role exists. + +**Example trust policy:** + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam:::user/" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": "" + } + } + } + ] +} +``` + +### AWS SDK Default authentication not working + +**Symptoms:** + +- Data source test fails when using AWS SDK Default +- Works locally but fails in production + +**Solutions:** + +1. Verify AWS credentials are configured in the environment where Grafana runs. +1. Check for credentials in the default locations: + - Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) + - Shared credentials file (`~/.aws/credentials`) + - EC2 instance metadata (if running on EC2) + - ECS task role (if running in ECS) + - EKS service account (if running in EKS) +1. Ensure the Grafana process has permission to read the credentials file. +1. For EKS with IRSA, set the pod's security context to allow user 472 (grafana) to access the projected token. Refer to [AWS authentication](ref:cloudwatch-aws-authentication) for details. + +### Credentials file not found + +**Symptoms:** + +- Error indicates credentials file cannot be read +- Authentication fails with "Credentials file" option + +**Solutions:** + +1. Create the credentials file at `~/.aws/credentials` for the user running the `grafana-server` service. +1. Verify the file has correct permissions (`0644`). +1. If the file exists but isn't working, move it to `/usr/share/grafana/` and set permissions to `0644`. +1. Ensure the profile name in the data source configuration matches a profile in the credentials file. + +## Connection errors + +These errors occur when Grafana cannot reach AWS CloudWatch endpoints. + +### "Request timed out" or connection failures + +**Symptoms:** + +- Data source test times out +- Queries fail with timeout errors +- Intermittent connection issues + +**Solutions:** + +1. Verify network connectivity from the Grafana server to AWS endpoints. +1. Check firewall rules allow outbound HTTPS (port 443) to AWS services. +1. If using a VPC, ensure proper NAT gateway or VPC endpoint configuration. +1. For Grafana Cloud connecting to private resources, configure [Private data source connect](ref:private-data-source-connect). +1. Check if the default region is correct—incorrect regions may cause longer timeouts. +1. Increase the timeout settings if queries involve large data volumes. + +### Custom endpoint configuration issues + +**Symptoms:** + +- Connection fails when using a custom endpoint +- Endpoint URL rejected + +**Solutions:** + +1. Verify the endpoint URL format is correct. +1. Ensure the endpoint is accessible from the Grafana server. +1. Check that the endpoint supports the required AWS APIs. +1. For VPC endpoints, verify the endpoint policy allows the required actions. + +## CloudWatch Metrics query errors + +These errors occur when querying CloudWatch Metrics. + +### "No data" or empty results + +**Symptoms:** + +- Query executes without error but returns no data +- Charts show "No data" message + +**Possible causes and solutions:** + +| Cause | Solution | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Time range doesn't contain data | Expand the dashboard time range. CloudWatch metrics have different retention periods based on resolution. | +| Wrong namespace or metric name | Verify the namespace (for example, `AWS/EC2`) and metric name (for example, `CPUUtilization`) are correct. | +| Incorrect dimensions | Ensure dimension names and values match your AWS resources exactly. | +| Match Exact enabled incorrectly | When Match Exact is enabled, all dimensions must be specified. Try disabling it to see if metrics appear. | +| Period too large | Reduce the period setting or set it to "auto" to ensure data points are returned for your time range. | +| Custom metrics not configured | Add custom metric namespaces in the data source configuration under **Namespaces of Custom Metrics**. | + +### "Metric not found" or metrics don't appear in drop-down + +**Symptoms:** + +- Expected metrics don't appear in the query editor +- Metric drop-down is empty for a namespace + +**Solutions:** + +1. Verify the metric exists in the selected region. +1. For custom metrics, add the namespace to **Namespaces of Custom Metrics** in the data source configuration. +1. Check that the IAM policy includes `cloudwatch:ListMetrics` permission. +1. CloudWatch limits `ListMetrics` to 500 results per page. To retrieve more metrics, increase the `list_metrics_page_limit` setting in the [Grafana configuration file](https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/configure/#configure-the-data-source-with-grafanaini). +1. Use the Query Inspector to verify the API request and response. + +### Dimension values not loading + +**Symptoms:** + +- Dimension value drop-down doesn't populate +- Wildcard searches return no results + +**Solutions:** + +1. Verify the IAM policy includes `cloudwatch:ListMetrics` permission. +1. Check that the namespace and metric are selected before dimension values can load. +1. For EC2 dimensions, ensure `ec2:DescribeTags` and `ec2:DescribeInstances` permissions are granted. +1. Dimension values require existing metrics—if no metrics match, no values appear. + +### "Too many data points" or API throttling + +**Symptoms:** + +- Queries fail with throttling errors +- Performance degrades with multiple panels + +**Solutions:** + +1. Increase the period setting to reduce the number of data points. +1. Reduce the time range of your queries. +1. Use fewer dimensions or wildcard queries per panel. +1. Request a quota increase for `GetMetricData` requests per second in the [AWS Service Quotas console](https://console.aws.amazon.com/servicequotas/). +1. Enable query caching in Grafana to reduce API calls. + +### Metric math expression errors + +**Symptoms:** + +- Expression returns errors +- Referenced metrics not found + +**Solutions:** + +1. Verify each referenced metric has a unique ID set. +1. Check that metric IDs start with a lowercase letter and contain only letters, numbers, and underscores. +1. Ensure all referenced metrics are in the same query. +1. Verify the expression syntax follows [AWS Metric Math](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/using-metric-math.html) documentation. +1. Metric math expressions can't be used with Grafana alerting if they reference other query rows. + +## CloudWatch Logs query errors + +These errors occur when querying CloudWatch Logs. + +### "Query failed" or logs don't appear + +**Symptoms:** + +- Log queries return errors +- No log data is displayed + +**Solutions:** + +1. Verify log group names are correct and exist in the selected region. +1. Check the IAM policy includes `logs:StartQuery`, `logs:GetQueryResults`, and `logs:DescribeLogGroups` permissions. +1. Ensure the time range contains log data. +1. Verify the query syntax is valid. For CloudWatch Logs Insights QL, test the query in the AWS Console. +1. Select the correct query language (Logs Insights QL, OpenSearch PPL, or OpenSearch SQL) based on your query syntax. + +### Log query timeout + +**Symptoms:** + +- Query runs for a long time then fails +- Error mentions timeout + +**Solutions:** + +1. Increase the **Query timeout result** setting in the data source configuration (default is 30 minutes). +1. Narrow the time range to reduce the amount of data scanned. +1. Add filters to your query to limit results. +1. Break complex queries into smaller, more focused queries. +1. For alerting, the timeout defined in the [Grafana configuration file](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#unified_alerting) takes precedence. + +### Log groups not appearing in selector + +**Symptoms:** + +- Log group selector is empty +- Can't find expected log groups + +**Solutions:** + +1. Verify the IAM policy includes `logs:DescribeLogGroups` permission. +1. Check that log groups exist in the selected region. +1. For cross-account observability, ensure proper IAM permissions for `oam:ListSinks` and `oam:ListAttachedLinks`. +1. Use prefix search to filter log groups if you have many groups. +1. Verify the selected account (for cross-account) contains the expected log groups. + +### OpenSearch SQL query errors + +**Symptoms:** + +- OpenSearch SQL queries fail +- Syntax errors with SQL queries + +**Solutions:** + +1. Specify the log group identifier or ARN in the `FROM` clause: + + ```sql + SELECT * FROM `log_group_name` WHERE `@message` LIKE '%error%' + ``` + +1. For multiple log groups, use the `logGroups` function: + + ```sql + SELECT * FROM `logGroups(logGroupIdentifier: ['LogGroup1', 'LogGroup2'])` + ``` + +1. Amazon CloudWatch supports only a subset of OpenSearch SQL commands. Refer to the [CloudWatch Logs documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CWL_AnalyzeLogData_Languages.html) for supported syntax. + +## Template variable errors + +These errors occur when using template variables with the CloudWatch data source. + +### Variables return no values + +**Symptoms:** + +- Variable drop-down is empty +- Dashboard fails to load with variable errors + +**Solutions:** + +1. Verify the data source connection is working. +1. Check that the IAM policy includes permissions for the variable query type: + - **Regions:** No additional permissions needed. + - **Namespaces:** No additional permissions needed. + - **Metrics:** Requires `cloudwatch:ListMetrics`. + - **Dimension Values:** Requires `cloudwatch:ListMetrics`. + - **EC2 Instance Attributes:** Requires `ec2:DescribeInstances`. + - **EBS Volume IDs:** Requires `ec2:DescribeVolumes`. + - **Resource ARNs:** Requires `tag:GetResources`. + - **Log Groups:** Requires `logs:DescribeLogGroups`. +1. For dependent variables, ensure parent variables have valid selections. +1. Verify the region is set correctly (use "default" for the data source's default region). + +For more information on template variables, refer to [CloudWatch template variables](ref:cloudwatch-template-variables). + +### Multi-value template variables cause query failures + +**Symptoms:** + +- Queries fail when selecting multiple dimension values +- Error about search expression limits + +**Solutions:** + +1. Search expressions are limited to 1,024 characters. Reduce the number of selected values. +1. Use the asterisk (`*`) wildcard instead of selecting "All" to query all metrics for a dimension. +1. Multi-valued template variables are only supported for dimension values—not for Region, Namespace, or Metric Name. + +## Cross-account observability errors + +These errors occur when using CloudWatch cross-account observability features. + +### Cross-account queries fail + +**Symptoms:** + +- Can't query metrics or logs from linked accounts +- Monitoring account badge doesn't appear + +**Solutions:** + +1. Verify cross-account observability is configured in the AWS CloudWatch console. +1. Add the required IAM permissions: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Action": ["oam:ListSinks", "oam:ListAttachedLinks"], + "Effect": "Allow", + "Resource": "*" + } + ] + } + ``` + +1. Check that the monitoring account and source accounts are properly linked in AWS. +1. Cross-account observability works within a single region—verify all accounts are in the same region. +1. EC2 Instance Attributes can't be queried across accounts because they use the EC2 API, not the CloudWatch API. + +## Quota and pricing issues + +These issues relate to AWS service quotas and cost management. + +### API throttling errors + +**Symptoms:** + +- "Rate exceeded" errors +- Dashboard panels intermittently fail to load + +**Solutions:** + +1. Reduce the frequency of dashboard refreshes. +1. Increase the period setting to reduce `GetMetricData` requests. +1. Enable query caching in Grafana (available in Grafana Enterprise and Grafana Cloud). +1. Request a quota increase in the [AWS Service Quotas console](https://console.aws.amazon.com/servicequotas/). +1. Consider consolidating similar queries using metric math. + +### Unexpectedly high CloudWatch costs + +**Symptoms:** + +- AWS CloudWatch costs are higher than expected +- Frequent API calls from Grafana + +**Solutions:** + +1. The `GetMetricData` API doesn't qualify for the CloudWatch API free tier. +1. Reduce dashboard auto-refresh frequency. +1. Increase the period setting to reduce data points returned. +1. Use query caching to reduce repeated API calls. +1. Review variable query settings—set variable refresh to "On dashboard load" instead of "On time range change." +1. Avoid using wildcards in dimensions when possible, as they generate search expressions with multiple API calls. + +## Other common issues + +These issues don't produce specific error messages but are commonly encountered. + +### Custom metrics don't appear + +**Symptoms:** + +- Custom metrics from applications or agents don't show in the namespace drop-down +- Only standard AWS namespaces are visible + +**Solutions:** + +1. Add your custom metric namespace to the **Namespaces of Custom Metrics** field in the data source configuration. +1. Separate multiple namespaces with commas (for example, `CWAgent,CustomNamespace`). +1. Verify custom metrics have been published to CloudWatch in the selected region. + +### Pre-configured dashboards not working + +**Symptoms:** + +- Imported dashboards show no data +- Dashboard variables don't load + +**Solutions:** + +1. Verify the data source name in the dashboard matches your CloudWatch data source. +1. Check that the dashboard's AWS region setting matches where your resources are located. +1. Ensure the IAM policy grants access to the required services (EC2, Lambda, RDS, etc.). +1. Verify resources exist and are emitting metrics in the selected region. + +### X-Ray trace links not appearing + +**Symptoms:** + +- Log entries don't show X-Ray trace links +- `@xrayTraceId` field not appearing + +**Solutions:** + +1. Verify an X-Ray data source is configured and linked in the CloudWatch data source settings. +1. Ensure your logs contain the `@xrayTraceId` field. +1. Update log queries to include `@xrayTraceId` in the fields, for example: `fields @message, @xrayTraceId`. +1. Configure your application to log X-Ray trace IDs. Refer to the [AWS X-Ray documentation](https://docs.aws.amazon.com/xray/latest/devguide/xray-services.html). + +## Enable debug logging + +To capture detailed error information for troubleshooting: + +1. Set the Grafana log level to `debug` in the configuration file: + + ```ini + [log] + level = debug + ``` + +1. Review logs in `/var/log/grafana/grafana.log` (or your configured log location). +1. Look for CloudWatch-specific entries that include request and response details. +1. Reset the log level to `info` after troubleshooting to avoid excessive log volume. + +## Get additional help + +If you've tried the solutions above and still encounter issues: + +1. Check the [Grafana community forums](https://community.grafana.com/) for similar issues. +1. Review the [CloudWatch plugin GitHub issues](https://github.com/grafana/grafana/issues) for known bugs. +1. Consult the [AWS CloudWatch documentation](https://docs.aws.amazon.com/cloudwatch/) for service-specific guidance. +1. Contact Grafana Support if you're an Enterprise, Cloud Pro, or Cloud Contracted user. +1. When reporting issues, include: + - Grafana version + - AWS region + - Error messages (redact sensitive information) + - Steps to reproduce + - Query configuration (redact credentials and account IDs) From 5eb0e6f4326f64db9ccccaa1e05011ddc8a8f4e9 Mon Sep 17 00:00:00 2001 From: Matthew Jacobson Date: Tue, 6 Jan 2026 11:05:01 -0500 Subject: [PATCH 40/79] Alerting: Update RuleGroupConfig definitions with missing fields (#115850) * Alerting: Update RuleGroupConfig definitions with missing fields This update adds previously missing fields to the `RuleGroupConfig` structs to ensure compatibility with external Prometheus-like rulers. Includes: - `labels`: per https://github.com/prometheus/prometheus/pull/11474 - `remote_write`: per https://github .com/grafana/mimir/blob/56f33fed6254fee5a53bde1eab36c604863e3d5f/pkg/mimirtool/rules/rwrulefmt/rulefmt.go#L16 Note: This does not add full support in Grafana; it only allows these fields to pass through the alerting proxy without causing unmarshal errors when using external rulers. * Update OpenAPI spec --- pkg/services/ngalert/api/tooling/api.json | 76 +++++++++++++++++-- .../api/tooling/definitions/cortex-ruler.go | 38 +++++++--- pkg/services/ngalert/api/tooling/post.json | 76 +++++++++++++++++-- pkg/services/ngalert/api/tooling/spec.json | 76 +++++++++++++++++-- public/api-merged.json | 76 +++++++++++++++++-- public/openapi3.json | 76 +++++++++++++++++-- 6 files changed, 371 insertions(+), 47 deletions(-) diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index af37efc58d7..5e4bc821447 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -647,12 +647,6 @@ }, "BacktestConfig": { "properties": { - "annotations": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, "condition": { "type": "string" }, @@ -662,8 +656,16 @@ }, "type": "array" }, + "exec_err_state": { + "enum": [ + "OK", + "Alerting", + "Error" + ], + "type": "string" + }, "for": { - "$ref": "#/definitions/Duration" + "type": "string" }, "from": { "format": "date-time", @@ -672,12 +674,22 @@ "interval": { "$ref": "#/definitions/Duration" }, + "keep_firing_for": { + "type": "string" + }, "labels": { "additionalProperties": { "type": "string" }, "type": "object" }, + "missing_series_evals_to_resolve": { + "format": "int64", + "type": "integer" + }, + "namespace_uid": { + "type": "string" + }, "no_data_state": { "enum": [ "Alerting", @@ -686,12 +698,18 @@ ], "type": "string" }, + "rule_group": { + "type": "string" + }, "title": { "type": "string" }, "to": { "format": "date-time", "type": "string" + }, + "uid": { + "type": "string" } }, "type": "object" @@ -1813,6 +1831,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -1823,6 +1847,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/definitions/GettableExtendedRuleNode" @@ -3142,6 +3172,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -3152,6 +3188,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/definitions/PostableExtendedRuleNode" @@ -3817,6 +3859,14 @@ }, "type": "object" }, + "RemoteWriteConfig": { + "properties": { + "url": { + "type": "string" + } + }, + "type": "object" + }, "ResponseDetails": { "properties": { "msg": { @@ -4093,6 +4143,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -4103,6 +4159,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/definitions/GettableExtendedRuleNode" diff --git a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go index 15cdb16d185..6b123aae306 100644 --- a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go +++ b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go @@ -284,11 +284,20 @@ type PostableRuleGroupConfig struct { // fields below are used by Mimir/Loki rulers - SourceTenants []string `yaml:"source_tenants,omitempty" json:"source_tenants,omitempty"` - EvaluationDelay *model.Duration `yaml:"evaluation_delay,omitempty" json:"evaluation_delay,omitempty"` - QueryOffset *model.Duration `yaml:"query_offset,omitempty" json:"query_offset,omitempty"` - AlignEvaluationTimeOnInterval bool `yaml:"align_evaluation_time_on_interval,omitempty" json:"align_evaluation_time_on_interval,omitempty"` - Limit int `yaml:"limit,omitempty" json:"limit,omitempty"` + SourceTenants []string `yaml:"source_tenants,omitempty" json:"source_tenants,omitempty"` + EvaluationDelay *model.Duration `yaml:"evaluation_delay,omitempty" json:"evaluation_delay,omitempty"` + QueryOffset *model.Duration `yaml:"query_offset,omitempty" json:"query_offset,omitempty"` + AlignEvaluationTimeOnInterval bool `yaml:"align_evaluation_time_on_interval,omitempty" json:"align_evaluation_time_on_interval,omitempty"` + Limit int `yaml:"limit,omitempty" json:"limit,omitempty"` + Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"` + + // GEM Ruler. + + RWConfigs []RemoteWriteConfig `yaml:"remote_write,omitempty" json:"remote_write,omitempty"` +} + +type RemoteWriteConfig struct { + URL string `yaml:"url,omitempty" json:"url,omitempty"` } func (c *PostableRuleGroupConfig) UnmarshalJSON(b []byte) error { @@ -328,8 +337,8 @@ func (c *PostableRuleGroupConfig) validate() error { return fmt.Errorf("cannot mix Grafana & Prometheus style rules") } - if hasGrafRules && (len(c.SourceTenants) > 0 || c.EvaluationDelay != nil || c.QueryOffset != nil || c.AlignEvaluationTimeOnInterval || c.Limit > 0) { - return fmt.Errorf("fields source_tenants, evaluation_delay, query_offset, align_evaluation_time_on_interval and limit are not supported for Grafana rules") + if hasGrafRules && (len(c.SourceTenants) > 0 || c.EvaluationDelay != nil || c.QueryOffset != nil || c.AlignEvaluationTimeOnInterval || c.Limit > 0 || len(c.Labels) > 0 || len(c.RWConfigs) > 0) { + return fmt.Errorf("fields source_tenants, evaluation_delay, query_offset, align_evaluation_time_on_interval, limit, labels, and remote_write are not supported for Grafana rules") } return nil } @@ -345,11 +354,16 @@ type GettableRuleGroupConfig struct { // fields below are used by Mimir/Loki rulers - SourceTenants []string `yaml:"source_tenants,omitempty" json:"source_tenants,omitempty"` - EvaluationDelay *model.Duration `yaml:"evaluation_delay,omitempty" json:"evaluation_delay,omitempty"` - QueryOffset *model.Duration `yaml:"query_offset,omitempty" json:"query_offset,omitempty"` - AlignEvaluationTimeOnInterval bool `yaml:"align_evaluation_time_on_interval,omitempty" json:"align_evaluation_time_on_interval,omitempty"` - Limit int `yaml:"limit,omitempty" json:"limit,omitempty"` + SourceTenants []string `yaml:"source_tenants,omitempty" json:"source_tenants,omitempty"` + EvaluationDelay *model.Duration `yaml:"evaluation_delay,omitempty" json:"evaluation_delay,omitempty"` + QueryOffset *model.Duration `yaml:"query_offset,omitempty" json:"query_offset,omitempty"` + AlignEvaluationTimeOnInterval bool `yaml:"align_evaluation_time_on_interval,omitempty" json:"align_evaluation_time_on_interval,omitempty"` + Limit int `yaml:"limit,omitempty" json:"limit,omitempty"` + Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"` + + // GEM Ruler. + + RWConfigs []RemoteWriteConfig `yaml:"remote_write,omitempty" json:"remote_write,omitempty"` } func (c *GettableRuleGroupConfig) UnmarshalJSON(b []byte) error { diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 1243007dfcf..cd6ce130cf3 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -647,12 +647,6 @@ }, "BacktestConfig": { "properties": { - "annotations": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, "condition": { "type": "string" }, @@ -662,8 +656,16 @@ }, "type": "array" }, + "exec_err_state": { + "enum": [ + "OK", + "Alerting", + "Error" + ], + "type": "string" + }, "for": { - "$ref": "#/definitions/Duration" + "type": "string" }, "from": { "format": "date-time", @@ -672,12 +674,22 @@ "interval": { "$ref": "#/definitions/Duration" }, + "keep_firing_for": { + "type": "string" + }, "labels": { "additionalProperties": { "type": "string" }, "type": "object" }, + "missing_series_evals_to_resolve": { + "format": "int64", + "type": "integer" + }, + "namespace_uid": { + "type": "string" + }, "no_data_state": { "enum": [ "Alerting", @@ -686,12 +698,18 @@ ], "type": "string" }, + "rule_group": { + "type": "string" + }, "title": { "type": "string" }, "to": { "format": "date-time", "type": "string" + }, + "uid": { + "type": "string" } }, "type": "object" @@ -1813,6 +1831,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -1823,6 +1847,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/definitions/GettableExtendedRuleNode" @@ -3142,6 +3172,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -3152,6 +3188,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/definitions/PostableExtendedRuleNode" @@ -3817,6 +3859,14 @@ }, "type": "object" }, + "RemoteWriteConfig": { + "properties": { + "url": { + "type": "string" + } + }, + "type": "object" + }, "ResponseDetails": { "properties": { "msg": { @@ -4093,6 +4143,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -4103,6 +4159,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/definitions/GettableExtendedRuleNode" diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 81e0aa894c9..b7331faea7c 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -5072,12 +5072,6 @@ "BacktestConfig": { "type": "object", "properties": { - "annotations": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "condition": { "type": "string" }, @@ -5087,8 +5081,16 @@ "$ref": "#/definitions/AlertQuery" } }, + "exec_err_state": { + "type": "string", + "enum": [ + "OK", + "Alerting", + "Error" + ] + }, "for": { - "$ref": "#/definitions/Duration" + "type": "string" }, "from": { "type": "string", @@ -5097,12 +5099,22 @@ "interval": { "$ref": "#/definitions/Duration" }, + "keep_firing_for": { + "type": "string" + }, "labels": { "type": "object", "additionalProperties": { "type": "string" } }, + "missing_series_evals_to_resolve": { + "type": "integer", + "format": "int64" + }, + "namespace_uid": { + "type": "string" + }, "no_data_state": { "type": "string", "enum": [ @@ -5111,12 +5123,18 @@ "OK" ] }, + "rule_group": { + "type": "string" + }, "title": { "type": "string" }, "to": { "type": "string", "format": "date-time" + }, + "uid": { + "type": "string" } } }, @@ -6239,6 +6257,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "limit": { "type": "integer", "format": "int64" @@ -6249,6 +6273,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "type": "array", + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + } + }, "rules": { "type": "array", "items": { @@ -7569,6 +7599,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "limit": { "type": "integer", "format": "int64" @@ -7579,6 +7615,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "type": "array", + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + } + }, "rules": { "type": "array", "items": { @@ -8243,6 +8285,14 @@ } } }, + "RemoteWriteConfig": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + }, "ResponseDetails": { "type": "object", "properties": { @@ -8520,6 +8570,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "limit": { "type": "integer", "format": "int64" @@ -8530,6 +8586,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "type": "array", + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + } + }, "rules": { "type": "array", "items": { diff --git a/public/api-merged.json b/public/api-merged.json index 323a545e0cf..642583d2600 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -13829,12 +13829,6 @@ "BacktestConfig": { "type": "object", "properties": { - "annotations": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "condition": { "type": "string" }, @@ -13844,8 +13838,16 @@ "$ref": "#/definitions/AlertQuery" } }, + "exec_err_state": { + "type": "string", + "enum": [ + "OK", + "Alerting", + "Error" + ] + }, "for": { - "$ref": "#/definitions/Duration" + "type": "string" }, "from": { "type": "string", @@ -13854,12 +13856,22 @@ "interval": { "$ref": "#/definitions/Duration" }, + "keep_firing_for": { + "type": "string" + }, "labels": { "type": "object", "additionalProperties": { "type": "string" } }, + "missing_series_evals_to_resolve": { + "type": "integer", + "format": "int64" + }, + "namespace_uid": { + "type": "string" + }, "no_data_state": { "type": "string", "enum": [ @@ -13868,12 +13880,18 @@ "OK" ] }, + "rule_group": { + "type": "string" + }, "title": { "type": "string" }, "to": { "type": "string", "format": "date-time" + }, + "uid": { + "type": "string" } } }, @@ -16778,6 +16796,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "limit": { "type": "integer", "format": "int64" @@ -16788,6 +16812,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "type": "array", + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + } + }, "rules": { "type": "array", "items": { @@ -19263,6 +19293,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "limit": { "type": "integer", "format": "int64" @@ -19273,6 +19309,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "type": "array", + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + } + }, "rules": { "type": "array", "items": { @@ -20310,6 +20352,14 @@ } } }, + "RemoteWriteConfig": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + }, "Report": { "type": "object", "properties": { @@ -21009,6 +21059,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "limit": { "type": "integer", "format": "int64" @@ -21019,6 +21075,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "type": "array", + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + } + }, "rules": { "type": "array", "items": { diff --git a/public/openapi3.json b/public/openapi3.json index 9978e3dfa78..cda13e8a2dc 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -3364,12 +3364,6 @@ }, "BacktestConfig": { "properties": { - "annotations": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, "condition": { "type": "string" }, @@ -3379,8 +3373,16 @@ }, "type": "array" }, + "exec_err_state": { + "enum": [ + "OK", + "Alerting", + "Error" + ], + "type": "string" + }, "for": { - "$ref": "#/components/schemas/Duration" + "type": "string" }, "from": { "format": "date-time", @@ -3389,12 +3391,22 @@ "interval": { "$ref": "#/components/schemas/Duration" }, + "keep_firing_for": { + "type": "string" + }, "labels": { "additionalProperties": { "type": "string" }, "type": "object" }, + "missing_series_evals_to_resolve": { + "format": "int64", + "type": "integer" + }, + "namespace_uid": { + "type": "string" + }, "no_data_state": { "enum": [ "Alerting", @@ -3403,12 +3415,18 @@ ], "type": "string" }, + "rule_group": { + "type": "string" + }, "title": { "type": "string" }, "to": { "format": "date-time", "type": "string" + }, + "uid": { + "type": "string" } }, "type": "object" @@ -6313,6 +6331,12 @@ "interval": { "$ref": "#/components/schemas/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -6323,6 +6347,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/components/schemas/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/components/schemas/GettableExtendedRuleNode" @@ -8798,6 +8828,12 @@ "interval": { "$ref": "#/components/schemas/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -8808,6 +8844,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/components/schemas/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/components/schemas/PostableExtendedRuleNode" @@ -9846,6 +9888,14 @@ }, "type": "object" }, + "RemoteWriteConfig": { + "properties": { + "url": { + "type": "string" + } + }, + "type": "object" + }, "Report": { "properties": { "created": { @@ -10544,6 +10594,12 @@ "interval": { "$ref": "#/components/schemas/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -10554,6 +10610,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/components/schemas/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/components/schemas/GettableExtendedRuleNode" From 8ff88036e70e948dadb115fcaa246ef8c2714174 Mon Sep 17 00:00:00 2001 From: Matt Cowley Date: Tue, 6 Jan 2026 16:18:25 +0000 Subject: [PATCH 41/79] UI: Allow lastActiveAt to be optional for UserIcon UserView (#115887) Allow lastActiveAt to be optional for UserIcon UserView --- .../components/UsersIndicator/UserIcon.mdx | 2 +- .../components/UsersIndicator/UserIcon.tsx | 31 ++++++++++--------- .../UsersIndicator/UsersIndicator.mdx | 2 +- .../UsersIndicator/UsersIndicator.tsx | 4 +-- .../src/components/UsersIndicator/types.ts | 2 +- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/grafana-ui/src/components/UsersIndicator/UserIcon.mdx b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.mdx index d9537dfdc1e..77c189e6e79 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/UserIcon.mdx +++ b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.mdx @@ -66,6 +66,6 @@ export interface UserView { avatarUrl?: string; }; /** Datetime string when the user was last active */ - lastActiveAt: DateTimeInput; + lastActiveAt?: DateTimeInput; } ``` diff --git a/packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx index ca5ff1f665c..945d46b2f79 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx +++ b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx @@ -10,7 +10,7 @@ import { Tooltip } from '../Tooltip/Tooltip'; import { UserView } from './types'; export interface UserIconProps { - /** An object that contains the user's details and 'lastActiveAt' status */ + /** An object that contains the user's details and an optional 'lastActiveAt' status */ userView: UserView; /** A boolean value that determines whether the tooltip should be shown or not */ showTooltip?: boolean; @@ -64,7 +64,8 @@ export const UserIcon = ({ showTooltip = true, }: PropsWithChildren) => { const { user, lastActiveAt } = userView; - const isActive = dateTime(lastActiveAt).diff(dateTime(), 'minutes', true) >= -15; + const hasActive = lastActiveAt !== undefined && lastActiveAt !== null; + const isActive = hasActive && dateTime(lastActiveAt).diff(dateTime(), 'minutes', true) >= -15; const theme = useTheme2(); const styles = useMemo(() => getStyles(theme, isActive), [theme, isActive]); const content = ( @@ -88,18 +89,20 @@ export const UserIcon = ({ const tooltip = (
{user.name}
-
- {isActive ? ( -
- - Active last 15m - - -
- ) : ( - formatViewed(lastActiveAt) - )} -
+ {hasActive && ( +
+ {isActive ? ( +
+ + Active last 15m + + +
+ ) : ( + formatViewed(lastActiveAt) + )} +
+ )}
); diff --git a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.mdx b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.mdx index b5d689af8ea..ade04a32c79 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.mdx +++ b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.mdx @@ -60,6 +60,6 @@ export interface UserView { avatarUrl?: string; }; /** Datetime string when the user was last active */ - lastActiveAt: DateTimeInput; + lastActiveAt?: DateTimeInput; } ``` diff --git a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx index 66c9a0bddca..b32145da0da 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx +++ b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx @@ -9,7 +9,7 @@ import { UserIcon } from './UserIcon'; import { UserView } from './types'; export interface UsersIndicatorProps { - /** An object that contains the user's details and 'lastActiveAt' status */ + /** An object that contains the user's details and an optional 'lastActiveAt' status */ users: UserView[]; /** A limit of how many user icons to show before collapsing them and showing a number of users instead */ limit?: number; @@ -40,7 +40,7 @@ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProp aria-label={t('grafana-ui.users-indicator.container-label', 'Users indicator container')} > {limitReached && ( - + {tooManyUsers ? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings '...' diff --git a/packages/grafana-ui/src/components/UsersIndicator/types.ts b/packages/grafana-ui/src/components/UsersIndicator/types.ts index 1ba9f2d4e5b..641afbf1914 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/types.ts +++ b/packages/grafana-ui/src/components/UsersIndicator/types.ts @@ -8,5 +8,5 @@ export interface UserView { avatarUrl?: string; }; /** Datetime string when the user was last active */ - lastActiveAt: DateTimeInput; + lastActiveAt?: DateTimeInput; } From 2cf485f6bf577e28d3d1fe443520c54efc1165eb Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Tue, 6 Jan 2026 11:25:40 -0500 Subject: [PATCH 42/79] Gauge: Fix Safari issues in SVG rendering (#115756) * Sparkline: Restore to a function component * fix whitespace lint issue * swap from clipPath to mask to help Safari * Gauge: Fix SVG issues in Safari * more steps in the right direction * don't set filters which don't exist * fix a couple other text and baseline stuff * fix tests after changes * clean up effects as follow-up to other PR * fix issue with threshold bars, and also simplify non-gradient case --- .../components/RadialGauge/RadialArcPath.tsx | 62 +++++++++++------ .../components/RadialGauge/RadialGauge.tsx | 24 ++++--- .../src/components/RadialGauge/RadialText.tsx | 21 ++---- .../__snapshots__/utils.test.ts.snap | 16 ++--- .../src/components/RadialGauge/colors.test.ts | 33 +-------- .../src/components/RadialGauge/colors.ts | 8 --- .../src/components/RadialGauge/effects.tsx | 42 +++++------- .../src/components/RadialGauge/types.ts | 2 + .../src/components/RadialGauge/utils.test.ts | 17 +++-- .../src/components/RadialGauge/utils.ts | 68 +++---------------- 10 files changed, 102 insertions(+), 191 deletions(-) diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx index f59614acd53..6d6d05047d2 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -1,4 +1,4 @@ -import { useId, memo, HTMLAttributes, ReactNode } from 'react'; +import { useId, memo, HTMLAttributes, ReactNode, SVGProps } from 'react'; import { FieldDisplay } from '@grafana/data'; @@ -50,14 +50,13 @@ export const RadialArcPath = memo( }: RadialArcPathProps) => { const id = useId(); - const bgDivStyle: HTMLAttributes['style'] = { width: '100%', height: '100%' }; - if ('color' in rest) { - bgDivStyle.backgroundColor = rest.color; - } else { - bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape); - } + const isGradient = 'gradient' in rest; - const { radius, centerX, centerY, barWidth } = dimensions; + const { vizWidth, vizHeight, radius, centerX, centerY, barWidth } = dimensions; + const pad = Math.ceil(Math.max(2, barWidth / 2)); // pad to cover stroke caps and glow in Safari + const boxX = Math.round(centerX - radius - barWidth - pad); + const boxY = Math.round(centerY - radius - barWidth - pad); + const boxSize = Math.round((radius + barWidth) * 2 + pad * 2); const path = drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars); @@ -72,9 +71,14 @@ export const RadialArcPath = memo( const dotRadius = endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2; + const bgDivStyle: HTMLAttributes['style'] = { width: boxSize, height: vizHeight, marginLeft: boxX }; + + const pathProps: SVGProps = {}; let barEndcapColors: [string, string] | undefined; let endpointMarks: ReactNode = null; - if ('gradient' in rest) { + if (isGradient) { + bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape); + if (endpointMarker && (rest.gradient?.length ?? 0) > 0) { switch (endpointMarker) { case 'point': @@ -115,25 +119,39 @@ export const RadialArcPath = memo( if (barEndcaps) { barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent); } + + pathProps.fill = 'none'; + pathProps.stroke = 'white'; + } else { + bgDivStyle.backgroundColor = rest.color; + + pathProps.fill = 'none'; + pathProps.stroke = rest.color; } + const pathEl = ( + + ); + return ( <> - {/* FIXME: optimize this by only using clippath + foreign obj for gradients */} - - - + {isGradient && ( + + + + {pathEl} + + + )} - -
- + {isGradient ? ( + +
+ + ) : ( + pathEl + )} {barEndcapColors?.[0] && } {barEndcapColors?.[1] && ( diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx index 452dd394ab1..e60a3dfde31 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { useId } from 'react'; +import { useId, ReactNode } from 'react'; import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -107,14 +107,14 @@ export function RadialGauge(props: RadialGaugeProps) { const startAngle = shape === 'gauge' ? 250 : 0; const endAngle = shape === 'gauge' ? 110 : 360; - const defs: React.ReactNode[] = []; - const graphics: React.ReactNode[] = []; - let sparklineElement: React.ReactNode | null = null; + const defs: ReactNode[] = []; + const graphics: ReactNode[] = []; + let sparklineElement: ReactNode | null = null; for (let barIndex = 0; barIndex < values.length; barIndex++) { const displayValue = values[barIndex]; const { angle, angleRange } = getValueAngleForValue(displayValue, startAngle, endAngle); - const gradientStops = buildGradientColors(gradient, theme, displayValue); + const gradientStops = gradient ? buildGradientColors(theme, displayValue) : undefined; const color = displayValue.display.color ?? FALLBACK_COLOR; const dimensions = calculateDimensions( width, @@ -131,7 +131,9 @@ export function RadialGauge(props: RadialGaugeProps) { // FIXME: I want to move the ids for these filters into a context which the children // can reference via a hook, rather than passing them down as props const spotlightGradientId = `spotlight-${barIndex}-${gaugeId}`; + const spotlightGradientRef = endpointMarker === 'glow' ? `url(#${spotlightGradientId})` : undefined; const glowFilterId = `glow-${gaugeId}`; + const glowFilterRef = glowBar ? `url(#${glowFilterId})` : undefined; if (endpointMarker === 'glow') { defs.push( @@ -154,7 +156,7 @@ export function RadialGauge(props: RadialGaugeProps) { fieldDisplay={displayValue} angleRange={angleRange} startAngle={startAngle} - glowFilter={`url(#${glowFilterId})`} + glowFilter={glowFilterRef} segmentCount={segmentCount} segmentSpacing={segmentSpacing} shape={shape} @@ -170,8 +172,8 @@ export function RadialGauge(props: RadialGaugeProps) { angleRange={angleRange} startAngle={startAngle} roundedBars={roundedBars} - glowFilter={`url(#${glowFilterId})`} - endpointMarkerGlowFilter={`url(#${spotlightGradientId})`} + glowFilter={glowFilterRef} + endpointMarkerGlowFilter={spotlightGradientRef} shape={shape} gradient={gradientStops} fieldDisplay={displayValue} @@ -183,7 +185,7 @@ export function RadialGauge(props: RadialGaugeProps) { // These elements are only added for first value / bar if (barIndex === 0) { if (glowBar) { - defs.push(); + defs.push(); } if (glowCenter) { @@ -234,7 +236,7 @@ export function RadialGauge(props: RadialGaugeProps) { endAngle={endAngle} angleRange={angleRange} roundedBars={roundedBars} - glowFilter={`url(#${glowFilterId})`} + glowFilter={glowFilterRef} shape={shape} gradient={gradientStops} /> @@ -260,7 +262,7 @@ export function RadialGauge(props: RadialGaugeProps) { const body = ( <> - {defs} + {defs.length > 0 && {defs}} {graphics} {sparklineElement} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx index 69ab16e450e..dc094b7261c 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx @@ -1,4 +1,3 @@ -import { css } from '@emotion/css'; import { memo } from 'react'; import { @@ -9,7 +8,6 @@ import { GrafanaTheme2, } from '@grafana/data'; -import { useStyles2 } from '../../themes/ThemeContext'; import { calculateFontSize } from '../../utils/measureText'; import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types'; @@ -50,7 +48,6 @@ export const RadialText = memo( valueManualFontSize, nameManualFontSize, }: RadialTextProps) => { - const styles = useStyles2(getStyles); const { centerX, centerY, radius, barWidth } = dimensions; if (textMode === 'none') { @@ -106,10 +103,9 @@ export const RadialText = memo( const valueY = showName ? centerY - nameHeight * (1 - VALUE_SPACE_PERCENTAGE) : centerY; const nameY = showValue ? valueY + valueHeight * VALUE_SPACE_PERCENTAGE : centerY; const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary; - const suffixShift = (valueFontSize - unitFontSize * LINE_HEIGHT_FACTOR) / 2; // adjust the text up on gauges and when sparklines are present - let yOffset = 0; + let yOffset = valueFontSize / 4; if (shape === 'gauge') { // we render from the center of the gauge, so move up by half of half of the total height yOffset -= (valueHeight + nameHeight) / 4; @@ -126,15 +122,12 @@ export const RadialText = memo( y={valueY} fontSize={valueFontSize} fill={theme.colors.text.primary} - className={styles.text} textAnchor="middle" - dominantBaseline="middle" + dominantBaseline="text-bottom" > {displayValue.prefix ?? ''} {displayValue.text} - - {displayValue.suffix ?? ''} - + {displayValue.suffix ?? ''} )} {showName && ( @@ -143,7 +136,7 @@ export const RadialText = memo( x={centerX} y={nameY} textAnchor="middle" - dominantBaseline="middle" + dominantBaseline="text-bottom" fill={nameColor} > {displayValue.title} @@ -155,9 +148,3 @@ export const RadialText = memo( ); RadialText.displayName = 'RadialText'; - -const getStyles = (_theme: GrafanaTheme2) => ({ - text: css({ - verticalAlign: 'bottom', - }), -}); diff --git a/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap index db4c1c40882..9d12b97a6c3 100644 --- a/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap +++ b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap @@ -1,17 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 110 A 90 90 0 1 1 149.98429203681178 110.00000137077838 A 10 10 0 0 1 149.98778269529805 130.00000106616096 A 70 70 0 1 0 150 130 A 10 10 0 0 1 150 110 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 120 A 80 80 0 1 1 149.98603736605492 120.00000121846968"`; -exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 10 A 90 90 0 0 1 100 190 L 100 170 A 70 70 0 0 0 100 30 L 100 10 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 20 A 80 80 0 0 1 100 180"`; -exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 17.5 A 82.5 82.5 0 0 1 100 182.5 L 100 177.5 A 77.5 77.5 0 0 0 100 22.5 L 100 17.5 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 20 A 80 80 0 0 1 100 180"`; -exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 40 A 60 60 0 0 1 100 160 L 100 140 A 40 40 0 0 0 100 60 L 100 40 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 50 A 50 50 0 0 1 100 150"`; -exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 10 A 90 90 0 0 1 190 100 L 170 100 A 70 70 0 0 0 100 30 L 100 10 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 20 A 80 80 0 0 1 180 100"`; -exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 A 10 10 0 0 1 30 100.00000000000001 A 70 70 0 1 0 100 30 A 10 10 0 0 1 100 10 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 20 A 80 80 0 1 1 20 100.00000000000001"`; -exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 L 30 100.00000000000001 A 70 70 0 1 0 100 30 L 100 10 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 20 A 80 80 0 1 1 20 100.00000000000001"`; -exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 -5 A 105 105 0 0 1 100 205 L 100 155 A 55 55 0 0 0 100 45 L 100 -5 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 20 A 80 80 0 0 1 100 180"`; diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.test.ts b/packages/grafana-ui/src/components/RadialGauge/colors.test.ts index 321e95bb921..36a4c70d619 100644 --- a/packages/grafana-ui/src/components/RadialGauge/colors.test.ts +++ b/packages/grafana-ui/src/components/RadialGauge/colors.test.ts @@ -1,6 +1,6 @@ import { defaultsDeep } from 'lodash'; -import { createTheme, FALLBACK_COLOR, Field, FieldDisplay, FieldType, ThresholdsMode } from '@grafana/data'; +import { createTheme, Field, FieldDisplay, FieldType, ThresholdsMode } from '@grafana/data'; import { FieldColorModeId } from '@grafana/schema'; import { @@ -50,35 +50,9 @@ describe('RadialGauge color utils', () => { }, }); - it('should return the baseColor if gradient is false-y', () => { - expect( - buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000') - ).toEqual([ - { color: '#FF0000', percent: 0 }, - { color: '#FF0000', percent: 1 }, - ]); - - expect( - buildGradientColors(undefined, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000') - ).toEqual([ - { color: '#FF0000', percent: 0 }, - { color: '#FF0000', percent: 1 }, - ]); - }); - - it('uses the fallback color if no baseColor is set', () => { - expect(buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)))).toEqual( - [ - { color: FALLBACK_COLOR, percent: 0 }, - { color: FALLBACK_COLOR, percent: 1 }, - ] - ); - }); - it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => { expect( buildGradientColors( - true, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds), { view: { getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: '#444444' }))) }, @@ -89,14 +63,13 @@ describe('RadialGauge color utils', () => { it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => { expect( - buildGradientColors(true, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000') + buildGradientColors(createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000') ).toMatchSnapshot(); }); it('should return gradient colors for continuous color modes', () => { expect( buildGradientColors( - true, createTheme(), buildFieldDisplay(createField(FieldColorModeId.ContinuousCividis)), '#00FF00' @@ -107,7 +80,6 @@ describe('RadialGauge color utils', () => { it.each(['dark', 'light'] as const)('should return gradient colors for by-value color mode in %s theme', (mode) => { expect( buildGradientColors( - true, createTheme({ colors: { mode } }), buildFieldDisplay(createField(FieldColorModeId.ContinuousBlues)) ) @@ -117,7 +89,6 @@ describe('RadialGauge color utils', () => { it.each(['dark', 'light'] as const)('should return gradient colors for fixed color mode in %s theme', (mode) => { expect( buildGradientColors( - true, createTheme({ colors: { mode } }), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#442299' diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.ts b/packages/grafana-ui/src/components/RadialGauge/colors.ts index 61160a9f826..3eb81c10899 100644 --- a/packages/grafana-ui/src/components/RadialGauge/colors.ts +++ b/packages/grafana-ui/src/components/RadialGauge/colors.ts @@ -7,18 +7,10 @@ import { GradientStop, RadialShape } from './types'; import { getFieldConfigMinMax, getFieldDisplayProcessor, getValuePercentageForValue } from './utils'; export function buildGradientColors( - gradient = false, theme: GrafanaTheme2, fieldDisplay: FieldDisplay, baseColor = fieldDisplay.display.color ?? FALLBACK_COLOR ): GradientStop[] { - if (!gradient) { - return [ - { color: baseColor, percent: 0 }, - { color: baseColor, percent: 1 }, - ]; - } - const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode); // thresholds get special handling diff --git a/packages/grafana-ui/src/components/RadialGauge/effects.tsx b/packages/grafana-ui/src/components/RadialGauge/effects.tsx index 53a255d4a45..2d3ae3daf21 100644 --- a/packages/grafana-ui/src/components/RadialGauge/effects.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/effects.tsx @@ -2,14 +2,20 @@ import { colorManipulator, GrafanaTheme2 } from '@grafana/data'; import { RadialGaugeDimensions } from './types'; +// some utility transparent white colors for gradients +const TRANSPARENT_WHITE = '#ffffff00'; +const MOSTLY_TRANSPARENT_WHITE = '#ffffff88'; +const MOSTLY_OPAQUE_WHITE = '#ffffffbb'; +const OPAQUE_WHITE = '#ffffff'; + +const MIN_GLOW_SIZE = 0.75; +const GLOW_FACTOR = 0.08; + export interface GlowGradientProps { id: string; barWidth: number; } -const MIN_GLOW_SIZE = 0.75; -const GLOW_FACTOR = 0.08; - export function GlowGradient({ id, barWidth }: GlowGradientProps) { // 0.75 is the minimum glow size, and it scales with bar width const glowSize = MIN_GLOW_SIZE + barWidth * GLOW_FACTOR; @@ -27,16 +33,6 @@ export function GlowGradient({ id, barWidth }: GlowGradientProps) { const CENTER_GLOW_OPACITY = 0.25; -export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) { - const transparentColor = colorManipulator.alpha(color, CENTER_GLOW_OPACITY); - return ( - - - - - ); -} - export interface CenterGlowProps { dimensions: RadialGaugeDimensions; gaugeId: string; @@ -52,7 +48,7 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps - + @@ -62,19 +58,15 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps ); } -export function SpotlightGradient({ - id, - dimensions, - roundedBars, - angle, - theme, -}: { +interface SpotlightGradientProps { id: string; dimensions: RadialGaugeDimensions; angle: number; roundedBars: boolean; theme: GrafanaTheme2; -}) { +} + +export function SpotlightGradient({ id, dimensions, roundedBars, angle, theme }: SpotlightGradientProps) { if (theme.isLight) { return null; } @@ -88,9 +80,9 @@ export function SpotlightGradient({ return ( - - - {roundedBars && } + + + {roundedBars && } ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/types.ts b/packages/grafana-ui/src/components/RadialGauge/types.ts index cc233dd524c..111c7aef7d6 100644 --- a/packages/grafana-ui/src/components/RadialGauge/types.ts +++ b/packages/grafana-ui/src/components/RadialGauge/types.ts @@ -2,6 +2,8 @@ export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'non export type RadialShape = 'circle' | 'gauge'; export interface RadialGaugeDimensions { + vizHeight: number; + vizWidth: number; margin: number; radius: number; centerX: number; diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.test.ts b/packages/grafana-ui/src/components/RadialGauge/utils.test.ts index b9b2e4ad8f3..70ee54a6337 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.test.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.test.ts @@ -283,7 +283,9 @@ describe('RadialGauge utils', () => { }); describe('drawRadialArcPath', () => { - const defaultDims: RadialGaugeDimensions = Object.freeze({ + const defaultDims = Object.freeze({ + vizHeight: 220, + vizWidth: 220, centerX: 100, centerY: 100, radius: 80, @@ -297,7 +299,7 @@ describe('RadialGauge utils', () => { scaleLabelsSpacing: 0, scaleLabelsRadius: 0, gaugeBottomY: 0, - }); + }) satisfies RadialGaugeDimensions; it.each([ { description: 'quarter arc', startAngle: 0, endAngle: 90 }, @@ -324,11 +326,6 @@ describe('RadialGauge utils', () => { expect(drawRadialArcPath(0, 360, defaultDims)).toEqual(drawRadialArcPath(0, 359.99, defaultDims)); expect(drawRadialArcPath(0, 380, defaultDims)).toEqual(drawRadialArcPath(0, 380, defaultDims)); }); - - it('should return empty string if inner radius collapses to zero or below', () => { - const smallRadiusDims = { ...defaultDims, radius: 5, barWidth: 20 }; - expect(drawRadialArcPath(0, 180, smallRadiusDims)).toBe(''); - }); }); }); @@ -341,7 +338,9 @@ describe('RadialGauge utils', () => { describe('getOptimalSegmentCount', () => { it('should adjust segment count based on dimensions and spacing', () => { - const dimensions: RadialGaugeDimensions = { + const dimensions = { + vizHeight: 220, + vizWidth: 220, centerX: 100, centerY: 100, radius: 80, @@ -355,7 +354,7 @@ describe('RadialGauge utils', () => { scaleLabelsSpacing: 0, scaleLabelsRadius: 0, gaugeBottomY: 0, - }; + } satisfies RadialGaugeDimensions; expect(getOptimalSegmentCount(dimensions, 2, 10, 360)).toBe(8); expect(getOptimalSegmentCount(dimensions, 1, 5, 360)).toBe(5); diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.ts b/packages/grafana-ui/src/components/RadialGauge/utils.ts index e26cf5eed2a..a18efc42699 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.ts @@ -155,6 +155,8 @@ export function calculateDimensions( } return { + vizWidth: width, + vizHeight: height, margin, gaugeBottomY: centerY + belowCenterY, radius: innerRadius, @@ -185,7 +187,7 @@ export function drawRadialArcPath( dimensions: RadialGaugeDimensions, roundedBars?: boolean ): string { - const { radius, centerX, centerY, barWidth } = dimensions; + const { radius, centerX, centerY } = dimensions; // For some reason a 100% full arc cannot be rendered if (endAngle >= 360) { @@ -197,66 +199,12 @@ export function drawRadialArcPath( const largeArc = endAngle > 180 ? 1 : 0; - const outerR = radius + barWidth / 2; - const innerR = Math.max(0, radius - barWidth / 2); - if (innerR <= 0) { - return ''; // cannot draw arc with 0 inner radius - } + let x1 = centerX + radius * Math.cos(startRadians); + let y1 = centerY + radius * Math.sin(startRadians); + let x2 = centerX + radius * Math.cos(endRadians); + let y2 = centerY + radius * Math.sin(endRadians); - // get points for both an inner and outer arc. we draw - // the arc entirely with a path's fill instead of using stroke - // so that it can be used as a clip-path. - const ox1 = centerX + outerR * Math.cos(startRadians); - const oy1 = centerY + outerR * Math.sin(startRadians); - const ox2 = centerX + outerR * Math.cos(endRadians); - const oy2 = centerY + outerR * Math.sin(endRadians); - - const ix1 = centerX + innerR * Math.cos(startRadians); - const iy1 = centerY + innerR * Math.sin(startRadians); - const ix2 = centerX + innerR * Math.cos(endRadians); - const iy2 = centerY + innerR * Math.sin(endRadians); - - // calculate the cap width in case we're drawing rounded bars - const capR = barWidth / 2; - - const pathParts = [ - // start at outer start - 'M', - ox1, - oy1, - // outer arc from start to end (clockwise) - 'A', - outerR, - outerR, - 0, - largeArc, - 1, - ox2, - oy2, - ]; - - if (roundedBars) { - // rounded end cap: small arc connecting outer end to inner end - pathParts.push('A', capR, capR, 0, 0, 1, ix2, iy2); - } else { - // straight line to inner end (square butt) - pathParts.push('L', ix2, iy2); - } - - // inner arc from end back to start (counter-clockwise) - pathParts.push('A', innerR, innerR, 0, largeArc, 0, ix1, iy1); - - if (roundedBars) { - // rounded start cap: small arc connecting inner start back to outer start - pathParts.push('A', capR, capR, 0, 0, 1, ox1, oy1); - } else { - // straight line back to outer start (square butt) - pathParts.push('L', ox1, oy1); - } - - pathParts.push('Z'); - - return pathParts.join(' '); + return ['M', x1, y1, 'A', radius, radius, 0, largeArc, 1, x2, y2].join(' '); } export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) { From 901360dca43fecfe557fe1605e68511b851b67ae Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Tue, 6 Jan 2026 09:48:31 -0700 Subject: [PATCH 43/79] Access Control: Re-add OSS Seeding (#115858) --- .../acimpl/basic_role_db_seed.go | 44 ++ .../acimpl/basic_role_db_seed_test.go | 128 ++++ pkg/services/accesscontrol/acimpl/service.go | 64 +- pkg/services/accesscontrol/database/seeder.go | 623 ++++++++++++++++++ .../accesscontrol/dualwrite/reconciler.go | 55 ++ .../dualwrite/reconciler_test.go | 67 ++ pkg/services/accesscontrol/models.go | 16 + pkg/services/accesscontrol/seeding/seeder.go | 452 +++++++++++++ pkg/tests/apis/folder/folder_tree_test.go | 2 - 9 files changed, 1447 insertions(+), 4 deletions(-) create mode 100644 pkg/services/accesscontrol/acimpl/basic_role_db_seed.go create mode 100644 pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go create mode 100644 pkg/services/accesscontrol/database/seeder.go create mode 100644 pkg/services/accesscontrol/dualwrite/reconciler_test.go create mode 100644 pkg/services/accesscontrol/seeding/seeder.go diff --git a/pkg/services/accesscontrol/acimpl/basic_role_db_seed.go b/pkg/services/accesscontrol/acimpl/basic_role_db_seed.go new file mode 100644 index 00000000000..c6128790d1a --- /dev/null +++ b/pkg/services/accesscontrol/acimpl/basic_role_db_seed.go @@ -0,0 +1,44 @@ +package acimpl + +import ( + "context" + "time" + + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +const ( + ossBasicRoleSeedLockName = "oss-ac-basic-role-seeder" + ossBasicRoleSeedTimeout = 2 * time.Minute +) + +// refreshBasicRolePermissionsInDB ensures basic role permissions are fully derived from in-memory registrations +func (s *Service) refreshBasicRolePermissionsInDB(ctx context.Context, rolesSnapshot map[string][]accesscontrol.Permission) error { + if s.sql == nil || s.seeder == nil { + return nil + } + + run := func(ctx context.Context) error { + desired := map[accesscontrol.SeedPermission]struct{}{} + for role, permissions := range rolesSnapshot { + for _, permission := range permissions { + desired[accesscontrol.SeedPermission{BuiltInRole: role, Action: permission.Action, Scope: permission.Scope}] = struct{}{} + } + } + s.seeder.SetDesiredPermissions(desired) + return s.seeder.Seed(ctx) + } + + if s.serverLock == nil { + return run(ctx) + } + + var err error + errLock := s.serverLock.LockExecuteAndRelease(ctx, ossBasicRoleSeedLockName, ossBasicRoleSeedTimeout, func(ctx context.Context) { + err = run(ctx) + }) + if errLock != nil { + return errLock + } + return err +} diff --git a/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go b/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go new file mode 100644 index 00000000000..986a32b66fc --- /dev/null +++ b/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go @@ -0,0 +1,128 @@ +package acimpl + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/localcache" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/database" + "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" + "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/testutil" +) + +func TestIntegration_OSSBasicRolePermissions_PersistAndRefreshOnRegisterFixedRoles(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + ctx := context.Background() + sql := db.InitTestDB(t) + store := database.ProvideService(sql) + + svc := ProvideOSSService( + setting.NewCfg(), + store, + &resourcepermissions.FakeActionSetSvc{}, + localcache.ProvideService(), + featuremgmt.WithFeatures(), + tracing.InitializeTracerForTest(), + sql, + permreg.ProvidePermissionRegistry(), + nil, + ) + + require.NoError(t, svc.DeclareFixedRoles(accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:test:role", + Permissions: []accesscontrol.Permission{ + {Action: "test:read", Scope: ""}, + }, + }, + Grants: []string{string(org.RoleViewer)}, + })) + + require.NoError(t, svc.RegisterFixedRoles(ctx)) + + // verify permission is persisted to DB for basic:viewer + require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { + var role accesscontrol.Role + ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) + require.NoError(t, err) + require.True(t, ok) + + var count int64 + count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count() + require.NoError(t, err) + require.Equal(t, int64(1), count) + return nil + })) + + // ensure RegisterFixedRoles refreshes it back to defaults + require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { + ts := time.Now() + var role accesscontrol.Role + ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) + require.NoError(t, err) + require.True(t, ok) + + _, err = sess.Exec("DELETE FROM permission WHERE role_id = ?", role.ID) + require.NoError(t, err) + p := accesscontrol.Permission{ + RoleID: role.ID, + Action: "custom:keep", + Scope: "", + Created: ts, + Updated: ts, + } + p.Kind, p.Attribute, p.Identifier = accesscontrol.SplitScope(p.Scope) + _, err = sess.Table("permission").Insert(&p) + return err + })) + + svc2 := ProvideOSSService( + setting.NewCfg(), + store, + &resourcepermissions.FakeActionSetSvc{}, + localcache.ProvideService(), + featuremgmt.WithFeatures(), + tracing.InitializeTracerForTest(), + sql, + permreg.ProvidePermissionRegistry(), + nil, + ) + require.NoError(t, svc2.DeclareFixedRoles(accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:test:role", + Permissions: []accesscontrol.Permission{ + {Action: "test:read", Scope: ""}, + }, + }, + Grants: []string{string(org.RoleViewer)}, + })) + require.NoError(t, svc2.RegisterFixedRoles(ctx)) + + require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { + var role accesscontrol.Role + ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) + require.NoError(t, err) + require.True(t, ok) + + var count int64 + count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count() + require.NoError(t, err) + require.Equal(t, int64(1), count) + + count, err = sess.Table("permission").Where("role_id = ? AND action = ?", role.ID, "custom:keep").Count() + require.NoError(t, err) + require.Equal(t, int64(0), count) + return nil + })) +} diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index 1ea8bf95f77..3aab2ad1248 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -30,6 +30,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/migrator" "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" "github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils" + "github.com/grafana/grafana/pkg/services/accesscontrol/seeding" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -96,6 +97,12 @@ func ProvideOSSService( roles: accesscontrol.BuildBasicRoleDefinitions(), store: store, permRegistry: permRegistry, + sql: db, + serverLock: lock, + } + + if backend, ok := store.(*database.AccessControlStore); ok { + s.seeder = seeding.New(log.New("accesscontrol.seeder"), backend, backend) } return s @@ -112,8 +119,11 @@ type Service struct { rolesMu sync.RWMutex roles map[string]*accesscontrol.RoleDTO store accesscontrol.Store + seeder *seeding.Seeder permRegistry permreg.PermissionRegistry isInitialized bool + sql db.DB + serverLock *serverlock.ServerLockService } func (s *Service) GetUsageStats(_ context.Context) map[string]any { @@ -431,17 +441,54 @@ func (s *Service) RegisterFixedRoles(ctx context.Context) error { defer span.End() s.rolesMu.Lock() - defer s.rolesMu.Unlock() - + registrations := s.registrations.Slice() s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool { s.registerRolesLocked(registration) return true }) s.isInitialized = true + + rolesSnapshot := s.getBasicRolePermissionsLocked() + s.rolesMu.Unlock() + + if s.seeder != nil { + if err := s.seeder.SeedRoles(ctx, registrations); err != nil { + return err + } + if err := s.seeder.RemoveAbsentRoles(ctx); err != nil { + return err + } + } + + if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil { + return err + } + return nil } +// getBasicRolePermissionsSnapshotFromRegistrationsLocked computes the desired basic role permissions from the +// current registration list, using the shared seeding registration logic. +// +// it has to be called while holding the roles lock +func (s *Service) getBasicRolePermissionsLocked() map[string][]accesscontrol.Permission { + desired := map[accesscontrol.SeedPermission]struct{}{} + s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool { + seeding.AppendDesiredPermissions(desired, s.log, ®istration.Role, registration.Grants, registration.Exclude) + return true + }) + + out := make(map[string][]accesscontrol.Permission) + for sp := range desired { + out[sp.BuiltInRole] = append(out[sp.BuiltInRole], accesscontrol.Permission{ + Action: sp.Action, + Scope: sp.Scope, + }) + } + return out +} + // registerRolesLocked processes a single role registration and adds permissions to basic roles. // Must be called with s.rolesMu locked. func (s *Service) registerRolesLocked(registration accesscontrol.RoleRegistration) { @@ -474,6 +521,7 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs defer span.End() acRegs := pluginutils.ToRegistrations(ID, name, regs) + updatedBasicRoles := false for _, r := range acRegs { if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil { return err @@ -500,11 +548,23 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs if initialized { s.rolesMu.Lock() s.registerRolesLocked(r) + updatedBasicRoles = true s.rolesMu.Unlock() s.cache.Flush() } } + if updatedBasicRoles { + s.rolesMu.RLock() + rolesSnapshot := s.getBasicRolePermissionsLocked() + s.rolesMu.RUnlock() + + // plugin roles can be declared after startup - keep DB in sync + if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil { + return err + } + } + return nil } diff --git a/pkg/services/accesscontrol/database/seeder.go b/pkg/services/accesscontrol/database/seeder.go new file mode 100644 index 00000000000..2f53d20b514 --- /dev/null +++ b/pkg/services/accesscontrol/database/seeder.go @@ -0,0 +1,623 @@ +package database + +import ( + "context" + "strings" + "time" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/seeding" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/util/xorm/core" +) + +const basicRolePermBatchSize = 500 + +// LoadRoles returns all fixed and plugin roles (global org) with permissions, indexed by role name. +func (s *AccessControlStore) LoadRoles(ctx context.Context) (map[string]*accesscontrol.RoleDTO, error) { + out := map[string]*accesscontrol.RoleDTO{} + + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + type roleRow struct { + ID int64 `xorm:"id"` + OrgID int64 `xorm:"org_id"` + Version int64 `xorm:"version"` + UID string `xorm:"uid"` + Name string `xorm:"name"` + DisplayName string `xorm:"display_name"` + Description string `xorm:"description"` + Group string `xorm:"group_name"` + Hidden bool `xorm:"hidden"` + Updated time.Time `xorm:"updated"` + Created time.Time `xorm:"created"` + } + + roles := []roleRow{} + if err := sess.Table("role"). + Where("org_id = ?", accesscontrol.GlobalOrgID). + Where("(name LIKE ? OR name LIKE ?)", accesscontrol.FixedRolePrefix+"%", accesscontrol.PluginRolePrefix+"%"). + Find(&roles); err != nil { + return err + } + + if len(roles) == 0 { + return nil + } + + roleIDs := make([]any, 0, len(roles)) + roleByID := make(map[int64]*accesscontrol.RoleDTO, len(roles)) + for _, r := range roles { + dto := &accesscontrol.RoleDTO{ + ID: r.ID, + OrgID: r.OrgID, + Version: r.Version, + UID: r.UID, + Name: r.Name, + DisplayName: r.DisplayName, + Description: r.Description, + Group: r.Group, + Hidden: r.Hidden, + Updated: r.Updated, + Created: r.Created, + } + out[dto.Name] = dto + roleByID[dto.ID] = dto + roleIDs = append(roleIDs, dto.ID) + } + + type permRow struct { + RoleID int64 `xorm:"role_id"` + Action string `xorm:"action"` + Scope string `xorm:"scope"` + } + perms := []permRow{} + if err := sess.Table("permission").In("role_id", roleIDs...).Find(&perms); err != nil { + return err + } + + for _, p := range perms { + dto := roleByID[p.RoleID] + if dto == nil { + continue + } + dto.Permissions = append(dto.Permissions, accesscontrol.Permission{ + RoleID: p.RoleID, + Action: p.Action, + Scope: p.Scope, + }) + } + + return nil + }) + + return out, err +} + +func (s *AccessControlStore) SetRole(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error { + if existingRole == nil { + return nil + } + + return s.sql.WithDbSession(ctx, func(sess *db.Session) error { + _, err := sess.Table("role"). + Where("id = ? AND org_id = ?", existingRole.ID, accesscontrol.GlobalOrgID). + Update(map[string]any{ + "display_name": wantedRole.DisplayName, + "description": wantedRole.Description, + "group_name": wantedRole.Group, + "hidden": wantedRole.Hidden, + "updated": time.Now(), + }) + return err + }) +} + +func (s *AccessControlStore) SetPermissions(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error { + if existingRole == nil { + return nil + } + + type key struct{ Action, Scope string } + existing := map[key]struct{}{} + for _, p := range existingRole.Permissions { + existing[key{p.Action, p.Scope}] = struct{}{} + } + desired := map[key]struct{}{} + for _, p := range wantedRole.Permissions { + desired[key{p.Action, p.Scope}] = struct{}{} + } + + toAdd := make([]accesscontrol.Permission, 0) + toRemove := make([]accesscontrol.SeedPermission, 0) + + now := time.Now() + for k := range desired { + if _, ok := existing[k]; ok { + continue + } + perm := accesscontrol.Permission{ + RoleID: existingRole.ID, + Action: k.Action, + Scope: k.Scope, + Created: now, + Updated: now, + } + perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) + toAdd = append(toAdd, perm) + } + + for k := range existing { + if _, ok := desired[k]; ok { + continue + } + toRemove = append(toRemove, accesscontrol.SeedPermission{Action: k.Action, Scope: k.Scope}) + } + + if len(toAdd) == 0 && len(toRemove) == 0 { + return nil + } + + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + if len(toRemove) > 0 { + if err := DeleteRolePermissionTuples(sess, s.sql.GetDBType(), existingRole.ID, toRemove); err != nil { + return err + } + } + + if len(toAdd) > 0 { + _, err := sess.InsertMulti(toAdd) + return err + } + + return nil + }) +} + +func (s *AccessControlStore) CreateRole(ctx context.Context, role accesscontrol.RoleDTO) error { + now := time.Now() + uid := role.UID + if uid == "" && (strings.HasPrefix(role.Name, accesscontrol.FixedRolePrefix) || strings.HasPrefix(role.Name, accesscontrol.PluginRolePrefix)) { + uid = accesscontrol.PrefixedRoleUID(role.Name) + } + r := accesscontrol.Role{ + OrgID: accesscontrol.GlobalOrgID, + Version: role.Version, + UID: uid, + Name: role.Name, + DisplayName: role.DisplayName, + Description: role.Description, + Group: role.Group, + Hidden: role.Hidden, + Created: now, + Updated: now, + } + if r.Version == 0 { + r.Version = 1 + } + + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + if _, err := sess.Insert(&r); err != nil { + return err + } + + if len(role.Permissions) == 0 { + return nil + } + + // De-duplicate permissions on (action, scope) to avoid unique constraint violations. + // Some role definitions may accidentally include duplicates. + type permKey struct{ Action, Scope string } + seen := make(map[permKey]struct{}, len(role.Permissions)) + + perms := make([]accesscontrol.Permission, 0, len(role.Permissions)) + for _, p := range role.Permissions { + k := permKey{Action: p.Action, Scope: p.Scope} + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + + perm := accesscontrol.Permission{ + RoleID: r.ID, + Action: p.Action, + Scope: p.Scope, + Created: now, + Updated: now, + } + perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) + perms = append(perms, perm) + } + _, err := sess.InsertMulti(perms) + return err + }) +} + +func (s *AccessControlStore) DeleteRoles(ctx context.Context, roleUIDs []string) error { + if len(roleUIDs) == 0 { + return nil + } + + uids := make([]any, 0, len(roleUIDs)) + for _, uid := range roleUIDs { + uids = append(uids, uid) + } + + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + type row struct { + ID int64 `xorm:"id"` + UID string `xorm:"uid"` + } + rows := []row{} + if err := sess.Table("role"). + Where("org_id = ?", accesscontrol.GlobalOrgID). + In("uid", uids...). + Find(&rows); err != nil { + return err + } + if len(rows) == 0 { + return nil + } + + roleIDs := make([]any, 0, len(rows)) + for _, r := range rows { + roleIDs = append(roleIDs, r.ID) + } + + // Remove permissions and assignments first to avoid FK issues (if enabled). + { + args := append([]any{"DELETE FROM permission WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + { + args := append([]any{"DELETE FROM user_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + { + args := append([]any{"DELETE FROM team_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + { + args := append([]any{"DELETE FROM builtin_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + + args := append([]any{"DELETE FROM role WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")", accesscontrol.GlobalOrgID}, uids...) + _, err := sess.Exec(args...) + return err + }) +} + +// OSS basic-role permission refresh uses seeding.Seeder.Seed() with a desired set computed in memory. +// These methods implement the permission seeding part of seeding.SeedingBackend against the current permission table. +func (s *AccessControlStore) LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { + var out map[accesscontrol.SeedPermission]struct{} + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + rows, err := LoadBasicRoleSeedPermissions(sess) + if err != nil { + return err + } + + out = make(map[accesscontrol.SeedPermission]struct{}, len(rows)) + for _, r := range rows { + r.Origin = "" + out[r] = struct{}{} + } + return nil + }) + return out, err +} + +func (s *AccessControlStore) Apply(ctx context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error { + rolesToUpgrade := seeding.RolesToUpgrade(added, removed) + + // Run the same OSS apply logic as ossBasicRoleSeedBackend.Apply inside a single transaction. + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + defs := accesscontrol.BuildBasicRoleDefinitions() + builtinToRoleID, err := EnsureBasicRolesExist(sess, defs) + if err != nil { + return err + } + + backend := &ossBasicRoleSeedBackend{ + sess: sess, + now: time.Now(), + builtinToRoleID: builtinToRoleID, + desired: nil, + dbType: s.sql.GetDBType(), + } + if err := backend.Apply(ctx, added, removed, updated); err != nil { + return err + } + + return BumpBasicRoleVersions(sess, rolesToUpgrade) + }) +} + +// EnsureBasicRolesExist ensures the built-in basic roles exist in the role table and are bound in builtin_role. +// It returns a mapping from builtin role name (for example "Admin") to role ID. +func EnsureBasicRolesExist(sess *db.Session, defs map[string]*accesscontrol.RoleDTO) (map[string]int64, error) { + uidToBuiltin := make(map[string]string, len(defs)) + uids := make([]any, 0, len(defs)) + for builtin, def := range defs { + uidToBuiltin[def.UID] = builtin + uids = append(uids, def.UID) + } + + type roleRow struct { + ID int64 `xorm:"id"` + UID string `xorm:"uid"` + } + + rows := []roleRow{} + if err := sess.Table("role"). + Where("org_id = ?", accesscontrol.GlobalOrgID). + In("uid", uids...). + Find(&rows); err != nil { + return nil, err + } + + ts := time.Now() + + builtinToRoleID := make(map[string]int64, len(defs)) + for _, r := range rows { + br, ok := uidToBuiltin[r.UID] + if !ok { + continue + } + builtinToRoleID[br] = r.ID + } + + for builtin, def := range defs { + roleID, ok := builtinToRoleID[builtin] + if !ok { + role := accesscontrol.Role{ + OrgID: def.OrgID, + Version: def.Version, + UID: def.UID, + Name: def.Name, + DisplayName: def.DisplayName, + Description: def.Description, + Group: def.Group, + Hidden: def.Hidden, + Created: ts, + Updated: ts, + } + if _, err := sess.Insert(&role); err != nil { + return nil, err + } + roleID = role.ID + builtinToRoleID[builtin] = roleID + } + + has, err := sess.Table("builtin_role"). + Where("role_id = ? AND role = ? AND org_id = ?", roleID, builtin, accesscontrol.GlobalOrgID). + Exist() + if err != nil { + return nil, err + } + if !has { + br := accesscontrol.BuiltinRole{ + RoleID: roleID, + OrgID: accesscontrol.GlobalOrgID, + Role: builtin, + Created: ts, + Updated: ts, + } + if _, err := sess.Table("builtin_role").Insert(&br); err != nil { + return nil, err + } + } + } + + return builtinToRoleID, nil +} + +// DeleteRolePermissionTuples deletes permissions for a single role by (action, scope) pairs. +// +// It uses a row-constructor IN clause where supported (MySQL, Postgres, SQLite) and falls back +// to a WHERE ... OR ... form for MSSQL. +func DeleteRolePermissionTuples(sess *db.Session, dbType core.DbType, roleID int64, perms []accesscontrol.SeedPermission) error { + if len(perms) == 0 { + return nil + } + + if dbType == migrator.MSSQL { + // MSSQL doesn't support (action, scope) IN ((?,?),(?,?)) row constructors. + where := make([]string, 0, len(perms)) + args := make([]any, 0, 1+len(perms)*2) + args = append(args, roleID) + for _, p := range perms { + where = append(where, "(action = ? AND scope = ?)") + args = append(args, p.Action, p.Scope) + } + _, err := sess.Exec( + append([]any{ + "DELETE FROM permission WHERE role_id = ? AND (" + strings.Join(where, " OR ") + ")", + }, args...)..., + ) + return err + } + + args := make([]any, 0, 1+len(perms)*2) + args = append(args, roleID) + for _, p := range perms { + args = append(args, p.Action, p.Scope) + } + sql := "DELETE FROM permission WHERE role_id = ? AND (action, scope) IN (" + + strings.Repeat("(?, ?),", len(perms)-1) + "(?, ?))" + _, err := sess.Exec(append([]any{sql}, args...)...) + return err +} + +type ossBasicRoleSeedBackend struct { + sess *db.Session + now time.Time + builtinToRoleID map[string]int64 + desired map[accesscontrol.SeedPermission]struct{} + dbType core.DbType +} + +func (b *ossBasicRoleSeedBackend) LoadPrevious(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { + rows, err := LoadBasicRoleSeedPermissions(b.sess) + if err != nil { + return nil, err + } + + out := make(map[accesscontrol.SeedPermission]struct{}, len(rows)) + for _, r := range rows { + // Ensure the key matches what OSS seeding uses (Origin is always empty for basic role refresh). + r.Origin = "" + out[r] = struct{}{} + } + return out, nil +} + +func (b *ossBasicRoleSeedBackend) LoadDesired(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { + return b.desired, nil +} + +func (b *ossBasicRoleSeedBackend) Apply(_ context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error { + // Delete removed permissions (this includes user-defined permissions that aren't in desired). + if len(removed) > 0 { + permsByRoleID := map[int64][]accesscontrol.SeedPermission{} + for _, p := range removed { + roleID, ok := b.builtinToRoleID[p.BuiltInRole] + if !ok { + continue + } + permsByRoleID[roleID] = append(permsByRoleID[roleID], p) + } + + for roleID, perms := range permsByRoleID { + // Chunk to keep statement sizes and parameter counts bounded. + if err := batch(len(perms), basicRolePermBatchSize, func(start, end int) error { + return DeleteRolePermissionTuples(b.sess, b.dbType, roleID, perms[start:end]) + }); err != nil { + return err + } + } + } + + // Insert added permissions and updated-target permissions. + toInsertSeed := make([]accesscontrol.SeedPermission, 0, len(added)+len(updated)) + toInsertSeed = append(toInsertSeed, added...) + for _, v := range updated { + toInsertSeed = append(toInsertSeed, v) + } + if len(toInsertSeed) == 0 { + return nil + } + + // De-duplicate on (role_id, action, scope). This avoids unique constraint violations when: + // - the same permission appears in both added and updated + // - multiple plugin origins grant the same permission (Origin is not persisted in permission table) + type permKey struct { + RoleID int64 + Action string + Scope string + } + seen := make(map[permKey]struct{}, len(toInsertSeed)) + + toInsert := make([]accesscontrol.Permission, 0, len(toInsertSeed)) + for _, p := range toInsertSeed { + roleID, ok := b.builtinToRoleID[p.BuiltInRole] + if !ok { + continue + } + k := permKey{RoleID: roleID, Action: p.Action, Scope: p.Scope} + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + + perm := accesscontrol.Permission{ + RoleID: roleID, + Action: p.Action, + Scope: p.Scope, + Created: b.now, + Updated: b.now, + } + perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) + toInsert = append(toInsert, perm) + } + + return batch(len(toInsert), basicRolePermBatchSize, func(start, end int) error { + // MySQL: ignore conflicts to make seeding idempotent under retries/concurrency. + // Conflicts can happen if the same permission already exists (unique on role_id, action, scope). + if b.dbType == migrator.MySQL { + args := make([]any, 0, (end-start)*8) + for i := start; i < end; i++ { + p := toInsert[i] + args = append(args, p.RoleID, p.Action, p.Scope, p.Kind, p.Attribute, p.Identifier, p.Updated, p.Created) + } + sql := append([]any{`INSERT IGNORE INTO permission (role_id, action, scope, kind, attribute, identifier, updated, created) VALUES ` + + strings.Repeat("(?, ?, ?, ?, ?, ?, ?, ?),", end-start-1) + "(?, ?, ?, ?, ?, ?, ?, ?)"}, args...) + _, err := b.sess.Exec(sql...) + return err + } + + _, err := b.sess.InsertMulti(toInsert[start:end]) + return err + }) +} + +func batch(count, size int, eachFn func(start, end int) error) error { + for i := 0; i < count; { + end := i + size + if end > count { + end = count + } + if err := eachFn(i, end); err != nil { + return err + } + i = end + } + return nil +} + +// BumpBasicRoleVersions increments the role version for the given builtin basic roles (Viewer/Editor/Admin/Grafana Admin). +// Unknown role names are ignored. +func BumpBasicRoleVersions(sess *db.Session, basicRoles []string) error { + if len(basicRoles) == 0 { + return nil + } + + defs := accesscontrol.BuildBasicRoleDefinitions() + uids := make([]any, 0, len(basicRoles)) + for _, br := range basicRoles { + def, ok := defs[br] + if !ok { + continue + } + uids = append(uids, def.UID) + } + if len(uids) == 0 { + return nil + } + + sql := "UPDATE role SET version = version + 1 WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")" + _, err := sess.Exec(append([]any{sql, accesscontrol.GlobalOrgID}, uids...)...) + return err +} + +// LoadBasicRoleSeedPermissions returns the current (builtin_role, action, scope) permissions granted to basic roles. +// It sets Origin to empty. +func LoadBasicRoleSeedPermissions(sess *db.Session) ([]accesscontrol.SeedPermission, error) { + rows := []accesscontrol.SeedPermission{} + err := sess.SQL( + `SELECT role.display_name AS builtin_role, p.action, p.scope, '' AS origin + FROM role INNER JOIN permission AS p ON p.role_id = role.id + WHERE role.org_id = ? AND role.name LIKE 'basic:%'`, + accesscontrol.GlobalOrgID, + ).Find(&rows) + return rows, err +} diff --git a/pkg/services/accesscontrol/dualwrite/reconciler.go b/pkg/services/accesscontrol/dualwrite/reconciler.go index a0f2f47b77d..ff6637219a4 100644 --- a/pkg/services/accesscontrol/dualwrite/reconciler.go +++ b/pkg/services/accesscontrol/dualwrite/reconciler.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/serverlock" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -130,6 +131,9 @@ func (r *ZanzanaReconciler) Run(ctx context.Context) error { // Reconcile schedules as job that will run and reconcile resources between // legacy access control and zanzana. func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error { + // Ensure we don't reconcile an empty/partial RBAC state before OSS has seeded basic role permissions. + // This matters most during startup where fixed-role loading + basic-role permission refresh runs as another background service. + r.waitForBasicRolesSeeded(ctx) r.reconcile(ctx) // FIXME: @@ -145,6 +149,57 @@ func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error { } } +func (r *ZanzanaReconciler) hasBasicRolePermissions(ctx context.Context) bool { + var count int64 + // Basic role permissions are stored on "basic:%" roles in the global org (0). + // In a fresh DB, this will be empty until fixed roles are registered and the basic role permission refresh runs. + type row struct { + Count int64 `xorm:"count"` + } + _ = r.store.WithDbSession(ctx, func(sess *db.Session) error { + var rr row + _, err := sess.SQL( + `SELECT COUNT(*) AS count + FROM role INNER JOIN permission AS p ON p.role_id = role.id + WHERE role.org_id = ? AND role.name LIKE ?`, + accesscontrol.GlobalOrgID, + accesscontrol.BasicRolePrefix+"%", + ).Get(&rr) + if err != nil { + return err + } + count = rr.Count + return nil + }) + return count > 0 +} + +func (r *ZanzanaReconciler) waitForBasicRolesSeeded(ctx context.Context) { + // Best-effort: don't block forever. If we can't observe basic roles, proceed anyway. + const ( + maxWait = 15 * time.Second + interval = 1 * time.Second + ) + + deadline := time.NewTimer(maxWait) + defer deadline.Stop() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + if r.hasBasicRolePermissions(ctx) { + return + } + select { + case <-ctx.Done(): + return + case <-deadline.C: + return + case <-ticker.C: + } + } +} + func (r *ZanzanaReconciler) reconcile(ctx context.Context) { run := func(ctx context.Context, namespace string) (ok bool) { now := time.Now() diff --git a/pkg/services/accesscontrol/dualwrite/reconciler_test.go b/pkg/services/accesscontrol/dualwrite/reconciler_test.go new file mode 100644 index 00000000000..0defea011a0 --- /dev/null +++ b/pkg/services/accesscontrol/dualwrite/reconciler_test.go @@ -0,0 +1,67 @@ +package dualwrite + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +func TestZanzanaReconciler_hasBasicRolePermissions(t *testing.T) { + env := setupTestEnv(t) + + r := &ZanzanaReconciler{ + store: env.db, + } + + ctx := context.Background() + require.False(t, r.hasBasicRolePermissions(ctx)) + + err := env.db.WithDbSession(ctx, func(sess *db.Session) error { + now := time.Now() + + _, err := sess.Exec( + `INSERT INTO role (org_id, uid, name, display_name, group_name, description, hidden, version, created, updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + accesscontrol.GlobalOrgID, + "basic_viewer_uid_test", + accesscontrol.BasicRolePrefix+"viewer", + "Viewer", + "Basic", + "Viewer role", + false, + 1, + now, + now, + ) + if err != nil { + return err + } + + var roleID int64 + if _, err := sess.SQL(`SELECT id FROM role WHERE org_id = ? AND uid = ?`, accesscontrol.GlobalOrgID, "basic_viewer_uid_test").Get(&roleID); err != nil { + return err + } + + _, err = sess.Exec( + `INSERT INTO permission (role_id, action, scope, kind, attribute, identifier, created, updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + roleID, + "dashboards:read", + "dashboards:*", + "", + "", + "", + now, + now, + ) + return err + }) + require.NoError(t, err) + + require.True(t, r.hasBasicRolePermissions(ctx)) +} diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index b18fb4134f3..85df44750d2 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -1,6 +1,7 @@ package accesscontrol import ( + "context" "encoding/json" "errors" "fmt" @@ -594,3 +595,18 @@ type QueryWithOrg struct { OrgId *int64 `json:"orgId"` Global bool `json:"global"` } + +type SeedPermission struct { + BuiltInRole string `xorm:"builtin_role"` + Action string `xorm:"action"` + Scope string `xorm:"scope"` + Origin string `xorm:"origin"` +} + +type RoleStore interface { + LoadRoles(ctx context.Context) (map[string]*RoleDTO, error) + SetRole(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error + SetPermissions(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error + CreateRole(ctx context.Context, role RoleDTO) error + DeleteRoles(ctx context.Context, roleUIDs []string) error +} diff --git a/pkg/services/accesscontrol/seeding/seeder.go b/pkg/services/accesscontrol/seeding/seeder.go new file mode 100644 index 00000000000..121be48dbb0 --- /dev/null +++ b/pkg/services/accesscontrol/seeding/seeder.go @@ -0,0 +1,452 @@ +package seeding + +import ( + "context" + "fmt" + "regexp" + "slices" + "strings" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" +) + +type Seeder struct { + log log.Logger + roleStore accesscontrol.RoleStore + backend SeedingBackend + builtinsPermissions map[accesscontrol.SeedPermission]struct{} + seededFixedRoles map[string]bool + seededPluginRoles map[string]bool + seededPlugins map[string]bool + hasSeededAlready bool +} + +// SeedingBackend provides the seed-set specific operations needed to seed. +type SeedingBackend interface { + // LoadPrevious returns the currently stored permissions for previously seeded roles. + LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error) + + // Apply updates the database to match the desired permissions. + Apply(ctx context.Context, + added, removed []accesscontrol.SeedPermission, + updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission, + ) error +} + +func New(log log.Logger, roleStore accesscontrol.RoleStore, backend SeedingBackend) *Seeder { + return &Seeder{ + log: log, + roleStore: roleStore, + backend: backend, + builtinsPermissions: map[accesscontrol.SeedPermission]struct{}{}, + seededFixedRoles: map[string]bool{}, + seededPluginRoles: map[string]bool{}, + seededPlugins: map[string]bool{}, + hasSeededAlready: false, + } +} + +// SetDesiredPermissions replaces the in-memory desired permission set used by Seed(). +func (s *Seeder) SetDesiredPermissions(desired map[accesscontrol.SeedPermission]struct{}) { + if desired == nil { + s.builtinsPermissions = map[accesscontrol.SeedPermission]struct{}{} + return + } + s.builtinsPermissions = desired +} + +// Seed loads current and desired permissions, diffs them (including scope updates), applies changes, and bumps versions. +func (s *Seeder) Seed(ctx context.Context) error { + previous, err := s.backend.LoadPrevious(ctx) + if err != nil { + return err + } + + // - Do not remove plugin permissions when the plugin didn't register this run (Origin set but not in seededPlugins). + // - Preserve legacy plugin app access permissions in the persisted seed set (these are granted by default). + if len(previous) > 0 { + filtered := make(map[accesscontrol.SeedPermission]struct{}, len(previous)) + for p := range previous { + // Legacy plugin app access permissions (Origin set) are granted by default and managed outside seeding. + // Keep them out of the diff so seeding doesn't try to remove or "re-add" them on every run. + if p.Action == pluginaccesscontrol.ActionAppAccess && p.Origin != "" { + continue + } + if p.Origin != "" && !s.seededPlugins[p.Origin] { + continue + } + filtered[p] = struct{}{} + } + previous = filtered + } + + added, removed, updated := s.permissionDiff(previous, s.builtinsPermissions) + + if err := s.backend.Apply(ctx, added, removed, updated); err != nil { + return err + } + return nil +} + +// SeedRoles populates the database with the roles and their assignments +// It will create roles that do not exist and update roles that have changed +// Do not use for provisioning. Validation is not enforced. +func (s *Seeder) SeedRoles(ctx context.Context, registrationList []accesscontrol.RoleRegistration) error { + roleMap, err := s.roleStore.LoadRoles(ctx) + if err != nil { + return err + } + + missingRoles := make([]accesscontrol.RoleRegistration, 0, len(registrationList)) + + // Diff existing roles with the ones we want to seed. + // If a role is missing, we add it to the missingRoles list + for _, registration := range registrationList { + registration := registration + role, ok := roleMap[registration.Role.Name] + switch { + case registration.Role.IsFixed(): + s.seededFixedRoles[registration.Role.Name] = true + case registration.Role.IsPlugin(): + s.seededPluginRoles[registration.Role.Name] = true + // To be resilient to failed plugin loadings, we remember the plugins that have registered, + // later we'll ignore permissions and roles of other plugins + s.seededPlugins[pluginutils.PluginIDFromName(registration.Role.Name)] = true + } + + s.rememberPermissionAssignments(®istration.Role, registration.Grants, registration.Exclude) + + if !ok { + missingRoles = append(missingRoles, registration) + continue + } + + if needsRoleUpdate(role, registration.Role) { + if err := s.roleStore.SetRole(ctx, role, registration.Role); err != nil { + return err + } + } + + if needsPermissionsUpdate(role, registration.Role) { + if err := s.roleStore.SetPermissions(ctx, role, registration.Role); err != nil { + return err + } + } + } + + for _, registration := range missingRoles { + if err := s.roleStore.CreateRole(ctx, registration.Role); err != nil { + return err + } + } + + return nil +} + +func needsPermissionsUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool { + if existingRole == nil { + return true + } + + if len(existingRole.Permissions) != len(wantedRole.Permissions) { + return true + } + + for _, p := range wantedRole.Permissions { + found := false + for _, ep := range existingRole.Permissions { + if ep.Action == p.Action && ep.Scope == p.Scope { + found = true + break + } + } + if !found { + return true + } + } + + return false +} + +func needsRoleUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool { + if existingRole == nil { + return true + } + + if existingRole.Name != wantedRole.Name { + return false + } + + if existingRole.DisplayName != wantedRole.DisplayName { + return true + } + + if existingRole.Description != wantedRole.Description { + return true + } + + if existingRole.Group != wantedRole.Group { + return true + } + + if existingRole.Hidden != wantedRole.Hidden { + return true + } + + return false +} + +// Deprecated: SeedRole is deprecated and should not be used. +// SeedRoles only does boot up seeding and should not be used for runtime seeding. +func (s *Seeder) SeedRole(ctx context.Context, role accesscontrol.RoleDTO, builtInRoles []string) error { + addedPermissions := make(map[string]struct{}, len(role.Permissions)) + permissions := make([]accesscontrol.Permission, 0, len(role.Permissions)) + for _, p := range role.Permissions { + key := fmt.Sprintf("%s:%s", p.Action, p.Scope) + if _, ok := addedPermissions[key]; !ok { + addedPermissions[key] = struct{}{} + permissions = append(permissions, accesscontrol.Permission{Action: p.Action, Scope: p.Scope}) + } + } + + wantedRole := accesscontrol.RoleDTO{ + OrgID: accesscontrol.GlobalOrgID, + Version: role.Version, + UID: role.UID, + Name: role.Name, + DisplayName: role.DisplayName, + Description: role.Description, + Group: role.Group, + Permissions: permissions, + Hidden: role.Hidden, + } + roleMap, err := s.roleStore.LoadRoles(ctx) + if err != nil { + return err + } + + existingRole := roleMap[wantedRole.Name] + if existingRole == nil { + if err := s.roleStore.CreateRole(ctx, wantedRole); err != nil { + return err + } + } else { + if needsRoleUpdate(existingRole, wantedRole) { + if err := s.roleStore.SetRole(ctx, existingRole, wantedRole); err != nil { + return err + } + } + if needsPermissionsUpdate(existingRole, wantedRole) { + if err := s.roleStore.SetPermissions(ctx, existingRole, wantedRole); err != nil { + return err + } + } + } + + // Remember seeded roles + if wantedRole.IsFixed() { + s.seededFixedRoles[wantedRole.Name] = true + } + isPluginRole := wantedRole.IsPlugin() + if isPluginRole { + s.seededPluginRoles[wantedRole.Name] = true + + // To be resilient to failed plugin loadings, we remember the plugins that have registered, + // later we'll ignore permissions and roles of other plugins + s.seededPlugins[pluginutils.PluginIDFromName(role.Name)] = true + } + + s.rememberPermissionAssignments(&wantedRole, builtInRoles, []string{}) + return nil +} + +func (s *Seeder) rememberPermissionAssignments(role *accesscontrol.RoleDTO, builtInRoles []string, excludedRoles []string) { + AppendDesiredPermissions(s.builtinsPermissions, s.log, role, builtInRoles, excludedRoles) +} + +// AppendDesiredPermissions accumulates permissions from a role registration onto basic roles (Viewer/Editor/Admin/Grafana Admin). +// - It expands parents via accesscontrol.BuiltInRolesWithParents. +// - It can optionally ignore plugin app access permissions (which are granted by default). +func AppendDesiredPermissions( + out map[accesscontrol.SeedPermission]struct{}, + logger log.Logger, + role *accesscontrol.RoleDTO, + builtInRoles []string, + excludedRoles []string, +) { + if out == nil || role == nil { + return + } + + for builtInRole := range accesscontrol.BuiltInRolesWithParents(builtInRoles) { + // Skip excluded grants + if slices.Contains(excludedRoles, builtInRole) { + continue + } + + for _, perm := range role.Permissions { + if role.IsPlugin() && perm.Action == pluginaccesscontrol.ActionAppAccess { + logger.Debug("Role is attempting to grant access permission, but this permission is already granted by default and will be ignored", + "role", role.Name, "permission", perm.Action, "scope", perm.Scope) + continue + } + + sp := accesscontrol.SeedPermission{ + BuiltInRole: builtInRole, + Action: perm.Action, + Scope: perm.Scope, + } + + if role.IsPlugin() { + sp.Origin = pluginutils.PluginIDFromName(role.Name) + } + + out[sp] = struct{}{} + } + } +} + +// permissionDiff returns: +// - added: present in desired permissions, not in previous permissions +// - removed: present in previous permissions, not in desired permissions +// - updated: same role + action, but scope changed +func (s *Seeder) permissionDiff(previous, desired map[accesscontrol.SeedPermission]struct{}) (added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) { + addedSet := make(map[accesscontrol.SeedPermission]struct{}, 0) + for n := range desired { + if _, already := previous[n]; !already { + addedSet[n] = struct{}{} + } else { + delete(previous, n) + } + } + + // Check if any of the new permissions is actually an old permission with an updated scope + updated = make(map[accesscontrol.SeedPermission]accesscontrol.SeedPermission, 0) + for n := range addedSet { + for p := range previous { + if n.BuiltInRole == p.BuiltInRole && n.Action == p.Action { + updated[p] = n + delete(addedSet, n) + } + } + } + + for p := range addedSet { + added = append(added, p) + } + + for p := range previous { + if p.Action == pluginaccesscontrol.ActionAppAccess && + p.Scope != pluginaccesscontrol.ScopeProvider.GetResourceAllScope() { + // Allows backward compatibility with plugins that have been seeded before the grant ignore rule was added + s.log.Info("This permission already existed so it will not be removed", + "role", p.BuiltInRole, "permission", p.Action, "scope", p.Scope) + continue + } + + removed = append(removed, p) + } + + return added, removed, updated +} + +func (s *Seeder) ClearBasicRolesPluginPermissions(ID string) { + removable := []accesscontrol.SeedPermission{} + + for key := range s.builtinsPermissions { + if matchPermissionByPluginID(key, ID) { + removable = append(removable, key) + } + } + + for _, perm := range removable { + delete(s.builtinsPermissions, perm) + } +} + +func matchPermissionByPluginID(perm accesscontrol.SeedPermission, pluginID string) bool { + if perm.Origin != pluginID { + return false + } + actionTemplate := regexp.MustCompile(fmt.Sprintf("%s[.:]", pluginID)) + scopeTemplate := fmt.Sprintf(":%s", pluginID) + return actionTemplate.MatchString(perm.Action) || strings.HasSuffix(perm.Scope, scopeTemplate) +} + +// RolesToUpgrade returns the unique basic roles that should have their version incremented. +func RolesToUpgrade(added, removed []accesscontrol.SeedPermission) []string { + set := map[string]struct{}{} + for _, p := range added { + set[p.BuiltInRole] = struct{}{} + } + for _, p := range removed { + set[p.BuiltInRole] = struct{}{} + } + out := make([]string, 0, len(set)) + for r := range set { + out = append(out, r) + } + return out +} + +func (s *Seeder) ClearPluginRoles(ID string) { + expectedPrefix := fmt.Sprintf("%s%s:", accesscontrol.PluginRolePrefix, ID) + + for roleName := range s.seededPluginRoles { + if strings.HasPrefix(roleName, expectedPrefix) { + delete(s.seededPluginRoles, roleName) + } + } +} + +func (s *Seeder) MarkSeededAlready() { + s.hasSeededAlready = true +} + +func (s *Seeder) HasSeededAlready() bool { + return s.hasSeededAlready +} + +func (s *Seeder) RemoveAbsentRoles(ctx context.Context) error { + roleMap, errGet := s.roleStore.LoadRoles(ctx) + if errGet != nil { + s.log.Error("failed to get fixed roles from store", "err", errGet) + return errGet + } + + toRemove := []string{} + for _, r := range roleMap { + if r == nil { + continue + } + if r.IsFixed() { + if !s.seededFixedRoles[r.Name] { + s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name) + toRemove = append(toRemove, r.UID) + } + continue + } + + if r.IsPlugin() { + if !s.seededPlugins[pluginutils.PluginIDFromName(r.Name)] { + // To be resilient to failed plugin loadings + // ignore stored roles related to plugins that have not registered this time + s.log.Debug("plugin role has not been registered on this run skipping its removal", "role", r.Name) + continue + } + if !s.seededPluginRoles[r.Name] { + s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name) + toRemove = append(toRemove, r.UID) + } + } + } + + if errDelete := s.roleStore.DeleteRoles(ctx, toRemove); errDelete != nil { + s.log.Error("failed to delete absent fixed and plugin roles", "err", errDelete) + return errDelete + } + return nil +} diff --git a/pkg/tests/apis/folder/folder_tree_test.go b/pkg/tests/apis/folder/folder_tree_test.go index 4d95d64b024..613d021b236 100644 --- a/pkg/tests/apis/folder/folder_tree_test.go +++ b/pkg/tests/apis/folder/folder_tree_test.go @@ -33,8 +33,6 @@ import ( ) func TestIntegrationFolderTreeZanzana(t *testing.T) { - // TODO: Add back OSS seeding and enable this test - t.Skip("Skipping folder tree test with Zanzana") testutil.SkipIntegrationTestInShortMode(t) runIntegrationFolderTree(t, testinfra.GrafanaOpts{ From 1f88aeb91f052a894752aa6ebda184bb8f9dc6b1 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Tue, 6 Jan 2026 09:54:12 -0700 Subject: [PATCH 44/79] Provisioning: Update docs around file watches (#115803) --- .../administration/provisioning/index.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/sources/administration/provisioning/index.md b/docs/sources/administration/provisioning/index.md index 15215275b53..29347497881 100644 --- a/docs/sources/administration/provisioning/index.md +++ b/docs/sources/administration/provisioning/index.md @@ -428,12 +428,25 @@ Or using a Kubernetes format, for example `kubernetes-dashboard.json`: You _must_ use the Kubernetes resource format to provision dashboards v2 / dynamic dashboards. -It later polls that path every `updateIntervalSeconds` for updates to the dashboard files and updates its database. - {{< admonition type="note" >}} Grafana installs dashboards at the root level if you don't set the `folder` field. {{< /admonition >}} +#### Detect updates to provisioned dashboards files + +After Grafana provisions your dashboards, it checks the filesystem for changes and updates dashboards as needed. + +The mechanism Grafana uses to do this depends on your `updateIntervalSeconds` value: + +- **More than 10 seconds**: Grafana polls the path at that interval. +- **10 seconds or less**: Grafana watches the filesystem for changes and updates dashboards when it detects them. + +{{< admonition type="note" >}} +When `updateIntervalSeconds` is 10 or less, Grafana relies on filesystem watch events to detect changes. +Depending on your filesystem and how you mount or sync dashboard files (for example, Docker bind mounts or some network filesystems), those events might not reach Grafana. +To work around this, set `updateIntervalSeconds` to more than 10 to force polling, or update your setup so filesystem watch events are propagated. +{{< /admonition >}} + #### Make changes to a provisioned dashboard You can make changes to a provisioned dashboard in the Grafana UI but its not possible to automatically save the changes back to the provisioning source. From e1c60e0a831b978bd0c05a551d745abd7ab22f53 Mon Sep 17 00:00:00 2001 From: Isabella Siu Date: Tue, 6 Jan 2026 12:17:20 -0500 Subject: [PATCH 45/79] Cloudwatch: Update grafana-aws-sdk to 1.4.2 (#115855) --- apps/advisor/go.mod | 12 ++++++------ apps/advisor/go.sum | 44 ++++++++++++++++++++++---------------------- apps/iam/go.mod | 22 +++++++++++----------- apps/iam/go.sum | 44 ++++++++++++++++++++++---------------------- apps/plugins/go.mod | 12 ++++++------ apps/plugins/go.sum | 24 ++++++++++++------------ go.mod | 22 +++++++++++----------- go.sum | 44 ++++++++++++++++++++++---------------------- go.work.sum | 18 ++++++++++++++++++ 9 files changed, 130 insertions(+), 112 deletions(-) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 8200cad9e13..c65d5dacdc5 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -69,12 +69,12 @@ require ( github.com/at-wat/mqtt-go v0.19.6 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect github.com/aws/smithy-go v1.23.2 // indirect github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/benbjohnson/clock v1.3.5 // indirect @@ -162,14 +162,14 @@ require ( github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect - github.com/grafana/grafana-aws-sdk v1.3.0 // indirect + github.com/grafana/grafana-aws-sdk v1.4.2 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana/apps/provisioning v0.0.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect - github.com/grafana/sqlds/v4 v4.2.7 // indirect + github.com/grafana/sqlds/v5 v5.0.3 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 3f15ad1534c..c306b9fa889 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -177,38 +177,38 @@ github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrK github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY= -github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= -github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= +github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y= +github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZgtAXTND1Kk9OXg= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= @@ -637,8 +637,8 @@ github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfU github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k= github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw= github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU= -github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= -github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= +github.com/grafana/grafana-aws-sdk v1.4.2 h1:GrUEoLbs46r8rG/GZL4L2b63Bo+rkIYKdtCT7kT5KkM= +github.com/grafana/grafana-aws-sdk v1.4.2/go.mod h1:1qnZdYs6gQzxxF0dDodaE7Rn9fiMzuhwvtaAZ7ySnhY= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= @@ -655,8 +655,8 @@ github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasn github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= -github.com/grafana/sqlds/v4 v4.2.7 h1:sFQhsS7DBakNMdxa++yOfJ9BVvkZwFJ0B95o57K0/XA= -github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= +github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU= +github.com/grafana/sqlds/v5 v5.0.3/go.mod h1:GKeTTiC+GeR1X0z3f0Iee+hZnNgN62uQpj5XVMx5Uew= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index d9e0db56519..0c91d66c945 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -108,22 +108,22 @@ require ( github.com/aws/aws-sdk-go v1.55.7 // indirect github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect - github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect github.com/aws/smithy-go v1.23.2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect @@ -229,7 +229,7 @@ require ( github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect - github.com/grafana/grafana-aws-sdk v1.3.0 // indirect + github.com/grafana/grafana-aws-sdk v1.4.2 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana-plugin-sdk-go v0.284.0 // indirect github.com/grafana/grafana/apps/dashboard v0.0.0 // indirect @@ -242,7 +242,7 @@ require ( github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect - github.com/grafana/sqlds/v4 v4.2.7 // indirect + github.com/grafana/sqlds/v5 v5.0.3 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 8ddbe4d3b9f..7c089ae155c 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -242,20 +242,20 @@ github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrK github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY= -github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= -github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= +github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y= +github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 h1:Nn3qce+OHZuMj/edx4its32uxedAmquCDxtZkrdeiD4= @@ -264,12 +264,12 @@ github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 h1:e5cbPZYTIY2nUEFie github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0/go.mod h1:UseIHRfrm7PqeZo6fcTb6FUCXzCnh1KJbQbmOfxArGM= github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 h1:IfMb3Ar8xEaWjgH/zeVHYD8izwJdQgRP5mKCTDt4GNk= github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2/go.mod h1:35jGWx7ECvCwTsApqicFYzZ7JFEnBc6oHUuOQ3xIS54= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U= github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 h1:zJeUxFP7+XP52u23vrp4zMcVhShTWbNO8dHV6xCSvFo= @@ -282,12 +282,12 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZ github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 h1:w6a0H79HrHf3lr+zrw+pSzR5B+caiQFAKiNHlrUcnoc= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1/go.mod h1:c6Vg0BRiU7v0MVhHupw90RyL120QBwAMLbDCzptGeMk= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 h1:60m4tnanN1ctzIu4V3bfCNJ39BiOPSm1gHFlFjTkRE0= @@ -853,8 +853,8 @@ github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfU github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k= github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw= github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU= -github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= -github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= +github.com/grafana/grafana-aws-sdk v1.4.2 h1:GrUEoLbs46r8rG/GZL4L2b63Bo+rkIYKdtCT7kT5KkM= +github.com/grafana/grafana-aws-sdk v1.4.2/go.mod h1:1qnZdYs6gQzxxF0dDodaE7Rn9fiMzuhwvtaAZ7ySnhY= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 h1:JOzchPgptwJdruYoed7x28lFDwhzs7kssResYsnC0iI= @@ -891,8 +891,8 @@ github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae h1:35W3Wjp github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae/go.mod h1:6CJ1uXmLZ13ufpO9xE4pST+DyaBt0uszzrV0YnoaVLQ= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= -github.com/grafana/sqlds/v4 v4.2.7 h1:sFQhsS7DBakNMdxa++yOfJ9BVvkZwFJ0B95o57K0/XA= -github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= +github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU= +github.com/grafana/sqlds/v5 v5.0.3/go.mod h1:GKeTTiC+GeR1X0z3f0Iee+hZnNgN62uQpj5XVMx5Uew= github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec h1:wnzJov9RhSHGaTYGzTygL4qq986fLen8xSqnQgaMd28= github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec/go.mod h1:j1IY7J2rUz7TcTjFVVx6HCpyTlYOJPtXuGRZ7sI+vSo= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index d2e00f4e823..69006b41792 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -31,12 +31,12 @@ require ( github.com/apache/arrow-go/v18 v18.4.1 // indirect github.com/armon/go-metrics v0.4.1 // indirect github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect github.com/aws/smithy-go v1.23.2 // indirect github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -97,14 +97,14 @@ require ( github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect - github.com/grafana/grafana-aws-sdk v1.3.0 // indirect + github.com/grafana/grafana-aws-sdk v1.4.2 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana-plugin-sdk-go v0.284.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect - github.com/grafana/sqlds/v4 v4.2.7 // indirect + github.com/grafana/sqlds/v5 v5.0.3 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index 34b430536ea..d1429e1a498 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -30,18 +30,18 @@ github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= @@ -229,8 +229,8 @@ github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfU github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k= github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw= github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU= -github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= -github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= +github.com/grafana/grafana-aws-sdk v1.4.2 h1:GrUEoLbs46r8rG/GZL4L2b63Bo+rkIYKdtCT7kT5KkM= +github.com/grafana/grafana-aws-sdk v1.4.2/go.mod h1:1qnZdYs6gQzxxF0dDodaE7Rn9fiMzuhwvtaAZ7ySnhY= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= @@ -243,8 +243,8 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= -github.com/grafana/sqlds/v4 v4.2.7 h1:sFQhsS7DBakNMdxa++yOfJ9BVvkZwFJ0B95o57K0/XA= -github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= +github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU= +github.com/grafana/sqlds/v5 v5.0.3/go.mod h1:GKeTTiC+GeR1X0z3f0Iee+hZnNgN62uQpj5XVMx5Uew= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= diff --git a/go.mod b/go.mod index 6df4d73089e..06659637f20 100644 --- a/go.mod +++ b/go.mod @@ -100,7 +100,7 @@ require ( github.com/grafana/grafana-api-golang-client v0.27.0 // @grafana/alerting-backend github.com/grafana/grafana-app-sdk v0.48.7 // @grafana/grafana-app-platform-squad github.com/grafana/grafana-app-sdk/logging v0.48.7 // @grafana/grafana-app-platform-squad - github.com/grafana/grafana-aws-sdk v1.3.0 // @grafana/aws-datasources + github.com/grafana/grafana-aws-sdk v1.4.2 // @grafana/aws-datasources github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // @grafana/partner-datasources github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 // @grafana/grafana-operator-experience-squad github.com/grafana/grafana-google-sdk-go v0.4.2 // @grafana/partner-datasources @@ -342,23 +342,23 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/at-wat/mqtt-go v0.19.6 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect - github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect @@ -456,7 +456,6 @@ require ( github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/grafana/jsonparser v0.0.0-20240425183733-ea80629e1a32 // indirect github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect - github.com/grafana/sqlds/v4 v4.2.7 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/hashicorp/consul/api v1.31.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -683,6 +682,7 @@ require ( github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/gophercloud/gophercloud/v2 v2.9.0 // indirect + github.com/grafana/sqlds/v5 v5.0.3 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/moby/go-archive v0.1.0 // indirect diff --git a/go.sum b/go.sum index 32ef27a6559..9fe2c39eb9c 100644 --- a/go.sum +++ b/go.sum @@ -854,20 +854,20 @@ github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrK github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY= -github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= -github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= +github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y= +github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 h1:Nn3qce+OHZuMj/edx4its32uxedAmquCDxtZkrdeiD4= @@ -876,12 +876,12 @@ github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 h1:e5cbPZYTIY2nUEFie github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0/go.mod h1:UseIHRfrm7PqeZo6fcTb6FUCXzCnh1KJbQbmOfxArGM= github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 h1:IfMb3Ar8xEaWjgH/zeVHYD8izwJdQgRP5mKCTDt4GNk= github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2/go.mod h1:35jGWx7ECvCwTsApqicFYzZ7JFEnBc6oHUuOQ3xIS54= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U= github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 h1:zJeUxFP7+XP52u23vrp4zMcVhShTWbNO8dHV6xCSvFo= @@ -894,12 +894,12 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZ github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 h1:w6a0H79HrHf3lr+zrw+pSzR5B+caiQFAKiNHlrUcnoc= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1/go.mod h1:c6Vg0BRiU7v0MVhHupw90RyL120QBwAMLbDCzptGeMk= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/axiomhq/hyperloglog v0.0.0-20191112132149-a4c4c47bc57f/go.mod h1:2stgcRjl6QmW+gU2h5E7BQXg4HU0gzxKWDuT5HviN9s= @@ -1651,8 +1651,8 @@ github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfU github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k= github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw= github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU= -github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= -github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= +github.com/grafana/grafana-aws-sdk v1.4.2 h1:GrUEoLbs46r8rG/GZL4L2b63Bo+rkIYKdtCT7kT5KkM= +github.com/grafana/grafana-aws-sdk v1.4.2/go.mod h1:1qnZdYs6gQzxxF0dDodaE7Rn9fiMzuhwvtaAZ7ySnhY= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 h1:JOzchPgptwJdruYoed7x28lFDwhzs7kssResYsnC0iI= @@ -1691,8 +1691,8 @@ github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrR github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56 h1:SDGrP81Vcd102L3UJEryRd1eestRw73wt+b8vnVEFe0= github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56/go.mod h1:S4+611dxnKt8z/ulbvaJzcgSHsuhjVc1QHNTcr1R7Fw= -github.com/grafana/sqlds/v4 v4.2.7 h1:sFQhsS7DBakNMdxa++yOfJ9BVvkZwFJ0B95o57K0/XA= -github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= +github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU= +github.com/grafana/sqlds/v5 v5.0.3/go.mod h1:GKeTTiC+GeR1X0z3f0Iee+hZnNgN62uQpj5XVMx5Uew= github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec h1:wnzJov9RhSHGaTYGzTygL4qq986fLen8xSqnQgaMd28= github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec/go.mod h1:j1IY7J2rUz7TcTjFVVx6HCpyTlYOJPtXuGRZ7sI+vSo= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= diff --git a/go.work.sum b/go.work.sum index f7c59731300..5030d8738a1 100644 --- a/go.work.sum +++ b/go.work.sum @@ -424,23 +424,30 @@ github.com/aws/aws-msk-iam-sasl-signer-go v1.0.1/go.mod h1:MVYeeOhILFFemC/XlYTCl github.com/aws/aws-sdk-go-v2 v1.36.5/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0= github.com/aws/aws-sdk-go-v2 v1.38.1/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/config v1.29.17/go.mod h1:9P4wwACpbeXs9Pm9w1QTh6BwWwJjwYvJ1iCt5QbCXh8= github.com/aws/aws-sdk-go-v2/config v1.31.2/go.mod h1:17ft42Yb2lF6OigqSYiDAiUcX4RIkEMY6XxEMJsrAes= +github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= github.com/aws/aws-sdk-go-v2/credentials v1.17.70/go.mod h1:M+lWhhmomVGgtuPOhO85u4pEa3SmssPTdcYpP/5J/xc= github.com/aws/aws-sdk-go-v2/credentials v1.18.6/go.mod h1:/jdQkh1iVPa01xndfECInp1v1Wnp70v3K4MvtlLGVEc= +github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.5 h1:oUEqVqonG3xuarrsze1KVJ30KagNYDemikTbdu8KlN8= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.5/go.mod h1:VNM08cHlOsIbSHRqb6D/M2L4kKXfJv3A2/f0GNbOQSc= github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.7.87 h1:oDPArGgCrG/4aTi86ij3S2PB59XXkTSKYVNQlmqRHXQ= github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.7.87/go.mod h1:ZeQC4gVarhdcWeM1c90DyBLaBCNhEeAbKUXwVI/byvw= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32/go.mod h1:h4Sg6FQdexC1yYG9RDnOvLbW1a/P986++/Y/a+GyEM8= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4/go.mod h1:9xzb8/SV62W6gHQGC/8rrvgNXU6ZoYM3sAIJCIrXJxY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69/go.mod h1:GJj8mmO6YT6EqgduWocwhMoxTLFitkhIrK+owzrYL2I= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36/go.mod h1:Q1lnJArKRXkenyog6+Y+zr7WDpk4e6XlR6gs20bbeNo= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4/go.mod h1:l4bdfCD7XyyZA9BolKBo1eLqgaJxl0/x91PL4Yqe0ao= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36/go.mod h1:UdyGa7Q91id/sdyHPwth+043HhmP6yP9MBHgbZM0xo8= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4/go.mod h1:yDmJgqOiH4EA8Hndnv4KwAo8jCGTSnM5ASG1nBI+toA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.0 h1:A99gjqZDbdhjtjJVZrmVzVKO2+p3MSg35bDWtbMQVxw= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.0/go.mod h1:mWB0GE1bqcVSvpW7OtFA0sKuHk52+IqtnsYU2jUfYAs= @@ -448,11 +455,13 @@ github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.0 h1:0wOCTKrmwkyC8Bk7 github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.0/go.mod h1:He/RikglWUczbkV+fkdpcV/3GdL/rTRNVy7VaUiezMo= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17 h1:x187MqiHwBGjMGAed8Y8K1VGuCtFvQvXb24r+bwmSdo= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17/go.mod h1:mC9qMbA6e1pwEq6X3zDGtZRXMG2YaElJkbJlMVHLs5I= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17/go.mod h1:ygpklyoaypuyDvOM5ujWGrYWpAK3h7ugnmKCU/76Ys4= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4/go.mod h1:nLEfLnVMmLvyIG58/6gsSA03F1voKGaCfHV7+lR8S7s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA= github.com/aws/aws-sdk-go-v2/service/kinesis v1.33.0 h1:JPXkrQk5OS/+Q81fKH97Ll/Vmmy0p9vwHhxw+V+tVjg= github.com/aws/aws-sdk-go-v2/service/kinesis v1.33.0/go.mod h1:dJngkoVMrq0K7QvRkdRZYM4NUp6cdWa2GBdpm8zoY8U= @@ -486,10 +495,13 @@ github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1 h1:OwMzNDe5VVTXD4kGmeK/FtqAITiV github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1/go.mod h1:IyVabkWrs8SNdOEZLyFFcW9bUltV4G6OQS0s6H20PHg= github.com/aws/aws-sdk-go-v2/service/sso v1.25.5/go.mod h1:b7SiVprpU+iGazDUqvRSLf5XmCdn+JtT1on7uNL6Ipc= github.com/aws/aws-sdk-go-v2/service/sso v1.28.2/go.mod h1:n9bTZFZcBa9hGGqVz3i/a6+NG0zmZgtkB9qVVFDqPA8= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3/go.mod h1:vq/GQR1gOFLquZMSrxUK/cpvKCNVYibNyJ1m7JrU88E= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2/go.mod h1:eknndR9rU8UpE/OmFpqU78V1EcXPKFTTm5l/buZYgvM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w= github.com/aws/aws-sdk-go-v2/service/sts v1.38.0/go.mod h1:bEPcjW7IbolPfK67G1nilqWyoxYMSPrDiIQ3RdIdKgo= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= @@ -934,6 +946,7 @@ github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9 github.com/grafana/grafana-aws-sdk v1.0.2/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE= github.com/grafana/grafana-aws-sdk v1.1.0/go.mod h1:7e+47EdHynteYWGoT5Ere9KeOXQObsk8F0vkOLQ1tz8= github.com/grafana/grafana-aws-sdk v1.2.0/go.mod h1:bBo7qOmM3f61vO+2JxTolNUph1l2TmtzmWcU9/Im+8A= +github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo= github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0/go.mod h1:H9sVh9A4yg5egMGZeh0mifxT1Q/uqwKe1LBjBJU6pN8= github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw= @@ -981,6 +994,7 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36 github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grafana/sqlds/v4 v4.2.4/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= +github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU= github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0/go.mod h1:7t5XR+2IA8P2qggOAHTj/GCZfoLBle3OvNSYh1VkRBU= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= @@ -1840,6 +1854,7 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.4 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0/go.mod h1:CosX/aS4eHnG9D7nESYpV753l4j9q5j3SL/PUYd2lR8= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.61.0/go.mod h1:HfvuU0kW9HewH14VCOLImqKvUgONodURG7Alj/IrnGI= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.62.0/go.mod h1:WfEApdZDMlLUAev/0QQpr8EJ/z0VWDKYZ5tF5RH5T1U= @@ -1946,6 +1961,7 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= @@ -1959,6 +1975,7 @@ golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5N golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= +golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e h1:qyrTQ++p1afMkO4DPEeLGq/3oTsdlvdH4vqZUBWzUKM= golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= @@ -2052,6 +2069,7 @@ golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= From 9409af6f1c4bc9c7ad3b2fb450a19cf53d828a90 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Tue, 6 Jan 2026 17:56:53 +0000 Subject: [PATCH 46/79] Plugins: Add plugin.isFullyInstalled to usePluginConfig hook (#115898) add isfullyinstalled to hook --- public/app/features/plugins/admin/hooks/usePluginConfig.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/plugins/admin/hooks/usePluginConfig.tsx b/public/app/features/plugins/admin/hooks/usePluginConfig.tsx index 62cb89aaf33..f80af8bd8a6 100644 --- a/public/app/features/plugins/admin/hooks/usePluginConfig.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginConfig.tsx @@ -17,5 +17,5 @@ export const usePluginConfig = (plugin?: CatalogPlugin) => { return loadPlugin(plugin.id); } return null; - }, [plugin?.id, plugin?.isInstalled, plugin?.isDisabled]); + }, [plugin?.id, plugin?.isInstalled, plugin?.isDisabled, plugin?.isFullyInstalled]); }; From c0fe27406b5e1f01caa0c48e597f767b2b5c08b9 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Tue, 6 Jan 2026 13:30:03 -0500 Subject: [PATCH 47/79] Table: Clamp Safari exclusions to 26.0 and 26.1 (#114454) --- .../grafana-ui/src/components/Table/TableNG/utils.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index b960d8c08c5..083e5c2a8d2 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -1108,12 +1108,18 @@ export function parseStyleJson(rawValue: unknown): CSSProperties | void { } } -// Safari 26 introduced rendering bugs which require us to disable several features of the table. +// Safari 26.0 introduced rendering bugs which require us to disable several features of the table. +// The bugs were later fixed in Safari 26.2. export const IS_SAFARI_26 = (() => { if (navigator == null) { return false; } const userAgent = navigator.userAgent; - const safariVersionMatch = userAgent.match(/Version\/(\d+)\./); - return safariVersionMatch && parseInt(safariVersionMatch[1], 10) === 26; + const safariVersionMatch = userAgent.match(/Version\/(\d+)\.(\d+)/); + if (!safariVersionMatch) { + return false; + } + const majorVersion = +safariVersionMatch[1]; + const minorVersion = +safariVersionMatch[2]; + return majorVersion === 26 && minorVersion <= 1; })(); From 29b04bd2ed95865265d7ca10e7770c38a9039bcc Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Tue, 6 Jan 2026 14:29:58 -0500 Subject: [PATCH 48/79] Dashboards: Use ISO 8601 format for panel time range state (#115901) date convertion --- .../dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx index 5f8e2e3350e..04f3eac85e2 100644 --- a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx +++ b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx @@ -70,8 +70,8 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase Date: Tue, 6 Jan 2026 16:19:25 -0700 Subject: [PATCH 49/79] Zanzana: Add orphan cleanup to reconciler (#115775) --- .../dualwrite/resource_reconciler.go | 123 +++++++++++++++++- .../resource_reconciler_orphan_test.go | 110 ++++++++++++++++ 2 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 pkg/services/accesscontrol/dualwrite/resource_reconciler_orphan_test.go diff --git a/pkg/services/accesscontrol/dualwrite/resource_reconciler.go b/pkg/services/accesscontrol/dualwrite/resource_reconciler.go index 63a4f8b25eb..0adf365ebde 100644 --- a/pkg/services/accesscontrol/dualwrite/resource_reconciler.go +++ b/pkg/services/accesscontrol/dualwrite/resource_reconciler.go @@ -3,11 +3,13 @@ package dualwrite import ( "context" "fmt" + "strings" openfgav1 "github.com/openfga/api/proto/openfga/v1" claims "github.com/grafana/authlib/types" + dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" "github.com/grafana/grafana/pkg/services/authz/zanzana" ) @@ -19,14 +21,30 @@ type legacyTupleCollector func(ctx context.Context, orgID int64) (map[string]map type zanzanaTupleCollector func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error) type resourceReconciler struct { - name string - legacy legacyTupleCollector - zanzana zanzanaTupleCollector - client zanzana.Client + name string + legacy legacyTupleCollector + zanzana zanzanaTupleCollector + client zanzana.Client + orphanObjectPrefix string + orphanRelations []string } -func newResourceReconciler(name string, legacy legacyTupleCollector, zanzana zanzanaTupleCollector, client zanzana.Client) resourceReconciler { - return resourceReconciler{name, legacy, zanzana, client} +func newResourceReconciler(name string, legacy legacyTupleCollector, zanzanaCollector zanzanaTupleCollector, client zanzana.Client) resourceReconciler { + r := resourceReconciler{name: name, legacy: legacy, zanzana: zanzanaCollector, client: client} + + // we only need to worry about orphaned tuples for reconcilers that use the managed permissions collector (i.e. dashboards & folders) + switch name { + case "managed folder permissions": + // prefix for folders is `folder:` + r.orphanObjectPrefix = zanzana.NewObjectEntry(zanzana.TypeFolder, "", "", "", "") + r.orphanRelations = append([]string{}, zanzana.RelationsFolder...) + case "managed dashboard permissions": + // prefix for dashboards will be `resource:dashboard.grafana.app/dashboards/` + r.orphanObjectPrefix = fmt.Sprintf("%s/", zanzana.NewObjectEntry(zanzana.TypeResource, dashboardV1.APIGroup, dashboardV1.DASHBOARD_RESOURCE, "", "")) + r.orphanRelations = append([]string{}, zanzana.RelationsResouce...) + } + + return r } func (r resourceReconciler) reconcile(ctx context.Context, namespace string) error { @@ -35,6 +53,15 @@ func (r resourceReconciler) reconcile(ctx context.Context, namespace string) err return err } + // 0. Fetch all tuples currently stored in Zanzana. This will be used later on + // to cleanup orphaned tuples. + // This order needs to be kept (fetching from Zanzana first) to avoid accidentally + // cleaning up new tuples that were added after the legacy tuples were fetched. + allTuplesInZanzana, err := r.readAllTuples(ctx, namespace) + if err != nil { + return fmt.Errorf("failed to read all tuples from zanzana for %s: %w", r.name, err) + } + // 1. Fetch grafana resources stored in grafana db. res, err := r.legacy(ctx, info.OrgID) if err != nil { @@ -87,6 +114,14 @@ func (r resourceReconciler) reconcile(ctx context.Context, namespace string) err } } + // when the last managed permission for a resource is removed, the legacy results will no + // longer contain any tuples for that resource. this process cleans it up when applicable. + orphans, err := r.collectOrphanDeletes(ctx, namespace, allTuplesInZanzana, res) + if err != nil { + return fmt.Errorf("failed to collect orphan deletes (%s): %w", r.name, err) + } + deletes = append(deletes, orphans...) + if len(writes) == 0 && len(deletes) == 0 { return nil } @@ -119,3 +154,79 @@ func (r resourceReconciler) reconcile(ctx context.Context, namespace string) err return nil } + +// collectOrphanDeletes collects tuples that are no longer present in the legacy results +// but still are present in zanzana. when that is the case, we need to delete the tuple from +// zanzana. this will happen when the last managed permission for a resource is removed. +// this is only used for dashboards and folders, as those are the only resources that use the managed permissions collector. +func (r resourceReconciler) collectOrphanDeletes( + ctx context.Context, + namespace string, + allTuplesInZanzana []*authzextv1.Tuple, + legacyReturnedTuples map[string]map[string]*openfgav1.TupleKey, +) ([]*openfgav1.TupleKeyWithoutCondition, error) { + if r.orphanObjectPrefix == "" || len(r.orphanRelations) == 0 { + return []*openfgav1.TupleKeyWithoutCondition{}, nil + } + + seen := map[string]struct{}{} + out := []*openfgav1.TupleKeyWithoutCondition{} + + // what relation types we are interested in cleaning up + relationsToCleanup := map[string]struct{}{} + for _, rel := range r.orphanRelations { + relationsToCleanup[rel] = struct{}{} + } + + for _, tuple := range allTuplesInZanzana { + if tuple == nil || tuple.Key == nil { + continue + } + // only cleanup the particular relation types we are interested in + if _, ok := relationsToCleanup[tuple.Key.Relation]; !ok { + continue + } + // only cleanup the particular object types we are interested in (either dashboards or folders) + if !strings.HasPrefix(tuple.Key.Object, r.orphanObjectPrefix) { + continue + } + // if legacy returned this object, it's not orphaned + if _, ok := legacyReturnedTuples[tuple.Key.Object]; ok { + continue + } + // keep track of the tuples we have already seen and marked for deletion + key := fmt.Sprintf("%s|%s|%s", tuple.Key.User, tuple.Key.Relation, tuple.Key.Object) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, &openfgav1.TupleKeyWithoutCondition{ + User: tuple.Key.User, + Relation: tuple.Key.Relation, + Object: tuple.Key.Object, + }) + } + + return out, nil +} + +func (r resourceReconciler) readAllTuples(ctx context.Context, namespace string) ([]*authzextv1.Tuple, error) { + var ( + out []*authzextv1.Tuple + continueToken string + ) + for { + res, err := r.client.Read(ctx, &authzextv1.ReadRequest{ + Namespace: namespace, + ContinuationToken: continueToken, + }) + if err != nil { + return nil, err + } + out = append(out, res.Tuples...) + continueToken = res.ContinuationToken + if continueToken == "" { + return out, nil + } + } +} diff --git a/pkg/services/accesscontrol/dualwrite/resource_reconciler_orphan_test.go b/pkg/services/accesscontrol/dualwrite/resource_reconciler_orphan_test.go new file mode 100644 index 00000000000..8daf06a8a77 --- /dev/null +++ b/pkg/services/accesscontrol/dualwrite/resource_reconciler_orphan_test.go @@ -0,0 +1,110 @@ +package dualwrite + +import ( + "context" + "testing" + + authlib "github.com/grafana/authlib/types" + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/require" + + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana" +) + +type fakeZanzanaClient struct { + readTuples []*authzextv1.Tuple + writeReqs []*authzextv1.WriteRequest +} + +func (f *fakeZanzanaClient) Read(ctx context.Context, req *authzextv1.ReadRequest) (*authzextv1.ReadResponse, error) { + return &authzextv1.ReadResponse{ + Tuples: f.readTuples, + ContinuationToken: "", + }, nil +} + +func (f *fakeZanzanaClient) Write(ctx context.Context, req *authzextv1.WriteRequest) error { + f.writeReqs = append(f.writeReqs, req) + return nil +} + +func (f *fakeZanzanaClient) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) { + return &authzextv1.BatchCheckResponse{}, nil +} + +func (f *fakeZanzanaClient) Mutate(ctx context.Context, req *authzextv1.MutateRequest) error { + return nil +} + +func (f *fakeZanzanaClient) Query(ctx context.Context, req *authzextv1.QueryRequest) (*authzextv1.QueryResponse, error) { + return &authzextv1.QueryResponse{}, nil +} + +func (f *fakeZanzanaClient) Check(ctx context.Context, info authlib.AuthInfo, req authlib.CheckRequest, folder string) (authlib.CheckResponse, error) { + return authlib.CheckResponse{Allowed: true}, nil +} + +func (f *fakeZanzanaClient) Compile(ctx context.Context, info authlib.AuthInfo, req authlib.ListRequest) (authlib.ItemChecker, authlib.Zookie, error) { + return func(name, folder string) bool { return true }, authlib.NoopZookie{}, nil +} + +func TestResourceReconciler_OrphanedManagedDashboardTuplesAreDeleted(t *testing.T) { + legacy := func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) { + return map[string]map[string]*openfgav1.TupleKey{}, nil + } + zCollector := func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error) { + return map[string]*openfgav1.TupleKey{}, nil + } + + fake := &fakeZanzanaClient{} + r := newResourceReconciler("managed dashboard permissions", legacy, zCollector, fake) + + require.NotEmpty(t, r.orphanObjectPrefix) + require.NotEmpty(t, r.orphanRelations) + + relAllowed := r.orphanRelations[0] + objAllowed := r.orphanObjectPrefix + "dash-uid-1" + + fake.readTuples = []*authzextv1.Tuple{ + // should be removed + { + Key: &authzextv1.TupleKey{ + User: "user:1", + Relation: relAllowed, + Object: objAllowed, + }, + }, + + // same relation but different object type/prefix - should stay + { + Key: &authzextv1.TupleKey{ + User: "user:1", + Relation: relAllowed, + Object: "folder:some-folder", + }, + }, + // same prefix but different relation - should stay + { + Key: &authzextv1.TupleKey{ + User: "user:1", + Relation: zanzana.RelationParent, + Object: objAllowed, + }, + }, + } + + err := r.reconcile(context.Background(), authlib.OrgNamespaceFormatter(1)) + require.NoError(t, err) + + require.Len(t, fake.writeReqs, 1) + wr := fake.writeReqs[0] + require.NotNil(t, wr.Deletes) + require.Nil(t, wr.Writes) + + require.Len(t, wr.Deletes.TupleKeys, 1) + del := wr.Deletes.TupleKeys[0] + require.Equal(t, "user:1", del.User) + require.Equal(t, relAllowed, del.Relation) + require.Equal(t, objAllowed, del.Object) +} From 05681efee3d5ea64de227d77186c02cfaaa113a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ida=20=C5=A0tambuk?= Date: Wed, 7 Jan 2026 09:48:50 +0100 Subject: [PATCH 50/79] Dynamic Dashboards: Show hidden variables greyed out (#115723) --- .../dashboards-edit-variables.spec.ts | 106 +++++++++++++++--- e2e-playwright/dashboard-new-layouts/utils.ts | 24 +++- .../src/selectors/pages.ts | 3 + .../scene/VariableControls.tsx | 34 +++++- .../DashboardControlsMenuButton.tsx | 2 + .../components/VariableDisplaySelect.tsx | 4 + public/locales/en-US/grafana.json | 1 + 7 files changed, 154 insertions(+), 20 deletions(-) diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-edit-variables.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-edit-variables.spec.ts index 4b5ea9574f8..15a41047fe6 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-edit-variables.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-edit-variables.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@grafana/plugin-e2e'; -import { flows, type Variable } from './utils'; +import { flows, saveDashboard, type Variable } from './utils'; test.use({ featureToggles: { @@ -64,20 +64,7 @@ test.describe( label: 'VariableUnderTest', }; - // common steps to add a new variable - await flows.newEditPaneVariableClick(dashboardPage, selectors); - await flows.newEditPanelCommonVariableInputs(dashboardPage, selectors, variable); - - // set the textbox variable value - const type = 'variable-type Value'; - const fieldLabel = dashboardPage.getByGrafanaSelector( - selectors.components.PanelEditor.OptionsPane.fieldLabel(type) - ); - await expect(fieldLabel).toBeVisible(); - const inputField = fieldLabel.locator('input'); - await expect(inputField).toBeVisible(); - await inputField.fill(variable.value); - await inputField.blur(); + await flows.addNewTextBoxVariable(dashboardPage, variable); // select the variable in the dashboard and confirm the variable value is set await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItem).click(); @@ -140,5 +127,94 @@ test.describe( await expect(panelContent).toBeVisible(); await expect(markdownContent).toContainText('VariableUnderTest: 10m'); }); + test('can hide a variable', async ({ dashboardPage, selectors, page }) => { + const variable: Variable = { + type: 'textbox', + name: 'VariableUnderTest', + value: 'foo', + label: 'VariableUnderTest', + }; + + await saveDashboard(dashboardPage, page, selectors, 'can hide a variable'); + await flows.addNewTextBoxVariable(dashboardPage, variable); + + // check the variable is visible in the dashboard + const variableLabel = dashboardPage.getByGrafanaSelector( + selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label) + ); + await expect(variableLabel).toBeVisible(); + // hide the variable + await dashboardPage + .getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.General.generalDisplaySelect) + .click(); + await page.getByText('Hidden', { exact: true }).click(); + + // check that the variable is still visible + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeVisible(); + + // save dashboard and exit edit mode and check variable is not visible + await saveDashboard(dashboardPage, page, selectors); + await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeHidden(); + // refresh and check that variable isn't visible + await page.reload(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeHidden(); + // check that the variable is visible in edit mode + await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeVisible(); + }); + + test('can hide variable under the controls menu', async ({ dashboardPage, selectors, page }) => { + const variable: Variable = { + type: 'textbox', + name: 'VariableUnderTest', + value: 'foo', + label: 'VariableUnderTest', + }; + await saveDashboard(dashboardPage, page, selectors, 'can hide a variable in controls menu'); + + await flows.addNewTextBoxVariable(dashboardPage, variable); + + // check the variable is visible in the dashboard + const variableLabel = dashboardPage.getByGrafanaSelector( + selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label) + ); + await expect(variableLabel).toBeVisible(); + // hide the variable + await dashboardPage + .getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.General.generalDisplaySelect) + .click(); + await page.getByText('Controls menu', { exact: true }).click(); + + // check that the variable is hidden under the controls menu + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeHidden(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.ControlsButton).click(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeVisible(); + + // save dashboard and refresh + await saveDashboard(dashboardPage, page, selectors); + await page.reload(); + + //check that the variable is hidden under the controls menu + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeHidden(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.ControlsButton).click(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeVisible(); + }); } ); diff --git a/e2e-playwright/dashboard-new-layouts/utils.ts b/e2e-playwright/dashboard-new-layouts/utils.ts index 69851994812..ade6825b7c1 100644 --- a/e2e-playwright/dashboard-new-layouts/utils.ts +++ b/e2e-playwright/dashboard-new-layouts/utils.ts @@ -79,6 +79,20 @@ export const flows = { await variableLabelInput.blur(); } }, + async addNewTextBoxVariable(dashboardPage: DashboardPage, variable: Variable) { + await flows.newEditPaneVariableClick(dashboardPage, selectors); + await flows.newEditPanelCommonVariableInputs(dashboardPage, selectors, variable); + // set the textbox variable value + const type = 'variable-type Value'; + const fieldLabel = dashboardPage.getByGrafanaSelector( + selectors.components.PanelEditor.OptionsPane.fieldLabel(type) + ); + await expect(fieldLabel).toBeVisible(); + const inputField = fieldLabel.locator('input'); + await expect(inputField).toBeVisible(); + await inputField.fill(variable.value); + await inputField.blur(); + }, }; export type Variable = { @@ -89,8 +103,16 @@ export type Variable = { value: string; }; -export async function saveDashboard(dashboardPage: DashboardPage, page: Page, selectors: E2ESelectorGroups) { +export async function saveDashboard( + dashboardPage: DashboardPage, + page: Page, + selectors: E2ESelectorGroups, + title?: string +) { await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.saveButton).click(); + if (title) { + await page.getByTestId(selectors.components.Drawer.DashboardSaveDrawer.saveAsTitleInput).fill(title); + } await dashboardPage.getByGrafanaSelector(selectors.components.Drawer.DashboardSaveDrawer.saveButton).click(); await expect(page.getByText('Dashboard saved')).toBeVisible(); } diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index b88dac9099c..35bc4f7c975 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -266,6 +266,9 @@ export const versionedPages = { Controls: { '11.1.0': 'data-testid dashboard controls', }, + ControlsButton: { + '12.3.0': 'data-testid dashboard controls button', + }, SubMenu: { submenu: { [MIN_GRAFANA_VERSION]: 'Dashboard submenu', diff --git a/public/app/features/dashboard-scene/scene/VariableControls.tsx b/public/app/features/dashboard-scene/scene/VariableControls.tsx index 73dd2614472..a2e6f3daf88 100644 --- a/public/app/features/dashboard-scene/scene/VariableControls.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControls.tsx @@ -19,6 +19,8 @@ import { AddVariableButton } from './VariableControlsAddButton'; export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { const { variables } = sceneGraph.getVariables(dashboard)!.useState(); + const { isEditing } = dashboard.useState(); + const isEditingNewLayouts = isEditing && config.featureToggles.dashboardNewLayouts; // Get visible variables for drilldown layout const visibleVariables = variables.filter((v) => v.state.hide !== VariableHide.inControlsMenu); @@ -35,13 +37,22 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { // Variables to render (exclude adhoc/groupby when drilldown controls are shown in top row) const variablesToRender = hasDrilldownControls ? restVariables.filter((v) => v.state.hide !== VariableHide.inControlsMenu) - : variables.filter((v) => v.state.hide !== VariableHide.inControlsMenu); + : variables.filter( + (v) => + // if we're editing in dynamic dashboards, still shows hidden variable but greyed out + (isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) || + v.state.hide !== VariableHide.inControlsMenu + ); return ( <> {variablesToRender.length > 0 && variablesToRender.map((variable) => ( - + ))} {config.featureToggles.dashboardNewLayouts ? : null} @@ -52,14 +63,17 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { interface VariableSelectProps { variable: SceneVariable; inMenu?: boolean; + isEditingNewLayouts?: boolean; } -export function VariableValueSelectWrapper({ variable, inMenu }: VariableSelectProps) { +export function VariableValueSelectWrapper({ variable, inMenu, isEditingNewLayouts }: VariableSelectProps) { const state = useSceneObjectState(variable, { shouldActivateOrKeepAlive: true }); const { isSelected, onSelect, isSelectable } = useElementSelection(variable.state.key); + const isHidden = state.hide === VariableHide.hideVariable; + const shouldShowHiddenVariables = isEditingNewLayouts && isHidden; const styles = useStyles2(getStyles); - if (state.hide === VariableHide.hideVariable) { + if (isHidden && !isEditingNewLayouts) { if (variable.UNSAFE_renderAsHidden) { return ; } @@ -97,6 +111,7 @@ export function VariableValueSelectWrapper({ variable, inMenu }: VariableSelectP
({ display: 'flex', alignItems: 'center', }), + hidden: css({ + opacity: 0.6, + '&:hover': css({ + opacity: 1, + }), + label: css({ + textDecoration: 'line-through', + }), + }), }); diff --git a/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.tsx b/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.tsx index f3a98f2e9b0..399cd8f13f6 100644 --- a/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.tsx +++ b/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.tsx @@ -1,6 +1,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; import { Dropdown, ToolbarButton, useStyles2 } from '@grafana/ui'; @@ -33,6 +34,7 @@ export function DashboardControlsButton({ dashboard }: { dashboard: DashboardSce Date: Wed, 7 Jan 2026 11:01:03 +0100 Subject: [PATCH 51/79] CustomVariable: support values with multiple properties (json values format) (#113844) * update Scenes libraries --------- Co-authored-by: idastambuk --- .../kinds/v2alpha1/dashboard_spec.cue | 3 +- .../kinds/v2beta1/dashboard_spec.cue | 1 + .../dashboard/v0alpha1/dashboard_kind.cue | 6 +- .../apis/dashboard/v1beta1/dashboard_kind.cue | 6 +- .../dashboard/v2alpha1/dashboard_spec.cue | 3 +- .../dashboard/v2alpha1/dashboard_spec_gen.go | 33 +- .../v2alpha1/zz_generated.openapi.go | 6 + .../apis/dashboard/v2beta1/dashboard_spec.cue | 1 + .../dashboard/v2beta1/dashboard_spec_gen.go | 33 +- .../dashboard/v2beta1/zz_generated.openapi.go | 6 + apps/dashboard/pkg/apis/dashboard_manifest.go | 4 +- .../conversion/v1beta1_to_v2alpha1.go | 11 + .../conversion/v2alpha1_to_v2beta1.go | 18 + kinds/dashboard/dashboard_kind.cue | 6 +- package.json | 4 +- .../grafana-data/src/types/templateVars.ts | 1 + .../raw/dashboard/x/dashboard_types.gen.ts | 5 + .../src/schema/dashboard/v2_examples.ts | 1 + .../dashboard/v2alpha1/types.spec.gen.ts | 5 +- .../dashboard/v2beta1/types.spec.gen.ts | 1 + pkg/kinds/dashboard/dashboard_spec_gen.go | 10 + .../dashboard.grafana.app-v2alpha1.json | 7 + .../dashboard.grafana.app-v2beta1.json | 7 + .../DashboardSceneSerializer.test.ts | 20 +- .../transformSceneToSaveModel.test.ts.snap | 5 + ...sformSceneToSaveModelSchemaV2.test.ts.snap | 1 + .../sceneVariablesSetToVariables.test.ts | 2 + .../sceneVariablesSetToVariables.ts | 4 + .../testfiles/nested_dashboard.json | 51 ++ .../transformSaveModelSchemaV2ToScene.ts | 3 +- .../settings/variables/VariableEditorForm.tsx | 6 +- .../components/CustomVariableForm.test.tsx | 76 ++- .../components/CustomVariableForm.tsx | 145 +++++- .../components/SelectionOptionsForm.tsx | 31 +- .../components/VariableValuesPreview.tsx | 111 +++- .../CustomVariableEditor.test.tsx | 300 +++++++---- .../CustomVariableEditor.tsx | 110 +++- .../CustomVariableEditor/ModalEditor.tsx | 159 ++++-- .../ModalEditorNonMultiProps.tsx | 119 +++++ .../useVariableSelectionOptionsCategory.tsx | 16 +- .../features/dashboard-scene/utils/clone.ts | 1 + .../dashboard-scene/utils/tracking.test.ts | 6 +- .../dashboard-scene/utils/variables.ts | 1 + .../app/features/dashboard/utils/tracking.ts | 15 + .../app/features/panel/panellinks/link_srv.ts | 45 +- .../panel/panellinks/specs/link_srv.test.ts | 489 ++++++++++-------- public/locales/en-US/grafana.json | 14 +- yarn.lock | 22 +- 48 files changed, 1480 insertions(+), 450 deletions(-) create mode 100644 public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditorNonMultiProps.tsx diff --git a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue index c13eb866c80..6488de41c96 100644 --- a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue @@ -129,7 +129,7 @@ DashboardLink: { placement?: DashboardLinkPlacement } -// Dashboard Link placement. Defines where the link should be displayed. +// Dashboard Link placement. Defines where the link should be displayed. // - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu DashboardLinkPlacement: "inControlsMenu" @@ -932,6 +932,7 @@ CustomVariableSpec: { skipUrlSync: bool | *false description?: string allowCustomValue: bool | *true + valuesFormat?: "csv" | "json" } // Custom variable kind diff --git a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue index bb833795354..a8e1f121213 100644 --- a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue @@ -935,6 +935,7 @@ CustomVariableSpec: { skipUrlSync: bool | *false description?: string allowCustomValue: bool | *true + valuesFormat?: "csv" | "json" } // Custom variable kind diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue index 8338c9e13c5..3c6c0e92355 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue @@ -222,8 +222,10 @@ lineage: schemas: [{ // Optional field, if you want to extract part of a series name or metric node segment. // Named capture groups can be used to separate the display text and value. regex?: string - // Determine whether regex applies to variable value or display text - regexApplyTo?: #VariableRegexApplyTo + // Optional, indicates whether a custom type variable uses CSV or JSON to define its values + valuesFormat?: "csv" | "json" | *"csv" + // Determine whether regex applies to variable value or display text + regexApplyTo?: #VariableRegexApplyTo // Additional static options for query variable staticOptions?: [...#VariableOption] // Ordering of static options in relation to options returned from data source for query variable diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue index 8338c9e13c5..3c6c0e92355 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue @@ -222,8 +222,10 @@ lineage: schemas: [{ // Optional field, if you want to extract part of a series name or metric node segment. // Named capture groups can be used to separate the display text and value. regex?: string - // Determine whether regex applies to variable value or display text - regexApplyTo?: #VariableRegexApplyTo + // Optional, indicates whether a custom type variable uses CSV or JSON to define its values + valuesFormat?: "csv" | "json" | *"csv" + // Determine whether regex applies to variable value or display text + regexApplyTo?: #VariableRegexApplyTo // Additional static options for query variable staticOptions?: [...#VariableOption] // Ordering of static options in relation to options returned from data source for query variable diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue index ec8d7eead87..2b027ff98e1 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue @@ -133,7 +133,7 @@ DashboardLink: { placement?: DashboardLinkPlacement } -// Dashboard Link placement. Defines where the link should be displayed. +// Dashboard Link placement. Defines where the link should be displayed. // - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu DashboardLinkPlacement: "inControlsMenu" @@ -936,6 +936,7 @@ CustomVariableSpec: { skipUrlSync: bool | *false description?: string allowCustomValue: bool | *true + valuesFormat?: "csv" | "json" } // Custom variable kind diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go index 883c5399663..3f594306ef5 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go @@ -1703,18 +1703,19 @@ func NewDashboardCustomVariableKind() *DashboardCustomVariableKind { // Custom variable specification // +k8s:openapi-gen=true type DashboardCustomVariableSpec struct { - Name string `json:"name"` - Query string `json:"query"` - Current DashboardVariableOption `json:"current"` - Options []DashboardVariableOption `json:"options"` - Multi bool `json:"multi"` - IncludeAll bool `json:"includeAll"` - AllValue *string `json:"allValue,omitempty"` - Label *string `json:"label,omitempty"` - Hide DashboardVariableHide `json:"hide"` - SkipUrlSync bool `json:"skipUrlSync"` - Description *string `json:"description,omitempty"` - AllowCustomValue bool `json:"allowCustomValue"` + Name string `json:"name"` + Query string `json:"query"` + Current DashboardVariableOption `json:"current"` + Options []DashboardVariableOption `json:"options"` + Multi bool `json:"multi"` + IncludeAll bool `json:"includeAll"` + AllValue *string `json:"allValue,omitempty"` + Label *string `json:"label,omitempty"` + Hide DashboardVariableHide `json:"hide"` + SkipUrlSync bool `json:"skipUrlSync"` + Description *string `json:"description,omitempty"` + AllowCustomValue bool `json:"allowCustomValue"` + ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"` } // NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object. @@ -2098,6 +2099,14 @@ const ( DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted" ) +// +k8s:openapi-gen=true +type DashboardCustomVariableSpecValuesFormat string + +const ( + DashboardCustomVariableSpecValuesFormatCsv DashboardCustomVariableSpecValuesFormat = "csv" + DashboardCustomVariableSpecValuesFormatJson DashboardCustomVariableSpecValuesFormat = "json" +) + // +k8s:openapi-gen=true type DashboardPanelKindOrLibraryPanelKind struct { PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"` diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go index d697aa4f8b9..4c6f3f5ed20 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go @@ -1548,6 +1548,12 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardCustomVariableSpec(ref common.R Format: "", }, }, + "valuesFormat": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, }, Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"}, }, diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue index 12d7bec351b..375ba67f003 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue @@ -939,6 +939,7 @@ CustomVariableSpec: { skipUrlSync: bool | *false description?: string allowCustomValue: bool | *true + valuesFormat?: "csv" | "json" } // Custom variable kind diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go index a8ec1537e38..96054cb2fc4 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go @@ -1707,18 +1707,19 @@ func NewDashboardCustomVariableKind() *DashboardCustomVariableKind { // Custom variable specification // +k8s:openapi-gen=true type DashboardCustomVariableSpec struct { - Name string `json:"name"` - Query string `json:"query"` - Current DashboardVariableOption `json:"current"` - Options []DashboardVariableOption `json:"options"` - Multi bool `json:"multi"` - IncludeAll bool `json:"includeAll"` - AllValue *string `json:"allValue,omitempty"` - Label *string `json:"label,omitempty"` - Hide DashboardVariableHide `json:"hide"` - SkipUrlSync bool `json:"skipUrlSync"` - Description *string `json:"description,omitempty"` - AllowCustomValue bool `json:"allowCustomValue"` + Name string `json:"name"` + Query string `json:"query"` + Current DashboardVariableOption `json:"current"` + Options []DashboardVariableOption `json:"options"` + Multi bool `json:"multi"` + IncludeAll bool `json:"includeAll"` + AllValue *string `json:"allValue,omitempty"` + Label *string `json:"label,omitempty"` + Hide DashboardVariableHide `json:"hide"` + SkipUrlSync bool `json:"skipUrlSync"` + Description *string `json:"description,omitempty"` + AllowCustomValue bool `json:"allowCustomValue"` + ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"` } // NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object. @@ -2133,6 +2134,14 @@ const ( DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted" ) +// +k8s:openapi-gen=true +type DashboardCustomVariableSpecValuesFormat string + +const ( + DashboardCustomVariableSpecValuesFormatCsv DashboardCustomVariableSpecValuesFormat = "csv" + DashboardCustomVariableSpecValuesFormatJson DashboardCustomVariableSpecValuesFormat = "json" +) + // +k8s:openapi-gen=true type DashboardPanelKindOrLibraryPanelKind struct { PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"` diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go index 2b1fe573336..402810f6e53 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go @@ -1560,6 +1560,12 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardCustomVariableSpec(ref common.Re Format: "", }, }, + "valuesFormat": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, }, Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"}, }, diff --git a/apps/dashboard/pkg/apis/dashboard_manifest.go b/apps/dashboard/pkg/apis/dashboard_manifest.go index c815e815e08..e94d66fec82 100644 --- a/apps/dashboard/pkg/apis/dashboard_manifest.go +++ b/apps/dashboard/pkg/apis/dashboard_manifest.go @@ -32,10 +32,10 @@ var ( rawSchemaDashboardv1beta1 = []byte(`{"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) versionSchemaDashboardv1beta1 app.VersionSchema _ = json.Unmarshal(rawSchemaDashboardv1beta1, &versionSchemaDashboardv1beta1) - rawSchemaDashboardv2alpha1 = []byte(`{"Action":{"additionalProperties":false,"properties":{"confirmation":{"type":"string"},"fetch":{"$ref":"#/components/schemas/FetchOptions"},"infinity":{"$ref":"#/components/schemas/InfinityOptions"},"oneClick":{"type":"boolean"},"style":{"additionalProperties":false,"properties":{"backgroundColor":{"type":"string"}},"type":"object"},"title":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionType"},"variables":{"items":{"$ref":"#/components/schemas/ActionVariable"},"type":"array"}},"required":["type","title"],"type":"object"},"ActionType":{"enum":["fetch","infinity"],"type":"string"},"ActionVariable":{"additionalProperties":false,"properties":{"key":{"type":"string"},"name":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionVariableType"}},"required":["key","name","type"],"type":"object"},"ActionVariableType":{"const":"string","description":"Action variable type","type":"string"},"AdHocFilterWithLabels":{"additionalProperties":false,"description":"Define the AdHocFilterWithLabels type","properties":{"condition":{"description":"@deprecated","type":"string"},"forceEdit":{"type":"boolean"},"key":{"type":"string"},"keyLabel":{"type":"string"},"operator":{"type":"string"},"origin":{"$ref":"#/components/schemas/FilterOrigin"},"value":{"type":"string"},"valueLabels":{"items":{"type":"string"},"type":"array"},"values":{"items":{"type":"string"},"type":"array"}},"required":["key","operator","value"],"type":"object"},"AdhocVariableKind":{"additionalProperties":false,"description":"Adhoc variable kind","properties":{"kind":{"const":"AdhocVariable","type":"string"},"spec":{"$ref":"#/components/schemas/AdhocVariableSpec"}},"required":["kind","spec"],"type":"object"},"AdhocVariableSpec":{"additionalProperties":false,"description":"Adhoc variable specification","properties":{"allowCustomValue":{"default":true,"type":"boolean"},"baseFilters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"defaultKeys":{"items":{"$ref":"#/components/schemas/MetricFindValue"},"type":"array"},"description":{"type":"string"},"filters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","baseFilters","filters","defaultKeys","hide","skipUrlSync","allowCustomValue"],"type":"object"},"AnnotationEventFieldMapping":{"additionalProperties":false,"description":"Annotation event field mapping. Defines how to map a data frame field to an annotation event field.","properties":{"regex":{"description":"Regular expression to apply to the field value","type":"string"},"source":{"default":"field","description":"Source type for the field value","type":"string"},"value":{"description":"Constant value to use when source is \"text\"","type":"string"}},"type":"object"},"AnnotationPanelFilter":{"additionalProperties":false,"properties":{"exclude":{"default":false,"description":"Should the specified panels be included or excluded","type":"boolean"},"ids":{"description":"Panel IDs that should be included or excluded","items":{"type":"integer"},"type":"array"}},"required":["ids"],"type":"object"},"AnnotationQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"AnnotationQuery","type":"string"},"spec":{"$ref":"#/components/schemas/AnnotationQuerySpec"}},"required":["kind","spec"],"type":"object"},"AnnotationQuerySpec":{"additionalProperties":false,"properties":{"builtIn":{"default":false,"type":"boolean"},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"enable":{"type":"boolean"},"filter":{"$ref":"#/components/schemas/AnnotationPanelFilter"},"hide":{"type":"boolean"},"iconColor":{"type":"string"},"legacyOptions":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"Catch-all field for datasource-specific properties","type":"object"},"mappings":{"additionalProperties":{"$ref":"#/components/schemas/AnnotationEventFieldMapping"},"description":"Mappings define how to convert data frame fields to annotation event fields.","type":"object"},"name":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"}},"required":["enable","hide","iconColor","name"],"type":"object"},"AutoGridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutItemSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"element":{"$ref":"#/components/schemas/ElementReference"},"repeat":{"$ref":"#/components/schemas/AutoGridRepeatOptions"}},"required":["element"],"type":"object"},"AutoGridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutSpec":{"additionalProperties":false,"properties":{"columnWidth":{"type":"number"},"columnWidthMode":{"default":"standard","enum":["narrow","standard","wide","custom"],"type":"string"},"fillScreen":{"default":false,"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/AutoGridLayoutItemKind"},"type":"array"},"maxColumnCount":{"default":3,"type":"number"},"rowHeight":{"type":"number"},"rowHeightMode":{"default":"standard","enum":["short","standard","tall","custom"],"type":"string"}},"required":["columnWidthMode","rowHeightMode","items"],"type":"object"},"AutoGridRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"ConditionalRenderingDataKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingData","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingDataSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingDataSpec":{"additionalProperties":false,"properties":{"value":{"type":"boolean"}},"required":["value"],"type":"object"},"ConditionalRenderingGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingGroup","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingGroupSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingGroupSpec":{"additionalProperties":false,"properties":{"condition":{"enum":["and","or"],"type":"string"},"items":{"items":{"oneOf":[{"$ref":"#/components/schemas/ConditionalRenderingVariableKind"},{"$ref":"#/components/schemas/ConditionalRenderingDataKind"},{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeKind"}]},"type":"array"},"visibility":{"enum":["show","hide"],"type":"string"}},"required":["visibility","condition","items"],"type":"object"},"ConditionalRenderingTimeRangeSizeKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingTimeRangeSize","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingTimeRangeSizeSpec":{"additionalProperties":false,"properties":{"value":{"type":"string"}},"required":["value"],"type":"object"},"ConditionalRenderingVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingVariableSpec":{"additionalProperties":false,"properties":{"operator":{"enum":["equals","notEquals","matches","notMatches"],"type":"string"},"value":{"type":"string"},"variable":{"type":"string"}},"required":["variable","operator","value"],"type":"object"},"ConstantVariableKind":{"additionalProperties":false,"description":"Constant variable kind","properties":{"kind":{"const":"ConstantVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConstantVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConstantVariableSpec":{"additionalProperties":false,"description":"Constant variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","hide","skipUrlSync"],"type":"object"},"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"CustomVariableKind":{"additionalProperties":false,"description":"Custom variable kind","properties":{"kind":{"const":"CustomVariable","type":"string"},"spec":{"$ref":"#/components/schemas/CustomVariableSpec"}},"required":["kind","spec"],"type":"object"},"CustomVariableSpec":{"additionalProperties":false,"description":"Custom variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"DashboardCursorSync":{"description":"\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip.","enum":["Crosshair","Tooltip","Off"],"type":"string"},"DashboardLink":{"additionalProperties":false,"description":"Links with references to other dashboards or external resources","properties":{"asDropdown":{"default":false,"description":"If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards","type":"boolean"},"icon":{"description":"Icon name to be displayed with the link","type":"string"},"includeVars":{"default":false,"description":"If true, includes current template variables values in the link as query params","type":"boolean"},"keepTime":{"default":false,"description":"If true, includes current time range in the link as query params","type":"boolean"},"placement":{"$ref":"#/components/schemas/DashboardLinkPlacement","description":"Placement can be used to display the link somewhere else on the dashboard other than above the visualisations."},"tags":{"description":"List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards","items":{"type":"string"},"type":"array"},"targetBlank":{"default":false,"description":"If true, the link will be opened in a new tab","type":"boolean"},"title":{"description":"Title to display with the link","type":"string"},"tooltip":{"description":"Tooltip to display when the user hovers their mouse over it","type":"string"},"type":{"$ref":"#/components/schemas/DashboardLinkType","description":"Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)\nFIXME: The type is generated as ` + "`" + `type: DashboardLinkType | dashboardLinkType.Link;` + "`" + ` but it should be ` + "`" + `type: DashboardLinkType` + "`" + `"},"url":{"description":"Link URL. Only required/valid if the type is link","type":"string"}},"required":["title","type","icon","tooltip","tags","asDropdown","targetBlank","includeVars","keepTime"],"type":"object"},"DashboardLinkPlacement":{"const":"inControlsMenu","description":"Dashboard Link placement. Defines where the link should be displayed.\n- \"inControlsMenu\" renders the link in bottom part of the dashboard controls dropdown menu","type":"string"},"DashboardLinkType":{"description":"Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)","enum":["link","dashboards"],"type":"string"},"DataLink":{"additionalProperties":false,"properties":{"targetBlank":{"type":"boolean"},"title":{"type":"string"},"url":{"type":"string"}},"required":["title","url"],"type":"object"},"DataQueryKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a DataQueryKind is the datasource type","type":"string"},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"}},"required":["kind","spec"],"type":"object"},"DataSourceRef":{"additionalProperties":false,"properties":{"type":{"description":"The plugin type-id","type":"string"},"uid":{"description":"Specific datasource instance","type":"string"}},"type":"object"},"DataTopic":{"description":"A topic is attached to DataFrame metadata in query results.\nThis specifies where the data should be used.","enum":["series","annotations","alertStates"],"type":"string"},"DataTransformerConfig":{"additionalProperties":false,"description":"Transformations allow to manipulate data returned by a query before the system applies a visualization.\nUsing transformations you can: rename fields, join time series data, perform mathematical operations across queries,\nuse the output of one transformation as the input to another transformation, etc.","properties":{"disabled":{"description":"Disabled transformations are skipped","type":"boolean"},"filter":{"$ref":"#/components/schemas/MatcherConfig","description":"Optional frame matcher. When missing it will be applied to all results"},"id":{"description":"Unique identifier of transformer","type":"string"},"options":{"additionalProperties":{},"description":"Options to be passed to the transformer\nValid options depend on the transformer id","type":"object"},"topic":{"$ref":"#/components/schemas/DataTopic","description":"Where to pull DataFrames from as input to transformation"}},"required":["id","options"],"type":"object"},"DatasourceVariableKind":{"additionalProperties":false,"description":"Datasource variable kind","properties":{"kind":{"const":"DatasourceVariable","type":"string"},"spec":{"$ref":"#/components/schemas/DatasourceVariableSpec"}},"required":["kind","spec"],"type":"object"},"DatasourceVariableSpec":{"additionalProperties":false,"description":"Datasource variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"pluginId":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","pluginId","refresh","regex","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"DynamicConfigValue":{"additionalProperties":false,"properties":{"id":{"default":"","type":"string"},"value":{"additionalProperties":{},"type":"object"}},"required":["id"],"type":"object"},"Element":{"description":"Supported dashboard elements\n|* more element types in the future","oneOf":[{"$ref":"#/components/schemas/PanelKind"},{"$ref":"#/components/schemas/LibraryPanelKind"}]},"ElementReference":{"additionalProperties":false,"properties":{"kind":{"const":"ElementReference","type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"},"FetchOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url"],"type":"object"},"FieldColor":{"additionalProperties":false,"description":"Map a field to a color.","properties":{"fixedColor":{"description":"The fixed color value for fixed or shades color modes.","type":"string"},"mode":{"$ref":"#/components/schemas/FieldColorModeId","description":"The main color scheme mode."},"seriesBy":{"$ref":"#/components/schemas/FieldColorSeriesByMode","description":"Some visualizations need to know how to assign a series color from by value color schemes."}},"required":["mode"],"type":"object"},"FieldColorModeId":{"description":"Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.\nContinuous color interpolates a color using the percentage of a value relative to min and max.\nAccepted values are:\n` + "`" + `thresholds` + "`" + `: From thresholds. Informs Grafana to take the color from the matching threshold\n` + "`" + `palette-classic` + "`" + `: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `palette-classic-by-name` + "`" + `: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `continuous-viridis` + "`" + `: Continuous Viridis palette mode\n` + "`" + `continuous-magma` + "`" + `: Continuous Magma palette mode\n` + "`" + `continuous-plasma` + "`" + `: Continuous Plasma palette mode\n` + "`" + `continuous-inferno` + "`" + `: Continuous Inferno palette mode\n` + "`" + `continuous-cividis` + "`" + `: Continuous Cividis palette mode\n` + "`" + `continuous-GrYlRd` + "`" + `: Continuous Green-Yellow-Red palette mode\n` + "`" + `continuous-RdYlGr` + "`" + `: Continuous Red-Yellow-Green palette mode\n` + "`" + `continuous-BlYlRd` + "`" + `: Continuous Blue-Yellow-Red palette mode\n` + "`" + `continuous-YlRd` + "`" + `: Continuous Yellow-Red palette mode\n` + "`" + `continuous-BlPu` + "`" + `: Continuous Blue-Purple palette mode\n` + "`" + `continuous-YlBl` + "`" + `: Continuous Yellow-Blue palette mode\n` + "`" + `continuous-blues` + "`" + `: Continuous Blue palette mode\n` + "`" + `continuous-reds` + "`" + `: Continuous Red palette mode\n` + "`" + `continuous-greens` + "`" + `: Continuous Green palette mode\n` + "`" + `continuous-purples` + "`" + `: Continuous Purple palette mode\n` + "`" + `shades` + "`" + `: Shades of a single color. Specify a single color, useful in an override rule.\n` + "`" + `fixed` + "`" + `: Fixed color mode. Specify a single color, useful in an override rule.","enum":["thresholds","palette-classic","palette-classic-by-name","continuous-viridis","continuous-magma","continuous-plasma","continuous-inferno","continuous-cividis","continuous-GrYlRd","continuous-RdYlGr","continuous-BlYlRd","continuous-YlRd","continuous-BlPu","continuous-YlBl","continuous-blues","continuous-reds","continuous-greens","continuous-purples","fixed","shades"],"type":"string"},"FieldColorSeriesByMode":{"description":"Defines how to assign a series color from \"by value\" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.","enum":["min","max","last"],"type":"string"},"FieldConfig":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"actions":{"description":"Define interactive HTTP requests that can be triggered from data visualizations.","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"color":{"$ref":"#/components/schemas/FieldColor","description":"Panel color configuration"},"custom":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"custom is specified by the FieldConfig field\nin panel plugin schemas.","type":"object"},"decimals":{"description":"Specify the number of decimals Grafana includes in the rendered value.\nIf you leave this field blank, Grafana automatically truncates the number of decimals based on the value.\nFor example 1.1234 will display as 1.12 and 100.456 will display as 100.\nTo display all decimals, set the unit to ` + "`" + `String` + "`" + `.","type":"number"},"description":{"description":"Human readable field metadata","type":"string"},"displayName":{"description":"The display value for this field. This supports template variables blank is auto","type":"string"},"displayNameFromDS":{"description":"This can be used by data sources that return and explicit naming structure for values and labels\nWhen this property is configured, this value is used rather than the default naming strategy.","type":"string"},"filterable":{"description":"True if data source field supports ad-hoc filters","type":"boolean"},"links":{"description":"The behavior when clicking on a result","items":{"additionalProperties":{},"type":"object"},"type":"array"},"mappings":{"description":"Convert input values into a display string","items":{"$ref":"#/components/schemas/ValueMapping"},"type":"array"},"max":{"description":"The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"min":{"description":"The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"noValue":{"description":"Alternative to empty string","type":"string"},"path":{"description":"An explicit path to the field in the datasource. When the frame meta includes a path,\nThis will default to ` + "`" + `${frame.meta.path}/${field.name}\n\nWhen defined, this value can be used as an identifier within the datasource scope, and\nmay be used to update the results","type":"string"},"thresholds":{"$ref":"#/components/schemas/ThresholdsConfig","description":"Map numeric values to states"},"unit":{"description":"Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n` + "`" + `suffix:\u003csuffix\u003e` + "`" + ` for custom unit that should go after value.\n` + "`" + `prefix:\u003cprefix\u003e` + "`" + ` for custom unit that should go before value.\n` + "`" + `time:\u003cformat\u003e` + "`" + ` For custom date time formats type for example ` + "`" + `time:YYYY-MM-DD` + "`" + `.\n` + "`" + `si:\u003cbase scale\u003e\u003cunit characters\u003e` + "`" + ` for custom SI units. For example: ` + "`" + `si: mF` + "`" + `. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n` + "`" + `count:\u003cunit\u003e` + "`" + ` for a custom count unit.\n` + "`" + `currency:\u003cunit\u003e` + "`" + ` for custom a currency unit.","type":"string"},"writeable":{"description":"True if data source can write a value to the path. Auth/authz are supported separately","type":"boolean"}},"type":"object"},"FieldConfigSource":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"defaults":{"$ref":"#/components/schemas/FieldConfig","description":"Defaults are the options applied to all fields."},"overrides":{"description":"Overrides are the options applied to specific fields overriding the defaults.","items":{"additionalProperties":false,"properties":{"__systemRef":{"description":"Describes config override rules created when interacting with Grafana.","type":"string"},"matcher":{"$ref":"#/components/schemas/MatcherConfig"},"properties":{"items":{"$ref":"#/components/schemas/DynamicConfigValue"},"type":"array"}},"required":["matcher","properties"],"type":"object"},"type":"array"}},"required":["defaults","overrides"],"type":"object"},"FilterOrigin":{"const":"dashboard","description":"Determine the origin of the adhoc variable filter","type":"string"},"GridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutItemSpec":{"additionalProperties":false,"properties":{"element":{"$ref":"#/components/schemas/ElementReference","description":"reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference"},"height":{"type":"integer"},"repeat":{"$ref":"#/components/schemas/RepeatOptions"},"width":{"type":"integer"},"x":{"type":"integer"},"y":{"type":"integer"}},"required":["x","y","width","height","element"],"type":"object"},"GridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutSpec":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/GridLayoutItemKind"},"type":"array"}},"required":["items"],"type":"object"},"GroupByVariableKind":{"additionalProperties":false,"description":"Group variable kind","properties":{"kind":{"const":"GroupByVariable","type":"string"},"spec":{"$ref":"#/components/schemas/GroupByVariableSpec"}},"required":["kind","spec"],"type":"object"},"GroupByVariableSpec":{"additionalProperties":false,"description":"GroupBy variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"defaultValue":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","options","multi","hide","skipUrlSync"],"type":"object"},"HttpRequestMethod":{"enum":["GET","PUT","POST","DELETE","PATCH"],"type":"string"},"InfinityOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"datasourceUid":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url","datasourceUid"],"type":"object"},"IntervalVariableKind":{"additionalProperties":false,"description":"Interval variable kind","properties":{"kind":{"const":"IntervalVariable","type":"string"},"spec":{"$ref":"#/components/schemas/IntervalVariableSpec"}},"required":["kind","spec"],"type":"object"},"IntervalVariableSpec":{"additionalProperties":false,"description":"Interval variable specification","properties":{"auto":{"default":false,"type":"boolean"},"auto_count":{"default":0,"type":"integer"},"auto_min":{"default":"","type":"string"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","auto","auto_min","auto_count","refresh","hide","skipUrlSync"],"type":"object"},"LibraryPanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"LibraryPanel","type":"string"},"spec":{"$ref":"#/components/schemas/LibraryPanelKindSpec"}},"required":["kind","spec"],"type":"object"},"LibraryPanelKindSpec":{"additionalProperties":false,"properties":{"id":{"description":"Panel ID for the library panel in the dashboard","type":"number"},"libraryPanel":{"$ref":"#/components/schemas/LibraryPanelRef"},"title":{"description":"Title for the library panel in the dashboard","type":"string"}},"required":["id","title","libraryPanel"],"type":"object"},"LibraryPanelRef":{"additionalProperties":false,"description":"A library panel is a reusable panel that you can use in any dashboard.\nWhen you make a change to a library panel, that change propagates to all instances of where the panel is used.\nLibrary panels streamline reuse of panels across multiple dashboards.","properties":{"name":{"description":"Library panel name","type":"string"},"uid":{"description":"Library panel uid","type":"string"}},"required":["name","uid"],"type":"object"},"MappingType":{"description":"Supported value mapping types\n` + "`" + `value` + "`" + `: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.\n` + "`" + `range` + "`" + `: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.\n` + "`" + `regex` + "`" + `: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.\n` + "`" + `special` + "`" + `: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.","enum":["value","range","regex","special"],"type":"string"},"MatcherConfig":{"additionalProperties":false,"description":"Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.\nIt comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.","properties":{"id":{"default":"","description":"The matcher id. This is used to find the matcher implementation from registry.","type":"string"},"options":{"additionalProperties":{},"description":"The matcher options. This is specific to the matcher implementation.","type":"object"}},"required":["id"],"type":"object"},"MetricFindValue":{"additionalProperties":false,"description":"Define the MetricFindValue type","properties":{"expandable":{"type":"boolean"},"group":{"type":"string"},"text":{"type":"string"},"value":{"oneOf":[{"type":"string"},{"type":"number"}]}},"required":["text"],"type":"object"},"PanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"Panel","type":"string"},"spec":{"$ref":"#/components/schemas/PanelSpec"}},"required":["kind","spec"],"type":"object"},"PanelQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"PanelQuery","type":"string"},"spec":{"$ref":"#/components/schemas/PanelQuerySpec"}},"required":["kind","spec"],"type":"object"},"PanelQuerySpec":{"additionalProperties":false,"properties":{"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"hidden":{"type":"boolean"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refId":{"type":"string"}},"required":["query","refId","hidden"],"type":"object"},"PanelSpec":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QueryGroupKind"},"description":{"type":"string"},"id":{"type":"number"},"links":{"items":{"$ref":"#/components/schemas/DataLink"},"type":"array"},"title":{"type":"string"},"transparent":{"type":"boolean"},"vizConfig":{"$ref":"#/components/schemas/VizConfigKind"}},"required":["id","title","description","links","data","vizConfig"],"type":"object"},"QueryGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"QueryGroup","type":"string"},"spec":{"$ref":"#/components/schemas/QueryGroupSpec"}},"required":["kind","spec"],"type":"object"},"QueryGroupSpec":{"additionalProperties":false,"properties":{"queries":{"items":{"$ref":"#/components/schemas/PanelQueryKind"},"type":"array"},"queryOptions":{"$ref":"#/components/schemas/QueryOptionsSpec"},"transformations":{"items":{"$ref":"#/components/schemas/TransformationKind"},"type":"array"}},"required":["queries","transformations","queryOptions"],"type":"object"},"QueryOptionsSpec":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"string"},"hideTimeOverride":{"type":"boolean"},"interval":{"type":"string"},"maxDataPoints":{"type":"integer"},"queryCachingTTL":{"type":"integer"},"timeFrom":{"type":"string"},"timeShift":{"type":"string"}},"type":"object"},"QueryVariableKind":{"additionalProperties":false,"description":"Query variable kind","properties":{"kind":{"const":"QueryVariable","type":"string"},"spec":{"$ref":"#/components/schemas/QueryVariableSpec"}},"required":["kind","spec"],"type":"object"},"QueryVariableSpec":{"additionalProperties":false,"description":"Query variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"definition":{"type":"string"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"placeholder":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"regexApplyTo":{"$ref":"#/components/schemas/VariableRegexApplyTo","default":"value"},"skipUrlSync":{"default":false,"type":"boolean"},"sort":{"$ref":"#/components/schemas/VariableSort"},"staticOptions":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"staticOptionsOrder":{"enum":["before","after","sorted"],"type":"string"}},"required":["name","current","hide","refresh","skipUrlSync","query","regex","sort","options","multi","includeAll","allowCustomValue"],"type":"object"},"RangeMap":{"additionalProperties":false,"description":"Maps numerical ranges to a display text and color.\nFor example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.","properties":{"options":{"additionalProperties":false,"description":"Range to match against and the result to apply when the value is within the range","properties":{"from":{"description":"Min value of the range. It can be null which means -Infinity","type":"number"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value is within the range"},"to":{"description":"Max value of the range. It can be null which means +Infinity","type":"number"}},"required":["from","to","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RegexMap":{"additionalProperties":false,"description":"Maps regular expressions to replacement text and a color.\nFor example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.","properties":{"options":{"additionalProperties":false,"description":"Regular expression to match against and the result to apply when the value matches the regex","properties":{"pattern":{"description":"Regular expression to match against","type":"string"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the regex"}},"required":["pattern","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RepeatMode":{"const":"variable","description":"other repeat modes will be added in the future: label, frame","type":"string"},"RepeatOptions":{"additionalProperties":false,"properties":{"direction":{"enum":["h","v"],"type":"string"},"maxPerRow":{"type":"integer"},"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayoutRow","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutRowSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowSpec":{"additionalProperties":false,"properties":{"collapse":{"type":"boolean"},"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"fillScreen":{"type":"boolean"},"hideHeader":{"type":"boolean"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/RowRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"RowsLayoutSpec":{"additionalProperties":false,"properties":{"rows":{"items":{"$ref":"#/components/schemas/RowsLayoutRowKind"},"type":"array"}},"required":["rows"],"type":"object"},"SpecialValueMap":{"additionalProperties":false,"description":"Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color.\nSee SpecialValueMatch to see the list of special values.\nFor example, you can configure a special value mapping so that null values appear as N/A.","properties":{"options":{"additionalProperties":false,"properties":{"match":{"$ref":"#/components/schemas/SpecialValueMatch","description":"Special value to match against"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the special value"}},"required":["match","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"SpecialValueMatch":{"description":"Special value types supported by the ` + "`" + `SpecialValueMap` + "`" + `","enum":["true","false","null","nan","null+nan","empty"],"type":"string"},"SwitchVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"SwitchVariable","type":"string"},"spec":{"$ref":"#/components/schemas/SwitchVariableSpec"}},"required":["kind","spec"],"type":"object"},"SwitchVariableSpec":{"additionalProperties":false,"description":"Switch variable specification","properties":{"current":{"default":"false","type":"string"},"description":{"type":"string"},"disabledValue":{"default":"false","type":"string"},"enabledValue":{"default":"true","type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","enabledValue","disabledValue","hide","skipUrlSync"],"type":"object"},"TabRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"TabsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutSpec":{"additionalProperties":false,"properties":{"tabs":{"items":{"$ref":"#/components/schemas/TabsLayoutTabKind"},"type":"array"}},"required":["tabs"],"type":"object"},"TabsLayoutTabKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayoutTab","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutTabSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutTabSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/TabRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"TextVariableKind":{"additionalProperties":false,"description":"Text variable kind","properties":{"kind":{"const":"TextVariable","type":"string"},"spec":{"$ref":"#/components/schemas/TextVariableSpec"}},"required":["kind","spec"],"type":"object"},"TextVariableSpec":{"additionalProperties":false,"description":"Text variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","query","hide","skipUrlSync"],"type":"object"},"Threshold":{"additionalProperties":false,"properties":{"color":{"type":"string"},"value":{"description":"Value null means -Infinity","type":"number"}},"required":["value","color"],"type":"object"},"ThresholdsConfig":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/ThresholdsMode"},"steps":{"items":{"$ref":"#/components/schemas/Threshold"},"type":"array"}},"required":["mode","steps"],"type":"object"},"ThresholdsMode":{"enum":["absolute","percentage"],"type":"string"},"TimeRangeOption":{"additionalProperties":false,"properties":{"display":{"default":"Last 6 hours","type":"string"},"from":{"default":"now-6h","type":"string"},"to":{"default":"now","type":"string"}},"required":["display","from","to"],"type":"object"},"TimeSettingsSpec":{"additionalProperties":false,"description":"Time configuration\nIt defines the default time config for the time picker, the refresh picker for the specific dashboard.","properties":{"autoRefresh":{"default":"","description":"Refresh rate of dashboard. Represented via interval string, e.g. \"5s\", \"1m\", \"1h\", \"1d\".\nv1: refresh","type":"string"},"autoRefreshIntervals":{"default":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"description":"Interval options available in the refresh picker dropdown.\nv1: timepicker.refresh_intervals","items":{"type":"string"},"type":"array"},"fiscalYearStartMonth":{"default":0,"description":"The month that the fiscal year starts on. 0 = January, 11 = December","type":"integer"},"from":{"default":"now-6h","description":"Start time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"hideTimepicker":{"default":false,"description":"Whether timepicker is visible or not.\nv1: timepicker.hidden","type":"boolean"},"nowDelay":{"description":"Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.\nv1: timepicker.nowDelay","type":"string"},"quickRanges":{"description":"Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.\nv1: timepicker.quick_ranges , not exposed in the UI","items":{"$ref":"#/components/schemas/TimeRangeOption"},"type":"array"},"timezone":{"default":"browser","description":"Timezone of dashboard. Accepted values are IANA TZDB zone ID or \"browser\" or \"utc\".","type":"string"},"to":{"default":"now","description":"End time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"weekStart":{"description":"Day when the week starts. Expressed by the name of the day in lowercase, e.g. \"monday\".","enum":["saturday","monday","sunday"],"type":"string"}},"required":["from","to","autoRefresh","autoRefreshIntervals","hideTimepicker","fiscalYearStartMonth"],"type":"object"},"TransformationKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a TransformationKind is the transformation ID","type":"string"},"spec":{"$ref":"#/components/schemas/DataTransformerConfig"}},"required":["kind","spec"],"type":"object"},"ValueMap":{"additionalProperties":false,"description":"Maps text values to a color or different display text and color.\nFor example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.","properties":{"options":{"additionalProperties":{"$ref":"#/components/schemas/ValueMappingResult"},"description":"Map with \u003cvalue_to_match\u003e: ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }","type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"ValueMapping":{"oneOf":[{"$ref":"#/components/schemas/ValueMap"},{"$ref":"#/components/schemas/RangeMap"},{"$ref":"#/components/schemas/RegexMap"},{"$ref":"#/components/schemas/SpecialValueMap"}]},"ValueMappingResult":{"additionalProperties":false,"description":"Result used as replacement with text and color when the value matches","properties":{"color":{"description":"Text to use when the value matches","type":"string"},"icon":{"description":"Icon to display when the value matches. Only specific visualizations.","type":"string"},"index":{"description":"Position in the mapping array. Only used internally.","type":"integer"},"text":{"description":"Text to display when the value matches","type":"string"}},"type":"object"},"VariableHide":{"description":"Determine if the variable shows on dashboard\nAccepted values are ` + "`" + `dontHide` + "`" + ` (show label and value), ` + "`" + `hideLabel` + "`" + ` (show value only), ` + "`" + `hideVariable` + "`" + ` (show nothing).","enum":["dontHide","hideLabel","hideVariable"],"type":"string"},"VariableKind":{"oneOf":[{"$ref":"#/components/schemas/QueryVariableKind"},{"$ref":"#/components/schemas/TextVariableKind"},{"$ref":"#/components/schemas/ConstantVariableKind"},{"$ref":"#/components/schemas/DatasourceVariableKind"},{"$ref":"#/components/schemas/IntervalVariableKind"},{"$ref":"#/components/schemas/CustomVariableKind"},{"$ref":"#/components/schemas/GroupByVariableKind"},{"$ref":"#/components/schemas/AdhocVariableKind"},{"$ref":"#/components/schemas/SwitchVariableKind"}]},"VariableOption":{"additionalProperties":false,"description":"Variable option specification","properties":{"selected":{"description":"Whether the option is selected or not","type":"boolean"},"text":{"description":"Text to be displayed for the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]},"value":{"description":"Value of the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]}},"required":["text","value"],"type":"object"},"VariableRefresh":{"description":"Options to config when to refresh a variable\n` + "`" + `never` + "`" + `: Never refresh the variable\n` + "`" + `onDashboardLoad` + "`" + `: Queries the data source every time the dashboard loads.\n` + "`" + `onTimeRangeChanged` + "`" + `: Queries the data source when the dashboard time range changes.","enum":["never","onDashboardLoad","onTimeRangeChanged"],"type":"string"},"VariableRegexApplyTo":{"description":"Determine whether regex applies to variable value or display text\nAccepted values are ` + "`" + `value` + "`" + ` (apply to value used in queries) or ` + "`" + `text` + "`" + ` (apply to display text shown to users)","enum":["value","text"],"type":"string"},"VariableSort":{"description":"Sort variable options\nAccepted values are:\n` + "`" + `disabled` + "`" + `: No sorting\n` + "`" + `alphabeticalAsc` + "`" + `: Alphabetical ASC\n` + "`" + `alphabeticalDesc` + "`" + `: Alphabetical DESC\n` + "`" + `numericalAsc` + "`" + `: Numerical ASC\n` + "`" + `numericalDesc` + "`" + `: Numerical DESC\n` + "`" + `alphabeticalCaseInsensitiveAsc` + "`" + `: Alphabetical Case Insensitive ASC\n` + "`" + `alphabeticalCaseInsensitiveDesc` + "`" + `: Alphabetical Case Insensitive DESC\n` + "`" + `naturalAsc` + "`" + `: Natural ASC\n` + "`" + `naturalDesc` + "`" + `: Natural DESC\nVariableSort enum with default value","enum":["disabled","alphabeticalAsc","alphabeticalDesc","numericalAsc","numericalDesc","alphabeticalCaseInsensitiveAsc","alphabeticalCaseInsensitiveDesc","naturalAsc","naturalDesc"],"type":"string"},"VizConfigKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a VizConfigKind is the plugin ID","type":"string"},"spec":{"$ref":"#/components/schemas/VizConfigSpec"}},"required":["kind","spec"],"type":"object"},"VizConfigSpec":{"additionalProperties":false,"description":"--- Kinds ---","properties":{"fieldConfig":{"$ref":"#/components/schemas/FieldConfigSource"},"options":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"pluginVersion":{"type":"string"}},"required":["pluginVersion","options","fieldConfig"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"annotations":{"items":{"$ref":"#/components/schemas/AnnotationQueryKind"},"type":"array"},"cursorSync":{"$ref":"#/components/schemas/DashboardCursorSync","default":"Off","description":"Configuration of dashboard cursor sync behavior.\n\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip."},"description":{"description":"Description of dashboard.","type":"string"},"editable":{"default":true,"description":"Whether a dashboard is editable or not.","type":"boolean"},"elements":{"additionalProperties":{"$ref":"#/components/schemas/Element"},"type":"object"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"links":{"description":"Links with references to other dashboards or external websites.","items":{"$ref":"#/components/schemas/DashboardLink"},"type":"array"},"liveNow":{"description":"When set to true, the dashboard will redraw panels at an interval matching the pixel width.\nThis will keep data \"moving left\" regardless of the query refresh rate. This setting helps\navoid dashboards presenting stale live data.","type":"boolean"},"preload":{"default":false,"description":"When set to true, the dashboard will load all panels in the dashboard when it's loaded.","type":"boolean"},"revision":{"description":"Plugins only. The version of the dashboard installed together with the plugin.\nThis is used to determine if the dashboard should be updated when the plugin is updated.","type":"integer"},"tags":{"description":"Tags associated with dashboard.","items":{"type":"string"},"type":"array"},"timeSettings":{"$ref":"#/components/schemas/TimeSettingsSpec"},"title":{"description":"Title of dashboard.","type":"string"},"variables":{"description":"Configured template variables.","items":{"$ref":"#/components/schemas/VariableKind"},"type":"array"}},"required":["annotations","cursorSync","elements","layout","links","preload","tags","timeSettings","title","variables"],"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) + rawSchemaDashboardv2alpha1 = []byte(`{"Action":{"additionalProperties":false,"properties":{"confirmation":{"type":"string"},"fetch":{"$ref":"#/components/schemas/FetchOptions"},"infinity":{"$ref":"#/components/schemas/InfinityOptions"},"oneClick":{"type":"boolean"},"style":{"additionalProperties":false,"properties":{"backgroundColor":{"type":"string"}},"type":"object"},"title":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionType"},"variables":{"items":{"$ref":"#/components/schemas/ActionVariable"},"type":"array"}},"required":["type","title"],"type":"object"},"ActionType":{"enum":["fetch","infinity"],"type":"string"},"ActionVariable":{"additionalProperties":false,"properties":{"key":{"type":"string"},"name":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionVariableType"}},"required":["key","name","type"],"type":"object"},"ActionVariableType":{"const":"string","description":"Action variable type","type":"string"},"AdHocFilterWithLabels":{"additionalProperties":false,"description":"Define the AdHocFilterWithLabels type","properties":{"condition":{"description":"@deprecated","type":"string"},"forceEdit":{"type":"boolean"},"key":{"type":"string"},"keyLabel":{"type":"string"},"operator":{"type":"string"},"origin":{"$ref":"#/components/schemas/FilterOrigin"},"value":{"type":"string"},"valueLabels":{"items":{"type":"string"},"type":"array"},"values":{"items":{"type":"string"},"type":"array"}},"required":["key","operator","value"],"type":"object"},"AdhocVariableKind":{"additionalProperties":false,"description":"Adhoc variable kind","properties":{"kind":{"const":"AdhocVariable","type":"string"},"spec":{"$ref":"#/components/schemas/AdhocVariableSpec"}},"required":["kind","spec"],"type":"object"},"AdhocVariableSpec":{"additionalProperties":false,"description":"Adhoc variable specification","properties":{"allowCustomValue":{"default":true,"type":"boolean"},"baseFilters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"defaultKeys":{"items":{"$ref":"#/components/schemas/MetricFindValue"},"type":"array"},"description":{"type":"string"},"filters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","baseFilters","filters","defaultKeys","hide","skipUrlSync","allowCustomValue"],"type":"object"},"AnnotationEventFieldMapping":{"additionalProperties":false,"description":"Annotation event field mapping. Defines how to map a data frame field to an annotation event field.","properties":{"regex":{"description":"Regular expression to apply to the field value","type":"string"},"source":{"default":"field","description":"Source type for the field value","type":"string"},"value":{"description":"Constant value to use when source is \"text\"","type":"string"}},"type":"object"},"AnnotationPanelFilter":{"additionalProperties":false,"properties":{"exclude":{"default":false,"description":"Should the specified panels be included or excluded","type":"boolean"},"ids":{"description":"Panel IDs that should be included or excluded","items":{"type":"integer"},"type":"array"}},"required":["ids"],"type":"object"},"AnnotationQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"AnnotationQuery","type":"string"},"spec":{"$ref":"#/components/schemas/AnnotationQuerySpec"}},"required":["kind","spec"],"type":"object"},"AnnotationQuerySpec":{"additionalProperties":false,"properties":{"builtIn":{"default":false,"type":"boolean"},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"enable":{"type":"boolean"},"filter":{"$ref":"#/components/schemas/AnnotationPanelFilter"},"hide":{"type":"boolean"},"iconColor":{"type":"string"},"legacyOptions":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"Catch-all field for datasource-specific properties","type":"object"},"mappings":{"additionalProperties":{"$ref":"#/components/schemas/AnnotationEventFieldMapping"},"description":"Mappings define how to convert data frame fields to annotation event fields.","type":"object"},"name":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"}},"required":["enable","hide","iconColor","name"],"type":"object"},"AutoGridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutItemSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"element":{"$ref":"#/components/schemas/ElementReference"},"repeat":{"$ref":"#/components/schemas/AutoGridRepeatOptions"}},"required":["element"],"type":"object"},"AutoGridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutSpec":{"additionalProperties":false,"properties":{"columnWidth":{"type":"number"},"columnWidthMode":{"default":"standard","enum":["narrow","standard","wide","custom"],"type":"string"},"fillScreen":{"default":false,"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/AutoGridLayoutItemKind"},"type":"array"},"maxColumnCount":{"default":3,"type":"number"},"rowHeight":{"type":"number"},"rowHeightMode":{"default":"standard","enum":["short","standard","tall","custom"],"type":"string"}},"required":["columnWidthMode","rowHeightMode","items"],"type":"object"},"AutoGridRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"ConditionalRenderingDataKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingData","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingDataSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingDataSpec":{"additionalProperties":false,"properties":{"value":{"type":"boolean"}},"required":["value"],"type":"object"},"ConditionalRenderingGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingGroup","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingGroupSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingGroupSpec":{"additionalProperties":false,"properties":{"condition":{"enum":["and","or"],"type":"string"},"items":{"items":{"oneOf":[{"$ref":"#/components/schemas/ConditionalRenderingVariableKind"},{"$ref":"#/components/schemas/ConditionalRenderingDataKind"},{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeKind"}]},"type":"array"},"visibility":{"enum":["show","hide"],"type":"string"}},"required":["visibility","condition","items"],"type":"object"},"ConditionalRenderingTimeRangeSizeKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingTimeRangeSize","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingTimeRangeSizeSpec":{"additionalProperties":false,"properties":{"value":{"type":"string"}},"required":["value"],"type":"object"},"ConditionalRenderingVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingVariableSpec":{"additionalProperties":false,"properties":{"operator":{"enum":["equals","notEquals","matches","notMatches"],"type":"string"},"value":{"type":"string"},"variable":{"type":"string"}},"required":["variable","operator","value"],"type":"object"},"ConstantVariableKind":{"additionalProperties":false,"description":"Constant variable kind","properties":{"kind":{"const":"ConstantVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConstantVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConstantVariableSpec":{"additionalProperties":false,"description":"Constant variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","hide","skipUrlSync"],"type":"object"},"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"CustomVariableKind":{"additionalProperties":false,"description":"Custom variable kind","properties":{"kind":{"const":"CustomVariable","type":"string"},"spec":{"$ref":"#/components/schemas/CustomVariableSpec"}},"required":["kind","spec"],"type":"object"},"CustomVariableSpec":{"additionalProperties":false,"description":"Custom variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"},"valuesFormat":{"enum":["csv","json"],"type":"string"}},"required":["name","query","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"DashboardCursorSync":{"description":"\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip.","enum":["Crosshair","Tooltip","Off"],"type":"string"},"DashboardLink":{"additionalProperties":false,"description":"Links with references to other dashboards or external resources","properties":{"asDropdown":{"default":false,"description":"If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards","type":"boolean"},"icon":{"description":"Icon name to be displayed with the link","type":"string"},"includeVars":{"default":false,"description":"If true, includes current template variables values in the link as query params","type":"boolean"},"keepTime":{"default":false,"description":"If true, includes current time range in the link as query params","type":"boolean"},"placement":{"$ref":"#/components/schemas/DashboardLinkPlacement","description":"Placement can be used to display the link somewhere else on the dashboard other than above the visualisations."},"tags":{"description":"List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards","items":{"type":"string"},"type":"array"},"targetBlank":{"default":false,"description":"If true, the link will be opened in a new tab","type":"boolean"},"title":{"description":"Title to display with the link","type":"string"},"tooltip":{"description":"Tooltip to display when the user hovers their mouse over it","type":"string"},"type":{"$ref":"#/components/schemas/DashboardLinkType","description":"Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)\nFIXME: The type is generated as ` + "`" + `type: DashboardLinkType | dashboardLinkType.Link;` + "`" + ` but it should be ` + "`" + `type: DashboardLinkType` + "`" + `"},"url":{"description":"Link URL. Only required/valid if the type is link","type":"string"}},"required":["title","type","icon","tooltip","tags","asDropdown","targetBlank","includeVars","keepTime"],"type":"object"},"DashboardLinkPlacement":{"const":"inControlsMenu","description":"Dashboard Link placement. Defines where the link should be displayed.\n- \"inControlsMenu\" renders the link in bottom part of the dashboard controls dropdown menu","type":"string"},"DashboardLinkType":{"description":"Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)","enum":["link","dashboards"],"type":"string"},"DataLink":{"additionalProperties":false,"properties":{"targetBlank":{"type":"boolean"},"title":{"type":"string"},"url":{"type":"string"}},"required":["title","url"],"type":"object"},"DataQueryKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a DataQueryKind is the datasource type","type":"string"},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"}},"required":["kind","spec"],"type":"object"},"DataSourceRef":{"additionalProperties":false,"properties":{"type":{"description":"The plugin type-id","type":"string"},"uid":{"description":"Specific datasource instance","type":"string"}},"type":"object"},"DataTopic":{"description":"A topic is attached to DataFrame metadata in query results.\nThis specifies where the data should be used.","enum":["series","annotations","alertStates"],"type":"string"},"DataTransformerConfig":{"additionalProperties":false,"description":"Transformations allow to manipulate data returned by a query before the system applies a visualization.\nUsing transformations you can: rename fields, join time series data, perform mathematical operations across queries,\nuse the output of one transformation as the input to another transformation, etc.","properties":{"disabled":{"description":"Disabled transformations are skipped","type":"boolean"},"filter":{"$ref":"#/components/schemas/MatcherConfig","description":"Optional frame matcher. When missing it will be applied to all results"},"id":{"description":"Unique identifier of transformer","type":"string"},"options":{"additionalProperties":{},"description":"Options to be passed to the transformer\nValid options depend on the transformer id","type":"object"},"topic":{"$ref":"#/components/schemas/DataTopic","description":"Where to pull DataFrames from as input to transformation"}},"required":["id","options"],"type":"object"},"DatasourceVariableKind":{"additionalProperties":false,"description":"Datasource variable kind","properties":{"kind":{"const":"DatasourceVariable","type":"string"},"spec":{"$ref":"#/components/schemas/DatasourceVariableSpec"}},"required":["kind","spec"],"type":"object"},"DatasourceVariableSpec":{"additionalProperties":false,"description":"Datasource variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"pluginId":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","pluginId","refresh","regex","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"DynamicConfigValue":{"additionalProperties":false,"properties":{"id":{"default":"","type":"string"},"value":{"additionalProperties":{},"type":"object"}},"required":["id"],"type":"object"},"Element":{"description":"Supported dashboard elements\n|* more element types in the future","oneOf":[{"$ref":"#/components/schemas/PanelKind"},{"$ref":"#/components/schemas/LibraryPanelKind"}]},"ElementReference":{"additionalProperties":false,"properties":{"kind":{"const":"ElementReference","type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"},"FetchOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url"],"type":"object"},"FieldColor":{"additionalProperties":false,"description":"Map a field to a color.","properties":{"fixedColor":{"description":"The fixed color value for fixed or shades color modes.","type":"string"},"mode":{"$ref":"#/components/schemas/FieldColorModeId","description":"The main color scheme mode."},"seriesBy":{"$ref":"#/components/schemas/FieldColorSeriesByMode","description":"Some visualizations need to know how to assign a series color from by value color schemes."}},"required":["mode"],"type":"object"},"FieldColorModeId":{"description":"Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.\nContinuous color interpolates a color using the percentage of a value relative to min and max.\nAccepted values are:\n` + "`" + `thresholds` + "`" + `: From thresholds. Informs Grafana to take the color from the matching threshold\n` + "`" + `palette-classic` + "`" + `: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `palette-classic-by-name` + "`" + `: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `continuous-viridis` + "`" + `: Continuous Viridis palette mode\n` + "`" + `continuous-magma` + "`" + `: Continuous Magma palette mode\n` + "`" + `continuous-plasma` + "`" + `: Continuous Plasma palette mode\n` + "`" + `continuous-inferno` + "`" + `: Continuous Inferno palette mode\n` + "`" + `continuous-cividis` + "`" + `: Continuous Cividis palette mode\n` + "`" + `continuous-GrYlRd` + "`" + `: Continuous Green-Yellow-Red palette mode\n` + "`" + `continuous-RdYlGr` + "`" + `: Continuous Red-Yellow-Green palette mode\n` + "`" + `continuous-BlYlRd` + "`" + `: Continuous Blue-Yellow-Red palette mode\n` + "`" + `continuous-YlRd` + "`" + `: Continuous Yellow-Red palette mode\n` + "`" + `continuous-BlPu` + "`" + `: Continuous Blue-Purple palette mode\n` + "`" + `continuous-YlBl` + "`" + `: Continuous Yellow-Blue palette mode\n` + "`" + `continuous-blues` + "`" + `: Continuous Blue palette mode\n` + "`" + `continuous-reds` + "`" + `: Continuous Red palette mode\n` + "`" + `continuous-greens` + "`" + `: Continuous Green palette mode\n` + "`" + `continuous-purples` + "`" + `: Continuous Purple palette mode\n` + "`" + `shades` + "`" + `: Shades of a single color. Specify a single color, useful in an override rule.\n` + "`" + `fixed` + "`" + `: Fixed color mode. Specify a single color, useful in an override rule.","enum":["thresholds","palette-classic","palette-classic-by-name","continuous-viridis","continuous-magma","continuous-plasma","continuous-inferno","continuous-cividis","continuous-GrYlRd","continuous-RdYlGr","continuous-BlYlRd","continuous-YlRd","continuous-BlPu","continuous-YlBl","continuous-blues","continuous-reds","continuous-greens","continuous-purples","fixed","shades"],"type":"string"},"FieldColorSeriesByMode":{"description":"Defines how to assign a series color from \"by value\" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.","enum":["min","max","last"],"type":"string"},"FieldConfig":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"actions":{"description":"Define interactive HTTP requests that can be triggered from data visualizations.","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"color":{"$ref":"#/components/schemas/FieldColor","description":"Panel color configuration"},"custom":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"custom is specified by the FieldConfig field\nin panel plugin schemas.","type":"object"},"decimals":{"description":"Specify the number of decimals Grafana includes in the rendered value.\nIf you leave this field blank, Grafana automatically truncates the number of decimals based on the value.\nFor example 1.1234 will display as 1.12 and 100.456 will display as 100.\nTo display all decimals, set the unit to ` + "`" + `String` + "`" + `.","type":"number"},"description":{"description":"Human readable field metadata","type":"string"},"displayName":{"description":"The display value for this field. This supports template variables blank is auto","type":"string"},"displayNameFromDS":{"description":"This can be used by data sources that return and explicit naming structure for values and labels\nWhen this property is configured, this value is used rather than the default naming strategy.","type":"string"},"filterable":{"description":"True if data source field supports ad-hoc filters","type":"boolean"},"links":{"description":"The behavior when clicking on a result","items":{"additionalProperties":{},"type":"object"},"type":"array"},"mappings":{"description":"Convert input values into a display string","items":{"$ref":"#/components/schemas/ValueMapping"},"type":"array"},"max":{"description":"The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"min":{"description":"The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"noValue":{"description":"Alternative to empty string","type":"string"},"path":{"description":"An explicit path to the field in the datasource. When the frame meta includes a path,\nThis will default to ` + "`" + `${frame.meta.path}/${field.name}\n\nWhen defined, this value can be used as an identifier within the datasource scope, and\nmay be used to update the results","type":"string"},"thresholds":{"$ref":"#/components/schemas/ThresholdsConfig","description":"Map numeric values to states"},"unit":{"description":"Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n` + "`" + `suffix:\u003csuffix\u003e` + "`" + ` for custom unit that should go after value.\n` + "`" + `prefix:\u003cprefix\u003e` + "`" + ` for custom unit that should go before value.\n` + "`" + `time:\u003cformat\u003e` + "`" + ` For custom date time formats type for example ` + "`" + `time:YYYY-MM-DD` + "`" + `.\n` + "`" + `si:\u003cbase scale\u003e\u003cunit characters\u003e` + "`" + ` for custom SI units. For example: ` + "`" + `si: mF` + "`" + `. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n` + "`" + `count:\u003cunit\u003e` + "`" + ` for a custom count unit.\n` + "`" + `currency:\u003cunit\u003e` + "`" + ` for custom a currency unit.","type":"string"},"writeable":{"description":"True if data source can write a value to the path. Auth/authz are supported separately","type":"boolean"}},"type":"object"},"FieldConfigSource":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"defaults":{"$ref":"#/components/schemas/FieldConfig","description":"Defaults are the options applied to all fields."},"overrides":{"description":"Overrides are the options applied to specific fields overriding the defaults.","items":{"additionalProperties":false,"properties":{"__systemRef":{"description":"Describes config override rules created when interacting with Grafana.","type":"string"},"matcher":{"$ref":"#/components/schemas/MatcherConfig"},"properties":{"items":{"$ref":"#/components/schemas/DynamicConfigValue"},"type":"array"}},"required":["matcher","properties"],"type":"object"},"type":"array"}},"required":["defaults","overrides"],"type":"object"},"FilterOrigin":{"const":"dashboard","description":"Determine the origin of the adhoc variable filter","type":"string"},"GridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutItemSpec":{"additionalProperties":false,"properties":{"element":{"$ref":"#/components/schemas/ElementReference","description":"reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference"},"height":{"type":"integer"},"repeat":{"$ref":"#/components/schemas/RepeatOptions"},"width":{"type":"integer"},"x":{"type":"integer"},"y":{"type":"integer"}},"required":["x","y","width","height","element"],"type":"object"},"GridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutSpec":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/GridLayoutItemKind"},"type":"array"}},"required":["items"],"type":"object"},"GroupByVariableKind":{"additionalProperties":false,"description":"Group variable kind","properties":{"kind":{"const":"GroupByVariable","type":"string"},"spec":{"$ref":"#/components/schemas/GroupByVariableSpec"}},"required":["kind","spec"],"type":"object"},"GroupByVariableSpec":{"additionalProperties":false,"description":"GroupBy variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"defaultValue":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","options","multi","hide","skipUrlSync"],"type":"object"},"HttpRequestMethod":{"enum":["GET","PUT","POST","DELETE","PATCH"],"type":"string"},"InfinityOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"datasourceUid":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url","datasourceUid"],"type":"object"},"IntervalVariableKind":{"additionalProperties":false,"description":"Interval variable kind","properties":{"kind":{"const":"IntervalVariable","type":"string"},"spec":{"$ref":"#/components/schemas/IntervalVariableSpec"}},"required":["kind","spec"],"type":"object"},"IntervalVariableSpec":{"additionalProperties":false,"description":"Interval variable specification","properties":{"auto":{"default":false,"type":"boolean"},"auto_count":{"default":0,"type":"integer"},"auto_min":{"default":"","type":"string"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","auto","auto_min","auto_count","refresh","hide","skipUrlSync"],"type":"object"},"LibraryPanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"LibraryPanel","type":"string"},"spec":{"$ref":"#/components/schemas/LibraryPanelKindSpec"}},"required":["kind","spec"],"type":"object"},"LibraryPanelKindSpec":{"additionalProperties":false,"properties":{"id":{"description":"Panel ID for the library panel in the dashboard","type":"number"},"libraryPanel":{"$ref":"#/components/schemas/LibraryPanelRef"},"title":{"description":"Title for the library panel in the dashboard","type":"string"}},"required":["id","title","libraryPanel"],"type":"object"},"LibraryPanelRef":{"additionalProperties":false,"description":"A library panel is a reusable panel that you can use in any dashboard.\nWhen you make a change to a library panel, that change propagates to all instances of where the panel is used.\nLibrary panels streamline reuse of panels across multiple dashboards.","properties":{"name":{"description":"Library panel name","type":"string"},"uid":{"description":"Library panel uid","type":"string"}},"required":["name","uid"],"type":"object"},"MappingType":{"description":"Supported value mapping types\n` + "`" + `value` + "`" + `: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.\n` + "`" + `range` + "`" + `: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.\n` + "`" + `regex` + "`" + `: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.\n` + "`" + `special` + "`" + `: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.","enum":["value","range","regex","special"],"type":"string"},"MatcherConfig":{"additionalProperties":false,"description":"Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.\nIt comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.","properties":{"id":{"default":"","description":"The matcher id. This is used to find the matcher implementation from registry.","type":"string"},"options":{"additionalProperties":{},"description":"The matcher options. This is specific to the matcher implementation.","type":"object"}},"required":["id"],"type":"object"},"MetricFindValue":{"additionalProperties":false,"description":"Define the MetricFindValue type","properties":{"expandable":{"type":"boolean"},"group":{"type":"string"},"text":{"type":"string"},"value":{"oneOf":[{"type":"string"},{"type":"number"}]}},"required":["text"],"type":"object"},"PanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"Panel","type":"string"},"spec":{"$ref":"#/components/schemas/PanelSpec"}},"required":["kind","spec"],"type":"object"},"PanelQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"PanelQuery","type":"string"},"spec":{"$ref":"#/components/schemas/PanelQuerySpec"}},"required":["kind","spec"],"type":"object"},"PanelQuerySpec":{"additionalProperties":false,"properties":{"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"hidden":{"type":"boolean"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refId":{"type":"string"}},"required":["query","refId","hidden"],"type":"object"},"PanelSpec":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QueryGroupKind"},"description":{"type":"string"},"id":{"type":"number"},"links":{"items":{"$ref":"#/components/schemas/DataLink"},"type":"array"},"title":{"type":"string"},"transparent":{"type":"boolean"},"vizConfig":{"$ref":"#/components/schemas/VizConfigKind"}},"required":["id","title","description","links","data","vizConfig"],"type":"object"},"QueryGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"QueryGroup","type":"string"},"spec":{"$ref":"#/components/schemas/QueryGroupSpec"}},"required":["kind","spec"],"type":"object"},"QueryGroupSpec":{"additionalProperties":false,"properties":{"queries":{"items":{"$ref":"#/components/schemas/PanelQueryKind"},"type":"array"},"queryOptions":{"$ref":"#/components/schemas/QueryOptionsSpec"},"transformations":{"items":{"$ref":"#/components/schemas/TransformationKind"},"type":"array"}},"required":["queries","transformations","queryOptions"],"type":"object"},"QueryOptionsSpec":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"string"},"hideTimeOverride":{"type":"boolean"},"interval":{"type":"string"},"maxDataPoints":{"type":"integer"},"queryCachingTTL":{"type":"integer"},"timeFrom":{"type":"string"},"timeShift":{"type":"string"}},"type":"object"},"QueryVariableKind":{"additionalProperties":false,"description":"Query variable kind","properties":{"kind":{"const":"QueryVariable","type":"string"},"spec":{"$ref":"#/components/schemas/QueryVariableSpec"}},"required":["kind","spec"],"type":"object"},"QueryVariableSpec":{"additionalProperties":false,"description":"Query variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"definition":{"type":"string"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"placeholder":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"regexApplyTo":{"$ref":"#/components/schemas/VariableRegexApplyTo","default":"value"},"skipUrlSync":{"default":false,"type":"boolean"},"sort":{"$ref":"#/components/schemas/VariableSort"},"staticOptions":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"staticOptionsOrder":{"enum":["before","after","sorted"],"type":"string"}},"required":["name","current","hide","refresh","skipUrlSync","query","regex","sort","options","multi","includeAll","allowCustomValue"],"type":"object"},"RangeMap":{"additionalProperties":false,"description":"Maps numerical ranges to a display text and color.\nFor example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.","properties":{"options":{"additionalProperties":false,"description":"Range to match against and the result to apply when the value is within the range","properties":{"from":{"description":"Min value of the range. It can be null which means -Infinity","type":"number"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value is within the range"},"to":{"description":"Max value of the range. It can be null which means +Infinity","type":"number"}},"required":["from","to","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RegexMap":{"additionalProperties":false,"description":"Maps regular expressions to replacement text and a color.\nFor example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.","properties":{"options":{"additionalProperties":false,"description":"Regular expression to match against and the result to apply when the value matches the regex","properties":{"pattern":{"description":"Regular expression to match against","type":"string"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the regex"}},"required":["pattern","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RepeatMode":{"const":"variable","description":"other repeat modes will be added in the future: label, frame","type":"string"},"RepeatOptions":{"additionalProperties":false,"properties":{"direction":{"enum":["h","v"],"type":"string"},"maxPerRow":{"type":"integer"},"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayoutRow","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutRowSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowSpec":{"additionalProperties":false,"properties":{"collapse":{"type":"boolean"},"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"fillScreen":{"type":"boolean"},"hideHeader":{"type":"boolean"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/RowRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"RowsLayoutSpec":{"additionalProperties":false,"properties":{"rows":{"items":{"$ref":"#/components/schemas/RowsLayoutRowKind"},"type":"array"}},"required":["rows"],"type":"object"},"SpecialValueMap":{"additionalProperties":false,"description":"Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color.\nSee SpecialValueMatch to see the list of special values.\nFor example, you can configure a special value mapping so that null values appear as N/A.","properties":{"options":{"additionalProperties":false,"properties":{"match":{"$ref":"#/components/schemas/SpecialValueMatch","description":"Special value to match against"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the special value"}},"required":["match","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"SpecialValueMatch":{"description":"Special value types supported by the ` + "`" + `SpecialValueMap` + "`" + `","enum":["true","false","null","nan","null+nan","empty"],"type":"string"},"SwitchVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"SwitchVariable","type":"string"},"spec":{"$ref":"#/components/schemas/SwitchVariableSpec"}},"required":["kind","spec"],"type":"object"},"SwitchVariableSpec":{"additionalProperties":false,"description":"Switch variable specification","properties":{"current":{"default":"false","type":"string"},"description":{"type":"string"},"disabledValue":{"default":"false","type":"string"},"enabledValue":{"default":"true","type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","enabledValue","disabledValue","hide","skipUrlSync"],"type":"object"},"TabRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"TabsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutSpec":{"additionalProperties":false,"properties":{"tabs":{"items":{"$ref":"#/components/schemas/TabsLayoutTabKind"},"type":"array"}},"required":["tabs"],"type":"object"},"TabsLayoutTabKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayoutTab","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutTabSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutTabSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/TabRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"TextVariableKind":{"additionalProperties":false,"description":"Text variable kind","properties":{"kind":{"const":"TextVariable","type":"string"},"spec":{"$ref":"#/components/schemas/TextVariableSpec"}},"required":["kind","spec"],"type":"object"},"TextVariableSpec":{"additionalProperties":false,"description":"Text variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","query","hide","skipUrlSync"],"type":"object"},"Threshold":{"additionalProperties":false,"properties":{"color":{"type":"string"},"value":{"description":"Value null means -Infinity","type":"number"}},"required":["value","color"],"type":"object"},"ThresholdsConfig":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/ThresholdsMode"},"steps":{"items":{"$ref":"#/components/schemas/Threshold"},"type":"array"}},"required":["mode","steps"],"type":"object"},"ThresholdsMode":{"enum":["absolute","percentage"],"type":"string"},"TimeRangeOption":{"additionalProperties":false,"properties":{"display":{"default":"Last 6 hours","type":"string"},"from":{"default":"now-6h","type":"string"},"to":{"default":"now","type":"string"}},"required":["display","from","to"],"type":"object"},"TimeSettingsSpec":{"additionalProperties":false,"description":"Time configuration\nIt defines the default time config for the time picker, the refresh picker for the specific dashboard.","properties":{"autoRefresh":{"default":"","description":"Refresh rate of dashboard. Represented via interval string, e.g. \"5s\", \"1m\", \"1h\", \"1d\".\nv1: refresh","type":"string"},"autoRefreshIntervals":{"default":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"description":"Interval options available in the refresh picker dropdown.\nv1: timepicker.refresh_intervals","items":{"type":"string"},"type":"array"},"fiscalYearStartMonth":{"default":0,"description":"The month that the fiscal year starts on. 0 = January, 11 = December","type":"integer"},"from":{"default":"now-6h","description":"Start time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"hideTimepicker":{"default":false,"description":"Whether timepicker is visible or not.\nv1: timepicker.hidden","type":"boolean"},"nowDelay":{"description":"Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.\nv1: timepicker.nowDelay","type":"string"},"quickRanges":{"description":"Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.\nv1: timepicker.quick_ranges , not exposed in the UI","items":{"$ref":"#/components/schemas/TimeRangeOption"},"type":"array"},"timezone":{"default":"browser","description":"Timezone of dashboard. Accepted values are IANA TZDB zone ID or \"browser\" or \"utc\".","type":"string"},"to":{"default":"now","description":"End time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"weekStart":{"description":"Day when the week starts. Expressed by the name of the day in lowercase, e.g. \"monday\".","enum":["saturday","monday","sunday"],"type":"string"}},"required":["from","to","autoRefresh","autoRefreshIntervals","hideTimepicker","fiscalYearStartMonth"],"type":"object"},"TransformationKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a TransformationKind is the transformation ID","type":"string"},"spec":{"$ref":"#/components/schemas/DataTransformerConfig"}},"required":["kind","spec"],"type":"object"},"ValueMap":{"additionalProperties":false,"description":"Maps text values to a color or different display text and color.\nFor example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.","properties":{"options":{"additionalProperties":{"$ref":"#/components/schemas/ValueMappingResult"},"description":"Map with \u003cvalue_to_match\u003e: ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }","type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"ValueMapping":{"oneOf":[{"$ref":"#/components/schemas/ValueMap"},{"$ref":"#/components/schemas/RangeMap"},{"$ref":"#/components/schemas/RegexMap"},{"$ref":"#/components/schemas/SpecialValueMap"}]},"ValueMappingResult":{"additionalProperties":false,"description":"Result used as replacement with text and color when the value matches","properties":{"color":{"description":"Text to use when the value matches","type":"string"},"icon":{"description":"Icon to display when the value matches. Only specific visualizations.","type":"string"},"index":{"description":"Position in the mapping array. Only used internally.","type":"integer"},"text":{"description":"Text to display when the value matches","type":"string"}},"type":"object"},"VariableHide":{"description":"Determine if the variable shows on dashboard\nAccepted values are ` + "`" + `dontHide` + "`" + ` (show label and value), ` + "`" + `hideLabel` + "`" + ` (show value only), ` + "`" + `hideVariable` + "`" + ` (show nothing).","enum":["dontHide","hideLabel","hideVariable"],"type":"string"},"VariableKind":{"oneOf":[{"$ref":"#/components/schemas/QueryVariableKind"},{"$ref":"#/components/schemas/TextVariableKind"},{"$ref":"#/components/schemas/ConstantVariableKind"},{"$ref":"#/components/schemas/DatasourceVariableKind"},{"$ref":"#/components/schemas/IntervalVariableKind"},{"$ref":"#/components/schemas/CustomVariableKind"},{"$ref":"#/components/schemas/GroupByVariableKind"},{"$ref":"#/components/schemas/AdhocVariableKind"},{"$ref":"#/components/schemas/SwitchVariableKind"}]},"VariableOption":{"additionalProperties":false,"description":"Variable option specification","properties":{"selected":{"description":"Whether the option is selected or not","type":"boolean"},"text":{"description":"Text to be displayed for the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]},"value":{"description":"Value of the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]}},"required":["text","value"],"type":"object"},"VariableRefresh":{"description":"Options to config when to refresh a variable\n` + "`" + `never` + "`" + `: Never refresh the variable\n` + "`" + `onDashboardLoad` + "`" + `: Queries the data source every time the dashboard loads.\n` + "`" + `onTimeRangeChanged` + "`" + `: Queries the data source when the dashboard time range changes.","enum":["never","onDashboardLoad","onTimeRangeChanged"],"type":"string"},"VariableRegexApplyTo":{"description":"Determine whether regex applies to variable value or display text\nAccepted values are ` + "`" + `value` + "`" + ` (apply to value used in queries) or ` + "`" + `text` + "`" + ` (apply to display text shown to users)","enum":["value","text"],"type":"string"},"VariableSort":{"description":"Sort variable options\nAccepted values are:\n` + "`" + `disabled` + "`" + `: No sorting\n` + "`" + `alphabeticalAsc` + "`" + `: Alphabetical ASC\n` + "`" + `alphabeticalDesc` + "`" + `: Alphabetical DESC\n` + "`" + `numericalAsc` + "`" + `: Numerical ASC\n` + "`" + `numericalDesc` + "`" + `: Numerical DESC\n` + "`" + `alphabeticalCaseInsensitiveAsc` + "`" + `: Alphabetical Case Insensitive ASC\n` + "`" + `alphabeticalCaseInsensitiveDesc` + "`" + `: Alphabetical Case Insensitive DESC\n` + "`" + `naturalAsc` + "`" + `: Natural ASC\n` + "`" + `naturalDesc` + "`" + `: Natural DESC\nVariableSort enum with default value","enum":["disabled","alphabeticalAsc","alphabeticalDesc","numericalAsc","numericalDesc","alphabeticalCaseInsensitiveAsc","alphabeticalCaseInsensitiveDesc","naturalAsc","naturalDesc"],"type":"string"},"VizConfigKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a VizConfigKind is the plugin ID","type":"string"},"spec":{"$ref":"#/components/schemas/VizConfigSpec"}},"required":["kind","spec"],"type":"object"},"VizConfigSpec":{"additionalProperties":false,"description":"--- Kinds ---","properties":{"fieldConfig":{"$ref":"#/components/schemas/FieldConfigSource"},"options":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"pluginVersion":{"type":"string"}},"required":["pluginVersion","options","fieldConfig"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"annotations":{"items":{"$ref":"#/components/schemas/AnnotationQueryKind"},"type":"array"},"cursorSync":{"$ref":"#/components/schemas/DashboardCursorSync","default":"Off","description":"Configuration of dashboard cursor sync behavior.\n\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip."},"description":{"description":"Description of dashboard.","type":"string"},"editable":{"default":true,"description":"Whether a dashboard is editable or not.","type":"boolean"},"elements":{"additionalProperties":{"$ref":"#/components/schemas/Element"},"type":"object"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"links":{"description":"Links with references to other dashboards or external websites.","items":{"$ref":"#/components/schemas/DashboardLink"},"type":"array"},"liveNow":{"description":"When set to true, the dashboard will redraw panels at an interval matching the pixel width.\nThis will keep data \"moving left\" regardless of the query refresh rate. This setting helps\navoid dashboards presenting stale live data.","type":"boolean"},"preload":{"default":false,"description":"When set to true, the dashboard will load all panels in the dashboard when it's loaded.","type":"boolean"},"revision":{"description":"Plugins only. The version of the dashboard installed together with the plugin.\nThis is used to determine if the dashboard should be updated when the plugin is updated.","type":"integer"},"tags":{"description":"Tags associated with dashboard.","items":{"type":"string"},"type":"array"},"timeSettings":{"$ref":"#/components/schemas/TimeSettingsSpec"},"title":{"description":"Title of dashboard.","type":"string"},"variables":{"description":"Configured template variables.","items":{"$ref":"#/components/schemas/VariableKind"},"type":"array"}},"required":["annotations","cursorSync","elements","layout","links","preload","tags","timeSettings","title","variables"],"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) versionSchemaDashboardv2alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaDashboardv2alpha1, &versionSchemaDashboardv2alpha1) - rawSchemaDashboardv2beta1 = []byte(`{"Action":{"additionalProperties":false,"properties":{"confirmation":{"type":"string"},"fetch":{"$ref":"#/components/schemas/FetchOptions"},"infinity":{"$ref":"#/components/schemas/InfinityOptions"},"oneClick":{"type":"boolean"},"style":{"additionalProperties":false,"properties":{"backgroundColor":{"type":"string"}},"type":"object"},"title":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionType"},"variables":{"items":{"$ref":"#/components/schemas/ActionVariable"},"type":"array"}},"required":["type","title"],"type":"object"},"ActionType":{"enum":["fetch","infinity"],"type":"string"},"ActionVariable":{"additionalProperties":false,"properties":{"key":{"type":"string"},"name":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionVariableType"}},"required":["key","name","type"],"type":"object"},"ActionVariableType":{"const":"string","description":"Action variable type","type":"string"},"AdHocFilterWithLabels":{"additionalProperties":false,"description":"Define the AdHocFilterWithLabels type","properties":{"condition":{"description":"@deprecated","type":"string"},"forceEdit":{"type":"boolean"},"key":{"type":"string"},"keyLabel":{"type":"string"},"operator":{"type":"string"},"origin":{"$ref":"#/components/schemas/FilterOrigin"},"value":{"type":"string"},"valueLabels":{"items":{"type":"string"},"type":"array"},"values":{"items":{"type":"string"},"type":"array"}},"required":["key","operator","value"],"type":"object"},"AdhocVariableKind":{"additionalProperties":false,"description":"Adhoc variable kind","properties":{"datasource":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"AdhocVariable","type":"string"},"spec":{"$ref":"#/components/schemas/AdhocVariableSpec"}},"required":["kind","group","spec"],"type":"object"},"AdhocVariableSpec":{"additionalProperties":false,"description":"Adhoc variable specification","properties":{"allowCustomValue":{"default":true,"type":"boolean"},"baseFilters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"defaultKeys":{"items":{"$ref":"#/components/schemas/MetricFindValue"},"type":"array"},"description":{"type":"string"},"filters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","baseFilters","filters","defaultKeys","hide","skipUrlSync","allowCustomValue"],"type":"object"},"AnnotationEventFieldMapping":{"additionalProperties":false,"description":"Annotation event field mapping. Defines how to map a data frame field to an annotation event field.","properties":{"regex":{"description":"Regular expression to apply to the field value","type":"string"},"source":{"default":"field","description":"Source type for the field value","type":"string"},"value":{"description":"Constant value to use when source is \"text\"","type":"string"}},"type":"object"},"AnnotationPanelFilter":{"additionalProperties":false,"properties":{"exclude":{"default":false,"description":"Should the specified panels be included or excluded","type":"boolean"},"ids":{"description":"Panel IDs that should be included or excluded","items":{"type":"integer"},"type":"array"}},"required":["ids"],"type":"object"},"AnnotationQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"AnnotationQuery","type":"string"},"spec":{"$ref":"#/components/schemas/AnnotationQuerySpec"}},"required":["kind","spec"],"type":"object"},"AnnotationQueryPlacement":{"const":"inControlsMenu","description":"Annotation Query placement. Defines where the annotation query should be displayed.\n- \"inControlsMenu\" renders the annotation query in the dashboard controls dropdown menu","type":"string"},"AnnotationQuerySpec":{"additionalProperties":false,"properties":{"builtIn":{"default":false,"type":"boolean"},"enable":{"type":"boolean"},"filter":{"$ref":"#/components/schemas/AnnotationPanelFilter"},"hide":{"type":"boolean"},"iconColor":{"type":"string"},"legacyOptions":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"Catch-all field for datasource-specific properties. Should not be available in as code tooling.","type":"object"},"mappings":{"additionalProperties":{"$ref":"#/components/schemas/AnnotationEventFieldMapping"},"description":"Mappings define how to convert data frame fields to annotation event fields.","type":"object"},"name":{"type":"string"},"placement":{"$ref":"#/components/schemas/AnnotationQueryPlacement","description":"Placement can be used to display the annotation query somewhere else on the dashboard other than the default location."},"query":{"$ref":"#/components/schemas/DataQueryKind"}},"required":["query","enable","hide","iconColor","name"],"type":"object"},"AutoGridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutItemSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"element":{"$ref":"#/components/schemas/ElementReference"},"repeat":{"$ref":"#/components/schemas/AutoGridRepeatOptions"}},"required":["element"],"type":"object"},"AutoGridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutSpec":{"additionalProperties":false,"properties":{"columnWidth":{"type":"number"},"columnWidthMode":{"default":"standard","enum":["narrow","standard","wide","custom"],"type":"string"},"fillScreen":{"default":false,"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/AutoGridLayoutItemKind"},"type":"array"},"maxColumnCount":{"default":3,"type":"number"},"rowHeight":{"type":"number"},"rowHeightMode":{"default":"standard","enum":["short","standard","tall","custom"],"type":"string"}},"required":["columnWidthMode","rowHeightMode","items"],"type":"object"},"AutoGridRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"ConditionalRenderingDataKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingData","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingDataSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingDataSpec":{"additionalProperties":false,"properties":{"value":{"type":"boolean"}},"required":["value"],"type":"object"},"ConditionalRenderingGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingGroup","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingGroupSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingGroupSpec":{"additionalProperties":false,"properties":{"condition":{"enum":["and","or"],"type":"string"},"items":{"items":{"oneOf":[{"$ref":"#/components/schemas/ConditionalRenderingVariableKind"},{"$ref":"#/components/schemas/ConditionalRenderingDataKind"},{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeKind"}]},"type":"array"},"visibility":{"enum":["show","hide"],"type":"string"}},"required":["visibility","condition","items"],"type":"object"},"ConditionalRenderingTimeRangeSizeKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingTimeRangeSize","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingTimeRangeSizeSpec":{"additionalProperties":false,"properties":{"value":{"type":"string"}},"required":["value"],"type":"object"},"ConditionalRenderingVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingVariableSpec":{"additionalProperties":false,"properties":{"operator":{"enum":["equals","notEquals","matches","notMatches"],"type":"string"},"value":{"type":"string"},"variable":{"type":"string"}},"required":["variable","operator","value"],"type":"object"},"ConstantVariableKind":{"additionalProperties":false,"description":"Constant variable kind","properties":{"kind":{"const":"ConstantVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConstantVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConstantVariableSpec":{"additionalProperties":false,"description":"Constant variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","hide","skipUrlSync"],"type":"object"},"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"CustomVariableKind":{"additionalProperties":false,"description":"Custom variable kind","properties":{"kind":{"const":"CustomVariable","type":"string"},"spec":{"$ref":"#/components/schemas/CustomVariableSpec"}},"required":["kind","spec"],"type":"object"},"CustomVariableSpec":{"additionalProperties":false,"description":"Custom variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"DashboardCursorSync":{"description":"\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip.","enum":["Crosshair","Tooltip","Off"],"type":"string"},"DashboardLink":{"additionalProperties":false,"description":"Links with references to other dashboards or external resources","properties":{"asDropdown":{"default":false,"description":"If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards","type":"boolean"},"icon":{"description":"Icon name to be displayed with the link","type":"string"},"includeVars":{"default":false,"description":"If true, includes current template variables values in the link as query params","type":"boolean"},"keepTime":{"default":false,"description":"If true, includes current time range in the link as query params","type":"boolean"},"placement":{"$ref":"#/components/schemas/DashboardLinkPlacement","description":"Placement can be used to display the link somewhere else on the dashboard other than above the visualisations."},"tags":{"description":"List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards","items":{"type":"string"},"type":"array"},"targetBlank":{"default":false,"description":"If true, the link will be opened in a new tab","type":"boolean"},"title":{"description":"Title to display with the link","type":"string"},"tooltip":{"description":"Tooltip to display when the user hovers their mouse over it","type":"string"},"type":{"$ref":"#/components/schemas/DashboardLinkType","description":"Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)\nFIXME: The type is generated as ` + "`" + `type: DashboardLinkType | dashboardLinkType.Link;` + "`" + ` but it should be ` + "`" + `type: DashboardLinkType` + "`" + `"},"url":{"description":"Link URL. Only required/valid if the type is link","type":"string"}},"required":["title","type","icon","tooltip","tags","asDropdown","targetBlank","includeVars","keepTime"],"type":"object"},"DashboardLinkPlacement":{"const":"inControlsMenu","description":"Dashboard Link placement. Defines where the link should be displayed.\n- \"inControlsMenu\" renders the link in bottom part of the dashboard controls dropdown menu","type":"string"},"DashboardLinkType":{"description":"Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)","enum":["link","dashboards"],"type":"string"},"DataLink":{"additionalProperties":false,"properties":{"targetBlank":{"type":"boolean"},"title":{"type":"string"},"url":{"type":"string"}},"required":["title","url"],"type":"object"},"DataQueryKind":{"additionalProperties":false,"properties":{"datasource":{"additionalProperties":false,"description":"New type for datasource reference\nNot creating a new type until we figure out how to handle DS refs for group by, adhoc, and every place that uses DataSourceRef in TS.","properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"DataQuery","type":"string"},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"version":{"default":"v0","type":"string"}},"required":["kind","group","version","spec"],"type":"object"},"DataTopic":{"description":"A topic is attached to DataFrame metadata in query results.\nThis specifies where the data should be used.","enum":["series","annotations","alertStates"],"type":"string"},"DataTransformerConfig":{"additionalProperties":false,"description":"Transformations allow to manipulate data returned by a query before the system applies a visualization.\nUsing transformations you can: rename fields, join time series data, perform mathematical operations across queries,\nuse the output of one transformation as the input to another transformation, etc.","properties":{"disabled":{"description":"Disabled transformations are skipped","type":"boolean"},"filter":{"$ref":"#/components/schemas/MatcherConfig","description":"Optional frame matcher. When missing it will be applied to all results"},"id":{"description":"Unique identifier of transformer","type":"string"},"options":{"additionalProperties":{},"description":"Options to be passed to the transformer\nValid options depend on the transformer id","type":"object"},"topic":{"$ref":"#/components/schemas/DataTopic","description":"Where to pull DataFrames from as input to transformation"}},"required":["id","options"],"type":"object"},"DatasourceVariableKind":{"additionalProperties":false,"description":"Datasource variable kind","properties":{"kind":{"const":"DatasourceVariable","type":"string"},"spec":{"$ref":"#/components/schemas/DatasourceVariableSpec"}},"required":["kind","spec"],"type":"object"},"DatasourceVariableSpec":{"additionalProperties":false,"description":"Datasource variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"pluginId":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","pluginId","refresh","regex","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"DynamicConfigValue":{"additionalProperties":false,"properties":{"id":{"default":"","type":"string"},"value":{"additionalProperties":{},"type":"object"}},"required":["id"],"type":"object"},"Element":{"description":"Supported dashboard elements\n|* more element types in the future","oneOf":[{"$ref":"#/components/schemas/PanelKind"},{"$ref":"#/components/schemas/LibraryPanelKind"}]},"ElementReference":{"additionalProperties":false,"properties":{"kind":{"const":"ElementReference","type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"},"FetchOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url"],"type":"object"},"FieldColor":{"additionalProperties":false,"description":"Map a field to a color.","properties":{"fixedColor":{"description":"The fixed color value for fixed or shades color modes.","type":"string"},"mode":{"$ref":"#/components/schemas/FieldColorModeId","description":"The main color scheme mode."},"seriesBy":{"$ref":"#/components/schemas/FieldColorSeriesByMode","description":"Some visualizations need to know how to assign a series color from by value color schemes."}},"required":["mode"],"type":"object"},"FieldColorModeId":{"description":"Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.\nContinuous color interpolates a color using the percentage of a value relative to min and max.\nAccepted values are:\n` + "`" + `thresholds` + "`" + `: From thresholds. Informs Grafana to take the color from the matching threshold\n` + "`" + `palette-classic` + "`" + `: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `palette-classic-by-name` + "`" + `: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `continuous-viridis` + "`" + `: Continuous Viridis palette mode\n` + "`" + `continuous-magma` + "`" + `: Continuous Magma palette mode\n` + "`" + `continuous-plasma` + "`" + `: Continuous Plasma palette mode\n` + "`" + `continuous-inferno` + "`" + `: Continuous Inferno palette mode\n` + "`" + `continuous-cividis` + "`" + `: Continuous Cividis palette mode\n` + "`" + `continuous-GrYlRd` + "`" + `: Continuous Green-Yellow-Red palette mode\n` + "`" + `continuous-RdYlGr` + "`" + `: Continuous Red-Yellow-Green palette mode\n` + "`" + `continuous-BlYlRd` + "`" + `: Continuous Blue-Yellow-Red palette mode\n` + "`" + `continuous-YlRd` + "`" + `: Continuous Yellow-Red palette mode\n` + "`" + `continuous-BlPu` + "`" + `: Continuous Blue-Purple palette mode\n` + "`" + `continuous-YlBl` + "`" + `: Continuous Yellow-Blue palette mode\n` + "`" + `continuous-blues` + "`" + `: Continuous Blue palette mode\n` + "`" + `continuous-reds` + "`" + `: Continuous Red palette mode\n` + "`" + `continuous-greens` + "`" + `: Continuous Green palette mode\n` + "`" + `continuous-purples` + "`" + `: Continuous Purple palette mode\n` + "`" + `shades` + "`" + `: Shades of a single color. Specify a single color, useful in an override rule.\n` + "`" + `fixed` + "`" + `: Fixed color mode. Specify a single color, useful in an override rule.","enum":["thresholds","palette-classic","palette-classic-by-name","continuous-viridis","continuous-magma","continuous-plasma","continuous-inferno","continuous-cividis","continuous-GrYlRd","continuous-RdYlGr","continuous-BlYlRd","continuous-YlRd","continuous-BlPu","continuous-YlBl","continuous-blues","continuous-reds","continuous-greens","continuous-purples","fixed","shades"],"type":"string"},"FieldColorSeriesByMode":{"description":"Defines how to assign a series color from \"by value\" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.","enum":["min","max","last"],"type":"string"},"FieldConfig":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"actions":{"description":"Define interactive HTTP requests that can be triggered from data visualizations.","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"color":{"$ref":"#/components/schemas/FieldColor","description":"Panel color configuration"},"custom":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"custom is specified by the FieldConfig field\nin panel plugin schemas.","type":"object"},"decimals":{"description":"Specify the number of decimals Grafana includes in the rendered value.\nIf you leave this field blank, Grafana automatically truncates the number of decimals based on the value.\nFor example 1.1234 will display as 1.12 and 100.456 will display as 100.\nTo display all decimals, set the unit to ` + "`" + `String` + "`" + `.","type":"number"},"description":{"description":"Human readable field metadata","type":"string"},"displayName":{"description":"The display value for this field. This supports template variables blank is auto","type":"string"},"displayNameFromDS":{"description":"This can be used by data sources that return and explicit naming structure for values and labels\nWhen this property is configured, this value is used rather than the default naming strategy.","type":"string"},"filterable":{"description":"True if data source field supports ad-hoc filters","type":"boolean"},"links":{"description":"The behavior when clicking on a result","items":{"additionalProperties":{},"type":"object"},"type":"array"},"mappings":{"description":"Convert input values into a display string","items":{"$ref":"#/components/schemas/ValueMapping"},"type":"array"},"max":{"description":"The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"min":{"description":"The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"noValue":{"description":"Alternative to empty string","type":"string"},"path":{"description":"An explicit path to the field in the datasource. When the frame meta includes a path,\nThis will default to ` + "`" + `${frame.meta.path}/${field.name}\n\nWhen defined, this value can be used as an identifier within the datasource scope, and\nmay be used to update the results","type":"string"},"thresholds":{"$ref":"#/components/schemas/ThresholdsConfig","description":"Map numeric values to states"},"unit":{"description":"Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n` + "`" + `suffix:\u003csuffix\u003e` + "`" + ` for custom unit that should go after value.\n` + "`" + `prefix:\u003cprefix\u003e` + "`" + ` for custom unit that should go before value.\n` + "`" + `time:\u003cformat\u003e` + "`" + ` For custom date time formats type for example ` + "`" + `time:YYYY-MM-DD` + "`" + `.\n` + "`" + `si:\u003cbase scale\u003e\u003cunit characters\u003e` + "`" + ` for custom SI units. For example: ` + "`" + `si: mF` + "`" + `. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n` + "`" + `count:\u003cunit\u003e` + "`" + ` for a custom count unit.\n` + "`" + `currency:\u003cunit\u003e` + "`" + ` for custom a currency unit.","type":"string"},"writeable":{"description":"True if data source can write a value to the path. Auth/authz are supported separately","type":"boolean"}},"type":"object"},"FieldConfigSource":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"defaults":{"$ref":"#/components/schemas/FieldConfig","description":"Defaults are the options applied to all fields."},"overrides":{"description":"Overrides are the options applied to specific fields overriding the defaults.","items":{"additionalProperties":false,"properties":{"__systemRef":{"description":"Describes config override rules created when interacting with Grafana.","type":"string"},"matcher":{"$ref":"#/components/schemas/MatcherConfig"},"properties":{"items":{"$ref":"#/components/schemas/DynamicConfigValue"},"type":"array"}},"required":["matcher","properties"],"type":"object"},"type":"array"}},"required":["defaults","overrides"],"type":"object"},"FilterOrigin":{"const":"dashboard","description":"Determine the origin of the adhoc variable filter","type":"string"},"GridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutItemSpec":{"additionalProperties":false,"properties":{"element":{"$ref":"#/components/schemas/ElementReference","description":"reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference"},"height":{"type":"integer"},"repeat":{"$ref":"#/components/schemas/RepeatOptions"},"width":{"type":"integer"},"x":{"type":"integer"},"y":{"type":"integer"}},"required":["x","y","width","height","element"],"type":"object"},"GridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutSpec":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/GridLayoutItemKind"},"type":"array"}},"required":["items"],"type":"object"},"GroupByVariableKind":{"additionalProperties":false,"description":"Group variable kind","properties":{"datasource":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"GroupByVariable","type":"string"},"spec":{"$ref":"#/components/schemas/GroupByVariableSpec"}},"required":["kind","group","spec"],"type":"object"},"GroupByVariableSpec":{"additionalProperties":false,"description":"GroupBy variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"defaultValue":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","options","multi","hide","skipUrlSync"],"type":"object"},"HttpRequestMethod":{"enum":["GET","PUT","POST","DELETE","PATCH"],"type":"string"},"InfinityOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"datasourceUid":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url","datasourceUid"],"type":"object"},"IntervalVariableKind":{"additionalProperties":false,"description":"Interval variable kind","properties":{"kind":{"const":"IntervalVariable","type":"string"},"spec":{"$ref":"#/components/schemas/IntervalVariableSpec"}},"required":["kind","spec"],"type":"object"},"IntervalVariableSpec":{"additionalProperties":false,"description":"Interval variable specification","properties":{"auto":{"default":false,"type":"boolean"},"auto_count":{"default":0,"type":"integer"},"auto_min":{"default":"","type":"string"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"refresh":{"const":"onTimeRangeChanged","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","auto","auto_min","auto_count","refresh","hide","skipUrlSync"],"type":"object"},"LibraryPanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"LibraryPanel","type":"string"},"spec":{"$ref":"#/components/schemas/LibraryPanelKindSpec"}},"required":["kind","spec"],"type":"object"},"LibraryPanelKindSpec":{"additionalProperties":false,"properties":{"id":{"description":"Panel ID for the library panel in the dashboard","type":"number"},"libraryPanel":{"$ref":"#/components/schemas/LibraryPanelRef"},"title":{"description":"Title for the library panel in the dashboard","type":"string"}},"required":["id","title","libraryPanel"],"type":"object"},"LibraryPanelRef":{"additionalProperties":false,"description":"A library panel is a reusable panel that you can use in any dashboard.\nWhen you make a change to a library panel, that change propagates to all instances of where the panel is used.\nLibrary panels streamline reuse of panels across multiple dashboards.","properties":{"name":{"description":"Library panel name","type":"string"},"uid":{"description":"Library panel uid","type":"string"}},"required":["name","uid"],"type":"object"},"MappingType":{"description":"Supported value mapping types\n` + "`" + `value` + "`" + `: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.\n` + "`" + `range` + "`" + `: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.\n` + "`" + `regex` + "`" + `: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.\n` + "`" + `special` + "`" + `: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.","enum":["value","range","regex","special"],"type":"string"},"MatcherConfig":{"additionalProperties":false,"description":"Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.\nIt comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.","properties":{"id":{"default":"","description":"The matcher id. This is used to find the matcher implementation from registry.","type":"string"},"options":{"additionalProperties":{},"description":"The matcher options. This is specific to the matcher implementation.","type":"object"}},"required":["id"],"type":"object"},"MetricFindValue":{"additionalProperties":false,"description":"Define the MetricFindValue type","properties":{"expandable":{"type":"boolean"},"group":{"type":"string"},"text":{"type":"string"},"value":{"oneOf":[{"type":"string"},{"type":"number"}]}},"required":["text"],"type":"object"},"PanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"Panel","type":"string"},"spec":{"$ref":"#/components/schemas/PanelSpec"}},"required":["kind","spec"],"type":"object"},"PanelQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"PanelQuery","type":"string"},"spec":{"$ref":"#/components/schemas/PanelQuerySpec"}},"required":["kind","spec"],"type":"object"},"PanelQuerySpec":{"additionalProperties":false,"properties":{"hidden":{"type":"boolean"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refId":{"default":"A","type":"string"}},"required":["query","refId","hidden"],"type":"object"},"PanelSpec":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QueryGroupKind"},"description":{"type":"string"},"id":{"type":"number"},"links":{"items":{"$ref":"#/components/schemas/DataLink"},"type":"array"},"title":{"type":"string"},"transparent":{"type":"boolean"},"vizConfig":{"$ref":"#/components/schemas/VizConfigKind"}},"required":["id","title","description","links","data","vizConfig"],"type":"object"},"QueryGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"QueryGroup","type":"string"},"spec":{"$ref":"#/components/schemas/QueryGroupSpec"}},"required":["kind","spec"],"type":"object"},"QueryGroupSpec":{"additionalProperties":false,"properties":{"queries":{"items":{"$ref":"#/components/schemas/PanelQueryKind"},"type":"array"},"queryOptions":{"$ref":"#/components/schemas/QueryOptionsSpec"},"transformations":{"items":{"$ref":"#/components/schemas/TransformationKind"},"type":"array"}},"required":["queries","transformations","queryOptions"],"type":"object"},"QueryOptionsSpec":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"string"},"hideTimeOverride":{"type":"boolean"},"interval":{"type":"string"},"maxDataPoints":{"type":"integer"},"queryCachingTTL":{"type":"integer"},"timeCompare":{"type":"string"},"timeFrom":{"type":"string"},"timeShift":{"type":"string"}},"type":"object"},"QueryVariableKind":{"additionalProperties":false,"description":"Query variable kind","properties":{"kind":{"const":"QueryVariable","type":"string"},"spec":{"$ref":"#/components/schemas/QueryVariableSpec"}},"required":["kind","spec"],"type":"object"},"QueryVariableSpec":{"additionalProperties":false,"description":"Query variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"definition":{"type":"string"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"placeholder":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"regexApplyTo":{"$ref":"#/components/schemas/VariableRegexApplyTo","default":"value"},"skipUrlSync":{"default":false,"type":"boolean"},"sort":{"$ref":"#/components/schemas/VariableSort"},"staticOptions":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"staticOptionsOrder":{"enum":["before","after","sorted"],"type":"string"}},"required":["name","current","hide","refresh","skipUrlSync","query","regex","sort","options","multi","includeAll","allowCustomValue"],"type":"object"},"RangeMap":{"additionalProperties":false,"description":"Maps numerical ranges to a display text and color.\nFor example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.","properties":{"options":{"additionalProperties":false,"description":"Range to match against and the result to apply when the value is within the range","properties":{"from":{"description":"Min value of the range. It can be null which means -Infinity","type":"number"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value is within the range"},"to":{"description":"Max value of the range. It can be null which means +Infinity","type":"number"}},"required":["from","to","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RegexMap":{"additionalProperties":false,"description":"Maps regular expressions to replacement text and a color.\nFor example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.","properties":{"options":{"additionalProperties":false,"description":"Regular expression to match against and the result to apply when the value matches the regex","properties":{"pattern":{"description":"Regular expression to match against","type":"string"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the regex"}},"required":["pattern","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RepeatMode":{"const":"variable","description":"other repeat modes will be added in the future: label, frame","type":"string"},"RepeatOptions":{"additionalProperties":false,"properties":{"direction":{"enum":["h","v"],"type":"string"},"maxPerRow":{"type":"integer"},"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayoutRow","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutRowSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowSpec":{"additionalProperties":false,"properties":{"collapse":{"type":"boolean"},"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"fillScreen":{"type":"boolean"},"hideHeader":{"type":"boolean"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/RowRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"RowsLayoutSpec":{"additionalProperties":false,"properties":{"rows":{"items":{"$ref":"#/components/schemas/RowsLayoutRowKind"},"type":"array"}},"required":["rows"],"type":"object"},"SpecialValueMap":{"additionalProperties":false,"description":"Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color.\nSee SpecialValueMatch to see the list of special values.\nFor example, you can configure a special value mapping so that null values appear as N/A.","properties":{"options":{"additionalProperties":false,"properties":{"match":{"$ref":"#/components/schemas/SpecialValueMatch","description":"Special value to match against"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the special value"}},"required":["match","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"SpecialValueMatch":{"description":"Special value types supported by the ` + "`" + `SpecialValueMap` + "`" + `","enum":["true","false","null","nan","null+nan","empty"],"type":"string"},"SwitchVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"SwitchVariable","type":"string"},"spec":{"$ref":"#/components/schemas/SwitchVariableSpec"}},"required":["kind","spec"],"type":"object"},"SwitchVariableSpec":{"additionalProperties":false,"properties":{"current":{"default":"false","type":"string"},"description":{"type":"string"},"disabledValue":{"default":"false","type":"string"},"enabledValue":{"default":"true","type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","enabledValue","disabledValue","hide","skipUrlSync"],"type":"object"},"TabRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"TabsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutSpec":{"additionalProperties":false,"properties":{"tabs":{"items":{"$ref":"#/components/schemas/TabsLayoutTabKind"},"type":"array"}},"required":["tabs"],"type":"object"},"TabsLayoutTabKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayoutTab","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutTabSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutTabSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/TabRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"TextVariableKind":{"additionalProperties":false,"description":"Text variable kind","properties":{"kind":{"const":"TextVariable","type":"string"},"spec":{"$ref":"#/components/schemas/TextVariableSpec"}},"required":["kind","spec"],"type":"object"},"TextVariableSpec":{"additionalProperties":false,"description":"Text variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","query","hide","skipUrlSync"],"type":"object"},"Threshold":{"additionalProperties":false,"properties":{"color":{"type":"string"},"value":{"description":"Value null means -Infinity","type":"number"}},"required":["value","color"],"type":"object"},"ThresholdsConfig":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/ThresholdsMode"},"steps":{"items":{"$ref":"#/components/schemas/Threshold"},"type":"array"}},"required":["mode","steps"],"type":"object"},"ThresholdsMode":{"enum":["absolute","percentage"],"type":"string"},"TimeRangeOption":{"additionalProperties":false,"properties":{"display":{"default":"Last 6 hours","type":"string"},"from":{"default":"now-6h","type":"string"},"to":{"default":"now","type":"string"}},"required":["display","from","to"],"type":"object"},"TimeSettingsSpec":{"additionalProperties":false,"description":"Time configuration\nIt defines the default time config for the time picker, the refresh picker for the specific dashboard.","properties":{"autoRefresh":{"default":"","description":"Refresh rate of dashboard. Represented via interval string, e.g. \"5s\", \"1m\", \"1h\", \"1d\".\nv1: refresh","type":"string"},"autoRefreshIntervals":{"default":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"description":"Interval options available in the refresh picker dropdown.\nv1: timepicker.refresh_intervals","items":{"type":"string"},"type":"array"},"fiscalYearStartMonth":{"default":0,"description":"The month that the fiscal year starts on. 0 = January, 11 = December","type":"integer"},"from":{"default":"now-6h","description":"Start time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"hideTimepicker":{"default":false,"description":"Whether timepicker is visible or not.\nv1: timepicker.hidden","type":"boolean"},"nowDelay":{"description":"Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.\nv1: timepicker.nowDelay","type":"string"},"quickRanges":{"description":"Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.\nv1: timepicker.quick_ranges , not exposed in the UI","items":{"$ref":"#/components/schemas/TimeRangeOption"},"type":"array"},"timezone":{"default":"browser","description":"Timezone of dashboard. Accepted values are IANA TZDB zone ID or \"browser\" or \"utc\".","type":"string"},"to":{"default":"now","description":"End time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"weekStart":{"description":"Day when the week starts. Expressed by the name of the day in lowercase, e.g. \"monday\".","enum":["saturday","monday","sunday"],"type":"string"}},"required":["from","to","autoRefresh","autoRefreshIntervals","hideTimepicker","fiscalYearStartMonth"],"type":"object"},"TransformationKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a TransformationKind is the transformation ID","type":"string"},"spec":{"$ref":"#/components/schemas/DataTransformerConfig"}},"required":["kind","spec"],"type":"object"},"ValueMap":{"additionalProperties":false,"description":"Maps text values to a color or different display text and color.\nFor example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.","properties":{"options":{"additionalProperties":{"$ref":"#/components/schemas/ValueMappingResult"},"description":"Map with \u003cvalue_to_match\u003e: ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }","type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"ValueMapping":{"oneOf":[{"$ref":"#/components/schemas/ValueMap"},{"$ref":"#/components/schemas/RangeMap"},{"$ref":"#/components/schemas/RegexMap"},{"$ref":"#/components/schemas/SpecialValueMap"}]},"ValueMappingResult":{"additionalProperties":false,"description":"Result used as replacement with text and color when the value matches","properties":{"color":{"description":"Text to use when the value matches","type":"string"},"icon":{"description":"Icon to display when the value matches. Only specific visualizations.","type":"string"},"index":{"description":"Position in the mapping array. Only used internally.","type":"integer"},"text":{"description":"Text to display when the value matches","type":"string"}},"type":"object"},"VariableHide":{"description":"Determine if the variable shows on dashboard\nAccepted values are ` + "`" + `dontHide` + "`" + ` (show label and value), ` + "`" + `hideLabel` + "`" + ` (show value only), ` + "`" + `hideVariable` + "`" + ` (show nothing), ` + "`" + `inControlsMenu` + "`" + ` (show in a drop-down menu).","enum":["dontHide","hideLabel","hideVariable","inControlsMenu"],"type":"string"},"VariableKind":{"oneOf":[{"$ref":"#/components/schemas/QueryVariableKind"},{"$ref":"#/components/schemas/TextVariableKind"},{"$ref":"#/components/schemas/ConstantVariableKind"},{"$ref":"#/components/schemas/DatasourceVariableKind"},{"$ref":"#/components/schemas/IntervalVariableKind"},{"$ref":"#/components/schemas/CustomVariableKind"},{"$ref":"#/components/schemas/GroupByVariableKind"},{"$ref":"#/components/schemas/AdhocVariableKind"},{"$ref":"#/components/schemas/SwitchVariableKind"}]},"VariableOption":{"additionalProperties":false,"description":"Variable option specification","properties":{"selected":{"description":"Whether the option is selected or not","type":"boolean"},"text":{"description":"Text to be displayed for the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]},"value":{"description":"Value of the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]}},"required":["text","value"],"type":"object"},"VariableRefresh":{"description":"Options to config when to refresh a variable\n` + "`" + `never` + "`" + `: Never refresh the variable\n` + "`" + `onDashboardLoad` + "`" + `: Queries the data source every time the dashboard loads.\n` + "`" + `onTimeRangeChanged` + "`" + `: Queries the data source when the dashboard time range changes.","enum":["never","onDashboardLoad","onTimeRangeChanged"],"type":"string"},"VariableRegexApplyTo":{"description":"Determine whether regex applies to variable value or display text\nAccepted values are ` + "`" + `value` + "`" + ` (apply to value used in queries) or ` + "`" + `text` + "`" + ` (apply to display text shown to users)","enum":["value","text"],"type":"string"},"VariableSort":{"description":"Sort variable options\nAccepted values are:\n` + "`" + `disabled` + "`" + `: No sorting\n` + "`" + `alphabeticalAsc` + "`" + `: Alphabetical ASC\n` + "`" + `alphabeticalDesc` + "`" + `: Alphabetical DESC\n` + "`" + `numericalAsc` + "`" + `: Numerical ASC\n` + "`" + `numericalDesc` + "`" + `: Numerical DESC\n` + "`" + `alphabeticalCaseInsensitiveAsc` + "`" + `: Alphabetical Case Insensitive ASC\n` + "`" + `alphabeticalCaseInsensitiveDesc` + "`" + `: Alphabetical Case Insensitive DESC\n` + "`" + `naturalAsc` + "`" + `: Natural ASC\n` + "`" + `naturalDesc` + "`" + `: Natural DESC\nVariableSort enum with default value","enum":["disabled","alphabeticalAsc","alphabeticalDesc","numericalAsc","numericalDesc","alphabeticalCaseInsensitiveAsc","alphabeticalCaseInsensitiveDesc","naturalAsc","naturalDesc"],"type":"string"},"VizConfigKind":{"additionalProperties":false,"properties":{"group":{"description":"The group is the plugin ID","type":"string"},"kind":{"const":"VizConfig","type":"string"},"spec":{"$ref":"#/components/schemas/VizConfigSpec"},"version":{"type":"string"}},"required":["kind","group","version","spec"],"type":"object"},"VizConfigSpec":{"additionalProperties":false,"description":"--- Kinds ---","properties":{"fieldConfig":{"$ref":"#/components/schemas/FieldConfigSource"},"options":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"}},"required":["options","fieldConfig"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"annotations":{"items":{"$ref":"#/components/schemas/AnnotationQueryKind"},"type":"array"},"cursorSync":{"$ref":"#/components/schemas/DashboardCursorSync","default":"Off","description":"Configuration of dashboard cursor sync behavior.\n\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip."},"description":{"description":"Description of dashboard.","type":"string"},"editable":{"default":true,"description":"Whether a dashboard is editable or not.","type":"boolean"},"elements":{"additionalProperties":{"$ref":"#/components/schemas/Element"},"type":"object"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"links":{"description":"Links with references to other dashboards or external websites.","items":{"$ref":"#/components/schemas/DashboardLink"},"type":"array"},"liveNow":{"description":"When set to true, the dashboard will redraw panels at an interval matching the pixel width.\nThis will keep data \"moving left\" regardless of the query refresh rate. This setting helps\navoid dashboards presenting stale live data.","type":"boolean"},"preload":{"default":false,"description":"When set to true, the dashboard will load all panels in the dashboard when it's loaded.","type":"boolean"},"revision":{"description":"Plugins only. The version of the dashboard installed together with the plugin.\nThis is used to determine if the dashboard should be updated when the plugin is updated.","type":"integer"},"tags":{"description":"Tags associated with dashboard.","items":{"type":"string"},"type":"array"},"timeSettings":{"$ref":"#/components/schemas/TimeSettingsSpec"},"title":{"description":"Title of dashboard.","type":"string"},"variables":{"description":"Configured template variables.","items":{"$ref":"#/components/schemas/VariableKind"},"type":"array"}},"required":["annotations","cursorSync","elements","layout","links","preload","tags","timeSettings","title","variables"],"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) + rawSchemaDashboardv2beta1 = []byte(`{"Action":{"additionalProperties":false,"properties":{"confirmation":{"type":"string"},"fetch":{"$ref":"#/components/schemas/FetchOptions"},"infinity":{"$ref":"#/components/schemas/InfinityOptions"},"oneClick":{"type":"boolean"},"style":{"additionalProperties":false,"properties":{"backgroundColor":{"type":"string"}},"type":"object"},"title":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionType"},"variables":{"items":{"$ref":"#/components/schemas/ActionVariable"},"type":"array"}},"required":["type","title"],"type":"object"},"ActionType":{"enum":["fetch","infinity"],"type":"string"},"ActionVariable":{"additionalProperties":false,"properties":{"key":{"type":"string"},"name":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionVariableType"}},"required":["key","name","type"],"type":"object"},"ActionVariableType":{"const":"string","description":"Action variable type","type":"string"},"AdHocFilterWithLabels":{"additionalProperties":false,"description":"Define the AdHocFilterWithLabels type","properties":{"condition":{"description":"@deprecated","type":"string"},"forceEdit":{"type":"boolean"},"key":{"type":"string"},"keyLabel":{"type":"string"},"operator":{"type":"string"},"origin":{"$ref":"#/components/schemas/FilterOrigin"},"value":{"type":"string"},"valueLabels":{"items":{"type":"string"},"type":"array"},"values":{"items":{"type":"string"},"type":"array"}},"required":["key","operator","value"],"type":"object"},"AdhocVariableKind":{"additionalProperties":false,"description":"Adhoc variable kind","properties":{"datasource":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"AdhocVariable","type":"string"},"spec":{"$ref":"#/components/schemas/AdhocVariableSpec"}},"required":["kind","group","spec"],"type":"object"},"AdhocVariableSpec":{"additionalProperties":false,"description":"Adhoc variable specification","properties":{"allowCustomValue":{"default":true,"type":"boolean"},"baseFilters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"defaultKeys":{"items":{"$ref":"#/components/schemas/MetricFindValue"},"type":"array"},"description":{"type":"string"},"filters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","baseFilters","filters","defaultKeys","hide","skipUrlSync","allowCustomValue"],"type":"object"},"AnnotationEventFieldMapping":{"additionalProperties":false,"description":"Annotation event field mapping. Defines how to map a data frame field to an annotation event field.","properties":{"regex":{"description":"Regular expression to apply to the field value","type":"string"},"source":{"default":"field","description":"Source type for the field value","type":"string"},"value":{"description":"Constant value to use when source is \"text\"","type":"string"}},"type":"object"},"AnnotationPanelFilter":{"additionalProperties":false,"properties":{"exclude":{"default":false,"description":"Should the specified panels be included or excluded","type":"boolean"},"ids":{"description":"Panel IDs that should be included or excluded","items":{"type":"integer"},"type":"array"}},"required":["ids"],"type":"object"},"AnnotationQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"AnnotationQuery","type":"string"},"spec":{"$ref":"#/components/schemas/AnnotationQuerySpec"}},"required":["kind","spec"],"type":"object"},"AnnotationQueryPlacement":{"const":"inControlsMenu","description":"Annotation Query placement. Defines where the annotation query should be displayed.\n- \"inControlsMenu\" renders the annotation query in the dashboard controls dropdown menu","type":"string"},"AnnotationQuerySpec":{"additionalProperties":false,"properties":{"builtIn":{"default":false,"type":"boolean"},"enable":{"type":"boolean"},"filter":{"$ref":"#/components/schemas/AnnotationPanelFilter"},"hide":{"type":"boolean"},"iconColor":{"type":"string"},"legacyOptions":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"Catch-all field for datasource-specific properties. Should not be available in as code tooling.","type":"object"},"mappings":{"additionalProperties":{"$ref":"#/components/schemas/AnnotationEventFieldMapping"},"description":"Mappings define how to convert data frame fields to annotation event fields.","type":"object"},"name":{"type":"string"},"placement":{"$ref":"#/components/schemas/AnnotationQueryPlacement","description":"Placement can be used to display the annotation query somewhere else on the dashboard other than the default location."},"query":{"$ref":"#/components/schemas/DataQueryKind"}},"required":["query","enable","hide","iconColor","name"],"type":"object"},"AutoGridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutItemSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"element":{"$ref":"#/components/schemas/ElementReference"},"repeat":{"$ref":"#/components/schemas/AutoGridRepeatOptions"}},"required":["element"],"type":"object"},"AutoGridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutSpec":{"additionalProperties":false,"properties":{"columnWidth":{"type":"number"},"columnWidthMode":{"default":"standard","enum":["narrow","standard","wide","custom"],"type":"string"},"fillScreen":{"default":false,"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/AutoGridLayoutItemKind"},"type":"array"},"maxColumnCount":{"default":3,"type":"number"},"rowHeight":{"type":"number"},"rowHeightMode":{"default":"standard","enum":["short","standard","tall","custom"],"type":"string"}},"required":["columnWidthMode","rowHeightMode","items"],"type":"object"},"AutoGridRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"ConditionalRenderingDataKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingData","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingDataSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingDataSpec":{"additionalProperties":false,"properties":{"value":{"type":"boolean"}},"required":["value"],"type":"object"},"ConditionalRenderingGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingGroup","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingGroupSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingGroupSpec":{"additionalProperties":false,"properties":{"condition":{"enum":["and","or"],"type":"string"},"items":{"items":{"oneOf":[{"$ref":"#/components/schemas/ConditionalRenderingVariableKind"},{"$ref":"#/components/schemas/ConditionalRenderingDataKind"},{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeKind"}]},"type":"array"},"visibility":{"enum":["show","hide"],"type":"string"}},"required":["visibility","condition","items"],"type":"object"},"ConditionalRenderingTimeRangeSizeKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingTimeRangeSize","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingTimeRangeSizeSpec":{"additionalProperties":false,"properties":{"value":{"type":"string"}},"required":["value"],"type":"object"},"ConditionalRenderingVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingVariableSpec":{"additionalProperties":false,"properties":{"operator":{"enum":["equals","notEquals","matches","notMatches"],"type":"string"},"value":{"type":"string"},"variable":{"type":"string"}},"required":["variable","operator","value"],"type":"object"},"ConstantVariableKind":{"additionalProperties":false,"description":"Constant variable kind","properties":{"kind":{"const":"ConstantVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConstantVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConstantVariableSpec":{"additionalProperties":false,"description":"Constant variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","hide","skipUrlSync"],"type":"object"},"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"CustomVariableKind":{"additionalProperties":false,"description":"Custom variable kind","properties":{"kind":{"const":"CustomVariable","type":"string"},"spec":{"$ref":"#/components/schemas/CustomVariableSpec"}},"required":["kind","spec"],"type":"object"},"CustomVariableSpec":{"additionalProperties":false,"description":"Custom variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"},"valuesFormat":{"enum":["csv","json"],"type":"string"}},"required":["name","query","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"DashboardCursorSync":{"description":"\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip.","enum":["Crosshair","Tooltip","Off"],"type":"string"},"DashboardLink":{"additionalProperties":false,"description":"Links with references to other dashboards or external resources","properties":{"asDropdown":{"default":false,"description":"If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards","type":"boolean"},"icon":{"description":"Icon name to be displayed with the link","type":"string"},"includeVars":{"default":false,"description":"If true, includes current template variables values in the link as query params","type":"boolean"},"keepTime":{"default":false,"description":"If true, includes current time range in the link as query params","type":"boolean"},"placement":{"$ref":"#/components/schemas/DashboardLinkPlacement","description":"Placement can be used to display the link somewhere else on the dashboard other than above the visualisations."},"tags":{"description":"List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards","items":{"type":"string"},"type":"array"},"targetBlank":{"default":false,"description":"If true, the link will be opened in a new tab","type":"boolean"},"title":{"description":"Title to display with the link","type":"string"},"tooltip":{"description":"Tooltip to display when the user hovers their mouse over it","type":"string"},"type":{"$ref":"#/components/schemas/DashboardLinkType","description":"Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)\nFIXME: The type is generated as ` + "`" + `type: DashboardLinkType | dashboardLinkType.Link;` + "`" + ` but it should be ` + "`" + `type: DashboardLinkType` + "`" + `"},"url":{"description":"Link URL. Only required/valid if the type is link","type":"string"}},"required":["title","type","icon","tooltip","tags","asDropdown","targetBlank","includeVars","keepTime"],"type":"object"},"DashboardLinkPlacement":{"const":"inControlsMenu","description":"Dashboard Link placement. Defines where the link should be displayed.\n- \"inControlsMenu\" renders the link in bottom part of the dashboard controls dropdown menu","type":"string"},"DashboardLinkType":{"description":"Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)","enum":["link","dashboards"],"type":"string"},"DataLink":{"additionalProperties":false,"properties":{"targetBlank":{"type":"boolean"},"title":{"type":"string"},"url":{"type":"string"}},"required":["title","url"],"type":"object"},"DataQueryKind":{"additionalProperties":false,"properties":{"datasource":{"additionalProperties":false,"description":"New type for datasource reference\nNot creating a new type until we figure out how to handle DS refs for group by, adhoc, and every place that uses DataSourceRef in TS.","properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"DataQuery","type":"string"},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"version":{"default":"v0","type":"string"}},"required":["kind","group","version","spec"],"type":"object"},"DataTopic":{"description":"A topic is attached to DataFrame metadata in query results.\nThis specifies where the data should be used.","enum":["series","annotations","alertStates"],"type":"string"},"DataTransformerConfig":{"additionalProperties":false,"description":"Transformations allow to manipulate data returned by a query before the system applies a visualization.\nUsing transformations you can: rename fields, join time series data, perform mathematical operations across queries,\nuse the output of one transformation as the input to another transformation, etc.","properties":{"disabled":{"description":"Disabled transformations are skipped","type":"boolean"},"filter":{"$ref":"#/components/schemas/MatcherConfig","description":"Optional frame matcher. When missing it will be applied to all results"},"id":{"description":"Unique identifier of transformer","type":"string"},"options":{"additionalProperties":{},"description":"Options to be passed to the transformer\nValid options depend on the transformer id","type":"object"},"topic":{"$ref":"#/components/schemas/DataTopic","description":"Where to pull DataFrames from as input to transformation"}},"required":["id","options"],"type":"object"},"DatasourceVariableKind":{"additionalProperties":false,"description":"Datasource variable kind","properties":{"kind":{"const":"DatasourceVariable","type":"string"},"spec":{"$ref":"#/components/schemas/DatasourceVariableSpec"}},"required":["kind","spec"],"type":"object"},"DatasourceVariableSpec":{"additionalProperties":false,"description":"Datasource variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"pluginId":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","pluginId","refresh","regex","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"DynamicConfigValue":{"additionalProperties":false,"properties":{"id":{"default":"","type":"string"},"value":{"additionalProperties":{},"type":"object"}},"required":["id"],"type":"object"},"Element":{"description":"Supported dashboard elements\n|* more element types in the future","oneOf":[{"$ref":"#/components/schemas/PanelKind"},{"$ref":"#/components/schemas/LibraryPanelKind"}]},"ElementReference":{"additionalProperties":false,"properties":{"kind":{"const":"ElementReference","type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"},"FetchOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url"],"type":"object"},"FieldColor":{"additionalProperties":false,"description":"Map a field to a color.","properties":{"fixedColor":{"description":"The fixed color value for fixed or shades color modes.","type":"string"},"mode":{"$ref":"#/components/schemas/FieldColorModeId","description":"The main color scheme mode."},"seriesBy":{"$ref":"#/components/schemas/FieldColorSeriesByMode","description":"Some visualizations need to know how to assign a series color from by value color schemes."}},"required":["mode"],"type":"object"},"FieldColorModeId":{"description":"Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.\nContinuous color interpolates a color using the percentage of a value relative to min and max.\nAccepted values are:\n` + "`" + `thresholds` + "`" + `: From thresholds. Informs Grafana to take the color from the matching threshold\n` + "`" + `palette-classic` + "`" + `: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `palette-classic-by-name` + "`" + `: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `continuous-viridis` + "`" + `: Continuous Viridis palette mode\n` + "`" + `continuous-magma` + "`" + `: Continuous Magma palette mode\n` + "`" + `continuous-plasma` + "`" + `: Continuous Plasma palette mode\n` + "`" + `continuous-inferno` + "`" + `: Continuous Inferno palette mode\n` + "`" + `continuous-cividis` + "`" + `: Continuous Cividis palette mode\n` + "`" + `continuous-GrYlRd` + "`" + `: Continuous Green-Yellow-Red palette mode\n` + "`" + `continuous-RdYlGr` + "`" + `: Continuous Red-Yellow-Green palette mode\n` + "`" + `continuous-BlYlRd` + "`" + `: Continuous Blue-Yellow-Red palette mode\n` + "`" + `continuous-YlRd` + "`" + `: Continuous Yellow-Red palette mode\n` + "`" + `continuous-BlPu` + "`" + `: Continuous Blue-Purple palette mode\n` + "`" + `continuous-YlBl` + "`" + `: Continuous Yellow-Blue palette mode\n` + "`" + `continuous-blues` + "`" + `: Continuous Blue palette mode\n` + "`" + `continuous-reds` + "`" + `: Continuous Red palette mode\n` + "`" + `continuous-greens` + "`" + `: Continuous Green palette mode\n` + "`" + `continuous-purples` + "`" + `: Continuous Purple palette mode\n` + "`" + `shades` + "`" + `: Shades of a single color. Specify a single color, useful in an override rule.\n` + "`" + `fixed` + "`" + `: Fixed color mode. Specify a single color, useful in an override rule.","enum":["thresholds","palette-classic","palette-classic-by-name","continuous-viridis","continuous-magma","continuous-plasma","continuous-inferno","continuous-cividis","continuous-GrYlRd","continuous-RdYlGr","continuous-BlYlRd","continuous-YlRd","continuous-BlPu","continuous-YlBl","continuous-blues","continuous-reds","continuous-greens","continuous-purples","fixed","shades"],"type":"string"},"FieldColorSeriesByMode":{"description":"Defines how to assign a series color from \"by value\" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.","enum":["min","max","last"],"type":"string"},"FieldConfig":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"actions":{"description":"Define interactive HTTP requests that can be triggered from data visualizations.","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"color":{"$ref":"#/components/schemas/FieldColor","description":"Panel color configuration"},"custom":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"custom is specified by the FieldConfig field\nin panel plugin schemas.","type":"object"},"decimals":{"description":"Specify the number of decimals Grafana includes in the rendered value.\nIf you leave this field blank, Grafana automatically truncates the number of decimals based on the value.\nFor example 1.1234 will display as 1.12 and 100.456 will display as 100.\nTo display all decimals, set the unit to ` + "`" + `String` + "`" + `.","type":"number"},"description":{"description":"Human readable field metadata","type":"string"},"displayName":{"description":"The display value for this field. This supports template variables blank is auto","type":"string"},"displayNameFromDS":{"description":"This can be used by data sources that return and explicit naming structure for values and labels\nWhen this property is configured, this value is used rather than the default naming strategy.","type":"string"},"filterable":{"description":"True if data source field supports ad-hoc filters","type":"boolean"},"links":{"description":"The behavior when clicking on a result","items":{"additionalProperties":{},"type":"object"},"type":"array"},"mappings":{"description":"Convert input values into a display string","items":{"$ref":"#/components/schemas/ValueMapping"},"type":"array"},"max":{"description":"The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"min":{"description":"The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"noValue":{"description":"Alternative to empty string","type":"string"},"path":{"description":"An explicit path to the field in the datasource. When the frame meta includes a path,\nThis will default to ` + "`" + `${frame.meta.path}/${field.name}\n\nWhen defined, this value can be used as an identifier within the datasource scope, and\nmay be used to update the results","type":"string"},"thresholds":{"$ref":"#/components/schemas/ThresholdsConfig","description":"Map numeric values to states"},"unit":{"description":"Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n` + "`" + `suffix:\u003csuffix\u003e` + "`" + ` for custom unit that should go after value.\n` + "`" + `prefix:\u003cprefix\u003e` + "`" + ` for custom unit that should go before value.\n` + "`" + `time:\u003cformat\u003e` + "`" + ` For custom date time formats type for example ` + "`" + `time:YYYY-MM-DD` + "`" + `.\n` + "`" + `si:\u003cbase scale\u003e\u003cunit characters\u003e` + "`" + ` for custom SI units. For example: ` + "`" + `si: mF` + "`" + `. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n` + "`" + `count:\u003cunit\u003e` + "`" + ` for a custom count unit.\n` + "`" + `currency:\u003cunit\u003e` + "`" + ` for custom a currency unit.","type":"string"},"writeable":{"description":"True if data source can write a value to the path. Auth/authz are supported separately","type":"boolean"}},"type":"object"},"FieldConfigSource":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"defaults":{"$ref":"#/components/schemas/FieldConfig","description":"Defaults are the options applied to all fields."},"overrides":{"description":"Overrides are the options applied to specific fields overriding the defaults.","items":{"additionalProperties":false,"properties":{"__systemRef":{"description":"Describes config override rules created when interacting with Grafana.","type":"string"},"matcher":{"$ref":"#/components/schemas/MatcherConfig"},"properties":{"items":{"$ref":"#/components/schemas/DynamicConfigValue"},"type":"array"}},"required":["matcher","properties"],"type":"object"},"type":"array"}},"required":["defaults","overrides"],"type":"object"},"FilterOrigin":{"const":"dashboard","description":"Determine the origin of the adhoc variable filter","type":"string"},"GridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutItemSpec":{"additionalProperties":false,"properties":{"element":{"$ref":"#/components/schemas/ElementReference","description":"reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference"},"height":{"type":"integer"},"repeat":{"$ref":"#/components/schemas/RepeatOptions"},"width":{"type":"integer"},"x":{"type":"integer"},"y":{"type":"integer"}},"required":["x","y","width","height","element"],"type":"object"},"GridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutSpec":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/GridLayoutItemKind"},"type":"array"}},"required":["items"],"type":"object"},"GroupByVariableKind":{"additionalProperties":false,"description":"Group variable kind","properties":{"datasource":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"GroupByVariable","type":"string"},"spec":{"$ref":"#/components/schemas/GroupByVariableSpec"}},"required":["kind","group","spec"],"type":"object"},"GroupByVariableSpec":{"additionalProperties":false,"description":"GroupBy variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"defaultValue":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","options","multi","hide","skipUrlSync"],"type":"object"},"HttpRequestMethod":{"enum":["GET","PUT","POST","DELETE","PATCH"],"type":"string"},"InfinityOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"datasourceUid":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url","datasourceUid"],"type":"object"},"IntervalVariableKind":{"additionalProperties":false,"description":"Interval variable kind","properties":{"kind":{"const":"IntervalVariable","type":"string"},"spec":{"$ref":"#/components/schemas/IntervalVariableSpec"}},"required":["kind","spec"],"type":"object"},"IntervalVariableSpec":{"additionalProperties":false,"description":"Interval variable specification","properties":{"auto":{"default":false,"type":"boolean"},"auto_count":{"default":0,"type":"integer"},"auto_min":{"default":"","type":"string"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"refresh":{"const":"onTimeRangeChanged","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","auto","auto_min","auto_count","refresh","hide","skipUrlSync"],"type":"object"},"LibraryPanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"LibraryPanel","type":"string"},"spec":{"$ref":"#/components/schemas/LibraryPanelKindSpec"}},"required":["kind","spec"],"type":"object"},"LibraryPanelKindSpec":{"additionalProperties":false,"properties":{"id":{"description":"Panel ID for the library panel in the dashboard","type":"number"},"libraryPanel":{"$ref":"#/components/schemas/LibraryPanelRef"},"title":{"description":"Title for the library panel in the dashboard","type":"string"}},"required":["id","title","libraryPanel"],"type":"object"},"LibraryPanelRef":{"additionalProperties":false,"description":"A library panel is a reusable panel that you can use in any dashboard.\nWhen you make a change to a library panel, that change propagates to all instances of where the panel is used.\nLibrary panels streamline reuse of panels across multiple dashboards.","properties":{"name":{"description":"Library panel name","type":"string"},"uid":{"description":"Library panel uid","type":"string"}},"required":["name","uid"],"type":"object"},"MappingType":{"description":"Supported value mapping types\n` + "`" + `value` + "`" + `: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.\n` + "`" + `range` + "`" + `: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.\n` + "`" + `regex` + "`" + `: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.\n` + "`" + `special` + "`" + `: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.","enum":["value","range","regex","special"],"type":"string"},"MatcherConfig":{"additionalProperties":false,"description":"Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.\nIt comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.","properties":{"id":{"default":"","description":"The matcher id. This is used to find the matcher implementation from registry.","type":"string"},"options":{"additionalProperties":{},"description":"The matcher options. This is specific to the matcher implementation.","type":"object"}},"required":["id"],"type":"object"},"MetricFindValue":{"additionalProperties":false,"description":"Define the MetricFindValue type","properties":{"expandable":{"type":"boolean"},"group":{"type":"string"},"text":{"type":"string"},"value":{"oneOf":[{"type":"string"},{"type":"number"}]}},"required":["text"],"type":"object"},"PanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"Panel","type":"string"},"spec":{"$ref":"#/components/schemas/PanelSpec"}},"required":["kind","spec"],"type":"object"},"PanelQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"PanelQuery","type":"string"},"spec":{"$ref":"#/components/schemas/PanelQuerySpec"}},"required":["kind","spec"],"type":"object"},"PanelQuerySpec":{"additionalProperties":false,"properties":{"hidden":{"type":"boolean"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refId":{"default":"A","type":"string"}},"required":["query","refId","hidden"],"type":"object"},"PanelSpec":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QueryGroupKind"},"description":{"type":"string"},"id":{"type":"number"},"links":{"items":{"$ref":"#/components/schemas/DataLink"},"type":"array"},"title":{"type":"string"},"transparent":{"type":"boolean"},"vizConfig":{"$ref":"#/components/schemas/VizConfigKind"}},"required":["id","title","description","links","data","vizConfig"],"type":"object"},"QueryGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"QueryGroup","type":"string"},"spec":{"$ref":"#/components/schemas/QueryGroupSpec"}},"required":["kind","spec"],"type":"object"},"QueryGroupSpec":{"additionalProperties":false,"properties":{"queries":{"items":{"$ref":"#/components/schemas/PanelQueryKind"},"type":"array"},"queryOptions":{"$ref":"#/components/schemas/QueryOptionsSpec"},"transformations":{"items":{"$ref":"#/components/schemas/TransformationKind"},"type":"array"}},"required":["queries","transformations","queryOptions"],"type":"object"},"QueryOptionsSpec":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"string"},"hideTimeOverride":{"type":"boolean"},"interval":{"type":"string"},"maxDataPoints":{"type":"integer"},"queryCachingTTL":{"type":"integer"},"timeCompare":{"type":"string"},"timeFrom":{"type":"string"},"timeShift":{"type":"string"}},"type":"object"},"QueryVariableKind":{"additionalProperties":false,"description":"Query variable kind","properties":{"kind":{"const":"QueryVariable","type":"string"},"spec":{"$ref":"#/components/schemas/QueryVariableSpec"}},"required":["kind","spec"],"type":"object"},"QueryVariableSpec":{"additionalProperties":false,"description":"Query variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"definition":{"type":"string"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"placeholder":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"regexApplyTo":{"$ref":"#/components/schemas/VariableRegexApplyTo","default":"value"},"skipUrlSync":{"default":false,"type":"boolean"},"sort":{"$ref":"#/components/schemas/VariableSort"},"staticOptions":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"staticOptionsOrder":{"enum":["before","after","sorted"],"type":"string"}},"required":["name","current","hide","refresh","skipUrlSync","query","regex","sort","options","multi","includeAll","allowCustomValue"],"type":"object"},"RangeMap":{"additionalProperties":false,"description":"Maps numerical ranges to a display text and color.\nFor example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.","properties":{"options":{"additionalProperties":false,"description":"Range to match against and the result to apply when the value is within the range","properties":{"from":{"description":"Min value of the range. It can be null which means -Infinity","type":"number"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value is within the range"},"to":{"description":"Max value of the range. It can be null which means +Infinity","type":"number"}},"required":["from","to","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RegexMap":{"additionalProperties":false,"description":"Maps regular expressions to replacement text and a color.\nFor example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.","properties":{"options":{"additionalProperties":false,"description":"Regular expression to match against and the result to apply when the value matches the regex","properties":{"pattern":{"description":"Regular expression to match against","type":"string"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the regex"}},"required":["pattern","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RepeatMode":{"const":"variable","description":"other repeat modes will be added in the future: label, frame","type":"string"},"RepeatOptions":{"additionalProperties":false,"properties":{"direction":{"enum":["h","v"],"type":"string"},"maxPerRow":{"type":"integer"},"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayoutRow","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutRowSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowSpec":{"additionalProperties":false,"properties":{"collapse":{"type":"boolean"},"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"fillScreen":{"type":"boolean"},"hideHeader":{"type":"boolean"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/RowRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"RowsLayoutSpec":{"additionalProperties":false,"properties":{"rows":{"items":{"$ref":"#/components/schemas/RowsLayoutRowKind"},"type":"array"}},"required":["rows"],"type":"object"},"SpecialValueMap":{"additionalProperties":false,"description":"Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color.\nSee SpecialValueMatch to see the list of special values.\nFor example, you can configure a special value mapping so that null values appear as N/A.","properties":{"options":{"additionalProperties":false,"properties":{"match":{"$ref":"#/components/schemas/SpecialValueMatch","description":"Special value to match against"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the special value"}},"required":["match","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"SpecialValueMatch":{"description":"Special value types supported by the ` + "`" + `SpecialValueMap` + "`" + `","enum":["true","false","null","nan","null+nan","empty"],"type":"string"},"SwitchVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"SwitchVariable","type":"string"},"spec":{"$ref":"#/components/schemas/SwitchVariableSpec"}},"required":["kind","spec"],"type":"object"},"SwitchVariableSpec":{"additionalProperties":false,"properties":{"current":{"default":"false","type":"string"},"description":{"type":"string"},"disabledValue":{"default":"false","type":"string"},"enabledValue":{"default":"true","type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","enabledValue","disabledValue","hide","skipUrlSync"],"type":"object"},"TabRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"TabsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutSpec":{"additionalProperties":false,"properties":{"tabs":{"items":{"$ref":"#/components/schemas/TabsLayoutTabKind"},"type":"array"}},"required":["tabs"],"type":"object"},"TabsLayoutTabKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayoutTab","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutTabSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutTabSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/TabRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"TextVariableKind":{"additionalProperties":false,"description":"Text variable kind","properties":{"kind":{"const":"TextVariable","type":"string"},"spec":{"$ref":"#/components/schemas/TextVariableSpec"}},"required":["kind","spec"],"type":"object"},"TextVariableSpec":{"additionalProperties":false,"description":"Text variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","query","hide","skipUrlSync"],"type":"object"},"Threshold":{"additionalProperties":false,"properties":{"color":{"type":"string"},"value":{"description":"Value null means -Infinity","type":"number"}},"required":["value","color"],"type":"object"},"ThresholdsConfig":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/ThresholdsMode"},"steps":{"items":{"$ref":"#/components/schemas/Threshold"},"type":"array"}},"required":["mode","steps"],"type":"object"},"ThresholdsMode":{"enum":["absolute","percentage"],"type":"string"},"TimeRangeOption":{"additionalProperties":false,"properties":{"display":{"default":"Last 6 hours","type":"string"},"from":{"default":"now-6h","type":"string"},"to":{"default":"now","type":"string"}},"required":["display","from","to"],"type":"object"},"TimeSettingsSpec":{"additionalProperties":false,"description":"Time configuration\nIt defines the default time config for the time picker, the refresh picker for the specific dashboard.","properties":{"autoRefresh":{"default":"","description":"Refresh rate of dashboard. Represented via interval string, e.g. \"5s\", \"1m\", \"1h\", \"1d\".\nv1: refresh","type":"string"},"autoRefreshIntervals":{"default":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"description":"Interval options available in the refresh picker dropdown.\nv1: timepicker.refresh_intervals","items":{"type":"string"},"type":"array"},"fiscalYearStartMonth":{"default":0,"description":"The month that the fiscal year starts on. 0 = January, 11 = December","type":"integer"},"from":{"default":"now-6h","description":"Start time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"hideTimepicker":{"default":false,"description":"Whether timepicker is visible or not.\nv1: timepicker.hidden","type":"boolean"},"nowDelay":{"description":"Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.\nv1: timepicker.nowDelay","type":"string"},"quickRanges":{"description":"Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.\nv1: timepicker.quick_ranges , not exposed in the UI","items":{"$ref":"#/components/schemas/TimeRangeOption"},"type":"array"},"timezone":{"default":"browser","description":"Timezone of dashboard. Accepted values are IANA TZDB zone ID or \"browser\" or \"utc\".","type":"string"},"to":{"default":"now","description":"End time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"weekStart":{"description":"Day when the week starts. Expressed by the name of the day in lowercase, e.g. \"monday\".","enum":["saturday","monday","sunday"],"type":"string"}},"required":["from","to","autoRefresh","autoRefreshIntervals","hideTimepicker","fiscalYearStartMonth"],"type":"object"},"TransformationKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a TransformationKind is the transformation ID","type":"string"},"spec":{"$ref":"#/components/schemas/DataTransformerConfig"}},"required":["kind","spec"],"type":"object"},"ValueMap":{"additionalProperties":false,"description":"Maps text values to a color or different display text and color.\nFor example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.","properties":{"options":{"additionalProperties":{"$ref":"#/components/schemas/ValueMappingResult"},"description":"Map with \u003cvalue_to_match\u003e: ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }","type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"ValueMapping":{"oneOf":[{"$ref":"#/components/schemas/ValueMap"},{"$ref":"#/components/schemas/RangeMap"},{"$ref":"#/components/schemas/RegexMap"},{"$ref":"#/components/schemas/SpecialValueMap"}]},"ValueMappingResult":{"additionalProperties":false,"description":"Result used as replacement with text and color when the value matches","properties":{"color":{"description":"Text to use when the value matches","type":"string"},"icon":{"description":"Icon to display when the value matches. Only specific visualizations.","type":"string"},"index":{"description":"Position in the mapping array. Only used internally.","type":"integer"},"text":{"description":"Text to display when the value matches","type":"string"}},"type":"object"},"VariableHide":{"description":"Determine if the variable shows on dashboard\nAccepted values are ` + "`" + `dontHide` + "`" + ` (show label and value), ` + "`" + `hideLabel` + "`" + ` (show value only), ` + "`" + `hideVariable` + "`" + ` (show nothing), ` + "`" + `inControlsMenu` + "`" + ` (show in a drop-down menu).","enum":["dontHide","hideLabel","hideVariable","inControlsMenu"],"type":"string"},"VariableKind":{"oneOf":[{"$ref":"#/components/schemas/QueryVariableKind"},{"$ref":"#/components/schemas/TextVariableKind"},{"$ref":"#/components/schemas/ConstantVariableKind"},{"$ref":"#/components/schemas/DatasourceVariableKind"},{"$ref":"#/components/schemas/IntervalVariableKind"},{"$ref":"#/components/schemas/CustomVariableKind"},{"$ref":"#/components/schemas/GroupByVariableKind"},{"$ref":"#/components/schemas/AdhocVariableKind"},{"$ref":"#/components/schemas/SwitchVariableKind"}]},"VariableOption":{"additionalProperties":false,"description":"Variable option specification","properties":{"selected":{"description":"Whether the option is selected or not","type":"boolean"},"text":{"description":"Text to be displayed for the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]},"value":{"description":"Value of the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]}},"required":["text","value"],"type":"object"},"VariableRefresh":{"description":"Options to config when to refresh a variable\n` + "`" + `never` + "`" + `: Never refresh the variable\n` + "`" + `onDashboardLoad` + "`" + `: Queries the data source every time the dashboard loads.\n` + "`" + `onTimeRangeChanged` + "`" + `: Queries the data source when the dashboard time range changes.","enum":["never","onDashboardLoad","onTimeRangeChanged"],"type":"string"},"VariableRegexApplyTo":{"description":"Determine whether regex applies to variable value or display text\nAccepted values are ` + "`" + `value` + "`" + ` (apply to value used in queries) or ` + "`" + `text` + "`" + ` (apply to display text shown to users)","enum":["value","text"],"type":"string"},"VariableSort":{"description":"Sort variable options\nAccepted values are:\n` + "`" + `disabled` + "`" + `: No sorting\n` + "`" + `alphabeticalAsc` + "`" + `: Alphabetical ASC\n` + "`" + `alphabeticalDesc` + "`" + `: Alphabetical DESC\n` + "`" + `numericalAsc` + "`" + `: Numerical ASC\n` + "`" + `numericalDesc` + "`" + `: Numerical DESC\n` + "`" + `alphabeticalCaseInsensitiveAsc` + "`" + `: Alphabetical Case Insensitive ASC\n` + "`" + `alphabeticalCaseInsensitiveDesc` + "`" + `: Alphabetical Case Insensitive DESC\n` + "`" + `naturalAsc` + "`" + `: Natural ASC\n` + "`" + `naturalDesc` + "`" + `: Natural DESC\nVariableSort enum with default value","enum":["disabled","alphabeticalAsc","alphabeticalDesc","numericalAsc","numericalDesc","alphabeticalCaseInsensitiveAsc","alphabeticalCaseInsensitiveDesc","naturalAsc","naturalDesc"],"type":"string"},"VizConfigKind":{"additionalProperties":false,"properties":{"group":{"description":"The group is the plugin ID","type":"string"},"kind":{"const":"VizConfig","type":"string"},"spec":{"$ref":"#/components/schemas/VizConfigSpec"},"version":{"type":"string"}},"required":["kind","group","version","spec"],"type":"object"},"VizConfigSpec":{"additionalProperties":false,"description":"--- Kinds ---","properties":{"fieldConfig":{"$ref":"#/components/schemas/FieldConfigSource"},"options":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"}},"required":["options","fieldConfig"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"annotations":{"items":{"$ref":"#/components/schemas/AnnotationQueryKind"},"type":"array"},"cursorSync":{"$ref":"#/components/schemas/DashboardCursorSync","default":"Off","description":"Configuration of dashboard cursor sync behavior.\n\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip."},"description":{"description":"Description of dashboard.","type":"string"},"editable":{"default":true,"description":"Whether a dashboard is editable or not.","type":"boolean"},"elements":{"additionalProperties":{"$ref":"#/components/schemas/Element"},"type":"object"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"links":{"description":"Links with references to other dashboards or external websites.","items":{"$ref":"#/components/schemas/DashboardLink"},"type":"array"},"liveNow":{"description":"When set to true, the dashboard will redraw panels at an interval matching the pixel width.\nThis will keep data \"moving left\" regardless of the query refresh rate. This setting helps\navoid dashboards presenting stale live data.","type":"boolean"},"preload":{"default":false,"description":"When set to true, the dashboard will load all panels in the dashboard when it's loaded.","type":"boolean"},"revision":{"description":"Plugins only. The version of the dashboard installed together with the plugin.\nThis is used to determine if the dashboard should be updated when the plugin is updated.","type":"integer"},"tags":{"description":"Tags associated with dashboard.","items":{"type":"string"},"type":"array"},"timeSettings":{"$ref":"#/components/schemas/TimeSettingsSpec"},"title":{"description":"Title of dashboard.","type":"string"},"variables":{"description":"Configured template variables.","items":{"$ref":"#/components/schemas/VariableKind"},"type":"array"}},"required":["annotations","cursorSync","elements","layout","links","preload","tags","timeSettings","title","variables"],"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) versionSchemaDashboardv2beta1 app.VersionSchema _ = json.Unmarshal(rawSchemaDashboardv2beta1, &versionSchemaDashboardv2beta1) ) diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index b63e0146cc2..59c622b07bc 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -1336,6 +1336,17 @@ func buildCustomVariable(varMap map[string]interface{}, commonProps CommonVariab customVar.Spec.AllValue = &allValue } + if valuesFormat := schemaversion.GetStringValue(varMap, "valuesFormat"); valuesFormat != "" { + switch valuesFormat { + case string(dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson): + format := dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson + customVar.Spec.ValuesFormat = &format + case string(dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv): + format := dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv + customVar.Spec.ValuesFormat = &format + } + } + return dashv2alpha1.DashboardVariableKind{ CustomVariableKind: customVar, }, nil diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go index 5b3a0d115b8..45803b6d7ec 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go @@ -685,6 +685,7 @@ func convertVariable_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardVariableKind, SkipUrlSync: in.CustomVariableKind.Spec.SkipUrlSync, Description: in.CustomVariableKind.Spec.Description, AllowCustomValue: in.CustomVariableKind.Spec.AllowCustomValue, + ValuesFormat: convertCustomValuesFormat_V2alpha1_to_V2beta1(in.CustomVariableKind.Spec.ValuesFormat), }, } } @@ -758,6 +759,23 @@ func convertVariable_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardVariableKind, return nil } +func convertCustomValuesFormat_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardCustomVariableSpecValuesFormat) *dashv2beta1.DashboardCustomVariableSpecValuesFormat { + if in == nil { + return nil + } + + switch *in { + case dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson: + v := dashv2beta1.DashboardCustomVariableSpecValuesFormatJson + return &v + case dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv: + v := dashv2beta1.DashboardCustomVariableSpecValuesFormatCsv + return &v + default: + return nil + } +} + func convertQueryVariableSpec_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardQueryVariableSpec, out *dashv2beta1.DashboardQueryVariableSpec, scope conversion.Scope) error { out.Name = in.Name out.Current = convertVariableOption_V2alpha1_to_V2beta1(in.Current) diff --git a/kinds/dashboard/dashboard_kind.cue b/kinds/dashboard/dashboard_kind.cue index 08b7a1b2b6e..f17aa67f209 100644 --- a/kinds/dashboard/dashboard_kind.cue +++ b/kinds/dashboard/dashboard_kind.cue @@ -218,8 +218,10 @@ lineage: schemas: [{ // Optional field, if you want to extract part of a series name or metric node segment. // Named capture groups can be used to separate the display text and value. regex?: string - // Determine whether regex applies to variable value or display text - regexApplyTo?: #VariableRegexApplyTo + // Optional, indicates whether a custom type variable uses CSV or JSON to define its values + valuesFormat?: "csv" | "json" | *"csv" + // Determine whether regex applies to variable value or display text + regexApplyTo?: #VariableRegexApplyTo // Additional static options for query variable staticOptions?: [...#VariableOption] // Ordering of static options in relation to options returned from data source for query variable diff --git a/package.json b/package.json index d36b4bfc5f8..36d93996980 100644 --- a/package.json +++ b/package.json @@ -295,8 +295,8 @@ "@grafana/plugin-ui": "^0.11.1", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "6.52.0", - "@grafana/scenes-react": "6.52.0", + "@grafana/scenes": "v6.52.1", + "@grafana/scenes-react": "v6.52.1", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/packages/grafana-data/src/types/templateVars.ts b/packages/grafana-data/src/types/templateVars.ts index 8b6ed69463b..62ce2862af2 100644 --- a/packages/grafana-data/src/types/templateVars.ts +++ b/packages/grafana-data/src/types/templateVars.ts @@ -103,6 +103,7 @@ export interface IntervalVariableModel extends VariableWithOptions { export interface CustomVariableModel extends VariableWithMultiSupport { type: 'custom'; + valuesFormat?: 'csv' | 'json'; } export interface DataSourceVariableModel extends VariableWithMultiSupport { diff --git a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts index f2423566ff5..bc1c1cbcbf4 100644 --- a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts +++ b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts @@ -211,6 +211,10 @@ export interface VariableModel { * Type of variable */ type: VariableType; + /** + * Optional, indicates whether a custom type variable uses CSV or JSON to define its values + */ + valuesFormat?: ('csv' | 'json'); } export const defaultVariableModel: Partial = { @@ -220,6 +224,7 @@ export const defaultVariableModel: Partial = { options: [], skipUrlSync: false, staticOptions: [], + valuesFormat: 'csv', }; /** diff --git a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts index e0f10d0f770..294e0262f95 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts @@ -317,6 +317,7 @@ export const handyTestingSchema: Spec = { query: 'option1, option2', skipUrlSync: false, allowCustomValue: true, + valuesFormat: 'csv', }, }, { diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts index 78068b9412d..aa1d1e19539 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts @@ -300,7 +300,7 @@ export interface FieldConfig { description?: string; // An explicit path to the field in the datasource. When the frame meta includes a path, // This will default to `${frame.meta.path}/${field.name} - // + // // When defined, this value can be used as an identifier within the datasource scope, and // may be used to update the results path?: string; @@ -1353,6 +1353,7 @@ export interface CustomVariableSpec { skipUrlSync: boolean; description?: string; allowCustomValue: boolean; + valuesFormat?: "csv" | "json"; } export const defaultCustomVariableSpec = (): CustomVariableSpec => ({ @@ -1365,6 +1366,7 @@ export const defaultCustomVariableSpec = (): CustomVariableSpec => ({ hide: "dontHide", skipUrlSync: false, allowCustomValue: true, + valuesFormat: undefined, }); // Group variable kind @@ -1549,4 +1551,3 @@ export const defaultSpec = (): Spec => ({ title: "", variables: [], }); - diff --git a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts index 6a05bed3f1c..1749a57c459 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts @@ -1359,6 +1359,7 @@ export interface CustomVariableSpec { skipUrlSync: boolean; description?: string; allowCustomValue: boolean; + valuesFormat?: "csv" | "json"; } export const defaultCustomVariableSpec = (): CustomVariableSpec => ({ diff --git a/pkg/kinds/dashboard/dashboard_spec_gen.go b/pkg/kinds/dashboard/dashboard_spec_gen.go index 7d2a0ba5439..ad0a1c88f24 100644 --- a/pkg/kinds/dashboard/dashboard_spec_gen.go +++ b/pkg/kinds/dashboard/dashboard_spec_gen.go @@ -837,6 +837,8 @@ type VariableModel struct { // Optional field, if you want to extract part of a series name or metric node segment. // Named capture groups can be used to separate the display text and value. Regex *string `json:"regex,omitempty"` + // Optional, indicates whether a custom type variable uses CSV or JSON to define its values + ValuesFormat *VariableModelValuesFormat `json:"valuesFormat,omitempty"` // Determine whether regex applies to variable value or display text RegexApplyTo *VariableRegexApplyTo `json:"regexApplyTo,omitempty"` // Additional static options for query variable @@ -852,6 +854,7 @@ func NewVariableModel() *VariableModel { Multi: (func(input bool) *bool { return &input })(false), AllowCustomValue: (func(input bool) *bool { return &input })(true), IncludeAll: (func(input bool) *bool { return &input })(false), + ValuesFormat: (func(input VariableModelValuesFormat) *VariableModelValuesFormat { return &input })(VariableModelValuesFormatCsv), } } @@ -1191,6 +1194,13 @@ const ( DataTransformerConfigTopicAlertStates DataTransformerConfigTopic = "alertStates" ) +type VariableModelValuesFormat string + +const ( + VariableModelValuesFormatCsv VariableModelValuesFormat = "csv" + VariableModelValuesFormatJson VariableModelValuesFormat = "json" +) + type VariableModelStaticOptionsOrder string const ( diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json index b0d21a3a60c..b89d431883b 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json @@ -1786,6 +1786,13 @@ "skipUrlSync": { "type": "boolean", "default": false + }, + "valuesFormat": { + "enum": [ + "csv", + "json" + ], + "type": "string" } }, "additionalProperties": false diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2beta1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2beta1.json index f4ecc6c4599..7f396bc20d4 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2beta1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2beta1.json @@ -1801,6 +1801,13 @@ "skipUrlSync": { "type": "boolean", "default": false + }, + "valuesFormat": { + "type": "string", + "enum": [ + "csv", + "json" + ] } }, "additionalProperties": false diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts index aa2d81dcc52..6179b70f217 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts @@ -345,6 +345,16 @@ describe('DashboardSceneSerializer', () => { type: 'textbox', name: 'search', }, + { + name: 'custom_csv', + type: 'custom', + valuesFormat: 'csv', + }, + { + name: 'custom_json', + type: 'custom', + valuesFormat: 'json', + }, ], }, }); @@ -359,6 +369,9 @@ describe('DashboardSceneSerializer', () => { panel_type_row_count: 1, variable_type_query_count: 2, variable_type_textbox_count: 1, + variable_type_custom_count: 2, + variable_type_custom_csv_count: 1, + variable_type_custom_json_count: 1, settings_nowdelay: undefined, settings_livenow: true, varsWithDataSource: [ @@ -701,7 +714,9 @@ describe('DashboardSceneSerializer', () => { panel_type_timeseries_count: 6, variable_type_adhoc_count: 1, variable_type_datasource_count: 1, - variable_type_custom_count: 1, + variable_type_custom_count: 3, + variable_type_custom_csv_count: 2, + variable_type_custom_json_count: 1, variable_type_query_count: 1, varsWithDataSource: [ { type: 'query', datasource: 'cloudwatch' }, @@ -714,7 +729,7 @@ describe('DashboardSceneSerializer', () => { panelCount: 6, rowCount: 6, tabCount: 4, - templateVariableCount: 4, + templateVariableCount: 6, maxNestingLevel: 3, dashStructure: '[{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"},{"kind":"panel"},{"kind":"panel"}]},{"kind":"tab","children":[]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"panel"}]}]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"}]},{"kind":"tab","children":[{"kind":"panel"}]}]}]}]', @@ -866,6 +881,7 @@ describe('DashboardSceneSerializer', () => { query: 'app1', skipUrlSync: false, allowCustomValue: true, + valuesFormat: 'csv', }, }, ]); diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap index 512ec28dd77..f0a1ef59584 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap @@ -294,6 +294,7 @@ exports[`Given a scene with custom quick ranges should save quick ranges to save "options": [], "query": "a, b, c", "type": "custom", + "valuesFormat": "csv", }, { "current": { @@ -680,6 +681,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back "options": [], "query": "A,B,C,D,E,F,E,G,H,I,J,K,L", "type": "custom", + "valuesFormat": "csv", }, { "current": { @@ -698,6 +700,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back "options": [], "query": "Bob : 1, Rob : 2,Sod : 3, Hod : 4, Cod : 5", "type": "custom", + "valuesFormat": "csv", }, ], }, @@ -1021,6 +1024,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho "options": [], "query": "a, b, c", "type": "custom", + "valuesFormat": "csv", }, { "current": { @@ -1381,6 +1385,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr "options": [], "query": "a, b, c", "type": "custom", + "valuesFormat": "csv", }, { "current": { diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap index b0133a8eb92..23f11bf3f71 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap @@ -196,6 +196,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model "options": [], "query": "option1, option2", "skipUrlSync": false, + "valuesFormat": "csv", }, }, { diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts index 33f06a7accc..d60d0b85b0e 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts @@ -376,6 +376,7 @@ describe('sceneVariablesSetToVariables', () => { "options": [], "query": "test,test1,test2", "type": "custom", + "valuesFormat": "csv", } `); }); @@ -1180,6 +1181,7 @@ describe('sceneVariablesSetToVariables', () => { "options": [], "query": "test,test1,test2", "skipUrlSync": false, + "valuesFormat": "csv", }, } `); diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts index fe7b56e22b5..cc83c131fed 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts @@ -120,6 +120,9 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio allValue: variable.state.allValue, includeAll: variable.state.includeAll, ...(variable.state.allowCustomValue !== undefined && { allowCustomValue: variable.state.allowCustomValue }), + // Ensure we persist the backend default when not specified to stay aligned with + // transformSaveModelSchemaV2ToScene which injects 'csv' on load. + valuesFormat: variable.state.valuesFormat ?? 'csv', }; variables.push(customVariable); } else if (sceneUtils.isDataSourceVariable(variable)) { @@ -408,6 +411,7 @@ export function sceneVariablesSetToSchemaV2Variables( allValue: variable.state.allValue, includeAll: variable.state.includeAll ?? false, allowCustomValue: variable.state.allowCustomValue ?? true, + valuesFormat: variable.state.valuesFormat ?? 'csv', }, }; variables.push(customVariable); diff --git a/public/app/features/dashboard-scene/serialization/testfiles/nested_dashboard.json b/public/app/features/dashboard-scene/serialization/testfiles/nested_dashboard.json index 8a39daf6155..7cb0537b49e 100644 --- a/public/app/features/dashboard-scene/serialization/testfiles/nested_dashboard.json +++ b/public/app/features/dashboard-scene/serialization/testfiles/nested_dashboard.json @@ -1169,6 +1169,57 @@ "skipUrlSync": false } }, + { + "kind": "CustomVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "test", + "value": "test" + }, + "hide": "dontHide", + "includeAll": false, + "multi": false, + "name": "custom0", + "options": [ + { + "selected": true, + "text": "test", + "value": "test" + } + ], + "valuesFormat": "csv", + "query": "test", + "skipUrlSync": false + } + }, + { + "kind": "CustomVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "test", + "value": "test" + }, + "hide": "dontHide", + "includeAll": false, + "multi": false, + "name": "custom0", + "options": [ + { + "selected": true, + "text": "test", + "value": "test", + "properties": { + "testProp": "test" + } + } + ], + "valuesFormat": "json", + "query": "test", + "skipUrlSync": false + } + }, { "kind": "DatasourceVariable", "spec": { diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index f343cbce00d..be2aa2f98f3 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -343,12 +343,12 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S } return new AdHocFiltersVariable(adhocVariableState); } + if (variable.kind === defaultCustomVariableKind().kind) { return new CustomVariable({ ...commonProperties, value: variable.spec.current?.value ?? '', text: variable.spec.current?.text ?? '', - query: variable.spec.query, isMulti: variable.spec.multi, allValue: variable.spec.allValue || undefined, @@ -357,6 +357,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S skipUrlSync: variable.spec.skipUrlSync, hide: transformVariableHideToEnumV1(variable.spec.hide), ...(variable.spec.allowCustomValue !== undefined && { allowCustomValue: variable.spec.allowCustomValue }), + valuesFormat: variable.spec.valuesFormat || 'csv', }); } else if (variable.kind === defaultQueryVariableKind().kind) { return new QueryVariable({ diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx index 61193d003f6..eb78f34a45b 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx @@ -9,7 +9,7 @@ import { Trans, t } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { SceneVariable } from '@grafana/scenes'; import { VariableHide, defaultVariableModel } from '@grafana/schema'; -import { Button, LoadingPlaceholder, ConfirmModal, ModalsController, Stack, useStyles2 } from '@grafana/ui'; +import { Button, ConfirmModal, LoadingPlaceholder, ModalsController, Stack, useStyles2 } from '@grafana/ui'; import { VariableDisplaySelect } from 'app/features/dashboard-scene/settings/variables/components/VariableDisplaySelect'; import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend'; import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField'; @@ -68,6 +68,8 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete const onDisplayChange = (display: VariableHide) => variable.setState({ hide: display }); const isHasVariableOptions = hasVariableOptions(variable); + const optionsForSelect = isHasVariableOptions ? variable.getOptionsForSelect(false) : []; + const hasMultiProps = 'valuesFormat' in variable.state && variable.state.valuesFormat === 'json'; const onDeleteVariable = (hideModal: () => void) => () => { reportInteraction('Delete variable'); @@ -123,7 +125,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete {EditorToRender && } - {isHasVariableOptions && } + {isHasVariableOptions && }
diff --git a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx index 924f9fa5702..8b0674b24d7 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx @@ -1,9 +1,16 @@ -import { render, fireEvent } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { selectors } from '@grafana/e2e-selectors'; import { CustomVariableForm } from './CustomVariableForm'; +jest.mock('@grafana/runtime', () => { + const actual = jest.requireActual('@grafana/runtime'); + actual.config.featureToggles = { multiPropsVariables: true }; + return actual; +}); + describe('CustomVariableForm', () => { const onQueryChange = jest.fn(); const onMultiChange = jest.fn(); @@ -130,4 +137,71 @@ describe('CustomVariableForm', () => { expect(onMultiChange).not.toHaveBeenCalled(); expect(onIncludeAllChange).not.toHaveBeenCalled(); }); + + describe('JSON values format', () => { + test('should render the form fields correctly', async () => { + const { getByTestId, queryByTestId } = render( + + ); + + await userEvent.click(screen.getByText('JSON')); + + const multiCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + const allowCustomValueCheckbox = queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch + ); + const includeAllCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + const allValueInput = queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + + expect(multiCheckbox).toBeInTheDocument(); + expect(multiCheckbox).toBeChecked(); + expect(includeAllCheckbox).toBeInTheDocument(); + expect(includeAllCheckbox).toBeChecked(); + + expect(allowCustomValueCheckbox).not.toBeInTheDocument(); + expect(allValueInput).not.toBeInTheDocument(); + }); + + test('should display validation error', async () => { + const validationError = new Error('Ooops! Validation error.'); + + const { findByText } = render( + + ); + + await userEvent.click(screen.getByText('JSON')); + + const errorEl = await findByText(validationError.message); + expect(errorEl).toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx index b3c78330156..662cf639b00 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx @@ -1,7 +1,10 @@ import { FormEvent } from 'react'; +import { CustomVariableModel } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; +import { Alert, FieldValidationMessage, Icon, RadioButtonGroup, Stack, TextLink, Tooltip } from '@grafana/ui'; import { SelectionOptionsForm } from './SelectionOptionsForm'; import { VariableLegend } from './VariableLegend'; @@ -9,10 +12,12 @@ import { VariableTextAreaField } from './VariableTextAreaField'; interface CustomVariableFormProps { query: string; + valuesFormat?: CustomVariableModel['valuesFormat']; multi: boolean; allValue?: string | null; includeAll: boolean; allowCustomValue?: boolean; + queryValidationError?: Error; onQueryChange: (event: FormEvent) => void; onMultiChange: (event: FormEvent) => void; onIncludeAllChange: (event: FormEvent) => void; @@ -20,9 +25,137 @@ interface CustomVariableFormProps { onQueryBlur?: (event: FormEvent) => void; onAllValueBlur?: (event: FormEvent) => void; onAllowCustomValueChange?: (event: FormEvent) => void; + onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void; } export function CustomVariableForm({ + query, + valuesFormat, + multi, + allValue, + includeAll, + allowCustomValue, + queryValidationError, + onQueryChange, + onMultiChange, + onIncludeAllChange, + onAllValueChange, + onAllowCustomValueChange, + onValuesFormatChange, +}: CustomVariableFormProps) { + if (!config.featureToggles.multiPropsVariables) { + return ( + + ); + } + + return ( + <> + + Custom options + + + + + + {queryValidationError && {queryValidationError.message}} + + + Selection options + + + + ); +} + +interface ValuesFormatSelectorProps { + valuesFormat?: CustomVariableModel['valuesFormat']; + onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void; +} + +export function ValuesFormatSelector({ valuesFormat, onValuesFormatChange }: ValuesFormatSelectorProps) { + return ( + + + {valuesFormat === 'json' && ( + + Provide a JSON representing an array of objects, where each object can have any number of properties. +
+ Check{' '} + + our docs + {' '} + for more information. + + } + placement="top" + interactive + > + +
+ )} +
+ ); +} + +function CustomVariableFormNonMultiProps({ + displayMultiPropsWarningBanner, query, multi, allValue, @@ -33,13 +166,23 @@ export function CustomVariableForm({ onIncludeAllChange, onAllValueChange, onAllowCustomValueChange, -}: CustomVariableFormProps) { +}: CustomVariableFormProps & { displayMultiPropsWarningBanner: boolean }) { return ( <> Custom options + {displayMultiPropsWarningBanner && ( +
+ {/* eslint-disable-next-line @grafana/i18n/no-untranslated-strings */} + + This feature is temporarily disabled, sorry for any inconvenience. Please recreate these options without + multi-properties. + +
+ )} + ) => void; onAllowCustomValueChange?: (event: ChangeEvent) => void; onIncludeAllChange: (event: ChangeEvent) => void; @@ -20,8 +22,10 @@ interface SelectionOptionsFormProps { export function SelectionOptionsForm({ multi, allowCustomValue, + disableAllowCustomValue, includeAll, allValue, + disableCustomAllValue, onMultiChange, onAllowCustomValueChange, onIncludeAllChange, @@ -39,18 +43,19 @@ export function SelectionOptionsForm({ onChange={onMultiChange} testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch} /> - {onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup - - )} + {!disableAllowCustomValue && + onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup + + )} - {includeAll && ( + {!disableCustomAllValue && includeAll && ( { +export const VariableValuesPreview = ({ options, hasMultiProps }: Props) => { + const styles = useStyles2(getStyles); + const hasOptions = options.length > 0; + const displayMultiPropsPreview = config.featureToggles.multiPropsVariables && hasMultiProps; + + return ( +
+ + + Preview of values ({'{{count}}'}) + + {hasOptions && displayMultiPropsPreview && } + {hasOptions && !displayMultiPropsPreview && } + +
+ ); +}; + +function VariableValuesWithPropsPreview({ options }: { options: VariableValueOption[] }) { + const styles = useStyles2(getStyles); + + const { data, columns } = useMemo(() => { + const data = options.map(({ label, value, properties }) => ({ + label: String(label), + value: String(value), + ...flattenProperties(properties), + })); + + return { + data, + columns: Object.keys(data[0] ?? {}).map((id) => ({ + id, + // see https://github.com/TanStack/table/issues/1671 + header: unsanitizeKey(id), + sortType: 'alphanumeric' as const, + })), + }; + }, [options]); + + return ( + String(r.value)} + pageSize={8} + /> + ); +} + +const sanitizeKey = (key: string) => key.replace(/\./g, '__dot__'); +const unsanitizeKey = (key: string) => key.replace(/__dot__/g, '.'); + +function flattenProperties(properties?: VariableValueOptionProperties, path = ''): Record { + if (properties === undefined) { + return {}; + } + + const result: Record = {}; + + for (const [key, value] of Object.entries(properties)) { + const newPath = path ? `${path}.${key}` : key; + + if (typeof value === 'object') { + Object.assign(result, flattenProperties(value, newPath)); + } else { + // see https://github.com/TanStack/table/issues/1671 + result[sanitizeKey(newPath)] = value; + } + } + + return result; +} + +function VariableValuesWithoutPropsPreview({ options }: { options: VariableValueOption[] }) { + const styles = useStyles2(getStyles); const [previewLimit, setPreviewLimit] = useState(20); const [previewOptions, setPreviewOptions] = useState([]); const showMoreOptions = useCallback( @@ -21,18 +98,10 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) = }, [previewLimit, setPreviewLimit] ); - const styles = useStyles2(getStyles); useEffect(() => setPreviewOptions(options.slice(0, previewLimit)), [previewLimit, options]); - if (!previewOptions.length) { - return null; - } - return ( -
- - Preview of values - + <> {previewOptions.map((o, index) => ( @@ -49,16 +118,17 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) = )} -
+ ); -}; -VariableValuesPreview.displayName = 'VariableValuesPreview'; +} +VariableValuesWithoutPropsPreview.displayName = 'VariableValuesWithoutPropsPreview'; function getStyles(theme: GrafanaTheme2) { return { - wrapper: css({ + previewContainer: css({ display: 'flex', flexDirection: 'column', + gap: theme.spacing(1), marginTop: theme.spacing(2), }), optionContainer: css({ @@ -71,5 +141,10 @@ function getStyles(theme: GrafanaTheme2) { textOverflow: 'ellipsis', maxWidth: '50vw', }), + table: css({ + td: css({ + padding: theme.spacing(0.5, 1), + }), + }), }; } diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.test.tsx index a7315d37a76..1be01c10f56 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.test.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.test.tsx @@ -5,117 +5,231 @@ import { CustomVariable } from '@grafana/scenes'; import { CustomVariableEditor } from './CustomVariableEditor'; +jest.mock('@grafana/runtime', () => { + const actual = jest.requireActual('@grafana/runtime'); + actual.config.featureToggles = { multiPropsVariables: true }; + return actual; +}); + +function setup(options: Partial[0]> = {}) { + return { + variable: new CustomVariable({ + name: 'customVar', + ...options, + }), + onRunQuery: jest.fn(), + }; +} + +function renderEditor(ui: React.ReactNode) { + const renderResult = render(ui); + + const elements = { + formatButton: (label: string) => renderResult.queryByLabelText(label) as HTMLElement, + queryInput: () => + renderResult.queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput + ) as HTMLTextAreaElement, + multiValueCheckbox: () => + renderResult.queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ) as HTMLInputElement, + allowCustomValueCheckbox: () => + renderResult.queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch + ) as HTMLInputElement, + includeAllCheckbox: () => + renderResult.queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ) as HTMLInputElement, + customAllValueInput: () => + renderResult.queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ) as HTMLInputElement, + }; + + return { + ...renderResult, + elements, + actions: { + updateValuesInput(newQuery: string) { + fireEvent.change(elements.queryInput(), { target: { value: newQuery } }); + fireEvent.blur(elements.queryInput()); + }, + changeValuesFormat(newFormat: 'csv' | 'json') { + const targetLabel = newFormat === 'json' ? 'JSON' : 'CSV'; + + const formatButton = elements.formatButton(targetLabel); + if (formatButton === null) { + throw new Error(`Unable to fire a "click" event - button with label "${targetLabel}" not found in DOM`); + } + + fireEvent.click(formatButton); + }, + }, + }; +} + describe('CustomVariableEditor', () => { - it('should render the CustomVariableForm with correct initial values', () => { - const variable = new CustomVariable({ - name: 'customVar', - query: 'test, test2', - value: 'test', - isMulti: true, - includeAll: true, - allValue: 'test', + describe('CSV values format', () => { + it('should render CustomVariableForm with the correct initial values', () => { + const { variable, onRunQuery } = setup({ + query: 'test, test2', + value: 'test', + isMulti: true, + includeAll: true, + allowCustomValue: true, + allValue: 'all', + }); + + const { elements } = renderEditor(); + + expect(elements.queryInput().value).toBe('test, test2'); + expect(elements.multiValueCheckbox().checked).toBe(true); + expect(elements.allowCustomValueCheckbox().checked).toBe(true); + expect(elements.includeAllCheckbox().checked).toBe(true); + expect(elements.customAllValueInput().value).toBe('all'); }); - const onRunQuery = jest.fn(); - const { getByTestId } = render(); + it('should update the variable state when some input values change ("Multi-value", "Allow custom values" & "Include All option")', () => { + const { variable, onRunQuery } = setup({ + query: 'test, test2', + value: 'test', + isMulti: false, + allowCustomValue: false, + includeAll: false, + }); - const queryInput = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput - ) as HTMLInputElement; - const allValueInput = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput - ) as HTMLInputElement; - const multiCheckbox = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch - ) as HTMLInputElement; - const includeAllCheckbox = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch - ) as HTMLInputElement; + const { elements } = renderEditor(); - expect(queryInput.value).toBe('test, test2'); - expect(allValueInput.value).toBe('test'); - expect(multiCheckbox.checked).toBe(true); - expect(includeAllCheckbox.checked).toBe(true); + expect(elements.multiValueCheckbox().checked).toBe(false); + expect(elements.allowCustomValueCheckbox().checked).toBe(false); + expect(elements.includeAllCheckbox().checked).toBe(false); + // include-all-custom input appears after include-all checkbox is checked only + expect(elements.customAllValueInput()).not.toBeInTheDocument(); + + fireEvent.click(elements.multiValueCheckbox()); + fireEvent.click(elements.allowCustomValueCheckbox()); + fireEvent.click(elements.includeAllCheckbox()); + + expect(variable.state.isMulti).toBe(true); + expect(variable.state.allowCustomValue).toBe(true); + expect(variable.state.includeAll).toBe(true); + expect(elements.customAllValueInput()).toBeInTheDocument(); + }); + + describe('when the values textarea loses focus after its value has changed', () => { + it('should update the query in the variable state and call the onRunQuery callback', async () => { + const { variable, onRunQuery } = setup({ query: 'test, test2', value: 'test' }); + + const { actions } = renderEditor(); + + actions.updateValuesInput('test3, test4'); + + expect(variable.state.query).toBe('test3, test4'); + expect(onRunQuery).toHaveBeenCalled(); + }); + }); + + describe('when the "Custom all value" input loses focus after its value has changed', () => { + it('should update the variable state', () => { + const { variable, onRunQuery } = setup({ + query: 'test, test2', + value: 'test', + isMulti: true, + includeAll: true, + }); + + const { elements } = renderEditor(); + + fireEvent.change(elements.customAllValueInput(), { target: { value: 'new custom all' } }); + fireEvent.blur(elements.customAllValueInput()); + + expect(variable.state.allValue).toBe('new custom all'); + }); + }); }); - it('should update the variable state when input values change', () => { - const variable = new CustomVariable({ - name: 'customVar', - query: 'test, test2', - value: 'test', + describe('JSON values format', () => { + const initialJsonQuery = `[ + {"value":1,"text":"Development","aws":"dev","azure":"development"}, + {"value":2,"text":"Production","aws":"prod","azure":"production"} + ]`; + + it('should render CustomVariableForm with the correct initial values', () => { + const { variable, onRunQuery } = setup({ + valuesFormat: 'json', + query: initialJsonQuery, + isMulti: true, + includeAll: true, + }); + + const { elements } = renderEditor(); + + expect(elements.queryInput().value).toBe(initialJsonQuery); + expect(elements.multiValueCheckbox().checked).toBe(true); + expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument(); + expect(elements.includeAllCheckbox().checked).toBe(true); + expect(elements.customAllValueInput()).not.toBeInTheDocument(); }); - const onRunQuery = jest.fn(); - const { getByTestId } = render(); + describe('when the values textarea loses focus after its value has changed', () => { + describe('if the value is valid JSON', () => { + it('should update the query in the variable state and call the onRunQuery callback', async () => { + const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery }); - const multiCheckbox = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch - ); - const includeAllCheckbox = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch - ); + const { actions } = renderEditor(); - const allowCustomValueCheckbox = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch - ); + actions.updateValuesInput('[]'); - // It include-all-custom input appears after include-all checkbox is checked only - expect(() => - getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput) - ).toThrow('Unable to find an element'); + expect(variable.state.query).toBe('[]'); + expect(onRunQuery).toHaveBeenCalled(); + }); + }); - fireEvent.click(allowCustomValueCheckbox); + describe('if the value is NOT valid JSON', () => { + it('should display a validation error message and neither update the query in the variable state nor call the onRunQuery callback', async () => { + const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery }); - fireEvent.click(multiCheckbox); + const { actions, getByRole } = renderEditor( + + ); - fireEvent.click(includeAllCheckbox); - const allValueInput = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput - ); + actions.updateValuesInput('[x]'); - expect(variable.state.isMulti).toBe(true); - expect(variable.state.includeAll).toBe(true); - expect(variable.state.allowCustomValue).toBe(false); - expect(allValueInput).toBeInTheDocument(); + expect(getByRole('alert')).toHaveTextContent(`Unexpected token 'x', "[x]" is not valid JSON`); + expect(variable.state.query).toBe(initialJsonQuery); + expect(onRunQuery).not.toHaveBeenCalled(); + }); + }); + }); }); - it('should call update query and re-run query when input loses focus', async () => { - const variable = new CustomVariable({ - name: 'customVar', - query: 'test, test2', - value: 'test', + describe('when switching values format', () => { + it('should switch the visibility of the proper form inputs ("Allow custom values" and "Custom all value")', () => { + const { variable, onRunQuery } = setup({ + valuesFormat: 'csv', + query: '', + isMulti: true, + includeAll: true, + allowCustomValue: true, + allValue: '', + }); + + const { elements, actions } = renderEditor(); + + expect(elements.allowCustomValueCheckbox()).toBeInTheDocument(); + expect(elements.customAllValueInput()).toBeInTheDocument(); + + actions.changeValuesFormat('json'); + + expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument(); + expect(elements.customAllValueInput()).not.toBeInTheDocument(); + + actions.changeValuesFormat('csv'); + + expect(elements.allowCustomValueCheckbox()).toBeInTheDocument(); + expect(elements.customAllValueInput()).toBeInTheDocument(); }); - const onRunQuery = jest.fn(); - - const { getByTestId } = render(); - - const queryInput = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput); - fireEvent.change(queryInput, { target: { value: 'test3, test4' } }); - fireEvent.blur(queryInput); - - expect(onRunQuery).toHaveBeenCalled(); - expect(variable.state.query).toBe('test3, test4'); - }); - - it('should update the variable state when all-custom-value input loses focus', () => { - const variable = new CustomVariable({ - name: 'customVar', - query: 'test, test2', - value: 'test', - isMulti: true, - includeAll: true, - }); - const onRunQuery = jest.fn(); - - const { getByTestId } = render(); - - const allValueInput = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput - ) as HTMLInputElement; - - fireEvent.change(allValueInput, { target: { value: 'new custom all' } }); - fireEvent.blur(allValueInput); - - expect(variable.state.allValue).toBe('new custom all'); }); }); diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx index 1d73d5a6f2b..048fe383890 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx @@ -1,16 +1,42 @@ -import { FormEvent, useCallback } from 'react'; +import { isObject } from 'lodash'; +import { FormEvent, useCallback, useState } from 'react'; -import { CustomVariable } from '@grafana/scenes'; +import { CustomVariableModel, shallowCompare } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; +import { CustomVariable, SceneVariable } from '@grafana/scenes'; +import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { CustomVariableForm } from '../../components/CustomVariableForm'; +import { PaneItem } from './PaneItem'; + interface CustomVariableEditorProps { variable: CustomVariable; onRunQuery: () => void; } export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEditorProps) { - const { query, isMulti, allValue, includeAll, allowCustomValue } = variable.useState(); + const { query, valuesFormat, isMulti, allValue, includeAll, allowCustomValue } = variable.useState(); + const [queryValidationError, setQueryValidationError] = useState(); + + const [prevQuery, setPrevQuery] = useState(''); + const onValuesFormatChange = useCallback( + (format: CustomVariableModel['valuesFormat']) => { + variable.setState({ query: prevQuery }); + variable.setState({ value: isMulti ? [] : undefined }); + variable.setState({ valuesFormat: format }); + variable.setState({ allowCustomValue: false }); + variable.setState({ allValue: undefined }); + onRunQuery(); + + setQueryValidationError(undefined); + if (query !== prevQuery) { + setPrevQuery(query); + } + }, + [isMulti, onRunQuery, prevQuery, query, variable] + ); const onMultiChange = useCallback( (event: FormEvent) => { @@ -28,10 +54,24 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi const onQueryChange = useCallback( (event: FormEvent) => { + setPrevQuery(''); + + if (config.featureToggles.multiPropsVariables && valuesFormat === 'json') { + const validationError = validateJsonQuery(event.currentTarget.value.trim()); + setQueryValidationError(validationError); + if (validationError) { + return; + } + } + + if (!config.featureToggles.multiPropsVariables) { + variable.setState({ valuesFormat: 'csv' }); + } + variable.setState({ query: event.currentTarget.value }); onRunQuery(); }, - [variable, onRunQuery] + [valuesFormat, variable, onRunQuery] ); const onAllValueChange = useCallback( @@ -51,15 +91,75 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi return ( ); } + +export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneItemDescriptor[] { + if (!(variable instanceof CustomVariable)) { + return []; + } + + return [ + new OptionsPaneItemDescriptor({ + title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'), + id: 'custom-variable-values', + render: ({ props }) => , + }), + ]; +} + +export const validateJsonQuery = (query: string): Error | undefined => { + if (!query) { + return; + } + + try { + const options = JSON.parse(query); + + if (!Array.isArray(options)) { + throw new Error('Enter a valid JSON array of objects'); + } + + if (!options.length) { + return; + } + + let errorIndex = options.findIndex((item) => !isObject(item)); + if (errorIndex !== -1) { + throw new Error(`All items must be objects. The item at index ${errorIndex} is not an object.`); + } + + const keys = Object.keys(options[0]); + if (!keys.includes('value')) { + throw new Error('Each object in the array must include at least a "value" property'); + } + if (keys.includes('')) { + throw new Error('Object property names cannot be empty strings'); + } + + errorIndex = options.findIndex((o) => !shallowCompare(keys, Object.keys(o))); + if (errorIndex !== -1) { + throw new Error( + `All objects must have the same set of properties. The object at index ${errorIndex} does not match the expected properties` + ); + } + + return; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return error as Error; + } +}; diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx index aed926a6809..aeb2be59af6 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx @@ -1,23 +1,43 @@ -import { useRef, useState } from 'react'; +import { FormEvent, useMemo, useRef, useState } from 'react'; import { lastValueFrom } from 'rxjs'; +import { CustomVariableModel } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; -import { CustomVariable, VariableValueOption, VariableValueSingle } from '@grafana/scenes'; -import { Button, Modal, Stack } from '@grafana/ui'; +import { config } from '@grafana/runtime'; +import { CustomVariable } from '@grafana/scenes'; +import { Button, FieldValidationMessage, Modal, Stack, TextArea } from '@grafana/ui'; import { dashboardEditActions } from '../../../../edit-pane/shared'; -import { VariableStaticOptionsForm, VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm'; -import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton'; +import { ValuesFormatSelector } from '../../components/CustomVariableForm'; import { VariableValuesPreview } from '../../components/VariableValuesPreview'; +import { validateJsonQuery } from './CustomVariableEditor'; +import { ModalEditorNonMultiProps } from './ModalEditorNonMultiProps'; + interface ModalEditorProps { variable: CustomVariable; onClose: () => void; } export function ModalEditor(props: ModalEditorProps) { - const { formRef, onCloseModal, options, onChangeOptions, onAddNewOption, onSaveOptions } = useModalEditor(props); + if (!config.featureToggles.multiPropsVariables) { + return ; + } + return ; +} + +function ModalEditorMultiProps(props: ModalEditorProps) { + const { + valuesFormat, + query, + queryValidationError, + options, + onCloseModal, + onValuesFormatChange, + onQueryChange, + onSaveOptions, + } = useModalEditor(props); return ( - - + +
+