diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0059f2f0559..49c1504b884 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -502,7 +502,6 @@ i18next.config.ts @grafana/grafana-frontend-platform /e2e-playwright/various-suite/filter-annotations.spec.ts @grafana/dashboards-squad /e2e-playwright/various-suite/frontend-sandbox-app.spec.ts @grafana/plugins-platform-frontend /e2e-playwright/various-suite/frontend-sandbox-datasource.spec.ts @grafana/plugins-platform-frontend -/e2e-playwright/various-suite/gauge.spec.ts @grafana/dataviz-squad /e2e-playwright/various-suite/grafana-datasource-random-walk.spec.ts @grafana/grafana-frontend-platform /e2e-playwright/various-suite/graph-auto-migrate.spec.ts @grafana/dataviz-squad /e2e-playwright/various-suite/inspect-drawer.spec.ts @grafana/dashboards-squad @@ -521,7 +520,7 @@ i18next.config.ts @grafana/grafana-frontend-platform /e2e-playwright/various-suite/solo-route.spec.ts @grafana/dashboards-squad /e2e-playwright/various-suite/trace-view-scrolling.spec.ts @grafana/observability-traces-and-profiling /e2e-playwright/various-suite/verify-i18n.spec.ts @grafana/grafana-frontend-platform -/e2e-playwright/various-suite/visualization-suggestions.spec.ts @grafana/dataviz-squad +/e2e-playwright/various-suite/visualization-suggestions*.spec.ts @grafana/dataviz-squad /e2e-playwright/various-suite/perf-test.spec.ts @grafana/grafana-frontend-platform # Packages diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 646ceed9a86..314726c5ecb 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -54,6 +54,7 @@ require ( github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect github.com/BurntSushi/toml v1.5.0 // indirect + github.com/Machiel/slugify v1.0.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect @@ -157,7 +158,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/google/wire v0.7.0 // indirect - github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 750d9f97fc5..112228d6ed8 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -115,6 +115,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= github.com/IBM/pgxpoolprometheus v1.1.2 h1:sHJwxoL5Lw4R79Zt+H4Uj1zZ4iqXJLdk7XDE7TPs97U= github.com/IBM/pgxpoolprometheus v1.1.2/go.mod h1:+vWzISN6S9ssgurhUNmm6AlXL9XLah3TdWJktquKTR8= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -619,8 +621,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/alerting/historian/go.mod b/apps/alerting/historian/go.mod index fb624d65db3..a79829d45c2 100644 --- a/apps/alerting/historian/go.mod +++ b/apps/alerting/historian/go.mod @@ -4,7 +4,7 @@ go 1.25.5 require ( github.com/go-kit/log v0.2.1 - github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 diff --git a/apps/alerting/historian/go.sum b/apps/alerting/historian/go.sum index 0835100976a..d45d418dfb8 100644 --- a/apps/alerting/historian/go.sum +++ b/apps/alerting/historian/go.sum @@ -243,8 +243,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo= diff --git a/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection.go b/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection.go index db3353b66a1..269fb51bd70 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection.go +++ b/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection.go @@ -180,12 +180,15 @@ func countAnnotationsV0V1(spec map[string]interface{}) int { return 0 } - annotationList, ok := annotations["list"].([]interface{}) - if !ok { - return 0 + // Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation) + if annotationList, ok := annotations["list"].([]interface{}); ok { + return len(annotationList) + } + if annotationList, ok := annotations["list"].([]map[string]interface{}); ok { + return len(annotationList) } - return len(annotationList) + return 0 } // countLinksV0V1 counts dashboard links in v0alpha1 or v1beta1 dashboard spec @@ -194,12 +197,15 @@ func countLinksV0V1(spec map[string]interface{}) int { return 0 } - links, ok := spec["links"].([]interface{}) - if !ok { - return 0 + // Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation) + if links, ok := spec["links"].([]interface{}); ok { + return len(links) + } + if links, ok := spec["links"].([]map[string]interface{}); ok { + return len(links) } - return len(links) + return 0 } // countVariablesV0V1 counts template variables in v0alpha1 or v1beta1 dashboard spec @@ -213,12 +219,15 @@ func countVariablesV0V1(spec map[string]interface{}) int { return 0 } - variableList, ok := templating["list"].([]interface{}) - if !ok { - return 0 + // Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation) + if variableList, ok := templating["list"].([]interface{}); ok { + return len(variableList) + } + if variableList, ok := templating["list"].([]map[string]interface{}); ok { + return len(variableList) } - return len(variableList) + return 0 } // collectStatsV0V1 collects statistics from v0alpha1 or v1beta1 dashboard diff --git a/apps/dashboard/pkg/migration/conversion/metrics.go b/apps/dashboard/pkg/migration/conversion/metrics.go index 5a60aa848de..9cbdec193e8 100644 --- a/apps/dashboard/pkg/migration/conversion/metrics.go +++ b/apps/dashboard/pkg/migration/conversion/metrics.go @@ -85,20 +85,20 @@ func withConversionMetrics(sourceVersionAPI, targetVersionAPI string, conversion // Only track schema versions for v0/v1 dashboards (v2+ info is redundant with API version) switch source := a.(type) { case *dashv0.Dashboard: - dashboardUID = string(source.UID) + dashboardUID = source.Name if source.Spec.Object != nil { sourceSchemaVersion = schemaversion.GetSchemaVersion(source.Spec.Object) } case *dashv1.Dashboard: - dashboardUID = string(source.UID) + dashboardUID = source.Name if source.Spec.Object != nil { sourceSchemaVersion = schemaversion.GetSchemaVersion(source.Spec.Object) } case *dashv2alpha1.Dashboard: - dashboardUID = string(source.UID) + dashboardUID = source.Name // Don't track schema version for v2+ (redundant with API version) case *dashv2beta1.Dashboard: - dashboardUID = string(source.UID) + dashboardUID = source.Name // Don't track schema version for v2+ (redundant with API version) } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v0alpha1.json index 5a6cebb8a91..e181c66eaf1 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v1beta1.json index 122c6772dfe..6c7da8a3864 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v10.table_thresholds.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v0alpha1.json index 0442d15c222..ed6e158988b 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v1beta1.json index e1548f77853..dab8e4d166c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v11.no-op-migration.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v0alpha1.json index 7aadfcf8535..0d943be33e1 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -93,6 +105,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -115,6 +131,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v1beta1.json index 7f7f61699e5..7d51fccb5f4 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.graph_thresholds.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -93,6 +105,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -115,6 +131,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v0alpha1.json index e768e617055..d26aeb546b0 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v1beta1.json index 2c88ea03447..1dc3f1743cd 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v13.minimal_graph_config.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v0alpha1.json index 6f6b04077da..cefd07bd3b0 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -50,6 +54,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v1beta1.json index d83cb315419..765bf5993d3 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v14.shared_crosshair_to_graph_tooltip.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -50,6 +54,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v0alpha1.json index cc366a688e3..556e8ee5040 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v0alpha1.json @@ -65,6 +65,9 @@ "type": "row" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Versions running\nShows the versions reported by each running pod.\n\nThe rollout will fail if any pod is not running the expected version.\n\nPods in green are running the expected version, while pods running other versions are shown in orange.\n\n", "fieldConfig": { "defaults": { @@ -185,6 +188,9 @@ "type": "barchart" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Deployment rollout progress\nShows the number of pods for each `Deployment` that match the desired configuration, as a proportion of the desired number of pods.\n\nThe rollout will fail if insufficient pods match the desired configuration for any `Deployment`.\n\nPods in green match the desired configuration, while pods that do not match the desired configuration are shown in orange.\n\n", "fieldConfig": { "defaults": { @@ -286,6 +292,9 @@ "type": "barchart" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### StatefulSet rollout progress\nShows the number of pods for each `StatefulSet` that match the desired configuration, as a proportion of the desired number of pods.\n\nThe rollout will fail if insufficient pods match the desired configuration for any `StatefulSet`.\n\nPods in green match the desired configuration, while pods that do not match the desired configuration are shown in orange.\n\n", "fieldConfig": { "defaults": { @@ -399,6 +408,9 @@ "type": "row" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Aggregator lag\nShows the consumption lag of each aggregator pod.\n\nThis panel may show no data if aggregators are not deployed to this cell.\n\nThe rollout will fail if any pod's consumption lag is both:\n* greater than 30s (red area on graph), and\n* trending upwards compared to 1 minute earlier\n\n", "fieldConfig": { "defaults": { @@ -468,6 +480,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Unhealthy Deployment replicas\nShows the number of unavailable pods for each `Deployment`.\n\nThe rollout will fail if any `Deployment` has an unavailable pod.\n\nBoth this panel and the rollout check ignore any `Deployment`s that require spot nodes, as these are expected to be unavailable from time to time.\n\n`Deployment`s shown in green do not have any unavailable pods, while `Deployment`s shown in orange have one or more unavailable pods.\n\n", "fieldConfig": { "defaults": { @@ -569,6 +584,9 @@ "type": "barchart" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Unhealthy StatefulSet replicas\nShows the number of pods for each `StatefulSet` that are not ready.\n\nThe rollout will fail if any `StatefulSet` has fewer ready pods than requested.\n\nBoth this panel and the rollout check ignore any `StatefulSets`s that require spot nodes, as these are expected to be unavailable from time to time.\n\n`StatefulSets`s shown in green do not have any pods that are not ready, while `StatefulSet`s shown in orange have one or more pods that are not ready.\n\n", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v1beta1.json index cbe61ee0ed7..0b50d42fbe7 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.mimir_rollout_debugging.v1beta1.json @@ -65,6 +65,9 @@ "type": "row" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Versions running\nShows the versions reported by each running pod.\n\nThe rollout will fail if any pod is not running the expected version.\n\nPods in green are running the expected version, while pods running other versions are shown in orange.\n\n", "fieldConfig": { "defaults": { @@ -185,6 +188,9 @@ "type": "barchart" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Deployment rollout progress\nShows the number of pods for each `Deployment` that match the desired configuration, as a proportion of the desired number of pods.\n\nThe rollout will fail if insufficient pods match the desired configuration for any `Deployment`.\n\nPods in green match the desired configuration, while pods that do not match the desired configuration are shown in orange.\n\n", "fieldConfig": { "defaults": { @@ -286,6 +292,9 @@ "type": "barchart" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### StatefulSet rollout progress\nShows the number of pods for each `StatefulSet` that match the desired configuration, as a proportion of the desired number of pods.\n\nThe rollout will fail if insufficient pods match the desired configuration for any `StatefulSet`.\n\nPods in green match the desired configuration, while pods that do not match the desired configuration are shown in orange.\n\n", "fieldConfig": { "defaults": { @@ -399,6 +408,9 @@ "type": "row" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Aggregator lag\nShows the consumption lag of each aggregator pod.\n\nThis panel may show no data if aggregators are not deployed to this cell.\n\nThe rollout will fail if any pod's consumption lag is both:\n* greater than 30s (red area on graph), and\n* trending upwards compared to 1 minute earlier\n\n", "fieldConfig": { "defaults": { @@ -468,6 +480,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Unhealthy Deployment replicas\nShows the number of unavailable pods for each `Deployment`.\n\nThe rollout will fail if any `Deployment` has an unavailable pod.\n\nBoth this panel and the rollout check ignore any `Deployment`s that require spot nodes, as these are expected to be unavailable from time to time.\n\n`Deployment`s shown in green do not have any unavailable pods, while `Deployment`s shown in orange have one or more unavailable pods.\n\n", "fieldConfig": { "defaults": { @@ -569,6 +584,9 @@ "type": "barchart" }, { + "datasource": { + "uid": "$datasource" + }, "description": "### Unhealthy StatefulSet replicas\nShows the number of pods for each `StatefulSet` that are not ready.\n\nThe rollout will fail if any `StatefulSet` has fewer ready pods than requested.\n\nBoth this panel and the rollout check ignore any `StatefulSets`s that require spot nodes, as these are expected to be unavailable from time to time.\n\n`StatefulSets`s shown in green do not have any pods that are not ready, while `StatefulSet`s shown in orange have one or more pods that are not ready.\n\n", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v0alpha1.json index dd68f978199..720fff258cc 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v0alpha1.json @@ -37,6 +37,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -59,6 +63,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v1beta1.json index 6efa2fd1853..370eafaad0c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v15.no-op-migration.v1beta1.json @@ -37,6 +37,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -59,6 +63,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v0alpha1.json index 3d8d1b11ad3..99a2102204c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v0alpha1.json @@ -44,6 +44,9 @@ "liveNow": false, "panels": [ { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample metric showing connection count.", "fieldConfig": { "defaults": { @@ -134,6 +137,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample counter metric.", "fieldConfig": { "defaults": { @@ -224,6 +230,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample error metric.", "fieldConfig": { "defaults": { @@ -314,6 +323,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample event rate metric.", "fieldConfig": { "defaults": { @@ -404,6 +416,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample resource utilization metric.", "fieldConfig": { "defaults": { @@ -493,6 +508,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample memory allocation metrics.", "fieldConfig": { "defaults": { @@ -593,6 +611,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample utilization percentage.", "fieldConfig": { "defaults": { @@ -662,6 +683,9 @@ "type": "row" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample latency metric for primary operations.", "fieldConfig": { "defaults": { @@ -748,6 +772,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample latency metric for secondary operations.", "fieldConfig": { "defaults": { @@ -834,6 +861,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample expansion events metric.", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v1beta1.json index e93a8eb3132..58f46289f57 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.empty-rows-and-panels-array.v1beta1.json @@ -44,6 +44,9 @@ "liveNow": false, "panels": [ { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample metric showing connection count.", "fieldConfig": { "defaults": { @@ -134,6 +137,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample counter metric.", "fieldConfig": { "defaults": { @@ -224,6 +230,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample error metric.", "fieldConfig": { "defaults": { @@ -314,6 +323,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample event rate metric.", "fieldConfig": { "defaults": { @@ -404,6 +416,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample resource utilization metric.", "fieldConfig": { "defaults": { @@ -493,6 +508,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample memory allocation metrics.", "fieldConfig": { "defaults": { @@ -593,6 +611,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample utilization percentage.", "fieldConfig": { "defaults": { @@ -662,6 +683,9 @@ "type": "row" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample latency metric for primary operations.", "fieldConfig": { "defaults": { @@ -748,6 +772,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample latency metric for secondary operations.", "fieldConfig": { "defaults": { @@ -834,6 +861,9 @@ "type": "timeseries" }, { + "datasource": { + "uid": "${example_datasource}" + }, "description": "Sample expansion events metric.", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v0alpha1.json index 286cc75e2c0..025ec384a0c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v0alpha1.json @@ -39,6 +39,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 12, @@ -61,6 +65,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 12, @@ -151,6 +159,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -173,6 +185,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -195,6 +211,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -229,6 +249,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 16, @@ -251,6 +275,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 8, @@ -286,6 +314,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 24, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v1beta1.json index a4d76abe265..1334aad64b4 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.grid_layout_upgrade.v1beta1.json @@ -39,6 +39,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 12, @@ -61,6 +65,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 12, @@ -151,6 +159,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -173,6 +185,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -195,6 +211,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -229,6 +249,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 16, @@ -251,6 +275,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 8, @@ -286,6 +314,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 24, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v0alpha1.json index a57a713ba61..0279b19424c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v0alpha1.json @@ -41,6 +41,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 24, @@ -78,6 +82,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -103,6 +111,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -128,6 +140,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "fieldConfig": { "defaults": { "thresholds": { @@ -174,6 +190,10 @@ "type": "stat" }, { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, "gridPos": { "h": 7, "w": 8, @@ -202,6 +222,10 @@ "type": "logs" }, { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, "gridPos": { "h": 7, "w": 8, @@ -230,6 +254,10 @@ "type": "logs" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -255,6 +283,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Number of concurrent processing threads available for handling operations", "gridPos": { "h": 7, @@ -280,6 +312,10 @@ "type": "stat" }, { + "datasource": { + "type": "tempo", + "uid": "${tempo}" + }, "gridPos": { "h": 7, "w": 8, @@ -324,6 +360,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Histogram showing p99, p95, p50, and p10 percentiles for job processing duration based on number of resources changed", "fieldConfig": { "defaults": { @@ -425,6 +465,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Histogram showing p99, p95, p50, and p10 percentiles for job processing duration based on number of resources changed", "gridPos": { "h": 7, @@ -477,6 +521,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Total number of jobs waiting to be processed", "gridPos": { "h": 7, @@ -502,6 +550,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "fieldConfig": { "defaults": { "unit": "s" @@ -532,6 +584,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "How long a job is in the queue before being picked up", "gridPos": { "h": 7, @@ -584,6 +640,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -609,6 +669,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "gridPos": { "h": 7, "w": 8, @@ -633,6 +697,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "gridPos": { "h": 7, "w": 8, @@ -675,6 +743,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "gridPos": { "h": 7, "w": 8, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v1beta1.json index 61094ee5dbd..5597a468c53 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v16.span_zero_demo.v1beta1.json @@ -41,6 +41,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 24, @@ -78,6 +82,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -103,6 +111,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -128,6 +140,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "fieldConfig": { "defaults": { "thresholds": { @@ -174,6 +190,10 @@ "type": "stat" }, { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, "gridPos": { "h": 7, "w": 8, @@ -202,6 +222,10 @@ "type": "logs" }, { + "datasource": { + "type": "loki", + "uid": "${loki}" + }, "gridPos": { "h": 7, "w": 8, @@ -230,6 +254,10 @@ "type": "logs" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -255,6 +283,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Number of concurrent processing threads available for handling operations", "gridPos": { "h": 7, @@ -280,6 +312,10 @@ "type": "stat" }, { + "datasource": { + "type": "tempo", + "uid": "${tempo}" + }, "gridPos": { "h": 7, "w": 8, @@ -324,6 +360,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Histogram showing p99, p95, p50, and p10 percentiles for job processing duration based on number of resources changed", "fieldConfig": { "defaults": { @@ -425,6 +465,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Histogram showing p99, p95, p50, and p10 percentiles for job processing duration based on number of resources changed", "gridPos": { "h": 7, @@ -477,6 +521,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "Total number of jobs waiting to be processed", "gridPos": { "h": 7, @@ -502,6 +550,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "fieldConfig": { "defaults": { "unit": "s" @@ -532,6 +584,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "description": "How long a job is in the queue before being picked up", "gridPos": { "h": 7, @@ -584,6 +640,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 7, "w": 8, @@ -609,6 +669,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "gridPos": { "h": 7, "w": 8, @@ -633,6 +697,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "gridPos": { "h": 7, "w": 8, @@ -675,6 +743,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "${prom}" + }, "gridPos": { "h": 7, "w": 8, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v0alpha1.json index e169d2bfa64..ae598c96c0e 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -71,6 +79,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, @@ -93,6 +105,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 4, @@ -115,6 +131,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -137,6 +157,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, @@ -159,6 +183,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 24, @@ -181,6 +209,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 24, @@ -203,6 +235,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, @@ -225,6 +261,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v1beta1.json index b5a64a7634f..6b5a09f65ab 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v17.minspan_to_maxperrow.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -71,6 +79,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, @@ -93,6 +105,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 4, @@ -115,6 +131,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 8, @@ -137,6 +157,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, @@ -159,6 +183,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 6, "w": 24, @@ -181,6 +209,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 24, @@ -203,6 +235,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, @@ -225,6 +261,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 4, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v0alpha1.json index cc57f1b1914..a34b41d8bb6 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +75,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -98,6 +106,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -126,6 +138,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -160,6 +176,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v1beta1.json index 1fcfe058203..269085d6048 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v18.gauge_options.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +75,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -98,6 +106,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -126,6 +138,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -160,6 +176,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v0alpha1.json index 44bdfd7c1a0..d71f8e881d2 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -55,6 +59,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -83,6 +91,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -111,6 +123,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -140,6 +156,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -168,6 +188,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v1beta1.json index 432355ba671..288d4f1c8aa 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v19.panel_links.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -55,6 +59,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -83,6 +91,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -111,6 +123,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -140,6 +156,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -168,6 +188,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v0alpha1.json index ec7aab7bc7a..ea66d446c51 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -50,6 +54,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -73,6 +81,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -95,6 +107,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v1beta1.json index 5aaa48878d6..bd99678a8ff 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v2.panels-and-services.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -50,6 +54,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -73,6 +81,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -95,6 +107,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v0alpha1.json index 40a06e400a4..7e234377108 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -62,6 +66,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -102,6 +110,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 24, @@ -144,6 +156,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -186,6 +202,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v1beta1.json index a914c1f29b6..4f519fb3d9c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v20.variable_syntax_links.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -62,6 +66,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -102,6 +110,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 24, @@ -144,6 +156,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -186,6 +202,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v0alpha1.json index 25823b0fd99..c4240b5dd20 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -60,6 +64,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -97,6 +105,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -136,6 +148,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -165,6 +181,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v1beta1.json index 6f5594d57f0..90783f28c4d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v21.data_links_series_to_field.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -60,6 +64,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -97,6 +105,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -136,6 +148,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -165,6 +181,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v0alpha1.json index ab9f271d900..a6e6e123cdb 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v1beta1.json index b17a18593b8..19143fd0ac0 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v22.table_panel_align.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v0alpha1.json index 6bf600c5014..f55f52af9cf 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v1beta1.json index 4f174d6742a..dd7c60c0e90 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v23.multi_variable_alignment.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v0alpha1.json index 876be802767..aeec2f8300d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests basic migration with default style pattern (/.*/) containing thresholds and colors. Should convert styles to fieldConfig.defaults with threshold steps.", "gridPos": { "h": 3, @@ -57,6 +61,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests comprehensive migration including: default style with thresholds/colors/unit/decimals/align/colorMode, column overrides with exact name and regex patterns, date formatting, hidden columns, and links with tooltips.", "gridPos": { "h": 3, @@ -80,6 +88,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of timeseries_aggregations transform to reduce transformation with column mappings (avg-\u003emean, max-\u003emax, min-\u003emin, total-\u003esum, current-\u003elastNotNull, count-\u003ecount).", "gridPos": { "h": 3, @@ -103,6 +115,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of timeseries_to_rows transform to seriesToRows transformation.", "gridPos": { "h": 3, @@ -126,6 +142,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of timeseries_to_columns transform to seriesToColumns transformation.", "gridPos": { "h": 3, @@ -149,6 +169,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of table transform to merge transformation. Also tests auto alignment conversion to empty string.", "gridPos": { "h": 3, @@ -172,6 +196,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests that existing transformations are preserved and new transformation from old format is appended to the list.", "gridPos": { "h": 3, @@ -208,6 +236,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests handling of mixed numeric and string threshold values (int, string, float) with proper type conversion.", "gridPos": { "h": 3, @@ -231,6 +263,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests all color mode mappings: cell-\u003ecolor-background, row-\u003ecolor-background, value-\u003ecolor-text.", "gridPos": { "h": 3, @@ -254,6 +290,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests all alignment options: left, center, right, and auto (should convert to empty string).", "gridPos": { "h": 3, @@ -277,6 +317,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests both field matcher types: byName for exact matches and byRegexp for regex patterns.", "gridPos": { "h": 3, @@ -300,6 +344,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests various link configurations: with and without tooltip, with and without target blank.", "gridPos": { "h": 3, @@ -323,6 +371,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests various date format patterns and aliases.", "gridPos": { "h": 3, @@ -346,6 +398,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "React table (table2) should not be migrated. Properties should remain unchanged.", "gridPos": { "h": 3, @@ -369,6 +425,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Angular table without styles property should not be migrated.", "gridPos": { "h": 3, @@ -392,6 +452,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Non-table panels should remain completely unchanged.", "gridPos": { "h": 3, @@ -415,6 +479,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Other panel types should not be affected by table migration.", "gridPos": { "h": 3, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v1beta1.json index d79faf07932..f9a126e5f34 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v24.table-angular.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests basic migration with default style pattern (/.*/) containing thresholds and colors. Should convert styles to fieldConfig.defaults with threshold steps.", "gridPos": { "h": 3, @@ -57,6 +61,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests comprehensive migration including: default style with thresholds/colors/unit/decimals/align/colorMode, column overrides with exact name and regex patterns, date formatting, hidden columns, and links with tooltips.", "gridPos": { "h": 3, @@ -80,6 +88,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of timeseries_aggregations transform to reduce transformation with column mappings (avg-\u003emean, max-\u003emax, min-\u003emin, total-\u003esum, current-\u003elastNotNull, count-\u003ecount).", "gridPos": { "h": 3, @@ -103,6 +115,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of timeseries_to_rows transform to seriesToRows transformation.", "gridPos": { "h": 3, @@ -126,6 +142,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of timeseries_to_columns transform to seriesToColumns transformation.", "gridPos": { "h": 3, @@ -149,6 +169,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests migration of table transform to merge transformation. Also tests auto alignment conversion to empty string.", "gridPos": { "h": 3, @@ -172,6 +196,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests that existing transformations are preserved and new transformation from old format is appended to the list.", "gridPos": { "h": 3, @@ -208,6 +236,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests handling of mixed numeric and string threshold values (int, string, float) with proper type conversion.", "gridPos": { "h": 3, @@ -231,6 +263,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests all color mode mappings: cell-\u003ecolor-background, row-\u003ecolor-background, value-\u003ecolor-text.", "gridPos": { "h": 3, @@ -254,6 +290,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests all alignment options: left, center, right, and auto (should convert to empty string).", "gridPos": { "h": 3, @@ -277,6 +317,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests both field matcher types: byName for exact matches and byRegexp for regex patterns.", "gridPos": { "h": 3, @@ -300,6 +344,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests various link configurations: with and without tooltip, with and without target blank.", "gridPos": { "h": 3, @@ -323,6 +371,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests various date format patterns and aliases.", "gridPos": { "h": 3, @@ -346,6 +398,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "React table (table2) should not be migrated. Properties should remain unchanged.", "gridPos": { "h": 3, @@ -369,6 +425,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Angular table without styles property should not be migrated.", "gridPos": { "h": 3, @@ -392,6 +452,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Non-table panels should remain completely unchanged.", "gridPos": { "h": 3, @@ -415,6 +479,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Other panel types should not be affected by table migration.", "gridPos": { "h": 3, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v0alpha1.json index 704035dffdf..92c8eaf2b14 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v0alpha1.json @@ -33,6 +33,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -55,6 +59,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v1beta1.json index 7daaf9fa380..2e0d3dbe0ef 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v25.no-op-migration.v1beta1.json @@ -33,6 +33,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -55,6 +59,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v0alpha1.json index 4ddc375e3d0..c36b9aaa1b9 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v1beta1.json index ed958f0fc45..028ecd9792b 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v26.text2_to_text.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "text" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v0alpha1.json index 99bdb079082..883875f9227 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v1beta1.json index e2a7c8d360f..2775106705e 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v27.repeated_panels_and_constant_variable.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v0alpha1.json index 549d23f389c..baaa4bd3d9f 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -56,6 +60,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -78,6 +86,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -107,6 +119,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v1beta1.json index 8fdfe74a0ca..da477bdc06d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_and_variable_properties.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -56,6 +60,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -78,6 +86,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -107,6 +119,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v0alpha1.json index f47146292f4..103dc503a7c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -56,6 +60,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -85,6 +93,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -107,6 +119,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -136,6 +152,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -159,6 +179,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "PD8C576611E62080A" + }, "gridPos": { "h": 8, "w": 8, @@ -182,6 +206,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "PD8C576611E62080A" + }, "fieldConfig": { "defaults": { "mappings": [ @@ -249,6 +277,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "PD8C576611E62080A" + }, "gridPos": { "h": 8, "w": 8, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v1beta1.json index 9e27ce9b373..fc357d68563 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v28.singlestat_migration.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -56,6 +60,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -85,6 +93,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -107,6 +119,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -136,6 +152,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -159,6 +179,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "PD8C576611E62080A" + }, "gridPos": { "h": 8, "w": 8, @@ -182,6 +206,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "PD8C576611E62080A" + }, "fieldConfig": { "defaults": { "mappings": [ @@ -249,6 +277,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "PD8C576611E62080A" + }, "gridPos": { "h": 8, "w": 8, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v0alpha1.json index 46a5be539cf..846bb168d12 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v1beta1.json index 797aa7aa93b..da06dcbea48 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v29.query_variables_refresh_and_options.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v0alpha1.json index 811f9a00d89..f1b00a18973 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -93,6 +105,10 @@ "type": "barchart" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v1beta1.json index 63203ed95de..4703aad237e 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v3.no-op.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -93,6 +105,10 @@ "type": "barchart" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v0alpha1.json index e6ec721a7f3..a7847652883 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "mappings": [ @@ -125,6 +129,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -151,6 +159,10 @@ "type": "xychart" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -178,6 +190,10 @@ "type": "xychart2" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -204,6 +220,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "mappings": [ @@ -305,6 +325,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "unit": "bytes" @@ -337,6 +361,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v1beta1.json index 240ee83acfe..21fb00e8845 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v30.value_mappings_and_tooltip_options.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "mappings": [ @@ -125,6 +129,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -151,6 +159,10 @@ "type": "xychart" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -178,6 +190,10 @@ "type": "xychart2" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -204,6 +220,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "mappings": [ @@ -305,6 +325,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "unit": "bytes" @@ -337,6 +361,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "overrides": [ { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v0alpha1.json index b3261291c34..00817bd05b6 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -59,6 +63,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -99,6 +107,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -149,6 +161,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -171,6 +187,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -259,6 +279,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v1beta1.json index 74ddec4f78c..6f3fb009a55 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v31.labels_to_fields_merge.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -59,6 +63,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -99,6 +107,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -149,6 +161,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -171,6 +187,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -259,6 +279,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v0alpha1.json index bde81f8e5b9..3389666f1ef 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v0alpha1.json @@ -33,6 +33,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +75,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v1beta1.json index 18aee40e8eb..ecf3f1bbd52 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v32.no_op_migration.v1beta1.json @@ -33,6 +33,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +75,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v0alpha1.json index a860cf2dd06..142d01d9ffb 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "loki", + "uid": "non-default-test-ds-uid" + }, "description": "Tests v33 migration behavior when panel datasource is explicitly null. Should remain null after migration (returnDefaultAsNull: true).", "gridPos": { "h": 3, @@ -51,6 +55,10 @@ "type": "stat" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests v33 migration behavior when panel datasource is already a proper object reference. Should remain unchanged.", "gridPos": { "h": 3, @@ -75,6 +83,10 @@ "type": "stat" }, { + "datasource": { + "type": "loki", + "uid": "non-default-test-ds-uid" + }, "description": "Tests v33 migration when panel datasource is a string name. Should convert to proper object with uid, type, apiVersion.", "gridPos": { "h": 3, @@ -98,6 +110,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests v33 migration when panel has datasource string but empty targets array. Panel datasource should still migrate.", "gridPos": { "h": 3, @@ -283,6 +299,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Nested panel with string datasource should migrate to proper object reference, proving row panel recursion works.", "gridPos": { "h": 3, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v1beta1.json index 36b1c55fb41..d9af9bca3d7 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v33.panel_ds_name_to_ref.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "loki", + "uid": "non-default-test-ds-uid" + }, "description": "Tests v33 migration behavior when panel datasource is explicitly null. Should remain null after migration (returnDefaultAsNull: true).", "gridPos": { "h": 3, @@ -51,6 +55,10 @@ "type": "stat" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests v33 migration behavior when panel datasource is already a proper object reference. Should remain unchanged.", "gridPos": { "h": 3, @@ -75,6 +83,10 @@ "type": "stat" }, { + "datasource": { + "type": "loki", + "uid": "non-default-test-ds-uid" + }, "description": "Tests v33 migration when panel datasource is a string name. Should convert to proper object with uid, type, apiVersion.", "gridPos": { "h": 3, @@ -98,6 +110,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests v33 migration when panel has datasource string but empty targets array. Panel datasource should still migrate.", "gridPos": { "h": 3, @@ -283,6 +299,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Nested panel with string datasource should migrate to proper object reference, proving row panel recursion works.", "gridPos": { "h": 3, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v0alpha1.json index af5a9eb4fb2..7755aa29cf7 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v0alpha1.json @@ -289,6 +289,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -355,6 +359,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -386,6 +394,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -492,6 +504,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -522,6 +538,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -626,6 +646,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -689,6 +713,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -738,6 +766,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -769,6 +801,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -818,6 +854,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -849,6 +889,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -927,6 +971,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -1006,6 +1054,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v1beta1.json index 8a6009228aa..1568231f000 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v34.multiple_stats_cloudwatch.v1beta1.json @@ -289,6 +289,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -355,6 +359,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -386,6 +394,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -492,6 +504,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -522,6 +538,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -626,6 +646,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -689,6 +713,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -738,6 +766,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -769,6 +801,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -818,6 +854,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -849,6 +889,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -927,6 +971,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -1006,6 +1054,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v0alpha1.json index 83e0d94fd78..9aef1000c89 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -70,6 +74,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -125,6 +133,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -154,6 +166,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -183,6 +199,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -205,6 +225,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -227,6 +251,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "unit": "bytes" @@ -254,6 +282,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v1beta1.json index 59a03171c59..5b383957bd3 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v35.ensure_x_axis_visibility.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -70,6 +74,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -125,6 +133,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -154,6 +166,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { @@ -183,6 +199,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -205,6 +225,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -227,6 +251,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "unit": "bytes" @@ -254,6 +282,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "fieldConfig": { "defaults": { "custom": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v0alpha1.json index 6cfba3afdaf..4678a84253d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v0alpha1.json @@ -77,6 +77,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests null panel datasource migration with targets - should fallback to default", "gridPos": { "h": 3, @@ -100,6 +104,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests null panel datasource with empty targets array - should create default target", "gridPos": { "h": 3, @@ -123,6 +131,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests null panel datasource with missing targets - should create default target array", "gridPos": { "h": 3, @@ -180,6 +192,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel with already migrated datasource object - should preserve existing refs", "gridPos": { "h": 3, @@ -203,6 +219,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "unknown-target-datasource" + }, "description": "Tests panel with unknown datasource - should preserve as UID-only reference", "gridPos": { "h": 3, @@ -260,6 +280,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel inheriting datasource from target when panel datasource was default", "gridPos": { "h": 3, @@ -283,6 +307,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel with datasource referenced by name - should migrate to full object", "gridPos": { "h": 3, @@ -306,6 +334,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel with datasource referenced by UID - should migrate to full object", "gridPos": { "h": 3, @@ -345,6 +377,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Nested panel in collapsed row with default datasource", "gridPos": { "h": 3, @@ -368,6 +404,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Nested panel in collapsed row with unknown datasource", "gridPos": { "h": 3, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v1beta1.json index 535ea1447f0..a1b5270382b 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v36.ds_name_to_ref.v1beta1.json @@ -77,6 +77,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests null panel datasource migration with targets - should fallback to default", "gridPos": { "h": 3, @@ -100,6 +104,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests null panel datasource with empty targets array - should create default target", "gridPos": { "h": 3, @@ -123,6 +131,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "description": "Tests null panel datasource with missing targets - should create default target array", "gridPos": { "h": 3, @@ -180,6 +192,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel with already migrated datasource object - should preserve existing refs", "gridPos": { "h": 3, @@ -203,6 +219,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "unknown-target-datasource" + }, "description": "Tests panel with unknown datasource - should preserve as UID-only reference", "gridPos": { "h": 3, @@ -260,6 +280,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel inheriting datasource from target when panel datasource was default", "gridPos": { "h": 3, @@ -283,6 +307,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel with datasource referenced by name - should migrate to full object", "gridPos": { "h": 3, @@ -306,6 +334,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Tests panel with datasource referenced by UID - should migrate to full object", "gridPos": { "h": 3, @@ -345,6 +377,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Nested panel in collapsed row with default datasource", "gridPos": { "h": 3, @@ -368,6 +404,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch", + "uid": "existing-target-uid" + }, "description": "Nested panel in collapsed row with unknown datasource", "gridPos": { "h": 3, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v0alpha1.json index 5daafe44636..5be318447ec 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v1beta1.json index e5b057639fb..8e1436e850a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v4.no-op.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v0alpha1.json index 4872dd21894..07538e41a8f 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v1beta1.json index aeb12a936e1..88148baa546 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v5.no-op.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v0alpha1.json index 7f61b3e7b90..5cdc2d5e424 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v0alpha1.json @@ -49,6 +49,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -72,6 +76,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v1beta1.json index cb826c382bd..558e765f719 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v6.pulldowns_and_templating.v1beta1.json @@ -49,6 +49,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, @@ -72,6 +76,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 8, "w": 12, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v0alpha1.json index 100c239dc08..db8c34264b4 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v1beta1.json index 2a2541d7861..18283a762cb 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v7.timepicker.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v0alpha1.json index 3f575f7f5fc..8ffe55cd0d9 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v0alpha1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v1beta1.json index fd2231c5509..d2ea95d4a03 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dashboards_from_v0_to_v2/v2beta1.v9.no-op.v1beta1.json @@ -27,6 +27,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -49,6 +53,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, @@ -71,6 +79,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "default-ds-uid" + }, "gridPos": { "h": 3, "w": 6, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json index e3e0747bfc4..ecb9021b96c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v0alpha1.json @@ -66,6 +66,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "description": "This panel demonstrates conditional rendering features", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json index 7c37ba8f8ab..35fc204793d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.complete.v1beta1.json @@ -66,6 +66,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "description": "This panel demonstrates conditional rendering features", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json index e648923e684..20f70a0a647 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json @@ -150,6 +150,9 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -245,6 +248,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -341,6 +348,9 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource" + }, "fieldConfig": { "defaults": { "color": { @@ -400,6 +410,10 @@ "type": "stat" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json index d2a4e93982e..9956ad6962f 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json @@ -150,6 +150,9 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -245,6 +248,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -341,6 +348,9 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource" + }, "fieldConfig": { "defaults": { "color": { @@ -400,6 +410,10 @@ "type": "stat" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json index 37bbba541cd..d57c8d086a9 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v0alpha1.json @@ -14,6 +14,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json index 464c1d4c240..b2e7bd78dba 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.viz-config.v1beta1.json @@ -14,6 +14,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v0alpha1.json index b942305c98d..f3b6ab864fb 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v0alpha1.json @@ -66,6 +66,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "description": "This panel demonstrates conditional rendering features", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v1beta1.json index 06f8c45bdad..e3236448c9d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.complete.v1beta1.json @@ -66,6 +66,10 @@ "type": "row" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "description": "This panel demonstrates conditional rendering features", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v0alpha1.json index c1066edc211..33fd022e0db 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v0alpha1.json @@ -80,6 +80,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "description": "This should resolve to the grafana-testdata-datasource datasource", "fieldConfig": { "defaults": { @@ -170,6 +174,9 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch" + }, "description": "This should resolve to the first elasticsearch datasource", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v1beta1.json index 9bbd2f8f555..05d6df3537a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.datasource-resolution.v1beta1.json @@ -80,6 +80,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "description": "This should resolve to the grafana-testdata-datasource datasource", "fieldConfig": { "defaults": { @@ -170,6 +174,9 @@ "type": "timeseries" }, { + "datasource": { + "type": "elasticsearch" + }, "description": "This should resolve to the first elasticsearch datasource", "fieldConfig": { "defaults": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json index 6a418f1e21d..4494023eb13 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json @@ -150,6 +150,9 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -245,6 +248,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -341,6 +348,9 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource" + }, "fieldConfig": { "defaults": { "color": { @@ -400,6 +410,10 @@ "type": "stat" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json index 212dbdfd0f3..bc8d90d796a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json @@ -150,6 +150,9 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -245,6 +248,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "prometheus", + "uid": "gdev-prometheus" + }, "fieldConfig": { "defaults": { "color": { @@ -341,6 +348,9 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource" + }, "fieldConfig": { "defaults": { "color": { @@ -400,6 +410,10 @@ "type": "stat" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v0alpha1.json index f81abd723dc..bd7c8c392ee 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v0alpha1.json @@ -67,6 +67,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -219,6 +223,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -309,6 +317,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v1beta1.json index 2ade48c620d..a81de0ed319 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.rows-with-nested-tabs.v1beta1.json @@ -67,6 +67,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -219,6 +223,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -309,6 +317,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v0alpha1.json index 0357c22536a..98791135a12 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v0alpha1.json @@ -54,6 +54,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v1beta1.json index 7a9ea77ba65..38c93eb1cc8 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tab-with-multiple-panels.v1beta1.json @@ -54,6 +54,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json index ef513d48d97..716b476f825 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v0alpha1.json @@ -54,6 +54,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -167,6 +171,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -271,6 +279,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -361,6 +373,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -467,6 +483,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json index f94a49fc420..f915142fd14 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-and-rows-repeated.v1beta1.json @@ -54,6 +54,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -167,6 +171,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -271,6 +279,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -361,6 +373,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -467,6 +483,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v0alpha1.json index 6ed7076e121..ebc40675495 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v0alpha1.json @@ -54,6 +54,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -167,6 +171,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -271,6 +279,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -361,6 +373,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v1beta1.json index 9444d6b8eef..b5c74ce6a26 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.tabs-with-nested-rows.v1beta1.json @@ -54,6 +54,10 @@ "type": "row" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -167,6 +171,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -271,6 +279,10 @@ "id": -1, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { @@ -361,6 +373,10 @@ "type": "timeseries" }, { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "defin43am9o1sd" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v0alpha1.json index bd8e440efb2..916f2442b71 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v0alpha1.json @@ -36,6 +36,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with ValueMap mapping type - maps specific text values to colors and display text", "fieldConfig": { "defaults": { @@ -104,6 +108,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text", "fieldConfig": { "defaults": { @@ -188,6 +196,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors", "fieldConfig": { "defaults": { @@ -267,6 +279,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text", "fieldConfig": { "defaults": { @@ -380,6 +396,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers", "fieldConfig": { "defaults": { @@ -567,6 +587,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with override that has empty properties array - tests conversion of overrides without any property modifications", "fieldConfig": { "overrides": [ diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v1beta1.json index 07d899db46c..9c96e15fbc3 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.value-mapping-and-overrides.v1beta1.json @@ -36,6 +36,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with ValueMap mapping type - maps specific text values to colors and display text", "fieldConfig": { "defaults": { @@ -104,6 +108,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text", "fieldConfig": { "defaults": { @@ -188,6 +196,10 @@ "type": "gauge" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors", "fieldConfig": { "defaults": { @@ -267,6 +279,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text", "fieldConfig": { "defaults": { @@ -380,6 +396,10 @@ "type": "stat" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers", "fieldConfig": { "defaults": { @@ -567,6 +587,10 @@ "type": "table" }, { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, "description": "Panel with override that has empty properties array - tests conversion of overrides without any property modifications", "fieldConfig": { "overrides": [ diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v0alpha1.json index 0502ebf6b4c..083dfb5347c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v0alpha1.json @@ -14,6 +14,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v1beta1.json index 2187a3406e7..69cec2d4e7b 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.viz-config.v1beta1.json @@ -14,6 +14,10 @@ "liveNow": false, "panels": [ { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "gdev-testdata" + }, "fieldConfig": { "defaults": { "color": { diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go index e2a3e17843c..46a2a533d41 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go @@ -1072,10 +1072,14 @@ func convertPanelKindToV1(panelKind *dashv2alpha1.DashboardPanelKind, panel map[ } panel["targets"] = targets - // Detect mixed datasource - set panel.datasource to "mixed" if queries use different datasources - // This matches the frontend behavior in getPanelDataSource (layoutSerializers/utils.ts) - if mixedDS := detectMixedDatasource(spec.Data.Spec.Queries); mixedDS != nil { - panel["datasource"] = mixedDS + // Set panel-level datasource from queries. + // - If queries use different datasources, set to "mixed" + // - If all queries use the same datasource, set to that datasource + // This is required because the frontend's legacy PanelModel.PanelQueryRunner.run uses panel.datasource + // and some components like CSVExportPage rely on it to resolve the datasource. If undefined, it falls back to the default datasource + // which would overwrite the query's datasource. + if panelDS := getPanelDatasource(spec.Data.Spec.Queries); panelDS != nil { + panel["datasource"] = panelDS } // Convert transformations @@ -1188,12 +1192,15 @@ func getDataSourceForQuery(explicitDS *dashv2alpha1.DashboardDataSourceRef, quer return nil } -// detectMixedDatasource checks if panel queries use different datasources. -// Returns a mixed datasource reference if queries use different datasources, nil otherwise. +// getPanelDatasource determines the panel-level datasource for V1. +// Returns: +// - Mixed datasource reference if queries use different datasources +// - First query's datasource if all queries use the same datasource +// - nil if no queries exist // Compares based on V2 input without runtime resolution: // - If query has explicit datasource.uid → use that UID and type // - Else → use query.Kind as type (empty UID) -func detectMixedDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[string]interface{} { +func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[string]interface{} { if len(queries) == 0 { return nil } @@ -1232,8 +1239,21 @@ func detectMixedDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[s } } - // Not mixed - don't set panel-level datasource - return nil + // Not mixed - return the first query's datasource so the panel has a datasource set. + // This is required because the frontend's legacy PanelModel.PanelQueryRunner.run uses panel.datasource + // to resolve the datasource, and if undefined, it falls back to the default datasource + // which then overwrites the query's datasource. + if firstType == "" && firstUID == "" { + return nil + } + result := make(map[string]interface{}) + if firstType != "" { + result["type"] = firstType + } + if firstUID != "" { + result["uid"] = firstUID + } + return result } func convertLibraryPanelKindToV1(libPanelKind *dashv2alpha1.DashboardLibraryPanelKind, panel map[string]interface{}) (map[string]interface{}, error) { diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-canvas/canvas_kitchen_sink.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-canvas/canvas_kitchen_sink.v42.json new file mode 100644 index 00000000000..cf4db2fb1b6 --- /dev/null +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-canvas/canvas_kitchen_sink.v42.json @@ -0,0 +1,598 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Mixed --" + }, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "1": { + "color": "green", + "icon": "img/icons/unicons/check-circle.svg", + "index": 0, + "text": "Success" + }, + "2": { + "color": "orange", + "icon": "img/icons/unicons/exclamation-triangle.svg", + "index": 1, + "text": "Warning" + }, + "3": { + "color": "red", + "icon": "img/icons/unicons/times-circle.svg", + "index": 2, + "text": "Error" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text" + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "success" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "1": { + "color": "green", + "icon": "img/icons/unicons/check-circle.svg", + "index": 0, + "text": "Success" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "warning" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "2": { + "color": "orange", + "icon": "img/icons/unicons/exclamation-triangle.svg", + "index": 1, + "text": "Warning" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "error" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "3": { + "color": "red", + "icon": "img/icons/unicons/times-circle.svg", + "index": 2, + "text": "Error" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "unmapped" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "1": { + "color": "green", + "icon": "img/icons/unicons/check-circle.svg", + "index": 0, + "text": "Success" + }, + "2": { + "color": "orange", + "icon": "img/icons/unicons/exclamation-triangle.svg", + "index": 1, + "text": "Warning" + }, + "3": { + "color": "red", + "icon": "img/icons/unicons/times-circle.svg", + "index": 2, + "text": "Error" + } + }, + "type": "value" + } + ] + } + ] + } + ] + }, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "inlineEditing": true, + "root": { + "background": { + "color": { + "fixed": "transparent" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "elements": [ + { + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 16, + "text": { + "fixed": "Field-based Icons (from value mappings):", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Header", + "placement": { + "height": 40, + "left": 20, + "top": 10, + "width": 400 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "success", + "fixed": "green" + }, + "path": { + "field": "success", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Success Icon", + "placement": { + "height": 50, + "left": 50, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "success", + "fixed": "text" + }, + "size": 12, + "text": { + "field": "success", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Success Text", + "placement": { + "height": 25, + "left": 30, + "top": 115, + "width": 90 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "warning", + "fixed": "orange" + }, + "path": { + "field": "warning", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Warning Icon", + "placement": { + "height": 50, + "left": 180, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "warning", + "fixed": "text" + }, + "size": 12, + "text": { + "field": "warning", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Warning Text", + "placement": { + "height": 25, + "left": 160, + "top": 115, + "width": 90 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "error", + "fixed": "red" + }, + "path": { + "field": "error", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Error Icon", + "placement": { + "height": 50, + "left": 310, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "error", + "fixed": "text" + }, + "size": 12, + "text": { + "field": "error", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Error Text", + "placement": { + "height": 25, + "left": 290, + "top": 115, + "width": 90 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "unmapped", + "fixed": "#808080" + }, + "path": { + "field": "unmapped", + "fixed": "img/icons/unicons/question-circle.svg", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Unmapped Icon", + "placement": { + "height": 50, + "left": 440, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "unmapped", + "fixed": "text" + }, + "size": 12, + "text": { + "fixed": "No mapping (14)", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Unmapped Text", + "placement": { + "height": 25, + "left": 410, + "top": 115, + "width": 110 + }, + "type": "text" + }, + { + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 14, + "text": { + "fixed": "Fixed Relative Path:", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Relative Label", + "placement": { + "height": 30, + "left": 50, + "top": 170, + "width": 200 + }, + "type": "text" + }, + { + "config": { + "fill": { + "fixed": "blue" + }, + "path": { + "fixed": "img/icons/unicons/cloud.svg", + "mode": "fixed" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Relative Icon", + "placement": { + "height": 50, + "left": 260, + "top": 165, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 14, + "text": { + "fixed": "Fixed Absolute URL:", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Absolute Label", + "placement": { + "height": 30, + "left": 50, + "top": 240, + "width": 200 + }, + "type": "text" + }, + { + "config": { + "fill": { + "fixed": "purple" + }, + "path": { + "fixed": "https://grafana.com/static/assets/img/grafana_icon.svg", + "mode": "fixed" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Absolute Icon", + "placement": { + "height": 50, + "left": 260, + "top": 235, + "width": 50 + }, + "type": "icon" + } + ], + "name": "Canvas Root", + "placement": { + "height": 100, + "left": 0, + "top": 0, + "width": 100 + }, + "type": "frame" + } + }, + "pluginVersion": "12.1.0", + "targets": [ + { + "csvContent": "success\n1", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_content" + }, + { + "csvContent": "warning\n2", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "B", + "scenarioId": "csv_content" + }, + { + "csvContent": "error\n3", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "C", + "scenarioId": "csv_content" + }, + { + "csvContent": "unmapped\n14", + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "D", + "scenarioId": "csv_content" + } + ], + "title": "Various SVG icons", + "type": "canvas" + } + ], + "refresh": "", + "schemaVersion": 42, + "tags": [ + "canvas", + "icons", + "test", + "v2" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Panel tests - Canvas - Kitchen sink", + "uid": "canvas-icon-fix-test-v2", + "weekStart": "" +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests.v42.json index 92a865b0b10..635103053bf 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests.v42.json @@ -628,6 +628,20 @@ } ], "title": "Only nulls and no user set min \u0026 max", + "transformations": [ + { + "id": "convertFieldType", + "options": { + "conversions": [ + { + "destinationType": "number", + "targetField": "A-series" + } + ], + "fields": {} + } + } + ], "type": "gauge" }, { diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json index cb130445efc..61f092d491e 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json @@ -71,13 +71,13 @@ "id": 1, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -149,13 +149,13 @@ "id": 4, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -227,13 +227,13 @@ "id": 3, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -305,13 +305,13 @@ "id": 8, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -383,13 +383,13 @@ "id": 22, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -461,13 +461,13 @@ "id": 23, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -552,13 +552,13 @@ "id": 18, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.1, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -630,13 +630,13 @@ "id": 19, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.32, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -708,13 +708,13 @@ "id": 20, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.57, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -786,13 +786,13 @@ "id": 21, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.8, "effects": { "barGlow": true, "centerGlow": true, "gradient": false }, - "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -881,13 +881,13 @@ "id": 25, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, "gradient": false }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -959,13 +959,13 @@ "id": 26, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, "gradient": false }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1037,13 +1037,13 @@ "id": 29, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, "gradient": true }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1115,13 +1115,13 @@ "id": 30, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, "gradient": false }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1193,13 +1193,13 @@ "id": 28, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, "gradient": false }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1288,13 +1288,13 @@ "id": 32, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, "gradient": true }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1370,13 +1370,13 @@ "id": 34, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, "gradient": true }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1452,13 +1452,13 @@ "id": 33, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, "gradient": true }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1541,9 +1541,9 @@ "id": 9, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, - "barShape": "rounded", "effects": { "barGlow": true, "centerGlow": true, @@ -1624,6 +1624,7 @@ "id": 11, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { @@ -1631,7 +1632,6 @@ "centerGlow": true, "gradient": true }, - "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1720,6 +1720,7 @@ "id": 13, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { @@ -1727,7 +1728,6 @@ "centerGlow": true, "gradient": true }, - "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1761,6 +1761,22 @@ } ], "title": "Active gateways", + "transformations": [ + { + "id": "calculateField", + "options": { + "mode": "unary", + "reduce": { + "reducer": "sum" + }, + "replaceFields": true, + "unary": { + "fieldName": "A-series", + "operator": "round" + } + } + } + ], "type": "radialbar" }, { @@ -1803,6 +1819,7 @@ "id": 14, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { @@ -1810,7 +1827,6 @@ "centerGlow": true, "gradient": true }, - "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1844,6 +1860,22 @@ } ], "title": "Active pods", + "transformations": [ + { + "id": "calculateField", + "options": { + "mode": "unary", + "reduce": { + "reducer": "sum" + }, + "replaceFields": true, + "unary": { + "fieldName": "A-series", + "operator": "round" + } + } + } + ], "type": "radialbar" }, { @@ -1885,6 +1917,7 @@ "id": 15, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.84, "effects": { @@ -1892,7 +1925,6 @@ "centerGlow": true, "gradient": true }, - "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1967,6 +1999,7 @@ "id": 16, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.66, "effects": { @@ -1974,7 +2007,6 @@ "centerGlow": true, "gradient": true }, - "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2054,13 +2086,13 @@ }, "id": 36, "options": { + "barShape": "flat", "barWidthFactor": 0.5, "effects": { "barGlow": false, "centerGlow": false, "gradient": true }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -2121,13 +2153,13 @@ }, "id": 37, "options": { + "barShape": "flat", "barWidthFactor": 0.5, "effects": { "barGlow": false, "centerGlow": false, "gradient": true }, - "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -2169,4 +2201,4 @@ "title": "Panel tests - Gauge (new)", "uid": "panel-tests-gauge-new", "weekStart": "" -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json index dda0fbcd432..449955a9cc8 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json @@ -485,6 +485,7 @@ }, "id": 12, "options": { + "displayName": "My gauge", "minVizHeight": 75, "minVizWidth": 75, "orientation": "auto", @@ -1160,4 +1161,4 @@ "title": "Panel tests - Old gauge to new", "uid": "panel-tests-old-gauge-to-new", "weekStart": "" -} +} \ No newline at end of file diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 54689bc54f3..aed406c5434 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -89,6 +89,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect github.com/IBM/pgxpoolprometheus v1.1.2 // indirect + github.com/Machiel/slugify v1.0.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect @@ -223,7 +224,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 28bf1486774..35997e0d1ec 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -167,6 +167,8 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/IBM/pgxpoolprometheus v1.1.2 h1:sHJwxoL5Lw4R79Zt+H4Uj1zZ4iqXJLdk7XDE7TPs97U= github.com/IBM/pgxpoolprometheus v1.1.2/go.mod h1:+vWzISN6S9ssgurhUNmm6AlXL9XLah3TdWJktquKTR8= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -827,8 +829,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index a2657edda7a..0b9ba53e76a 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -13,6 +13,7 @@ require ( github.com/grafana/grafana v0.0.0-00010101000000-000000000000 github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 + github.com/grafana/grafana/pkg/apimachinery v0.0.0 github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.3 k8s.io/apiserver v0.34.3 @@ -23,6 +24,7 @@ require ( require ( cel.dev/expr v0.25.1 // indirect + github.com/Machiel/slugify v1.0.1 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect @@ -90,7 +92,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect @@ -98,7 +100,6 @@ require ( github.com/grafana/grafana-aws-sdk v1.3.0 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana-plugin-sdk-go v0.284.0 // indirect - github.com/grafana/grafana/pkg/apimachinery v0.0.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect @@ -191,7 +192,6 @@ require ( go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 // indirect go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index f0c923083af..3a7e9849fad 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -7,6 +7,8 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= @@ -213,8 +215,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= @@ -541,8 +543,6 @@ go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0/go.mod h1:B9Oka5QVD0bn go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= -go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= diff --git a/apps/plugins/kinds/meta.cue b/apps/plugins/kinds/meta.cue index d29308e2ca2..b3b506e6101 100644 --- a/apps/plugins/kinds/meta.cue +++ b/apps/plugins/kinds/meta.cue @@ -137,7 +137,7 @@ metaV0Alpha1: { type?: "dashboard" | "page" | "panel" | "datasource" name?: string component?: string - role?: "Admin" | "Editor" | "Viewer" + role?: "Admin" | "Editor" | "Viewer" | "None" action?: string path?: string addToNav?: bool diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go index f3671a040db..c88d9b7ff8a 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go @@ -504,6 +504,7 @@ const ( MetaIncludeRoleAdmin MetaIncludeRole = "Admin" MetaIncludeRoleEditor MetaIncludeRole = "Editor" MetaIncludeRoleViewer MetaIncludeRole = "Viewer" + MetaIncludeRoleNone MetaIncludeRole = "None" ) // +k8s:openapi-gen=true diff --git a/apps/plugins/pkg/apis/plugins_manifest.go b/apps/plugins/pkg/apis/plugins_manifest.go index 7b87dcf0ea7..1cabb46a611 100644 --- a/apps/plugins/pkg/apis/plugins_manifest.go +++ b/apps/plugins/pkg/apis/plugins_manifest.go @@ -23,7 +23,7 @@ var ( rawSchemaPluginv0alpha1 = []byte(`{"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"Plugin":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"id":{"type":"string"},"url":{"type":"string"},"version":{"type":"string"}},"required":["id","version"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) versionSchemaPluginv0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaPluginv0alpha1, &versionSchemaPluginv0alpha1) - rawSchemaMetav0alpha1 = []byte(`{"Dependencies":{"additionalProperties":false,"properties":{"extensions":{"additionalProperties":false,"properties":{"exposedComponents":{"description":"+listType=set","items":{"type":"string"},"type":"array"}},"type":"object"},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"enum":["app","datasource","panel"],"type":"string"}},"required":["id","type","name"],"type":"object"},"type":"array"}},"required":["grafanaDependency"],"type":"object"},"EnterpriseFeatures":{"additionalProperties":false,"properties":{"healthDiagnosticsErrors":{"default":false,"description":"Allow additional properties","type":"boolean"}},"type":"object"},"Extensions":{"additionalProperties":false,"properties":{"addedComponents":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedLinks":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"}},"type":"object"},"IAM":{"additionalProperties":false,"properties":{"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"Include":{"additionalProperties":false,"properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"enum":["Admin","Editor","Viewer"],"type":"string"},"type":{"enum":["dashboard","page","panel","datasource"],"type":"string"},"uid":{"type":"string"}},"type":"object"},"Info":{"additionalProperties":false,"properties":{"author":{"additionalProperties":false,"description":"Optional fields","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","items":{"type":"string"},"type":"array"},"links":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"type":"array"},"logos":{"additionalProperties":false,"properties":{"large":{"type":"string"},"small":{"type":"string"}},"required":["small","large"],"type":"object"},"screenshots":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"path":{"type":"string"}},"type":"object"},"type":"array"},"updated":{"type":"string"},"version":{"type":"string"}},"required":["keywords","logos","updated","version"],"type":"object"},"JSONData":{"additionalProperties":false,"description":"JSON configuration schema for Grafana plugins\nConverted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json","properties":{"alerting":{"description":"Optional fields","type":"boolean"},"annotations":{"type":"boolean"},"autoEnabled":{"type":"boolean"},"backend":{"type":"boolean"},"buildMode":{"type":"string"},"builtIn":{"type":"boolean"},"category":{"enum":["tsdb","logging","cloud","tracing","profiling","sql","enterprise","iot","other"],"type":"string"},"dependencies":{"$ref":"#/components/schemas/Dependencies","description":"Dependency information"},"enterpriseFeatures":{"$ref":"#/components/schemas/EnterpriseFeatures"},"executable":{"type":"string"},"extensions":{"$ref":"#/components/schemas/Extensions"},"hideFromList":{"type":"boolean"},"iam":{"$ref":"#/components/schemas/IAM"},"id":{"description":"Unique name of the plugin","type":"string"},"includes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Include"},"type":"array"},"info":{"$ref":"#/components/schemas/Info","description":"Metadata for the plugin"},"logs":{"type":"boolean"},"metrics":{"type":"boolean"},"multiValueFilterOperators":{"type":"boolean"},"name":{"description":"Human-readable name of the plugin","type":"string"},"pascalName":{"type":"string"},"preload":{"type":"boolean"},"queryOptions":{"$ref":"#/components/schemas/QueryOptions"},"roles":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Role"},"type":"array"},"routes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Route"},"type":"array"},"skipDataQuery":{"type":"boolean"},"state":{"enum":["alpha","beta"],"type":"string"},"streaming":{"type":"boolean"},"suggestions":{"type":"boolean"},"tracing":{"type":"boolean"},"type":{"description":"Plugin type","enum":["app","datasource","panel","renderer"],"type":"string"}},"required":["id","type","name","info","dependencies"],"type":"object"},"Meta":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"QueryOptions":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"type":"object"},"Role":{"additionalProperties":false,"properties":{"grants":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"role":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"}},"type":"object"},"Route":{"additionalProperties":false,"properties":{"body":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"headers":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"jwtTokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"content":{"type":"string"},"name":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"spec":{"additionalProperties":false,"properties":{"angular":{"additionalProperties":false,"properties":{"detected":{"type":"boolean"}},"required":["detected"],"type":"object"},"baseURL":{"type":"string"},"children":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"class":{"enum":["core","external"],"type":"string"},"module":{"additionalProperties":false,"properties":{"hash":{"type":"string"},"loadingStrategy":{"enum":["fetch","script"],"type":"string"},"path":{"type":"string"}},"required":["path"],"type":"object"},"pluginJson":{"$ref":"#/components/schemas/JSONData"},"signature":{"additionalProperties":false,"properties":{"org":{"type":"string"},"status":{"enum":["internal","valid","invalid","modified","unsigned"],"type":"string"},"type":{"enum":["grafana","commercial","community","private","private-glob"],"type":"string"}},"required":["status"],"type":"object"},"translations":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["pluginJson","class"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + rawSchemaMetav0alpha1 = []byte(`{"Dependencies":{"additionalProperties":false,"properties":{"extensions":{"additionalProperties":false,"properties":{"exposedComponents":{"description":"+listType=set","items":{"type":"string"},"type":"array"}},"type":"object"},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"enum":["app","datasource","panel"],"type":"string"}},"required":["id","type","name"],"type":"object"},"type":"array"}},"required":["grafanaDependency"],"type":"object"},"EnterpriseFeatures":{"additionalProperties":false,"properties":{"healthDiagnosticsErrors":{"default":false,"description":"Allow additional properties","type":"boolean"}},"type":"object"},"Extensions":{"additionalProperties":false,"properties":{"addedComponents":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedLinks":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"}},"type":"object"},"IAM":{"additionalProperties":false,"properties":{"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"Include":{"additionalProperties":false,"properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"enum":["Admin","Editor","Viewer","None"],"type":"string"},"type":{"enum":["dashboard","page","panel","datasource"],"type":"string"},"uid":{"type":"string"}},"type":"object"},"Info":{"additionalProperties":false,"properties":{"author":{"additionalProperties":false,"description":"Optional fields","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","items":{"type":"string"},"type":"array"},"links":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"type":"array"},"logos":{"additionalProperties":false,"properties":{"large":{"type":"string"},"small":{"type":"string"}},"required":["small","large"],"type":"object"},"screenshots":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"path":{"type":"string"}},"type":"object"},"type":"array"},"updated":{"type":"string"},"version":{"type":"string"}},"required":["keywords","logos","updated","version"],"type":"object"},"JSONData":{"additionalProperties":false,"description":"JSON configuration schema for Grafana plugins\nConverted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json","properties":{"alerting":{"description":"Optional fields","type":"boolean"},"annotations":{"type":"boolean"},"autoEnabled":{"type":"boolean"},"backend":{"type":"boolean"},"buildMode":{"type":"string"},"builtIn":{"type":"boolean"},"category":{"enum":["tsdb","logging","cloud","tracing","profiling","sql","enterprise","iot","other"],"type":"string"},"dependencies":{"$ref":"#/components/schemas/Dependencies","description":"Dependency information"},"enterpriseFeatures":{"$ref":"#/components/schemas/EnterpriseFeatures"},"executable":{"type":"string"},"extensions":{"$ref":"#/components/schemas/Extensions"},"hideFromList":{"type":"boolean"},"iam":{"$ref":"#/components/schemas/IAM"},"id":{"description":"Unique name of the plugin","type":"string"},"includes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Include"},"type":"array"},"info":{"$ref":"#/components/schemas/Info","description":"Metadata for the plugin"},"logs":{"type":"boolean"},"metrics":{"type":"boolean"},"multiValueFilterOperators":{"type":"boolean"},"name":{"description":"Human-readable name of the plugin","type":"string"},"pascalName":{"type":"string"},"preload":{"type":"boolean"},"queryOptions":{"$ref":"#/components/schemas/QueryOptions"},"roles":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Role"},"type":"array"},"routes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Route"},"type":"array"},"skipDataQuery":{"type":"boolean"},"state":{"enum":["alpha","beta"],"type":"string"},"streaming":{"type":"boolean"},"suggestions":{"type":"boolean"},"tracing":{"type":"boolean"},"type":{"description":"Plugin type","enum":["app","datasource","panel","renderer"],"type":"string"}},"required":["id","type","name","info","dependencies"],"type":"object"},"Meta":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"QueryOptions":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"type":"object"},"Role":{"additionalProperties":false,"properties":{"grants":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"role":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"}},"type":"object"},"Route":{"additionalProperties":false,"properties":{"body":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"headers":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"jwtTokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"content":{"type":"string"},"name":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"spec":{"additionalProperties":false,"properties":{"angular":{"additionalProperties":false,"properties":{"detected":{"type":"boolean"}},"required":["detected"],"type":"object"},"baseURL":{"type":"string"},"children":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"class":{"enum":["core","external"],"type":"string"},"module":{"additionalProperties":false,"properties":{"hash":{"type":"string"},"loadingStrategy":{"enum":["fetch","script"],"type":"string"},"path":{"type":"string"}},"required":["path"],"type":"object"},"pluginJson":{"$ref":"#/components/schemas/JSONData"},"signature":{"additionalProperties":false,"properties":{"org":{"type":"string"},"status":{"enum":["internal","valid","invalid","modified","unsigned"],"type":"string"},"type":{"enum":["grafana","commercial","community","private","private-glob"],"type":"string"}},"required":["status"],"type":"object"},"translations":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["pluginJson","class"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) versionSchemaMetav0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaMetav0alpha1, &versionSchemaMetav0alpha1) ) diff --git a/apps/plugins/pkg/app/meta/converter.go b/apps/plugins/pkg/app/meta/converter.go index 28180a263bd..2af699f6f9e 100644 --- a/apps/plugins/pkg/app/meta/converter.go +++ b/apps/plugins/pkg/app/meta/converter.go @@ -5,6 +5,7 @@ import ( "time" pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" ) @@ -253,12 +254,14 @@ func jsonDataToMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.MetaJSOND if include.Role != "" { var role pluginsv0alpha1.MetaIncludeRole switch include.Role { - case "Admin": + case identity.RoleAdmin: role = pluginsv0alpha1.MetaIncludeRoleAdmin - case "Editor": + case identity.RoleEditor: role = pluginsv0alpha1.MetaIncludeRoleEditor - case "Viewer": + case identity.RoleViewer: role = pluginsv0alpha1.MetaIncludeRoleViewer + case identity.RoleNone: + role = pluginsv0alpha1.MetaIncludeRoleNone } v0Include.Role = &role } diff --git a/apps/plugins/pkg/app/meta/core.go b/apps/plugins/pkg/app/meta/core.go index 9fb167d5578..1837386f7a3 100644 --- a/apps/plugins/pkg/app/meta/core.go +++ b/apps/plugins/pkg/app/meta/core.go @@ -20,7 +20,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/pipeline/termination" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation" "github.com/grafana/grafana/pkg/plugins/manager/sources" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" ) const ( diff --git a/devenv/dev-dashboards/panel-canvas/canvas_kitchen_sink.json b/devenv/dev-dashboards/panel-canvas/canvas_kitchen_sink.json new file mode 100644 index 00000000000..069a43c0888 --- /dev/null +++ b/devenv/dev-dashboards/panel-canvas/canvas_kitchen_sink.json @@ -0,0 +1,582 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Mixed --" + }, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "1": { + "color": "green", + "icon": "img/icons/unicons/check-circle.svg", + "index": 0, + "text": "Success" + }, + "2": { + "color": "orange", + "icon": "img/icons/unicons/exclamation-triangle.svg", + "index": 1, + "text": "Warning" + }, + "3": { + "color": "red", + "icon": "img/icons/unicons/times-circle.svg", + "index": 2, + "text": "Error" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "success" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "1": { + "color": "green", + "icon": "img/icons/unicons/check-circle.svg", + "index": 0, + "text": "Success" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "warning" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "2": { + "color": "orange", + "icon": "img/icons/unicons/exclamation-triangle.svg", + "index": 1, + "text": "Warning" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "error" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "3": { + "color": "red", + "icon": "img/icons/unicons/times-circle.svg", + "index": 2, + "text": "Error" + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "unmapped" + }, + "properties": [ + { + "id": "mappings", + "value": [ + { + "options": { + "1": { + "color": "green", + "icon": "img/icons/unicons/check-circle.svg", + "index": 0, + "text": "Success" + }, + "2": { + "color": "orange", + "icon": "img/icons/unicons/exclamation-triangle.svg", + "index": 1, + "text": "Warning" + }, + "3": { + "color": "red", + "icon": "img/icons/unicons/times-circle.svg", + "index": 2, + "text": "Error" + } + }, + "type": "value" + } + ] + } + ] + } + ] + }, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "inlineEditing": true, + "root": { + "background": { + "color": { + "fixed": "transparent" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "elements": [ + { + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 16, + "text": { + "fixed": "Field-based Icons (from value mappings):", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Header", + "placement": { + "height": 40, + "left": 20, + "top": 10, + "width": 400 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "success", + "fixed": "green" + }, + "path": { + "field": "success", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Success Icon", + "placement": { + "height": 50, + "left": 50, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "success", + "fixed": "text" + }, + "size": 12, + "text": { + "field": "success", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Success Text", + "placement": { + "height": 25, + "left": 30, + "top": 115, + "width": 90 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "warning", + "fixed": "orange" + }, + "path": { + "field": "warning", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Warning Icon", + "placement": { + "height": 50, + "left": 180, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "warning", + "fixed": "text" + }, + "size": 12, + "text": { + "field": "warning", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Warning Text", + "placement": { + "height": 25, + "left": 160, + "top": 115, + "width": 90 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "error", + "fixed": "red" + }, + "path": { + "field": "error", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Error Icon", + "placement": { + "height": 50, + "left": 310, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "error", + "fixed": "text" + }, + "size": 12, + "text": { + "field": "error", + "mode": "field" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Error Text", + "placement": { + "height": 25, + "left": 290, + "top": 115, + "width": 90 + }, + "type": "text" + }, + { + "config": { + "fill": { + "field": "unmapped", + "fixed": "#808080" + }, + "path": { + "field": "unmapped", + "fixed": "img/icons/unicons/question-circle.svg", + "mode": "field" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Unmapped Icon", + "placement": { + "height": 50, + "left": 440, + "top": 60, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "field": "unmapped", + "fixed": "text" + }, + "size": 12, + "text": { + "fixed": "No mapping (14)", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Unmapped Text", + "placement": { + "height": 25, + "left": 410, + "top": 115, + "width": 110 + }, + "type": "text" + }, + { + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 14, + "text": { + "fixed": "Fixed Relative Path:", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Relative Label", + "placement": { + "height": 30, + "left": 50, + "top": 170, + "width": 200 + }, + "type": "text" + }, + { + "config": { + "fill": { + "fixed": "blue" + }, + "path": { + "fixed": "img/icons/unicons/cloud.svg", + "mode": "fixed" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Relative Icon", + "placement": { + "height": 50, + "left": 260, + "top": 165, + "width": 50 + }, + "type": "icon" + }, + { + "config": { + "align": "center", + "color": { + "fixed": "text" + }, + "size": 14, + "text": { + "fixed": "Fixed Absolute URL:", + "mode": "fixed" + }, + "valign": "middle" + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Absolute Label", + "placement": { + "height": 30, + "left": 50, + "top": 240, + "width": 200 + }, + "type": "text" + }, + { + "config": { + "fill": { + "fixed": "purple" + }, + "path": { + "fixed": "https://grafana.com/static/assets/img/grafana_icon.svg", + "mode": "fixed" + } + }, + "constraint": { + "horizontal": "left", + "vertical": "top" + }, + "name": "Absolute Icon", + "placement": { + "height": 50, + "left": 260, + "top": 235, + "width": 50 + }, + "type": "icon" + } + ], + "name": "Canvas Root", + "placement": { + "height": 100, + "left": 0, + "top": 0, + "width": 100 + }, + "type": "frame" + } + }, + "pluginVersion": "12.1.0", + "targets": [ + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "A", + "scenarioId": "csv_content", + "csvContent": "success\n1" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "B", + "scenarioId": "csv_content", + "csvContent": "warning\n2" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "C", + "scenarioId": "csv_content", + "csvContent": "error\n3" + }, + { + "datasource": { + "type": "grafana-testdata-datasource", + "uid": "PD8C576611E62080A" + }, + "refId": "D", + "scenarioId": "csv_content", + "csvContent": "unmapped\n14" + } + ], + "title": "Various SVG icons", + "type": "canvas" + } + ], + "schemaVersion": 39, + "tags": ["canvas", "icons", "test", "v2"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Panel tests - Canvas - Kitchen sink", + "uid": "canvas-icon-fix-test-v2", + "version": 1, + "weekStart": "" +} diff --git a/devenv/dev-dashboards/panel-gauge/gauge_tests.json b/devenv/dev-dashboards/panel-gauge/gauge_tests.json index 0c8e2651e67..f32ace420b4 100644 --- a/devenv/dev-dashboards/panel-gauge/gauge_tests.json +++ b/devenv/dev-dashboards/panel-gauge/gauge_tests.json @@ -600,6 +600,20 @@ "stringInput": "null,null" } ], + "transformations": [ + { + "id": "convertFieldType", + "options": { + "fields": {}, + "conversions": [ + { + "targetField": "A-series", + "destinationType": "number" + } + ] + } + } + ], "title": "Only nulls and no user set min & max", "type": "gauge" }, diff --git a/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json b/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json index 65cc0b0a5ae..ff69226fdf5 100644 --- a/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json +++ b/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json @@ -1718,6 +1718,22 @@ "startValue": 0 } ], + "transformations": [ + { + "id": "calculateField", + "options": { + "mode": "unary", + "reduce": { + "reducer": "sum" + }, + "replaceFields": true, + "unary": { + "operator": "round", + "fieldName": "A-series" + } + } + } + ], "title": "Active gateways", "type": "radialbar" }, @@ -1799,6 +1815,22 @@ "startValue": 0 } ], + "transformations": [ + { + "id": "calculateField", + "options": { + "mode": "unary", + "reduce": { + "reducer": "sum" + }, + "replaceFields": true, + "unary": { + "operator": "round", + "fieldName": "A-series" + } + } + } + ], "title": "Active pods", "type": "radialbar" }, diff --git a/devenv/dev-dashboards/panel-gauge/gauge_tests_old_to_new.json b/devenv/dev-dashboards/panel-gauge/gauge_tests_old_to_new.json index bee1ece914e..3ca00ad5b63 100644 --- a/devenv/dev-dashboards/panel-gauge/gauge_tests_old_to_new.json +++ b/devenv/dev-dashboards/panel-gauge/gauge_tests_old_to_new.json @@ -474,6 +474,7 @@ }, "id": 12, "options": { + "displayName": "My gauge", "minVizHeight": 75, "minVizWidth": 75, "orientation": "auto", diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet index cac117cd4e7..98eb14e9217 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -24,6 +24,7 @@ "canvas-connection-examples": (import '../dev-dashboards/panel-canvas/canvas-connection-examples.json'), "canvas-datalinks": (import '../dev-dashboards/panel-canvas/canvas-datalinks.json'), "canvas-examples": (import '../dev-dashboards/panel-canvas/canvas-examples.json'), + "canvas_kitchen_sink": (import '../dev-dashboards/panel-canvas/canvas_kitchen_sink.json'), "color_modes": (import '../dev-dashboards/panel-common/color_modes.json'), "config-from-query": (import '../dev-dashboards/transforms/config-from-query.json'), "dashlist": (import '../dev-dashboards/panel-dashlist/dashlist.json'), diff --git a/docs/sources/alerting/alerting-rules/alerting-migration.md b/docs/sources/alerting/alerting-rules/alerting-migration.md index ab2a5e995cd..320b04b4643 100644 --- a/docs/sources/alerting/alerting-rules/alerting-migration.md +++ b/docs/sources/alerting/alerting-rules/alerting-migration.md @@ -134,7 +134,7 @@ To convert data source-managed alert rules to Grafana managed alerts: Pausing stops alert rule evaluation behavior for the newly created Grafana-managed alert rules. -9. (Optional) In the **Target data source** of the **Recording rules** section, you can select the data source that the imported recording rules will query. By default, it is the data source selected in the **Data source** dropdown. +9. (Optional) In the **Target data source** of the **Recording rules** section, you can select the data source to which the imported recording rules will write metrics. By default, it is the data source selected in the **Data source** dropdown. 10. Click **Import**. diff --git a/docs/sources/datasources/aws-cloudwatch/configure/index.md b/docs/sources/datasources/aws-cloudwatch/configure/index.md index 7e80433756b..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,26 +36,16 @@ 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/ - 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 +94,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 | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -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._ @@ -172,7 +158,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 +309,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 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. diff --git a/docs/sources/developers/plugins/plugin.schema.json b/docs/sources/developers/plugins/plugin.schema.json index 1898cd46b94..cae948ce4f3 100644 --- a/docs/sources/developers/plugins/plugin.schema.json +++ b/docs/sources/developers/plugins/plugin.schema.json @@ -369,7 +369,7 @@ "description": "For data source plugins. Proxy routes used for plugin authentication and adding headers to HTTP requests made by the plugin. For more information, refer to [Authentication for data source plugins](https://grafana.com/developers/plugin-tools/how-to-guides/data-source-plugins/add-authentication-for-data-source-plugins).", "items": { "type": "object", - "description": "", + "description": "For data source plugins. Proxy routes used for plugin authentication and adding headers to HTTP requests made by the plugin. For more information, refer to [Authentication for data source plugins](https://grafana.com/developers/plugin-tools/how-to-guides/data-source-plugins/add-authentication-for-data-source-plugins).", "additionalProperties": false, "properties": { "path": { diff --git a/e2e-playwright/panels-suite/canvas-icon-mappings.spec.ts b/e2e-playwright/panels-suite/canvas-icon-mappings.spec.ts new file mode 100644 index 00000000000..72de7a6c62c --- /dev/null +++ b/e2e-playwright/panels-suite/canvas-icon-mappings.spec.ts @@ -0,0 +1,99 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +const DASHBOARD_UID = 'canvas-icon-fix-test-v2'; +const PANEL_TITLE = 'Various SVG icons'; + +test.describe('Canvas Panel - Icon Mappings', () => { + test('should render field-based icons from value mappings correctly', async ({ gotoDashboardPage, page }) => { + await test.step('Navigate to dashboard and wait for panel to load', async () => { + await gotoDashboardPage({ uid: DASHBOARD_UID }); + await page.waitForSelector('svg', { timeout: 10000 }); + }); + + await test.step('Verify value mapping text values are displayed', async () => { + await expect(page.getByText('Success')).toBeVisible(); + await expect(page.getByText('Warning')).toBeVisible(); + await expect(page.getByText('Error')).toBeVisible(); + }); + + await test.step('Verify SVG icons rendered for mapped values', async () => { + const svgCount = await page.locator('svg').count(); + expect(svgCount).toBeGreaterThanOrEqual(3); + }); + }); + + test('should render fixed path icons correctly', async ({ gotoDashboardPage, page }) => { + await test.step('Set up network interception for absolute URL icon', async () => { + await page.route('https://grafana.com/static/assets/img/grafana_icon.svg', async (route) => { + const dummySvg = ` + + TEST + `; + await route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: dummySvg, + }); + }); + }); + + await test.step('Navigate to dashboard and wait for SVGs to load', async () => { + await gotoDashboardPage({ uid: DASHBOARD_UID }); + await page.waitForSelector('svg:not([aria-hidden="true"])', { timeout: 10000 }); + await page.waitForLoadState('networkidle', { timeout: 10000 }); + }); + + await test.step('Verify at least 5 visible SVG icons are rendered (3 mapped + 2 fixed)', async () => { + const visibleSvgs = page.locator('svg:not([aria-hidden="true"])'); + const svgCount = await visibleSvgs.count(); + expect(svgCount).toBeGreaterThanOrEqual(5); + }); + + await test.step('Verify visible SVG icons have content', async () => { + const visibleSvgs = page.locator('svg:not([aria-hidden="true"])'); + const count = await visibleSvgs.count(); + + for (let i = 0; i < Math.min(count, 5); i++) { + const svg = visibleSvgs.nth(i); + await expect(svg).toBeAttached(); + const svgContent = await svg.innerHTML(); + expect(svgContent.length).toBeGreaterThan(0); + } + }); + }); + + test('should not make invalid requests for unmapped numeric values', async ({ gotoDashboardPage, page }) => { + const failedRequests: string[] = []; + + await test.step('Set up network request monitoring', async () => { + page.on('requestfailed', (request) => { + const url = request.url(); + if (url.match(/\/build\/\d+$/)) { + failedRequests.push(url); + } + }); + }); + + await test.step('Navigate to dashboard and wait for loading', async () => { + await gotoDashboardPage({ uid: DASHBOARD_UID }); + await page.waitForTimeout(2000); + }); + + await test.step('Verify no invalid numeric path requests were made', async () => { + expect(failedRequests).toHaveLength(0); + }); + }); + + test('should display text values from value mappings correctly', async ({ gotoDashboardPage, page }) => { + await test.step('Navigate to dashboard', async () => { + await gotoDashboardPage({ uid: DASHBOARD_UID }); + }); + + await test.step('Verify mapped text values are displayed', async () => { + await expect(page.getByText('Success')).toBeVisible(); + await expect(page.getByText('Warning')).toBeVisible(); + await expect(page.getByText('Error')).toBeVisible(); + await expect(page.getByText('No mapping (14)')).toBeVisible(); + }); + }); +}); diff --git a/e2e-playwright/panels-suite/gauge.spec.ts b/e2e-playwright/panels-suite/gauge.spec.ts new file mode 100644 index 00000000000..cb4b26e9850 --- /dev/null +++ b/e2e-playwright/panels-suite/gauge.spec.ts @@ -0,0 +1,101 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +// this test requires a larger viewport so all gauge panels load properly +test.use({ + featureToggles: { newGauge: true }, + viewport: { width: 1280, height: 3000 }, +}); + +const OLD_GAUGES_DASHBOARD_UID = '_5rDmaQiz'; +const NEW_GAUGES_DASHBOARD_UID = 'panel-tests-gauge-new'; + +test.describe( + 'Gauge Panel', + { + tag: ['@panels', '@gauge'], + }, + () => { + test('successfully migrates all gauge panels', async ({ gotoDashboardPage, selectors }) => { + const dashboardPage = await gotoDashboardPage({ uid: OLD_GAUGES_DASHBOARD_UID }); + + // check that gauges are rendered + const gaugeElements = dashboardPage.getByGrafanaSelector( + selectors.components.Panels.Visualization.Gauge.Container + ); + await expect(gaugeElements).toHaveCount(16); + + // check that no panel errors exist + const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error')); + await expect(errorInfo).toBeHidden(); + }); + + test('renders new gauge panels', async ({ gotoDashboardPage, selectors }) => { + // open Panel Tests - Gauge + const dashboardPage = await gotoDashboardPage({ uid: NEW_GAUGES_DASHBOARD_UID }); + + // check that gauges are rendered + const gaugeElements = dashboardPage.getByGrafanaSelector( + selectors.components.Panels.Visualization.Gauge.Container + ); + await expect(gaugeElements).toHaveCount(32); + + // check that no panel errors exist + const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error')); + await expect(errorInfo).toBeHidden(); + }); + + test('renders sparklines in gauge panels', async ({ gotoDashboardPage, page }) => { + await gotoDashboardPage({ + uid: NEW_GAUGES_DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '11' }), + }); + + await expect(page.locator('.uplot')).toHaveCount(5); + }); + + test('"no data"', async ({ gotoDashboardPage, selectors }) => { + const dashboardPage = await gotoDashboardPage({ + uid: NEW_GAUGES_DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '36' }), + }); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container), + 'that the gauge does not appear' + ).toBeHidden(); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage), + 'that the empty text appears' + ).toHaveText('No data'); + + // update the "No value" option and see if the panel updates + const noValueOption = dashboardPage + .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Standard options No value')) + .locator('input'); + + await noValueOption.fill('My empty value'); + await noValueOption.blur(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container), + 'that the empty text shows up in an empty gauge' + ).toHaveText('My empty value'); + + // test the "no numeric fields" message on the next panel + const dashboardPage2 = await gotoDashboardPage({ + uid: NEW_GAUGES_DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '37' }), + }); + + await expect( + dashboardPage2.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container), + 'that the gauge does not appear' + ).toBeHidden(); + + await expect( + dashboardPage2.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage), + 'that the empty text appears' + ).toHaveText('Data is missing a number field'); + }); + } +); diff --git a/e2e-playwright/panels-suite/state-timeline.spec.ts b/e2e-playwright/panels-suite/state-timeline.spec.ts index ebdeb8d5398..d851a25f8af 100644 --- a/e2e-playwright/panels-suite/state-timeline.spec.ts +++ b/e2e-playwright/panels-suite/state-timeline.spec.ts @@ -78,6 +78,16 @@ test.describe('Panels test: StateTimeine', { tag: ['@panels', '@state-timeline'] await dashboardPage.getByGrafanaSelector(selectors.components.Portal.container).getByLabel('Close').click(); await expect(tooltip, 'tooltip closed on "x" click').toBeHidden(); + // test that CMD/CTRL+C doesn't dismiss the tooltip + await stateTimelineUplot.click({ position: { x: 100, y: 50 } }); + await expect(tooltip, 'tooltip appears on click').toBeVisible(); + await page.keyboard.press('Meta+C'); + await expect(tooltip, 'tooltip persists after CMD/CTRL+C').toBeVisible(); + + // test that Escape key dismisses the tooltip + await page.keyboard.press('Escape'); + await expect(tooltip, 'tooltip closed on Escape key').toBeHidden(); + // disable tooltips await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Tooltip Tooltip mode')) diff --git a/e2e-playwright/panels-suite/status-history.spec.ts b/e2e-playwright/panels-suite/status-history.spec.ts index bece3aa47c7..b2e628c0795 100644 --- a/e2e-playwright/panels-suite/status-history.spec.ts +++ b/e2e-playwright/panels-suite/status-history.spec.ts @@ -76,6 +76,16 @@ test.describe('Panels test: StatusHistory', { tag: ['@panels', '@status-history' await dashboardPage.getByGrafanaSelector(selectors.components.Portal.container).getByLabel('Close').click(); await expect(tooltip, 'tooltip closed on "x" click').toBeHidden(); + // test that CMD/CTRL+C doesn't dismiss the tooltip + await statusHistoryUplot.click({ position: { x: 100, y: 50 } }); + await expect(tooltip, 'tooltip appears on click').toBeVisible(); + await page.keyboard.press('Meta+C'); + await expect(tooltip, 'tooltip persists after CMD/CTRL+C').toBeVisible(); + + // test that Escape key dismisses the tooltip + await page.keyboard.press('Escape'); + await expect(tooltip, 'tooltip closed on Escape key').toBeHidden(); + // disable tooltips await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Tooltip Tooltip mode')) diff --git a/e2e-playwright/smoke-tests-suite/panels.spec.ts b/e2e-playwright/smoke-tests-suite/panels.spec.ts index 22f6088d99d..0ad50a1aed7 100644 --- a/e2e-playwright/smoke-tests-suite/panels.spec.ts +++ b/e2e-playwright/smoke-tests-suite/panels.spec.ts @@ -1,4 +1,4 @@ -import { BootData } from '@grafana/data'; +import { BootData, PanelPluginMeta } from '@grafana/data'; import { test, expect } from '@grafana/plugin-e2e'; test.describe( @@ -22,7 +22,7 @@ test.describe( await dashboardPage.addPanel(); // Get panel types from window object - const panelTypes = await page.evaluate(() => { + const panelTypes: PanelPluginMeta[] = await page.evaluate(() => { // @grafana/plugin-e2e doesn't export the full bootdata config // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const win = window as typeof window & { grafanaBootData: BootData }; diff --git a/e2e-playwright/various-suite/gauge.spec.ts b/e2e-playwright/various-suite/gauge.spec.ts deleted file mode 100644 index 3e2d6023c95..00000000000 --- a/e2e-playwright/various-suite/gauge.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { test, expect } from '@grafana/plugin-e2e'; - -// this test requires a larger viewport so all gauge panels load properly -test.use({ - viewport: { width: 1280, height: 1080 }, -}); - -test.describe( - 'Gauge Panel', - { - tag: ['@various'], - }, - () => { - test('Gauge rendering e2e tests', async ({ gotoDashboardPage, selectors, page }) => { - // open Panel Tests - Gauge - const dashboardPage = await gotoDashboardPage({ uid: '_5rDmaQiz' }); - - // check that gauges are rendered - const gaugeElements = page.locator('.flot-base'); - await expect(gaugeElements).toHaveCount(16); - - // check that no panel errors exist - const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error')); - await expect(errorInfo).toBeHidden(); - }); - } -); diff --git a/e2e-playwright/various-suite/visualization-suggestions-v2.spec.ts b/e2e-playwright/various-suite/visualization-suggestions-v2.spec.ts new file mode 100644 index 00000000000..282ec1314cc --- /dev/null +++ b/e2e-playwright/various-suite/visualization-suggestions-v2.spec.ts @@ -0,0 +1,178 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +test.use({ + featureToggles: { + newVizSuggestions: true, + externalVizSuggestions: false, + }, + viewport: { + width: 800, + height: 1500, + }, +}); + +test.describe( + 'Visualization suggestions v2', + { + tag: ['@various', '@suggestions'], + }, + () => { + test('Should be shown and clickable', async ({ selectors, gotoPanelEditPage }) => { + // Open dashboard with edit panel + const panelEditPage = await gotoPanelEditPage({ + dashboard: { + uid: 'aBXrJ0R7z', + }, + id: '9', + }); + + await expect( + panelEditPage.getByGrafanaSelector(selectors.components.Panels.Panel.content).locator('.uplot'), + 'time series to be rendered inside panel' + ).toBeVisible(); + + // Try visualization suggestions + await panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker).click(); + await panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('Suggestions')).click(); + + // Verify we see suggestions + await expect( + panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.card('Line chart')), + 'line chart suggestion to be rendered' + ).toBeVisible(); + + // TODO: in this part of the test, we will change the query and the transforms and observe suggestions being updated. + + // Select a visualization and verify table header is visible from preview + await panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.card('Table')).click(); + await expect( + panelEditPage + .getByGrafanaSelector(selectors.components.Panels.Panel.content) + .getByRole('grid') + .getByRole('row') + .first(), + 'table to be rendered inside panel' + ).toBeVisible(); + await expect( + panelEditPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.discardChangesButton), + 'discard changes button disabled since panel has not yet changed' + ).toBeDisabled(); + + // apply the suggestion and verify panel options are visible + await panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.confirm('Table')).click(); + await expect( + panelEditPage + .getByGrafanaSelector(selectors.components.Panels.Panel.content) + .getByRole('grid') + .getByRole('row') + .first(), + 'table to be rendered inside panel' + ).toBeVisible(); + await expect( + panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header), + 'options pane to be rendered' + ).toBeVisible(); + await expect( + panelEditPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.discardChangesButton), + 'discard changes button enabled now that panel is dirty' + ).toBeEnabled(); + }); + + test('should not apply suggestion if you navigate toggle the viz picker back off', async ({ + selectors, + gotoPanelEditPage, + }) => { + // Open dashboard with edit panel + const panelEditPage = await gotoPanelEditPage({ + dashboard: { + uid: 'aBXrJ0R7z', + }, + id: '9', + }); + + await expect( + panelEditPage.getByGrafanaSelector(selectors.components.Panels.Panel.content).locator('.uplot'), + 'time series to be rendered inside panel;' + ).toBeVisible(); + + // Try visualization suggestions + await panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker).click(); + await panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('Suggestions')).click(); + + // Verify we see suggestions + await expect( + panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.card('Line chart')), + 'line chart suggestion to be rendered' + ).toBeVisible(); + + // Select a visualization + await panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.card('Table')).click(); + await expect( + panelEditPage + .getByGrafanaSelector(selectors.components.Panels.Panel.content) + .getByRole('grid') + .getByRole('row') + .first(), + 'table to be rendered inside panel' + ).toBeVisible(); + await expect( + panelEditPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.discardChangesButton) + ).toBeDisabled(); + + // Verify that toggling the viz picker back cancels the suggestion, restores the line chart, shows panel options + await panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker).click(); + await expect( + panelEditPage.getByGrafanaSelector(selectors.components.Panels.Panel.content).locator('.uplot'), + 'time series to be rendered inside panel' + ).toBeVisible(); + await expect( + panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header), + 'options pane to be rendered' + ).toBeVisible(); + await expect( + panelEditPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.discardChangesButton), + 'discard changes button is still disabled since no changes were applied' + ).toBeDisabled(); + }); + + test('should not apply suggestion if you navigate back to the dashboard', async ({ + page, + selectors, + gotoPanelEditPage, + }) => { + // Open dashboard with edit panel + const panelEditPage = await gotoPanelEditPage({ + dashboard: { + uid: 'aBXrJ0R7z', + }, + id: '9', + }); + + // Try visualization suggestions + await panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker).click(); + await panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('Suggestions')).click(); + + // Verify we see suggestions + await expect( + panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.card('Line chart')), + 'line chart suggestion to be rendered' + ).toBeVisible(); + + // Select a visualization + await panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.card('Table')).click(); + await expect(page.getByRole('grid').getByRole('row').first(), 'table row to be rendered').toBeVisible(); + await expect( + panelEditPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.discardChangesButton) + ).toBeDisabled(); + + // Verify that navigating back to the dashboard cancels the suggestion and restores the line chart. + await panelEditPage + .getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton) + .click(); + await expect( + page.locator('[data-viz-panel-key="panel-9"]').locator('.uplot'), + 'time series to be rendered inside the panel' + ).toBeVisible(); + }); + } +); diff --git a/e2e-playwright/various-suite/visualization-suggestions.spec.ts b/e2e-playwright/various-suite/visualization-suggestions.spec.ts index d01447b7eb7..7872e5a69d0 100644 --- a/e2e-playwright/various-suite/visualization-suggestions.spec.ts +++ b/e2e-playwright/various-suite/visualization-suggestions.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.describe( 'Visualization suggestions', { - tag: ['@various'], + tag: ['@various', '@suggestions'], }, () => { test('Should be shown and clickable', async ({ page, selectors, gotoPanelEditPage }) => { diff --git a/go.mod b/go.mod index b848514cea4..fa38e9ec99d 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,7 @@ require ( github.com/crewjam/saml v0.4.14 // @grafana/identity-access-team github.com/dgraph-io/badger/v4 v4.7.0 // @grafana/grafana-search-and-storage github.com/dlmiddlecote/sqlstats v1.0.2 // @grafana/grafana-backend-group - github.com/docker/go-connections v0.6.0 // indirect; @grafana/grafana-app-platform-squad + github.com/docker/go-connections v0.6.0 // @grafana/grafana-app-platform-squad github.com/dolthub/go-mysql-server v0.19.1-0.20250410182021-5632d67cd46e // @grafana/grafana-datasources-core-services github.com/dolthub/vitess v0.0.0-20250930230441-70c2c6a98e33 // @grafana/grafana-datasources-core-services github.com/dustin/go-humanize v1.0.1 // @grafana/observability-traces-and-profiling @@ -87,7 +87,7 @@ require ( github.com/googleapis/gax-go/v2 v2.15.0 // @grafana/grafana-backend-group github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics @@ -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 @@ -181,6 +182,7 @@ require ( github.com/xlab/treeprint v1.2.0 // @grafana/observability-traces-and-profiling github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // @grafana/grafana-operator-experience-squad github.com/yudai/gojsondiff v1.0.0 // @grafana/grafana-backend-group + go.etcd.io/bbolt v1.4.2 // @grafana/grafana-search-and-storage go.opentelemetry.io/collector/pdata v1.44.0 // @grafana/grafana-backend-group go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // @grafana/plugins-platform-backend go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // @grafana/grafana-operator-experience-squad @@ -556,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 @@ -603,7 +604,6 @@ require ( github.com/yuin/gopher-lua v1.1.1 // indirect github.com/zclconf/go-cty v1.16.3 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.etcd.io/bbolt v1.4.2 // indirect go.etcd.io/etcd/api/v3 v3.6.6 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.6 // indirect go.etcd.io/etcd/client/v3 v3.6.6 // indirect @@ -660,6 +660,8 @@ require ( require github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec // @grafana/observability-traces-and-profiling +require github.com/Machiel/slugify v1.0.1 // @grafana/plugins-platform-backend + require ( github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/IBM/pgxpoolprometheus v1.1.2 // indirect @@ -679,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 069d53dd5e9..7d2582cf711 100644 --- a/go.sum +++ b/go.sum @@ -738,6 +738,8 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXY github.com/IBM/pgxpoolprometheus v1.1.2 h1:sHJwxoL5Lw4R79Zt+H4Uj1zZ4iqXJLdk7XDE7TPs97U= github.com/IBM/pgxpoolprometheus v1.1.2/go.mod h1:+vWzISN6S9ssgurhUNmm6AlXL9XLah3TdWJktquKTR8= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -1605,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= @@ -1622,8 +1623,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= 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/package.json b/package.json index 73dec9dbc90..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", @@ -460,7 +461,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/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index b50a074e4a2..326b53ccedd 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -285,6 +285,10 @@ const injectedRtkApi = api query: (queryArg) => ({ url: `/snapshots/delete/${queryArg.deleteKey}`, method: 'DELETE' }), invalidatesTags: ['Snapshot'], }), + getSnapshotSettings: build.query({ + query: () => ({ url: `/snapshots/settings` }), + providesTags: ['Snapshot'], + }), getSnapshot: build.query({ query: (queryArg) => ({ url: `/snapshots/${queryArg.name}`, @@ -742,6 +746,8 @@ export type DeleteWithKeyApiArg = { /** unique key returned in create */ deleteKey: string; }; +export type GetSnapshotSettingsApiResponse = /** status 200 undefined */ any; +export type GetSnapshotSettingsApiArg = void; export type GetSnapshotApiResponse = /** status 200 OK */ Snapshot; export type GetSnapshotApiArg = { /** name of the Snapshot */ @@ -1273,6 +1279,8 @@ export const { useLazyListSnapshotQuery, useCreateSnapshotMutation, useDeleteWithKeyMutation, + useGetSnapshotSettingsQuery, + useLazyGetSnapshotSettingsQuery, useGetSnapshotQuery, useLazyGetSnapshotQuery, useDeleteSnapshotMutation, 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/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index de04398a3e1..7dd819a1512 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -535,6 +535,11 @@ export const versionedComponents = { '12.3.0': 'data-testid viz-tooltip-wrapper', }, }, + Gauge: { + Container: { + '12.4.0': 'data-testid gauge container', + }, + }, }, }, VizLegend: { @@ -1288,6 +1293,9 @@ export const versionedComponents = { card: { [MIN_GRAFANA_VERSION]: (name: string) => `data-testid suggestion-${name}`, }, + confirm: { + '12.4.0': (name: string) => `data-testid suggestion-${name} confirm button`, + }, }, ColorSwatch: { name: { 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 5a6fbdf8518..00000000000 --- a/packages/grafana-prometheus/src/dashboards/prometheus_2_stats.json +++ /dev/null @@ -1,1403 +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\"}[5m]))", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 2, - "legendFormat": "samples", - "metric": "", - "refId": "A", - "step": 20 - } - ], - "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", - "interval": "", - "intervalFactor": 2, - "legendFormat": "{{job}}", - "metric": "", - "refId": "A", - "step": 20 - } - ], - "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, - "interval": "", - "intervalFactor": 2, - "legendFormat": "p8s process resident memory", - "refId": "D", - "step": 20 - }, - { - "expr": "process_virtual_memory_bytes{job=\"prometheus\"}", - "format": "time_series", - "hide": false, - "intervalFactor": 2, - "legendFormat": "virtual memory", - "refId": "C", - "step": 20 - } - ], - "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", - "intervalFactor": 2, - "legendFormat": "", - "refId": "A", - "step": 60 - } - ], - "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", - "interval": "", - "intervalFactor": 2, - "legendFormat": "active_appenders", - "metric": "", - "refId": "A", - "step": 20 - }, - { - "expr": "sum(process_open_fds{job=\"prometheus\"})", - "format": "time_series", - "interval": "", - "intervalFactor": 2, - "legendFormat": "open_fds", - "refId": "B", - "step": 20 - } - ], - "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", - "intervalFactor": 2, - "legendFormat": "blocks", - "refId": "A", - "step": 20 - } - ], - "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", - "interval": "", - "intervalFactor": 2, - "legendFormat": "chunks", - "refId": "A", - "step": 20 - } - ], - "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", - "intervalFactor": 2, - "legendFormat": "duration-p99", - "refId": "A", - "step": 20 - }, - { - "expr": "irate(prometheus_tsdb_head_gc_duration_seconds_count{job=\"prometheus\"}[5m])", - "format": "time_series", - "intervalFactor": 2, - "legendFormat": "collections", - "refId": "B", - "step": 20 - } - ], - "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\"}[5m])) by (le))", - "format": "time_series", - "hide": false, - "interval": "", - "intervalFactor": 2, - "legendFormat": "duration-{{p99}}", - "refId": "A", - "step": 20 - }, - { - "expr": "irate(prometheus_tsdb_compactions_total{job=\"prometheus\"}[5m])", - "format": "time_series", - "intervalFactor": 2, - "legendFormat": "compactions", - "refId": "B", - "step": 20 - }, - { - "expr": "irate(prometheus_tsdb_compactions_failed_total{job=\"prometheus\"}[5m])", - "format": "time_series", - "intervalFactor": 2, - "legendFormat": "failed", - "refId": "C", - "step": 20 - }, - { - "expr": "irate(prometheus_tsdb_compactions_triggered_total{job=\"prometheus\"}[5m])", - "format": "time_series", - "intervalFactor": 2, - "legendFormat": "triggered", - "refId": "D", - "step": 20 - } - ], - "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\"}[5m])", - "format": "time_series", - "intervalFactor": 2, - "legendFormat": "reloads", - "refId": "A", - "step": 20 - }, - { - "expr": "rate(prometheus_tsdb_reloads_failures_total{job=\"prometheus\"}[5m])", - "format": "time_series", - "hide": false, - "intervalFactor": 2, - "legendFormat": "failures", - "refId": "B", - "step": 20 - } - ], - "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", - "intervalFactor": 2, - "legendFormat": "{{slice}}_p99", - "refId": "A", - "step": 20 - } - ], - "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", - "interval": "", - "intervalFactor": 2, - "legendFormat": "{{quantile}}", - "refId": "A", - "step": 10 - } - ], - "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\"}[5m])", - "format": "time_series", - "intervalFactor": 2, - "legendFormat": "missed", - "refId": "B", - "step": 10 - }, - { - "expr": "rate(prometheus_rule_group_iterations_total{job=\"prometheus\"}[5m])", - "format": "time_series", - "intervalFactor": 2, - "legendFormat": "iterations", - "refId": "A", - "step": 10 - } - ], - "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 -} diff --git a/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts index 7c29eb7b463..c0f8481a7f5 100644 --- a/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts @@ -35,6 +35,7 @@ export interface Options extends common.SingleStatBaseOptions { showThresholdLabels: boolean; showThresholdMarkers: boolean; sparkline?: boolean; + textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none'); } export const defaultOptions: Partial = { @@ -48,4 +49,5 @@ export const defaultOptions: Partial = { showThresholdLabels: false, showThresholdMarkers: true, sparkline: true, + textMode: 'auto', }; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 8eace0b38b8..f969bbcf3f0 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -248,15 +248,17 @@ export function PanelChrome({ const onContentPointerDown = React.useCallback( (evt: React.PointerEvent) => { - // Ignore clicks inside buttons, links, canvas and svg elments + // When selected, ignore clicks inside buttons, links, canvas and svg elments // This does prevent a clicks inside a graphs from selecting panel as there is normal div above the canvas element that intercepts the click - if (evt.target instanceof Element && evt.target.closest('button,a,canvas,svg')) { + if (isSelected && evt.target instanceof Element && evt.target.closest('button,a,canvas,svg')) { + // Stop propagation otherwise row config editor will get selected + evt.stopPropagation(); return; } onSelect?.(evt); }, - [onSelect] + [isSelected, onSelect] ); const headerContent = ( diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx index b0574ef3f87..5c5faa64db7 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx @@ -32,24 +32,6 @@ const meta: Meta = { controls: { exclude: ['theme', 'values', 'vizCount'], }, - a11y: { - config: { - rules: [ - { - id: 'scrollable-region-focusable', - selector: 'body', - enabled: false, - }, - // NOTE: this is necessary due to a false positive with the filered svg glow in one of the examples. - // The color-contrast in this component should be accessible! - { - id: 'color-contrast', - selector: 'text', - enabled: false, - }, - ], - }, - }, }, args: { barWidthFactor: 0.2, diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx index 1251a364230..452dd394ab1 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx @@ -2,6 +2,7 @@ import { css, cx } from '@emotion/css'; import { useId } from 'react'; import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; @@ -275,7 +276,11 @@ export function RadialGauge(props: RadialGaugeProps) { } return ( -
+
{body}
); diff --git a/packages/grafana-ui/src/components/RadialGauge/effects.tsx b/packages/grafana-ui/src/components/RadialGauge/effects.tsx index c48307f177e..53a255d4a45 100644 --- a/packages/grafana-ui/src/components/RadialGauge/effects.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/effects.tsx @@ -1,4 +1,4 @@ -import { GrafanaTheme2 } from '@grafana/data'; +import { colorManipulator, GrafanaTheme2 } from '@grafana/data'; import { RadialGaugeDimensions } from './types'; @@ -25,13 +25,14 @@ export function GlowGradient({ id, barWidth }: GlowGradientProps) { ); } -const CENTER_GLOW_OPACITY = 0.15; +const CENTER_GLOW_OPACITY = 0.25; export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) { + const transparentColor = colorManipulator.alpha(color, CENTER_GLOW_OPACITY); return ( - - + + ); } @@ -44,13 +45,14 @@ export interface CenterGlowProps { export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps) { const gradientId = `circle-glow-${gaugeId}`; + const transparentColor = color ? colorManipulator.alpha(color, CENTER_GLOW_OPACITY) : color; return ( <> - - + + @@ -86,9 +88,9 @@ export function SpotlightGradient({ return ( - - - {roundedBars && } + + + {roundedBars && } ); } diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index d1fb4f3b0e0..a9d3f039c42 100644 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -17,8 +17,9 @@ export interface SparklineProps extends Themeable2 { showHighlights?: boolean; } -export const SparklineFn: React.FC = memo((props) => { +export const Sparkline: React.FC = memo((props) => { const { sparkline, config: fieldConfig, theme, width, height, showHighlights } = props; + const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights); if (warning) { return null; @@ -30,14 +31,4 @@ export const SparklineFn: React.FC = memo((props) => { return ; }); -SparklineFn.displayName = 'Sparkline'; - -// we converted to function component above, but some apps extend Sparkline, so we need -// to keep exporting a class component until those apps are all rolled out. -// see https://github.com/grafana/app-observability-plugin/pull/2079 -// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -export class Sparkline extends React.PureComponent { - render() { - return ; - } -} +Sparkline.displayName = 'Sparkline'; diff --git a/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx b/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx index a1fb6c6a1c3..735e198f0bc 100644 --- a/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx +++ b/packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx @@ -167,7 +167,8 @@ export class VizRepeater extends PureComponent { + if (e instanceof KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + dismiss(); + } + return; + } + // this tooltip is Portaled, but actions inside it create forms in Modals const isModalOrPortaled = '[role="dialog"], #grafana-portal-container'; diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index 00ccb0a665c..de38697f613 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -300,11 +300,11 @@ func (proxy *DataSourceProxy) validateRequest() error { } // route match - r1, err := util.CleanRelativePath(proxy.proxyPath) + r1, err := plugins.CleanRelativePath(proxy.proxyPath) if err != nil { return err } - r2, err := util.CleanRelativePath(route.Path) + r2, err := plugins.CleanRelativePath(route.Path) if err != nil { return err } diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 4fa7292dcb5..50e32326565 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -13,7 +13,6 @@ import ( "sort" "strings" - "github.com/grafana/grafana/pkg/plugins/auth" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -22,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/auth" "github.com/grafana/grafana/pkg/plugins/repo" ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -32,7 +32,6 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -355,7 +354,7 @@ func (hs *HTTPServer) getPluginAssets(c *contextmodel.ReqContext) { } // prepend slash for cleaning relative paths - requestedFile, err := util.CleanRelativePath(web.Params(c.Req)["*"]) + requestedFile, err := plugins.CleanRelativePath(web.Params(c.Req)["*"]) if err != nil { // slash is prepended above therefore this is not expected to fail c.JsonApiErr(500, "Failed to clean relative file path", err) @@ -598,9 +597,9 @@ func mdFilepath(mdFilename string) (string, error) { fileExt := filepath.Ext(mdFilename) switch fileExt { case "md": - return util.CleanRelativePath(mdFilename) + return plugins.CleanRelativePath(mdFilename) case "": - return util.CleanRelativePath(fmt.Sprintf("%s.md", mdFilename)) + return plugins.CleanRelativePath(fmt.Sprintf("%s.md", mdFilename)) default: return "", ErrUnexpectedFileExtension } diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index bda739c5020..342c6293d7e 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -30,6 +30,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/plugins/pluginscdn" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" @@ -44,7 +45,6 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" diff --git a/pkg/apiserver/auditing/logger.go b/pkg/apiserver/auditing/logger.go new file mode 100644 index 00000000000..8e60d463255 --- /dev/null +++ b/pkg/apiserver/auditing/logger.go @@ -0,0 +1,55 @@ +package auditing + +import ( + "context" + "encoding/json" + "time" +) + +// Sinkable is a log entry abstraction that can be sent to an audit log sink through the different implementing methods. +type Sinkable interface { + json.Marshaler + KVPairs() []any + Time() time.Time +} + +// Logger specifies the contract for a specific audit logger. +type Logger interface { + Log(entry Sinkable) error + Close() error + Type() string +} + +// Implementation inspired by https://github.com/grafana/grafana-app-sdk/blob/main/logging/logger.go +type loggerContextKey struct{} + +var ( + // DefaultLogger is the default Logger if one hasn't been provided in the context. + // You may use this to add arbitrary audit logging outside of an API request lifecycle. + DefaultLogger Logger = &NoopLogger{} + + contextKey = loggerContextKey{} +) + +// FromContext returns the Logger set in the context with Context(), or the DefaultLogger if no Logger is set in the context. +// If DefaultLogger is nil, it returns a *NoopLogger so that the return is always valid to call methods on without nil-checking. +// You may use this to add arbitrary audit logging outside of an API request lifecycle. +func FromContext(ctx context.Context) Logger { + if l := ctx.Value(contextKey); l != nil { + if logger, ok := l.(Logger); ok { + return logger + } + } + + if DefaultLogger != nil { + return DefaultLogger + } + + return &NoopLogger{} +} + +// Context returns a new context built from the provided context with the provided logger in it. +// The Logger added with Context() can be retrieved with FromContext() +func Context(ctx context.Context, logger Logger) context.Context { + return context.WithValue(ctx, contextKey, logger) +} diff --git a/pkg/apiserver/auditing/noop.go b/pkg/apiserver/auditing/noop.go index 5a6b39a3b71..c36c3577a09 100644 --- a/pkg/apiserver/auditing/noop.go +++ b/pkg/apiserver/auditing/noop.go @@ -11,9 +11,9 @@ type NoopBackend struct{} func ProvideNoopBackend() audit.Backend { return &NoopBackend{} } -func (b *NoopBackend) ProcessEvents(k8sEvents ...*auditinternal.Event) bool { return false } +func (NoopBackend) ProcessEvents(...*auditinternal.Event) bool { return false } -func (NoopBackend) Run(stopCh <-chan struct{}) error { return nil } +func (NoopBackend) Run(<-chan struct{}) error { return nil } func (NoopBackend) Shutdown() {} @@ -34,3 +34,14 @@ type NoopPolicyRuleEvaluator struct{} func (NoopPolicyRuleEvaluator) EvaluatePolicyRule(authorizer.Attributes) audit.RequestAuditConfig { return audit.RequestAuditConfig{Level: auditinternal.LevelNone} } + +// NoopLogger is a no-op implementation of Logger +type NoopLogger struct{} + +func ProvideNoopLogger() Logger { return &NoopLogger{} } + +func (NoopLogger) Type() string { return "noop" } + +func (NoopLogger) Log(Sinkable) error { return nil } + +func (NoopLogger) Close() error { return nil } diff --git a/pkg/clientauth/providers.go b/pkg/clientauth/providers.go new file mode 100644 index 00000000000..fcb2cd77240 --- /dev/null +++ b/pkg/clientauth/providers.go @@ -0,0 +1,43 @@ +package clientauth + +import ( + "context" +) + +// NamespaceProvider is a strategy for determining the namespace to use in token exchange requests. +type NamespaceProvider interface { + GetNamespace(ctx context.Context) string +} + +// AudienceProvider is a strategy for determining the audiences to use in token exchange requests. +type AudienceProvider interface { + GetAudiences(ctx context.Context) []string +} + +// StaticNamespaceProvider returns a fixed namespace for all requests. +type StaticNamespaceProvider struct { + namespace string +} + +// NewStaticNamespaceProvider creates a namespace provider that always returns the same namespace. +func NewStaticNamespaceProvider(namespace string) *StaticNamespaceProvider { + return &StaticNamespaceProvider{namespace: namespace} +} + +func (p *StaticNamespaceProvider) GetNamespace(ctx context.Context) string { + return p.namespace +} + +// StaticAudienceProvider returns a fixed set of audiences for all requests. +type StaticAudienceProvider struct { + audiences []string +} + +// NewStaticAudienceProvider creates an audience provider that always returns the same audiences. +func NewStaticAudienceProvider(audiences ...string) *StaticAudienceProvider { + return &StaticAudienceProvider{audiences: audiences} +} + +func (p *StaticAudienceProvider) GetAudiences(ctx context.Context) []string { + return p.audiences +} diff --git a/pkg/clientauth/providers_test.go b/pkg/clientauth/providers_test.go new file mode 100644 index 00000000000..fdacfbeafb6 --- /dev/null +++ b/pkg/clientauth/providers_test.go @@ -0,0 +1,78 @@ +package clientauth + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStaticNamespaceProvider(t *testing.T) { + tests := []struct { + name string + namespace string + expectedNamespace string + }{ + { + name: "wildcard namespace", + namespace: "*", + expectedNamespace: "*", + }, + { + name: "specific namespace", + namespace: "my-namespace", + expectedNamespace: "my-namespace", + }, + { + name: "empty namespace", + namespace: "", + expectedNamespace: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := NewStaticNamespaceProvider(tt.namespace) + result := provider.GetNamespace(context.Background()) + require.Equal(t, tt.expectedNamespace, result) + }) + } +} + +func TestStaticAudienceProvider(t *testing.T) { + tests := []struct { + name string + audiences []string + expectedAudiences []string + }{ + { + name: "single audience", + audiences: []string{"folder.grafana.app"}, + expectedAudiences: []string{"folder.grafana.app"}, + }, + { + name: "multiple audiences", + audiences: []string{"audience1", "audience2", "audience3"}, + expectedAudiences: []string{"audience1", "audience2", "audience3"}, + }, + { + name: "empty audiences", + audiences: []string{}, + expectedAudiences: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := NewStaticAudienceProvider(tt.audiences...) + result := provider.GetAudiences(context.Background()) + require.Equal(t, tt.expectedAudiences, result) + }) + } +} + +func TestProviderInterfaces(t *testing.T) { + // Verify that all providers implement their interfaces + var _ NamespaceProvider = (*StaticNamespaceProvider)(nil) + var _ AudienceProvider = (*StaticAudienceProvider)(nil) +} diff --git a/pkg/clientauth/roundtripper.go b/pkg/clientauth/roundtripper.go new file mode 100644 index 00000000000..225f6afcad9 --- /dev/null +++ b/pkg/clientauth/roundtripper.go @@ -0,0 +1,90 @@ +package clientauth + +import ( + "fmt" + "net/http" + + authnlib "github.com/grafana/authlib/authn" + utilnet "k8s.io/apimachinery/pkg/util/net" + "k8s.io/client-go/transport" +) + +// tokenExchangeRoundTripper wraps an http.RoundTripper and injects an exchanged +// access token into outgoing requests via the X-Access-Token header. +type tokenExchangeRoundTripper struct { + exchanger authnlib.TokenExchanger + transport http.RoundTripper + namespaceProvider NamespaceProvider + audienceProvider AudienceProvider +} + +var _ http.RoundTripper = (*tokenExchangeRoundTripper)(nil) + +// newTokenExchangeRoundTripperWithStrategies creates a new RoundTripper with custom +// namespace and audience strategies, allowing for flexible configuration. +func newTokenExchangeRoundTripperWithStrategies( + exchanger authnlib.TokenExchanger, + transport http.RoundTripper, + namespaceProvider NamespaceProvider, + audienceProvider AudienceProvider, +) *tokenExchangeRoundTripper { + return &tokenExchangeRoundTripper{ + exchanger: exchanger, + transport: transport, + namespaceProvider: namespaceProvider, + audienceProvider: audienceProvider, + } +} + +// RoundTrip implements http.RoundTripper by exchanging a token and setting it +// in the X-Access-Token header before forwarding the request. +func (t *tokenExchangeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + ctx := req.Context() + + tokenResponse, err := t.exchanger.Exchange(ctx, authnlib.TokenExchangeRequest{ + Audiences: t.audienceProvider.GetAudiences(ctx), + Namespace: t.namespaceProvider.GetNamespace(ctx), + }) + if err != nil { + return nil, fmt.Errorf("failed to exchange token: %w", err) + } + + // Clone the request as RoundTrippers are not expected to mutate the passed request + req = utilnet.CloneRequest(req) + + req.Header.Set("X-Access-Token", "Bearer "+tokenResponse.Token) + + return t.transport.RoundTrip(req) +} + +// NewStaticTokenExchangeTransportWrapper creates a transport.WrapperFunc that wraps +// an http.RoundTripper with token exchange authentication for use with k8s +// rest.Config.WrapTransport. +func NewStaticTokenExchangeTransportWrapper( + exchanger authnlib.TokenExchanger, + audience string, + namespace string, +) transport.WrapperFunc { + return func(rt http.RoundTripper) http.RoundTripper { + return newTokenExchangeRoundTripperWithStrategies(exchanger, rt, NewStaticNamespaceProvider(namespace), NewStaticAudienceProvider(audience)) + } +} + +// NewTokenExchangeTransportWrapperWithStrategies creates a transport.WrapperFunc with custom strategies. +func NewTokenExchangeTransportWrapper( + exchanger authnlib.TokenExchanger, + namespaceProvider NamespaceProvider, + audienceProvider AudienceProvider, +) transport.WrapperFunc { + return func(rt http.RoundTripper) http.RoundTripper { + return newTokenExchangeRoundTripperWithStrategies( + exchanger, + rt, + namespaceProvider, + audienceProvider, + ) + } +} + +// WildcardNamespace is a convenience constant for the wildcard namespace. +const WildcardNamespace = "*" diff --git a/pkg/clientauth/roundtripper_test.go b/pkg/clientauth/roundtripper_test.go new file mode 100644 index 00000000000..96ee99406b3 --- /dev/null +++ b/pkg/clientauth/roundtripper_test.go @@ -0,0 +1,259 @@ +package clientauth + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/authlib/authn" + "github.com/stretchr/testify/require" +) + +type fakeExchanger struct { + resp *authn.TokenExchangeResponse + err error + gotReq *authn.TokenExchangeRequest +} + +func (f *fakeExchanger) Exchange(_ context.Context, req authn.TokenExchangeRequest) (*authn.TokenExchangeResponse, error) { + f.gotReq = &req + return f.resp, f.err +} + +// roundTripperFunc allows building a stub transport inline +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestTokenExchangeRoundTripper_SetsAccessTokenHeader(t *testing.T) { + exchanger := &fakeExchanger{resp: &authn.TokenExchangeResponse{Token: "test-token-123"}} + + var capturedHeader string + transport := roundTripperFunc(func(r *http.Request) (*http.Response, error) { + capturedHeader = r.Header.Get("X-Access-Token") + rr := httptest.NewRecorder() + rr.WriteHeader(http.StatusOK) + return rr.Result(), nil + }) + + rt := newTokenExchangeRoundTripperWithStrategies(exchanger, transport, NewStaticNamespaceProvider("test-namespace"), NewStaticAudienceProvider("test-audience")) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.org", nil) + + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + if resp != nil { + _ = resp.Body.Close() + } + + // Clean up response + _ = resp.Body.Close() + + require.Equal(t, "Bearer test-token-123", capturedHeader) +} + +func TestTokenExchangeRoundTripper_PropagatesExchangeError(t *testing.T) { + expectedErr := errors.New("token exchange failed") + exchanger := &fakeExchanger{err: expectedErr} + + transport := roundTripperFunc(func(_ *http.Request) (*http.Response, error) { + t.Fatal("transport should not be called on exchange error") + return nil, nil + }) + + rt := newTokenExchangeRoundTripperWithStrategies(exchanger, transport, NewStaticNamespaceProvider("test-namespace"), NewStaticAudienceProvider("test-audience")) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.org", nil) + + resp, err := rt.RoundTrip(req) + require.Error(t, err) + if resp != nil { + _ = resp.Body.Close() + } + require.ErrorContains(t, err, "failed to exchange token") + require.ErrorIs(t, err, expectedErr) +} + +func TestTokenExchangeRoundTripper_SendsCorrectAudienceAndNamespace(t *testing.T) { + tests := []struct { + name string + audience string + namespace string + expectedAudiences []string + expectedNamespace string + }{ + { + name: "single audience with wildcard namespace", + audience: "folder.grafana.app", + namespace: "*", + expectedAudiences: []string{"folder.grafana.app"}, + expectedNamespace: "*", + }, + { + name: "different audience with wildcard namespace", + audience: "dashboard.grafana.app", + namespace: "*", + expectedAudiences: []string{"dashboard.grafana.app"}, + expectedNamespace: "*", + }, + { + name: "audience with specific namespace", + audience: "test-audience", + namespace: "test-namespace", + expectedAudiences: []string{"test-audience"}, + expectedNamespace: "test-namespace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exchanger := &fakeExchanger{resp: &authn.TokenExchangeResponse{Token: "token"}} + transport := roundTripperFunc(func(_ *http.Request) (*http.Response, error) { + rr := httptest.NewRecorder() + rr.WriteHeader(http.StatusOK) + return rr.Result(), nil + }) + + rt := newTokenExchangeRoundTripperWithStrategies(exchanger, transport, NewStaticNamespaceProvider(tt.namespace), NewStaticAudienceProvider(tt.audience)) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.org", nil) + + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + if resp != nil { + _ = resp.Body.Close() + } + + require.NotNil(t, exchanger.gotReq) + require.Equal(t, tt.expectedAudiences, exchanger.gotReq.Audiences) + require.Equal(t, tt.expectedNamespace, exchanger.gotReq.Namespace) + }) + } +} + +func TestTokenExchangeRoundTripper_DoesNotMutateOriginalRequest(t *testing.T) { + exchanger := &fakeExchanger{resp: &authn.TokenExchangeResponse{Token: "token"}} + transport := roundTripperFunc(func(r *http.Request) (*http.Response, error) { + rr := httptest.NewRecorder() + rr.WriteHeader(http.StatusOK) + return rr.Result(), nil + }) + + rt := newTokenExchangeRoundTripperWithStrategies(exchanger, transport, NewStaticNamespaceProvider("namespace"), NewStaticAudienceProvider("audience")) + originalReq, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.org", nil) + + // Ensure original request has no X-Access-Token header + originalReq.Header.Set("X-Custom-Header", "original-value") + require.Empty(t, originalReq.Header.Get("X-Access-Token")) + + resp, err := rt.RoundTrip(originalReq) + require.NoError(t, err) + _ = resp.Body.Close() + + // Original request should not have been mutated + require.Empty(t, originalReq.Header.Get("X-Access-Token")) + require.Equal(t, "original-value", originalReq.Header.Get("X-Custom-Header")) +} + +func TestTokenExchangeRoundTripper_PropagatesTransportError(t *testing.T) { + exchanger := &fakeExchanger{resp: &authn.TokenExchangeResponse{Token: "token"}} + expectedErr := errors.New("transport error") + transport := roundTripperFunc(func(_ *http.Request) (*http.Response, error) { + return nil, expectedErr + }) + + rt := newTokenExchangeRoundTripperWithStrategies(exchanger, transport, NewStaticNamespaceProvider("namespace"), NewStaticAudienceProvider("audience")) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.org", nil) + + resp, err := rt.RoundTrip(req) + require.Error(t, err) + if resp != nil { + _ = resp.Body.Close() + } + require.ErrorIs(t, err, expectedErr) +} + +func TestNewTokenExchangeTransportWrapper(t *testing.T) { + exchanger := &fakeExchanger{resp: &authn.TokenExchangeResponse{Token: "wrapped-token"}} + + var capturedHeader string + baseTransport := roundTripperFunc(func(r *http.Request) (*http.Response, error) { + capturedHeader = r.Header.Get("X-Access-Token") + rr := httptest.NewRecorder() + rr.WriteHeader(http.StatusOK) + return rr.Result(), nil + }) + + wrapper := NewStaticTokenExchangeTransportWrapper(exchanger, "test-audience", "test-namespace") + wrappedTransport := wrapper(baseTransport) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.org", nil) + resp, err := wrappedTransport.RoundTrip(req) + require.NoError(t, err) + _ = resp.Body.Close() + + require.Equal(t, "Bearer wrapped-token", capturedHeader) + require.NotNil(t, exchanger.gotReq) + require.Equal(t, []string{"test-audience"}, exchanger.gotReq.Audiences) + require.Equal(t, "test-namespace", exchanger.gotReq.Namespace) +} + +func TestTokenExchangeRoundTripperWithStrategies(t *testing.T) { + tests := []struct { + name string + namespaceProvider NamespaceProvider + audienceProvider AudienceProvider + expectedNamespace string + expectedAudiences []string + expectedHeader string + }{ + { + name: "static providers with bearer prefix", + namespaceProvider: NewStaticNamespaceProvider("*"), + audienceProvider: NewStaticAudienceProvider("folder.grafana.app"), + expectedNamespace: "*", + expectedAudiences: []string{"folder.grafana.app"}, + expectedHeader: "Bearer test-token", + }, + { + name: "multiple audiences", + namespaceProvider: NewStaticNamespaceProvider("*"), + audienceProvider: NewStaticAudienceProvider("audience1", "audience2"), + expectedNamespace: "*", + expectedAudiences: []string{"audience1", "audience2"}, + expectedHeader: "Bearer test-token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exchanger := &fakeExchanger{resp: &authn.TokenExchangeResponse{Token: "test-token"}} + + var capturedHeader string + transport := roundTripperFunc(func(r *http.Request) (*http.Response, error) { + capturedHeader = r.Header.Get("X-Access-Token") + rr := httptest.NewRecorder() + rr.WriteHeader(http.StatusOK) + return rr.Result(), nil + }) + + rt := newTokenExchangeRoundTripperWithStrategies( + exchanger, + transport, + tt.namespaceProvider, + tt.audienceProvider, + ) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.org", nil) + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + if resp != nil { + _ = resp.Body.Close() + } + + require.Equal(t, tt.expectedHeader, capturedHeader) + require.NotNil(t, exchanger.gotReq) + require.Equal(t, tt.expectedAudiences, exchanger.gotReq.Audiences) + require.Equal(t, tt.expectedNamespace, exchanger.gotReq.Namespace) + }) + } +} diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index 113c2f8e4bb..472652cc103 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -15,6 +15,7 @@ import ( _ "github.com/blugelabs/bluge" _ "github.com/blugelabs/bluge_segment_api" _ "github.com/crewjam/saml" + _ "github.com/docker/go-connections/nat" _ "github.com/go-jose/go-jose/v4" _ "github.com/gobwas/glob" _ "github.com/googleapis/gax-go/v2" @@ -30,6 +31,7 @@ import ( _ "github.com/spf13/cobra" // used by the standalone apiserver cli _ "github.com/spyzhov/ajson" _ "github.com/stretchr/testify/require" + _ "github.com/testcontainers/testcontainers-go" _ "gocloud.dev/secrets/awskms" _ "gocloud.dev/secrets/azurekeyvault" _ "gocloud.dev/secrets/gcpkms" @@ -54,9 +56,7 @@ import ( _ "github.com/grafana/e2e" _ "github.com/grafana/gofpdf" _ "github.com/grafana/gomemcache/memcache" - _ "github.com/grafana/tempo/pkg/traceql" - _ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1" _ "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1" - _ "github.com/testcontainers/testcontainers-go" + _ "github.com/grafana/tempo/pkg/traceql" ) diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go index 449959c0d68..9ba6aede494 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go +++ b/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go @@ -3,13 +3,14 @@ package httpclientprovider import ( "testing" - "github.com/grafana/grafana/pkg/services/validations" - "github.com/grafana/grafana-aws-sdk/pkg/awsauth" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/services/validations" + "github.com/grafana/grafana/pkg/setting" ) func TestHTTPClientProvider(t *testing.T) { @@ -77,7 +78,7 @@ func TestHTTPClientProvider(t *testing.T) { newProviderFunc = origNewProviderFunc }) tracer := tracing.InitializeTracerForTest() - _ = New(&setting.Cfg{PluginSettings: setting.PluginSettings{"example": {"har_log_enabled": "true"}}}, &validations.OSSDataSourceRequestURLValidator{}, tracer) + _ = New(&setting.Cfg{PluginSettings: config.PluginSettings{"example": {"har_log_enabled": "true"}}}, &validations.OSSDataSourceRequestURLValidator{}, tracer) require.Len(t, providerOpts, 1) o := providerOpts[0] require.Len(t, o.Middlewares, 10) diff --git a/pkg/infra/httpclient/httpclientprovider/http_logger_middleware.go b/pkg/infra/httpclient/httpclientprovider/http_logger_middleware.go index fceafbf06a5..daf248f384e 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_logger_middleware.go +++ b/pkg/infra/httpclient/httpclientprovider/http_logger_middleware.go @@ -5,12 +5,13 @@ import ( sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" httplogger "github.com/grafana/grafana-plugin-sdk-go/experimental/http_logger" - "github.com/grafana/grafana/pkg/setting" + + "github.com/grafana/grafana/pkg/plugins/config" ) const HTTPLoggerMiddlewareName = "http-logger" -func HTTPLoggerMiddleware(cfg setting.PluginSettings) sdkhttpclient.Middleware { +func HTTPLoggerMiddleware(cfg config.PluginSettings) sdkhttpclient.Middleware { return sdkhttpclient.NamedMiddlewareFunc(HTTPLoggerMiddlewareName, func(opts sdkhttpclient.Options, next http.RoundTripper) http.RoundTripper { datasourceType, exists := opts.Labels["datasource_type"] if !exists { @@ -29,7 +30,7 @@ func HTTPLoggerMiddleware(cfg setting.PluginSettings) sdkhttpclient.Middleware { }) } -func httpLoggingEnabled(cfg setting.PluginSettings) bool { +func httpLoggingEnabled(cfg config.PluginSettings) bool { for _, settings := range cfg { if enabled := settings["har_log_enabled"]; enabled == "true" { return true @@ -38,7 +39,7 @@ func httpLoggingEnabled(cfg setting.PluginSettings) bool { return false } -func getLoggerSettings(datasourceType string, cfg setting.PluginSettings) (enabled bool, path string) { +func getLoggerSettings(datasourceType string, cfg config.PluginSettings) (enabled bool, path string) { settings, ok := cfg[datasourceType] if !ok { return diff --git a/pkg/infra/httpclient/httpclientprovider/http_logger_middleware_test.go b/pkg/infra/httpclient/httpclientprovider/http_logger_middleware_test.go index 006c87411e9..7ef5b17b08a 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_logger_middleware_test.go +++ b/pkg/infra/httpclient/httpclientprovider/http_logger_middleware_test.go @@ -9,15 +9,17 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/experimental/e2e/storage" - "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/plugins/config" ) func TestHTTPLoggerMiddleware(t *testing.T) { t.Run("Should return middleware name", func(t *testing.T) { - mw := HTTPLoggerMiddleware(setting.PluginSettings{}) + mw := HTTPLoggerMiddleware(config.PluginSettings{}) middlewareName, ok := mw.(httpclient.MiddlewareName) require.True(t, ok) require.Equal(t, HTTPLoggerMiddlewareName, middlewareName.MiddlewareName()) @@ -27,7 +29,7 @@ func TestHTTPLoggerMiddleware(t *testing.T) { tempPath := path.Join(os.TempDir(), fmt.Sprintf("http_logger_test_%d.har", time.Now().UnixMilli())) ctx := &testContext{} finalRoundTripper := ctx.createRoundTripper("finalrt") - mw := HTTPLoggerMiddleware(setting.PluginSettings{"example-datasource": {"har_log_enabled": "false", "har_log_path": tempPath}}) + mw := HTTPLoggerMiddleware(config.PluginSettings{"example-datasource": {"har_log_enabled": "false", "har_log_path": tempPath}}) rt := mw.CreateMiddleware(httpclient.Options{Labels: map[string]string{"datasource_type": "example-datasource"}}, finalRoundTripper) require.NotNil(t, rt) @@ -54,7 +56,7 @@ func TestHTTPLoggerMiddleware(t *testing.T) { }() ctx := &testContext{} finalRoundTripper := ctx.createRoundTripper("finalrt") - mw := HTTPLoggerMiddleware(setting.PluginSettings{"example-datasource": {"har_log_enabled": "true", "har_log_path": f.Name()}}) + mw := HTTPLoggerMiddleware(config.PluginSettings{"example-datasource": {"har_log_enabled": "true", "har_log_path": f.Name()}}) rt := mw.CreateMiddleware(httpclient.Options{Labels: map[string]string{"datasource_type": "example-datasource"}}, finalRoundTripper) require.NotNil(t, rt) diff --git a/pkg/login/social/connectors/generic_oauth.go b/pkg/login/social/connectors/generic_oauth.go index 02b93285e93..b0b148c884f 100644 --- a/pkg/login/social/connectors/generic_oauth.go +++ b/pkg/login/social/connectors/generic_oauth.go @@ -162,23 +162,6 @@ func (s *SocialGenericOAuth) Reload(ctx context.Context, settings ssoModels.SSOS return nil } -// TODOD: remove this in the next PR and use the isGroupMember from social.go -func (s *SocialGenericOAuth) isGroupMember(groups []string) bool { - if len(s.info.AllowedGroups) == 0 { - return true - } - - for _, allowedGroup := range s.info.AllowedGroups { - for _, group := range groups { - if group == allowedGroup { - return true - } - } - } - - return false -} - func (s *SocialGenericOAuth) isTeamMember(ctx context.Context, client *http.Client) bool { if len(s.teamIds) == 0 { return true diff --git a/pkg/login/social/connectors/okta_oauth.go b/pkg/login/social/connectors/okta_oauth.go index b91a73c92cb..7abd33f6db5 100644 --- a/pkg/login/social/connectors/okta_oauth.go +++ b/pkg/login/social/connectors/okta_oauth.go @@ -205,20 +205,3 @@ func (s *SocialOkta) getGroups(data *OktaUserInfoJson) []string { } return groups } - -// TODO: remove this in a separate PR and use the isGroupMember from the social.go -func (s *SocialOkta) isGroupMember(groups []string) bool { - if len(s.info.AllowedGroups) == 0 { - return true - } - - for _, allowedGroup := range s.info.AllowedGroups { - for _, group := range groups { - if group == allowedGroup { - return true - } - } - } - - return false -} 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/operators/iam/zanzana_folder_reconciler.go b/pkg/operators/iam/zanzana_folder_reconciler.go index ee2233d7877..2fabef43dbd 100644 --- a/pkg/operators/iam/zanzana_folder_reconciler.go +++ b/pkg/operators/iam/zanzana_folder_reconciler.go @@ -6,24 +6,22 @@ import ( "errors" "fmt" "log/slog" - "net/http" "os" "os/signal" "syscall" "github.com/prometheus/client_golang/prometheus" "k8s.io/client-go/rest" - "k8s.io/client-go/transport" "github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana-app-sdk/operator" folder "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/apps/iam/pkg/app" + "github.com/grafana/grafana/pkg/clientauth" "github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/authlib/authn" - utilnet "k8s.io/apimachinery/pkg/util/net" ) func RunIAMFolderReconciler(deps server.OperatorDependencies) error { @@ -151,12 +149,11 @@ func buildKubeConfigFromFolderAppURL( return &rest.Config{ APIPath: "/apis", Host: folderAppURL, - WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper { - return &authRoundTripper{ - tokenExchangeClient: tokenExchangeClient, - transport: rt, - } - }), + WrapTransport: clientauth.NewStaticTokenExchangeTransportWrapper( + tokenExchangeClient, + folder.GROUP, + clientauth.WildcardNamespace, + ), TLSClientConfig: tlsConfig, }, nil } @@ -189,23 +186,3 @@ func buildTLSConfig(insecure bool, certFile, keyFile, caFile string) (rest.TLSCl return tlsConfig, nil } - -type authRoundTripper struct { - tokenExchangeClient *authn.TokenExchangeClient - transport http.RoundTripper -} - -func (t *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - tokenResponse, err := t.tokenExchangeClient.Exchange(req.Context(), authn.TokenExchangeRequest{ - Audiences: []string{folder.GROUP}, - Namespace: "*", - }) - if err != nil { - return nil, fmt.Errorf("failed to exchange token: %w", err) - } - - // clone the request as RTs are not expected to mutate the passed request - req = utilnet.CloneRequest(req) - req.Header.Set("X-Access-Token", "Bearer "+tokenResponse.Token) - return t.transport.RoundTrip(req) -} diff --git a/pkg/plugins/backendplugin/coreplugin/core_plugin_test.go b/pkg/plugins/backendplugin/coreplugin/core_plugin_test.go index e4a6cecfef9..4f9ae0ffa29 100644 --- a/pkg/plugins/backendplugin/coreplugin/core_plugin_test.go +++ b/pkg/plugins/backendplugin/coreplugin/core_plugin_test.go @@ -1,20 +1,20 @@ -package coreplugin_test +package coreplugin import ( "context" "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" - "github.com/stretchr/testify/require" ) func TestCorePlugin(t *testing.T) { t.Run("New core plugin with empty opts should return expected values", func(t *testing.T) { - factory := coreplugin.New(backend.ServeOpts{}) + factory := New(backend.ServeOpts{}) p, err := factory("plugin", log.New("test"), pluginfakes.InitializeNoopTracerForTest(), nil) require.NoError(t, err) require.NotNil(t, p) @@ -36,7 +36,7 @@ func TestCorePlugin(t *testing.T) { t.Run("New core plugin with handlers set in opts should return expected values", func(t *testing.T) { checkHealthCalled := false callResourceCalled := false - factory := coreplugin.New(backend.ServeOpts{ + factory := New(backend.ServeOpts{ CheckHealthHandler: backend.CheckHealthHandlerFunc(func(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { checkHealthCalled = true diff --git a/pkg/plugins/backendplugin/coreplugin/registry.go b/pkg/plugins/backendplugin/coreplugin/registry.go index 1e610b1ef1c..47c59216646 100644 --- a/pkg/plugins/backendplugin/coreplugin/registry.go +++ b/pkg/plugins/backendplugin/coreplugin/registry.go @@ -5,18 +5,16 @@ import ( "errors" "fmt" + "go.opentelemetry.io/otel/trace" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" sdklog "github.com/grafana/grafana-plugin-sdk-go/backend/log" sdktracing "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" - "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" cloudmonitoring "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring" "github.com/grafana/grafana/pkg/tsdb/cloudwatch" @@ -94,7 +92,7 @@ func NewRegistry(store map[string]backendplugin.PluginFactoryFunc) *Registry { } } -func ProvideCoreRegistry(tracer tracing.Tracer, am *azuremonitor.Service, cw *cloudwatch.Service, cm *cloudmonitoring.Service, +func ProvideCoreRegistry(tracer trace.Tracer, am *azuremonitor.Service, cw *cloudwatch.Service, cm *cloudmonitoring.Service, es *elasticsearch.Service, grap *graphite.Service, idb *influxdb.Service, lk *loki.Service, otsdb *opentsdb.Service, pr *prometheus.Service, t *tempo.Service, td *testdatasource.Service, pg *postgres.Service, my *mysql.Service, ms *mssql.Service, graf *grafanads.Service, pyroscope *pyroscope.Service, parca *parca.Service, zipkin *zipkin.Service, jaeger *jaeger.Service) *Registry { @@ -204,7 +202,7 @@ var ErrCorePluginNotFound = errors.New("core plugin not found") // NewPlugin factory for creating and initializing a single core plugin. // Note: cfg only needed for mssql connection pooling defaults. -func NewPlugin(pluginID string, cfg *setting.Cfg, httpClientProvider *httpclient.Provider, tracer tracing.Tracer, features featuremgmt.FeatureToggles) (*plugins.Plugin, error) { +func NewPlugin(pluginID string, httpClientProvider *httpclient.Provider, tracer trace.Tracer) (*plugins.Plugin, error) { jsonData := plugins.JSONData{ ID: pluginID, AliasIDs: []string{}, @@ -241,7 +239,7 @@ func NewPlugin(pluginID string, cfg *setting.Cfg, httpClientProvider *httpclient case MySQL: svc = mysql.ProvideService() case MSSQL: - svc = mssql.ProvideService(cfg) + svc = mssql.ProvideService() case Pyroscope: svc = pyroscope.ProvideService(httpClientProvider) case Parca: diff --git a/pkg/plugins/backendplugin/coreplugin/registry_test.go b/pkg/plugins/backendplugin/coreplugin/registry_test.go index 41a1ca7f7ec..a413ad73b3b 100644 --- a/pkg/plugins/backendplugin/coreplugin/registry_test.go +++ b/pkg/plugins/backendplugin/coreplugin/registry_test.go @@ -4,11 +4,10 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/plugins/log" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" ) func TestNewPlugin(t *testing.T) { @@ -46,7 +45,7 @@ func TestNewPlugin(t *testing.T) { tc.ExpectedID = tc.ID } - p, err := NewPlugin(tc.ID, setting.NewCfg(), httpclient.NewProvider(), tracing.InitializeTracerForTest(), featuremgmt.WithFeatures()) + p, err := NewPlugin(tc.ID, httpclient.NewProvider(), tracing.NoopTracer()) if tc.ExpectedNotFoundErr { require.ErrorIs(t, err, ErrCorePluginNotFound) require.Nil(t, p) diff --git a/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go b/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go index d1bcb5640a2..f8ffd6d6d71 100644 --- a/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go +++ b/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go @@ -9,7 +9,6 @@ import ( "github.com/hashicorp/go-plugin" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/process" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/log" @@ -90,14 +89,6 @@ func (p *grpcPlugin) Start(_ context.Context) error { return errors.New("no compatible plugin implementation found") } - elevated, err := process.IsRunningWithElevatedPrivileges() - if err != nil { - p.logger.Debug("Error checking plugin process execution privilege", "error", err) - } - if elevated { - p.logger.Warn("Plugin process is running with elevated privileges. This is not recommended") - } - p.state = pluginStateStartSuccess return nil } diff --git a/pkg/plugins/config/config.go b/pkg/plugins/config/config.go index 40e744147bb..c84e70233b3 100644 --- a/pkg/plugins/config/config.go +++ b/pkg/plugins/config/config.go @@ -1,9 +1,5 @@ package config -import ( - "github.com/grafana/grafana/pkg/setting" -) - // PluginManagementCfg is the configuration for the plugin management system. // It includes settings which are used to configure different components of plugin management. type PluginManagementCfg struct { @@ -11,7 +7,7 @@ type PluginManagementCfg struct { PluginsPath string - PluginSettings setting.PluginSettings + PluginSettings PluginSettings PluginsAllowUnsigned []string DisablePlugins []string ForwardHostEnvVars []string @@ -35,8 +31,11 @@ type Features struct { TempoAlertingEnabled bool } +// PluginSettings maps plugin id to map of key/value settings. +type PluginSettings map[string]map[string]string + // NewPluginManagementCfg returns a new PluginManagementCfg. -func NewPluginManagementCfg(devMode bool, pluginsPath string, pluginSettings setting.PluginSettings, pluginsAllowUnsigned []string, +func NewPluginManagementCfg(devMode bool, pluginsPath string, pluginSettings PluginSettings, pluginsAllowUnsigned []string, pluginsCDNURLTemplate string, appURL string, features Features, grafanaComAPIURL string, disablePlugins []string, forwardHostEnvVars []string, grafanaComAPIToken string, ) *PluginManagementCfg { diff --git a/pkg/util/filepath.go b/pkg/plugins/filepath.go similarity index 99% rename from pkg/util/filepath.go rename to pkg/plugins/filepath.go index fc760058407..613c15699af 100644 --- a/pkg/util/filepath.go +++ b/pkg/plugins/filepath.go @@ -1,4 +1,4 @@ -package util +package plugins import ( "errors" diff --git a/pkg/util/filepath_test.go b/pkg/plugins/filepath_test.go similarity index 98% rename from pkg/util/filepath_test.go rename to pkg/plugins/filepath_test.go index dcaf10a53f9..efcea7b5653 100644 --- a/pkg/util/filepath_test.go +++ b/pkg/plugins/filepath_test.go @@ -1,4 +1,4 @@ -package util +package plugins import ( "path/filepath" diff --git a/pkg/plugins/localfiles.go b/pkg/plugins/localfiles.go index 073ab0e844d..c1f3e3753c4 100644 --- a/pkg/plugins/localfiles.go +++ b/pkg/plugins/localfiles.go @@ -7,8 +7,6 @@ import ( "os" "path/filepath" "strings" - - "github.com/grafana/grafana/pkg/util" ) var ( @@ -110,7 +108,7 @@ func (f LocalFS) walkFunc(basePath string, acc map[string]struct{}) filepath.Wal // If a nil error is returned, the caller should take care of calling Close() the returned fs.File. // If the file does not exist, ErrFileNotExist is returned. func (f LocalFS) Open(name string) (fs.File, error) { - cleanPath, err := util.CleanRelativePath(name) + cleanPath, err := CleanRelativePath(name) if err != nil { return nil, err } @@ -155,7 +153,7 @@ func (f LocalFS) Files() ([]string, error) { if err != nil { return nil, err } - clenRelPath, err := util.CleanRelativePath(relPath) + clenRelPath, err := CleanRelativePath(relPath) if err != nil { continue } diff --git a/pkg/plugins/manager/client/client.go b/pkg/plugins/manager/client/client.go index 5cc8f44c5b8..ec903ae46cd 100644 --- a/pkg/plugins/manager/client/client.go +++ b/pkg/plugins/manager/client/client.go @@ -14,7 +14,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/manager/registry" - "github.com/grafana/grafana/pkg/util/proxyutil" ) const ( @@ -124,7 +123,7 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq res.Headers = map[string][]string{} } - proxyutil.SetProxyResponseHeaders(res.Headers) + SetCSPHeader(res.Headers) ensureContentTypeHeader(res) } @@ -281,6 +280,10 @@ func (s *Service) ValidateAdmission(ctx context.Context, req *backend.AdmissionR return plugin.ValidateAdmission(ctx, req) } +func SetCSPHeader(header http.Header) { + header.Set("Content-Security-Policy", "sandbox") +} + // plugin finds a plugin with `pluginID` from the registry that is not decommissioned func (s *Service) plugin(ctx context.Context, pluginID, pluginVersion string) (*plugins.Plugin, bool) { p, exists := s.pluginRegistry.Plugin(ctx, pluginID, pluginVersion) diff --git a/pkg/plugins/manager/client/client_test.go b/pkg/plugins/manager/client/client_test.go index 1a11f330de9..f1ce6b2576f 100644 --- a/pkg/plugins/manager/client/client_test.go +++ b/pkg/plugins/manager/client/client_test.go @@ -8,11 +8,11 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" - "github.com/grafana/grafana/pkg/util/testutil" - "github.com/stretchr/testify/require" ) func TestQueryData(t *testing.T) { @@ -157,9 +157,7 @@ func TestCheckHealth(t *testing.T) { }) } -func TestIntegrationCallResource(t *testing.T) { - testutil.SkipIntegrationTestInShortMode(t) - +func TestCallResource(t *testing.T) { registry := pluginfakes.NewFakePluginRegistry() p := &plugins.Plugin{ JSONData: plugins.JSONData{ diff --git a/pkg/plugins/manager/loader/loader.go b/pkg/plugins/manager/loader/loader.go index d4943cee865..0bbaa0db015 100644 --- a/pkg/plugins/manager/loader/loader.go +++ b/pkg/plugins/manager/loader/loader.go @@ -15,7 +15,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/pipeline/initialization" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/termination" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" ) type Loader struct { diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index 6e6021bc021..7613392f497 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/bootstrap" @@ -20,8 +21,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation" "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" "github.com/grafana/grafana/pkg/plugins/manager/sources" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" ) var compareOpts = []cmp.Option{cmpopts.IgnoreFields(plugins.Plugin{}, "client", "log", "mu"), fsComparer} @@ -180,7 +180,7 @@ func TestLoader_Load(t *testing.T) { Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", - Role: org.RoleViewer, + Role: identity.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-connections", }, @@ -188,21 +188,21 @@ func TestLoader_Load(t *testing.T) { Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", - Role: org.RoleViewer, + Role: identity.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-memory", }, { Name: "Nginx Panel", Type: string(plugins.TypePanel), - Role: org.RoleViewer, + Role: identity.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-panel", }, { Name: "Nginx Datasource", Type: string(plugins.TypeDataSource), - Role: org.RoleViewer, + Role: identity.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-datasource", }, @@ -413,8 +413,8 @@ func TestLoader_Load(t *testing.T) { }, }, Includes: []*plugins.Includes{ - {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-memory"}, - {Name: "Root Page (react)", Type: "page", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"}, + {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: identity.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-memory"}, + {Name: "Root Page (react)", Type: "page", Role: identity.RoleViewer, Action: plugins.ActionAppAccess, Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"}, }, Extensions: plugins.Extensions{ AddedLinks: []plugins.AddedLink{}, diff --git a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go index e6845322516..f20c1ff1ead 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go +++ b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go @@ -6,12 +6,12 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/semconv" ) diff --git a/pkg/plugins/manager/pipeline/bootstrap/steps.go b/pkg/plugins/manager/pipeline/bootstrap/steps.go index 7608ba2c4fa..5c365ebb47c 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/steps.go +++ b/pkg/plugins/manager/pipeline/bootstrap/steps.go @@ -5,7 +5,8 @@ import ( "path" "slices" - "github.com/grafana/grafana/pkg/infra/slugify" + "github.com/Machiel/slugify" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" diff --git a/pkg/plugins/manager/pipeline/discovery/discovery.go b/pkg/plugins/manager/pipeline/discovery/discovery.go index e5bdc50dd62..08a74b1cce0 100644 --- a/pkg/plugins/manager/pipeline/discovery/discovery.go +++ b/pkg/plugins/manager/pipeline/discovery/discovery.go @@ -7,10 +7,10 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" ) // Discoverer is responsible for the Discovery stage of the plugin loader pipeline. diff --git a/pkg/plugins/manager/pipeline/initialization/initialization.go b/pkg/plugins/manager/pipeline/initialization/initialization.go index 4319f4811a7..6a697fc7009 100644 --- a/pkg/plugins/manager/pipeline/initialization/initialization.go +++ b/pkg/plugins/manager/pipeline/initialization/initialization.go @@ -6,10 +6,10 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/semconv" ) diff --git a/pkg/plugins/manager/pipeline/termination/termination.go b/pkg/plugins/manager/pipeline/termination/termination.go index fdb28396bbf..f27ec531bc7 100644 --- a/pkg/plugins/manager/pipeline/termination/termination.go +++ b/pkg/plugins/manager/pipeline/termination/termination.go @@ -6,10 +6,10 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/semconv" ) diff --git a/pkg/plugins/manager/pipeline/validation/validation.go b/pkg/plugins/manager/pipeline/validation/validation.go index 36db1f25163..465ed0ce089 100644 --- a/pkg/plugins/manager/pipeline/validation/validation.go +++ b/pkg/plugins/manager/pipeline/validation/validation.go @@ -6,10 +6,10 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/semconv" ) diff --git a/pkg/plugins/manager/sources/source_local_disk.go b/pkg/plugins/manager/sources/source_local_disk.go index 0ec55afbe0b..fc0cf3efc7a 100644 --- a/pkg/plugins/manager/sources/source_local_disk.go +++ b/pkg/plugins/manager/sources/source_local_disk.go @@ -10,14 +10,12 @@ import ( "slices" "strings" - "github.com/grafana/grafana/pkg/infra/fs" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" - "github.com/grafana/grafana/pkg/util" ) -var walk = util.Walk +var walk = plugins.Walk var ( ErrInvalidPluginJSONFilePath = errors.New("invalid plugin.json filepath was provided") @@ -79,15 +77,14 @@ func (s *LocalSource) Discover(_ context.Context) ([]*plugins.FoundBundle, error pluginJSONPaths := make([]string, 0, len(s.paths)) for _, path := range s.paths { - exists, err := fs.Exists(path) - if err != nil { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + s.log.Warn("Skipping finding plugins as directory does not exist", "path", path) + continue + } s.log.Warn("Skipping finding plugins as an error occurred", "path", path, "error", err) continue } - if !exists { - s.log.Warn("Skipping finding plugins as directory does not exist", "path", path) - continue - } paths, err := s.getAbsPluginJSONPaths(path) if err != nil { @@ -217,7 +214,7 @@ func (s *LocalSource) getAbsPluginJSONPaths(path string) ([]string, error) { } if fi.Name() == "node_modules" { - return util.ErrWalkSkipDir + return plugins.ErrWalkSkipDir } if fi.IsDir() { diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 8356a652793..dfad38bcaa1 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/apimachinery/identity" ) const ( @@ -148,17 +148,17 @@ type ExtensionsDependencies struct { } type Includes struct { - Name string `json:"name"` - Path string `json:"path"` - Type string `json:"type"` - Component string `json:"component"` - Role org.RoleType `json:"role"` - Action string `json:"action,omitempty"` - AddToNav bool `json:"addToNav"` - DefaultNav bool `json:"defaultNav"` - Slug string `json:"slug"` - Icon string `json:"icon"` - UID string `json:"uid"` + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Component string `json:"component"` + Role identity.RoleType `json:"role"` + Action string `json:"action,omitempty"` + AddToNav bool `json:"addToNav"` + DefaultNav bool `json:"defaultNav"` + Slug string `json:"slug"` + Icon string `json:"icon"` + UID string `json:"uid"` ID string `json:"-"` } diff --git a/pkg/services/pluginsintegration/pluginerrs/errors.go b/pkg/plugins/pluginerrs/errors.go similarity index 100% rename from pkg/services/pluginsintegration/pluginerrs/errors.go rename to pkg/plugins/pluginerrs/errors.go diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 950dbfe7849..bf1b23a35b0 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -14,12 +14,11 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/plugins/auth" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2" "github.com/grafana/grafana/pkg/plugins/log" - "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/util" ) var ( @@ -27,7 +26,6 @@ var ( ErrPluginFileRead = errors.New("file could not be read") ErrUninstallInvalidPluginDir = errors.New("cannot recognize as plugin folder") ErrInvalidPluginJSON = errors.New("did not find valid type or id properties in plugin.json") - ErrUnsupportedAlias = errors.New("can not set alias in plugin.json") ) type Plugin struct { @@ -187,11 +185,11 @@ func ReadPluginJSON(reader io.Reader) (JSONData, error) { for _, include := range plugin.Includes { if include.Role == "" { - include.Role = org.RoleViewer + include.Role = identity.RoleViewer } // Default to app access for app plugins - if plugin.Type == TypeApp && include.Role == org.RoleViewer && include.Action == "" { + if plugin.Type == TypeApp && include.Role == identity.RoleViewer && include.Action == "" { include.Action = ActionAppAccess } } @@ -220,17 +218,17 @@ func (d JSONData) DashboardIncludes() []*Includes { // Route describes a plugin route that is defined in // the plugin.json file for a plugin. type Route struct { - Path string `json:"path"` - Method string `json:"method"` - ReqRole org.RoleType `json:"reqRole"` - ReqAction string `json:"reqAction"` - URL string `json:"url"` - URLParams []URLParam `json:"urlParams"` - Headers []Header `json:"headers"` - AuthType string `json:"authType"` - TokenAuth *JWTTokenAuth `json:"tokenAuth"` - JwtTokenAuth *JWTTokenAuth `json:"jwtTokenAuth"` - Body json.RawMessage `json:"body"` + Path string `json:"path"` + Method string `json:"method"` + ReqRole identity.RoleType `json:"reqRole"` + ReqAction string `json:"reqAction"` + URL string `json:"url"` + URLParams []URLParam `json:"urlParams"` + Headers []Header `json:"headers"` + AuthType string `json:"authType"` + TokenAuth *JWTTokenAuth `json:"tokenAuth"` + JwtTokenAuth *JWTTokenAuth `json:"jwtTokenAuth"` + Body json.RawMessage `json:"body"` } // Header describes an HTTP header that is forwarded with @@ -414,7 +412,7 @@ func (p *Plugin) ConvertObjects(ctx context.Context, req *backend.ConversionRequ } func (p *Plugin) File(name string) (fs.File, error) { - cleanPath, err := util.CleanRelativePath(name) + cleanPath, err := CleanRelativePath(name) if err != nil { // CleanRelativePath should clean and make the path relative so this is not expected to fail return nil, err diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go index 3ee1f1a2dab..ef77045dcc4 100644 --- a/pkg/plugins/plugins_test.go +++ b/pkg/plugins/plugins_test.go @@ -8,8 +8,9 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/grafana/grafana/pkg/services/org" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/apimachinery/identity" ) func Test_ReadPluginJSON(t *testing.T) { @@ -73,10 +74,10 @@ func Test_ReadPluginJSON(t *testing.T) { }, Includes: []*Includes{ - {Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer, Action: ActionAppAccess}, - {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer, Action: ActionAppAccess}, - {Name: "Nginx Panel", Type: "panel", Role: org.RoleViewer, Action: ActionAppAccess}, - {Name: "Nginx Datasource", Type: "datasource", Role: org.RoleViewer, Action: ActionAppAccess}, + {Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: identity.RoleViewer, Action: ActionAppAccess}, + {Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: identity.RoleViewer, Action: ActionAppAccess}, + {Name: "Nginx Panel", Type: "panel", Role: identity.RoleViewer, Action: ActionAppAccess}, + {Name: "Nginx Datasource", Type: "datasource", Role: identity.RoleViewer, Action: ActionAppAccess}, }, Backend: false, }, @@ -126,7 +127,7 @@ func Test_ReadPluginJSON(t *testing.T) { }, Includes: []*Includes{ - {Name: "Pie Charts", Path: "dashboards/demo.json", Type: "dashboard", Role: org.RoleViewer}, + {Name: "Pie Charts", Path: "dashboards/demo.json", Type: "dashboard", Role: identity.RoleViewer}, }, }, }, diff --git a/pkg/plugins/tracing/tracing.go b/pkg/plugins/tracing/tracing.go new file mode 100644 index 00000000000..f039b10914b --- /dev/null +++ b/pkg/plugins/tracing/tracing.go @@ -0,0 +1,35 @@ +package tracing + +import ( + "context" + "net/http" + + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +// Tracer defines the service used to create new spans. +type Tracer interface { + trace.Tracer + + // Inject adds identifying information for the span to the + // headers defined in [http.Header] map (this mutates http.Header). + Inject(context.Context, http.Header, trace.Span) +} + +// Error sets the status to error and record the error as an exception in the provided span. +// This is a simplified version that works directly with OpenTelemetry spans. +func Error(span trace.Span, err error) error { + if err == nil { + return nil + } + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return err +} + +// NoopTracer returns a no-op tracer that can be used when tracing is not available. +func NoopTracer() trace.Tracer { + return noop.NewTracerProvider().Tracer("") +} diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index eeeb76f924e..eed79dd6f0d 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/grafana/grafana/pkg/configprovider" "github.com/prometheus/client_golang/prometheus" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -62,7 +63,6 @@ import ( "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/storage/unified/apistore" @@ -128,7 +128,6 @@ type DashboardsAPIBuilder struct { } func RegisterAPIService( - cfg *setting.Cfg, features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, dashboardService dashboards.DashboardService, @@ -154,7 +153,14 @@ func RegisterAPIService( publicDashboardService publicdashboards.Service, snapshotService dashboardsnapshots.Service, dashboardActivityChannel live.DashboardActivityChannel, + configProvider configprovider.ConfigProvider, ) *DashboardsAPIBuilder { + cfg, err := configProvider.Get(context.Background()) + if err != nil { + logging.DefaultLogger.Error("failed to load settings configuration instance", "stackId", cfg.StackID, "err", err) + return nil + } + dbp := legacysql.NewDatabaseProvider(sql) namespacer := request.GetNamespaceMapper(cfg) legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter) @@ -747,7 +753,6 @@ func (b *DashboardsAPIBuilder) storageForVersion( ResourceInfo: *snapshots, Service: b.snapshotService, Namespacer: b.namespacer, - Options: b.snapshotOptions, } storage[snapshots.StoragePath()] = snapshotLegacyStore storage[snapshots.StoragePath("dashboard")], err = snapshot.NewDashboardREST(dashboards, b.snapshotService) diff --git a/pkg/registry/apis/dashboard/snapshot/routes.go b/pkg/registry/apis/dashboard/snapshot/routes.go index c8175d6d9dd..832589f5c68 100644 --- a/pkg/registry/apis/dashboard/snapshot/routes.go +++ b/pkg/registry/apis/dashboard/snapshot/routes.go @@ -29,6 +29,8 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin createCmd := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.DashboardCreateCommand"].Schema createExample := `{"dashboard":{"annotations":{"list":[{"name":"Annotations & Alerts","enable":true,"iconColor":"rgba(0, 211, 255, 1)","snapshotData":[],"type":"dashboard","builtIn":1,"hide":true}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"id":203,"links":[],"liveNow":false,"panels":[{"datasource":null,"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":0},"id":1,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"10.4.0-pre","snapshotData":[{"fields":[{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"showPoints":"auto","thresholdsStyle":{"mode":"off"}},"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"time","type":"time","values":[1706030536378,1706034856378,1706039176378,1706043496378,1706047816378,1706052136378]},{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"A-series","type":"number","values":[1,20,90,30,50,0]}],"refId":"A"}],"targets":[],"title":"Simple example","type":"timeseries","links":[]}],"refresh":"","schemaVersion":39,"snapshot":{"timestamp":"2024-01-23T23:22:16.377Z"},"tags":[],"templating":{"list":[]},"time":{"from":"2024-01-23T17:22:20.380Z","to":"2024-01-23T23:22:20.380Z","raw":{"from":"now-6h","to":"now"}},"timepicker":{},"timezone":"","title":"simple and small","uid":"b22ec8db-399b-403b-b6c7-b0fb30ccb2a5","version":1,"weekStart":""},"name":"simple and small","expires":86400}` createRsp := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.DashboardCreateResponse"].Schema + getSettingsRsp := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.SnapshotSharingOptions"].Schema + getSettingsRspExample := `{"snapshotsEnabled":true,"externalSnapshotURL":"https://externalurl.com","externalSnapshotName":"external","externalEnabled":true}` return &builder.APIRoutes{ Namespace: []builder.APIRouteHandler{ @@ -167,5 +169,84 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin }) }, }, + { + Path: prefix + "/settings", + Spec: &spec3.PathProps{ + Get: &spec3.Operation{ + VendorExtensible: spec.VendorExtensible{ + Extensions: map[string]any{ + "x-grafana-action": "get", + "x-kubernetes-group-version-kind": metav1.GroupVersionKind{ + Group: dashv0.GROUP, + Version: dashv0.VERSION, + Kind: "SnapshotSharingOptions", + }, + }, + }, + OperationProps: spec3.OperationProps{ + Tags: tags, + OperationId: "getSnapshotSettings", + Description: "Get Snapshot sharing settings", + Parameters: []*spec3.Parameter{ + { + ParameterProps: spec3.ParameterProps{ + Name: "namespace", + In: "path", + Required: true, + Example: "default", + Description: "workspace", + Schema: spec.StringProperty(), + }, + }, + }, + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + StatusCodeResponses: map[int]*spec3.Response{ + 200: { + ResponseProps: spec3.ResponseProps{ + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &getSettingsRsp, + Example: getSettingsRspExample, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Handler: func(w http.ResponseWriter, r *http.Request) { + user, err := identity.GetRequester(r.Context()) + if err != nil { + errhttp.Write(r.Context(), err, w) + return + } + wrap := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: r, + Resp: web.NewResponseWriter(r.Method, w), + }, + } + + vars := mux.Vars(r) + info, err := authlib.ParseNamespace(vars["namespace"]) + if err != nil { + wrap.JsonApiErr(http.StatusBadRequest, "expected namespace", nil) + return + } + if info.OrgID != user.GetOrgID() { + wrap.JsonApiErr(http.StatusBadRequest, + fmt.Sprintf("user orgId does not match namespace (%d != %d)", info.OrgID, user.GetOrgID()), nil) + return + } + + wrap.JSON(http.StatusOK, options) + }, + }, }} } diff --git a/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go b/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go index aafbc2b283d..7ba2d4228c5 100644 --- a/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go +++ b/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go @@ -2,7 +2,6 @@ package snapshot import ( "context" - "fmt" "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -29,7 +28,6 @@ type SnapshotLegacyStore struct { ResourceInfo utils.ResourceInfo Service dashboardsnapshots.Service Namespacer request.NamespaceMapper - Options dashV0.SnapshotSharingOptions } func (s *SnapshotLegacyStore) New() runtime.Object { @@ -117,15 +115,6 @@ func (s *SnapshotLegacyStore) List(ctx context.Context, options *internalversion } func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { - info, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - return nil, err - } - - err = s.checkEnabled(info.Value) - if err != nil { - return nil, err - } query := dashboardsnapshots.GetDashboardSnapshotQuery{ Key: name, } @@ -140,10 +129,3 @@ func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *met } return nil, s.ResourceInfo.NewNotFound(name) } - -func (s *SnapshotLegacyStore) checkEnabled(ns string) error { - if !s.Options.SnapshotsEnabled { - return fmt.Errorf("snapshots not enabled") - } - return nil -} diff --git a/pkg/registry/apis/iam/authorizer/parent_provider.go b/pkg/registry/apis/iam/authorizer/parent_provider.go index 4b7555d85ba..e64f73c2ce5 100644 --- a/pkg/registry/apis/iam/authorizer/parent_provider.go +++ b/pkg/registry/apis/iam/authorizer/parent_provider.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net/http" "sync" "github.com/grafana/authlib/authn" @@ -15,8 +14,8 @@ import ( dashboardv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" folderv1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" - "github.com/grafana/grafana/apps/provisioning/pkg/auth" "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/clientauth" ) var ( @@ -73,10 +72,8 @@ func NewRemoteConfigProvider(cfg map[schema.GroupResource]DialConfig, exchangeCl for gr, dialConfig := range cfg { configProviders[gr] = func(ctx context.Context) (*rest.Config, error) { return &rest.Config{ - Host: dialConfig.Host, - WrapTransport: func(rt http.RoundTripper) http.RoundTripper { - return auth.NewRoundTripper(exchangeClient, rt, dialConfig.Audience) - }, + Host: dialConfig.Host, + WrapTransport: clientauth.NewStaticTokenExchangeTransportWrapper(exchangeClient, dialConfig.Audience, clientauth.WildcardNamespace), TLSClientConfig: rest.TLSClientConfig{ Insecure: dialConfig.Insecure, CAFile: dialConfig.CAFile, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 44838906d6e..bf7daebf4e5 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -44,8 +44,8 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/process" "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" - "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/repo" "github.com/grafana/grafana/pkg/registry/apis" @@ -193,10 +193,10 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginexternal" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller" service6 "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsources" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsso" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" @@ -371,7 +371,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } cacheService := localcache.ProvideService() ossDataSourceRequestValidator := validations.ProvideValidator() - sourcesService := sources.ProvideService(cfg, pluginManagementCfg) + pluginsourcesService := pluginsources.ProvideService(cfg, pluginManagementCfg) discovery := pipeline.ProvideDiscoveryStage(pluginManagementCfg, inMemory) keystoreService := keystore.ProvideService(kvStore) keyRetriever := dynamic.ProvideService(cfg, keystoreService) @@ -391,13 +391,13 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api return nil, err } validate := pipeline.ProvideValidationStage(pluginManagementCfg, validation, angularinspectorService) + tracer := otelTracer() ossDataSourceRequestURLValidator := validations.ProvideURLValidator() httpclientProvider := httpclientprovider.New(cfg, ossDataSourceRequestURLValidator, tracingService) azuremonitorService := azuremonitor.ProvideService(httpclientProvider) cloudwatchService := cloudwatch.ProvideService() cloudmonitoringService := cloudmonitoring.ProvideService(httpclientProvider) elasticsearchService := elasticsearch.ProvideService(httpclientProvider) - tracer := otelTracer() graphiteService := graphite.ProvideService(httpclientProvider, tracer) influxdbService := influxdb.ProvideService(httpclientProvider) lokiService := loki.ProvideService(httpclientProvider, tracer) @@ -407,7 +407,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api testdatasourceService := testdatasource.ProvideService() postgresService := postgres.ProvideService() mysqlService := mysql.ProvideService() - mssqlService := mssql.ProvideService(cfg) + mssqlService := mssql.ProvideService() entityEventsService := store.ProvideEntityEventsService(cfg, sqlStore, featureToggles) configProvider, err := configprovider.ProvideService(cfg) if err != nil { @@ -557,7 +557,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api parcaService := parca.ProvideService(httpclientProvider) zipkinService := zipkin.ProvideService(httpclientProvider) jaegerService := jaeger.ProvideService(httpclientProvider) - corepluginRegistry := coreplugin.ProvideCoreRegistry(tracingService, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService) + corepluginRegistry := coreplugin.ProvideCoreRegistry(tracer, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService) providerService := provider2.ProvideService(corepluginRegistry) processService := process.ProvideService() retrieverService := retriever.ProvideService(sqlStore, apikeyService, kvStore, userService, orgService) @@ -581,7 +581,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } errorRegistry := pluginerrs.ProvideErrorTracker() loaderLoader := loader.ProvideService(pluginManagementCfg, discovery, bootstrap, validate, initialize, terminate, errorRegistry) - pluginstoreService, err := pluginstore.ProvideService(inMemory, sourcesService, loaderLoader, featureToggles) + pluginstoreService, err := pluginstore.ProvideService(inMemory, pluginsourcesService, loaderLoader, featureToggles) if err != nil { return nil, err } @@ -852,7 +852,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService) + zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer) investigationsAppProvider := investigations.RegisterApp(cfg) appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg) if err != nil { @@ -880,8 +880,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService) apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive) - dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel) - dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService) + dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel, configProvider) + dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, pluginsourcesService) if err != nil { return nil, err } @@ -1035,7 +1035,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } cacheService := localcache.ProvideService() ossDataSourceRequestValidator := validations.ProvideValidator() - sourcesService := sources.ProvideService(cfg, pluginManagementCfg) + pluginsourcesService := pluginsources.ProvideService(cfg, pluginManagementCfg) discovery := pipeline.ProvideDiscoveryStage(pluginManagementCfg, inMemory) keystoreService := keystore.ProvideService(kvStore) keyRetriever := dynamic.ProvideService(cfg, keystoreService) @@ -1055,13 +1055,13 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac return nil, err } validate := pipeline.ProvideValidationStage(pluginManagementCfg, validation, angularinspectorService) + tracer := otelTracer() ossDataSourceRequestURLValidator := validations.ProvideURLValidator() httpclientProvider := httpclientprovider.New(cfg, ossDataSourceRequestURLValidator, tracingService) azuremonitorService := azuremonitor.ProvideService(httpclientProvider) cloudwatchService := cloudwatch.ProvideService() cloudmonitoringService := cloudmonitoring.ProvideService(httpclientProvider) elasticsearchService := elasticsearch.ProvideService(httpclientProvider) - tracer := otelTracer() graphiteService := graphite.ProvideService(httpclientProvider, tracer) influxdbService := influxdb.ProvideService(httpclientProvider) lokiService := loki.ProvideService(httpclientProvider, tracer) @@ -1071,7 +1071,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac testdatasourceService := testdatasource.ProvideService() postgresService := postgres.ProvideService() mysqlService := mysql.ProvideService() - mssqlService := mssql.ProvideService(cfg) + mssqlService := mssql.ProvideService() entityEventsService := store.ProvideEntityEventsService(cfg, sqlStore, featureToggles) configProvider, err := configprovider.ProvideService(cfg) if err != nil { @@ -1221,7 +1221,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac parcaService := parca.ProvideService(httpclientProvider) zipkinService := zipkin.ProvideService(httpclientProvider) jaegerService := jaeger.ProvideService(httpclientProvider) - corepluginRegistry := coreplugin.ProvideCoreRegistry(tracingService, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService) + corepluginRegistry := coreplugin.ProvideCoreRegistry(tracer, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService) providerService := provider2.ProvideService(corepluginRegistry) processService := process.ProvideService() retrieverService := retriever.ProvideService(sqlStore, apikeyService, kvStore, userService, orgService) @@ -1245,7 +1245,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } errorRegistry := pluginerrs.ProvideErrorTracker() loaderLoader := loader.ProvideService(pluginManagementCfg, discovery, bootstrap, validate, initialize, terminate, errorRegistry) - pluginstoreService, err := pluginstore.ProvideService(inMemory, sourcesService, loaderLoader, featureToggles) + pluginstoreService, err := pluginstore.ProvideService(inMemory, pluginsourcesService, loaderLoader, featureToggles) if err != nil { return nil, err } @@ -1518,7 +1518,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService) + zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer) investigationsAppProvider := investigations.RegisterApp(cfg) appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg) if err != nil { @@ -1546,8 +1546,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService) apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive) - dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel) - dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService) + dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel, configProvider) + dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, pluginsourcesService) if err != nil { return nil, err } diff --git a/pkg/services/accesscontrol/dualwrite/reconciler.go b/pkg/services/accesscontrol/dualwrite/reconciler.go index a16f6534082..a0f2f47b77d 100644 --- a/pkg/services/accesscontrol/dualwrite/reconciler.go +++ b/pkg/services/accesscontrol/dualwrite/reconciler.go @@ -6,6 +6,8 @@ import ( "strconv" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" "go.opentelemetry.io/otel" claims "github.com/grafana/authlib/types" @@ -33,12 +35,15 @@ type ZanzanaReconciler struct { store db.DB client zanzana.Client lock *serverlock.ServerLockService + metrics struct { + lastSuccess prometheus.Gauge + } // reconcilers are migrations that tries to reconcile the state of grafana db to zanzana store. // These are run periodically to try to maintain a consistent state. reconcilers []resourceReconciler } -func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureToggles, client zanzana.Client, store db.DB, lock *serverlock.ServerLockService, folderService folder.Service) *ZanzanaReconciler { +func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureToggles, client zanzana.Client, store db.DB, lock *serverlock.ServerLockService, folderService folder.Service, reg prometheus.Registerer) *ZanzanaReconciler { zanzanaReconciler := &ZanzanaReconciler{ cfg: cfg, log: reconcilerLogger, @@ -92,6 +97,13 @@ func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureTogg }, } + if reg != nil { + zanzanaReconciler.metrics.lastSuccess = promauto.With(reg).NewGauge(prometheus.GaugeOpts{ + Name: "grafana_zanzana_reconcile_last_success_timestamp_seconds", + Help: "Unix timestamp (seconds) when the Zanzana reconciler last completed a reconciliation cycle.", + }) + } + if cfg.Anonymous.Enabled { zanzanaReconciler.reconcilers = append(zanzanaReconciler.reconcilers, newResourceReconciler( @@ -134,17 +146,20 @@ func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error { } 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 } } r.log.Debug("Finished reconciliation", "elapsed", time.Since(now)) + return ok } var namespaces []string @@ -169,16 +184,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 { diff --git a/pkg/services/apiserver/auth/authorizer/role.go b/pkg/services/apiserver/auth/authorizer/role.go index e8e70dd01c8..63313352571 100644 --- a/pkg/services/apiserver/auth/authorizer/role.go +++ b/pkg/services/apiserver/auth/authorizer/role.go @@ -15,6 +15,8 @@ var _ authorizer.Authorizer = &roleAuthorizer{} var orgRoleNoneAsViewerAPIGroups = []string{ "productactivation.ext.grafana.com", + // playlist can be removed after this issue is resolved: https://github.com/grafana/grafana/issues/115712 + "playlist.grafana.app", } type roleAuthorizer struct{} diff --git a/pkg/services/authz/rbac.go b/pkg/services/authz/rbac.go index c6c078a9b4a..a96ffc5d61d 100644 --- a/pkg/services/authz/rbac.go +++ b/pkg/services/authz/rbac.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net/http" "time" "github.com/fullstorydev/grpchan/inprocgrpc" @@ -24,6 +23,7 @@ import ( authlib "github.com/grafana/authlib/types" "github.com/grafana/dskit/middleware" + "github.com/grafana/grafana/pkg/clientauth" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -262,9 +262,11 @@ func RegisterRBACAuthZService( folderStore = store.NewAPIFolderStore(tracer, reg, func(ctx context.Context) (*rest.Config, error) { return &rest.Config{ Host: cfg.Folder.Host, - WrapTransport: func(rt http.RoundTripper) http.RoundTripper { - return &tokenExhangeRoundTripper{te: exchangeClient, rt: rt} - }, + WrapTransport: clientauth.NewStaticTokenExchangeTransportWrapper( + exchangeClient, + "folder.grafana.app", + clientauth.WildcardNamespace, + ), TLSClientConfig: rest.TLSClientConfig{ Insecure: cfg.Folder.Insecure, CAFile: cfg.Folder.CAFile, @@ -291,27 +293,6 @@ func RegisterRBACAuthZService( authzv1.RegisterAuthzServiceServer(srv, server) } -var _ http.RoundTripper = tokenExhangeRoundTripper{} - -type tokenExhangeRoundTripper struct { - te authnlib.TokenExchanger - rt http.RoundTripper -} - -func (t tokenExhangeRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { - res, err := t.te.Exchange(r.Context(), authnlib.TokenExchangeRequest{ - Namespace: "*", - Audiences: []string{"folder.grafana.app"}, - }) - - if err != nil { - return nil, fmt.Errorf("create access token: %w", err) - } - - r.Header.Set("X-Access-Token", "Bearer "+res.Token) - return t.rt.RoundTrip(r) -} - type NoopCache struct{} func (lc *NoopCache) Get(ctx context.Context, key string) ([]byte, error) { diff --git a/pkg/services/authz/zanzana.go b/pkg/services/authz/zanzana.go index f6751258f2d..79eb19a8b5b 100644 --- a/pkg/services/authz/zanzana.go +++ b/pkg/services/authz/zanzana.go @@ -78,6 +78,9 @@ func ProvideZanzanaClient(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, fea ctx = types.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{ Rest: authnlib.AccessTokenClaims{ Namespace: "*", + Permissions: []string{ + zanzana.TokenPermissionUpdate, + }, }, })) return ctx, nil diff --git a/pkg/services/authz/zanzana/server/auth.go b/pkg/services/authz/zanzana/server/auth.go index f137bdca47f..5ea412c35b8 100644 --- a/pkg/services/authz/zanzana/server/auth.go +++ b/pkg/services/authz/zanzana/server/auth.go @@ -4,7 +4,9 @@ import ( "context" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/setting" + "golang.org/x/exp/slices" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -30,3 +32,20 @@ func authorize(ctx context.Context, namespace string, ss setting.ZanzanaServerSe } return nil } + +func authorizeWrite(ctx context.Context, namespace string, ss setting.ZanzanaServerSettings) error { + if err := authorize(ctx, namespace, ss); err != nil { + return err + } + + c, ok := claims.AuthInfoFrom(ctx) + if !ok { + return status.Errorf(codes.Unauthenticated, "unauthenticated") + } + + if !slices.Contains(c.GetTokenPermissions(), zanzana.TokenPermissionUpdate) { + return status.Errorf(codes.PermissionDenied, "missing token permission %s", zanzana.TokenPermissionUpdate) + } + + return nil +} diff --git a/pkg/services/authz/zanzana/server/server_bench_test.go b/pkg/services/authz/zanzana/server/server_bench_test.go index 98ec58560b0..1d76dbea66b 100644 --- a/pkg/services/authz/zanzana/server/server_bench_test.go +++ b/pkg/services/authz/zanzana/server/server_bench_test.go @@ -391,7 +391,7 @@ func setupBenchmarkServer(b *testing.B) (*Server, *benchmarkData) { b.Logf("Total tuples to write: %d", len(allTuples)) // Get store info - ctx := newContextWithNamespace() + ctx := newContextWithZanzanaUpdatePermission() storeInf, err := srv.getStoreInfo(ctx, benchNamespace) require.NoError(b, err) diff --git a/pkg/services/authz/zanzana/server/server_mutate.go b/pkg/services/authz/zanzana/server/server_mutate.go index 15a57404bbc..3af0d06180d 100644 --- a/pkg/services/authz/zanzana/server/server_mutate.go +++ b/pkg/services/authz/zanzana/server/server_mutate.go @@ -8,6 +8,7 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" "go.opentelemetry.io/otel/codes" + "google.golang.org/grpc/status" authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" ) @@ -35,6 +36,9 @@ func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + if _, ok := status.FromError(err); ok { + return nil, err + } s.logger.Error("failed to perform mutate request", "error", err, "namespace", req.GetNamespace()) return nil, errors.New("failed to perform mutate request") } @@ -43,7 +47,7 @@ func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au } func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) { - if err := authorize(ctx, req.GetNamespace(), s.cfg); err != nil { + if err := authorizeWrite(ctx, req.GetNamespace(), s.cfg); err != nil { return nil, err } diff --git a/pkg/services/authz/zanzana/server/server_mutate_folder_test.go b/pkg/services/authz/zanzana/server/server_mutate_folder_test.go index a01c3b08740..410e70099d8 100644 --- a/pkg/services/authz/zanzana/server/server_mutate_folder_test.go +++ b/pkg/services/authz/zanzana/server/server_mutate_folder_test.go @@ -30,7 +30,7 @@ func testMutateFolders(t *testing.T, srv *Server) { setupMutateFolders(t, srv) t.Run("should create new folder parent relation", func(t *testing.T) { - _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + _, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{ Namespace: "default", Operations: []*v1.MutateOperation{ { @@ -61,7 +61,7 @@ func testMutateFolders(t *testing.T, srv *Server) { }) t.Run("should delete folder parent relation", func(t *testing.T) { - _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + _, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{ Namespace: "default", Operations: []*v1.MutateOperation{ { @@ -88,7 +88,7 @@ func testMutateFolders(t *testing.T, srv *Server) { }) t.Run("should clean up all parent relations", func(t *testing.T) { - _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + _, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{ Namespace: "default", Operations: []*v1.MutateOperation{ { @@ -115,7 +115,7 @@ func testMutateFolders(t *testing.T, srv *Server) { }) t.Run("should perform batch mutate if multiple operations are provided", func(t *testing.T) { - _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + _, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{ Namespace: "default", Operations: []*v1.MutateOperation{ { diff --git a/pkg/services/authz/zanzana/server/server_mutate_org_role_test.go b/pkg/services/authz/zanzana/server/server_mutate_org_role_test.go index 7c7472cf6ac..899ba4be86f 100644 --- a/pkg/services/authz/zanzana/server/server_mutate_org_role_test.go +++ b/pkg/services/authz/zanzana/server/server_mutate_org_role_test.go @@ -25,7 +25,7 @@ func testMutateOrgRoles(t *testing.T, srv *Server) { setupMutateOrgRoles(t, srv) t.Run("should update user org role and delete old role", func(t *testing.T) { - _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + _, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{ Namespace: "default", Operations: []*v1.MutateOperation{ { @@ -63,7 +63,7 @@ func testMutateOrgRoles(t *testing.T, srv *Server) { }) t.Run("should add user org role and delete old role", func(t *testing.T) { - _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + _, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{ Namespace: "default", Operations: []*v1.MutateOperation{ { diff --git a/pkg/services/authz/zanzana/server/server_mutate_resourcepermissions_test.go b/pkg/services/authz/zanzana/server/server_mutate_resourcepermissions_test.go index 3336d9d813b..296ed9e2be3 100644 --- a/pkg/services/authz/zanzana/server/server_mutate_resourcepermissions_test.go +++ b/pkg/services/authz/zanzana/server/server_mutate_resourcepermissions_test.go @@ -28,7 +28,7 @@ func testMutateResourcePermissions(t *testing.T, srv *Server) { setupMutateResourcePermissions(t, srv) t.Run("should create new resource permission", func(t *testing.T) { - _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + _, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{ Namespace: "default", Operations: []*v1.MutateOperation{ { @@ -76,7 +76,7 @@ func testMutateResourcePermissions(t *testing.T, srv *Server) { require.NoError(t, err) require.Len(t, res.Tuples, 2) - _, err = srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + _, err = srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{ Namespace: "default", Operations: []*v1.MutateOperation{ { diff --git a/pkg/services/authz/zanzana/server/server_mutate_rolebindings_test.go b/pkg/services/authz/zanzana/server/server_mutate_rolebindings_test.go index 87db285c360..3d4b0c5db51 100644 --- a/pkg/services/authz/zanzana/server/server_mutate_rolebindings_test.go +++ b/pkg/services/authz/zanzana/server/server_mutate_rolebindings_test.go @@ -25,7 +25,7 @@ func testMutateRoleBindings(t *testing.T, srv *Server) { setupMutateRoleBindings(t, srv) t.Run("should update user role and delete old role", func(t *testing.T) { - _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + _, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{ Namespace: "default", Operations: []*v1.MutateOperation{ { @@ -75,7 +75,7 @@ func testMutateRoleBindings(t *testing.T, srv *Server) { }) t.Run("should assign role to basic role", func(t *testing.T) { - _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + _, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{ Namespace: "default", Operations: []*v1.MutateOperation{ { diff --git a/pkg/services/authz/zanzana/server/server_mutate_roles_test.go b/pkg/services/authz/zanzana/server/server_mutate_roles_test.go index 1128c0c0f72..e6c92dc66f8 100644 --- a/pkg/services/authz/zanzana/server/server_mutate_roles_test.go +++ b/pkg/services/authz/zanzana/server/server_mutate_roles_test.go @@ -25,7 +25,7 @@ func testMutateRoles(t *testing.T, srv *Server) { setupMutateRoles(t, srv) t.Run("should update role and delete old role permissions", func(t *testing.T) { - _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + _, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{ Namespace: "default", Operations: []*v1.MutateOperation{ { diff --git a/pkg/services/authz/zanzana/server/server_mutate_teambindings_test.go b/pkg/services/authz/zanzana/server/server_mutate_teambindings_test.go index 5103b142fc5..a4c8d73d856 100644 --- a/pkg/services/authz/zanzana/server/server_mutate_teambindings_test.go +++ b/pkg/services/authz/zanzana/server/server_mutate_teambindings_test.go @@ -25,7 +25,7 @@ func testMutateTeamBindings(t *testing.T, srv *Server) { setupMutateTeamBindings(t, srv) t.Run("should update user team binding and delete old team binding", func(t *testing.T) { - _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + _, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{ Namespace: "default", Operations: []*v1.MutateOperation{ { diff --git a/pkg/services/authz/zanzana/server/server_mutate_test.go b/pkg/services/authz/zanzana/server/server_mutate_test.go index c1fcfabbe43..57949b9ef12 100644 --- a/pkg/services/authz/zanzana/server/server_mutate_test.go +++ b/pkg/services/authz/zanzana/server/server_mutate_test.go @@ -5,6 +5,8 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/structpb" iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" @@ -33,7 +35,7 @@ func testMutate(t *testing.T, srv *Server) { setupMutate(t, srv) t.Run("should perform multiple mutate operations", func(t *testing.T) { - _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + _, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{ Namespace: "default", Operations: []*v1.MutateOperation{ { @@ -133,6 +135,25 @@ func testMutate(t *testing.T, srv *Server) { require.NoError(t, err) require.Len(t, res.Tuples, 0) }) + + t.Run("should reject mutate without zanzana:update", func(t *testing.T) { + _, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{ + Namespace: "default", + Operations: []*v1.MutateOperation{ + { + Operation: &v1.MutateOperation_SetFolderParent{ + SetFolderParent: &v1.SetFolderParentOperation{ + Folder: "new-folder", + Parent: "1", + DeleteExisting: false, + }, + }, + }, + }, + }) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + }) } func TestDeduplicateTupleKeys(t *testing.T) { diff --git a/pkg/services/authz/zanzana/server/server_test.go b/pkg/services/authz/zanzana/server/server_test.go index 3f3a7e2cad6..b910d1f492a 100644 --- a/pkg/services/authz/zanzana/server/server_test.go +++ b/pkg/services/authz/zanzana/server/server_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/authz/zanzana/common" "github.com/grafana/grafana/pkg/services/authz/zanzana/store" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -218,11 +219,21 @@ func setupOpenFGADatabase(t *testing.T, srv *Server, tuples []*openfgav1.TupleKe } func newContextWithNamespace() context.Context { + return newContextWithNamespaceAndPermissions() +} + +func newContextWithNamespaceAndPermissions(perms ...string) context.Context { ctx := context.Background() ctx = claims.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{ Rest: authnlib.AccessTokenClaims{ - Namespace: "*", + Namespace: "*", + Permissions: perms, + DelegatedPermissions: perms, }, })) return ctx } + +func newContextWithZanzanaUpdatePermission() context.Context { + return newContextWithNamespaceAndPermissions(zanzana.TokenPermissionUpdate) +} diff --git a/pkg/services/authz/zanzana/server/server_write.go b/pkg/services/authz/zanzana/server/server_write.go index d16d706a320..dd0e4b11f0d 100644 --- a/pkg/services/authz/zanzana/server/server_write.go +++ b/pkg/services/authz/zanzana/server/server_write.go @@ -8,6 +8,7 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" "go.opentelemetry.io/otel/codes" + "google.golang.org/grpc/status" authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" "github.com/grafana/grafana/pkg/services/authz/zanzana/common" @@ -25,6 +26,9 @@ func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*auth if err != nil { span.RecordError(err) span.SetStatus(codes.Error, err.Error()) + if _, ok := status.FromError(err); ok { + return nil, err + } s.logger.Error("failed to perform write request", "error", err, "namespace", req.GetNamespace()) return nil, errors.New("failed to perform write request") } @@ -33,7 +37,7 @@ func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*auth } func (s *Server) write(ctx context.Context, req *authzextv1.WriteRequest) (*authzextv1.WriteResponse, error) { - if err := authorize(ctx, req.GetNamespace(), s.cfg); err != nil { + if err := authorizeWrite(ctx, req.GetNamespace(), s.cfg); err != nil { return nil, err } diff --git a/pkg/services/authz/zanzana/server/server_write_test.go b/pkg/services/authz/zanzana/server/server_write_test.go new file mode 100644 index 00000000000..892edcb694c --- /dev/null +++ b/pkg/services/authz/zanzana/server/server_write_test.go @@ -0,0 +1,46 @@ +package server + +import ( + "testing" + + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana/common" + "github.com/stretchr/testify/require" +) + +func TestWriteAuthorization(t *testing.T) { + cfg := setting.NewCfg() + testStore := sqlstore.NewTestStore(t, sqlstore.WithCfg(cfg)) + srv := setupOpenFGAServer(t, testStore, cfg) + setup(t, srv) + + req := &authzextv1.WriteRequest{ + Namespace: namespace, + Writes: &authzextv1.WriteRequestWrites{ + TupleKeys: []*authzextv1.TupleKey{ + { + // Folder parent tuples are valid without any relationship condition. + User: "folder:1", + Relation: common.RelationParent, + Object: "folder:write-authz-test", + }, + }, + }, + } + + t.Run("denies Write without zanzana:update", func(t *testing.T) { + _, err := srv.Write(newContextWithNamespace(), req) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + }) + + t.Run("allows Write with zanzana:update", func(t *testing.T) { + _, err := srv.Write(newContextWithZanzanaUpdatePermission(), req) + require.NoError(t, err) + }) +} 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) +} diff --git a/pkg/services/authz/zanzana/zanzana.go b/pkg/services/authz/zanzana/zanzana.go index 6cadfc0c199..e9d7eecba41 100644 --- a/pkg/services/authz/zanzana/zanzana.go +++ b/pkg/services/authz/zanzana/zanzana.go @@ -16,6 +16,9 @@ const ( TypeNamespace = common.TypeGroupResouce ) +// TokenPermissionUpdate is required for callers to perform write operations against Zanzana (Mutate/Write). +const TokenPermissionUpdate = "zanzana:update" //nolint:gosec // G101: permission identifier, not a credential. + const ( RelationTeamMember = common.RelationTeamMember RelationTeamAdmin = common.RelationTeamAdmin @@ -42,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 ) 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/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/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) { 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, diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index dc0f6d1f13d..4fdd8e08b92 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -805,9 +805,20 @@ func TestRouteGetRuleStatuses(t *testing.T) { OrgID: orgID, })).GenerateManyRef(3) + // Plugin rule with __grafana_origin label for plugins filter test + pluginRule := gen.With( + gen.WithGroupKey(ngmodels.AlertRuleGroupKey{ + RuleGroup: "plugins-test-plugin", + NamespaceUID: "folder-2", + OrgID: orgID, + }), + gen.WithLabels(map[string]string{"__grafana_origin": "plugin/grafana-slo-app"}), + ).GenerateRef() + ruleStore.PutRule(context.Background(), rulesInGroup1...) ruleStore.PutRule(context.Background(), rulesInGroup2...) ruleStore.PutRule(context.Background(), rulesInGroup3...) + ruleStore.PutRule(context.Background(), pluginRule) api := NewPrometheusSrv( log.NewNopLogger(), @@ -818,7 +829,7 @@ func TestRouteGetRuleStatuses(t *testing.T) { fakes.NewFakeProvisioningStore(), ) - permissions := createPermissionsForRules(slices.Concat(rulesInGroup1, rulesInGroup2, rulesInGroup3), orgID) + permissions := createPermissionsForRules(slices.Concat(rulesInGroup1, rulesInGroup2, rulesInGroup3, []*ngmodels.AlertRule{pluginRule}), orgID) user := &user.SignedInUser{ OrgID: orgID, Permissions: permissions, @@ -948,6 +959,68 @@ func TestRouteGetRuleStatuses(t *testing.T) { require.Equal(t, expectedRuleInGroup3.UID, result.Data.RuleGroups[0].Rules[0].UID) } }) + + t.Run("should filter rules by plugins parameter", func(t *testing.T) { + t.Run("returns all groups when plugins filter not specified", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?folder_uid=folder-2", nil) + require.NoError(t, err) + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 2, "should return all groups including plugin group") + }) + + t.Run("excludes plugin rules when plugins=hide", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?folder_uid=folder-2&plugins=hide", nil) + require.NoError(t, err) + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 1, "should only return non-plugin groups") + for _, group := range result.Data.RuleGroups { + require.NotEqual(t, "plugins-test-plugin", group.Name, "should not include plugin group") + } + }) + + t.Run("returns only plugin rules when plugins=only", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?folder_uid=folder-2&plugins=only", nil) + require.NoError(t, err) + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 1, "should only return plugin group") + require.Equal(t, "plugins-test-plugin", result.Data.RuleGroups[0].Name) + }) + + t.Run("returns all groups when plugins filter has invalid value", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?folder_uid=folder-2&plugins=invalid", nil) + require.NoError(t, err) + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 2, "invalid value should return all groups") + }) + }) }) t.Run("when requesting rules with pagination", func(t *testing.T) { diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index 077761d0caf..16efa84fe26 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -452,17 +452,18 @@ type paginationContext struct { alertStateMutator RuleAlertStateMutator // Query parameters - namespaceUIDs []string - ruleUIDs []string - dashboardUID string - panelID int64 - ruleGroups []string - receiverName string - dataSourceUIDs []string - title string - searchRuleGroup string - ruleType ngmodels.RuleTypeFilter - ruleNamesSet map[string]struct{} + namespaceUIDs []string + ruleUIDs []string + dashboardUID string + panelID int64 + ruleGroups []string + receiverName string + dataSourceUIDs []string + title string + searchRuleGroup string + ruleType ngmodels.RuleTypeFilter + pluginOriginFilter ngmodels.PluginOriginFilter + ruleNamesSet map[string]struct{} // Filters stateFilterSet map[eval.State]struct{} @@ -508,11 +509,12 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert SearchRuleGroup: ctx.searchRuleGroup, LabelMatchers: storeMatchers, }, - RuleType: ctx.ruleType, - Limit: remainingGroups, - RuleLimit: remainingRules, - ContinueToken: token, - Compact: ctx.compact, + RuleType: ctx.ruleType, + PluginOriginFilter: ctx.pluginOriginFilter, + Limit: remainingGroups, + RuleLimit: remainingRules, + ContinueToken: token, + Compact: ctx.compact, } ruleList, newToken, err := store.ListAlertRulesByGroup(ctx.opts.Ctx, &byGroupQuery) @@ -668,6 +670,7 @@ func paginateRuleGroups(log log.Logger, store ListAlertRulesStoreV2, ctx *pagina return allGroups, rulesTotals, continueToken, nil } +// nolint:gocyclo func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceStore ProvenanceStore) apimodels.RuleResponse { ctx, span := tracer.Start(opts.Ctx, "api.prometheus.PrepareRuleGroupStatusesV2") defer span.End() @@ -825,6 +828,15 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ruleType = ngmodels.RuleTypeFilterAll } + pluginOriginFilter := ngmodels.PluginOriginFilterNone + switch opts.Query.Get("plugins") { + case "hide": + pluginOriginFilter = ngmodels.PluginOriginFilterHide + case "only": + pluginOriginFilter = ngmodels.PluginOriginFilterOnly + } + span.SetAttributes(attribute.String("plugins_filter", string(pluginOriginFilter))) + // Pagination limits // // group_limit: Maximum number of rule groups to return @@ -874,6 +886,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt dataSourceUIDs: dataSourceUIDs, searchRuleGroup: searchRuleGroup, ruleType: ruleType, + pluginOriginFilter: pluginOriginFilter, ruleNamesSet: ruleNamesSet, stateFilterSet: stateFilterSet, healthFilterSet: healthFilterSet, diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 7d6e8a1fab5..4b59fcfa164 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -171,6 +171,9 @@ const ( // AutogeneratedRouteSettingsHashLabel a label name that contains the hash of the notification settings that will be used to send notifications for the alert. // This should uniquely identify the notification settings (group_by, group_wait, group_interval, repeat_interval, mute_time_intervals) for the alert. AutogeneratedRouteSettingsHashLabel = "__grafana_route_settings_hash__" + + // PluginGrafanaOriginLabel is a label that indicates that the alert rule originated from a plugin. + PluginGrafanaOriginLabel = "__grafana_origin" ) const ( @@ -966,6 +969,14 @@ const ( RuleTypeFilterRecording ) +type PluginOriginFilter string + +const ( + PluginOriginFilterNone PluginOriginFilter = "" // Show all (default) + PluginOriginFilterHide PluginOriginFilter = "hide" // Exclude plugin rules + PluginOriginFilterOnly PluginOriginFilter = "only" // Only plugin rules +) + type GroupCursor struct { NamespaceUID string `json:"n"` RuleGroup string `json:"g"` @@ -1026,7 +1037,8 @@ type ListAlertRulesQuery struct { type ListAlertRulesExtendedQuery struct { ListAlertRulesQuery - RuleType RuleTypeFilter + RuleType RuleTypeFilter + PluginOriginFilter PluginOriginFilter Limit int64 RuleLimit int64 diff --git a/pkg/services/ngalert/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) + }) + } +} diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 30856587794..225499dc1d3 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -30,11 +30,13 @@ import ( "github.com/grafana/grafana/pkg/util" ) -// AlertRuleMaxTitleLength is the maximum length of the alert rule title -const AlertRuleMaxTitleLength = 190 +const ( + // AlertRuleMaxTitleLength is the maximum length of the alert rule title + AlertRuleMaxTitleLength = 190 -// AlertRuleMaxRuleGroupNameLength is the maximum length of the alert rule group name -const AlertRuleMaxRuleGroupNameLength = 190 + // AlertRuleMaxRuleGroupNameLength is the maximum length of the alert rule group name + AlertRuleMaxRuleGroupNameLength = 190 +) var ( ErrOptimisticLock = errors.New("version conflict while updating a record in the database with optimistic locking") @@ -970,6 +972,13 @@ func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.Lis } } + if query.PluginOriginFilter != ngmodels.PluginOriginFilterNone { + q, err = st.filterByPluginOrigin(query.PluginOriginFilter, q) + if err != nil { + return nil, groupsSet, err + } + } + // FIXME: record is nullable but we don't save it as null when it's nil switch query.RuleType { case ngmodels.RuleTypeFilterAlerting: @@ -1427,6 +1436,32 @@ func (st DBstore) filterByLabelMatchers(matchers labels.Matchers, sess *xorm.Ses return sess, nil } +// filterByPluginOrigin adds filtering for plugin-originated rules based on the __grafana_origin label. +func (st DBstore) filterByPluginOrigin(filter ngmodels.PluginOriginFilter, sess *xorm.Session) (*xorm.Session, error) { + if filter == ngmodels.PluginOriginFilterNone { + return sess, nil + } + + var sql string + var args []any + var err error + + switch filter { + case ngmodels.PluginOriginFilterHide: + sql, args, err = buildLabelKeyMissingCondition(st.SQLStore.GetDialect(), "labels", ngmodels.PluginGrafanaOriginLabel) + case ngmodels.PluginOriginFilterOnly: + sql, args, err = buildLabelKeyExistsCondition(st.SQLStore.GetDialect(), "labels", ngmodels.PluginGrafanaOriginLabel) + default: + return nil, fmt.Errorf("unknown plugin origin filter %q", filter) + } + + if err != nil { + return nil, err + } + + return sess.And(sql, args...), nil +} + func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string, validateProvenance func(ngmodels.Provenance) bool, dryRun bool) ([]ngmodels.AlertRuleKey, []ngmodels.AlertRuleKey, error) { // fetch entire rules because Update method requires it because it copies rules to version table rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{ diff --git a/pkg/services/ngalert/store/alert_rule_labels.go b/pkg/services/ngalert/store/alert_rule_labels.go index 721071d5219..9c35d194d37 100644 --- a/pkg/services/ngalert/store/alert_rule_labels.go +++ b/pkg/services/ngalert/store/alert_rule_labels.go @@ -18,6 +18,20 @@ func buildLabelMatcherCondition(dialect migrator.Dialect, column string, m *labe return buildLabelMatcherJSON(dialect, column, m) } +func buildLabelKeyExistsCondition(dialect migrator.Dialect, column string, key string) (string, []any, error) { + if dialect.DriverName() == migrator.SQLite { + return globKeyExists(column, key) + } + return jsonKeyExists(dialect, column, key) +} + +func buildLabelKeyMissingCondition(dialect migrator.Dialect, column string, key string) (string, []any, error) { + if dialect.DriverName() == migrator.SQLite { + return globKeyMissing(column, key) + } + return jsonKeyMissing(dialect, column, key) +} + func buildLabelMatcherGlob(column string, m *labels.Matcher) (string, []any, error) { switch { case m.Type == labels.MatchEqual && m.Value == "": @@ -37,7 +51,10 @@ func buildLabelMatcherJSON(dialect migrator.Dialect, column string, m *labels.Ma switch { case m.Type == labels.MatchEqual && m.Value == "": eqSQL, eqArgs := jsonEquals(dialect, column, m.Name, "") - missingSQL, missingArgs := jsonKeyMissing(dialect, column, m.Name) + missingSQL, missingArgs, err := jsonKeyMissing(dialect, column, m.Name) + if err != nil { + return "", nil, err + } return "(" + eqSQL + " OR " + missingSQL + ")", append(eqArgs, missingArgs...), nil case m.Type == labels.MatchEqual: sql, args := jsonEquals(dialect, column, m.Name, m.Value) diff --git a/pkg/services/ngalert/store/alert_rule_labels_test.go b/pkg/services/ngalert/store/alert_rule_labels_test.go index 2006c49fd7a..1e6eb2b04e6 100644 --- a/pkg/services/ngalert/store/alert_rule_labels_test.go +++ b/pkg/services/ngalert/store/alert_rule_labels_test.go @@ -134,3 +134,93 @@ func TestBuildLabelMatcherJSON(t *testing.T) { }) } } + +func TestBuildLabelKeyExistsCondition(t *testing.T) { + tests := []struct { + name string + dialect migrator.Dialect + column string + key string + wantSQL string + wantArgs []any + }{ + { + name: "MySQL", + dialect: migrator.NewMysqlDialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NOT NULL", + wantArgs: []any{"__grafana_origin"}, + }, + { + name: "PostgreSQL", + dialect: migrator.NewPostgresDialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NOT NULL", + wantArgs: []any{"__grafana_origin"}, + }, + { + name: "SQLite", + dialect: migrator.NewSQLite3Dialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "labels GLOB ?", + wantArgs: []any{`*"__grafana_origin":*`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args, err := buildLabelKeyExistsCondition(tt.dialect, tt.column, tt.key) + require.NoError(t, err) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestBuildLabelKeyMissingCondition(t *testing.T) { + tests := []struct { + name string + dialect migrator.Dialect + column string + key string + wantSQL string + wantArgs []any + }{ + { + name: "MySQL", + dialect: migrator.NewMysqlDialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL", + wantArgs: []any{"__grafana_origin"}, + }, + { + name: "PostgreSQL", + dialect: migrator.NewPostgresDialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL", + wantArgs: []any{"__grafana_origin"}, + }, + { + name: "SQLite", + dialect: migrator.NewSQLite3Dialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "labels NOT GLOB ?", + wantArgs: []any{`*"__grafana_origin":*`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args, err := buildLabelKeyMissingCondition(tt.dialect, tt.column, tt.key) + require.NoError(t, err) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index f7497c7b05a..e38a7052dff 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -2592,6 +2592,54 @@ func TestIntegration_ListAlertRules(t *testing.T) { require.ErrorContains(t, err, "is not supported") }) }) + + t.Run("filter by PluginOriginFilter", func(t *testing.T) { + sqlStore := db.InitTestDB(t) + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b) + testOrgID := int64(12345) + testRuleGen := ruleGen.With(models.RuleMuts.WithOrgID(testOrgID)) + + regularRule := createRule(t, store, testRuleGen) + pluginRule := createRule(t, store, testRuleGen.With( + models.RuleMuts.WithLabel(models.PluginGrafanaOriginLabel, "plugin/grafana-slo-app"), + )) + + tc := []struct { + name string + filter models.PluginOriginFilter + expectedRules []*models.AlertRule + }{ + { + name: "should return all rules when PluginOriginFilterNone", + filter: models.PluginOriginFilterNone, + expectedRules: []*models.AlertRule{regularRule, pluginRule}, + }, + { + name: "should filter out plugin rules when PluginOriginFilterHide", + filter: models.PluginOriginFilterHide, + expectedRules: []*models.AlertRule{regularRule}, + }, + { + name: "should return only plugin rules when PluginOriginFilterOnly", + filter: models.PluginOriginFilterOnly, + expectedRules: []*models.AlertRule{pluginRule}, + }, + } + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + query := &models.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: models.ListAlertRulesQuery{ + OrgID: testOrgID, + }, + PluginOriginFilter: tt.filter, + } + result, _, err := store.ListAlertRulesByGroup(context.Background(), query) + require.NoError(t, err) + require.ElementsMatch(t, tt.expectedRules, result) + }) + } + }) } func TestIntegration_ListAlertRulesPaginated(t *testing.T) { diff --git a/pkg/services/ngalert/store/json.go b/pkg/services/ngalert/store/json.go index e0c71975a76..ac38d2f4ca5 100644 --- a/pkg/services/ngalert/store/json.go +++ b/pkg/services/ngalert/store/json.go @@ -34,14 +34,26 @@ func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string, return fmt.Sprintf("(%s IS NULL OR %s != ?)", jx, jx), []any{key, key, value} } -func jsonKeyMissing(dialect migrator.Dialect, column, key string) (string, []any) { +func jsonKeyMissing(dialect migrator.Dialect, column, key string) (string, []any, error) { + return jsonKeyCondition(dialect, column, key, false) +} + +func jsonKeyExists(dialect migrator.Dialect, column, key string) (string, []any, error) { + return jsonKeyCondition(dialect, column, key, true) +} + +func jsonKeyCondition(dialect migrator.Dialect, column, key string, exists bool) (string, []any, error) { + nullCheck := "IS NULL" + if exists { + nullCheck = "IS NOT NULL" + } switch dialect.DriverName() { case migrator.MySQL: - return fmt.Sprintf("JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)) IS NULL", column), []any{key} + return fmt.Sprintf("JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)) %s", column, nullCheck), []any{key}, nil case migrator.Postgres: - return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) IS NULL", column), []any{key} + return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) %s", column, nullCheck), []any{key}, nil default: - return "", nil + return "", nil, fmt.Errorf("unsupported dialect for JSON key condition: %s", dialect.DriverName()) } } @@ -71,6 +83,14 @@ func globKeyMissing(column, key string) (string, []any, error) { return column + " NOT GLOB ?", []any{"*" + pattern + "*"}, nil } +func globKeyExists(column, key string) (string, []any, error) { + pattern, err := buildGlobKeyPattern(key) + if err != nil { + return "", nil, err + } + return column + " GLOB ?", []any{"*" + pattern + "*"}, nil +} + // Search for `"key":"value"` func buildGlobPattern(key, value string) (string, error) { keyJSON, err := json.Marshal(key) diff --git a/pkg/services/ngalert/store/json_test.go b/pkg/services/ngalert/store/json_test.go index d09d3741c4b..93ca1531f61 100644 --- a/pkg/services/ngalert/store/json_test.go +++ b/pkg/services/ngalert/store/json_test.go @@ -114,7 +114,45 @@ func TestJsonKeyMissing(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - sql, args := jsonKeyMissing(tt.dialect, tt.column, tt.key) + sql, args, err := jsonKeyMissing(tt.dialect, tt.column, tt.key) + require.NoError(t, err) + require.Equal(t, tt.wantSQL, sql) + require.Equal(t, tt.wantArgs, args) + }) + } +} + +func TestJsonKeyExists(t *testing.T) { + tests := []struct { + name string + dialect migrator.Dialect + column string + key string + wantSQL string + wantArgs []any + }{ + { + name: "MySQL", + dialect: migrator.NewMysqlDialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NOT NULL", + wantArgs: []any{"__grafana_origin"}, + }, + { + name: "PostgreSQL", + dialect: migrator.NewPostgresDialect(), + column: "labels", + key: "__grafana_origin", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NOT NULL", + wantArgs: []any{"__grafana_origin"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sql, args, err := jsonKeyExists(tt.dialect, tt.column, tt.key) + require.NoError(t, err) require.Equal(t, tt.wantSQL, sql) require.Equal(t, tt.wantArgs, args) }) diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index f4b5afb282d..654eaac471d 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -228,6 +228,9 @@ func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlert return nil, "", err } + // Filter by PluginOriginFilter if specified + ruleList = applyPluginOriginFilter(ruleList, q.PluginOriginFilter) + // < group limit logic > // sort rules to ensure order is consistent, pagination depends on this @@ -304,6 +307,10 @@ func (f *RuleStore) ListAlertRulesPaginated(_ context.Context, q *models.ListAle if err != nil { return nil, "", err } + + // Filter by PluginOriginFilter if specified + rules = applyPluginOriginFilter(rules, q.PluginOriginFilter) + return rules, "", nil } @@ -648,3 +655,28 @@ func (f *RuleStore) ListDeletedRules(_ context.Context, orgID int64) ([]*models. } return f.Deleted[orgID], nil } + +// applyPluginOriginFilter filters rules based on the presence of the __grafana_origin label. +func applyPluginOriginFilter(rules []*models.AlertRule, filter models.PluginOriginFilter) []*models.AlertRule { + if filter == models.PluginOriginFilterNone { + return rules + } + + filteredList := make([]*models.AlertRule, 0, len(rules)) + for _, r := range rules { + _, hasOriginLabel := r.Labels[models.PluginGrafanaOriginLabel] + switch filter { + case models.PluginOriginFilterHide: + if !hasOriginLabel { + filteredList = append(filteredList, r) + } + case models.PluginOriginFilterOnly: + if hasOriginLabel { + filteredList = append(filteredList, r) + } + default: + filteredList = append(filteredList, r) + } + } + return filteredList +} diff --git a/pkg/services/pluginsintegration/dashboards/filestore.go b/pkg/services/pluginsintegration/dashboards/filestore.go index e854e6e1340..9e1545ed051 100644 --- a/pkg/services/pluginsintegration/dashboards/filestore.go +++ b/pkg/services/pluginsintegration/dashboards/filestore.go @@ -7,7 +7,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" - "github.com/grafana/grafana/pkg/util" ) var _ FileStore = (*FileStoreManager)(nil) @@ -87,7 +86,7 @@ func (m *FileStoreManager) GetPluginDashboardFileContents(ctx context.Context, a return nil, errors.New("plugin dashboard file not found") } - cleanPath, err := util.CleanRelativePath(includedFile.Path) + cleanPath, err := plugins.CleanRelativePath(includedFile.Path) if err != nil { // CleanRelativePath should clean and make the path relative so this is not expected to fail return nil, err diff --git a/pkg/services/pluginsintegration/loader/loader.go b/pkg/services/pluginsintegration/loader/loader.go index b3a03885752..986151041ff 100644 --- a/pkg/services/pluginsintegration/loader/loader.go +++ b/pkg/services/pluginsintegration/loader/loader.go @@ -11,7 +11,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/pipeline/initialization" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/termination" "github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" ) var _ pluginsLoader.Service = (*Loader)(nil) diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index 60f5ac757a1..a5a7a7fd8db 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -25,9 +25,9 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" ) diff --git a/pkg/services/pluginsintegration/pipeline/steps_test.go b/pkg/services/pluginsintegration/pipeline/steps_test.go index 009d489d2ba..4c9cf07e577 100644 --- a/pkg/services/pluginsintegration/pipeline/steps_test.go +++ b/pkg/services/pluginsintegration/pipeline/steps_test.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/manager/registry" - "github.com/grafana/grafana/pkg/setting" ) func TestSkipPlugins(t *testing.T) { @@ -68,7 +67,7 @@ func TestAsExternal(t *testing.T) { t.Run("should skip a core plugin", func(t *testing.T) { cfg := &config.PluginManagementCfg{ - PluginSettings: setting.PluginSettings{ + PluginSettings: config.PluginSettings{ "plugin1": map[string]string{ "as_external": "true", }, diff --git a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go index 28f9d015fff..192717c34ff 100644 --- a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go +++ b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go @@ -17,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" - "github.com/grafana/grafana/pkg/setting" ) func TestService_Calculate(t *testing.T) { @@ -31,7 +30,7 @@ func TestService_Calculate(t *testing.T) { tcs := []struct { name string - pluginSettings setting.PluginSettings + pluginSettings config.PluginSettings plugin pluginstore.Plugin expected plugins.LoadingStrategy }{ @@ -81,7 +80,7 @@ func TestService_Calculate(t *testing.T) { }, { name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is configured as CDN enabled and plugin is not angular", - pluginSettings: setting.PluginSettings{ + pluginSettings: config.PluginSettings{ "parent-datasource": { "cdn": "true", }, @@ -94,7 +93,7 @@ func TestService_Calculate(t *testing.T) { }, { name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is configured as CDN enabled and plugin is angular", - pluginSettings: setting.PluginSettings{ + pluginSettings: config.PluginSettings{ "parent-datasource": { "cdn": "true", }, @@ -107,7 +106,7 @@ func TestService_Calculate(t *testing.T) { }, { name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is not configured as CDN enabled and plugin is angular", - pluginSettings: setting.PluginSettings{}, + pluginSettings: config.PluginSettings{}, plugin: newPlugin(pluginID, withAngular(true), withFS(plugins.NewFakeFS()), func(p pluginstore.Plugin) pluginstore.Plugin { p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"} return p @@ -375,9 +374,9 @@ func TestService_ModuleHash(t *testing.T) { } t.Run(tc.name, func(t *testing.T) { - var pluginSettings setting.PluginSettings + var pluginSettings config.PluginSettings if tc.cdn { - pluginSettings = setting.PluginSettings{ + pluginSettings = config.PluginSettings{ pluginID: { "cdn": "true", }, @@ -412,7 +411,7 @@ func TestService_ModuleHash(t *testing.T) { func TestService_ModuleHash_Cache(t *testing.T) { pCfg := &config.PluginManagementCfg{ - PluginSettings: setting.PluginSettings{}, + PluginSettings: config.PluginSettings{}, Features: config.Features{SriChecksEnabled: true}, } svc := ProvideService( @@ -448,7 +447,7 @@ func TestService_ModuleHash_Cache(t *testing.T) { pCfg = &config.PluginManagementCfg{ PluginsCDNURLTemplate: "https://cdn.grafana.com", - PluginSettings: setting.PluginSettings{ + PluginSettings: config.PluginSettings{ pluginID: { "cdn": "true", }, @@ -577,14 +576,14 @@ func withClass(class plugins.Class) func(p pluginstore.Plugin) pluginstore.Plugi } } -func newCfg(ps setting.PluginSettings) *config.PluginManagementCfg { +func newCfg(ps config.PluginSettings) *config.PluginManagementCfg { return &config.PluginManagementCfg{ PluginSettings: ps, } } -func newPluginSettings(pluginID string, kv map[string]string) setting.PluginSettings { - return setting.PluginSettings{ +func newPluginSettings(pluginID string, kv map[string]string) config.PluginSettings { + return config.PluginSettings{ pluginID: kv, } } diff --git a/pkg/services/pluginsintegration/pluginconfig/config.go b/pkg/services/pluginsintegration/pluginconfig/config.go index 3207c2b3bd4..288f204cd12 100644 --- a/pkg/services/pluginsintegration/pluginconfig/config.go +++ b/pkg/services/pluginsintegration/pluginconfig/config.go @@ -48,7 +48,7 @@ type PluginInstanceCfg struct { Tracing config.Tracing - PluginSettings setting.PluginSettings + PluginSettings config.PluginSettings AWSAllowedAuthProviders []string AWSAssumeRoleEnabled bool @@ -127,8 +127,8 @@ func ProvidePluginInstanceConfig(cfg *setting.Cfg, settingProvider setting.Provi }, nil } -func extractPluginSettings(settingProvider setting.Provider) setting.PluginSettings { - ps := setting.PluginSettings{} +func extractPluginSettings(settingProvider setting.Provider) config.PluginSettings { + ps := config.PluginSettings{} for sectionName, sectionCopy := range settingProvider.Current() { if !strings.HasPrefix(sectionName, "plugin.") { continue diff --git a/pkg/services/pluginsintegration/pluginexternal/check_test.go b/pkg/services/pluginsintegration/pluginexternal/check_test.go index 68cfb68dcfb..9d3316b51d0 100644 --- a/pkg/services/pluginsintegration/pluginexternal/check_test.go +++ b/pkg/services/pluginsintegration/pluginexternal/check_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" @@ -12,7 +13,7 @@ import ( func TestService_validateExternal(t *testing.T) { cfg := setting.NewCfg() - cfg.PluginSettings = setting.PluginSettings{ + cfg.PluginSettings = config.PluginSettings{ "grafana-testdata-datasource": map[string]string{ "as_external": "true", }, diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index 9c0cf4d10c6..fbfa3379afd 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/sources" pluginassets2 "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/repo" "github.com/grafana/grafana/pkg/services/caching" @@ -50,11 +51,11 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginexternal" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsources" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsso" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" @@ -154,8 +155,8 @@ var WireExtensionSet = wire.NewSet( wire.Bind(new(managedplugins.Manager), new(*managedplugins.Noop)), provisionedplugins.NewNoop, wire.Bind(new(provisionedplugins.Manager), new(*provisionedplugins.Noop)), - sources.ProvideService, - wire.Bind(new(sources.Registry), new(*sources.Service)), + pluginsources.ProvideService, + wire.Bind(new(sources.Registry), new(*pluginsources.Service)), checkregistry.ProvideService, wire.Bind(new(checkregistry.CheckService), new(*checkregistry.Service)), pluginassets2.NewLocalProvider, diff --git a/pkg/plugins/manager/sources/sources.go b/pkg/services/pluginsintegration/pluginsources/pluginsources.go similarity index 78% rename from pkg/plugins/manager/sources/sources.go rename to pkg/services/pluginsintegration/pluginsources/pluginsources.go index addeede1dd7..1d47d0cd1aa 100644 --- a/pkg/plugins/manager/sources/sources.go +++ b/pkg/services/pluginsintegration/pluginsources/pluginsources.go @@ -1,4 +1,4 @@ -package sources +package pluginsources import ( "context" @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/setting" ) @@ -27,7 +28,7 @@ func ProvideService(cfg *setting.Cfg, pCcfg *config.PluginManagementCfg) *Servic func (s *Service) List(_ context.Context) []plugins.PluginSource { r := []plugins.PluginSource{ - NewLocalSource( + sources.NewLocalSource( plugins.ClassCore, s.corePluginPaths(), ), @@ -38,7 +39,7 @@ func (s *Service) List(_ context.Context) []plugins.PluginSource { } func (s *Service) externalPluginSources() []plugins.PluginSource { - localSrcs, err := DirAsLocalSources(s.cfg, s.cfg.PluginsPath, plugins.ClassExternal) + localSrcs, err := sources.DirAsLocalSources(s.cfg, s.cfg.PluginsPath, plugins.ClassExternal) if err != nil { s.log.Error("Failed to load external plugins", "error", err) return []plugins.PluginSource{} @@ -53,20 +54,20 @@ func (s *Service) externalPluginSources() []plugins.PluginSource { } func (s *Service) pluginSettingSources() []plugins.PluginSource { - sources := make([]plugins.PluginSource, 0, len(s.cfg.PluginSettings)) + srcs := make([]plugins.PluginSource, 0, len(s.cfg.PluginSettings)) for _, ps := range s.cfg.PluginSettings { path, exists := ps["path"] if !exists || path == "" { continue } if s.cfg.DevMode { - sources = append(sources, NewUnsafeLocalSource(plugins.ClassExternal, []string{path})) + srcs = append(srcs, sources.NewUnsafeLocalSource(plugins.ClassExternal, []string{path})) } else { - sources = append(sources, NewLocalSource(plugins.ClassExternal, []string{path})) + srcs = append(srcs, sources.NewLocalSource(plugins.ClassExternal, []string{path})) } } - return sources + return srcs } // corePluginPaths provides a list of the Core plugin file system paths diff --git a/pkg/plugins/manager/sources/sources_test.go b/pkg/services/pluginsintegration/pluginsources/pluginsources_test.go similarity index 83% rename from pkg/plugins/manager/sources/sources_test.go rename to pkg/services/pluginsintegration/pluginsources/pluginsources_test.go index f56fbffa52b..ba9e77bbfa2 100644 --- a/pkg/plugins/manager/sources/sources_test.go +++ b/pkg/services/pluginsintegration/pluginsources/pluginsources_test.go @@ -1,4 +1,4 @@ -package sources +package pluginsources import ( "context" @@ -9,13 +9,13 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/setting" ) func TestSources_List(t *testing.T) { t.Run("Plugin sources are populated by default and listed in specific order", func(t *testing.T) { - testdata, err := filepath.Abs("../testdata") - require.NoError(t, err) + testdata := testDataDir(t) cfg := &setting.Cfg{ StaticRootPath: testdata, @@ -23,7 +23,7 @@ func TestSources_List(t *testing.T) { pCfg := &config.PluginManagementCfg{ PluginsPath: filepath.Join(testdata, "pluginRootWithDist"), - PluginSettings: setting.PluginSettings{ + PluginSettings: config.PluginSettings{ "foo": map[string]string{ "path": filepath.Join(testdata, "test-app"), }, @@ -41,7 +41,7 @@ func TestSources_List(t *testing.T) { require.Len(t, srcs, 5) require.Equal(t, srcs[0].PluginClass(ctx), plugins.ClassCore) - if localSrc, ok := srcs[0].(*LocalSource); ok { + if localSrc, ok := srcs[0].(*sources.LocalSource); ok { require.Equal(t, localSrc.Paths(), []string{ filepath.Join(testdata, "app", "plugins", "datasource"), filepath.Join(testdata, "app", "plugins", "panel"), @@ -56,7 +56,7 @@ func TestSources_List(t *testing.T) { require.Equal(t, "", sig.SigningOrg) require.Equal(t, srcs[1].PluginClass(ctx), plugins.ClassExternal) - if localSrc, ok := srcs[1].(*LocalSource); ok { + if localSrc, ok := srcs[1].(*sources.LocalSource); ok { require.Equal(t, localSrc.Paths(), []string{ filepath.Join(testdata, "pluginRootWithDist", "datasource"), }) @@ -68,7 +68,7 @@ func TestSources_List(t *testing.T) { require.Equal(t, plugins.Signature{}, sig) require.Equal(t, srcs[2].PluginClass(ctx), plugins.ClassExternal) - if localSrc, ok := srcs[2].(*LocalSource); ok { + if localSrc, ok := srcs[2].(*sources.LocalSource); ok { require.Equal(t, localSrc.Paths(), []string{ filepath.Join(testdata, "pluginRootWithDist", "dist"), }) @@ -80,7 +80,7 @@ func TestSources_List(t *testing.T) { require.Equal(t, plugins.Signature{}, sig) require.Equal(t, srcs[3].PluginClass(ctx), plugins.ClassExternal) - if localSrc, ok := srcs[3].(*LocalSource); ok { + if localSrc, ok := srcs[3].(*sources.LocalSource); ok { require.Equal(t, localSrc.Paths(), []string{ filepath.Join(testdata, "pluginRootWithDist", "panel"), }) @@ -93,9 +93,7 @@ func TestSources_List(t *testing.T) { }) t.Run("Plugin sources are populated with symbolic links", func(t *testing.T) { - testdata, err := filepath.Abs("../testdata") - require.NoError(t, err) - + testdata := testDataDir(t) cfg := &setting.Cfg{ StaticRootPath: testdata, } @@ -113,7 +111,7 @@ func TestSources_List(t *testing.T) { if _, exists := uris[class]; !exists { uris[class] = map[string]struct{}{} } - if localSrc, ok := src.(*LocalSource); ok { + if localSrc, ok := src.(*sources.LocalSource); ok { for _, path := range localSrc.Paths() { uris[class][path] = struct{}{} } @@ -130,3 +128,12 @@ func TestSources_List(t *testing.T) { }, "should include external symlinked plugin") }) } + +func testDataDir(t *testing.T) string { + dir, err := filepath.Abs("../../../plugins/manager/testdata") + if err != nil { + t.Errorf("could not construct absolute path of current dir") + return "" + } + return dir +} diff --git a/pkg/services/pluginsintegration/plugintest/plugins_test.go b/pkg/services/pluginsintegration/plugintest/plugins_test.go index df4a0fd7b75..de1afb67710 100644 --- a/pkg/services/pluginsintegration/plugintest/plugins_test.go +++ b/pkg/services/pluginsintegration/plugintest/plugins_test.go @@ -163,7 +163,7 @@ func TestIntegrationPluginManager(t *testing.T) { td := testdatasource.ProvideService() pg := postgres.ProvideService() my := mysql.ProvideService() - ms := mssql.ProvideService(cfg) + ms := mssql.ProvideService() db := db.InitTestDB(t, sqlstore.InitTestDBOpt{Cfg: cfg}) sv2 := searchV2.ProvideService(cfg, db, nil, nil, tracer, features, nil, nil, nil) graf := grafanads.ProvideService(sv2, nil, features) diff --git a/pkg/services/pluginsintegration/renderer/renderer.go b/pkg/services/pluginsintegration/renderer/renderer.go index c061693879b..6d02768f79e 100644 --- a/pkg/services/pluginsintegration/renderer/renderer.go +++ b/pkg/services/pluginsintegration/renderer/renderer.go @@ -22,8 +22,8 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/sources" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/rendering" ) diff --git a/pkg/services/pluginsintegration/test_helper.go b/pkg/services/pluginsintegration/test_helper.go index d89f9e393ba..d6cff58d313 100644 --- a/pkg/services/pluginsintegration/test_helper.go +++ b/pkg/services/pluginsintegration/test_helper.go @@ -24,12 +24,12 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" - "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" - "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsources" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" "github.com/grafana/grafana/pkg/setting" @@ -64,7 +64,7 @@ func CreateIntegrationTestCtx(t *testing.T, cfg *setting.Cfg, coreRegistry *core Terminator: term, }) - ps, err := pluginstore.NewPluginStoreForTest(reg, l, sources.ProvideService(cfg, pCfg)) + ps, err := pluginstore.NewPluginStoreForTest(reg, l, pluginsources.ProvideService(cfg, pCfg)) require.NoError(t, err) return &IntegrationTestCtx{ diff --git a/pkg/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/pkg/setting/setting.go b/pkg/setting/setting.go index 995a01ad825..aafc9167ce4 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -29,6 +29,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/osutil" ) @@ -208,7 +209,7 @@ type Cfg struct { // Plugins PluginsEnableAlpha bool PluginsAppsSkipVerifyTLS bool - PluginSettings PluginSettings + PluginSettings config.PluginSettings PluginsAllowUnsigned []string PluginCatalogURL string PluginCatalogHiddenPlugins []string diff --git a/pkg/setting/setting_plugins.go b/pkg/setting/setting_plugins.go index 5a83c3b1f5a..a901f35088b 100644 --- a/pkg/setting/setting_plugins.go +++ b/pkg/setting/setting_plugins.go @@ -7,6 +7,7 @@ import ( "gopkg.in/ini.v1" + "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/util" ) @@ -15,11 +16,8 @@ const ( PluginUpdateStrategyMinor = "minor" ) -// PluginSettings maps plugin id to map of key/value settings. -type PluginSettings map[string]map[string]string - -func extractPluginSettings(sections []*ini.Section) PluginSettings { - psMap := PluginSettings{} +func extractPluginSettings(sections []*ini.Section) config.PluginSettings { + psMap := config.PluginSettings{} for _, section := range sections { sectionName := section.Name() if !strings.HasPrefix(sectionName, "plugin.") { diff --git a/pkg/storage/secret/metadata/data/secure_value_get_latest_version_and_created_at.sql b/pkg/storage/secret/metadata/data/secure_value_get_latest_version_and_created_at.sql index c7349f27a03..92a9ca2bd58 100644 --- a/pkg/storage/secret/metadata/data/secure_value_get_latest_version_and_created_at.sql +++ b/pkg/storage/secret/metadata/data/secure_value_get_latest_version_and_created_at.sql @@ -1,12 +1,13 @@ SELECT {{ .Ident "created" }}, + {{ .Ident "created_by" }}, {{ .Ident "version" }}, {{ .Ident "active" }}, {{ .Ident "namespace" }}, {{ .Ident "name" }} FROM {{ .Ident "secret_secure_value" }} -WHERE +WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND {{ .Ident "name" }} = {{ .Arg .Name }} ORDER BY {{ .Ident "version" }} DESC diff --git a/pkg/storage/secret/metadata/secure_value_model.go b/pkg/storage/secret/metadata/secure_value_model.go index 81ed2eb2849..32a06122b14 100644 --- a/pkg/storage/secret/metadata/secure_value_model.go +++ b/pkg/storage/secret/metadata/secure_value_model.go @@ -122,7 +122,7 @@ func (sv *secureValueDB) toKubernetes() (*secretv1beta1.SecureValue, error) { } // toCreateRow maps a Kubernetes resource into a DB row for new resources being created/inserted. -func toCreateRow(createdAt, updatedAt int64, keeper string, sv *secretv1beta1.SecureValue, actorUID string) (*secureValueDB, error) { +func toCreateRow(createdAt, updatedAt int64, keeper string, sv *secretv1beta1.SecureValue, createdBy, updatedBy string) (*secureValueDB, error) { row, err := toRow(keeper, sv, "") if err != nil { return nil, fmt.Errorf("failed to convert SecureValue to secureValueDB: %w", err) @@ -130,9 +130,9 @@ func toCreateRow(createdAt, updatedAt int64, keeper string, sv *secretv1beta1.Se row.GUID = uuid.New().String() row.Created = createdAt - row.CreatedBy = actorUID + row.CreatedBy = createdBy row.Updated = updatedAt - row.UpdatedBy = actorUID + row.UpdatedBy = updatedBy return row, nil } diff --git a/pkg/storage/secret/metadata/secure_value_store.go b/pkg/storage/secret/metadata/secure_value_store.go index 1fc3a788cf2..f7a4b830d27 100644 --- a/pkg/storage/secret/metadata/secure_value_store.go +++ b/pkg/storage/secret/metadata/secure_value_store.go @@ -85,7 +85,7 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, keeper string, var row *secureValueDB err := s.db.Transaction(ctx, func(ctx context.Context) error { - latest, err := s.getLatestVersionAndCreatedAt(ctx, xkube.Namespace(sv.Namespace), sv.Name) + latest, err := s.getLatestVersionAndCreated(ctx, xkube.Namespace(sv.Namespace), sv.Name) if err != nil { return fmt.Errorf("fetching latest secure value version: %w", err) } @@ -110,7 +110,13 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, keeper string, } updatedAt := now - row, err = toCreateRow(createdAt, updatedAt, keeper, sv, actorUID) + createdBy := actorUID + if latest.createdBy != "" { + createdBy = latest.createdBy + } + updatedBy := actorUID + + row, err = toCreateRow(createdAt, updatedAt, keeper, sv, createdBy, updatedBy) if err != nil { return fmt.Errorf("to create row: %w", err) } @@ -161,13 +167,14 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, keeper string, return createdSecureValue, nil } -type versionAndCreatedAt struct { +type versionAndCreated struct { createdAt int64 + createdBy string version int64 } -func (s *secureValueMetadataStorage) getLatestVersionAndCreatedAt(ctx context.Context, namespace xkube.Namespace, name string) (versionAndCreatedAt, error) { - ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.getLatestVersionAndCreatedAt", trace.WithAttributes( +func (s *secureValueMetadataStorage) getLatestVersionAndCreated(ctx context.Context, namespace xkube.Namespace, name string) (versionAndCreated, error) { + ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.getLatestVersionAndCreated", trace.WithAttributes( attribute.String("name", name), attribute.String("namespace", namespace.String()), )) @@ -181,45 +188,48 @@ func (s *secureValueMetadataStorage) getLatestVersionAndCreatedAt(ctx context.Co q, err := sqltemplate.Execute(sqlGetLatestSecureValueVersionAndCreatedAt, req) if err != nil { - return versionAndCreatedAt{}, fmt.Errorf("execute template %q: %w", sqlGetLatestSecureValueVersionAndCreatedAt.Name(), err) + return versionAndCreated{}, fmt.Errorf("execute template %q: %w", sqlGetLatestSecureValueVersionAndCreatedAt.Name(), err) } rows, err := s.db.QueryContext(ctx, q, req.GetArgs()...) if err != nil { - return versionAndCreatedAt{}, fmt.Errorf("fetching latest version for secure value: namespace=%+v name=%+v %w", namespace, name, err) + return versionAndCreated{}, fmt.Errorf("fetching latest version for secure value: namespace=%+v name=%+v %w", namespace, name, err) } defer func() { _ = rows.Close() }() if err := rows.Err(); err != nil { - return versionAndCreatedAt{}, fmt.Errorf("error executing query: %w", err) + return versionAndCreated{}, fmt.Errorf("error executing query: %w", err) } if !rows.Next() { - return versionAndCreatedAt{}, nil + return versionAndCreated{}, nil } var ( createdAt int64 + createdBy string version int64 active bool namespaceFromDB string nameFromDB string ) - if err := rows.Scan(&createdAt, &version, &active, &namespaceFromDB, &nameFromDB); err != nil { - return versionAndCreatedAt{}, fmt.Errorf("scanning version from returned rows: %w", err) + if err := rows.Scan(&createdAt, &createdBy, &version, &active, &namespaceFromDB, &nameFromDB); err != nil { + return versionAndCreated{}, fmt.Errorf("scanning version and created from returned rows: %w", err) } if namespaceFromDB != namespace.String() || nameFromDB != name { - return versionAndCreatedAt{}, fmt.Errorf("bug: expected to find latest version for namespace=%+v name=%+v but got version for namespace=%+v name=%+v", + return versionAndCreated{}, fmt.Errorf("bug: expected to find version and created for namespace=%+v name=%+v but got for namespace=%+v name=%+v", namespace, name, namespaceFromDB, nameFromDB) } if !active { createdAt = 0 + createdBy = "" } - return versionAndCreatedAt{ + return versionAndCreated{ createdAt: createdAt, + createdBy: createdBy, version: version, }, nil } diff --git a/pkg/storage/secret/metadata/testdata/mysql--secure_value_get_latest_version_and_created_at-get latest secure value version.sql b/pkg/storage/secret/metadata/testdata/mysql--secure_value_get_latest_version_and_created_at-get latest secure value version.sql index 2e88ee079c2..965d12a2744 100755 --- a/pkg/storage/secret/metadata/testdata/mysql--secure_value_get_latest_version_and_created_at-get latest secure value version.sql +++ b/pkg/storage/secret/metadata/testdata/mysql--secure_value_get_latest_version_and_created_at-get latest secure value version.sql @@ -1,12 +1,13 @@ SELECT `created`, + `created_by`, `version`, `active`, `namespace`, `name` FROM `secret_secure_value` -WHERE +WHERE `namespace` = 'ns' AND `name` = 'name' ORDER BY `version` DESC diff --git a/pkg/storage/secret/metadata/testdata/postgres--secure_value_get_latest_version_and_created_at-get latest secure value version.sql b/pkg/storage/secret/metadata/testdata/postgres--secure_value_get_latest_version_and_created_at-get latest secure value version.sql index a9f21aafc0a..67542c38e1b 100755 --- a/pkg/storage/secret/metadata/testdata/postgres--secure_value_get_latest_version_and_created_at-get latest secure value version.sql +++ b/pkg/storage/secret/metadata/testdata/postgres--secure_value_get_latest_version_and_created_at-get latest secure value version.sql @@ -1,12 +1,13 @@ SELECT "created", + "created_by", "version", "active", "namespace", "name" FROM "secret_secure_value" -WHERE +WHERE "namespace" = 'ns' AND "name" = 'name' ORDER BY "version" DESC diff --git a/pkg/storage/secret/metadata/testdata/sqlite--secure_value_get_latest_version_and_created_at-get latest secure value version.sql b/pkg/storage/secret/metadata/testdata/sqlite--secure_value_get_latest_version_and_created_at-get latest secure value version.sql index a9f21aafc0a..67542c38e1b 100755 --- a/pkg/storage/secret/metadata/testdata/sqlite--secure_value_get_latest_version_and_created_at-get latest secure value version.sql +++ b/pkg/storage/secret/metadata/testdata/sqlite--secure_value_get_latest_version_and_created_at-get latest secure value version.sql @@ -1,12 +1,13 @@ SELECT "created", + "created_by", "version", "active", "namespace", "name" FROM "secret_secure_value" -WHERE +WHERE "namespace" = 'ns' AND "name" = 'name' ORDER BY "version" DESC diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index eb9fa4df3bd..d6ff00a81c0 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -25,6 +25,7 @@ import ( bleveSearch "github.com/blevesearch/bleve/v2/search/searcher" index "github.com/blevesearch/bleve_index_api" "github.com/prometheus/client_golang/prometheus" + bolterrors "go.etcd.io/bbolt/errors" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.uber.org/atomic" @@ -44,6 +45,7 @@ import ( const ( indexStorageMemory = "memory" indexStorageFile = "file" + boltTimeout = "500ms" ) // Keys used to store internal data in index. @@ -415,14 +417,25 @@ func (b *bleveBackend) BuildIndex( // This happens on startup, or when memory-based index has expired. (We don't expire file-based indexes) // If we do have an unexpired cached index already, we always build a new index from scratch. if cachedIndex == nil && !rebuild { - index, fileIndexName, indexRV = b.findPreviousFileBasedIndex(resourceDir) + result := b.findPreviousFileBasedIndex(resourceDir) + if result != nil && result.IsOpen { + // Index file exists but is opened by another process, fallback to memory. + // Keep the name so we can skip cleanup of that directory. + newIndexType = indexStorageMemory + fileIndexName = result.Name + } else if result != nil && result.Index != nil { + // Found and opened existing index successfully + index = result.Index + fileIndexName = result.Name + indexRV = result.RV + } } - if index != nil { + if newIndexType == indexStorageFile && index != nil { build = false logWithDetails.Debug("Existing index found on filesystem", "indexRV", indexRV, "directory", filepath.Join(resourceDir, fileIndexName)) defer closeIndexOnExit(index, "") // Close index, but don't delete directory. - } else { + } else if newIndexType == indexStorageFile { // Building index from scratch. Index name has a time component in it to be unique, but if // we happen to create non-unique name, we bump the time and try again. @@ -449,7 +462,9 @@ func (b *bleveBackend) BuildIndex( logWithDetails.Info("Building index using filesystem", "directory", indexDir) defer closeIndexOnExit(index, indexDir) // Close index, and delete new index directory. } - } else { + } + + if newIndexType == indexStorageMemory { index, err = newBleveIndex("", mapper, time.Now(), b.opts.BuildVersion) if err != nil { return nil, fmt.Errorf("error creating new in-memory bleve index: %w", err) @@ -552,30 +567,30 @@ func cleanFileSegment(input string) string { return input } -// cleanOldIndexes deletes all subdirectories inside dir, skipping directory with "skipName". +// cleanOldIndexes deletes all subdirectories inside resourceDir, skipping directory with "skipName". // "skipName" can be empty. -func (b *bleveBackend) cleanOldIndexes(dir string, skipName string) { - files, err := os.ReadDir(dir) +func (b *bleveBackend) cleanOldIndexes(resourceDir string, skipName string) { + entries, err := os.ReadDir(resourceDir) if err != nil { if os.IsNotExist(err) { return } - b.log.Warn("error cleaning folders from", "directory", dir, "error", err) + b.log.Warn("error cleaning folders from", "directory", resourceDir, "error", err) return } - for _, file := range files { - if file.IsDir() && file.Name() != skipName { - fpath := filepath.Join(dir, file.Name()) - if !isPathWithinRoot(fpath, b.opts.Root) { - b.log.Warn("Skipping cleanup of directory", "directory", fpath) + for _, ent := range entries { + if ent.IsDir() && ent.Name() != skipName { + indexDir := filepath.Join(resourceDir, ent.Name()) + if !isPathWithinRoot(indexDir, b.opts.Root) { + b.log.Warn("Skipping cleanup of directory", "directory", indexDir) continue } - err = os.RemoveAll(fpath) + err = os.RemoveAll(indexDir) if err != nil { - b.log.Error("Unable to remove old index folder", "directory", fpath, "error", err) + b.log.Error("Unable to remove old index folder", "directory", indexDir, "error", err) } else { - b.log.Info("Removed old index folder", "directory", fpath) + b.log.Info("Removed old index folder", "directory", indexDir) } } } @@ -622,10 +637,17 @@ func formatIndexName(now time.Time) string { return now.Format("20060102-150405") } -func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Index, string, int64) { +type fileIndex struct { + Index bleve.Index + Name string + RV int64 + IsOpen bool +} + +func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) *fileIndex { entries, err := os.ReadDir(resourceDir) if err != nil { - return nil, "", 0 + return nil } for _, ent := range entries { @@ -635,8 +657,13 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Ind indexName := ent.Name() indexDir := filepath.Join(resourceDir, indexName) - idx, err := bleve.Open(indexDir) + + idx, err := bleve.OpenUsing(indexDir, map[string]interface{}{"bolt_timeout": boltTimeout}) if err != nil { + if errors.Is(err, bolterrors.ErrTimeout) { + b.log.Debug("Index is opened by another process (timeout), skipping", "indexDir", indexDir) + return &fileIndex{Name: indexName, IsOpen: true} + } b.log.Debug("error opening index", "indexDir", indexDir, "err", err) continue } @@ -648,10 +675,14 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Ind continue } - return idx, indexName, indexRV + return &fileIndex{ + Index: idx, + Name: indexName, + RV: indexRV, + } } - return nil, "", 0 + return nil } // Stop closes all indexes and stops background tasks. diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index a23f261cfc5..c879440e7b6 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -1583,3 +1583,76 @@ func docCount(t *testing.T, idx resource.ResourceIndex) int { require.NoError(t, err) return int(cnt) } + +func TestBleveBackendFallsBackToMemory(t *testing.T) { + ns := resource.NamespacedResource{ + Namespace: "test", + Group: "group", + Resource: "resource", + } + + tmpDir := t.TempDir() + + // First, create a file-based index with one backend and keep it open + backend1, reg1 := setupBleveBackend(t, withRootDir(tmpDir)) + index1, err := backend1.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.NoError(t, err) + require.NotNil(t, index1) + + // Verify first index is file-based + bleveIdx1, ok := index1.(*bleveIndex) + require.True(t, ok) + require.Equal(t, indexStorageFile, bleveIdx1.indexStorage) + checkOpenIndexes(t, reg1, 0, 1) + + // Now create a second backend using the same directory + // This simulates another instance trying to open the same index + backend2, reg2 := setupBleveBackend(t, withRootDir(tmpDir)) + + // BuildIndex should detect the file is locked and fallback to memory + index2, err := backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.NoError(t, err) + require.NotNil(t, index2) + + // Verify second index fell back to in-memory despite size being above file threshold + bleveIdx2, ok := index2.(*bleveIndex) + require.True(t, ok) + require.Equal(t, indexStorageMemory, bleveIdx2.indexStorage) + + // Verify metrics show 1 memory index and 0 file indexes for backend2 + checkOpenIndexes(t, reg2, 1, 0) + + // Verify the in-memory index works correctly + require.Equal(t, 10, docCount(t, index2)) + + // Clean up: close first backend to release the file lock + backend1.Stop() +} + +func TestBleveSkipCleanOldIndexesOnMemoryFallback(t *testing.T) { + ns := resource.NamespacedResource{ + Namespace: "test", + Group: "group", + Resource: "resource", + } + + tmpDir := t.TempDir() + + backend1, _ := setupBleveBackend(t, withRootDir(tmpDir)) + _, err := backend1.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.NoError(t, err) + + // Now create a second backend using the same directory + // This simulates another instance trying to open the same index + backend2, _ := setupBleveBackend(t, withRootDir(tmpDir)) + + // BuildIndex should detect the file is locked and fallback to memory + _, err = backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.NoError(t, err) + + // Verify that the index directory still exists (i.e., cleanOldIndexes was skipped) + verifyDirEntriesCount(t, backend2.getResourceDir(ns), 1) + + // Clean up: close first backend to release the file lock + backend1.Stop() +} diff --git a/pkg/storage/unified/sql/service.go b/pkg/storage/unified/sql/service.go index 75b3e80fcb0..06275c8754c 100644 --- a/pkg/storage/unified/sql/service.go +++ b/pkg/storage/unified/sql/service.go @@ -115,6 +115,7 @@ func ProvideUnifiedStorageGrpcService( cfg: cfg, features: features, stopCh: make(chan struct{}), + stoppedCh: make(chan error, 1), authenticator: authn, tracing: tracer, db: db, diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index b35bf6959c5..ea6fa972f97 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -2470,7 +2470,7 @@ var expNonEmailNotifications = map[string][]string{ "title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert1/view?orgId=1", "text": "Integration Test ", "fallback": "Integration Test [FIRING:1] SlackAlert1 (default)", - "footer": "Grafana v", + "footer": "Grafana", "footer_icon": "https://grafana.com/static/assets/img/fav32.png", "color": "#D63232", "ts": %s, @@ -2490,7 +2490,7 @@ var expNonEmailNotifications = map[string][]string{ "title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1", "text": "**Firing**\n\nValue: A=1\nLabels:\n - alertname = SlackAlert2\n - grafana_folder = default\nAnnotations:\nSource: http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=__alert_rule_uid__%%3DUID_SlackAlert2&orgId=1\n", "fallback": "[FIRING:1] SlackAlert2 (default)", - "footer": "Grafana v", + "footer": "Grafana", "footer_icon": "https://grafana.com/static/assets/img/fav32.png", "color": "#D63232", "ts": %s, diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index 067372a3470..1695e267903 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -910,6 +910,87 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) { } } +func TestIntegrationPrometheusPluginsFilter(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Password: "password", + Login: "grafana", + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") + + apiClient.CreateFolder(t, "folder1", "folder1") + + // Create a regular alert rule + createRule(t, apiClient, "folder1", withRuleGroup("group1")) + // Create a rule from plugin + createRule(t, apiClient, "folder1", withRuleGroup("group2"), withLabels(map[string]string{"__grafana_origin": "plugin/grafana-slo-app"})) + + verifyRulesResponse := func(t *testing.T, b []byte, expectedGroupName string, shouldHaveOriginLabel bool) { + t.Helper() + + var result apimodels.RuleResponse + require.NoError(t, json.Unmarshal(b, &result)) + require.Equal(t, "success", result.Status) + + require.Len(t, result.Data.RuleGroups, 1) + group := result.Data.RuleGroups[0] + require.Equal(t, expectedGroupName, group.Name) + + require.Len(t, group.Rules, 1) + rule := group.Rules[0] + _, hasOriginLabel := rule.Labels.Map()["__grafana_origin"] + require.Equal(t, shouldHaveOriginLabel, hasOriginLabel) + } + + t.Run("plugins=hide returns only non-plugin rules", func(t *testing.T) { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?plugins=hide", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + require.Equal(t, http.StatusOK, resp.StatusCode) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + verifyRulesResponse(t, b, "group1", false) + }) + + t.Run("plugins=only returns only plugin rules", func(t *testing.T) { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?plugins=only", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + require.Equal(t, http.StatusOK, resp.StatusCode) + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + verifyRulesResponse(t, b, "group2", true) + }) +} + func TestIntegrationPrometheusRulesPermissions(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index a05829976f4..f25fb859299 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -5105,43 +5105,71 @@ func rulesNamespaceWithoutVariableValues(t *testing.T, b []byte) (string, map[st return string(json), m } -func createRule(t *testing.T, client apiClient, folder string) (apimodels.PostableRuleGroupConfig, string) { +type ruleOption func(*ruleConfig) + +type ruleConfig struct { + rule *apimodels.PostableExtendedRuleNode + groupName string +} + +func withLabels(labels map[string]string) ruleOption { + return func(cfg *ruleConfig) { + cfg.rule.Labels = labels + } +} + +func withRuleGroup(groupName string) ruleOption { + return func(cfg *ruleConfig) { + cfg.groupName = groupName + } +} + +func createRule(t *testing.T, client apiClient, folder string, opts ...ruleOption) (apimodels.PostableRuleGroupConfig, string) { t.Helper() interval, err := model.ParseDuration("1m") require.NoError(t, err) doubleInterval := 2 * interval - rules := apimodels.PostableRuleGroupConfig{ - Name: "arulegroup", - Interval: interval, - Rules: []apimodels.PostableExtendedRuleNode{ - { - ApiRuleNode: &apimodels.ApiRuleNode{ - For: &doubleInterval, - Labels: map[string]string{"label1": "val1"}, - Annotations: map[string]string{"annotation1": "val1"}, - }, - GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ - Title: fmt.Sprintf("rule under folder %s", folder), - Condition: "A", - Data: []apimodels.AlertQuery{ - { - RefID: "A", - RelativeTimeRange: apimodels.RelativeTimeRange{ - From: apimodels.Duration(time.Duration(5) * time.Hour), - To: apimodels.Duration(time.Duration(3) * time.Hour), - }, - DatasourceUID: expr.DatasourceUID, - Model: json.RawMessage(`{ - "type": "math", - "expression": "2 + 3 > 1" - }`), - }, + + rule := apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &doubleInterval, + Labels: map[string]string{"label1": "val1"}, + Annotations: map[string]string{"annotation1": "val1"}, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: fmt.Sprintf("rule under folder %s", folder), + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), }, }, }, } + + cfg := &ruleConfig{ + rule: &rule, + groupName: "arulegroup", + } + for _, opt := range opts { + opt(cfg) + } + + rules := apimodels.PostableRuleGroupConfig{ + Name: cfg.groupName, + Interval: interval, + Rules: []apimodels.PostableExtendedRuleNode{*cfg.rule}, + } resp, status, _ := client.PostRulesGroupWithStatus(t, folder, &rules, false) require.Equal(t, http.StatusAccepted, status) require.Len(t, resp.Created, 1) diff --git a/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json b/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json index b3dabb7cde2..fe4f2f2f924 100644 --- a/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json +++ b/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json @@ -2699,6 +2699,24 @@ "secure": false, "dependsOn": "", "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Footer", + "description": "Templated footer of the slack message", + "placeholder": "{{ template \"slack.default.footer\" . }}", + "propertyName": "footer", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null } ] }, diff --git a/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json b/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json index 50c92e4d069..d3797d9cafa 100644 --- a/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json +++ b/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json @@ -7017,6 +7017,24 @@ "secure": false, "dependsOn": "", "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Footer", + "description": "Templated footer of the slack message", + "placeholder": "{{ template \"slack.default.footer\" . }}", + "propertyName": "footer", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null } ] }, diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index 36461ea910e..ee40bf00b47 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -132,53 +132,6 @@ func TestIntegrationDashboardAPIValidation(t *testing.T) { } } -// TestIntegrationDashboardAPIAuthorization tests the dashboard K8s API with authorization checks -func TestIntegrationDashboardAPIAuthorization(t *testing.T) { - testutil.SkipIntegrationTestInShortMode(t) - - dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5} - for _, dualWriterMode := range dualWriterModes { - t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) { - helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ - DisableDataMigrations: true, - DisableAnonymous: true, - UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ - "dashboards.dashboard.grafana.app": { - DualWriterMode: dualWriterMode, - }, - "folders.folder.grafana.app": { - DualWriterMode: dualWriterMode, - }, - }, - UnifiedStorageEnableSearch: true, - }) - - t.Cleanup(func() { - helper.Shutdown() - }) - - org1Ctx := createTestContext(t, helper, helper.Org1, dualWriterMode) - org2Ctx := createTestContext(t, helper, helper.OrgB, dualWriterMode) - - t.Run("Authorization tests for all identity types", func(t *testing.T) { - runAuthorizationTests(t, org1Ctx) - }) - - t.Run("Dashboard permission tests", func(t *testing.T) { - runDashboardPermissionTests(t, org1Ctx, true) - }) - - t.Run("Cross-organization tests", func(t *testing.T) { - runCrossOrgTests(t, org1Ctx, org2Ctx) - }) - - t.Run("Dashboard HTTP API test", func(t *testing.T) { - runDashboardHttpTest(t, org1Ctx, org2Ctx) - }) - }) - } -} - // TestIntegrationDashboardAPI tests the dashboard K8s API func TestIntegrationDashboardAPI(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) diff --git a/pkg/tests/apis/folder/folder_tree_test.go b/pkg/tests/apis/folder/folder_tree_test.go index 1b3d5df37f2..4d95d64b024 100644 --- a/pkg/tests/apis/folder/folder_tree_test.go +++ b/pkg/tests/apis/folder/folder_tree_test.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" "testing" + "time" "github.com/stretchr/testify/require" "github.com/xlab/treeprint" @@ -31,6 +32,35 @@ import ( "github.com/grafana/grafana/pkg/util/testutil" ) +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{ + DisableDataMigrations: true, + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": { + DualWriterMode: grafanarest.Mode5, + }, + folderV1.RESOURCEGROUP: { + DualWriterMode: grafanarest.Mode5, + }, + }, + EnableFeatureToggles: []string{ + "zanzana", + "zanzanaNoLegacyClient", + "kubernetesAuthzZanzanaSync", + }, + UnifiedStorageEnableSearch: true, + ZanzanaReconciliationInterval: 100 * time.Millisecond, + DisableZanzanaCache: true, + }) +} + func TestIntegrationFolderTree(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) @@ -47,7 +77,7 @@ func TestIntegrationFolderTree(t *testing.T) { } for _, mode := range modes { t.Run(fmt.Sprintf("mode %d", mode), func(t *testing.T) { - helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + runIntegrationFolderTree(t, testinfra.GrafanaOpts{ DisableDataMigrations: true, AppModeProduction: true, DisableAnonymous: true, @@ -62,113 +92,122 @@ func TestIntegrationFolderTree(t *testing.T) { }, UnifiedStorageEnableSearch: mode >= grafanarest.Mode3, // make sure modes 0-3 work without search enabled }) - defer helper.Shutdown() + }) + } +} - tests := []struct { - Name string - Definition FolderDefinition - Expected []ExpectedTree - }{ - { - Name: "admin-only-tree", - Definition: FolderDefinition{ +func runIntegrationFolderTree(t *testing.T, opts testinfra.GrafanaOpts) { + if !db.IsTestDbSQLite() { + t.Skip("test only on sqlite for now") + } + + helper := apis.NewK8sTestHelper(t, opts) + defer helper.Shutdown() + + apis.AwaitZanzanaReconcileNext(t, helper) + + tests := []struct { + Name string + Definition FolderDefinition + Expected []ExpectedTree + }{ + { + Name: "admin-only-tree", + Definition: FolderDefinition{ + Children: []FolderDefinition{ + {Name: "top", + Creator: helper.Org1.Admin, Children: []FolderDefinition{ - {Name: "top", + {Name: "middle", Creator: helper.Org1.Admin, Children: []FolderDefinition{ - {Name: "middle", + {Name: "child", Creator: helper.Org1.Admin, - Children: []FolderDefinition{ - {Name: "child", - Creator: helper.Org1.Admin, - Permissions: []FolderPermission{{ - Permission: "View", - User: helper.Org1.None, - }}, - }, - }, + Permissions: []FolderPermission{{ + Permission: "View", + User: helper.Org1.None, + }}, }, }, }, }, }, - Expected: []ExpectedTree{ - {User: helper.Org1.Admin, Listing: ` + }, + }, + Expected: []ExpectedTree{ + {User: helper.Org1.Admin, Listing: ` └── top (admin,edit,save,delete) ....└── middle (admin,edit,save,delete) ........└── child (admin,edit,save,delete)`}, - {User: helper.Org1.Viewer, Listing: ` + {User: helper.Org1.Viewer, Listing: ` └── top (view) ....└── middle (view) ........└── child (view)`}, - {User: helper.Org1.None, Listing: ` + {User: helper.Org1.None, Listing: ` └── sharedwithme (???) ....└── child (view)`, - E403: []string{"top", "middle"}, - }, - }, + E403: []string{"top", "middle"}, }, - } + }, + }, + } - var statusCode int - for _, tt := range tests { - t.Run(tt.Name, func(t *testing.T) { - tt.Definition.RequireUniqueName(t, make(map[string]bool)) + var statusCode int + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + tt.Definition.RequireUniqueName(t, make(map[string]bool)) - tt.Definition.CreateWithLegacyAPI(t, helper, "") - // CreateWithLegacyAPI + tt.Definition.CreateWithLegacyAPI(t, helper, "") - for _, expect := range tt.Expected { - unstructured, client := getFolderClients(t, expect.User) - t.Run(fmt.Sprintf("query as %s", expect.User.Identity.GetLogin()), func(t *testing.T) { - legacy := getFoldersFromLegacyAPISearch(t, client) - legacy.requireEqual(t, expect.Listing, "legacy") + for _, expect := range tt.Expected { + unstructured, client := getFolderClients(t, expect.User) + t.Run(fmt.Sprintf("query as %s", expect.User.Identity.GetLogin()), func(t *testing.T) { + legacy := getFoldersFromLegacyAPISearch(t, client) + legacy.requireEqual(t, expect.Listing, "legacy") - listed := getFoldersFromAPIServerList(t, unstructured) - listed.requireEqual(t, expect.Listing, "listed") + listed := getFoldersFromAPIServerList(t, unstructured) + listed.requireEqual(t, expect.Listing, "listed") - search := getFoldersFromDashboardV0Search(t, client, expect.User.Identity.GetNamespace()) - search.requireEqual(t, expect.Listing, "search") + search := getFoldersFromDashboardV0Search(t, client, expect.User.Identity.GetNamespace()) + search.requireEqual(t, expect.Listing, "search") - // ensure sure GET also works on each folder we can list - listed.forEach(func(fv *FolderView) { - if fv.Name == folder.SharedWithMeFolderUID { - return // skip it - } - found, err := unstructured.Get(context.Background(), fv.Name, v1.GetOptions{}) - require.NoErrorf(t, err, "getting folder: %s", fv.Name) - require.Equal(t, found.GetName(), fv.Name) - }) + // ensure sure GET also works on each folder we can list + listed.forEach(func(fv *FolderView) { + if fv.Name == folder.SharedWithMeFolderUID { + return // skip it + } + found, err := unstructured.Get(context.Background(), fv.Name, v1.GetOptions{}) + require.NoErrorf(t, err, "getting folder: %s", fv.Name) + require.Equal(t, found.GetName(), fv.Name) + }) - // Forbidden things should really be hidden - for _, name := range expect.E403 { - _, err := unstructured.Get(context.Background(), name, v1.GetOptions{}) - require.Error(t, err) - require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ???? + // Forbidden things should really be hidden + for _, name := range expect.E403 { + _, err := unstructured.Get(context.Background(), name, v1.GetOptions{}) + require.Error(t, err) + require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ???? - result := client.Get().AbsPath("api", "folders", name). - Do(context.Background()). - StatusCode(&statusCode) - require.Equal(t, int(http.StatusForbidden), statusCode) - require.Error(t, result.Error()) + result := client.Get().AbsPath("api", "folders", name). + Do(context.Background()). + StatusCode(&statusCode) + require.Equal(t, int(http.StatusForbidden), statusCode) + require.Error(t, result.Error()) - // Verify sub-resources are hidden - for _, sub := range []string{"access", "parents", "children", "counts"} { - _, err := unstructured.Get(context.Background(), name, v1.GetOptions{}, sub) - require.Error(t, err, "expect error for subresource", sub) - require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ???? - } + // Verify sub-resources are hidden + for _, sub := range []string{"access", "parents", "children", "counts"} { + _, err := unstructured.Get(context.Background(), name, v1.GetOptions{}, sub) + require.Error(t, err, "expect error for subresource", sub) + require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ???? + } - // Verify legacy API access is also hidden - for _, sub := range []string{"permissions", "counts"} { - result := client.Get().AbsPath("api", "folders", name, sub). - Do(context.Background()). - StatusCode(&statusCode) - require.Equalf(t, int(http.StatusForbidden), statusCode, "legacy access to: %s", sub) - require.Error(t, result.Error()) - } - } - }) + // Verify legacy API access is also hidden + for _, sub := range []string{"permissions", "counts"} { + result := client.Get().AbsPath("api", "folders", name, sub). + Do(context.Background()). + StatusCode(&statusCode) + require.Equalf(t, int(http.StatusForbidden), statusCode, "legacy access to: %s", sub) + require.Error(t, result.Error()) + } } }) } @@ -212,6 +251,8 @@ func (f *FolderDefinition) CreateWithLegacyAPI(t *testing.T, h *apis.K8sTestHelp }) require.NoError(t, err) + apis.AwaitZanzanaReconcileNext(t, h) + var statusCode int result := client.Post().AbsPath("api", "folders"). Body(body). diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json index 61834093866..4634143bd45 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -2169,6 +2169,43 @@ ] } }, + "/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/snapshots/settings": { + "get": { + "tags": [ + "Snapshot" + ], + "description": "Get Snapshot sharing settings", + "operationId": "getSnapshotSettings", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "workspace", + "required": true, + "schema": { + "type": "string" + }, + "example": "default" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {}, + "example": "{\"snapshotsEnabled\":true,\"externalSnapshotURL\":\"https://externalurl.com\",\"externalSnapshotName\":\"external\",\"externalEnabled\":true}" + } + } + } + }, + "x-grafana-action": "get", + "x-kubernetes-group-version-kind": { + "group": "dashboard.grafana.app", + "version": "v0alpha1", + "kind": "SnapshotSharingOptions" + } + } + }, "/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/snapshots/{name}": { "get": { "tags": [ diff --git a/pkg/tests/apis/playlist/playlist_test.go b/pkg/tests/apis/playlist/playlist_test.go index 2611624debb..da9a6530e5b 100644 --- a/pkg/tests/apis/playlist/playlist_test.go +++ b/pkg/tests/apis/playlist/playlist_test.go @@ -426,6 +426,45 @@ func doPlaylistTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelp require.Equal(t, metav1.StatusReasonForbidden, rsp.Status.Reason) }) + t.Run("Check CRUD operations with None role", func(t *testing.T) { + // Create a playlist with admin user + clientAdmin := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + GVR: gvr, + }) + created, err := clientAdmin.Resource.Create(context.Background(), + helper.LoadYAMLOrJSONFile("testdata/playlist-generate.yaml"), + metav1.CreateOptions{}, + ) + require.NoError(t, err) + + clientNone := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.None, + GVR: gvr, + }) + + // Now check if None user can perform a Get to start a playlist + _, err = clientNone.Resource.Get(context.Background(), created.GetName(), metav1.GetOptions{}) + require.NoError(t, err) + + // None role can get but can not create edit or delete a playlist + _, err = clientNone.Resource.Create(context.Background(), + helper.LoadYAMLOrJSONFile("testdata/playlist-generate.yaml"), + metav1.CreateOptions{}, + ) + require.Error(t, err) + + _, err = clientNone.Resource.Update(context.Background(), created, metav1.UpdateOptions{}) + require.Error(t, err) + + err = clientNone.Resource.Delete(context.Background(), created.GetName(), metav1.DeleteOptions{}) + require.Error(t, err) + + // delete created resource + err = clientAdmin.Resource.Delete(context.Background(), created.GetName(), metav1.DeleteOptions{}) + require.NoError(t, err) + }) + t.Run("Check k8s client-go List from different org users", func(t *testing.T) { // Check Org1 Viewer client := helper.GetResourceClient(apis.ResourceClientArgs{ diff --git a/pkg/tests/apis/zanzana_reconcile.go b/pkg/tests/apis/zanzana_reconcile.go new file mode 100644 index 00000000000..f8a5673fed7 --- /dev/null +++ b/pkg/tests/apis/zanzana_reconcile.go @@ -0,0 +1,87 @@ +package apis + +import ( + "bytes" + "context" + "net/http" + "testing" + "time" + + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/common/expfmt" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +const zanzanaReconcileLastSuccessMetric = "grafana_zanzana_reconcile_last_success_timestamp_seconds" + +// AwaitZanzanaReconcileNext waits for the next Zanzana reconciliation cycle to complete. +// It is a no-op unless the `zanzana` feature toggle is enabled for the running test env. +func AwaitZanzanaReconcileNext(t *testing.T, helper *K8sTestHelper) { + t.Helper() + + enabled := false + if helper != nil { + enabled = helper.GetEnv().FeatureToggles.GetEnabled(context.Background())[featuremgmt.FlagZanzana] + } + if helper == nil || !enabled { + return + } + + prev, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) + if !ok { + prev = 0 + } + + require.EventuallyWithT(t, func(c *assert.CollectT) { + ts, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) + assert.True(c, ok, "expected to find %s in /metrics", zanzanaReconcileLastSuccessMetric) + if !ok { + return + } + assert.Greater(c, ts, prev, "expected %s (%v) > %v", zanzanaReconcileLastSuccessMetric, ts, prev) + }, 30*time.Second, 50*time.Millisecond) +} + +func getZanzanaReconcileLastSuccessTimestampSeconds(t *testing.T, helper *K8sTestHelper) (float64, bool) { + t.Helper() + + rsp := DoRequest(helper, RequestParams{ + User: helper.Org1.Admin, + Path: "/metrics", + Accept: "text/plain", + }, &struct{}{}) + if rsp.Response == nil || rsp.Response.StatusCode != http.StatusOK { + return 0, false + } + + parser := expfmt.NewTextParser(model.UTF8Validation) + metrics, err := parser.TextToMetricFamilies(bytes.NewReader(rsp.Body)) + if err != nil { + return 0, false + } + + metric := metrics[zanzanaReconcileLastSuccessMetric] + if metric == nil || len(metric.Metric) == 0 { + return 0, false + } + + m := metric.Metric[0] + switch metric.GetType() { + case dto.MetricType_GAUGE: + if m.Gauge == nil { + return 0, false + } + return m.Gauge.GetValue(), true + case dto.MetricType_UNTYPED: + if m.Untyped == nil { + return 0, false + } + return m.Untyped.GetValue(), true + default: + return 0, false + } +} diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 6e2f0660c32..13507b7987a 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -609,6 +609,20 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) } + if opts.ZanzanaReconciliationInterval != 0 { + rbacSect, err := cfg.NewSection("rbac") + require.NoError(t, err) + _, err = rbacSect.NewKey("zanzana_reconciliation_interval", opts.ZanzanaReconciliationInterval.String()) + require.NoError(t, err) + } + + if opts.DisableZanzanaCache { + rbacSect, err := cfg.NewSection("rbac") + require.NoError(t, err) + _, err = rbacSect.NewKey("disable_zanzana_cache", "true") + require.NoError(t, err) + } + dashboardsSection, err := getOrCreateSection("dashboards") require.NoError(t, err) _, err = dashboardsSection.NewKey("min_refresh_interval", "10s") @@ -687,6 +701,8 @@ type GrafanaOpts struct { SecretsManagerEnableDBMigrations bool OpenFeatureAPIEnabled bool DisableAuthZClientCache bool + ZanzanaReconciliationInterval time.Duration + DisableZanzanaCache bool // Allow creating grafana dir beforehand Dir string diff --git a/pkg/tsdb/elasticsearch/data_query_processor.go b/pkg/tsdb/elasticsearch/data_query_processor.go index 1c4ec7b3cdd..288d6ce30de 100644 --- a/pkg/tsdb/elasticsearch/data_query_processor.go +++ b/pkg/tsdb/elasticsearch/data_query_processor.go @@ -24,7 +24,7 @@ func (e *elasticsearchDataQuery) processQuery(q *Query, ms *es.MultiSearchReques filters.AddDateRangeFilter(defaultTimeField, to, from, es.DateFormatEpochMS) filters.AddQueryStringFilter(q.RawQuery, true) - if q.EditorType != nil && *q.EditorType == "code" && q.RawDSLQuery != "" { + if q.EditorType != nil && *q.EditorType == "code" { cfg := backend.GrafanaConfigFromContext(e.ctx) if !cfg.FeatureToggles().IsEnabled("elasticsearchRawDSLQuery") { return backend.DownstreamError(fmt.Errorf("raw DSL query feature is disabled. Enable the elasticsearchRawDSLQuery feature toggle to use this query type")) diff --git a/pkg/tsdb/elasticsearch/data_query_validator.go b/pkg/tsdb/elasticsearch/data_query_validator.go index 648dbb53109..72bcde016b6 100644 --- a/pkg/tsdb/elasticsearch/data_query_validator.go +++ b/pkg/tsdb/elasticsearch/data_query_validator.go @@ -7,7 +7,7 @@ import ( // isQueryWithError validates the query and returns an error if invalid func isQueryWithError(query *Query) error { // Skip validation for raw DSL queries because no easy way to see it is valid without just running it - if query.EditorType != nil && *query.EditorType == "code" && query.RawDSLQuery != "" { + if query.EditorType != nil && *query.EditorType == "code" { return nil } if len(query.BucketAggs) == 0 { 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) { diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index 77de0b82603..1b286684747 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -13,7 +13,7 @@ import ( _ "github.com/microsoft/go-mssqldb/integratedauth/krb5" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/mssql/sqleng" ) @@ -22,10 +22,10 @@ type Service struct { logger log.Logger } -func ProvideService(cfg *setting.Cfg) *Service { +func ProvideService() *Service { logger := backend.NewLoggerWith("logger", "tsdb.mssql") return &Service{ - im: datasource.NewInstanceManager(NewInstanceSettings(cfg, logger)), + im: datasource.NewInstanceManager(NewInstanceSettings(logger)), logger: logger, } } @@ -48,7 +48,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) return dsHandler.QueryData(azusercontext.WithUserFromQueryReq(ctx, req), req) } -func NewInstanceSettings(cfg *setting.Cfg, logger log.Logger) datasource.InstanceFactoryFunc { +func NewInstanceSettings(logger log.Logger) datasource.InstanceFactoryFunc { return func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { grafCfg := backend.GrafanaConfigFromContext(ctx) sqlCfg, err := grafCfg.SQL() diff --git a/pkg/tsdb/mssql/standalone/main.go b/pkg/tsdb/mssql/standalone/main.go index a4fafe05f1a..3f28d9cc131 100644 --- a/pkg/tsdb/mssql/standalone/main.go +++ b/pkg/tsdb/mssql/standalone/main.go @@ -6,7 +6,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/log" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb/mssql" ) @@ -20,8 +20,7 @@ func main() { // ID). When datasource configuration changed Dispose method will be called and // new datasource instance created using NewSampleDatasource factory. logger := backend.NewLoggerWith("logger", "tsdb.mssql") - cfg := setting.NewCfg() - if err := datasource.Manage("mssql", mssql.NewInstanceSettings(cfg, logger), datasource.ManageOpts{}); err != nil { + if err := datasource.Manage("mssql", mssql.NewInstanceSettings(logger), datasource.ManageOpts{}); err != nil { log.DefaultLogger.Error(err.Error()) os.Exit(1) } diff --git a/pkg/tsdb/opentsdb/callresource.go b/pkg/tsdb/opentsdb/callresource.go index be0f81b9c80..74ed9b53188 100644 --- a/pkg/tsdb/opentsdb/callresource.go +++ b/pkg/tsdb/opentsdb/callresource.go @@ -1,10 +1,13 @@ package opentsdb import ( + "encoding/json" "fmt" "net/http" "net/url" "path" + "sort" + "strings" "github.com/grafana/grafana-plugin-sdk-go/backend" ) @@ -65,3 +68,386 @@ func (s *Service) HandleSuggestQuery(rw http.ResponseWriter, req *http.Request) return } } + +func (s *Service) HandleAggregatorsQuery(rw http.ResponseWriter, req *http.Request) { + logger := logger.FromContext(req.Context()) + + dsInfo, err := s.getDSInfo(req.Context(), backend.PluginConfigFromContext(req.Context())) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to get datasource info: %v", err), http.StatusInternalServerError) + return + } + + u, err := url.Parse(dsInfo.URL) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to parse datasource URL: %v", err), http.StatusInternalServerError) + return + } + + u.Path = path.Join(u.Path, "api/aggregators") + httpReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to create request: %v", err), http.StatusInternalServerError) + return + } + + res, err := dsInfo.HTTPClient.Do(httpReq) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to execute request: %v", err), http.StatusInternalServerError) + return + } + + defer func() { + if err := res.Body.Close(); err != nil { + logger.Error("Failed to close response body", "error", err) + } + }() + + responseBody, err := DecodeResponseBody(res, logger) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to decode response: %v", err), http.StatusInternalServerError) + return + } + + var aggregators []string + if err := json.Unmarshal(responseBody, &aggregators); err != nil { + http.Error(rw, fmt.Sprintf("failed to unmarshal aggregators response: %v", err), http.StatusInternalServerError) + return + } + + sort.Strings(aggregators) + sortedResponse, err := json.Marshal(aggregators) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to marshal response: %v", err), http.StatusInternalServerError) + return + } + + for name, values := range res.Header { + if name == "Content-Encoding" || name == "Content-Length" { + continue + } + for _, value := range values { + rw.Header().Add(name, value) + } + } + + rw.WriteHeader(res.StatusCode) + if _, err := rw.Write(sortedResponse); err != nil { + logger.Error("Failed to write response", "error", err) + return + } +} + +func (s *Service) HandleFiltersQuery(rw http.ResponseWriter, req *http.Request) { + logger := logger.FromContext(req.Context()) + + dsInfo, err := s.getDSInfo(req.Context(), backend.PluginConfigFromContext(req.Context())) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to get datasource info: %v", err), http.StatusInternalServerError) + return + } + + u, err := url.Parse(dsInfo.URL) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to parse datasource URL: %v", err), http.StatusInternalServerError) + return + } + + u.Path = path.Join(u.Path, "/api/config/filters") + httpReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to create request: %v", err), http.StatusInternalServerError) + return + } + + res, err := dsInfo.HTTPClient.Do(httpReq) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to execute request: %v", err), http.StatusInternalServerError) + return + } + + defer func() { + if err := res.Body.Close(); err != nil { + logger.Error("Failed to close response body", "error", err) + } + }() + + responseBody, err := DecodeResponseBody(res, logger) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to decode response: %v", err), http.StatusInternalServerError) + return + } + + var filters map[string]json.RawMessage + if err := json.Unmarshal(responseBody, &filters); err != nil { + http.Error(rw, fmt.Sprintf("failed to unmarshal filters response: %v", err), http.StatusInternalServerError) + return + } + + keys := make([]string, 0, len(filters)) + for key := range filters { + keys = append(keys, key) + } + + sort.Strings(keys) + sortedResponse, err := json.Marshal(keys) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to marshal response: %v", err), http.StatusInternalServerError) + return + } + + for name, values := range res.Header { + if name == "Content-Encoding" || name == "Content-Length" { + continue + } + for _, value := range values { + rw.Header().Add(name, value) + } + } + + rw.WriteHeader(res.StatusCode) + if _, err := rw.Write(sortedResponse); err != nil { + logger.Error("Failed to write response", "error", err) + return + } +} + +func (s *Service) HandleLookupQuery(rw http.ResponseWriter, req *http.Request) { + queryParams := req.URL.Query() + typeParam := queryParams.Get("type") + if typeParam == "" { + http.Error(rw, "missing 'type' parameter", http.StatusBadRequest) + return + } + + switch typeParam { + case "key": + s.HandleKeyLookup(rw, req, queryParams) + case "keyvalue": + s.HandleKeyValueLookup(rw, req, queryParams) + default: + http.Error(rw, fmt.Sprintf("unsupported type: %s", typeParam), http.StatusBadRequest) + return + } +} + +func (s *Service) HandleKeyLookup(rw http.ResponseWriter, req *http.Request, queryParams url.Values) { + logger := logger.FromContext(req.Context()) + + dsInfo, err := s.getDSInfo(req.Context(), backend.PluginConfigFromContext(req.Context())) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to get datasource info: %v", err), http.StatusInternalServerError) + return + } + + metric := queryParams.Get("metric") + if metric == "" { + http.Error(rw, "missing 'metric' parameter", http.StatusBadRequest) + return + } + + u, err := url.Parse(dsInfo.URL) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to parse datasource URL: %v", err), http.StatusInternalServerError) + return + } + + u.Path = path.Join(u.Path, "api/search/lookup") + lookupQueryParams := u.Query() + lookupQueryParams.Set("m", metric) + lookupQueryParams.Set("limit", "1000") + u.RawQuery = lookupQueryParams.Encode() + + httpReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to create request: %v", err), http.StatusInternalServerError) + return + } + + res, err := dsInfo.HTTPClient.Do(httpReq) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to execute request: %v", err), http.StatusInternalServerError) + return + } + + defer func() { + if err := res.Body.Close(); err != nil { + logger.Error("Failed to close response body", "error", err) + } + }() + + responseBody, err := DecodeResponseBody(res, logger) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to decode response: %v", err), http.StatusInternalServerError) + return + } + + var lookupResponse struct { + Results []struct { + Tags map[string]string `json:"tags"` + } `json:"results"` + } + + if err := json.Unmarshal(responseBody, &lookupResponse); err != nil { + http.Error(rw, fmt.Sprintf("failed to unmarshal lookup response: %v", err), http.StatusInternalServerError) + return + } + + tagKeysMap := make(map[string]bool) + for _, result := range lookupResponse.Results { + for tagKey := range result.Tags { + tagKeysMap[tagKey] = true + } + } + + tagKeys := make([]string, 0, len(tagKeysMap)) + for tagKey := range tagKeysMap { + tagKeys = append(tagKeys, tagKey) + } + + sort.Strings(tagKeys) + sortedResponse, err := json.Marshal(tagKeys) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to marshal response: %v", err), http.StatusInternalServerError) + return + } + + for name, values := range res.Header { + if name == "Content-Encoding" || name == "Content-Length" { + continue + } + for _, value := range values { + rw.Header().Add(name, value) + } + } + + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(res.StatusCode) + if _, err := rw.Write(sortedResponse); err != nil { + logger.Error("Failed to write response", "error", err) + return + } +} + +func (s *Service) HandleKeyValueLookup(rw http.ResponseWriter, req *http.Request, queryParams url.Values) { + logger := logger.FromContext(req.Context()) + + dsInfo, err := s.getDSInfo(req.Context(), backend.PluginConfigFromContext(req.Context())) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to get datasource info: %v", err), http.StatusInternalServerError) + return + } + + metric := queryParams.Get("metric") + if metric == "" { + http.Error(rw, "missing 'metric' parameter", http.StatusBadRequest) + return + } + + keys := queryParams.Get("keys") + if keys == "" { + http.Error(rw, "missing 'keys' parameter", http.StatusBadRequest) + return + } + + keysArray := strings.Split(keys, ",") + for i := range keysArray { + keysArray[i] = strings.TrimSpace(keysArray[i]) + } + + if len(keysArray) == 0 { + http.Error(rw, "keys parameter cannot be empty", http.StatusBadRequest) + return + } + + key := keysArray[0] + keysQuery := key + "=*" + + if len(keysArray) > 1 { + keysQuery += "," + strings.Join(keysArray[1:], ",") + } + + m := metric + "{" + keysQuery + "}" + + u, err := url.Parse(dsInfo.URL) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to parse datasource URL: %v", err), http.StatusInternalServerError) + return + } + + u.Path = path.Join(u.Path, "api/search/lookup") + lookupQueryParams := u.Query() + lookupQueryParams.Set("m", m) + lookupQueryParams.Set("limit", fmt.Sprintf("%d", dsInfo.LookupLimit)) + u.RawQuery = lookupQueryParams.Encode() + + httpReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to create request: %v", err), http.StatusInternalServerError) + return + } + + res, err := dsInfo.HTTPClient.Do(httpReq) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to execute request: %v", err), http.StatusInternalServerError) + return + } + + defer func() { + if err := res.Body.Close(); err != nil { + logger.Error("Failed to close response body", "error", err) + } + }() + + responseBody, err := DecodeResponseBody(res, logger) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to decode response: %v", err), http.StatusInternalServerError) + return + } + + var lookupResponse struct { + Results []struct { + Tags map[string]string `json:"tags"` + } `json:"results"` + } + + if err := json.Unmarshal(responseBody, &lookupResponse); err != nil { + http.Error(rw, fmt.Sprintf("failed to unmarshal lookup response: %v", err), http.StatusInternalServerError) + return + } + + tagValuesMap := make(map[string]bool) + for _, result := range lookupResponse.Results { + if tagValue, exists := result.Tags[key]; exists { + tagValuesMap[tagValue] = true + } + } + + tagValues := make([]string, 0, len(tagValuesMap)) + for tagValue := range tagValuesMap { + tagValues = append(tagValues, tagValue) + } + + sort.Strings(tagValues) + sortedResponse, err := json.Marshal(tagValues) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to marshal response: %v", err), http.StatusInternalServerError) + return + } + + for name, values := range res.Header { + if name == "Content-Encoding" || name == "Content-Length" { + continue + } + for _, value := range values { + rw.Header().Add(name, value) + } + } + + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(res.StatusCode) + if _, err := rw.Write(sortedResponse); err != nil { + logger.Error("Failed to write response", "error", err) + return + } +} diff --git a/pkg/tsdb/opentsdb/opentsdb.go b/pkg/tsdb/opentsdb/opentsdb.go index a694445e1cd..533fadccb75 100644 --- a/pkg/tsdb/opentsdb/opentsdb.go +++ b/pkg/tsdb/opentsdb/opentsdb.go @@ -152,6 +152,9 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { mux := http.NewServeMux() mux.HandleFunc("/api/suggest", s.HandleSuggestQuery) + mux.HandleFunc("/api/aggregators", s.HandleAggregatorsQuery) + mux.HandleFunc("/api/config/filters", s.HandleFiltersQuery) + mux.HandleFunc("/api/search/lookup", s.HandleLookupQuery) handler := httpadapter.New(mux) return handler.CallResource(ctx, req, sender) diff --git a/pkg/tsdb/opentsdb/types.go b/pkg/tsdb/opentsdb/types.go index 89aed49baa8..0a01239ce65 100644 --- a/pkg/tsdb/opentsdb/types.go +++ b/pkg/tsdb/opentsdb/types.go @@ -7,9 +7,16 @@ type OpenTsdbQuery struct { } type OpenTsdbCommon struct { - Metric string `json:"metric"` - Tags map[string]string `json:"tags"` - AggregateTags []string `json:"aggregateTags"` + Metric string `json:"metric"` + Tags map[string]string `json:"tags"` + AggregateTags []string `json:"aggregateTags"` + Annotations []OpenTsdbAnnotation `json:"annotations,omitempty"` + GlobalAnnotations []OpenTsdbAnnotation `json:"globalAnnotations,omitempty"` +} + +type OpenTsdbAnnotation struct { + Description string `json:"description"` + StartTime float64 `json:"startTime"` } type OpenTsdbResponse struct { diff --git a/pkg/tsdb/opentsdb/utils.go b/pkg/tsdb/opentsdb/utils.go index ddfa8122fce..df3ea67ae25 100644 --- a/pkg/tsdb/opentsdb/utils.go +++ b/pkg/tsdb/opentsdb/utils.go @@ -198,11 +198,21 @@ func CreateDataFrame(val OpenTsdbCommon, length int, refID string) *data.Frame { sort.Strings(tagKeys) tagKeys = append(tagKeys, val.AggregateTags...) + custom := map[string]any{ + "tagKeys": tagKeys, + } + if len(val.Annotations) > 0 { + custom["annotations"] = val.Annotations + } + if len(val.GlobalAnnotations) > 0 { + custom["globalAnnotations"] = val.GlobalAnnotations + } + frame := data.NewFrameOfFieldTypes(val.Metric, length, data.FieldTypeTime, data.FieldTypeFloat64) frame.Meta = &data.FrameMeta{ Type: data.FrameTypeTimeSeriesMulti, TypeVersion: data.FrameTypeVersion{0, 1}, - Custom: map[string]any{"tagKeys": tagKeys}, + Custom: custom, } frame.RefID = refID timeField := frame.Fields[0] diff --git a/pkg/util/proxyutil/proxyutil.go b/pkg/util/proxyutil/proxyutil.go index eeb640c0854..df4f48be970 100644 --- a/pkg/util/proxyutil/proxyutil.go +++ b/pkg/util/proxyutil/proxyutil.go @@ -8,6 +8,7 @@ import ( "strings" claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" ) @@ -91,12 +92,6 @@ func ClearCookieHeader(req *http.Request, keepCookiesNames []string, skipCookies } } -// SetProxyResponseHeaders sets proxy response headers. -// Sets Content-Security-Policy: sandbox -func SetProxyResponseHeaders(header http.Header) { - header.Set("Content-Security-Policy", "sandbox") -} - // SetViaHeader adds Grafana's reverse proxy to the proxy chain. // Defined in RFC 9110 7.6.3 https://datatracker.ietf.org/doc/html/rfc9110#name-via func SetViaHeader(header http.Header, major, minor int) { diff --git a/pkg/util/proxyutil/reverse_proxy.go b/pkg/util/proxyutil/reverse_proxy.go index d077d792251..c3c5c942a13 100644 --- a/pkg/util/proxyutil/reverse_proxy.go +++ b/pkg/util/proxyutil/reverse_proxy.go @@ -11,6 +11,7 @@ import ( glog "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/middleware/requestmeta" + "github.com/grafana/grafana/pkg/plugins/manager/client" "github.com/grafana/grafana/pkg/services/contexthandler" ) @@ -102,7 +103,7 @@ func modifyResponse(logger glog.Logger) func(resp *http.Response) error { resp.Header.Del(header) } - SetProxyResponseHeaders(resp.Header) + client.SetCSPHeader(resp.Header) SetViaHeader(resp.Header, resp.ProtoMajor, resp.ProtoMinor) requestmeta.WithStatusSource(resp.Request.Context(), resp.StatusCode) diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index 264c5498be9..df0d21ebda8 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -192,7 +192,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ( }); const xField = frame.fields[0]; - const xAxisHidden = xField.config.custom.axisPlacement === AxisPlacement.Hidden; + const xAxisHidden = xField.config.custom?.axisPlacement === AxisPlacement.Hidden; builder.addAxis({ show: !xAxisHidden, 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/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index 12fa3aae223..9432da368b6 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -48,6 +48,7 @@ export type GrafanaPromRulesOptions = Omit ({ url: `api/prometheus/grafana/api/v1/rules`, params: { @@ -123,6 +125,7 @@ export const prometheusApi = alertingApi.injectEndpoints({ 'search.rule_group': searchGroupName, dashboard_uid: dashboardUid, rule_matcher: ruleMatchers, + plugins: plugins, }, }), providesTags: (_result, _error, { folderUid, groupName, ruleName }) => { diff --git a/public/app/features/alerting/unified/home/GettingStarted.tsx b/public/app/features/alerting/unified/home/GettingStarted.tsx index e25e3da07e8..9440902374d 100644 --- a/public/app/features/alerting/unified/home/GettingStarted.tsx +++ b/public/app/features/alerting/unified/home/GettingStarted.tsx @@ -4,6 +4,7 @@ import SVG from 'react-inlinesvg'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; import { Stack, Text, TextLink, useStyles2, useTheme2 } from '@grafana/ui'; import atAGlanceDarkSvg from 'img/alerting/at_a_glance_dark.svg'; import atAGlanceLightSvg from 'img/alerting/at_a_glance_light.svg'; @@ -123,6 +124,20 @@ export function WelcomeHeader({ className }: { className?: string }) { return ( + {config.featureToggles.alertingTriage && ( + <> + +
+ + )} ({ container: css({ color: theme.colors.text.primary, flex: 1, - minWidth: '240px', + minWidth: '160px', display: 'grid', rowGap: theme.spacing(1), gridTemplateColumns: 'min-content 1fr 1fr 1fr', diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts index ab9404d4e52..f97b1195a61 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts @@ -429,7 +429,7 @@ describe('grafana-managed rules', () => { ]); }); - it('should still apply other frontend filters', () => { + it('should include plugins in backend filter and skip frontend filtering', () => { // Set up test plugin as installed config.apps[SupportedPlugin.Slo] = pluginMetaToPluginConfig(pluginMeta[SupportedPlugin.Slo]); @@ -445,14 +445,15 @@ describe('grafana-managed rules', () => { alerts: [], }); - // Plugins filter should still work on frontend - const { frontendFilter } = getGrafanaFilter(getFilter({ plugins: 'hide' })); + // Plugins filter should be handled by backend + const { backendFilter, frontendFilter } = getGrafanaFilter(getFilter({ plugins: 'hide' })); - // Non-plugin rules should pass through + // Backend filter should include plugins parameter + expect(backendFilter.plugins).toBe('hide'); + + // Frontend filter should pass through all rules (no filtering) expect(frontendFilter.ruleMatches(regularRule)).toBe(true); - - // Plugin-provided rules should be filtered out - expect(frontendFilter.ruleMatches(pluginRule)).toBe(false); + expect(frontendFilter.ruleMatches(pluginRule)).toBe(true); }); }); @@ -808,7 +809,10 @@ describe('grafana-managed rules', () => { it('should return true for client-side only filters', () => { expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true); + }); + + it('should return false for plugins filter (handled by backend when feature toggle is enabled)', () => { + expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(false); }); it('should return false for backend-only filters (state, health, contactPoint)', () => { @@ -831,13 +835,15 @@ describe('grafana-managed rules', () => { expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); - // Should return true for: frontend-handled filters (labels, namespace, plugins) + // Should return true for: frontend-handled filters (labels, namespace) expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); - expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true); + + // plugins is backend-handled when alertingUIUseFullyCompatBackendFilters is enabled + expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(false); }); }); @@ -856,9 +862,11 @@ describe('grafana-managed rules', () => { expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); - // Should return true for: always-frontend filters only (namespace, plugins) + // Should return true for: always-frontend filters only (namespace) expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); - expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(true); + + // plugins is backend-handled when both feature toggles are enabled + expect(hasGrafanaClientSideFilters(getFilter({ plugins: 'hide' }))).toBe(false); // Should return false for: backend-handled filters when both feature toggles are enabled expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false); diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts index 96ee951ee37..e8c4cf3c44a 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts @@ -95,6 +95,7 @@ export function getGrafanaFilter(filterState: Partial) { searchGroupName: groupFilterConfig.groupName ? undefined : normalizedFilterState.groupName, datasources: ruleFilterConfig.dataSourceNames ? undefined : datasourceUids, ruleMatchers: ruleMatchersBackendFilter, + plugins: ruleFilterConfig.plugins ? undefined : normalizedFilterState.plugins, }; return { @@ -128,7 +129,7 @@ function buildGrafanaFilterConfigs() { labels: useBackendFilters ? null : labelsFilter, ruleHealth: null, dashboardUid: useBackendFilters || useFullyCompatibleBackendFilters ? null : dashboardUidFilter, - plugins: pluginsFilter, + plugins: useBackendFilters || useFullyCompatibleBackendFilters ? null : pluginsFilter, contactPoint: null, }; diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx index 7ce42744241..950785c2ffd 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx @@ -83,6 +83,24 @@ export function DashboardEditPaneRenderer({ editPane, dashboard, isDocked }: Pro onClick={() => dashboard.openV2SchemaEditor()} /> */} + + window.open( + 'https://docs.google.com/forms/d/e/1FAIpQLSfDZJM_VlZgRHDx8UPtLWbd9bIBPRxoA28qynTHEYniyPXO6Q/viewform', + '_blank' + ) + } + title={t( + 'dashboard-scene.dashboard-edit-pane-renderer.title-feedback-dashboard-editing-experience', + 'Give feedback on the new dashboard editing experience' + )} + tooltip={t( + 'dashboard-scene.dashboard-edit-pane-renderer.title-feedback-dashboard-editing-experience', + 'Give feedback on the new dashboard editing experience' + )} + /> )} {hasUid && } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx index eab5f3e9c58..1e8ff639785 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx @@ -4,7 +4,7 @@ import { DataFrame, DataTransformerID, standardTransformersRegistry, Transformer import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; -import { Box, Button, Grid, Stack, Text } from '@grafana/ui'; +import { Box, Button, Stack, Text } from '@grafana/ui'; import config from 'app/core/config'; import { SqlExpressionCard } from '../../../dashboard/components/TransformationsEditor/SqlExpressionCard'; @@ -26,9 +26,6 @@ const TRANSFORMATION_IDS = [ DataTransformerID.filterByValue, ]; -const GRID_COLUMNS_WITH_SQL = 5; -const GRID_COLUMNS_WITHOUT_SQL = 4; - export function LegacyEmptyTransformationsMessage({ onShowPicker }: { onShowPicker: () => void }) { return ( @@ -94,13 +91,25 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps) }; const showSqlCard = hasGoToQueries && config.featureToggles.sqlExpressions; - const gridColumns = showSqlCard ? GRID_COLUMNS_WITH_SQL : GRID_COLUMNS_WITHOUT_SQL; return ( - - + + + + + Add a Transformation + + + + Transformations allow data to be changed in various ways before your visualization is shown. +
+ This includes joining data together, renaming fields, making calculations, formatting data for display, + and more. +
+
+
{(hasAddTransformation || hasGoToQueries) && ( - + {showSqlCard && ( ))} - +
)} - - - +
); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts index ee2bda935fd..89634322347 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts @@ -112,6 +112,37 @@ describe('PanelEditor', () => { }); }); + describe('Entering panel edit', () => { + it('should clear edit pane selection', () => { + pluginPromise = Promise.resolve(getPanelPlugin({ id: 'text', skipDataQuery: true })); + + const panel = new VizPanel({ + key: 'panel-1', + pluginId: 'text', + title: 'original title', + }); + const gridItem = new DashboardGridItem({ body: panel }); + const panelEditor = buildPanelEditScene(panel); + const dashboard = new DashboardScene({ + editPanel: panelEditor, + isEditing: true, + $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }), + body: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [gridItem], + }), + }), + }); + + dashboard.state.editPane.selectObject(panel, panel.state.key!, { force: true }); + expect(dashboard.state.editPane.getSelection()).toBe(panel); + + deactivate = activateFullSceneTree(dashboard); + + expect(dashboard.state.editPane.getSelection()).toBeUndefined(); + }); + }); + describe('When discarding', () => { it('should discard changes revert all changes', async () => { const { panelEditor, panel, dashboard } = await setup(); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index e656a39e6a1..c5566fdab38 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -51,6 +51,7 @@ export interface PanelEditorState extends SceneObjectState { panelRef: SceneObjectRef; showLibraryPanelSaveModal?: boolean; showLibraryPanelUnlinkModal?: boolean; + editPreview?: VizPanel; tableView?: VizPanel; pluginLoadErrror?: string; /** @@ -84,6 +85,11 @@ export class PanelEditor extends SceneObjectBase { private _activationHandler() { const panel = this.state.panelRef.resolve(); + const dashboard = getDashboardSceneFor(this); + + // Clear any panel selection when entering panel edit mode. + // Need to clear selection here since selection is activated when panel edit mode is entered through the panel actions menu. This causes sidebar panel editor to be open when exiting panel edit mode + dashboard.state.editPane.clearSelection(); if (panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) { if (config.featureToggles.newVizSuggestions) { @@ -145,6 +151,9 @@ export class PanelEditor extends SceneObjectBase { const changedState = layoutItem.state; const originalState = this._layoutItemState!; + this.setState({ editPreview: undefined }); + this.state.optionsPane?.setState({ editPreviewRef: undefined }); + // Temp fix for old edit mode if (this._layoutItem instanceof DashboardGridItem && !config.featureToggles.dashboardNewLayouts) { this._layoutItem.handleEditChange(); @@ -251,16 +260,40 @@ export class PanelEditor extends SceneObjectBase { ); // Setup options pane + const optionsPane = new PanelOptionsPane({ + panelRef: this.state.panelRef, + editPreviewRef: this.state.editPreview?.getRef(), + searchQuery: '', + listMode: OptionFilter.All, + isVizPickerOpen: isUnconfigured, + isNewPanel: this.state.isNewPanel, + }); + this.setState({ - optionsPane: new PanelOptionsPane({ - panelRef: this.state.panelRef, - searchQuery: '', - listMode: OptionFilter.All, - isVizPickerOpen: isUnconfigured, - isNewPanel: this.state.isNewPanel, - }), + optionsPane, isInitializing: false, }); + + this._subs.add( + optionsPane.subscribeToState((newState, oldState) => { + if (newState.isVizPickerOpen !== oldState.isVizPickerOpen) { + const panel = this.state.panelRef.resolve(); + let editPreview: VizPanel | undefined; + if (newState.isVizPickerOpen) { + // we just "pick" timeseries, viz type will likely be overridden by Suggestions. + const editPreviewBuilder = PanelBuilders.timeseries() + .setTitle(panel.state.title) + .setDescription(panel.state.description); + if (panel.state.$data) { + editPreviewBuilder.setData(new DataProviderSharer({ source: panel.state.$data.getRef() })); + } + editPreview = editPreviewBuilder.build(); + } + this.setState({ editPreview }); + optionsPane.setState({ editPreviewRef: editPreview?.getRef() }); + } + }) + ); } else { // plugin changed after first time initialization // Just update data pane diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx index 30b52bf9260..1f1c3860571 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx @@ -81,7 +81,7 @@ export function PanelEditorRenderer({ model }: SceneComponentProps) function VizAndDataPane({ model }: SceneComponentProps) { const dashboard = getDashboardSceneFor(model); - const { dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal, tableView } = model.useState(); + const { dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal, tableView, editPreview } = model.useState(); const panel = model.getPanel(); const libraryPanel = getLibraryPanelBehavior(panel); const { controls } = dashboard.useState(); @@ -113,7 +113,7 @@ function VizAndDataPane({ model }: SceneComponentProps) { )}
- +
{showLibraryPanelSaveModal && libraryPanel && ( { expect(panel.state.pluginId).toBe('timeseries'); - optionsPane.onChangePanelPlugin({ pluginId: 'table' }); + optionsPane.onChangePanel({ pluginId: 'table' }); expect(optionsPane['_cachedPluginOptions']['timeseries']?.options).toBe(panel.state.options); expect(optionsPane['_cachedPluginOptions']['timeseries']?.fieldConfig).toBe(panel.state.fieldConfig); @@ -52,7 +52,7 @@ describe('PanelOptionsPane', () => { panel.setState({ $data: undefined }); panel.activate(); - optionsPane.onChangePanelPlugin({ + optionsPane.onChangePanel({ pluginId: 'table', options: { showHeader: false }, fieldConfig: { @@ -114,7 +114,7 @@ describe('PanelOptionsPane', () => { expect(panel.state.fieldConfig.overrides[1].properties).toHaveLength(1); expect(panel.state.fieldConfig.defaults.custom).toHaveProperty('axisBorderShow'); - optionsPane.onChangePanelPlugin({ pluginId: 'table' }); + optionsPane.onChangePanel({ pluginId: 'table' }); expect(mockFn).toHaveBeenCalled(); expect(mockFn.mock.calls[0][2].defaults.color?.mode).toBe('palette-classic'); @@ -146,8 +146,8 @@ describe('PanelOptionsPane', () => { const mockOnFieldConfigChange = jest.fn(); panel.onFieldConfigChange = mockOnFieldConfigChange; - // Call onChangePanelPlugin with fieldConfig that has overrides - optionsPane.onChangePanelPlugin({ + // Call onChangePanel with fieldConfig that has overrides + optionsPane.onChangePanel({ pluginId: 'table', fieldConfig: { defaults: { unit: 'percent' }, @@ -178,7 +178,7 @@ describe('PanelOptionsPane', () => { panel.onFieldConfigChange = mockOnFieldConfigChange; // Call without fieldConfig - optionsPane.onChangePanelPlugin({ + optionsPane.onChangePanel({ pluginId: 'table', options: { showHeader: false }, }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx index 3f54971f99d..b863bf2fc78 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx @@ -41,6 +41,7 @@ export interface PanelOptionsPaneState extends SceneObjectState { panelRef: SceneObjectRef; isNewPanel?: boolean; hasPickedViz?: boolean; + editPreviewRef?: SceneObjectRef; } interface PluginOptionsCache { @@ -63,8 +64,7 @@ export class PanelOptionsPane extends SceneObjectBase { }); }; - onChangePanelPlugin = (options: VizTypeChangeDetails) => { - const panel = this.state.panelRef.resolve(); + onChangePanel = (options: VizTypeChangeDetails, panel = this.state.panelRef.resolve()) => { const { options: prevOptions, fieldConfig: prevFieldConfig, pluginId: prevPluginId } = panel.state; const pluginId = options.pluginId; @@ -137,8 +137,10 @@ export class PanelOptionsPane extends SceneObjectBase { } function PanelOptionsPaneComponent({ model }: SceneComponentProps) { - const { isVizPickerOpen, searchQuery, listMode, panelRef, isNewPanel, hasPickedViz } = model.useState(); + const { isVizPickerOpen, searchQuery, listMode, panelRef, isNewPanel, hasPickedViz, editPreviewRef } = + model.useState(); const panel = panelRef.resolve(); + const editPreview = editPreviewRef?.resolve() ?? panel; // if something goes wrong, at least update the panel. const { pluginId } = panel.useState(); const { data } = sceneGraph.getData(panel).useState(); const styles = useStyles2(getStyles); @@ -229,7 +231,8 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps void; + editPreview: VizPanel; + onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void; onClose: () => void; } @@ -41,7 +42,7 @@ const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }> : [allVisualizationsTab, suggestionsTab]; }; -export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackButton }: Props) { +export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose, showBackButton }: Props) { const styles = useStyles2(getStyles); const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]); const filterId = useId(); @@ -97,49 +98,55 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackBut - {listMode === VisualizationSelectPaneTab.Suggestions && ( - - )} - {listMode === VisualizationSelectPaneTab.Visualizations && ( - - - - {showBackButton && ( - - )} - - - + + + + {showBackButton && ( + + )} + + + + {listMode === VisualizationSelectPaneTab.Suggestions && ( + + )} + {listMode === VisualizationSelectPaneTab.Visualizations && ( - - )} + )} +
@@ -155,7 +162,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ gap: theme.spacing(2), }), searchField: css({ - marginTop: theme.spacing(0.5), // input glow with the boundary without this + margin: theme.spacing(0.5, 0, 1, 0), // input glow with the boundary without this }), tabs: css({ width: '100%', diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 7ddd7c4e779..91adc3660a8 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -90,7 +90,6 @@ import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; import { addNewRowTo } from './layouts-shared/addNew'; import { clearClipboard } from './layouts-shared/paste'; -import { getIsLazy } from './layouts-shared/utils'; import { DashboardLayoutManager } from './types/DashboardLayoutManager'; import { LayoutParent } from './types/LayoutParent'; @@ -199,7 +198,7 @@ export class DashboardScene extends SceneObjectBase impleme meta: {}, editable: true, $timeRange: state.$timeRange ?? new SceneTimeRange({}), - body: state.body ?? DefaultGridLayoutManager.fromVizPanels([], getIsLazy(state.preload)), + body: state.body ?? DefaultGridLayoutManager.fromVizPanels([]), links: state.links ?? [], ...state, editPane: new DashboardEditPane(), diff --git a/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx b/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx index 2186d9b4863..b1eca307731 100644 --- a/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx +++ b/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx @@ -1,7 +1,7 @@ import React, { useContext, useEffect, useState } from 'react'; import { Trans } from '@grafana/i18n'; -import { VizPanel } from '@grafana/scenes'; +import { LazyLoader, VizPanel } from '@grafana/scenes'; import { Box, Spinner } from '@grafana/ui'; import { DashboardScene } from './DashboardScene'; @@ -51,11 +51,23 @@ export function useSoloPanelContext() { return useContext(SoloPanelContext); } -export function renderMatchingSoloPanels(soloPanelContext: SoloPanelContextValue, panels: VizPanel[]) { +export function renderMatchingSoloPanels( + soloPanelContext: SoloPanelContextValue, + panels: VizPanel[], + isLazy?: boolean +) { const matches: React.ReactNode[] = []; for (const panel of panels) { if (soloPanelContext.matches(panel)) { - matches.push(); + if (isLazy) { + matches.push( + + + + ); + } else { + matches.push(); + } } } diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx index 15b7e82ae36..6ead7a35d22 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx @@ -8,6 +8,7 @@ import { useStyles2 } from '@grafana/ui'; import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup'; import { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useIsConditionallyHidden'; import { useDashboardState } from '../../utils/utils'; +import { SoloPanelContextValueWithSearchStringFilter } from '../PanelSearchLayout'; import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext'; import { getIsLazy } from '../layouts-shared/utils'; @@ -89,7 +90,11 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps; +} + +function PanelWrapper({ panel, isLazy, containerRef }: PanelWrapperProps) { + if (isLazy) { + return ( + + + + ); + } + return ( +
+ +
+ ); +} + export function DashboardGridItemRenderer({ model }: SceneComponentProps) { const { repeatedPanels = [], itemHeight, variableName, body } = model.useState(); const soloPanelContext = useSoloPanelContext(); + const { preload } = useDashboardState(model); + const isLazy = useMemo(() => getIsLazy(preload), [preload]); const layoutStyle = useLayoutStyle( model.getRepeatDirection(), model.getChildCount(), @@ -20,26 +46,22 @@ export function DashboardGridItemRenderer({ model }: SceneComponentProps - -
- ); + return ; } return (
-
- -
+ {repeatedPanels.map((panel) => ( -
- -
+ ))}
); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index e299272de78..68288297e42 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -47,7 +47,6 @@ import { AutoGridItem } from '../layout-auto-grid/AutoGridItem'; import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions'; import { clearClipboard, getDashboardGridItemFromClipboard } from '../layouts-shared/paste'; import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles'; -import { getIsLazy } from '../layouts-shared/utils'; import { DashboardLayoutGrid } from '../types/DashboardLayoutGrid'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; @@ -565,11 +564,10 @@ export class DefaultGridLayoutManager public static createFromLayout(currentLayout: DashboardLayoutManager): DefaultGridLayoutManager { const panels = currentLayout.getVizPanels(); - const isLazy = getIsLazy(getDashboardSceneFor(currentLayout).state.preload)!; - return DefaultGridLayoutManager.fromVizPanels(panels, isLazy); + return DefaultGridLayoutManager.fromVizPanels(panels); } - public static fromVizPanels(panels: VizPanel[] = [], isLazy?: boolean | undefined): DefaultGridLayoutManager { + public static fromVizPanels(panels: VizPanel[] = []): DefaultGridLayoutManager { const children: DashboardGridItem[] = []; const panelHeight = 10; const panelWidth = GRID_COLUMN_COUNT / 3; @@ -607,7 +605,6 @@ export class DefaultGridLayoutManager children: children, isDraggable: true, isResizable: true, - isLazy, }), }); } @@ -615,8 +612,7 @@ export class DefaultGridLayoutManager public static fromGridItems( gridItems: SceneGridItemLike[], isDraggable?: boolean, - isResizable?: boolean, - isLazy?: boolean | undefined + isResizable?: boolean ): DefaultGridLayoutManager { const children = gridItems.reduce((acc, gridItem) => { gridItem.clearParent(); @@ -630,7 +626,6 @@ export class DefaultGridLayoutManager children, isDraggable, isResizable, - isLazy, }), }); } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index 48f11357e24..b7459463958 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -358,8 +358,7 @@ export class RowsLayoutManager extends SceneObjectBase i layout: DefaultGridLayoutManager.fromGridItems( rowConfig.children, rowConfig.isDraggable ?? layout.state.grid.state.isDraggable, - rowConfig.isResizable ?? layout.state.grid.state.isResizable, - layout.state.grid.state.isLazy + rowConfig.isResizable ?? layout.state.grid.state.isResizable ), }) ); diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx index dd5c4ac20b6..9f75b5b7be4 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx +++ b/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx @@ -59,7 +59,11 @@ export function CanvasGridAddActions({ layoutManager }: Props) { }, [layoutManager]); return ( -
+
evt.stopPropagation()} + onPointerDown={(evt) => evt.stopPropagation()} + > @@ -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/app/features/dashboard/services/SnapshotSrv.ts b/public/app/features/dashboard/services/SnapshotSrv.ts index 276f9d717df..ba74866499a 100644 --- a/public/app/features/dashboard/services/SnapshotSrv.ts +++ b/public/app/features/dashboard/services/SnapshotSrv.ts @@ -118,10 +118,7 @@ class K8sAPI implements DashboardSnapshotSrv { } async getSharingOptions() { - // TODO? should this be in a config service, or in the same service? - // we have http://localhost:3000/apis/dashboardsnapshot.grafana.app/v0alpha1/namespaces/default/options - // BUT that has an unclear user mapping story still, so lets stick with the existing shared-options endpoint - return getBackendSrv().get('/api/snapshot/shared-options'); + return getBackendSrv().get(this.url + '/settings'); } async getSnapshot(uid: string): Promise { diff --git a/public/app/features/dimensions/resource.test.ts b/public/app/features/dimensions/resource.test.ts index ad644b61b4f..ac4d1c349dc 100644 --- a/public/app/features/dimensions/resource.test.ts +++ b/public/app/features/dimensions/resource.test.ts @@ -103,6 +103,84 @@ describe('getResourceDimension', () => { expect(getResourceDimension(frame, config).value()).toEqual(''); }); + it('should handle numeric field values with icon from value mapping', () => { + const publicPath = 'https://grafana.fake/public/'; + const frame = createDataFrame({ + fields: [ + { + name: 'status_field', + values: [1, 2, 3], + display: (v) => ({ + text: v === 1 ? 'Success' : v === 2 ? 'Warning' : 'Error', + numeric: Number(v), + icon: + v === 1 + ? 'img/icons/unicons/check-circle.svg' + : v === 2 + ? 'img/icons/unicons/exclamation-triangle.svg' + : 'img/icons/unicons/times-circle.svg', + }), + }, + ], + }); + const config = { mode: ResourceDimensionMode.Field, field: 'status_field', fixed: '' }; + + expect(getResourceDimension(frame, config).get(0)).toEqual(`${publicPath}build/img/icons/unicons/check-circle.svg`); + expect(getResourceDimension(frame, config).get(1)).toEqual( + `${publicPath}build/img/icons/unicons/exclamation-triangle.svg` + ); + expect(getResourceDimension(frame, config).get(2)).toEqual(`${publicPath}build/img/icons/unicons/times-circle.svg`); + }); + + it('should return empty string for unmapped numeric values without icon', () => { + const frame = createDataFrame({ + fields: [ + { + name: 'status_field', + values: [14], + display: (v) => ({ + text: String(v), + numeric: Number(v), + icon: undefined, + }), + }, + ], + }); + const config = { mode: ResourceDimensionMode.Field, field: 'status_field', fixed: '' }; + + expect(getResourceDimension(frame, config).get(0)).toEqual(''); + expect(getResourceDimension(frame, config).value()).toEqual(''); + }); + + it('should handle mixed numeric values with partial mappings', () => { + const publicPath = 'https://grafana.fake/public/'; + const frame = createDataFrame({ + fields: [ + { + name: 'status_field', + values: [1, 99, 2], + display: (v) => ({ + text: v === 1 ? 'Success' : v === 2 ? 'Warning' : String(v), + numeric: Number(v), + icon: + v === 1 + ? 'img/icons/unicons/check-circle.svg' + : v === 2 + ? 'img/icons/unicons/exclamation-triangle.svg' + : undefined, + }), + }, + ], + }); + const config = { mode: ResourceDimensionMode.Field, field: 'status_field', fixed: '' }; + + expect(getResourceDimension(frame, config).get(0)).toEqual(`${publicPath}build/img/icons/unicons/check-circle.svg`); + expect(getResourceDimension(frame, config).get(1)).toEqual(''); + expect(getResourceDimension(frame, config).get(2)).toEqual( + `${publicPath}build/img/icons/unicons/exclamation-triangle.svg` + ); + }); + // TODO: write tests for mapping modes }); @@ -125,4 +203,25 @@ describe('getPublicOrAbsoluteUrl', () => { expect(getPublicOrAbsoluteUrl({ path: 'icon.png' })).toEqual(''); expect(getPublicOrAbsoluteUrl(['icon.png'])).toEqual(''); }); + + it('should handle undefined publicPath gracefully', () => { + const originalPath = window.__grafana_public_path__; + + // @ts-ignore - Intentionally testing runtime edge case + window.__grafana_public_path__ = undefined; + + expect(getPublicOrAbsoluteUrl('icon.png')).toEqual('/build/icon.png'); + + window.__grafana_public_path__ = originalPath; + }); + + it('should handle empty string publicPath gracefully', () => { + const originalPath = window.__grafana_public_path__; + + window.__grafana_public_path__ = ''; + + expect(getPublicOrAbsoluteUrl('icon.png')).toEqual('/build/icon.png'); + + window.__grafana_public_path__ = originalPath; + }); }); diff --git a/public/app/features/dimensions/resource.ts b/public/app/features/dimensions/resource.ts index 4eb28242550..822d808b016 100644 --- a/public/app/features/dimensions/resource.ts +++ b/public/app/features/dimensions/resource.ts @@ -15,8 +15,9 @@ export function getPublicOrAbsoluteUrl(path: unknown): string { // NOTE: The value of `path` could be either an URL string or a relative // path to a Grafana CDN asset served from the CDN. const isUrl = path.indexOf(':/') > 0; + const publicPath = window.__grafana_public_path__ || '/'; - return isUrl ? path : `${window.__grafana_public_path__}build/${path}`; + return isUrl ? path : `${publicPath}build/${path}`; } export function getResourceDimension( @@ -56,11 +57,11 @@ export function getResourceDimension( // mode === ResourceDimensionMode.Field case const getImageOrIcon = (value: unknown): string => { - if (typeof value !== 'string') { + if (typeof value !== 'string' && typeof value !== 'number') { return ''; } - let url = value; + let url = typeof value === 'string' ? value : ''; if (field && field.display) { const displayValue = field.display(value); if (displayValue.icon) { @@ -68,6 +69,11 @@ export function getResourceDimension( } } + const noIconFound = !url; + if (noIconFound) { + return ''; + } + return getPublicOrAbsoluteUrl(url); }; diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index d1763ad835f..924b5f3b6bf 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -9,8 +9,10 @@ import { PanelPluginMeta, PanelPluginVisualizationSuggestion, } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; +import { VizPanel } from '@grafana/scenes'; import { Alert, Button, Icon, Spinner, Text, useStyles2 } from '@grafana/ui'; import { UNCONFIGURED_PANEL_PLUGIN_ID } from 'app/features/dashboard-scene/scene/UnconfiguredPanel'; @@ -23,25 +25,47 @@ import { VisualizationSuggestionCard } from './VisualizationSuggestionCard'; import { VizTypeChangeDetails } from './types'; export interface Props { - onChange: (options: VizTypeChangeDetails) => void; + onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void; + editPreview?: VizPanel; data?: PanelData; panel?: PanelModel; + searchQuery?: string; } -const useSuggestions = (data: PanelData | undefined) => { +const useSuggestions = (data: PanelData | undefined, searchQuery: string | undefined) => { const [hasFetched, setHasFetched] = useState(false); const { value, loading, error, retry } = useAsyncRetry(async () => { await new Promise((resolve) => setTimeout(resolve, hasFetched ? 75 : 0)); setHasFetched(true); return await getAllSuggestions(data); }, [hasFetched, data]); - return { value, loading, error, retry }; + + const filteredValue = useMemo(() => { + if (!value || !searchQuery) { + return value; + } + + const lowerCaseQuery = searchQuery.toLowerCase(); + const filteredSuggestions = value.suggestions.filter( + (suggestion) => + suggestion.name.toLowerCase().includes(lowerCaseQuery) || + suggestion.pluginId.toLowerCase().includes(lowerCaseQuery) || + suggestion.description?.toLowerCase().includes(lowerCaseQuery) + ); + + return { + ...value, + suggestions: filteredSuggestions, + }; + }, [value, searchQuery]); + + return { value: filteredValue, loading, error, retry }; }; -export function VisualizationSuggestions({ onChange, data, panel }: Props) { +export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery }: Props) { const styles = useStyles2(getStyles); - const { value: result, loading, error, retry } = useSuggestions(data); + const { value: result, loading, error, retry } = useSuggestions(data, searchQuery); const suggestions = result?.suggestions; const hasLoadingErrors = result?.hasErrors ?? false; @@ -73,18 +97,21 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) { const applySuggestion = useCallback( (suggestion: PanelPluginVisualizationSuggestion, isPreview?: boolean) => { - onChange({ - pluginId: suggestion.pluginId, - options: suggestion.options, - fieldConfig: suggestion.fieldConfig, - withModKey: isPreview, - }); + onChange( + { + pluginId: suggestion.pluginId, + options: suggestion.options, + fieldConfig: suggestion.fieldConfig, + withModKey: isPreview, + }, + isPreview ? editPreview : undefined + ); if (isPreview) { setSuggestionHash(suggestion.hash); } }, - [onChange] + [onChange, editPreview] ); useEffect(() => { @@ -185,17 +212,13 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) { variant="primary" size={'md'} className={styles.applySuggestionButton} + data-testid={selectors.components.VisualizationPreview.confirm(suggestion.name)} aria-label={t( 'panel.visualization-suggestions.apply-suggestion-aria-label', 'Apply {{suggestionName}} visualization', { suggestionName: suggestion.name } )} - onClick={() => - onChange({ - pluginId: suggestion.pluginId, - withModKey: false, - }) - } + onClick={() => applySuggestion(suggestion, false)} > {t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')} 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/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts index 6a34d5d7d91..f4a5cc02dde 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts @@ -7,7 +7,7 @@ import { import { defaultBucketAgg } from '../../../../queryDef'; import { reducerTester } from '../../../reducerTester'; import { changeMetricType } from '../../MetricAggregationsEditor/state/actions'; -import { initQuery } from '../../state'; +import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; import { bucketAggregationConfig } from '../utils'; import { @@ -180,4 +180,27 @@ describe('Bucket Aggregations Reducer', () => { .thenStateShouldEqual([bucketAgg]); }); }); + + describe('When switching editor type', () => { + it('Should reset bucket aggregations to default when switching editor types', () => { + const defaultTimeField = '@timestamp'; + const initialState: BucketAggregation[] = [ + { + id: '1', + type: 'date_histogram', + field: '@timestamp', + }, + { + id: '2', + type: 'terms', + field: 'status', + }, + ]; + + reducerTester() + .givenReducer(createReducer(defaultTimeField), initialState) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('code')) + .thenStateShouldEqual([{ ...defaultBucketAgg('2'), field: defaultTimeField }]); + }); + }); }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts index b3638e1f1d1..5ba29e656d8 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts @@ -6,7 +6,7 @@ import { defaultBucketAgg } from '../../../../queryDef'; import { removeEmpty } from '../../../../utils'; import { changeMetricType } from '../../MetricAggregationsEditor/state/actions'; import { metricAggregationConfig } from '../../MetricAggregationsEditor/utils'; -import { initQuery } from '../../state'; +import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; import { bucketAggregationConfig } from '../utils'; import { @@ -87,6 +87,11 @@ export const createReducer = return state; } + if (changeEditorTypeAndResetQuery.match(action)) { + // Returns the default bucket agg. We will always want to set the default when switching types + return [{ ...defaultBucketAgg('2'), field: defaultTimeField }]; + } + if (changeBucketAggregationSetting.match(action)) { return state!.map((bucketAgg) => { if (bucketAgg.id !== action.payload.bucketAgg.id) { diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts index 5662ad399ea..9dcbaa9f974 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts @@ -7,7 +7,7 @@ import { import { defaultMetricAgg } from '../../../../queryDef'; import { reducerTester } from '../../../reducerTester'; -import { initQuery } from '../../state'; +import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; import { metricAggregationConfig } from '../utils'; import { @@ -248,4 +248,26 @@ describe('Metric Aggregations Reducer', () => { .whenActionIsDispatched(initQuery()) .thenStateShouldEqual([defaultMetricAgg('1')]); }); + + describe('When switching editor type', () => { + it('Should reset to single default metric when switching to code editor', () => { + const initialState: MetricAggregation[] = [ + { + id: '1', + type: 'avg', + field: 'value', + }, + { + id: '2', + type: 'max', + field: 'value', + }, + ]; + + reducerTester() + .givenReducer(reducer, initialState) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('code')) + .thenStateShouldEqual([defaultMetricAgg('1')]); + }); + }); }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts index 966bd71d6c8..c0dab7bd4b1 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts @@ -4,7 +4,7 @@ import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasourc import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef'; import { removeEmpty } from '../../../../utils'; -import { initQuery } from '../../state'; +import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations'; import { getChildren, metricAggregationConfig } from '../utils'; @@ -65,6 +65,11 @@ export const reducer = ( }); } + if (changeEditorTypeAndResetQuery.match(action)) { + // Reset to default metric when switching to editor types + return [defaultMetricAgg('1')]; + } + if (changeMetricField.match(action)) { return state!.map((metric) => { if (metric.id !== action.payload.id) { diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts index cad89cd32a7..111b284eb79 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts @@ -1,7 +1,15 @@ import { ElasticsearchDataQuery } from '../../dataquery.gen'; import { reducerTester } from '../reducerTester'; -import { aliasPatternReducer, changeAliasPattern, changeQuery, initQuery, queryReducer } from './state'; +import { + aliasPatternReducer, + changeAliasPattern, + changeEditorTypeAndResetQuery, + changeQuery, + initQuery, + queryReducer, + rawDSLQueryReducer, +} from './state'; describe('Query Reducer', () => { describe('On Init', () => { @@ -42,6 +50,17 @@ describe('Query Reducer', () => { .whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' }) .thenStateShouldEqual(initialState); }); + + describe('When switching editor type', () => { + it('Should clear query when switching editor types', () => { + const initialQuery: ElasticsearchDataQuery['query'] = 'Some lucene query'; + + reducerTester() + .givenReducer(queryReducer, initialQuery) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('code')) + .thenStateShouldEqual(''); + }); + }); }); describe('Alias Pattern Reducer', () => { @@ -62,4 +81,26 @@ describe('Alias Pattern Reducer', () => { .whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' }) .thenStateShouldEqual(initialState); }); + + describe('When switching editor type', () => { + it('Should clear alias when switching editor types', () => { + const initialAlias: ElasticsearchDataQuery['alias'] = 'Some alias pattern'; + + reducerTester() + .givenReducer(aliasPatternReducer, initialAlias) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('code')) + .thenStateShouldEqual(''); + }); + }); +}); + +describe('Raw DSL Query Reducer', () => { + it('Should clear raw DSL query when switching editor types', () => { + const initialRawQuery: ElasticsearchDataQuery['rawDSLQuery'] = '{"query": {"match_all": {}}}'; + + reducerTester() + .givenReducer(rawDSLQueryReducer, initialRawQuery) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('builder')) + .thenStateShouldEqual(''); + }); }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts index a9ed51b39ff..5a1be7be31c 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts @@ -58,6 +58,10 @@ export const aliasPatternReducer = (prevAliasPattern: ElasticsearchDataQuery['al return action.payload; } + if (changeEditorTypeAndResetQuery.match(action)) { + return ''; + } + if (initQuery.match(action)) { return prevAliasPattern || ''; } diff --git a/public/app/plugins/datasource/opentsdb/datasource.ts b/public/app/plugins/datasource/opentsdb/datasource.ts index 24356eefbac..da3473be8ad 100644 --- a/public/app/plugins/datasource/opentsdb/datasource.ts +++ b/public/app/plugins/datasource/opentsdb/datasource.ts @@ -77,8 +77,28 @@ export default class OpenTsDatasource extends DataSourceWithBackend): Observable { + if (options.targets.some((target: OpenTsdbQuery) => target.fromAnnotations)) { + const streams: Array> = []; + + for (const annotation of options.targets) { + if (annotation.target) { + streams.push( + new Observable((subscriber) => { + this.annotationEvent(options, annotation) + .then((events) => subscriber.next({ data: [toDataFrame(events)] })) + .catch((ex) => { + return subscriber.next({ data: [toDataFrame([])] }); + }) + .finally(() => subscriber.complete()); + }) + ); + } + } + + return merge(...streams); + } + if (config.featureToggles.opentsdbBackendMigration) { const hasValidTargets = options.targets.some((target) => target.metric && !target.hide); if (!hasValidTargets) { @@ -93,31 +113,6 @@ export default class OpenTsDatasource extends DataSourceWithBackend target.fromAnnotations)) { - const streams: Array> = []; - - for (const annotation of options.targets) { - if (annotation.target) { - streams.push( - new Observable((subscriber) => { - this.annotationEvent(options, annotation) - .then((events) => subscriber.next({ data: [toDataFrame(events)] })) - .catch((ex) => { - // grafana fetch throws the error so for annotation consistency among datasources - // we return an empty array which displays as 'no events found' - // in the annnotation editor - return subscriber.next({ data: [toDataFrame([])] }); - }) - .finally(() => subscriber.complete()); - }) - ); - } - } - - return merge(...streams); - } - const start = this.convertToTSDBTime(options.range.raw.from, false, options.timezone); const end = this.convertToTSDBTime(options.range.raw.to, true, options.timezone); const qs: any[] = []; @@ -181,6 +176,50 @@ export default class OpenTsDatasource extends DataSourceWithBackend { + if (config.featureToggles.opentsdbBackendMigration) { + const query: OpenTsdbQuery = { + refId: annotation.refId ?? 'Anno', + metric: annotation.target, + aggregator: 'sum', + fromAnnotations: true, + isGlobal: annotation.isGlobal, + disableDownsampling: true, + }; + + const queryRequest: DataQueryRequest = { + ...options, + targets: [query], + }; + + return lastValueFrom( + super.query(queryRequest).pipe( + map((response) => { + const eventList: AnnotationEvent[] = []; + + for (const frame of response.data) { + const annotationObject = annotation.isGlobal + ? frame.meta?.custom?.globalAnnotations + : frame.meta?.custom?.annotations; + + if (annotationObject && isArray(annotationObject)) { + annotationObject.forEach((ann) => { + const event: AnnotationEvent = { + text: ann.description, + time: Math.floor(ann.startTime) * 1000, + annotation: annotation, + }; + + eventList.push(event); + }); + } + } + + return eventList; + }) + ) + ); + } + const start = this.convertToTSDBTime(options.range.raw.from, false, options.timezone); const end = this.convertToTSDBTime(options.range.raw.to, true, options.timezone); const qs = []; @@ -306,6 +345,10 @@ export default class OpenTsDatasource extends DataSourceWithBackend { return key.trim(); }); @@ -337,6 +380,10 @@ export default class OpenTsDatasource extends DataSourceWithBackend { result = result.data.results; @@ -450,6 +497,11 @@ export default class OpenTsDatasource extends DataSourceWithBackend { @@ -468,6 +520,11 @@ export default class OpenTsDatasource extends DataSourceWithBackend { diff --git a/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json b/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json index 57e2bb5e47d..6c9831a5d2f 100644 --- a/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json +++ b/public/app/plugins/datasource/prometheus/dashboards/prometheus_2_stats.json @@ -179,15 +179,12 @@ "pluginVersion": "8.1.0-pre", "targets": [ { - "expr": "sum(irate(prometheus_tsdb_head_samples_appended_total{job=\"prometheus\"}[5m]))", + "expr": "sum(irate(prometheus_tsdb_head_samples_appended_total{job=\"prometheus\"}[$__rate_interval]))", "format": "time_series", "hide": false, - "interval": "", - "intervalFactor": 2, "legendFormat": "samples", "metric": "", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -272,12 +269,9 @@ { "expr": "topk(5, max(scrape_duration_seconds) by (job))", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "{{job}}", "metric": "", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -365,20 +359,15 @@ "expr": "sum(process_resident_memory_bytes{job=\"prometheus\"})", "format": "time_series", "hide": false, - "interval": "", - "intervalFactor": 2, "legendFormat": "p8s process resident memory", - "refId": "D", - "step": 20 + "refId": "D" }, { "expr": "process_virtual_memory_bytes{job=\"prometheus\"}", "format": "time_series", "hide": false, - "intervalFactor": 2, "legendFormat": "virtual memory", - "refId": "C", - "step": 20 + "refId": "C" } ], "timeFrom": null, @@ -453,10 +442,8 @@ { "expr": "prometheus_tsdb_wal_corruptions_total{job=\"prometheus\"}", "format": "time_series", - "intervalFactor": 2, "legendFormat": "", - "refId": "A", - "step": 60 + "refId": "A" } ], "title": "WAL Corruptions", @@ -539,21 +526,15 @@ { "expr": "sum(prometheus_tsdb_head_active_appenders{job=\"prometheus\"})", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "active_appenders", "metric": "", - "refId": "A", - "step": 20 + "refId": "A" }, { "expr": "sum(process_open_fds{job=\"prometheus\"})", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "open_fds", - "refId": "B", - "step": 20 + "refId": "B" } ], "timeFrom": null, @@ -669,10 +650,8 @@ { "expr": "prometheus_tsdb_blocks_loaded{job=\"prometheus\"}", "format": "time_series", - "intervalFactor": 2, "legendFormat": "blocks", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -758,11 +737,8 @@ { "expr": "prometheus_tsdb_head_chunks{job=\"prometheus\"}", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "chunks", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -861,18 +837,14 @@ { "expr": "prometheus_tsdb_head_gc_duration_seconds{job=\"prometheus\",quantile=\"0.99\"}", "format": "time_series", - "intervalFactor": 2, "legendFormat": "duration-p99", - "refId": "A", - "step": 20 + "refId": "A" }, { - "expr": "irate(prometheus_tsdb_head_gc_duration_seconds_count{job=\"prometheus\"}[5m])", + "expr": "irate(prometheus_tsdb_head_gc_duration_seconds_count{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "collections", - "refId": "B", - "step": 20 + "refId": "B" } ], "timeFrom": null, @@ -970,38 +942,29 @@ "pluginVersion": "8.1.0-pre", "targets": [ { - "expr": "histogram_quantile(0.99, sum(rate(prometheus_tsdb_compaction_duration_bucket{job=\"prometheus\"}[5m])) by (le))", + "expr": "histogram_quantile(0.99, sum(rate(prometheus_tsdb_compaction_duration_bucket{job=\"prometheus\"}[$__rate_interval])) by (le))", "format": "time_series", "hide": false, - "interval": "", - "intervalFactor": 2, "legendFormat": "duration-{{p99}}", - "refId": "A", - "step": 20 + "refId": "A" }, { - "expr": "irate(prometheus_tsdb_compactions_total{job=\"prometheus\"}[5m])", + "expr": "irate(prometheus_tsdb_compactions_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "compactions", - "refId": "B", - "step": 20 + "refId": "B" }, { - "expr": "irate(prometheus_tsdb_compactions_failed_total{job=\"prometheus\"}[5m])", + "expr": "irate(prometheus_tsdb_compactions_failed_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "failed", - "refId": "C", - "step": 20 + "refId": "C" }, { - "expr": "irate(prometheus_tsdb_compactions_triggered_total{job=\"prometheus\"}[5m])", + "expr": "irate(prometheus_tsdb_compactions_triggered_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "triggered", - "refId": "D", - "step": 20 + "refId": "D" } ], "timeFrom": null, @@ -1084,21 +1047,17 @@ "pluginVersion": "8.1.0-pre", "targets": [ { - "expr": "rate(prometheus_tsdb_reloads_total{job=\"prometheus\"}[5m])", + "expr": "rate(prometheus_tsdb_reloads_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "reloads", - "refId": "A", - "step": 20 + "refId": "A" }, { - "expr": "rate(prometheus_tsdb_reloads_failures_total{job=\"prometheus\"}[5m])", + "expr": "rate(prometheus_tsdb_reloads_failures_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", "hide": false, - "intervalFactor": 2, "legendFormat": "failures", - "refId": "B", - "step": 20 + "refId": "B" } ], "timeFrom": null, @@ -1183,10 +1142,8 @@ { "expr": "prometheus_engine_query_duration_seconds{job=\"prometheus\", quantile=\"0.99\"}", "format": "time_series", - "intervalFactor": 2, "legendFormat": "{{slice}}_p99", - "refId": "A", - "step": 20 + "refId": "A" } ], "timeFrom": null, @@ -1271,11 +1228,8 @@ { "expr": "max(prometheus_rule_group_duration_seconds{job=\"prometheus\"}) by (quantile)", "format": "time_series", - "interval": "", - "intervalFactor": 2, "legendFormat": "{{quantile}}", - "refId": "A", - "step": 10 + "refId": "A" } ], "timeFrom": null, @@ -1358,20 +1312,16 @@ "pluginVersion": "8.1.0-pre", "targets": [ { - "expr": "rate(prometheus_rule_group_iterations_missed_total{job=\"prometheus\"}[5m])", + "expr": "rate(prometheus_rule_group_iterations_missed_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "missed", - "refId": "B", - "step": 10 + "refId": "B" }, { - "expr": "rate(prometheus_rule_group_iterations_total{job=\"prometheus\"}[5m])", + "expr": "rate(prometheus_rule_group_iterations_total{job=\"prometheus\"}[$__rate_interval])", "format": "time_series", - "intervalFactor": 2, "legendFormat": "iterations", - "refId": "A", - "step": 10 + "refId": "A" } ], "timeFrom": null, diff --git a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx index 03232406463..42c70511977 100644 --- a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx +++ b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx @@ -52,6 +52,7 @@ export function RadialBarPanel({ nameManualFontSize={options.text?.titleSize} endpointMarker={options.endpointMarker !== 'none' ? options.endpointMarker : undefined} onClick={menuProps.openMenu} + textMode={options.textMode} /> ); } @@ -65,9 +66,7 @@ export function RadialBarPanel({ if (hasLinks && getLinks) { return ( - {(api) => { - return renderComponent(valueProps, api); - }} + {(api) => renderComponent(valueProps, api)} ); } diff --git a/public/app/plugins/panel/radialbar/__snapshots__/GaugeMigrations.test.ts.snap b/public/app/plugins/panel/radialbar/__snapshots__/migrations.test.ts.snap similarity index 100% rename from public/app/plugins/panel/radialbar/__snapshots__/GaugeMigrations.test.ts.snap rename to public/app/plugins/panel/radialbar/__snapshots__/migrations.test.ts.snap diff --git a/public/app/plugins/panel/radialbar/GaugeMigrations.test.ts b/public/app/plugins/panel/radialbar/migrations.test.ts similarity index 82% rename from public/app/plugins/panel/radialbar/GaugeMigrations.test.ts rename to public/app/plugins/panel/radialbar/migrations.test.ts index 8e870517f65..a09f1435e90 100644 --- a/public/app/plugins/panel/radialbar/GaugeMigrations.test.ts +++ b/public/app/plugins/panel/radialbar/migrations.test.ts @@ -1,7 +1,7 @@ import { PanelModel } from '@grafana/data'; import { FieldColorModeId } from '@grafana/schema/dist/esm/index.gen'; -import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './GaugeMigrations'; +import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './migrations'; describe('Gauge Panel Migrations', () => { it('from old gauge', () => { @@ -32,6 +32,51 @@ describe('Gauge Panel Migrations', () => { expect(result.sparkline).toBe(false); }); + it.each([ + { + textMode: 'value_and_name', + displayName: 'My gauge', + }, + { + textMode: undefined, + displayName: '', + }, + { + textMode: undefined, + displayName: null, + }, + { + textMode: undefined, + displayName: undefined, + }, + ])('sets the the text mode to "$textMode" for "$displayName"', ({ textMode, displayName }) => { + const panel = { + id: 2, + options: { + displayName, + reduceOptions: { + calcs: ['lastNotNull'], + }, + showThresholdLabels: false, + showThresholdMarkers: true, + }, + fieldConfig: { + defaults: { + color: { + mode: FieldColorModeId.Fixed, + fixedColor: 'blue', + }, + }, + overrides: [], + }, + pluginVersion: '12.3.0', + type: 'gauge', + } as Omit; + + const result = gaugePanelMigrationHandler(panel as PanelModel); + expect(result.textMode).toEqual(textMode); + }); + it('does not overwrite new gauge', () => { const panel = { id: 2, diff --git a/public/app/plugins/panel/radialbar/GaugeMigrations.ts b/public/app/plugins/panel/radialbar/migrations.ts similarity index 91% rename from public/app/plugins/panel/radialbar/GaugeMigrations.ts rename to public/app/plugins/panel/radialbar/migrations.ts index a64bc93b1cc..ec7840fe779 100644 --- a/public/app/plugins/panel/radialbar/GaugeMigrations.ts +++ b/public/app/plugins/panel/radialbar/migrations.ts @@ -21,6 +21,11 @@ export function gaugePanelMigrationHandler(panel: PanelModel): Partial< newOptions.sparkline = false; newOptions.effects = { gradient: false }; + // if a display name is set, set the appropriate text mode + if ('displayName' in newOptions && newOptions.displayName && newOptions.displayName !== '') { + newOptions.textMode = 'value_and_name'; + } + // Remove deprecated sizing options if ('sizing' in newOptions) { delete newOptions.sizing; diff --git a/public/app/plugins/panel/radialbar/module.tsx b/public/app/plugins/panel/radialbar/module.tsx index 3dfcb3e3d5f..f7afa0ef2c9 100644 --- a/public/app/plugins/panel/radialbar/module.tsx +++ b/public/app/plugins/panel/radialbar/module.tsx @@ -5,8 +5,8 @@ import { commonOptionsBuilder } from '@grafana/ui'; import { addOrientationOption, addStandardDataReduceOptions } from '../stat/common'; import { EffectsEditor } from './EffectsEditor'; -import { gaugePanelChangedHandler, gaugePanelMigrationHandler, shouldMigrateGauge } from './GaugeMigrations'; import { RadialBarPanel } from './RadialBarPanel'; +import { gaugePanelChangedHandler, gaugePanelMigrationHandler, shouldMigrateGauge } from './migrations'; import { defaultGaugePanelEffects, defaultOptions, Options } from './panelcfg.gen'; import { radialBarSuggestionsSupplier } from './suggestions'; @@ -99,6 +99,22 @@ export const plugin = new PanelPlugin(RadialBarPanel) showIf: (options) => options.barShape === 'rounded' && options.segmentCount === 1, }); + builder.addSelect({ + path: 'textMode', + name: t('radialbar.config.text-mode', 'Text mode'), + category, + settings: { + options: [ + { value: 'auto', label: t('radialbar.config.text-mode-auto', 'Auto') }, + { value: 'value_and_name', label: t('radialbar.config.text-mode-value-and-name', 'Value and Name') }, + { value: 'value', label: t('radialbar.config.text-mode-value', 'Value') }, + { value: 'name', label: t('radialbar.config.text-mode-name', 'Name') }, + { value: 'none', label: t('radialbar.config.text-mode-none', 'None') }, + ], + }, + defaultValue: defaultOptions.textMode, + }); + builder.addBooleanSwitch({ path: 'sparkline', name: t('radialbar.config.sparkline', 'Show sparkline'), diff --git a/public/app/plugins/panel/radialbar/panelcfg.cue b/public/app/plugins/panel/radialbar/panelcfg.cue index c959512c0b8..6e5fd7eec21 100644 --- a/public/app/plugins/panel/radialbar/panelcfg.cue +++ b/public/app/plugins/panel/radialbar/panelcfg.cue @@ -42,6 +42,7 @@ composableKinds: PanelCfg: { barWidthFactor: number | *0.5 barShape: "flat" | "rounded" | *"flat" endpointMarker?: "point" | "glow" | "none" | *"point" + textMode?: "auto" | "value_and_name" | "value" | "name" | "none" | *"auto" effects: GaugePanelEffects | *{} } @cuetsy(kind="interface") } diff --git a/public/app/plugins/panel/radialbar/panelcfg.gen.ts b/public/app/plugins/panel/radialbar/panelcfg.gen.ts index e050a044f77..594747136ef 100644 --- a/public/app/plugins/panel/radialbar/panelcfg.gen.ts +++ b/public/app/plugins/panel/radialbar/panelcfg.gen.ts @@ -33,6 +33,7 @@ export interface Options extends common.SingleStatBaseOptions { showThresholdLabels: boolean; showThresholdMarkers: boolean; sparkline?: boolean; + textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none'); } export const defaultOptions: Partial = { @@ -46,4 +47,5 @@ export const defaultOptions: Partial = { showThresholdLabels: false, showThresholdMarkers: true, sparkline: true, + textMode: 'auto', }; 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[] { diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 7ed8c4bf808..39d34f617a9 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -6009,6 +6009,9 @@ "name-values-separated-comma": "Hodnoty oddělené čárkou", "selection-options": "Možnosti výběru" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "Zpět na seznam", "label-icon": "Ikona", @@ -12620,6 +12623,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index a8d01ea13bb..a2a5dcbb9b4 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -5967,6 +5967,9 @@ "name-values-separated-comma": "Werte werden durch Komma getrennt", "selection-options": "Auswahloptionen" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "Zurück zur Liste", "label-icon": "Symbol", @@ -12516,6 +12519,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a51e48d0e7f..3883da6e44e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3135,9 +3135,12 @@ "table": "Table" }, "welcome-header": { + "description-alert-activity": "See what is currently alerting and explore historical data to investigate current or past issues.", "description-alert-rules": "Define the condition that must be met before an alert rule fires", "description-configure-firing-alert-instances-routed-contact": "Configure how firing alert instances are routed to contact points", "description-configure-receives-notifications": "Configure who receives notifications and how they are sent", + "href-text-alert-activity": "View alert activity", + "title-alert-activity": "Alert activity", "title-alert-rules": "Alert rules", "title-contact-points": "Contact points", "title-notification-policies": "Notification policies" @@ -5796,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:", @@ -5967,6 +5971,9 @@ "name-values-separated-comma": "Values separated by comma", "selection-options": "Selection options" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "Give feedback on the new dashboard editing experience" + }, "dashboard-link-form": { "back-to-list": "Back to list", "label-icon": "Icon", @@ -12516,6 +12523,12 @@ "shape-circle": "Circle", "shape-gauge": "Arc", "sparkline": "Show sparkline", + "text-mode": "Text mode", + "text-mode-auto": "Auto", + "text-mode-name": "Name", + "text-mode-none": "None", + "text-mode-value": "Value", + "text-mode-value-and-name": "Value and Name", "threshold-labels": "Show threshold labels", "threshold-markers": "Show thresholds" } @@ -14387,6 +14400,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/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index a6f418cc1be..9a9cce82f5f 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -5967,6 +5967,9 @@ "name-values-separated-comma": "Valores separados por comas", "selection-options": "Opciones de selección" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "Regresar a la lista", "label-icon": "Icono", @@ -12516,6 +12519,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index b6e586e10db..11411891f38 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -5967,6 +5967,9 @@ "name-values-separated-comma": "Valeurs séparées par des virgules", "selection-options": "Options de sélection" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "Retour à la liste", "label-icon": "Icône", @@ -12516,6 +12519,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index d51785fdb8a..7a95606822e 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -5967,6 +5967,9 @@ "name-values-separated-comma": "Értékek vesszővel elválasztva", "selection-options": "Kijelölés beállításai" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "Vissza a listához", "label-icon": "Ikon", @@ -12516,6 +12519,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index c000333c5c2..250ee7778c1 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -5946,6 +5946,9 @@ "name-values-separated-comma": "Nilai dipisahkan dengan koma", "selection-options": "Opsi pemilihan" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "Kembali ke daftar", "label-icon": "Ikon", @@ -12464,6 +12467,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 5b5839ddf39..9db051b6aa6 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -5967,6 +5967,9 @@ "name-values-separated-comma": "Valori separati da virgola", "selection-options": "Seleziona opzioni" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "Torna all'elenco", "label-icon": "Icona", @@ -12516,6 +12519,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 85597e5cff8..c968ce753bc 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -5946,6 +5946,9 @@ "name-values-separated-comma": "カンマ区切りの値", "selection-options": "選択オプション" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "一覧に戻る", "label-icon": "アイコン", @@ -12464,6 +12467,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 25e6bea87a4..f7c6e29f8f4 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -5946,6 +5946,9 @@ "name-values-separated-comma": "쉼표로 구분된 값", "selection-options": "선택 옵션" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "목록으로 돌아가기", "label-icon": "아이콘", @@ -12464,6 +12467,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index b1f700e5957..e2c53a858f1 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -5967,6 +5967,9 @@ "name-values-separated-comma": "Waarden gescheiden door komma", "selection-options": "Selectiemogelijkheden" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "Terug naar lijst", "label-icon": "Pictogram", @@ -12516,6 +12519,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 2705b11c9ae..d174debb2cb 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -6009,6 +6009,9 @@ "name-values-separated-comma": "Wartości rozdzielone przecinkami", "selection-options": "Opcje wyboru" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "Powrót do listy", "label-icon": "Ikona", @@ -12620,6 +12623,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 250376a959f..550ffe30a1f 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -5967,6 +5967,9 @@ "name-values-separated-comma": "Valores separados por vírgula", "selection-options": "Opções de seleção" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "Voltar para a lista", "label-icon": "Ícone", @@ -12516,6 +12519,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index beb5f7d3de8..841d71ca037 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -5967,6 +5967,9 @@ "name-values-separated-comma": "Valores separados por vírgulas", "selection-options": "Opções de seleção" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "Voltar à lista", "label-icon": "Ícone", @@ -12516,6 +12519,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 8655e70fba0..19f0c4563d4 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -6009,6 +6009,9 @@ "name-values-separated-comma": "Значения, разделенные запятыми", "selection-options": "Параметры выбора" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "Назад к списку", "label-icon": "Значок", @@ -12620,6 +12623,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 4869152e5e8..383543aed5e 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -5967,6 +5967,9 @@ "name-values-separated-comma": "Värden åtskilda med kommatecken", "selection-options": "Urvalsalternativ" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "Tillbaka till listan", "label-icon": "Ikon", @@ -12516,6 +12519,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index ad54e0fd3e8..ff539a2a51f 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -5967,6 +5967,9 @@ "name-values-separated-comma": "Virgülle ayrılmış değerler", "selection-options": "Seçim ayarları" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "Listeye geri dön", "label-icon": "Simge", @@ -12516,6 +12519,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 7a4b67ba8e7..0c3a6206bd7 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -5946,6 +5946,9 @@ "name-values-separated-comma": "以逗号分隔的值", "selection-options": "选择内容选项" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "回到列表", "label-icon": "图标", @@ -12464,6 +12467,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 5e3786ac559..215ad1f150f 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -5946,6 +5946,9 @@ "name-values-separated-comma": "以逗號分隔的值", "selection-options": "選擇選項" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "" + }, "dashboard-link-form": { "back-to-list": "返回清單", "label-icon": "圖示", @@ -12464,6 +12467,12 @@ "shape-circle": "", "shape-gauge": "", "sparkline": "", + "text-mode": "", + "text-mode-auto": "", + "text-mode-name": "", + "text-mode-none": "", + "text-mode-value": "", + "text-mode-value-and-name": "", "threshold-labels": "", "threshold-markers": "" } diff --git a/public/vendor/bootstrap/bootstrap.js b/public/vendor/bootstrap/bootstrap.js deleted file mode 100644 index 8730550092a..00000000000 --- a/public/vendor/bootstrap/bootstrap.js +++ /dev/null @@ -1,1512 +0,0 @@ -/* =================================================== - * bootstrap-transition.js v2.3.2 - * http://getbootstrap.com/2.3.2/javascript.html#transitions - * =================================================== - * Copyright 2013 Twitter, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ========================================================== */ - - -!function($) { - - "use strict"; // jshint ;_; - - - /* CSS TRANSITION SUPPORT (http://www.modernizr.com/) - * ======================================================= */ - - $(function() { - - $.support.transition = (function() { - - var transitionEnd = (function() { - - var el = document.createElement('bootstrap') - , transEndEventNames = { - 'WebkitTransition': 'webkitTransitionEnd' - , 'MozTransition': 'transitionend' - , 'OTransition': 'oTransitionEnd otransitionend' - , 'transition': 'transitionend' - } - , name - - for (name in transEndEventNames) { - if (el.style[name] !== undefined) { - return transEndEventNames[name] - } - } - - }()) - - return transitionEnd && { - end: transitionEnd - } - - })() - - }) - -}(window.jQuery);/* ========================================================== - * bootstrap-alert.js v2.3.2 - * http://getbootstrap.com/2.3.2/javascript.html#alerts - * ========================================================== - * Copyright 2013 Twitter, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ========================================================== */ - - -!function($) { - - "use strict"; // jshint ;_; - - /* ============================================================ - * bootstrap-dropdown.js v2.3.2 - * http://getbootstrap.com/2.3.2/javascript.html#dropdowns - * ============================================================ - * Copyright 2013 Twitter, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ============================================================ */ - - - /* DROPDOWN CLASS DEFINITION - * ========================= */ - - var toggle = '[data-toggle=dropdown]' - , Dropdown = function(element) { - var $el = $(element).on('click.dropdown.data-api', this.toggle) - $('html').on('click.dropdown.data-api', function() { - $el.parent().removeClass('open') - }) - } - - Dropdown.prototype = { - - constructor: Dropdown - - , toggle: function(e) { - var $this = $(this) - , $parent - , isActive - - if ($this.is('.disabled, :disabled')) return - - $parent = getParent($this) - - isActive = $parent.hasClass('open') - - clearMenus() - - if (!isActive) { - if ('ontouchstart' in document.documentElement) { - // if mobile we we use a backdrop because click events don't delegate - $('