From e310d5e8ee249d7424876de8758b425c7161af62 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 5 Jan 2026 15:27:35 +0000 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 04/17] 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 05/17] 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 06/17] 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 07/17] 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 08/17] 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 09/17] 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 10/17] 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 11/17] 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 12/17] 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 13/17] 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 14/17] 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 15/17] 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 16/17] 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 17/17] 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 (