diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7843dca6c87..ef476f4d272 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -501,7 +501,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 @@ -520,7 +519,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 c1b2c7acd25..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-20251212143239-491433b332b7 // 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 760641a8857..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-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/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 0524e1a3852..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-20251212143239-491433b332b7 + 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 6e82a1dea7b..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-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/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/alerting/historian/pkg/app/notification/lokireader.go b/apps/alerting/historian/pkg/app/notification/lokireader.go index c26519e59b4..636ae083d91 100644 --- a/apps/alerting/historian/pkg/app/notification/lokireader.go +++ b/apps/alerting/historian/pkg/app/notification/lokireader.go @@ -31,6 +31,10 @@ const ( maxLimit = 1000 Namespace = "grafana" Subsystem = "alerting" + + // LogQL field path for alert rule UID after JSON parsing. + // Loki flattens nested JSON fields with underscores: alert.labels.__alert_rule_uid__ -> alert_labels___alert_rule_uid__ + lokiAlertRuleUIDField = "alert_labels___alert_rule_uid__" ) var ( @@ -111,13 +115,13 @@ func buildQuery(query Query) (string, error) { fmt.Sprintf(`%s=%q`, historian.LabelFrom, historian.LabelFromValue), } - if query.RuleUID != nil { - selectors = append(selectors, - fmt.Sprintf(`%s=%q`, historian.LabelRuleUID, *query.RuleUID)) - } - logql := fmt.Sprintf(`{%s} | json`, strings.Join(selectors, `,`)) + // Add ruleUID filter as JSON line filter if specified. + if query.RuleUID != nil && *query.RuleUID != "" { + logql += fmt.Sprintf(` | %s = %q`, lokiAlertRuleUIDField, *query.RuleUID) + } + // Add receiver filter if specified. if query.Receiver != nil && *query.Receiver != "" { logql += fmt.Sprintf(` | receiver = %q`, *query.Receiver) @@ -211,16 +215,13 @@ func parseLokiEntry(s lokiclient.Sample) (Entry, error) { groupLabels = make(map[string]string) } - alerts := make([]EntryAlert, len(lokiEntry.Alerts)) - for i, a := range lokiEntry.Alerts { - alerts[i] = EntryAlert{ - Status: a.Status, - Labels: a.Labels, - Annotations: a.Annotations, - StartsAt: a.StartsAt, - EndsAt: a.EndsAt, - } - } + alerts := []EntryAlert{{ + Status: lokiEntry.Alert.Status, + Labels: lokiEntry.Alert.Labels, + Annotations: lokiEntry.Alert.Annotations, + StartsAt: lokiEntry.Alert.StartsAt, + EndsAt: lokiEntry.Alert.EndsAt, + }} return Entry{ Timestamp: s.T, diff --git a/apps/alerting/historian/pkg/app/notification/lokireader_test.go b/apps/alerting/historian/pkg/app/notification/lokireader_test.go index 708c9d10df1..c9c35cb1e62 100644 --- a/apps/alerting/historian/pkg/app/notification/lokireader_test.go +++ b/apps/alerting/historian/pkg/app/notification/lokireader_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/grafana/alerting/models" "github.com/grafana/alerting/notify/historian" "github.com/grafana/alerting/notify/historian/lokiclient" "github.com/grafana/grafana-app-sdk/logging" @@ -133,9 +134,8 @@ func TestBuildQuery(t *testing.T) { query: Query{ RuleUID: stringPtr("test-rule-uid"), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid"`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with receiver filter", @@ -143,9 +143,8 @@ func TestBuildQuery(t *testing.T) { RuleUID: stringPtr("test-rule-uid"), Receiver: stringPtr("email-receiver"), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | receiver = "email-receiver"`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | receiver = "email-receiver"`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with status filter", @@ -153,9 +152,8 @@ func TestBuildQuery(t *testing.T) { RuleUID: stringPtr("test-rule-uid"), Status: createStatusPtr(v0alpha1.CreateNotificationqueryRequestNotificationStatusFiring), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | status = "firing"`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | status = "firing"`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with success outcome filter", @@ -163,9 +161,8 @@ func TestBuildQuery(t *testing.T) { RuleUID: stringPtr("test-rule-uid"), Outcome: outcomePtr(v0alpha1.CreateNotificationqueryRequestNotificationOutcomeSuccess), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | error = ""`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | error = ""`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with error outcome filter", @@ -173,9 +170,8 @@ func TestBuildQuery(t *testing.T) { RuleUID: stringPtr("test-rule-uid"), Outcome: outcomePtr(v0alpha1.CreateNotificationqueryRequestNotificationOutcomeError), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | error != ""`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | error != ""`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with many filters", @@ -185,9 +181,8 @@ func TestBuildQuery(t *testing.T) { Status: createStatusPtr(v0alpha1.CreateNotificationqueryRequestNotificationStatusResolved), Outcome: outcomePtr(v0alpha1.CreateNotificationqueryRequestNotificationOutcomeSuccess), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | receiver = "email-receiver" | status = "resolved" | error = ""`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | receiver = "email-receiver" | status = "resolved" | error = ""`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with group label matcher", @@ -277,19 +272,19 @@ func TestParseLokiEntry(t *testing.T) { GroupLabels: map[string]string{ "alertname": "test-alert", }, - Alerts: []historian.NotificationHistoryLokiEntryAlert{ - { - Status: "firing", - Labels: map[string]string{ - "severity": "critical", - }, - Annotations: map[string]string{ - "summary": "Test alert", - }, - StartsAt: now, - EndsAt: now.Add(1 * time.Hour), + Alert: historian.NotificationHistoryLokiEntryAlert{ + Status: "firing", + Labels: map[string]string{ + "severity": "critical", }, + Annotations: map[string]string{ + "summary": "Test alert", + }, + StartsAt: now, + EndsAt: now.Add(1 * time.Hour), }, + AlertIndex: 0, + AlertCount: 1, Retry: false, Duration: 100, PipelineTime: now, @@ -335,7 +330,9 @@ func TestParseLokiEntry(t *testing.T) { Error: "notification failed", GroupKey: "key:thing", GroupLabels: map[string]string{}, - Alerts: []historian.NotificationHistoryLokiEntryAlert{}, + Alert: historian.NotificationHistoryLokiEntryAlert{}, + AlertIndex: 0, + AlertCount: 1, PipelineTime: now, }), }, @@ -347,7 +344,7 @@ func TestParseLokiEntry(t *testing.T) { Outcome: OutcomeError, GroupKey: "key:thing", GroupLabels: map[string]string{}, - Alerts: []EntryAlert{}, + Alerts: []EntryAlert{{}}, Error: stringPtr("notification failed"), PipelineTime: now, }, @@ -365,7 +362,7 @@ func TestParseLokiEntry(t *testing.T) { Status: Status("firing"), Outcome: OutcomeSuccess, GroupLabels: map[string]string{}, - Alerts: []EntryAlert{}, + Alerts: []EntryAlert{{}}, PipelineTime: now, }, }, @@ -448,7 +445,9 @@ func TestLokiReader_RunQuery(t *testing.T) { Receiver: "receiver-1", Status: "firing", GroupLabels: map[string]string{}, - Alerts: []historian.NotificationHistoryLokiEntryAlert{}, + Alert: historian.NotificationHistoryLokiEntryAlert{}, + AlertIndex: 0, + AlertCount: 1, PipelineTime: now, }), }, @@ -459,7 +458,9 @@ func TestLokiReader_RunQuery(t *testing.T) { Receiver: "receiver-3", Status: "firing", GroupLabels: map[string]string{}, - Alerts: []historian.NotificationHistoryLokiEntryAlert{}, + Alert: historian.NotificationHistoryLokiEntryAlert{}, + AlertIndex: 0, + AlertCount: 1, PipelineTime: now, }), }, @@ -474,7 +475,9 @@ func TestLokiReader_RunQuery(t *testing.T) { Receiver: "receiver-2", Status: "firing", GroupLabels: map[string]string{}, - Alerts: []historian.NotificationHistoryLokiEntryAlert{}, + Alert: historian.NotificationHistoryLokiEntryAlert{}, + AlertIndex: 0, + AlertCount: 1, PipelineTime: now, }), }, @@ -546,19 +549,19 @@ func createMockLokiResponse(timestamp time.Time) lokiclient.QueryRes { GroupLabels: map[string]string{ "alertname": "test-alert", }, - Alerts: []historian.NotificationHistoryLokiEntryAlert{ - { - Status: "firing", - Labels: map[string]string{ - "severity": "critical", - }, - Annotations: map[string]string{ - "summary": "Test alert", - }, - StartsAt: timestamp, - EndsAt: timestamp.Add(1 * time.Hour), + Alert: historian.NotificationHistoryLokiEntryAlert{ + Status: "firing", + Labels: map[string]string{ + "severity": "critical", }, + Annotations: map[string]string{ + "summary": "Test alert", + }, + StartsAt: timestamp, + EndsAt: timestamp.Add(1 * time.Hour), }, + AlertIndex: 0, + AlertCount: 1, Retry: false, Duration: 100, PipelineTime: timestamp, @@ -587,10 +590,19 @@ func createLokiEntryJSONWithNilLabels(t *testing.T, timestamp time.Time) string "status": "firing", "error": "", "groupLabels": null, - "alerts": [], + "alert": {}, + "alertIndex": 0, + "alertCount": 1, "retry": false, "duration": 0, "pipelineTime": "%s" }`, timestamp.Format(time.RFC3339Nano)) return jsonStr } + +func TestRuleUIDLabelConstant(t *testing.T) { + // Verify that models.RuleUIDLabel has the expected value. + // If this changes in the alerting module, our LogQL field path constant will be incorrect + // and filtering for a single alert rule by its UID will break. + assert.Equal(t, "__alert_rule_uid__", models.RuleUIDLabel) +} 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/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.json index 7c7c479199d..62648d7a4aa 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.json @@ -71,12 +71,11 @@ "id": 1, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "rounded": true, - "spotlight": false, "gradient": false }, "orientation": "auto", @@ -150,12 +149,11 @@ "id": 4, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "rounded": true, - "spotlight": false, "gradient": false }, "orientation": "auto", @@ -229,12 +227,11 @@ "id": 3, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": false, "gradient": false }, "orientation": "auto", @@ -271,85 +268,6 @@ "title": "Center and bar glow", "type": "radialbar" }, - { - "datasource": { - "type": "grafana-testdata-datasource" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 4, - "x": 12, - "y": 1 - }, - "id": 5, - "maxDataPoints": 20, - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "rounded": true, - "spotlight": true, - "gradient": false - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "pluginVersion": "13.0.0-pre", - "targets": [ - { - "alias": "1", - "datasource": { - "type": "grafana-testdata-datasource" - }, - "max": 100, - "min": 1, - "noise": 22, - "refId": "A", - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - ], - "title": "Spotlight", - "type": "radialbar" - }, { "datasource": { "type": "grafana-testdata-datasource" @@ -391,10 +309,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -470,10 +387,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": false, - "spotlight": true, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -549,10 +465,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": false, - "spotlight": true, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -641,10 +556,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -720,10 +634,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -799,10 +712,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -878,10 +790,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -974,10 +885,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1053,10 +963,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1132,10 +1041,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1211,10 +1119,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1290,10 +1197,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1386,10 +1292,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1469,10 +1374,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1552,10 +1456,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1641,13 +1544,13 @@ "options": { "barWidth": 12, "barWidthFactor": 0.4, + "barShape": "rounded", "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1662,8 +1565,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1730,10 +1632,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1748,8 +1649,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1830,10 +1730,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1848,8 +1747,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1917,9 +1815,6 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "sparkline": false, - "spotlight": true, "gradient": true }, "glow": "both", @@ -1934,10 +1829,10 @@ "segmentCount": 12, "segmentSpacing": 0.3, "shape": "circle", + "barShape": "rounded", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2004,10 +1899,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2022,8 +1916,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2090,10 +1983,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2108,8 +2000,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.json index a3de6df336a..dda0fbcd432 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.json @@ -955,8 +955,6 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, "orientation": "auto", diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.bom-in-links.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.bom-in-links.json new file mode 100644 index 00000000000..86992c3380c --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.bom-in-links.json @@ -0,0 +1,142 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "bom-in-links-test", + "namespace": "org-1", + "labels": { + "test": "bom-stripping" + } + }, + "spec": { + "title": "BOM Stripping Test Dashboard", + "description": "Testing that BOM characters are stripped from URLs during conversion", + "schemaVersion": 42, + "tags": ["test", "bom"], + "editable": true, + "links": [ + { + "title": "Dashboard link with BOM", + "type": "link", + "url": "http://example.com?var=${datasource}&other=value", + "targetBlank": true, + "icon": "external link" + } + ], + "panels": [ + { + "id": 1, + "type": "table", + "title": "Panel with BOM in field config override links", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green"}, + {"color": "red", "value": 80} + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "server" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Override link with BOM", + "url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}&var-server=${__value.raw}" + } + ] + } + ] + } + ] + }, + "links": [ + { + "title": "Panel data link with BOM", + "url": "http://example.com/${__data.fields.cluster}&var=value", + "targetBlank": true + } + ], + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "test-ds" + } + } + ] + }, + { + "id": 2, + "type": "timeseries", + "title": "Panel with BOM in options dataLinks", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "options": { + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom" + }, + "dataLinks": [ + { + "title": "Options data link with BOM", + "url": "http://example.com?series=${__series.name}&time=${__value.time}", + "targetBlank": true + } + ] + }, + "fieldConfig": { + "defaults": { + "links": [ + { + "title": "Field config default link with BOM", + "url": "http://example.com?field=${__field.name}&value=${__value.raw}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "test-ds" + } + } + ] + } + ], + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m"] + } + } +} + diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.groupby.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.groupby.json new file mode 100644 index 00000000000..88527ef6036 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.groupby.json @@ -0,0 +1,166 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "groupby-test" + }, + "spec": { + "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": "prometheus", + "uid": "test-uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "editorMode": "code", + "expr": "sum(counters_requests)", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "works with group by var", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [ + { + "current": { + "text": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ], + "value": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ] + }, + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "name": "Group by", + "type": "groupby" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "groupby test", + "weekStart": "" + } + } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json index 89857905689..b1dbd3de041 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json @@ -120,7 +120,7 @@ "value": [ { "title": "filter", - "url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + "url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json index 13320b47904..9089dd1d1fb 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json @@ -124,7 +124,7 @@ "value": [ { "title": "filter", - "url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + "url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json index 2eeb3040e6d..66ce1cd0f3a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json @@ -77,13 +77,12 @@ "id": 1, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -156,13 +155,12 @@ "id": 4, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -235,13 +233,12 @@ "id": 3, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -277,85 +274,6 @@ "title": "Center and bar glow", "type": "radialbar" }, - { - "datasource": { - "type": "grafana-testdata-datasource" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 4, - "x": 12, - "y": 1 - }, - "id": 5, - "maxDataPoints": 20, - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "pluginVersion": "13.0.0-pre", - "targets": [ - { - "alias": "1", - "datasource": { - "type": "grafana-testdata-datasource" - }, - "max": 100, - "min": 1, - "noise": 22, - "refId": "A", - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - ], - "title": "Spotlight", - "type": "radialbar" - }, { "datasource": { "type": "grafana-testdata-datasource" @@ -393,13 +311,12 @@ "id": 8, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -472,13 +389,12 @@ "id": 22, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -551,13 +467,12 @@ "id": 23, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -643,13 +558,12 @@ "id": 18, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.1, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -722,13 +636,12 @@ "id": 19, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.32, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -801,13 +714,12 @@ "id": 20, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.57, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -880,13 +792,12 @@ "id": 21, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.8, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -976,13 +887,12 @@ "id": 25, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1055,13 +965,12 @@ "id": 26, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1134,13 +1043,12 @@ "id": 29, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1213,13 +1121,12 @@ "id": 30, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1292,13 +1199,12 @@ "id": 28, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1388,13 +1294,12 @@ "id": 32, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1471,13 +1376,12 @@ "id": 34, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1554,13 +1458,12 @@ "id": 33, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1645,15 +1548,15 @@ "id": 9, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1668,8 +1571,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1731,14 +1633,13 @@ "id": 11, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1754,8 +1655,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1831,14 +1731,13 @@ "id": 13, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1854,8 +1753,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1918,15 +1816,13 @@ "id": 14, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "sparkline": false, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1942,8 +1838,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2005,14 +1900,13 @@ "id": 15, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.84, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -2028,8 +1922,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2091,14 +1984,13 @@ "id": 16, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.66, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -2114,8 +2006,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json index 4aecf4e0d9c..95850646c59 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json @@ -73,13 +73,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -165,14 +164,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -188,8 +186,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "fieldConfig": { "defaults": { @@ -262,14 +259,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -285,8 +281,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -360,15 +355,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "sparkline": false, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -384,8 +377,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -459,14 +451,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.84, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -482,8 +473,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -556,14 +546,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.66, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -579,8 +568,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -653,13 +641,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.1, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -745,13 +732,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.32, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -837,13 +823,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.57, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -929,13 +914,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.8, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1021,13 +1005,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1113,13 +1096,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1201,13 +1183,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1293,13 +1274,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1385,13 +1365,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1477,13 +1456,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1573,13 +1551,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1661,13 +1638,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1753,13 +1729,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1849,13 +1824,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1945,13 +1919,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2045,105 +2018,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "fieldConfig": { - "defaults": { - "min": 0, - "max": 100, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "value": 0, - "color": "green" - }, - { - "value": 80, - "color": "red" - } - ] - }, - "color": { - "mode": "thresholds" - } - }, - "overrides": [] - } - } - } - } - }, - "panel-5": { - "kind": "Panel", - "spec": { - "id": 5, - "title": "Spotlight", - "description": "", - "links": [], - "data": { - "kind": "QueryGroup", - "spec": { - "queries": [ - { - "kind": "PanelQuery", - "spec": { - "query": { - "kind": "grafana-testdata-datasource", - "spec": { - "alias": "1", - "max": 100, - "min": 1, - "noise": 22, - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - }, - "refId": "A", - "hidden": false - } - } - ], - "transformations": [], - "queryOptions": { - "maxDataPoints": 20 - } - } - }, - "vizConfig": { - "kind": "radialbar", - "spec": { - "pluginVersion": "13.0.0-pre", - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -2229,13 +2109,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -2321,15 +2200,15 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2344,8 +2223,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -2429,19 +2307,6 @@ } } }, - { - "kind": "GridLayoutItem", - "spec": { - "x": 12, - "y": 0, - "width": 4, - "height": 6, - "element": { - "kind": "ElementReference", - "name": "panel-5" - } - } - }, { "kind": "GridLayoutItem", "spec": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json index 6b567f19b5e..fda0d31e71b 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json @@ -77,13 +77,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -172,14 +171,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -195,8 +193,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "fieldConfig": { "defaults": { @@ -272,14 +269,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -295,8 +291,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -373,15 +368,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "sparkline": false, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -397,8 +390,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -475,14 +467,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.84, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -498,8 +489,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -575,14 +565,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.66, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -598,8 +587,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -675,13 +663,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.1, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -770,13 +757,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.32, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -865,13 +851,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.57, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -960,13 +945,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.8, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1055,13 +1039,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1150,13 +1133,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1241,13 +1223,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1336,13 +1317,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1431,13 +1411,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1526,13 +1505,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1625,13 +1603,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1716,13 +1693,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1811,13 +1787,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1910,13 +1885,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2009,13 +1983,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2112,108 +2085,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "fieldConfig": { - "defaults": { - "min": 0, - "max": 100, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "value": 0, - "color": "green" - }, - { - "value": 80, - "color": "red" - } - ] - }, - "color": { - "mode": "thresholds" - } - }, - "overrides": [] - } - } - } - } - }, - "panel-5": { - "kind": "Panel", - "spec": { - "id": 5, - "title": "Spotlight", - "description": "", - "links": [], - "data": { - "kind": "QueryGroup", - "spec": { - "queries": [ - { - "kind": "PanelQuery", - "spec": { - "query": { - "kind": "DataQuery", - "group": "grafana-testdata-datasource", - "version": "v0", - "spec": { - "alias": "1", - "max": 100, - "min": 1, - "noise": 22, - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - }, - "refId": "A", - "hidden": false - } - } - ], - "transformations": [], - "queryOptions": { - "maxDataPoints": 20 - } - } - }, - "vizConfig": { - "kind": "VizConfig", - "group": "radialbar", - "version": "13.0.0-pre", - "spec": { - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -2302,13 +2179,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -2397,15 +2273,15 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2420,8 +2296,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -2505,19 +2380,6 @@ } } }, - { - "kind": "GridLayoutItem", - "spec": { - "x": 12, - "y": 0, - "width": 4, - "height": 6, - "element": { - "kind": "ElementReference", - "name": "panel-5" - } - } - }, { "kind": "GridLayoutItem", "spec": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json index 959f0193ad6..2dddd657c5f 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json @@ -961,9 +961,7 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json index 66b29e88d13..db19ac588c1 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json @@ -864,9 +864,7 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json index b870d0a91ad..8ddc6feb297 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json @@ -901,9 +901,7 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v0alpha1.json new file mode 100644 index 00000000000..449e76f1173 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v0alpha1.json @@ -0,0 +1,161 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v0alpha1", + "metadata": { + "name": "bom-in-links-test", + "namespace": "org-1", + "labels": { + "test": "bom-stripping" + } + }, + "spec": { + "description": "Testing that BOM characters are stripped from URLs during conversion", + "editable": true, + "links": [ + { + "icon": "external link", + "targetBlank": true, + "title": "Dashboard link with BOM", + "type": "link", + "url": "http://example.com?var=${datasource}\u0026other=value" + } + ], + "panels": [ + { + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "server" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Override link with BOM", + "url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + } + ] + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "links": [ + { + "targetBlank": true, + "title": "Panel data link with BOM", + "url": "http://example.com/${__data.fields.cluster}\u0026var=value" + } + ], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-ds" + }, + "refId": "A" + } + ], + "title": "Panel with BOM in field config override links", + "type": "table" + }, + { + "fieldConfig": { + "defaults": { + "links": [ + { + "targetBlank": false, + "title": "Field config default link with BOM", + "url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}" + } + ] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "dataLinks": [ + { + "targetBlank": true, + "title": "Options data link with BOM", + "url": "http://example.com?series=${__series.name}\u0026time=${__value.time}" + } + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-ds" + }, + "refId": "A" + } + ], + "title": "Panel with BOM in options dataLinks", + "type": "timeseries" + } + ], + "schemaVersion": 42, + "tags": [ + "test", + "bom" + ], + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m" + ] + }, + "title": "BOM Stripping Test Dashboard" + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2alpha1.json new file mode 100644 index 00000000000..38547ea5b8e --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2alpha1.json @@ -0,0 +1,242 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2alpha1", + "metadata": { + "name": "bom-in-links-test", + "namespace": "org-1", + "labels": { + "test": "bom-stripping" + } + }, + "spec": { + "annotations": [], + "cursorSync": "Off", + "description": "Testing that BOM characters are stripped from URLs during conversion", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "Panel with BOM in field config override links", + "description": "", + "links": [ + { + "title": "Panel data link with BOM", + "url": "http://example.com/${__data.fields.cluster}\u0026var=value", + "targetBlank": true + } + ], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": {} + }, + "datasource": { + "type": "prometheus", + "uid": "test-ds" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "table", + "spec": { + "pluginVersion": "", + "options": {}, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "server" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Override link with BOM", + "url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + } + ] + } + ] + } + ] + } + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "Panel with BOM in options dataLinks", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": {} + }, + "datasource": { + "type": "prometheus", + "uid": "test-ds" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "", + "options": { + "dataLinks": [ + { + "targetBlank": true, + "title": "Options data link with BOM", + "url": "http://example.com?series=${__series.name}\u0026time=${__value.time}" + } + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + } + }, + "fieldConfig": { + "defaults": { + "links": [ + { + "targetBlank": false, + "title": "Field config default link with BOM", + "url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}" + } + ] + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + } + ] + } + }, + "links": [ + { + "title": "Dashboard link with BOM", + "type": "link", + "icon": "external link", + "tooltip": "", + "url": "http://example.com?var=${datasource}\u0026other=value", + "tags": [], + "asDropdown": false, + "targetBlank": true, + "includeVars": false, + "keepTime": false + } + ], + "liveNow": false, + "preload": false, + "tags": [ + "test", + "bom" + ], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "BOM Stripping Test Dashboard", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2beta1.json new file mode 100644 index 00000000000..d85da89fe7a --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2beta1.json @@ -0,0 +1,246 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2beta1", + "metadata": { + "name": "bom-in-links-test", + "namespace": "org-1", + "labels": { + "test": "bom-stripping" + } + }, + "spec": { + "annotations": [], + "cursorSync": "Off", + "description": "Testing that BOM characters are stripped from URLs during conversion", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "Panel with BOM in field config override links", + "description": "", + "links": [ + { + "title": "Panel data link with BOM", + "url": "http://example.com/${__data.fields.cluster}\u0026var=value", + "targetBlank": true + } + ], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "test-ds" + }, + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "table", + "version": "", + "spec": { + "options": {}, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "server" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Override link with BOM", + "url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + } + ] + } + ] + } + ] + } + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "Panel with BOM in options dataLinks", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "test-ds" + }, + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "", + "spec": { + "options": { + "dataLinks": [ + { + "targetBlank": true, + "title": "Options data link with BOM", + "url": "http://example.com?series=${__series.name}\u0026time=${__value.time}" + } + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + } + }, + "fieldConfig": { + "defaults": { + "links": [ + { + "targetBlank": false, + "title": "Field config default link with BOM", + "url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}" + } + ] + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + } + ] + } + }, + "links": [ + { + "title": "Dashboard link with BOM", + "type": "link", + "icon": "external link", + "tooltip": "", + "url": "http://example.com?var=${datasource}\u0026other=value", + "tags": [], + "asDropdown": false, + "targetBlank": true, + "includeVars": false, + "keepTime": false + } + ], + "liveNow": false, + "preload": false, + "tags": [ + "test", + "bom" + ], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "BOM Stripping Test Dashboard", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v0alpha1.json new file mode 100644 index 00000000000..463d1864dce --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v0alpha1.json @@ -0,0 +1,172 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v0alpha1", + "metadata": { + "name": "groupby-test" + }, + "spec": { + "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": "prometheus", + "uid": "test-uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "editorMode": "code", + "expr": "sum(counters_requests)", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "works with group by var", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [ + { + "current": { + "text": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ], + "value": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ] + }, + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "name": "Group by", + "type": "groupby" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "groupby test", + "weekStart": "" + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2alpha1.json new file mode 100644 index 00000000000..58bf555354c --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2alpha1.json @@ -0,0 +1,229 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v2alpha1", + "metadata": { + "name": "groupby-test" + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "query": { + "kind": "grafana", + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "works with group by var", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "editorMode": "code", + "expr": "sum(counters_requests)", + "legendFormat": "__auto", + "range": true + } + }, + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "12.4.0-pre", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "groupby test", + "variables": [ + { + "kind": "GroupByVariable", + "spec": { + "name": "Group by", + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "current": { + "text": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ], + "value": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ] + }, + "options": [], + "multi": true, + "hide": "dontHide", + "skipUrlSync": false + } + } + ] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2beta1.json new file mode 100644 index 00000000000..c2ac16a874c --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2beta1.json @@ -0,0 +1,232 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v2beta1", + "metadata": { + "name": "groupby-test" + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana", + "version": "v0", + "datasource": { + "name": "-- Grafana --" + }, + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "works with group by var", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "test-uid" + }, + "spec": { + "editorMode": "code", + "expr": "sum(counters_requests)", + "legendFormat": "__auto", + "range": true + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "12.4.0-pre", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "groupby test", + "variables": [ + { + "kind": "GroupByVariable", + "group": "prometheus", + "datasource": { + "name": "test-uid" + }, + "spec": { + "name": "Group by", + "current": { + "text": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ], + "value": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ] + }, + "options": [], + "multi": true, + "hide": "dontHide", + "skipUrlSync": false + } + } + ] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 92c5d937fe7..b63e0146cc2 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -229,6 +229,36 @@ func getBoolField(m map[string]interface{}, key string, defaultValue bool) bool return defaultValue } +// stripBOM removes Byte Order Mark (BOM) characters from a string. +// BOMs (U+FEFF) can be introduced through copy/paste from certain editors +// and cause CUE validation errors ("illegal byte order mark"). +func stripBOM(s string) string { + return strings.ReplaceAll(s, "\ufeff", "") +} + +// stripBOMFromInterface recursively strips BOM characters from all strings +// in an interface{} value (map, slice, or string). +func stripBOMFromInterface(v interface{}) interface{} { + switch val := v.(type) { + case string: + return stripBOM(val) + case map[string]interface{}: + result := make(map[string]interface{}, len(val)) + for k, v := range val { + result[k] = stripBOMFromInterface(v) + } + return result + case []interface{}: + result := make([]interface{}, len(val)) + for i, item := range val { + result[i] = stripBOMFromInterface(item) + } + return result + default: + return v + } +} + func getUnionField[T ~string](m map[string]interface{}, key string) *T { if val, ok := m[key]; ok { if str, ok := val.(string); ok && str != "" { @@ -393,7 +423,8 @@ func transformLinks(dashboard map[string]interface{}) []dashv2alpha1.DashboardDa // Optional field - only set if present if url, exists := linkMap["url"]; exists { if urlStr, ok := url.(string); ok { - dashLink.Url = &urlStr + cleanUrl := stripBOM(urlStr) + dashLink.Url = &cleanUrl } } @@ -1734,7 +1765,9 @@ func buildGroupByVariable(ctx context.Context, varMap map[string]interface{}, co Hide: commonProps.Hide, SkipUrlSync: commonProps.SkipUrlSync, Current: buildVariableCurrent(varMap["current"]), - Multi: getBoolField(varMap, "multi", false), + // We set it to true by default because GroupByVariable + // constructor defaults to multi: true + Multi: getBoolField(varMap, "multi", true), }, } @@ -2237,7 +2270,7 @@ func transformDataLinks(panelMap map[string]interface{}) []dashv2alpha1.Dashboar if linkMap, ok := link.(map[string]interface{}); ok { dataLink := dashv2alpha1.DashboardDataLink{ Title: schemaversion.GetStringValue(linkMap, "title"), - Url: schemaversion.GetStringValue(linkMap, "url"), + Url: stripBOM(schemaversion.GetStringValue(linkMap, "url")), } if _, exists := linkMap["targetBlank"]; exists { targetBlank := getBoolField(linkMap, "targetBlank", false) @@ -2329,6 +2362,12 @@ func buildVizConfig(panelMap map[string]interface{}) dashv2alpha1.DashboardVizCo } } + // Strip BOMs from options (may contain dataLinks with URLs that have BOMs) + cleanedOptions := stripBOMFromInterface(options) + if cleanedMap, ok := cleanedOptions.(map[string]interface{}); ok { + options = cleanedMap + } + // Build field config by mapping each field individually fieldConfigSource := extractFieldConfigSource(fieldConfig) @@ -2472,9 +2511,14 @@ func extractFieldConfigDefaults(defaults map[string]interface{}) dashv2alpha1.Da hasDefaults = true } - // Extract array field + // Extract array field - strip BOMs from link URLs if linksArray, ok := extractArrayField(defaults, "links"); ok { - fieldConfigDefaults.Links = linksArray + cleanedLinks := stripBOMFromInterface(linksArray) + if cleanedArray, ok := cleanedLinks.([]interface{}); ok { + fieldConfigDefaults.Links = cleanedArray + } else { + fieldConfigDefaults.Links = linksArray + } hasDefaults = true } @@ -2760,9 +2804,11 @@ func extractFieldConfigOverrides(fieldConfig map[string]interface{}) []dashv2alp fieldOverride.Properties = make([]dashv2alpha1.DashboardDynamicConfigValue, 0, len(propertiesArray)) for _, property := range propertiesArray { if propertyMap, ok := property.(map[string]interface{}); ok { + // Strip BOMs from property values (may contain links with URLs) + cleanedValue := stripBOMFromInterface(propertyMap["value"]) fieldOverride.Properties = append(fieldOverride.Properties, dashv2alpha1.DashboardDynamicConfigValue{ Id: schemaversion.GetStringValue(propertyMap, "id"), - Value: propertyMap["value"], + Value: cleanedValue, }) } } 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..a705218273e 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" }, { @@ -1179,4 +1193,4 @@ "title": "Panel Tests - Gauge", "uid": "_5rDmaQiz", "weekStart": "" -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json index a89d8744f39..f24ec9a61a0 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 @@ -75,10 +75,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -154,10 +153,9 @@ "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -233,10 +231,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -305,85 +302,6 @@ "x": 12, "y": 1 }, - "id": 5, - "maxDataPoints": 20, - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "pluginVersion": "13.0.0-pre", - "targets": [ - { - "alias": "1", - "datasource": { - "type": "grafana-testdata-datasource" - }, - "max": 100, - "min": 1, - "noise": 22, - "refId": "A", - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - ], - "title": "Spotlight", - "type": "radialbar" - }, - { - "datasource": { - "type": "grafana-testdata-datasource" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 4, - "x": 16, - "y": 1 - }, "id": 8, "maxDataPoints": 20, "options": { @@ -391,10 +309,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -460,8 +377,8 @@ "gridPos": { "h": 6, "w": 4, - "x": 0, - "y": 7 + "x": 16, + "y": 1 }, "id": 22, "maxDataPoints": 20, @@ -470,10 +387,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -539,8 +455,8 @@ "gridPos": { "h": 6, "w": 4, - "x": 4, - "y": 7 + "x": 20, + "y": 1 }, "id": 23, "maxDataPoints": 20, @@ -549,10 +465,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -593,7 +508,7 @@ "h": 1, "w": 24, "x": 0, - "y": 13 + "y": 7 }, "id": 17, "panels": [], @@ -630,9 +545,9 @@ }, "gridPos": { "h": 6, - "w": 5, + "w": 4, "x": 0, - "y": 14 + "y": 8 }, "id": 18, "maxDataPoints": 20, @@ -641,10 +556,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -709,9 +623,9 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 5, - "y": 14 + "w": 4, + "x": 4, + "y": 8 }, "id": 19, "maxDataPoints": 20, @@ -720,10 +634,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -788,9 +701,9 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 10, - "y": 14 + "w": 4, + "x": 8, + "y": 8 }, "id": 20, "maxDataPoints": 20, @@ -799,10 +712,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -867,9 +779,9 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 15, - "y": 14 + "w": 4, + "x": 12, + "y": 8 }, "id": 21, "maxDataPoints": 20, @@ -878,10 +790,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -922,7 +833,7 @@ "h": 1, "w": 24, "x": 0, - "y": 20 + "y": 14 }, "id": 24, "panels": [], @@ -963,9 +874,9 @@ }, "gridPos": { "h": 6, - "w": 6, + "w": 4, "x": 0, - "y": 21 + "y": 15 }, "id": 25, "maxDataPoints": 20, @@ -974,10 +885,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1042,9 +952,9 @@ }, "gridPos": { "h": 6, - "w": 6, - "x": 6, - "y": 21 + "w": 4, + "x": 4, + "y": 15 }, "id": 26, "maxDataPoints": 20, @@ -1053,10 +963,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1121,9 +1030,9 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 12, - "y": 21 + "w": 4, + "x": 8, + "y": 15 }, "id": 29, "maxDataPoints": 20, @@ -1132,10 +1041,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1199,10 +1107,10 @@ "overrides": [] }, "gridPos": { - "h": 7, - "w": 6, - "x": 0, - "y": 27 + "h": 6, + "w": 4, + "x": 12, + "y": 15 }, "id": 30, "maxDataPoints": 20, @@ -1211,10 +1119,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1278,10 +1185,10 @@ "overrides": [] }, "gridPos": { - "h": 7, - "w": 6, - "x": 6, - "y": 27 + "h": 6, + "w": 4, + "x": 16, + "y": 15 }, "id": 28, "maxDataPoints": 20, @@ -1290,10 +1197,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1330,7 +1236,7 @@ "h": 1, "w": 24, "x": 0, - "y": 34 + "y": 21 }, "id": 31, "panels": [], @@ -1377,7 +1283,7 @@ "h": 10, "w": 7, "x": 0, - "y": 35 + "y": 22 }, "id": 32, "maxDataPoints": 20, @@ -1386,10 +1292,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1460,7 +1365,7 @@ "h": 10, "w": 7, "x": 7, - "y": 35 + "y": 22 }, "id": 34, "maxDataPoints": 20, @@ -1469,10 +1374,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1543,7 +1447,7 @@ "h": 10, "w": 6, "x": 14, - "y": 35 + "y": 22 }, "id": 33, "maxDataPoints": 20, @@ -1552,10 +1456,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1592,7 +1495,7 @@ "h": 1, "w": 24, "x": 0, - "y": 45 + "y": 32 }, "id": 6, "panels": [], @@ -1633,20 +1536,20 @@ "h": 6, "w": 24, "x": 0, - "y": 46 + "y": 33 }, "id": 9, "maxDataPoints": 20, "options": { "barWidth": 12, "barWidthFactor": 0.4, + "barShape": "rounded", "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1661,8 +1564,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1717,7 +1619,7 @@ "h": 6, "w": 24, "x": 0, - "y": 52 + "y": 39 }, "id": 11, "maxDataPoints": 20, @@ -1727,10 +1629,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1745,8 +1646,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1773,7 +1673,7 @@ "h": 1, "w": 24, "x": 0, - "y": 58 + "y": 45 }, "id": 12, "panels": [], @@ -1815,7 +1715,7 @@ "h": 7, "w": 4, "x": 0, - "y": 59 + "y": 46 }, "id": 13, "maxDataPoints": 20, @@ -1825,10 +1725,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1843,8 +1742,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1862,6 +1760,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" }, @@ -1900,7 +1814,7 @@ "h": 7, "w": 5, "x": 4, - "y": 59 + "y": 46 }, "id": 14, "maxDataPoints": 20, @@ -1910,10 +1824,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1928,8 +1841,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1947,6 +1859,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" }, @@ -1984,7 +1912,7 @@ "h": 7, "w": 5, "x": 9, - "y": 59 + "y": 46 }, "id": 15, "maxDataPoints": 20, @@ -1994,10 +1922,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2012,8 +1939,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2068,7 +1994,7 @@ "h": 7, "w": 6, "x": 14, - "y": 59 + "y": 46 }, "id": 16, "maxDataPoints": 20, @@ -2078,10 +2004,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2096,8 +2021,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2124,7 +2048,7 @@ "h": 1, "w": 24, "x": 0, - "y": 66 + "y": 53 }, "id": 35, "panels": [], @@ -2155,10 +2079,10 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 6, + "h": 5, + "w": 12, "x": 0, - "y": 67 + "y": 54 }, "id": 36, "options": { @@ -2166,10 +2090,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -2223,10 +2146,10 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 6, - "x": 6, - "y": 67 + "h": 5, + "w": 12, + "x": 12, + "y": 54 }, "id": 37, "options": { @@ -2234,10 +2157,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -2279,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 4a5ac97a6b5..a75a58b7530 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", @@ -955,9 +956,7 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1162,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 24769ca825f..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-20251212143239-491433b332b7 // 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 4584bbd9cc1..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-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/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/README.md b/apps/plugins/README.md new file mode 100644 index 00000000000..7f91dd6ea12 --- /dev/null +++ b/apps/plugins/README.md @@ -0,0 +1,20 @@ +# Plugins App + +API documentation is available at http://localhost:3000/swagger?api=plugins.grafana.app-v0alpha1 + +## Codegen + +- Go: `make generate` +- Frontend: Follow instructions in this [README](../..//packages/grafana-api-clients/README.md) + +## Plugin sync + +The plugin sync pushes the plugins loaded from disk to the plugins API. + +To enable, add these feature toggles in your `custom.ini`: + +```ini +[feature_toggles] +pluginInstallAPISync = true +pluginStoreServiceLoading = true +``` diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index edcf18ea3e3..9a3e3776efb 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -23,6 +23,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 +91,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-20251212143239-491433b332b7 // 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 @@ -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 c5fbc7a39a5..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-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/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/conf/defaults.ini b/conf/defaults.ini index 28cd0420eb2..363ca39d0c4 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1653,6 +1653,11 @@ loki_basic_auth_password = # Accepts duration formats like: 30s, 1m, 1h. rule_query_offset = 1m +# Default data source UID to use for query execution when importing Prometheus rules. +# This default is used when the X-Grafana-Alerting-Datasource-UID header is not provided. +# If not set, the header becomes required. +default_datasource_uid = + [recording_rules] # Enable recording rules. enabled = true diff --git a/conf/sample.ini b/conf/sample.ini index 0bb5b82fdc9..530b14c87ac 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -1615,6 +1615,11 @@ max_annotations_to_keep = # Accepts duration formats like: 30s, 1m, 1h. rule_query_offset = 1m +# Default data source UID to use for query execution when importing Prometheus rules. +# This default is used when the X-Grafana-Alerting-Datasource-UID header is not provided. +# If not set, the header becomes required. +default_datasource_uid = + #################################### Recording Rules ##################### [recording_rules] # Enable recording rules. 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 b3c47c9aa7a..ff69226fdf5 100644 --- a/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json +++ b/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json @@ -71,13 +71,12 @@ "id": 1, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -148,13 +147,12 @@ "id": 4, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -225,13 +223,12 @@ "id": 3, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -299,93 +296,15 @@ "x": 12, "y": 1 }, - "id": 5, - "maxDataPoints": 20, - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true - }, - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "pluginVersion": "13.0.0-pre", - "targets": [ - { - "alias": "1", - "datasource": { - "type": "grafana-testdata-datasource" - }, - "max": 100, - "min": 1, - "noise": 22, - "refId": "A", - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - ], - "title": "Spotlight", - "type": "radialbar" - }, - { - "datasource": { - "type": "grafana-testdata-datasource" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 4, - "x": 16, - "y": 1 - }, "id": 8, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -450,19 +369,18 @@ "gridPos": { "h": 6, "w": 4, - "x": 0, - "y": 7 + "x": 16, + "y": 1 }, "id": 22, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -527,19 +445,18 @@ "gridPos": { "h": 6, "w": 4, - "x": 4, - "y": 7 + "x": 20, + "y": 1 }, "id": 23, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -579,7 +496,7 @@ "h": 1, "w": 24, "x": 0, - "y": 13 + "y": 7 }, "id": 17, "panels": [], @@ -616,20 +533,19 @@ }, "gridPos": { "h": 6, - "w": 5, + "w": 4, "x": 0, - "y": 14 + "y": 8 }, "id": 18, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.1, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -693,20 +609,19 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 5, - "y": 14 + "w": 4, + "x": 4, + "y": 8 }, "id": 19, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.32, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -770,20 +685,19 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 10, - "y": 14 + "w": 4, + "x": 8, + "y": 8 }, "id": 20, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.57, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -847,20 +761,19 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 15, - "y": 14 + "w": 4, + "x": 12, + "y": 8 }, "id": 21, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.8, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -900,7 +813,7 @@ "h": 1, "w": 24, "x": 0, - "y": 20 + "y": 14 }, "id": 24, "panels": [], @@ -941,20 +854,19 @@ }, "gridPos": { "h": 6, - "w": 6, + "w": 4, "x": 0, - "y": 21 + "y": 15 }, "id": 25, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1018,20 +930,19 @@ }, "gridPos": { "h": 6, - "w": 6, - "x": 6, - "y": 21 + "w": 4, + "x": 4, + "y": 15 }, "id": 26, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1095,20 +1006,19 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 12, - "y": 21 + "w": 4, + "x": 8, + "y": 15 }, "id": 29, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1171,21 +1081,20 @@ "overrides": [] }, "gridPos": { - "h": 7, - "w": 6, - "x": 0, - "y": 27 + "h": 6, + "w": 4, + "x": 12, + "y": 15 }, "id": 30, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1248,21 +1157,20 @@ "overrides": [] }, "gridPos": { - "h": 7, - "w": 6, - "x": 6, - "y": 27 + "h": 6, + "w": 4, + "x": 16, + "y": 15 }, "id": 28, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1298,7 +1206,7 @@ "h": 1, "w": 24, "x": 0, - "y": 34 + "y": 21 }, "id": 31, "panels": [], @@ -1345,18 +1253,17 @@ "h": 10, "w": 7, "x": 0, - "y": 35 + "y": 22 }, "id": 32, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1426,18 +1333,17 @@ "h": 10, "w": 7, "x": 7, - "y": 35 + "y": 22 }, "id": 34, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1507,18 +1413,17 @@ "h": 10, "w": 6, "x": 14, - "y": 35 + "y": 22 }, "id": 33, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1554,7 +1459,7 @@ "h": 1, "w": 24, "x": 0, - "y": 45 + "y": 32 }, "id": 6, "panels": [], @@ -1595,20 +1500,20 @@ "h": 6, "w": 24, "x": 0, - "y": 46 + "y": 33 }, "id": 9, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1621,8 +1526,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1677,19 +1581,18 @@ "h": 6, "w": 24, "x": 0, - "y": 52 + "y": 39 }, "id": 11, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1703,8 +1606,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1731,7 +1633,7 @@ "h": 1, "w": 24, "x": 0, - "y": 58 + "y": 45 }, "id": 12, "panels": [], @@ -1773,19 +1675,18 @@ "h": 7, "w": 4, "x": 0, - "y": 59 + "y": 46 }, "id": 13, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1799,8 +1700,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1818,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" }, @@ -1856,19 +1772,18 @@ "h": 7, "w": 5, "x": 4, - "y": 59 + "y": 46 }, "id": 14, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1882,8 +1797,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1901,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" }, @@ -1938,19 +1868,18 @@ "h": 7, "w": 5, "x": 9, - "y": 59 + "y": 46 }, "id": 15, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.84, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1964,8 +1893,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2020,19 +1948,18 @@ "h": 7, "w": 6, "x": 14, - "y": 59 + "y": 46 }, "id": 16, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.66, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -2046,8 +1973,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2074,7 +2000,7 @@ "h": 1, "w": 24, "x": 0, - "y": 66 + "y": 53 }, "id": 35, "panels": [], @@ -2105,20 +2031,19 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 6, + "h": 5, + "w": 12, "x": 0, - "y": 67 + "y": 54 }, "id": 36, "options": { + "barShape": "flat", "barWidthFactor": 0.5, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2171,20 +2096,19 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 6, - "x": 6, - "y": 67 + "h": 5, + "w": 12, + "x": 12, + "y": 54 }, "id": 37, "options": { + "barShape": "flat", "barWidthFactor": 0.5, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2224,5 +2148,6 @@ "timezone": "browser", "title": "Panel tests - Gauge (new)", "uid": "panel-tests-gauge-new", - "version": 9 + "version": 22, + "weekStart": "" } 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 b071ddff802..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", @@ -956,8 +957,6 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false } } diff --git a/docs/sources/alerting/alerting-rules/alerting-migration.md b/docs/sources/alerting/alerting-rules/alerting-migration.md index 3afa3aec453..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**. @@ -242,6 +242,8 @@ Set to `true` to import recording rules in paused state. The UID of the data source to use for alert rule queries. +If not specified in the header, Grafana uses the configured default from `unified_alerting.prometheus_conversion.default_datasource_uid`. If neither the header nor the configuration option is provided, the request fails. + #### `X-Grafana-Alerting-Target-Datasource-UID` The UID of the target data source for recording rules. If not specified, the value from `X-Grafana-Alerting-Datasource-UID` is used. diff --git a/docs/sources/datasources/_index.md b/docs/sources/datasources/_index.md index e7da61ea2f5..28d46c9c600 100644 --- a/docs/sources/datasources/_index.md +++ b/docs/sources/datasources/_index.md @@ -112,6 +112,12 @@ For example, this video demonstrates the visual Prometheus query builder: For general information about querying in Grafana, and common options and user interface elements across all query editors, refer to [Query and transform data](ref:query-transform-data). +## Build a dashboard from the data source + +After you've configured a data source, you can start creating a dashboard directly from it, by clicking the **Build a dashboard** button. + +For more information, refer to [Begin dashboard creation from data source configuration](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/create-dashboard/#begin-dashboard-creation-from-connections). + ## Special data sources Grafana includes three special data sources: 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/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 05d9cb66228..67c361b2bdc 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -2052,6 +2052,10 @@ This section applies only to rules imported as Grafana-managed rules. For more i Set the query offset to imported Grafana-managed rules when `query_offset` is not defined in the original rule group configuration. The default value is `1m`. +#### `default_datasource_uid` + +Set the default data source UID to use for query execution when importing Prometheus rules. Grafana uses this default when the `X-Grafana-Alerting-Datasource-UID` header isn't provided during import. If this option isn't set, the header becomes required. The default value is empty. +
### `[annotations]` diff --git a/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md b/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md index 3d5e765e0cd..10196a40811 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md @@ -99,7 +99,7 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee - Understand the query language of the target data source. - Ensure that data source for which you are writing a query has been added. For more information about adding a data source, refer to [Add a data source](ref:add-a-data-source) if you need instructions. -**To create a dashboard**: +To create a dashboard, follow these steps: {{< shared id="create-dashboard" >}} @@ -171,6 +171,28 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee Now, when you want to make more changes to the saved dashboard, click **Edit** in the top-right corner. +### Begin dashboard creation from data source configuration + +You can start the process of creating a dashboard directly from a data source rather than from the **Dashboards** page. + +To begin building a dashboard directly from a data source, follow these steps: + +1. Navigate to **Connections > Data sources**. +1. On the row of the data source for which you want to build a dashboard, click **Build a dashboard**. + + The empty dashboard page opens. + +1. Do one of the following: + - Click **+Add visualization** to configure all the elements of the new dashboard. + - Select one of the suggested dashboards by clicking its **Use dashboard** button. This can be helpful when you're not sure how to most effectively visualize your data. + The suggested dashboards are specific to your data source type (for example, Prometheus, Loki, or Elasticsearch). If there are more than three dashboard suggestions, you can click **View all** to see the rest of them. + + ![Empty dashboard with add visualization and suggested dashboard options](/media/docs/grafana/dashboards/screenshot-suggested-dashboards-v12.3.png) + + {{< docs/public-preview product="Suggested dashboards" >}} + +1. Complete the rest of the dashboard configuration. For more detailed steps, refer to [Create a dashboard](#create-a-dashboard), beginning at step five. + ## Copy a dashboard To copy a dashboard, follow these steps: diff --git a/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md b/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md index 5fcd2344fe2..e7749ba5b88 100644 --- a/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md +++ b/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md @@ -98,7 +98,7 @@ You can share dashboards in the following ways: - [As a report](#schedule-a-report) - [As a snapshot](#share-a-snapshot) - [As a PDF export](#export-a-dashboard-as-pdf) -- [As a JSON file export](#export-a-dashboard-as-json) +- [As a JSON file export](#export-a-dashboard-as-code) - [As an image export](#export-a-dashboard-as-an-image) When you share a dashboard externally as a link or by email, those dashboards are included in a list of your shared dashboards. To view the list and manage these dashboards, navigate to **Dashboards > Shared dashboards**. diff --git a/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts b/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts index 6765d299e53..caa83bfdb90 100644 --- a/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts @@ -10,7 +10,7 @@ const NUM_NESTED_DASHBOARDS = 60; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts b/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts index 8ae318bcefa..6949eca4555 100644 --- a/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts @@ -5,7 +5,7 @@ import testDashboard from '../dashboards/TestDashboard.json'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts b/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts index a97a04a14b8..e15991514a2 100644 --- a/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts @@ -7,7 +7,7 @@ test.use({ scenes: true, sharingDashboardImage: true, // Enable the export image feature kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts b/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts index 428193ab5fa..26a8fb61dc8 100644 --- a/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts index b0ecf44f9f1..f874cefa27c 100644 --- a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts b/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts index 0a982e148b5..ca05fd24160 100644 --- a/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts @@ -5,7 +5,7 @@ import testDashboard from '../dashboards/DataLinkWithoutSlugTest.json'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts b/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts index 20f455ea3a8..b7e18e56b45 100644 --- a/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts @@ -5,7 +5,7 @@ import testDashboard from '../dashboards/DashboardLiveTest.json'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts index fd5dc979d81..9653218f5ff 100644 --- a/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts b/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts index c59e323076d..9f15a740fcf 100644 --- a/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts index 3398e9aaa35..3827872c199 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts @@ -4,7 +4,7 @@ test.use({ featureToggles: { scenes: true, kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts index 26f8b85d13e..6ff1825f9cd 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts @@ -4,7 +4,7 @@ test.use({ featureToggles: { scenes: true, kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts index 1a7e03d6243..b5c77458121 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts @@ -6,7 +6,7 @@ test.use({ featureToggles: { scenes: true, kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts b/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts index 78c35dc5de7..0a4eb5d3a9a 100644 --- a/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts @@ -6,7 +6,7 @@ test.use({ timezoneId: 'Pacific/Easter', featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts index 937224290b0..ee3de574512 100644 --- a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts @@ -8,7 +8,7 @@ const TIMEZONE_DASHBOARD_UID = 'd41dbaa2-a39e-4536-ab2b-caca52f1a9c8'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts b/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts index 4ffd65b83a3..ae2f08b230f 100644 --- a/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts @@ -17,7 +17,7 @@ test.use({ }, featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts b/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts index f457eddf19e..aad8ed3367a 100644 --- a/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts +++ b/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/general-dashboards.spec.ts b/e2e-playwright/dashboards-suite/general-dashboards.spec.ts index 99f84cb6d9f..d3ba9d9046e 100644 --- a/e2e-playwright/dashboards-suite/general-dashboards.spec.ts +++ b/e2e-playwright/dashboards-suite/general-dashboards.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/import-dashboard.spec.ts b/e2e-playwright/dashboards-suite/import-dashboard.spec.ts index 5fdca6954aa..f489f576887 100644 --- a/e2e-playwright/dashboards-suite/import-dashboard.spec.ts +++ b/e2e-playwright/dashboards-suite/import-dashboard.spec.ts @@ -5,7 +5,7 @@ import testDashboard from '../dashboards/TestDashboard.json'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts b/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts index ca06e31528a..08847be8d8e 100644 --- a/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts +++ b/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts b/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts index fa0b5fc1bfd..0abd9d248f1 100644 --- a/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts @@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts b/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts index c14a952e1d9..79e545c9a41 100644 --- a/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts @@ -53,7 +53,7 @@ async function assertPreviewValues( test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts b/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts index cc19e67cda4..03859ccc5ea 100644 --- a/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts @@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts b/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts index d76b5291c42..9c5cc8cc60f 100644 --- a/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts @@ -19,7 +19,7 @@ async function assertPreviewValues( test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-query-variable.spec.ts b/e2e-playwright/dashboards-suite/new-query-variable.spec.ts index 852261dbaf5..96375f3fb97 100644 --- a/e2e-playwright/dashboards-suite/new-query-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-query-variable.spec.ts @@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Templating - Nested Template Variables'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts b/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts index c669dc563c4..ba3b9466e7d 100644 --- a/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts @@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts b/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts index a55f14b8643..413466972e1 100644 --- a/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'WVpf2jp7z/repeating-a-panel-horizontally'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts b/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts index bb188c87e8d..53966eadf05 100644 --- a/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'OY8Ghjt7k/repeating-a-panel-vertically'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts b/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts index e31c5792062..06c0e77989b 100644 --- a/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'dtpl2Ctnk/repeating-an-empty-row'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts b/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts index 53290345e73..629abda2ce3 100644 --- a/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts +++ b/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/snapshot-create.spec.ts b/e2e-playwright/dashboards-suite/snapshot-create.spec.ts index 123aa7f3279..50febc211b8 100644 --- a/e2e-playwright/dashboards-suite/snapshot-create.spec.ts +++ b/e2e-playwright/dashboards-suite/snapshot-create.spec.ts @@ -5,7 +5,7 @@ const DASHBOARD_UID = 'ZqZnVvFZz'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts b/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts index 1d8fd32ff06..1806aca24bf 100644 --- a/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts +++ b/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts @@ -5,7 +5,7 @@ const DASHBOARD_UID = 'yBCC3aKGk'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/textbox-variables.spec.ts b/e2e-playwright/dashboards-suite/textbox-variables.spec.ts index 4fb56ef8b8e..b78e781dad1 100644 --- a/e2e-playwright/dashboards-suite/textbox-variables.spec.ts +++ b/e2e-playwright/dashboards-suite/textbox-variables.spec.ts @@ -7,7 +7,7 @@ const PAGE_UNDER_TEST = 'AejrN1AMz'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/panels-suite/canvas-scene.spec.ts b/e2e-playwright/panels-suite/canvas-scene.spec.ts index b1fc028f3ae..c0b392d544b 100644 --- a/e2e-playwright/panels-suite/canvas-scene.spec.ts +++ b/e2e-playwright/panels-suite/canvas-scene.spec.ts @@ -2,18 +2,16 @@ import { Locator } from '@playwright/test'; import { test, expect } from '@grafana/plugin-e2e'; -import { setVisualization } from './vizpicker-utils'; - test.use({ featureToggles: { canvasPanelPanZoom: true, }, }); test.describe('Canvas Panel - Scene Tests', () => { - test.beforeEach(async ({ page, gotoDashboardPage, selectors }) => { + test.beforeEach(async ({ page, gotoDashboardPage }) => { const dashboardPage = await gotoDashboardPage({}); const panelEditPage = await dashboardPage.addPanel(); - await setVisualization(panelEditPage, 'Canvas', selectors); + await panelEditPage.setVisualization('Canvas'); // Wait for canvas panel to load await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 }); 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/vizpicker-utils.ts b/e2e-playwright/panels-suite/vizpicker-utils.ts deleted file mode 100644 index 1785dd7e04a..00000000000 --- a/e2e-playwright/panels-suite/vizpicker-utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expect, E2ESelectorGroups, PanelEditPage } from '@grafana/plugin-e2e'; - -// this replaces the panelEditPage.setVisualization method used previously in tests, since it -// does not know how to use the updated 12.4 viz picker UI to set the visualization -export const setVisualization = async (panelEditPage: PanelEditPage, vizName: string, selectors: E2ESelectorGroups) => { - const vizPicker = panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker); - await expect(vizPicker, '"Change" button should be visible').toBeVisible(); - await vizPicker.click(); - - const allVizTabBtn = panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('All visualizations')); - await expect(allVizTabBtn, '"All visualiations" button should be visible').toBeVisible(); - await allVizTabBtn.click(); - - const vizItem = panelEditPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(vizName)); - await expect(vizItem, `"${vizName}" item should be visible`).toBeVisible(); - await vizItem.scrollIntoViewIfNeeded(); - await vizItem.click(); - - await expect(vizPicker, '"Change" button should be visible again').toBeVisible(); - await expect( - panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header), - 'Panel header should have the new viz type name' - ).toHaveText(vizName); -}; diff --git a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts index 336dbef0a29..0133a3e3712 100644 --- a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts +++ b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts @@ -1,6 +1,5 @@ import { expect, test } from '@grafana/plugin-e2e'; -import { setVisualization } from '../../../panels-suite/vizpicker-utils'; import { formatExpectError } from '../errors'; import { successfulDataQuery } from '../mocks/queries'; @@ -25,10 +24,10 @@ test.describe( ).toContainText(['Field', 'Max', 'Mean', 'Last']); }); - test('table panel data assertions', async ({ panelEditPage, selectors }) => { + test('table panel data assertions', async ({ panelEditPage }) => { await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); await panelEditPage.datasource.set('gdev-testdata'); - await setVisualization(panelEditPage, 'Table', selectors); + await panelEditPage.setVisualization('Table'); await panelEditPage.refreshPanel(); await expect( panelEditPage.panel.locator, @@ -44,10 +43,10 @@ test.describe( ).toContainText(['val1', 'val2', 'val3', 'val4']); }); - test('timeseries panel - table view assertions', async ({ panelEditPage, selectors }) => { + test('timeseries panel - table view assertions', async ({ panelEditPage }) => { await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); await panelEditPage.datasource.set('gdev-testdata'); - await setVisualization(panelEditPage, 'Time series', selectors); + await panelEditPage.setVisualization('Time series'); await panelEditPage.refreshPanel(); await panelEditPage.toggleTableView(); await expect( diff --git a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts index 93e0525ab0e..46c36277848 100644 --- a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts +++ b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts @@ -1,6 +1,5 @@ import { expect, test } from '@grafana/plugin-e2e'; -import { setVisualization } from '../../../panels-suite/vizpicker-utils'; import { formatExpectError } from '../errors'; import { successfulDataQuery } from '../mocks/queries'; import { scenarios } from '../mocks/resources'; @@ -54,10 +53,10 @@ test.describe( ).toHaveText(scenarios.map((s) => s.name)); }); - test('mocked query data response', async ({ panelEditPage, page, selectors }) => { + test('mocked query data response', async ({ panelEditPage, page }) => { await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); await panelEditPage.datasource.set('gdev-testdata'); - await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors); + await panelEditPage.setVisualization(TABLE_VIZ_NAME); await panelEditPage.refreshPanel(); await expect( panelEditPage.panel.getErrorIcon(), @@ -76,7 +75,7 @@ test.describe( selectors, page, }) => { - await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors); + await panelEditPage.setVisualization(TABLE_VIZ_NAME); await expect( panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header), formatExpectError('Expected panel visualization to be set to table') @@ -93,8 +92,8 @@ test.describe( ).toBeVisible(); }); - test('Select time zone in timezone picker', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('Select time zone in timezone picker', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = await panelEditPage.getCustomOptions('Axis'); const timeZonePicker = axisOptions.getSelect('Time zone'); @@ -102,8 +101,8 @@ test.describe( await expect(timeZonePicker).toHaveSelected('Europe/Stockholm'); }); - test('select unit in unit picker', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('select unit in unit picker', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const standardOptions = panelEditPage.getStandardOptions(); const unitPicker = standardOptions.getUnitPicker('Unit'); @@ -112,8 +111,8 @@ test.describe( await expect(unitPicker).toHaveSelected('Pixels'); }); - test('enter value in number input', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('enter value in number input', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const lineWith = axisOptions.getNumberInput('Soft min'); @@ -122,8 +121,8 @@ test.describe( await expect(lineWith).toHaveValue('10'); }); - test('enter value in slider', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('enter value in slider', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const graphOptions = panelEditPage.getCustomOptions('Graph styles'); const lineWidth = graphOptions.getSliderInput('Line width'); @@ -132,8 +131,8 @@ test.describe( await expect(lineWidth).toHaveValue('10'); }); - test('select value in single value select', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('select value in single value select', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const standardOptions = panelEditPage.getStandardOptions(); const colorSchemeSelect = standardOptions.getSelect('Color scheme'); @@ -141,8 +140,8 @@ test.describe( await expect(colorSchemeSelect).toHaveSelected('Classic palette'); }); - test('clear input', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('clear input', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const panelOptions = panelEditPage.getPanelOptions(); const title = panelOptions.getTextInput('Title'); @@ -151,8 +150,8 @@ test.describe( await expect(title).toHaveValue(''); }); - test('enter value in input', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('enter value in input', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const panelOptions = panelEditPage.getPanelOptions(); const description = panelOptions.getTextInput('Description'); @@ -161,8 +160,8 @@ test.describe( await expect(description).toHaveValue('This is a panel'); }); - test('unchecking switch', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('unchecking switch', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const showBorder = axisOptions.getSwitch('Show border'); @@ -174,8 +173,8 @@ test.describe( await expect(showBorder).toBeChecked({ checked: false }); }); - test('checking switch', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('checking switch', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const showBorder = axisOptions.getSwitch('Show border'); @@ -184,8 +183,8 @@ test.describe( await expect(showBorder).toBeChecked(); }); - test('re-selecting value in radio button group', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('re-selecting value in radio button group', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const placement = axisOptions.getRadioGroup('Placement'); @@ -196,8 +195,8 @@ test.describe( await expect(placement).toHaveChecked('Auto'); }); - test('selecting value in radio button group', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('selecting value in radio button group', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const placement = axisOptions.getRadioGroup('Placement'); 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/eslint-suppressions.json b/eslint-suppressions.json index 36eb78aaa72..597f2b94a1a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1911,11 +1911,6 @@ "count": 1 } }, - "public/app/features/dashboard-scene/settings/JsonModelEditView.tsx": { - "react/no-unescaped-entities": { - "count": 2 - } - }, "public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx": { "react-hooks/rules-of-hooks": { "count": 4 diff --git a/go.mod b/go.mod index fb1ab1ce189..83d82e3af5d 100644 --- a/go.mod +++ b/go.mod @@ -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-20251212143239-491433b332b7 // @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 @@ -181,6 +181,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 @@ -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 diff --git a/go.sum b/go.sum index c1a8d8ad808..2b3b2cb4e3f 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= @@ -1622,8 +1624,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-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/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 73813d12650..ca22b546c86 100644 --- a/go.work.sum +++ b/go.work.sum @@ -793,7 +793,15 @@ github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5 github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-pdf/fpdf v0.6.0 h1:MlgtGIfsdMEEQJr2le6b/HNr1ZlQwxyWr77r2aj2U/8= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= @@ -982,7 +990,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9K github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU= github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= @@ -1404,7 +1411,6 @@ github.com/richardartoul/molecule v1.0.0/go.mod h1:uvX/8buq8uVeiZiFht+0lqSLBHF+u github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= @@ -1623,7 +1629,6 @@ go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5queth go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/collector v0.121.0/go.mod h1:M4TlnmkjIgishm2DNCk9K3hMKTmAsY9w8cNFsp9EchM= go.opentelemetry.io/collector v0.124.0/go.mod h1:QzERYfmHUedawjr8Ph/CBEEkVqWS8IlxRLAZt+KHlCg= go.opentelemetry.io/collector/client v1.29.0/go.mod h1:LCUoEV2KCTKA1i+/txZaGsSPVWUcqeOV6wCfNsAippE= @@ -1839,6 +1844,7 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1: go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/contrib/otelconf v0.15.0 h1:BLNiIUsrNcqhSKpsa6CnhE6LdrpY1A8X0szMVsu99eo= go.opentelemetry.io/contrib/otelconf v0.15.0/go.mod h1:OPH1seO5z9dp1P26gnLtoM9ht7JDvh3Ws6XRHuXqImY= go.opentelemetry.io/contrib/propagators/aws v1.37.0 h1:cp8AFiM/qjBm10C/ATIRnEDXpD5MBknrA0ANw4T2/ss= @@ -1910,7 +1916,6 @@ go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v8 go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= @@ -2118,8 +2123,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go. google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= +google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822 h1:zWFRixYR5QlotL+Uv3YfsPRENIrQFXiGs+iwqel6fOQ= google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822/go.mod h1:h6yxum/C2qRb4txaZRLDHK8RyS0H/o2oEDeKY4onY/Y= @@ -2150,10 +2155,9 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= @@ -2177,7 +2181,6 @@ google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7E google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20 h1:MLBCGN1O7GzIx+cBiwfYPwtmZ41U3Mn/cotLJciaArI= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0= @@ -2299,7 +2302,6 @@ sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ih sigs.k8s.io/structured-merge-diff/v4 v4.5.0 h1:nbCitCK2hfnhyiKo6uf2HxUPTCodY6Qaf85SbDIaMBk= sigs.k8s.io/structured-merge-diff/v4 v4.5.0/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= sigs.k8s.io/structured-merge-diff/v6 v6.2.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= 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 5d3e72b13aa..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 @@ -243,6 +243,7 @@ const injectedRtkApi = api type: queryArg['type'], folder: queryArg.folder, facet: queryArg.facet, + facetLimit: queryArg.facetLimit, tags: queryArg.tags, libraryPanel: queryArg.libraryPanel, permission: queryArg.permission, @@ -284,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}`, @@ -663,6 +668,8 @@ export type SearchDashboardsAndFoldersApiArg = { folder?: string; /** count distinct terms for selected fields */ facet?: string[]; + /** maximum number of terms to return per facet (default 50, max 1000) */ + facetLimit?: number; /** tag query filter */ tags?: string[]; /** find dashboards that reference a given libraryPanel */ @@ -739,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 */ @@ -1270,6 +1279,8 @@ export const { useLazyListSnapshotQuery, useCreateSnapshotMutation, useDeleteWithKeyMutation, + useGetSnapshotSettingsQuery, + useLazyGetSnapshotSettingsQuery, useGetSnapshotQuery, useLazyGetSnapshotQuery, useDeleteSnapshotMutation, diff --git a/packages/grafana-data/src/field/fieldDisplay.test.ts b/packages/grafana-data/src/field/fieldDisplay.test.ts index 718c0e54430..5ec3ed7ba4f 100644 --- a/packages/grafana-data/src/field/fieldDisplay.test.ts +++ b/packages/grafana-data/src/field/fieldDisplay.test.ts @@ -3,11 +3,18 @@ import { merge } from 'lodash'; import { toDataFrame } from '../dataframe/processDataFrame'; import { createTheme } from '../themes/createTheme'; import { ReducerID } from '../transformations/fieldReducer'; +import { FieldType } from '../types/dataFrame'; import { FieldConfigPropertyItem } from '../types/fieldOverrides'; import { MappingType, SpecialValueMatch, ValueMapping } from '../types/valueMapping'; import { getDisplayProcessor } from './displayProcessor'; -import { fixCellTemplateExpressions, getFieldDisplayValues, GetFieldDisplayValuesOptions } from './fieldDisplay'; +import { + FieldSparkline, + fixCellTemplateExpressions, + getFieldDisplayValues, + GetFieldDisplayValuesOptions, + getSparklineHighlight, +} from './fieldDisplay'; import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry'; describe('FieldDisplay', () => { @@ -556,3 +563,71 @@ describe('fixCellTemplateExpressions', () => { ); }); }); + +describe('getSparklineHighlight', () => { + const sparkline: FieldSparkline = { + y: { name: 'A', type: FieldType.number, values: [null, 2, 3, 4, 10, 8, 8, 8, 9, null], config: {} }, + }; + + it.each([ + { + calc: ReducerID.last, + expected: { + type: 'point', + xIdx: 9, + }, + }, + { + calc: ReducerID.max, + expected: { + type: 'point', + xIdx: 4, + }, + }, + { + calc: ReducerID.min, + expected: { + type: 'point', + xIdx: 1, + }, + }, + { + calc: ReducerID.first, + expected: { + type: 'point', + xIdx: 0, + }, + }, + { + calc: ReducerID.firstNotNull, + expected: { + type: 'point', + xIdx: 1, + }, + }, + { + calc: ReducerID.lastNotNull, + expected: { + type: 'point', + xIdx: 8, + }, + }, + { + calc: ReducerID.mean, + expected: { + type: 'line', + y: 6.5, + }, + }, + { + calc: ReducerID.median, + expected: { + type: 'line', + y: 8, + }, + }, + ])('it calculates the correct highlight for the $calc', ({ calc, expected }) => { + const result = getSparklineHighlight(sparkline, calc); + expect(result).toEqual(expected); + }); +}); diff --git a/packages/grafana-data/src/field/fieldDisplay.ts b/packages/grafana-data/src/field/fieldDisplay.ts index 3d82f571926..3496f419395 100644 --- a/packages/grafana-data/src/field/fieldDisplay.ts +++ b/packages/grafana-data/src/field/fieldDisplay.ts @@ -3,7 +3,7 @@ import { isEmpty } from 'lodash'; import { DataFrameView } from '../dataframe/DataFrameView'; import { getTimeField } from '../dataframe/processDataFrame'; import { GrafanaTheme2 } from '../themes/types'; -import { reduceField, ReducerID } from '../transformations/fieldReducer'; +import { isReducerID, reduceField, ReducerID } from '../transformations/fieldReducer'; import { getFieldMatcher } from '../transformations/matchers'; import { FieldMatcherID } from '../transformations/matchers/ids'; import { ScopedVars } from '../types/ScopedVars'; @@ -43,6 +43,7 @@ export interface FieldSparkline { x?: Field; // if this does not exist, use the index timeRange?: TimeRange; // Optionally force an absolute time highlightIndex?: number; + highlightLine?: number; } export interface FieldDisplay { @@ -72,6 +73,76 @@ export interface GetFieldDisplayValuesOptions { export const DEFAULT_FIELD_DISPLAY_VALUES_LIMIT = 25; +interface SparklineHighlightPoint { + type: 'point'; + xIdx: number; +} + +interface SparklineHighlightLine { + type: 'line'; + y: number; +} + +export function getSparklineHighlight( + sparkline: FieldSparkline, + calc: ReducerID +): SparklineHighlightPoint | SparklineHighlightLine | void { + switch (calc) { + case ReducerID.last: + return { type: 'point', xIdx: sparkline.y.values.length - 1 }; + case ReducerID.first: + return { type: 'point', xIdx: 0 }; + case ReducerID.lastNotNull: { + for (let k = sparkline.y.values.length - 1; k >= 0; k--) { + const v = sparkline.y.values[k]; + if (v !== null && v !== undefined && !Number.isNaN(v)) { + return { type: 'point', xIdx: k }; + } + } + return; + } + case ReducerID.firstNotNull: { + for (let k = 0; k < sparkline.y.values.length; k++) { + const v = sparkline.y.values[k]; + if (v !== null && v !== undefined && !Number.isNaN(v)) { + return { type: 'point', xIdx: k }; + } + } + return; + } + case ReducerID.min: { + let minIdx = -1; + let prevMin = Infinity; + for (let k = 0; k < sparkline.y.values.length; k++) { + const v = sparkline.y.values[k]; + if (v !== null && v !== undefined && !Number.isNaN(v) && v < prevMin) { + prevMin = v; + minIdx = k; + } + } + return minIdx >= 0 ? { type: 'point', xIdx: minIdx } : undefined; + } + case ReducerID.max: { + let maxIdx = -1; + let prevMax = -Infinity; + for (let k = 0; k < sparkline.y.values.length; k++) { + const v = sparkline.y.values[k]; + if (v !== null && v !== undefined && !Number.isNaN(v) && v > prevMax) { + prevMax = v; + maxIdx = k; + } + } + return maxIdx >= 0 ? { type: 'point', xIdx: maxIdx } : undefined; + } + case ReducerID.mean: + return { type: 'line', y: reduceField({ field: sparkline.y, reducers: [ReducerID.mean] }).mean }; + case ReducerID.median: + return { type: 'line', y: reduceField({ field: sparkline.y, reducers: [ReducerID.median] }).median }; + default: + return; + } +} + export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): FieldDisplay[] => { const { replaceVariables, reduceOptions, timeZone, theme } = options; const calcs = reduceOptions.calcs.length ? reduceOptions.calcs : [ReducerID.last]; @@ -190,62 +261,16 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi y: dataFrame.fields[i], x: timeField, }; - let highlightIdx: number | undefined = (() => { - switch (calc) { - case ReducerID.last: - return sparkline.y.values.length - 1; - case ReducerID.first: - return 0; - // TODO: #112977 enable more reducers for highlight index - // case ReducerID.lastNotNull: { - // for (let k = sparkline.y.values.length - 1; k >= 0; k--) { - // const v = sparkline.y.values[k]; - // if (v !== null && v !== undefined && !Number.isNaN(v)) { - // return k; - // } - // } - // return; - // } - // case ReducerID.firstNotNull: { - // for (let k = 0; k < sparkline.y.values.length; k++) { - // const v = sparkline.y.values[k]; - // if (v !== null && v !== undefined && !Number.isNaN(v)) { - // return k; - // } - // } - // return; - // } - // case ReducerID.min: { - // let minIdx = -1; - // let prevMin = Infinity; - // for (let k = 0; k < sparkline.y.values.length; k++) { - // const v = sparkline.y.values[k]; - // if (v !== null && v !== undefined && !Number.isNaN(v) && v < prevMin) { - // prevMin = v; - // minIdx = k; - // } - // } - // return minIdx >= 0 ? minIdx : undefined; - // } - // case ReducerID.max: { - // let maxIdx = -1; - // let prevMax = -Infinity; - // for (let k = 0; k < sparkline.y.values.length; k++) { - // const v = sparkline.y.values[k]; - // if (v !== null && v !== undefined && !Number.isNaN(v) && v > prevMax) { - // prevMax = v; - // maxIdx = k; - // } - // } - // return maxIdx >= 0 ? maxIdx : undefined; - // } - default: - return; + if (isReducerID(calc)) { + const sparklineHighlight = getSparklineHighlight(sparkline, calc); + switch (sparklineHighlight?.type) { + case 'point': + sparkline.highlightIndex = sparklineHighlight.xIdx; + break; + case 'line': + sparkline.highlightLine = sparklineHighlight.y; + break; } - })(); - - if (typeof highlightIdx === 'number') { - sparkline.highlightIndex = highlightIdx; } } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 981b10dfb1c..aebbab8c6f9 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -356,10 +356,6 @@ export interface FeatureToggles { */ dashboardNewLayouts?: boolean; /** - * Use the v2 kubernetes API in the frontend for dashboards - */ - kubernetesDashboardsV2?: boolean; - /** * Enables undo/redo in dynamic dashboards */ dashboardUndoRedo?: boolean; @@ -421,6 +417,10 @@ export interface FeatureToggles { */ jitterAlertRulesWithinGroups?: boolean; /** + * Enable audit logging with Kubernetes under app platform + */ + auditLoggingAppPlatform?: boolean; + /** * Enable the secrets management API and services under app platform */ secretsManagementAppPlatform?: 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/querybuilder/components/metrics-modal/MetricsModal.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx index f57fbb59bd6..a91179fad3e 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx @@ -48,7 +48,7 @@ describe('MetricsModal', () => { operations: [], }; - setup(query, ['with-labels'], true); + setup(query, ['with-labels']); await waitFor(() => { expect(screen.getByText('with-labels')).toBeInTheDocument(); }); @@ -220,6 +220,10 @@ function createDatasource(withLabels?: boolean) { // display different results if their labels are selected in the PromVisualQuery if (withLabels) { languageProvider.queryMetricsMetadata = jest.fn().mockResolvedValue({ + ALERTS: { + type: 'gauge', + help: 'alerts help text', + }, 'with-labels': { type: 'with-labels-type', help: 'with-labels-help', @@ -297,7 +301,7 @@ function createProps(query: PromVisualQuery, datasource: PrometheusDatasource, m }; } -function setup(query: PromVisualQuery, metrics: string[], withlabels?: boolean) { +function setup(query: PromVisualQuery, metrics: string[]) { const withLabels: boolean = query.labels.length > 0; const datasource = createDatasource(withLabels); const props = createProps(query, datasource, metrics); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx index 59c4c703ccf..bf92a3ddc77 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx @@ -138,7 +138,7 @@ const MetricsModalContent = (props: MetricsModalProps) => { export const MetricsModal = (props: MetricsModalProps) => { return ( - + ); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx index 955b2c1b585..46082f476b5 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx @@ -4,6 +4,7 @@ import { ReactNode } from 'react'; import { TimeRange } from '@grafana/data'; import { PrometheusLanguageProviderInterface } from '../../../language_provider'; +import { getMockTimeRange } from '../../../test/mocks/datasource'; import { DEFAULT_RESULTS_PER_PAGE, MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext'; import { generateMetricData } from './helpers'; @@ -25,7 +26,9 @@ const mockLanguageProvider: PrometheusLanguageProviderInterface = { // Helper to create wrapper component const createWrapper = (languageProvider = mockLanguageProvider) => { return ({ children }: { children: ReactNode }) => ( - {children} + + {children} + ); }; @@ -167,6 +170,7 @@ describe('MetricsModalContext', () => { it('should handle empty metadata response', async () => { (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({}); + (mockLanguageProvider.queryLabelValues as jest.Mock).mockResolvedValue(['metric1', 'metric2']); const { result } = renderHook(() => useMetricsModal(), { wrapper: createWrapper(), @@ -176,7 +180,18 @@ describe('MetricsModalContext', () => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.filteredMetricsData).toEqual([]); + expect(result.current.filteredMetricsData).toEqual([ + { + value: 'metric1', + type: 'counter', + description: 'Test metric', + }, + { + value: 'metric2', + type: 'counter', + description: 'Test metric', + }, + ]); }); it('should handle metadata fetch error', async () => { @@ -239,6 +254,7 @@ describe('MetricsModalContext', () => { })); (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({ + ALERTS: { type: 'gauge', help: 'Test alerts help' }, test_metric: { type: 'counter', help: 'Test metric' }, }); @@ -250,7 +266,7 @@ describe('MetricsModalContext', () => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.filteredMetricsData).toHaveLength(1); + expect(result.current.filteredMetricsData).toHaveLength(2); expect(result.current.selectedTypes).toEqual([]); }); @@ -318,7 +334,7 @@ describe('MetricsModalContext', () => { }; const { getByTestId } = render( - + ); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx index 3361b448547..117e3aad56e 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx @@ -52,11 +52,13 @@ const MetricsModalContext = createContext( type MetricsModalContextProviderProps = { languageProvider: PrometheusLanguageProviderInterface; + timeRange: TimeRange; }; export const MetricsModalContextProvider: FC> = ({ children, languageProvider, + timeRange, }) => { const [isLoading, setIsLoading] = useState(true); const [metricsData, setMetricsData] = useState([]); @@ -111,8 +113,16 @@ export const MetricsModalContextProvider: FC generateMetricData(m, languageProvider)); + setMetricsData(processedData); } else { const processedData = Object.keys(metadata).map((m) => generateMetricData(m, languageProvider)); setMetricsData(processedData); @@ -122,7 +132,7 @@ export const MetricsModalContextProvider: FC 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 aedc041cf71..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 @@ -16,36 +16,38 @@ export interface GaugePanelEffects { barGlow?: boolean; centerGlow?: boolean; gradient?: boolean; - rounded?: boolean; - spotlight?: boolean; } export const defaultGaugePanelEffects: Partial = { barGlow: false, centerGlow: false, gradient: true, - rounded: false, - spotlight: false, }; export interface Options extends common.SingleStatBaseOptions { + barShape: ('flat' | 'rounded'); barWidthFactor: number; effects: GaugePanelEffects; + endpointMarker?: ('point' | 'glow' | 'none'); segmentCount: number; segmentSpacing: number; shape: ('circle' | 'gauge'); showThresholdLabels: boolean; showThresholdMarkers: boolean; sparkline?: boolean; + textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none'); } export const defaultOptions: Partial = { + barShape: 'flat', barWidthFactor: 0.5, effects: {}, + endpointMarker: 'point', segmentCount: 1, segmentSpacing: 0.3, shape: 'gauge', 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/RadialArcPath.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx index f54f3cd6f22..f59614acd53 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -1,52 +1,149 @@ -import { GaugeDimensions, toRad } from './utils'; +import { useId, memo, HTMLAttributes, ReactNode } from 'react'; -export interface RadialArcPathProps { - startAngle: number; - dimensions: GaugeDimensions; - color: string; - glowFilter?: string; +import { FieldDisplay } from '@grafana/data'; + +import { getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors'; +import { RadialShape, RadialGaugeDimensions, GradientStop } from './types'; +import { drawRadialArcPath, toRad } from './utils'; + +export interface RadialArcPathPropsBase { arcLengthDeg: number; + barEndcaps?: boolean; + dimensions: RadialGaugeDimensions; + fieldDisplay: FieldDisplay; roundedBars?: boolean; + shape: RadialShape; + endpointMarker?: 'point' | 'glow'; + startAngle: number; + glowFilter?: string; + endpointMarkerGlowFilter?: string; } -export function RadialArcPath({ - startAngle: angle, - dimensions, - color, - glowFilter, - arcLengthDeg, - roundedBars, -}: RadialArcPathProps) { - const { radius, centerX, centerY, barWidth } = dimensions; +interface RadialArcPathPropsWithColor extends RadialArcPathPropsBase { + color: string; +} - if (arcLengthDeg === 360) { - // For some reason a 100% full arc cannot be rendered - arcLengthDeg = 359.99; +interface RadialArcPathPropsWithGradient extends RadialArcPathPropsBase { + gradient: GradientStop[]; +} + +type RadialArcPathProps = RadialArcPathPropsWithColor | RadialArcPathPropsWithGradient; + +const ENDPOINT_MARKER_MIN_ANGLE = 10; +const DOT_OPACITY = 0.5; +const DOT_RADIUS_FACTOR = 0.4; +const MAX_DOT_RADIUS = 8; + +export const RadialArcPath = memo( + ({ + arcLengthDeg, + dimensions, + fieldDisplay, + roundedBars, + shape, + endpointMarker, + barEndcaps, + startAngle: angle, + glowFilter, + endpointMarkerGlowFilter, + ...rest + }: RadialArcPathProps) => { + const id = useId(); + + const bgDivStyle: HTMLAttributes['style'] = { width: '100%', height: '100%' }; + if ('color' in rest) { + bgDivStyle.backgroundColor = rest.color; + } else { + bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape); + } + + const { radius, centerX, centerY, barWidth } = dimensions; + + const path = drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars); + + const startRadians = toRad(angle); + const endRadians = toRad(angle + arcLengthDeg); + + const xStart = centerX + radius * Math.cos(startRadians); + const yStart = centerY + radius * Math.sin(startRadians); + const xEnd = centerX + radius * Math.cos(endRadians); + const yEnd = centerY + radius * Math.sin(endRadians); + + const dotRadius = + endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2; + + let barEndcapColors: [string, string] | undefined; + let endpointMarks: ReactNode = null; + if ('gradient' in rest) { + if (endpointMarker && (rest.gradient?.length ?? 0) > 0) { + switch (endpointMarker) { + case 'point': + const [pointColorStart, pointColorEnd] = getEndpointMarkerColors( + rest.gradient!, + fieldDisplay.display.percent + ); + endpointMarks = ( + <> + {arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && ( + + )} + + + ); + break; + case 'glow': + const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE); + const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle); + const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle); + endpointMarks = + arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE ? ( + + ) : null; + break; + default: + break; + } + } + + if (barEndcaps) { + barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent); + } + } + + return ( + <> + {/* FIXME: optimize this by only using clippath + foreign obj for gradients */} + + + + + + +
+ + {barEndcapColors?.[0] && } + {barEndcapColors?.[1] && ( + + )} + + + {endpointMarks} + + ); } +); - const startRadians = toRad(angle); - const endRadians = toRad(angle + arcLengthDeg); - - let x1 = centerX + radius * Math.cos(startRadians); - let y1 = centerY + radius * Math.sin(startRadians); - let x2 = centerX + radius * Math.cos(endRadians); - let y2 = centerY + radius * Math.sin(endRadians); - - const largeArc = arcLengthDeg > 180 ? 1 : 0; - - const path = ['M', x1, y1, 'A', radius, radius, 0, largeArc, 1, x2, y2].join(' '); - - return ( - - ); -} +RadialArcPath.displayName = 'RadialArcPath'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx index 2bacc3af3e8..719ec52c625 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx @@ -1,97 +1,64 @@ -import { GrafanaTheme2 } from '@grafana/data'; +import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data'; import { useTheme2 } from '../../themes/ThemeContext'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; -import { GaugeDimensions, toRad } from './utils'; +import { RadialShape, RadialGaugeDimensions, GradientStop } from './types'; export interface RadialBarProps { - dimensions: GaugeDimensions; - colorDefs: RadialColorDefs; - angleRange: number; angle: number; - startAngle: number; + angleRange: number; + dimensions: RadialGaugeDimensions; + fieldDisplay: FieldDisplay; + gradient?: GradientStop[]; roundedBars?: boolean; - spotlightStroke: string; + endpointMarker?: 'point' | 'glow'; + shape: RadialShape; + startAngle: number; glowFilter?: string; + endpointMarkerGlowFilter?: string; } export function RadialBar({ - dimensions, - colorDefs, - angleRange, angle, - startAngle, + angleRange, + dimensions, + fieldDisplay, + gradient, roundedBars, - spotlightStroke, + endpointMarker, + shape, + startAngle, glowFilter, + endpointMarkerGlowFilter, }: RadialBarProps) { const theme = useTheme2(); - + const colorProps = gradient ? { gradient } : { color: fieldDisplay.display.color ?? FALLBACK_COLOR }; return ( <> - - {/** Track */} - - {/** The colored bar */} - - {spotlightStroke && angle > 8 && ( - - )} - - {colorDefs.getDefs()} + {/** Track */} + + {/** The colored bar */} + ); } - -interface SpotlightEffectProps { - dimensions: GaugeDimensions; - angle: number; - glowFilter?: string; - spotlightStroke: string; - theme: GrafanaTheme2; - roundedBars?: boolean; -} - -function SpotlightSquareEffect({ dimensions, angle, glowFilter, spotlightStroke, roundedBars }: SpotlightEffectProps) { - const { radius, centerX, centerY, barWidth } = dimensions; - - const angleRadian = toRad(angle); - const x1 = centerX + radius * Math.cos(angleRadian - 0.2); - const y1 = centerY + radius * Math.sin(angleRadian - 0.2); - const x2 = centerX + radius * Math.cos(angleRadian); - const y2 = centerY + radius * Math.sin(angleRadian); - - const path = ['M', x1, y1, 'A', radius, radius, 0, 0, 1, x2, y2].join(' '); - - return ( - - ); -} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx index dbc93d2da7f..b51cb4ce2f1 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx @@ -1,126 +1,74 @@ -import { FieldDisplay } from '@grafana/data'; +import { memo } from 'react'; + +import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data'; import { useTheme2 } from '../../themes/ThemeContext'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; -import { GaugeDimensions } from './utils'; +import { RadialShape, RadialGaugeDimensions, GradientStop } from './types'; +import { + getAngleBetweenSegments, + getFieldConfigMinMax, + getFieldDisplayProcessor, + getOptimalSegmentCount, +} from './utils'; export interface RadialBarSegmentedProps { fieldDisplay: FieldDisplay; - dimensions: GaugeDimensions; - colorDefs: RadialColorDefs; + dimensions: RadialGaugeDimensions; angleRange: number; startAngle: number; glowFilter?: string; segmentCount: number; segmentSpacing: number; + shape: RadialShape; + gradient?: GradientStop[]; } -export function RadialBarSegmented({ - fieldDisplay, - dimensions, - startAngle, - angleRange, - glowFilter, - segmentCount, - segmentSpacing, - colorDefs, -}: RadialBarSegmentedProps) { - const segments: React.ReactNode[] = []; - const theme = useTheme2(); - const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange); - const min = fieldDisplay.field.min ?? 0; - const max = fieldDisplay.field.max ?? 100; - const value = fieldDisplay.display.numeric; - const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange); - const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments; +export const RadialBarSegmented = memo( + ({ + fieldDisplay, + dimensions, + startAngle, + angleRange, + glowFilter, + gradient, + segmentCount, + segmentSpacing, + shape, + }: RadialBarSegmentedProps) => { + const theme = useTheme2(); + const segments: React.ReactNode[] = []; + const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange); + const [min, max] = getFieldConfigMinMax(fieldDisplay); + const value = fieldDisplay.display.numeric; + const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange); + const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments; + const displayProcessor = getFieldDisplayProcessor(fieldDisplay); - for (let i = 0; i < segmentCountAdjusted; i++) { - const angleValue = min + ((max - min) / segmentCountAdjusted) * i; - const angleColor = colorDefs.getSegmentColor(angleValue); - const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01; - const segmentColor = angleValue >= value ? theme.colors.action.hover : angleColor; + for (let i = 0; i < segmentCountAdjusted; i++) { + const angleValue = min + ((max - min) / segmentCountAdjusted) * i; + const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01; + const segmentColor = + angleValue >= value ? theme.colors.border.medium : (displayProcessor(angleValue).color ?? FALLBACK_COLOR); + const colorProps = angleValue < value && gradient ? { gradient } : { color: segmentColor }; - segments.push( - - ); + segments.push( + + ); + } + + return {segments}; } +); - return ( - <> - {segments} - {colorDefs.getDefs()} - - ); -} - -export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) { - // Max spacing is 8 degrees between segments - // Changing this constant could be considered a breaking change - const maxAngleBetweenSegments = Math.max(range / 1.5 / segmentCount, 2); - return segmentSpacing * maxAngleBetweenSegments; -} - -function getOptimalSegmentCount( - dimensions: GaugeDimensions, - segmentSpacing: number, - segmentCount: number, - range: number -) { - const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, range); - - const innerRadius = dimensions.radius - dimensions.barWidth / 2; - const circumference = Math.PI * innerRadius * 2 * (range / 360); - const maxSegments = Math.floor(circumference / (angleBetweenSegments + 3)); - - return Math.min(maxSegments, segmentCount); -} - -// export function RadialSegmentLine({ -// gaugeId, -// center, -// angle, -// size, -// color, -// barWidth, -// roundedBars, -// glow, -// margin, -// segmentWidth, -// }: RadialSegmentProps) { -// const arcSize = size - barWidth; -// const radius = arcSize / 2 - margin; - -// const angleRad = (Math.PI * (angle - 90)) / 180; -// const lineLength = radius - barWidth; - -// const x1 = center + radius * Math.cos(angleRad); -// const y1 = center + radius * Math.sin(angleRad); -// const x2 = center + lineLength * Math.cos(angleRad); -// const y2 = center + lineLength * Math.sin(angleRad); - -// return ( -// -// ); -// } +RadialBarSegmented.displayName = 'RadialBarSegmented'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx deleted file mode 100644 index 6bcf4876880..00000000000 --- a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import tinycolor from 'tinycolor2'; - -import { DisplayProcessor, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data'; - -import { RadialGradientMode, RadialShape } from './RadialGauge'; -import { GaugeDimensions } from './utils'; - -export interface RadialColorDefsOptions { - gradient: RadialGradientMode; - fieldDisplay: FieldDisplay; - theme: GrafanaTheme2; - dimensions: GaugeDimensions; - shape: RadialShape; - gaugeId: string; - displayProcessor: DisplayProcessor; -} - -export class RadialColorDefs { - private colorToIds: Record = {}; - private defs: React.ReactNode[] = []; - - constructor(private options: RadialColorDefsOptions) {} - - getSegmentColor(forValue: number): string { - const { displayProcessor } = this.options; - const baseColor = displayProcessor(forValue).color ?? FALLBACK_COLOR; - - return this.getColor(baseColor, true); - } - - getColor(baseColor: string, forSegment?: boolean): string { - const { gradient, dimensions, gaugeId, fieldDisplay, shape, theme } = this.options; - - const id = `value-color-${baseColor}-${gaugeId}`; - - if (this.colorToIds[id]) { - return this.colorToIds[id]; - } - - // If no gradient, just return the base color - if (gradient === 'none') { - this.colorToIds[id] = baseColor; - return baseColor; - } - - const returnColor = (this.colorToIds[id] = `url(#${id})`); - const colorModeId = fieldDisplay.field.color?.mode; - const colorMode = getFieldColorMode(colorModeId); - const valuePercent = fieldDisplay.display.percent ?? 0; - - // Handle continusous color modes first - // If it's a segment color we don't want to do continuous gradients - if (colorMode.isContinuous && colorMode.getColors && !forSegment) { - const colors = colorMode.getColors(theme); - const count = colors.length; - - this.defs.push( - - {colors.map((stopColor, i) => ( - - ))} - - ); - - return returnColor; - } - - // For value based colors we want to stay more true to the specific color - // So a radial gradient that adds a bit of light and shade works best - if (colorMode.isByValue) { - const color1 = tinycolor(baseColor).darken(5); - - this.defs.push( - - - - - - ); - - return returnColor; - } - - // For fixed / palette based color scales we can create a more fun - // hue and light based linear gradient that we rotate/move with the value - - const x2 = shape === 'circle' ? 0 : dimensions.centerX + dimensions.radius; - const y2 = shape === 'circle' ? dimensions.centerY + dimensions.radius : 0; - const color1 = tinycolor(baseColor).spin(-20).darken(5); - const color2 = tinycolor(baseColor).saturate(20).spin(20).brighten(10); - - // this makes it so the gradient is always brightest at the current value - const transform = - shape === 'circle' - ? `rotate(${360 * valuePercent - 180} ${dimensions.centerX} ${dimensions.centerY})` - : `translate(-${dimensions.radius * 2 * (1 - valuePercent)}, 0)`; - - this.defs.push( - - {theme.isDark ? ( - <> - - - - ) : ( - <> - - - - )} - - ); - - return returnColor; - } - - getMainBarColor(): string { - return this.getColor(this.options.fieldDisplay.display.color ?? FALLBACK_COLOR); - } - - getDefs(): React.ReactNode[] { - return this.defs; - } -} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx index c098beda886..5c5faa64db7 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx @@ -13,7 +13,8 @@ import { FieldColorModeId } from '@grafana/schema'; import { useTheme2 } from '../../themes/ThemeContext'; import { Stack } from '../Layout/Stack/Stack'; -import { RadialGauge, RadialGaugeProps, RadialGradientMode, RadialShape, RadialTextMode } from './RadialGauge'; +import { RadialGauge, RadialGaugeProps } from './RadialGauge'; +import { RadialShape, RadialTextMode } from './types'; interface StoryProps extends RadialGaugeProps { value: number; @@ -34,7 +35,6 @@ const meta: Meta = { }, args: { barWidthFactor: 0.2, - spotlight: false, glowBar: false, glowCenter: false, sparkline: false, @@ -42,7 +42,7 @@ const meta: Meta = { width: 200, height: 200, shape: 'circle', - gradient: 'none', + gradient: false, seriesCount: 1, segmentCount: 0, segmentSpacing: 0.2, @@ -56,14 +56,14 @@ const meta: Meta = { width: { control: { type: 'range', min: 50, max: 600 } }, height: { control: { type: 'range', min: 50, max: 600 } }, value: { control: { type: 'range', min: 0, max: 110 } }, - spotlight: { control: 'boolean' }, roundedBars: { control: 'boolean' }, sparkline: { control: 'boolean' }, thresholdsBar: { control: 'boolean' }, - gradient: { control: { type: 'radio' } }, + gradient: { control: { type: 'boolean' } }, seriesCount: { control: { type: 'range', min: 1, max: 20 } }, segmentCount: { control: { type: 'range', min: 0, max: 100 } }, segmentSpacing: { control: { type: 'range', min: 0, max: 1, step: 0.01 } }, + endpointMarker: { control: { type: 'select' }, options: ['none', 'point', 'glow'] }, colorScheme: { control: { type: 'select' }, options: [ @@ -102,57 +102,17 @@ export const Examples: StoryFn = (args) => {
Bar width
- - - - + + + +
Effects
- - - - + + + +
Shape: Gauge & color scale
@@ -160,14 +120,14 @@ export const Examples: StoryFn = (args) => { value={40} shape="gauge" width={250} - gradient="auto" + gradient colorScheme={FieldColorModeId.ContinuousGrYlRd} glowCenter={true} barWidthFactor={0.6} /> = (args) => { value={args.value ?? 70} color="blue" shape="gauge" - gradient="auto" + gradient sparkline={true} - spotlight glowBar={true} glowCenter={true} barWidthFactor={0.2} @@ -194,9 +153,8 @@ export const Examples: StoryFn = (args) => { value={args.value ?? 30} color="green" shape="gauge" - gradient="auto" + gradient sparkline={true} - spotlight glowBar={true} glowCenter={true} barWidthFactor={0.8} @@ -206,9 +164,8 @@ export const Examples: StoryFn = (args) => { color="red" shape="gauge" width={250} - gradient="auto" + gradient sparkline={true} - spotlight glowBar={true} glowCenter={true} barWidthFactor={0.2} @@ -218,9 +175,8 @@ export const Examples: StoryFn = (args) => { color="red" width={250} shape="gauge" - gradient="auto" + gradient sparkline={true} - spotlight glowBar={true} glowCenter={true} barWidthFactor={0.8} @@ -231,7 +187,7 @@ export const Examples: StoryFn = (args) => { = (args) => { = (args) => { = (args) => { = (args) => { value={args.value ?? 80} width={250} colorScheme={FieldColorModeId.ContinuousGrYlRd} - spotlight shape="gauge" - gradient="auto" + gradient glowBar={true} glowCenter={true} segmentCount={40} @@ -285,10 +239,9 @@ export const Examples: StoryFn = (args) => { @@ -296,7 +249,7 @@ export const Examples: StoryFn = (args) => { value={args.value ?? 70} width={250} colorScheme={FieldColorModeId.Thresholds} - gradient="auto" + gradient glowCenter={true} thresholdsBar={true} roundedBars={false} @@ -307,7 +260,7 @@ export const Examples: StoryFn = (args) => { value={args.value ?? 70} width={250} colorScheme={FieldColorModeId.Thresholds} - gradient="auto" + gradient glowCenter={true} thresholdsBar={true} roundedBars={false} @@ -347,14 +300,12 @@ export const Temp: StoryFn = (args) => { shape="gauge" roundedBars={false} barWidthFactor={0.8} - spotlight /> ); }; interface ExampleProps { - gradient?: RadialGradientMode; color?: string; seriesName?: string; value?: number; @@ -363,7 +314,7 @@ interface ExampleProps { max?: number; width?: number; height?: number; - spotlight?: boolean; + gradient?: boolean; glowBar?: boolean; glowCenter?: boolean; barWidthFactor?: number; @@ -376,12 +327,12 @@ interface ExampleProps { roundedBars?: boolean; thresholdsBar?: boolean; colorScheme?: FieldColorModeId; + endpointMarker?: RadialGaugeProps['endpointMarker']; decimals?: number; showScaleLabels?: boolean; } export function RadialGaugeExample({ - gradient = 'none', color, seriesName = 'Server A', value = 70, @@ -390,7 +341,7 @@ export function RadialGaugeExample({ max = 100, width = 200, height = 200, - spotlight = false, + gradient = false, glowBar = false, glowCenter = false, barWidthFactor = 0.4, @@ -403,6 +354,7 @@ export function RadialGaugeExample({ roundedBars = false, thresholdsBar = false, colorScheme = FieldColorModeId.Thresholds, + endpointMarker = 'glow', decimals = 0, showScaleLabels, }: ExampleProps) { @@ -480,7 +432,6 @@ export function RadialGaugeExample({ barWidthFactor={barWidthFactor} gradient={gradient} shape={shape} - spotlight={spotlight} glowBar={glowBar} glowCenter={glowCenter} textMode={textMode} @@ -490,6 +441,7 @@ export function RadialGaugeExample({ roundedBars={roundedBars} thresholdsBar={thresholdsBar} showScaleLabels={showScaleLabels} + endpointMarker={endpointMarker} /> ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.test.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.test.tsx index e2fed36ec3f..783e3b764da 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.test.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.test.tsx @@ -1,13 +1,28 @@ import { render, screen } from '@testing-library/react'; +import { ComponentProps } from 'react'; import { RadialGaugeExample } from './RadialGauge.story'; describe('RadialGauge', () => { - it('should render', () => { - render(); - - expect(screen.getByRole('img')).toBeInTheDocument(); - }); + it.each([ + { description: 'default', props: {} }, + { description: 'gauge shape', props: { shape: 'gauge' } }, + { description: 'with gradient', props: { gradient: true } }, + { description: 'with glow bar', props: { glowBar: true } }, + { description: 'with glow center', props: { glowCenter: true } }, + { description: 'with segments', props: { segmentCount: 5 } }, + { description: 'with rounded bars', props: { roundedBars: true } }, + { description: 'with endpoint marker glow', props: { roundedBars: true, endpointMarker: 'glow' } }, + { description: 'with endpoint marker point', props: { roundedBars: true, endpointMarker: 'point' } }, + { description: 'with thresholds bar', props: { thresholdsBar: true } }, + { description: 'with sparkline', props: { sparkline: true } }, + ] satisfies Array<{ description: string; props?: ComponentProps }>)( + 'should render $description without throwing', + ({ props }) => { + render(); + expect(screen.getByRole('img')).toBeInTheDocument(); + } + ); it('should render threshold labels', () => { render(); diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx index fadabf8ec72..452dd394ab1 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx @@ -1,14 +1,8 @@ import { css, cx } from '@emotion/css'; -import { isNumber } from 'lodash'; import { useId } from 'react'; -import { - DisplayValueAlignmentFactors, - FieldDisplay, - getDisplayProcessor, - GrafanaTheme2, - TimeRange, -} from '@grafana/data'; +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'; @@ -16,12 +10,13 @@ import { getFormattedThresholds } from '../Gauge/utils'; import { RadialBar } from './RadialBar'; import { RadialBarSegmented } from './RadialBarSegmented'; -import { RadialColorDefs } from './RadialColorDefs'; import { RadialScaleLabels } from './RadialScaleLabels'; import { RadialSparkline } from './RadialSparkline'; import { RadialText } from './RadialText'; import { ThresholdsBar } from './ThresholdsBar'; +import { buildGradientColors } from './colors'; import { GlowGradient, MiddleCircleGlow, SpotlightGradient } from './effects'; +import { RadialShape, RadialTextMode } from './types'; import { calculateDimensions, getValueAngleForValue } from './utils'; export interface RadialGaugeProps { @@ -32,7 +27,7 @@ export interface RadialGaugeProps { * Circle or gauge (partial circle) */ shape?: RadialShape; - gradient?: RadialGradientMode; + gradient?: boolean; /** * Bar width is always relative to size of the gauge. * But this gives you control over the width relative to size. @@ -40,12 +35,14 @@ export interface RadialGaugeProps { * Defaults to 0.4 **/ barWidthFactor?: number; - /** Adds a white spotlight for the end position */ - spotlight?: boolean; glowBar?: boolean; glowCenter?: boolean; roundedBars?: boolean; thresholdsBar?: boolean; + /** + * Specify if an endpoint marker should be shown at the end of the bar + */ + endpointMarker?: 'point' | 'glow'; /** * Number of segments depends on size of gauge but this * factor 1-10 gives you relative control @@ -75,10 +72,6 @@ export interface RadialGaugeProps { timeRange?: TimeRange; } -export type RadialGradientMode = 'none' | 'auto'; -export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none'; -export type RadialShape = 'circle' | 'gauge'; - /** * https://developers.grafana.com/ui/latest/index.html?path=/docs/plugins-radialgauge--docs */ @@ -87,9 +80,8 @@ export function RadialGauge(props: RadialGaugeProps) { width = 256, height = 256, shape = 'circle', - gradient = 'none', + gradient = false, barWidthFactor = 0.4, - spotlight = false, glowBar = false, glowCenter = false, textMode = 'auto', @@ -99,6 +91,7 @@ export function RadialGauge(props: RadialGaugeProps) { roundedBars = true, thresholdsBar = false, showScaleLabels = false, + endpointMarker, onClick, values, } = props; @@ -121,7 +114,8 @@ export function RadialGauge(props: RadialGaugeProps) { for (let barIndex = 0; barIndex < values.length; barIndex++) { const displayValue = values[barIndex]; const { angle, angleRange } = getValueAngleForValue(displayValue, startAngle, endAngle); - const color = displayValue.display.color ?? 'gray'; + const gradientStops = buildGradientColors(gradient, theme, displayValue); + const color = displayValue.display.color ?? FALLBACK_COLOR; const dimensions = calculateDimensions( width, height, @@ -134,20 +128,12 @@ export function RadialGauge(props: RadialGaugeProps) { showScaleLabels ); - const displayProcessor = getFieldDisplayProcessor(displayValue); + // FIXME: I want to move the ids for these filters into a context which the children + // can reference via a hook, rather than passing them down as props const spotlightGradientId = `spotlight-${barIndex}-${gaugeId}`; const glowFilterId = `glow-${gaugeId}`; - const colorDefs = new RadialColorDefs({ - gradient, - fieldDisplay: displayValue, - theme, - dimensions, - shape, - gaugeId, - displayProcessor, - }); - if (spotlight && theme.isDark) { + if (endpointMarker === 'glow') { defs.push( ); } else { @@ -179,13 +166,16 @@ export function RadialGauge(props: RadialGaugeProps) { ); } @@ -245,7 +235,8 @@ export function RadialGauge(props: RadialGaugeProps) { angleRange={angleRange} roundedBars={roundedBars} glowFilter={`url(#${glowFilterId})`} - colorDefs={colorDefs} + shape={shape} + gradient={gradientStops} /> ); } @@ -285,23 +276,16 @@ export function RadialGauge(props: RadialGaugeProps) { } return ( -
+
{body}
); } -function getFieldDisplayProcessor(displayValue: FieldDisplay) { - if (displayValue.view && isNumber(displayValue.colIndex)) { - const dp = displayValue.view.getFieldDisplayProcessor(displayValue.colIndex); - if (dp) { - return dp; - } - } - - return getDisplayProcessor(); -} - function getStyles(theme: GrafanaTheme2) { return { vizWrapper: css({ diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx index 994b3b35eac..6588150602a 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx @@ -1,87 +1,84 @@ +import { memo } from 'react'; + import { FieldDisplay, GrafanaTheme2, Threshold } from '@grafana/data'; import { t } from '@grafana/i18n'; import { measureText } from '../../utils/measureText'; -import { GaugeDimensions, toCartesian } from './utils'; +import { RadialGaugeDimensions } from './types'; +import { getFieldConfigMinMax, toCartesian } from './utils'; interface RadialScaleLabelsProps { fieldDisplay: FieldDisplay; theme: GrafanaTheme2; thresholds: Threshold[]; - dimensions: GaugeDimensions; + dimensions: RadialGaugeDimensions; startAngle: number; endAngle: number; angleRange: number; } -export function RadialScaleLabels({ - fieldDisplay, - thresholds, - theme, - dimensions, - startAngle, - endAngle, - angleRange, -}: RadialScaleLabelsProps) { - const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions; +const LINE_HEIGHT_FACTOR = 1.2; - const fieldConfig = fieldDisplay.field; - const min = fieldConfig.min ?? 0; - const max = fieldConfig.max ?? 100; +export const RadialScaleLabels = memo( + ({ fieldDisplay, thresholds, theme, dimensions, startAngle, endAngle, angleRange }: RadialScaleLabelsProps) => { + const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions; + const [min, max] = getFieldConfigMinMax(fieldDisplay); - const fontSize = scaleLabelsFontSize; - const textLineHeight = scaleLabelsFontSize * 1.2; - const radius = scaleLabelsRadius - textLineHeight; + const fontSize = scaleLabelsFontSize; + const textLineHeight = scaleLabelsFontSize * LINE_HEIGHT_FACTOR; + const radius = scaleLabelsRadius - textLineHeight; - function getTextPosition(text: string, value: number, index: number) { - const isLast = index === thresholds.length - 1; - const isFirst = index === 0; + function getTextPosition(text: string, value: number, index: number) { + const isLast = index === thresholds.length - 1; + const isFirst = index === 0; - let valueDeg = ((value - min) / (max - min)) * angleRange; - let finalAngle = startAngle + valueDeg; + let valueDeg = ((value - min) / (max - min)) * angleRange; + let finalAngle = startAngle + valueDeg; - // Now adjust the final angle based on the label text width and the labels position on the arc - let measure = measureText(text, fontSize, theme.typography.fontWeightMedium); - let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange; + // Now adjust the final angle based on the label text width and the labels position on the arc + let measure = measureText(text, fontSize, theme.typography.fontWeightMedium); + let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange; - // the centering is different for gauge or circle shapes for some reason - finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2; + // the centering is different for gauge or circle shapes for some reason + finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2; - // For circle gauges we need to shift the first label more - if (isFirst) { - finalAngle += textWidthAngle; + // For circle gauges we need to shift the first label more + if (isFirst) { + finalAngle += textWidthAngle; + } + + // For circle gauges we need to shift the last label more + if (isLast && endAngle === 360) { + finalAngle -= textWidthAngle; + } + + const position = toCartesian(centerX, centerY, radius, finalAngle); + + return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` }; } - // For circle gauges we need to shift the last label more - if (isLast && endAngle === 360) { - finalAngle -= textWidthAngle; - } - - const position = toCartesian(centerX, centerY, radius, finalAngle); - - return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` }; + return ( + + {thresholds.map((threshold, index) => { + const labelPos = getTextPosition(String(threshold.value), threshold.value, index); + return ( + + {threshold.value} + + ); + })} + + ); } +); - return ( - - {thresholds.map((threshold, index) => { - const labelPos = getTextPosition(String(threshold.value), threshold.value, index); - - return ( - - {threshold.value} - - ); - })} - - ); -} +RadialScaleLabels.displayName = 'RadialScaleLabels'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx index acb255a3f3e..4a52d5241d5 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx @@ -1,49 +1,76 @@ +import { memo, useMemo } from 'react'; + import { FieldDisplay, GrafanaTheme2, FieldConfig } from '@grafana/data'; import { GraphFieldConfig, GraphGradientMode, LineInterpolation } from '@grafana/schema'; import { Sparkline } from '../Sparkline/Sparkline'; -import { RadialShape, RadialTextMode } from './RadialGauge'; -import { GaugeDimensions } from './utils'; +import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types'; interface RadialSparklineProps { - sparkline: FieldDisplay['sparkline']; - dimensions: GaugeDimensions; - theme: GrafanaTheme2; color?: string; - shape?: RadialShape; + dimensions: RadialGaugeDimensions; + shape: RadialShape; + sparkline: FieldDisplay['sparkline']; textMode: Exclude; + theme: GrafanaTheme2; } -export function RadialSparkline({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) { - const { radius, barWidth } = dimensions; - if (!sparkline) { - return null; +const SPARKLINE_HEIGHT_DIVISOR = 4; +const SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE = 4; +const SPARKLINE_WIDTH_FACTOR_ARC = 1.4; +const SPARKLINE_WIDTH_FACTOR_CIRCLE = 1.6; +const SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE = 4; +const SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE = 3.3; +const SPARKLINE_SPACING = 8; + +export function getSparklineDimensions( + radius: number, + barWidth: number, + showNameAndValue: boolean, + shape: RadialShape +): { width: number; height: number } { + const height = radius / (showNameAndValue ? SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE : SPARKLINE_HEIGHT_DIVISOR); + const width = radius * (shape === 'gauge' ? SPARKLINE_WIDTH_FACTOR_ARC : SPARKLINE_WIDTH_FACTOR_CIRCLE) - barWidth; + return { width, height }; +} + +export const RadialSparkline = memo( + ({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) => { + const { radius, barWidth } = dimensions; + + const showNameAndValue = textMode === 'value_and_name'; + const { width, height } = getSparklineDimensions(radius, barWidth, showNameAndValue, shape); + const topPos = + shape === 'gauge' + ? dimensions.gaugeBottomY - height - SPARKLINE_SPACING + : `calc(50% + ${radius / (showNameAndValue ? SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE : SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE)}px)`; + + const config: FieldConfig = useMemo( + () => ({ + color: { + mode: 'fixed', + fixedColor: color ?? 'blue', + }, + custom: { + gradientMode: GraphGradientMode.Opacity, + fillOpacity: 40, + lineInterpolation: LineInterpolation.Smooth, + }, + }), + [color] + ); + + if (!sparkline) { + return null; + } + + return ( +
+ +
+ ); } +); - const showNameAndValue = textMode === 'value_and_name'; - const height = radius / (showNameAndValue ? 4 : 3); - const width = radius * (shape === 'gauge' ? 1.6 : 1.4) - barWidth; - const topPos = - shape === 'gauge' - ? `${dimensions.gaugeBottomY - height}px` - : `calc(50% + ${radius / (showNameAndValue ? 3.3 : 4)}px)`; - - const config: FieldConfig = { - color: { - mode: 'fixed', - fixedColor: color ?? 'blue', - }, - custom: { - gradientMode: GraphGradientMode.Opacity, - fillOpacity: 40, - lineInterpolation: LineInterpolation.Smooth, - }, - }; - - return ( -
- -
- ); -} +RadialSparkline.displayName = 'RadialSparkline'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx index 51a1c64c842..69ab16e450e 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import { memo } from 'react'; import { DisplayValue, @@ -11,13 +12,12 @@ import { import { useStyles2 } from '../../themes/ThemeContext'; import { calculateFontSize } from '../../utils/measureText'; -import { RadialShape, RadialTextMode } from './RadialGauge'; -import { GaugeDimensions } from './utils'; +import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types'; interface RadialTextProps { displayValue: DisplayValue; theme: GrafanaTheme2; - dimensions: GaugeDimensions; + dimensions: RadialGaugeDimensions; textMode: Exclude; shape: RadialShape; sparkline?: FieldSparkline; @@ -26,123 +26,137 @@ interface RadialTextProps { nameManualFontSize?: number; } -export function RadialText({ - displayValue, - theme, - dimensions, - textMode, - shape, - sparkline, - alignmentFactors, - valueManualFontSize, - nameManualFontSize, -}: RadialTextProps) { - const styles = useStyles2(getStyles); - const { centerX, centerY, radius, barWidth } = dimensions; +const LINE_HEIGHT_FACTOR = 1.21; +const VALUE_WIDTH_TO_RADIUS_FACTOR = 0.82; +const NAME_TO_HEIGHT_FACTOR = 0.45; +const LARGE_RADIUS_SCALING_DECAY = 0.86; +const MAX_TEXT_WIDTH_DIVISOR = 7; +const MAX_NAME_HEIGHT_DIVISOR = 4; +const VALUE_SPACE_PERCENTAGE = 0.7; +const SPARKLINE_SPACING = 8; +const MIN_VALUE_FONT_SIZE = 1; +const MIN_NAME_FONT_SIZE = 10; +const MIN_UNIT_FONT_SIZE = 6; - if (textMode === 'none') { - return null; - } +export const RadialText = memo( + ({ + displayValue, + theme, + dimensions, + textMode, + shape, + sparkline, + alignmentFactors, + valueManualFontSize, + nameManualFontSize, + }: RadialTextProps) => { + const styles = useStyles2(getStyles); + const { centerX, centerY, radius, barWidth } = dimensions; - const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? ''; - const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue); + if (textMode === 'none') { + return null; + } - const showValue = textMode === 'value' || textMode === 'value_and_name'; - const showName = textMode === 'name' || textMode === 'value_and_name'; - const maxTextWidth = radius * 2 - barWidth - radius / 7; + const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? ''; + const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue); - // Not sure where this comes from but svg text is not using body line-height - const lineHeight = 1.21; - const valueWidthToRadiusFactor = 0.82; - const nameToHeightFactor = 0.45; - const largeRadiusScalingDecay = 0.86; + const showValue = textMode === 'value' || textMode === 'value_and_name'; + const showName = textMode === 'name' || textMode === 'value_and_name'; + const maxTextWidth = radius * 2 - barWidth - radius / MAX_TEXT_WIDTH_DIVISOR; - // This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels - let maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay); - let maxNameHeight = radius / 4; + // This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels + let maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY); + let maxNameHeight = radius / MAX_NAME_HEIGHT_DIVISOR; - if (showValue && showName) { - maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay); - maxNameHeight = nameToHeightFactor * Math.pow(radius, largeRadiusScalingDecay); - } + if (showValue && showName) { + maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY); + maxNameHeight = NAME_TO_HEIGHT_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY); + } - const valueFontSize = - valueManualFontSize ?? - calculateFontSize( - valueToAlignTo, - maxTextWidth, - maxValueHeight, - lineHeight, - undefined, - theme.typography.body.fontWeight + const valueFontSize = Math.max( + valueManualFontSize ?? + calculateFontSize( + valueToAlignTo, + maxTextWidth, + maxValueHeight, + LINE_HEIGHT_FACTOR, + undefined, + theme.typography.body.fontWeight + ), + MIN_VALUE_FONT_SIZE ); - const nameFontSize = - nameManualFontSize ?? - calculateFontSize( - nameToAlignTo, - maxTextWidth, - maxNameHeight, - lineHeight, - undefined, - theme.typography.body.fontWeight + const nameFontSize = Math.max( + nameManualFontSize ?? + calculateFontSize( + nameToAlignTo, + maxTextWidth, + maxNameHeight, + LINE_HEIGHT_FACTOR, + undefined, + theme.typography.body.fontWeight + ), + MIN_NAME_FONT_SIZE ); - const unitFontSize = Math.max(valueFontSize * 0.7, 5); - const valueHeight = valueFontSize * lineHeight; - const nameHeight = nameFontSize * lineHeight; + const unitFontSize = Math.max(valueFontSize * VALUE_SPACE_PERCENTAGE, MIN_UNIT_FONT_SIZE); + const valueHeight = valueFontSize * LINE_HEIGHT_FACTOR; + const nameHeight = nameFontSize * LINE_HEIGHT_FACTOR; - const valueY = showName ? centerY - nameHeight * 0.3 : centerY; - const nameY = showValue ? valueY + valueHeight * 0.7 : centerY; - const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary; - const suffixShift = (valueFontSize - unitFontSize * 1.2) / 2; + const valueY = showName ? centerY - nameHeight * (1 - VALUE_SPACE_PERCENTAGE) : centerY; + const nameY = showValue ? valueY + valueHeight * VALUE_SPACE_PERCENTAGE : centerY; + const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary; + const suffixShift = (valueFontSize - unitFontSize * LINE_HEIGHT_FACTOR) / 2; - // adjust the text up on gauges and when sparklines are present - let yOffset = 0; - if (shape === 'gauge') { - // we render from the center of the gauge, so move up by half of half of the total height - yOffset -= (valueHeight + nameHeight) / 4; - } - if (sparkline) { - yOffset -= 8; + // adjust the text up on gauges and when sparklines are present + let yOffset = 0; + if (shape === 'gauge') { + // we render from the center of the gauge, so move up by half of half of the total height + yOffset -= (valueHeight + nameHeight) / 4; + } + if (sparkline) { + yOffset -= SPARKLINE_SPACING; + } + + return ( + + {showValue && ( + + {displayValue.prefix ?? ''} + {displayValue.text} + + {displayValue.suffix ?? ''} + + + )} + {showName && ( + + {displayValue.title} + + )} + + ); } +); - return ( - - {showValue && ( - - {displayValue.prefix ?? ''} - {displayValue.text} - - {displayValue.suffix ?? ''} - - - )} - {showName && ( - - {displayValue.title} - - )} - - ); -} +RadialText.displayName = 'RadialText'; -const getStyles = (theme: GrafanaTheme2) => ({ +const getStyles = (_theme: GrafanaTheme2) => ({ text: css({ verticalAlign: 'bottom', }), diff --git a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx index 602038ccf10..cb2829934b9 100644 --- a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx @@ -1,20 +1,22 @@ import { FieldDisplay, Threshold } from '@grafana/data'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; -import { GaugeDimensions } from './utils'; +import { GradientStop, RadialGaugeDimensions, RadialShape } from './types'; +import { getFieldConfigMinMax } from './utils'; -export interface Props { - dimensions: GaugeDimensions; +interface ThresholdsBarProps { + dimensions: RadialGaugeDimensions; angleRange: number; startAngle: number; endAngle: number; + shape: RadialShape; fieldDisplay: FieldDisplay; roundedBars?: boolean; glowFilter?: string; - colorDefs: RadialColorDefs; thresholds: Threshold[]; + gradient?: GradientStop[]; } + export function ThresholdsBar({ dimensions, fieldDisplay, @@ -22,19 +24,18 @@ export function ThresholdsBar({ angleRange, roundedBars, glowFilter, - colorDefs, thresholds, -}: Props) { - const fieldConfig = fieldDisplay.field; - const min = fieldConfig.min ?? 0; - const max = fieldConfig.max ?? 100; - + shape, + gradient, +}: ThresholdsBarProps) { const thresholdDimensions = { ...dimensions, barWidth: dimensions.thresholdsBarWidth, radius: dimensions.thresholdsBarRadius, }; + const [min, max] = getFieldConfigMinMax(fieldDisplay); + let currentStart = startAngle; let paths: React.ReactNode[] = []; @@ -48,27 +49,26 @@ export function ThresholdsBar({ valueDeg = 0; } - let lengthDeg = valueDeg - currentStart + startAngle; + const lengthDeg = valueDeg - currentStart + startAngle; + const colorProps = gradient ? { gradient } : { color: threshold.color }; paths.push( ); currentStart += lengthDeg; } - return ( - <> - {paths} - {colorDefs.getDefs()} - - ); + return {paths}; } diff --git a/packages/grafana-ui/src/components/RadialGauge/__snapshots__/colors.test.ts.snap b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/colors.test.ts.snap new file mode 100644 index 00000000000..97b053c2d61 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/colors.test.ts.snap @@ -0,0 +1,144 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RadialGauge color utils buildGradientColors should map threshold colors correctly (with baseColor if displayProcessor does not return colors) 1`] = ` +[ + { + "color": "#444444", + "percent": 0, + }, + { + "color": "#FADE2A", + "percent": 0.5, + }, + { + "color": "#F2495C", + "percent": 0.8, + }, + { + "color": "#444444", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should map threshold colors correctly (with baseColor if displayProcessor does not return colors) 2`] = ` +[ + { + "color": "#FF0000", + "percent": 0, + }, + { + "color": "#FADE2A", + "percent": 0.5, + }, + { + "color": "#F2495C", + "percent": 0.8, + }, + { + "color": "#FF0000", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for by-value color mode in dark theme 1`] = ` +[ + { + "color": "#181b1f", + "percent": 0, + }, + { + "color": "#1F60C4", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for by-value color mode in light theme 1`] = ` +[ + { + "color": "#ffffff", + "percent": 0, + }, + { + "color": "#1250B0", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for continuous color modes 1`] = ` +[ + { + "color": "rgb(0, 32, 81)", + "percent": 0, + }, + { + "color": "rgb(17, 54, 108)", + "percent": 0.125, + }, + { + "color": "rgb(60, 77, 110)", + "percent": 0.25, + }, + { + "color": "rgb(98, 100, 111)", + "percent": 0.375, + }, + { + "color": "rgb(127, 124, 117)", + "percent": 0.5, + }, + { + "color": "rgb(154, 148, 120)", + "percent": 0.625, + }, + { + "color": "rgb(187, 175, 113)", + "percent": 0.75, + }, + { + "color": "rgb(226, 203, 92)", + "percent": 0.875, + }, + { + "color": "rgb(253, 234, 69)", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for fixed color mode in dark theme 1`] = ` +[ + { + "color": "#37237a", + "percent": 0, + }, + { + "color": "#a146da", + "percent": 0.75, + }, + { + "color": "#a146da", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for fixed color mode in light theme 1`] = ` +[ + { + "color": "#a146da", + "percent": 0, + }, + { + "color": "#3e2b9a", + "percent": 0.75, + }, + { + "color": "#3e2b9a", + "percent": 1, + }, +] +`; diff --git a/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap new file mode 100644 index 00000000000..db4c1c40882 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 110 A 90 90 0 1 1 149.98429203681178 110.00000137077838 A 10 10 0 0 1 149.98778269529805 130.00000106616096 A 70 70 0 1 0 150 130 A 10 10 0 0 1 150 110 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 10 A 90 90 0 0 1 100 190 L 100 170 A 70 70 0 0 0 100 30 L 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 17.5 A 82.5 82.5 0 0 1 100 182.5 L 100 177.5 A 77.5 77.5 0 0 0 100 22.5 L 100 17.5 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 40 A 60 60 0 0 1 100 160 L 100 140 A 40 40 0 0 0 100 60 L 100 40 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 10 A 90 90 0 0 1 190 100 L 170 100 A 70 70 0 0 0 100 30 L 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 A 10 10 0 0 1 30 100.00000000000001 A 70 70 0 1 0 100 30 A 10 10 0 0 1 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 L 30 100.00000000000001 A 70 70 0 1 0 100 30 L 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 -5 A 105 105 0 0 1 100 205 L 100 155 A 55 55 0 0 0 100 45 L 100 -5 Z"`; diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.test.ts b/packages/grafana-ui/src/components/RadialGauge/colors.test.ts new file mode 100644 index 00000000000..321e95bb921 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/colors.test.ts @@ -0,0 +1,306 @@ +import { defaultsDeep } from 'lodash'; + +import { createTheme, FALLBACK_COLOR, Field, FieldDisplay, FieldType, ThresholdsMode } from '@grafana/data'; +import { FieldColorModeId } from '@grafana/schema'; + +import { + buildGradientColors, + colorAtGradientPercent, + getBarEndcapColors, + getEndpointMarkerColors, + getGradientCss, + getGradientStopsForPercent, +} from './colors'; + +export type DeepPartial = { + [P in keyof T]?: DeepPartial; +}; + +describe('RadialGauge color utils', () => { + describe('buildGradientColors', () => { + const createField = (colorMode: FieldColorModeId): Field => + ({ + type: FieldType.number, + name: 'Test Field', + config: { + color: { + mode: colorMode, + }, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { value: -Infinity, color: 'green' }, + { value: 50, color: 'yellow' }, + { value: 80, color: 'red' }, + ], + }, + }, + values: [70, 40, 30, 90, 55], + }) satisfies Field; + + const buildFieldDisplay = (field: Field, part = {}): FieldDisplay => + defaultsDeep(part, { + field: field.config, + colIndex: 0, + view: { + getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: undefined }))), + }, + display: { + numeric: 75, + }, + }); + + it('should return the baseColor if gradient is false-y', () => { + expect( + buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000') + ).toEqual([ + { color: '#FF0000', percent: 0 }, + { color: '#FF0000', percent: 1 }, + ]); + + expect( + buildGradientColors(undefined, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000') + ).toEqual([ + { color: '#FF0000', percent: 0 }, + { color: '#FF0000', percent: 1 }, + ]); + }); + + it('uses the fallback color if no baseColor is set', () => { + expect(buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)))).toEqual( + [ + { color: FALLBACK_COLOR, percent: 0 }, + { color: FALLBACK_COLOR, percent: 1 }, + ] + ); + }); + + it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => { + expect( + buildGradientColors( + true, + createTheme(), + buildFieldDisplay(createField(FieldColorModeId.Thresholds), { + view: { getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: '#444444' }))) }, + }) + ) + ).toMatchSnapshot(); + }); + + it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => { + expect( + buildGradientColors(true, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000') + ).toMatchSnapshot(); + }); + + it('should return gradient colors for continuous color modes', () => { + expect( + buildGradientColors( + true, + createTheme(), + buildFieldDisplay(createField(FieldColorModeId.ContinuousCividis)), + '#00FF00' + ) + ).toMatchSnapshot(); + }); + + it.each(['dark', 'light'] as const)('should return gradient colors for by-value color mode in %s theme', (mode) => { + expect( + buildGradientColors( + true, + createTheme({ colors: { mode } }), + buildFieldDisplay(createField(FieldColorModeId.ContinuousBlues)) + ) + ).toMatchSnapshot(); + }); + + it.each(['dark', 'light'] as const)('should return gradient colors for fixed color mode in %s theme', (mode) => { + expect( + buildGradientColors( + true, + createTheme({ colors: { mode } }), + buildFieldDisplay(createField(FieldColorModeId.Fixed)), + '#442299' + ) + ).toMatchSnapshot(); + }); + }); + + describe('colorAtGradientPercent', () => { + it('should calculate the color at a given percent in a gradient of two colors', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#0000ff', percent: 1 }, + ]; + expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#bf0040'); + expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#800080'); + expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#4000bf'); + expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff'); + }); + + it('should calculate the color at a given percent in a gradient of multiple colors', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#808000'); + expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#00ff00'); + expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#008080'); + expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff'); + }); + + it('will still work if unsorted', () => { + const gradient = [ + { color: '#0000ff', percent: 1 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#ff0000', percent: 0 }, + ]; + expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#808000'); + expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#00ff00'); + expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#008080'); + expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff'); + }); + + it('should not throw an error when percent is outside 0-1 range', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#0000ff', percent: 1 }, + ]; + expect(colorAtGradientPercent(gradient, -0.5).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 1.5).toHexString()).toBe('#0000ff'); + }); + + it('should throw an error when less than two stops are provided', () => { + expect(() => { + colorAtGradientPercent([], 0.5); + }).toThrow('colorAtGradientPercent requires at least two color stops'); + expect(() => { + colorAtGradientPercent([{ color: '#ff0000', percent: 0 }], 0.5); + }).toThrow('colorAtGradientPercent requires at least two color stops'); + }); + }); + + describe('getBarEndcapColors', () => { + it('should return the first and last colors in the gradient', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const [startColor, endColor] = getBarEndcapColors(gradient); + expect(startColor).toBe('#ff0000'); + expect(endColor).toBe('#0000ff'); + }); + + it('should return the correct end color based on percent', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const [startColor, endColor] = getBarEndcapColors(gradient, 0.25); + expect(startColor).toBe('#ff0000'); + expect(endColor).toBe('#808000'); + }); + + it('should handle gradients with only one colors', () => { + const gradient = [{ color: '#ff0000', percent: 0 }]; + const [startColor, endColor] = getBarEndcapColors(gradient); + expect(startColor).toBe('#ff0000'); + expect(endColor).toBe('#ff0000'); + }); + + it('should throw an error when no colors are provided', () => { + expect(() => { + getBarEndcapColors([]); + }).toThrow('getBarEndcapColors requires at least one color stop'); + }); + }); + + describe('getGradientCss', () => { + it('should return conic-gradient CSS for circle shape', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const css = getGradientCss(gradient, 'circle'); + expect(css).toBe('conic-gradient(from 0deg, #ff0000 0.00%, #00ff00 50.00%, #0000ff 100.00%)'); + }); + + it('should return linear-gradient CSS for arc shape', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const css = getGradientCss(gradient, 'gauge'); + expect(css).toBe('linear-gradient(90deg, #ff0000 0.00%, #00ff00 50.00%, #0000ff 100.00%)'); + }); + }); + + describe('getEndpointMarkerColors', () => { + it('should return contrasting guide dot colors based on the gradient endpoints and percent', () => { + const gradient = [ + { color: '#000000', percent: 0 }, + { color: '#ffffff', percent: 0.5 }, + { color: '#ffffff', percent: 1 }, + ]; + const [startDotColor, endDotColor] = getEndpointMarkerColors(gradient, 0.35); + expect(startDotColor).toBe('#fbfbfb'); + expect(endDotColor).toBe('#111217'); + }); + }); + + describe('getGradientStopsForPercent', () => { + it('should return the correct gradient stops for a given percent', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const [left, right] = getGradientStopsForPercent(gradient, 0.25); + expect(left).toEqual({ color: '#ff0000', percent: 0 }); + expect(right).toEqual({ color: '#00ff00', percent: 0.5 }); + }); + + it('should handle edge cases where percent is at the boundaries', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + let [left, right] = getGradientStopsForPercent(gradient, 0); + expect(left).toEqual({ color: '#ff0000', percent: 0 }); + expect(right).toEqual({ color: '#ff0000', percent: 0 }); + + [left, right] = getGradientStopsForPercent(gradient, 1); + expect(left).toEqual({ color: '#0000ff', percent: 1 }); + expect(right).toEqual({ color: '#0000ff', percent: 1 }); + }); + + it('should return the same stop if there is one that is equal to the percentage', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + + let [left, right] = getGradientStopsForPercent(gradient, 0); + expect(left).toEqual({ color: '#ff0000', percent: 0 }); + expect(right).toEqual({ color: '#ff0000', percent: 0 }); + + [left, right] = getGradientStopsForPercent(gradient, 0.5); + expect(left).toEqual({ color: '#00ff00', percent: 0.5 }); + expect(right).toEqual({ color: '#00ff00', percent: 0.5 }); + + [left, right] = getGradientStopsForPercent(gradient, 1); + expect(left).toEqual({ color: '#0000ff', percent: 1 }); + expect(right).toEqual({ color: '#0000ff', percent: 1 }); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.ts b/packages/grafana-ui/src/components/RadialGauge/colors.ts new file mode 100644 index 00000000000..61160a9f826 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/colors.ts @@ -0,0 +1,195 @@ +import tinycolor from 'tinycolor2'; + +import { colorManipulator, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data'; +import { FieldColorModeId } from '@grafana/schema'; + +import { GradientStop, RadialShape } from './types'; +import { getFieldConfigMinMax, getFieldDisplayProcessor, getValuePercentageForValue } from './utils'; + +export function buildGradientColors( + gradient = false, + theme: GrafanaTheme2, + fieldDisplay: FieldDisplay, + baseColor = fieldDisplay.display.color ?? FALLBACK_COLOR +): GradientStop[] { + if (!gradient) { + return [ + { color: baseColor, percent: 0 }, + { color: baseColor, percent: 1 }, + ]; + } + + const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode); + + // thresholds get special handling + if (colorMode.id === FieldColorModeId.Thresholds) { + const displayProcessor = getFieldDisplayProcessor(fieldDisplay); + const [min, max] = getFieldConfigMinMax(fieldDisplay); + const thresholds = fieldDisplay.field.thresholds?.steps ?? []; + + const result: Array<{ color: string; percent: number }> = [ + { color: displayProcessor(min).color ?? baseColor, percent: 0 }, + ]; + + for (const threshold of thresholds) { + if (threshold.value > min && threshold.value < max) { + const percent = (threshold.value - min) / (max - min); + result.push({ color: theme.visualization.getColorByName(threshold.color), percent }); + } + } + + result.push({ color: displayProcessor(max).color ?? baseColor, percent: 1 }); + + return result; + } + + // Handle continuous color modes before other by-value modes + if (colorMode.isContinuous && colorMode.getColors) { + const colors = colorMode.getColors(theme); + return colors.map((color, idx) => ({ color, percent: idx / (colors.length - 1) })); + } + + // For value-based colors, we want to stay more true to the specific color, + // so a radial gradient that adds a bit of light and shade works best + if (colorMode.isByValue) { + const darkerColor = tinycolor(baseColor).darken(5); + const lighterColor = tinycolor(baseColor).spin(20).lighten(10); + + const color1 = theme.isDark ? lighterColor : darkerColor; + const color2 = theme.isDark ? darkerColor : lighterColor; + + return [ + { color: color1.toString(), percent: 0 }, + { color: color2.toString(), percent: 0.6 }, + { color: color2.toString(), percent: 1 }, + ]; + } + + // For fixed / palette based color scales we can create a more hue and light + // based linear gradient that we rotate with the value + const darkerColor = tinycolor(baseColor) + .spin(-20) + .darken(theme.isDark ? 15 : 5); + const lighterColor = tinycolor(baseColor).saturate(20).spin(20).brighten(10).lighten(10); + + const underlyingGradient = [ + { color: theme.isDark ? darkerColor.toString() : lighterColor.toString(), percent: 0 }, + { color: theme.isDark ? lighterColor.toString() : darkerColor.toString(), percent: 1 }, + ]; + + // rotate the gradient so that the highest contrasting point is the value, depending on theme. + const valuePercent = getValuePercentageForValue(fieldDisplay); + const startColor = theme.isDark + ? colorAtGradientPercent(underlyingGradient, 1 - valuePercent).toHexString() + : underlyingGradient[0].color; + const endColor = theme.isDark + ? underlyingGradient[1].color + : colorAtGradientPercent(underlyingGradient, valuePercent).toHexString(); + return [ + { color: startColor, percent: 0 }, + { color: endColor, percent: valuePercent }, + { color: endColor, percent: 1 }, + ]; +} + +/** + * get the relevant gradient stops surrounding a given percentage. could be same stop if the + * percent matches a stop exactly. + * + * @param sortedGradientStops - gradient stops sorted by percent + * @param percent - percentage 0..1 + * @returns {[GradientStop, GradientStop]} - the two gradient stops surrounding the given percentage + */ +export function getGradientStopsForPercent( + sortedGradientStops: GradientStop[], + percent: number +): [GradientStop, GradientStop] { + if (percent <= 0) { + return [sortedGradientStops[0], sortedGradientStops[0]]; + } + if (percent >= 1) { + const last = sortedGradientStops.length - 1; + return [sortedGradientStops[last], sortedGradientStops[last]]; + } + + // find surrounding stops using binary search + let lo = 0; + let hi = sortedGradientStops.length - 1; + while (lo + 1 < hi) { + const mid = (lo + hi) >> 1; + if (percent === sortedGradientStops[mid].percent) { + return [sortedGradientStops[mid], sortedGradientStops[mid]]; + } + + if (percent < sortedGradientStops[mid].percent) { + hi = mid; + } else { + lo = mid; + } + } + return [sortedGradientStops[lo], sortedGradientStops[hi]]; +} + +/** + * @alpha - perhaps this should go in colorManipulator.ts + * Given color stops (each with a color and percentage 0..1) returns the color at a given percentage. + * Uses tinycolor.mix for interpolation. + * @params stops - array of color stops (percentages 0..1) + * @params percent - percentage 0..1 + * @returns color at the given percentage + */ +export function colorAtGradientPercent(stops: GradientStop[], percent: number): tinycolor.Instance { + if (!stops || stops.length < 2) { + throw new Error('colorAtGradientPercent requires at least two color stops'); + } + + const sorted = stops + .map((s: GradientStop): GradientStop => ({ color: s.color, percent: Math.min(Math.max(0, s.percent), 1) })) + .sort((a: GradientStop, b: GradientStop) => a.percent - b.percent); + + const [left, right] = getGradientStopsForPercent(sorted, percent); + const range = right.percent - left.percent; + const t = range === 0 ? 0 : (percent - left.percent) / range; // 0..1 + return tinycolor.mix(left.color, right.color, t * 100); +} + +export function getBarEndcapColors(gradientStops: GradientStop[], percent = 1): [string, string] { + if (gradientStops.length === 0) { + throw new Error('getBarEndcapColors requires at least one color stop'); + } + + const startColor = gradientStops[0].color; + let endColor = gradientStops[gradientStops.length - 1].color; + + // if we have a percentageFilled, use it to get a the correct end color based on where the bar terminates + if (gradientStops.length >= 2) { + const endColorByPercentage = colorAtGradientPercent(gradientStops, percent); + endColor = + endColorByPercentage.getAlpha() === 1 ? endColorByPercentage.toHexString() : endColorByPercentage.toHex8String(); + } + return [startColor, endColor]; +} + +export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape): string { + const colorStrings = gradientStops.map((stop) => `${stop.color} ${(stop.percent * 100).toFixed(2)}%`); + if (shape === 'circle') { + return `conic-gradient(from 0deg, ${colorStrings.join(', ')})`; + } + return `linear-gradient(90deg, ${colorStrings.join(', ')})`; +} + +// the theme does not make the full palette available to us, and we +// don't want transparent colors which our grays usually have. +const GRAY_05 = '#111217'; +const GRAY_90 = '#fbfbfb'; +const CONTRAST_THRESHOLD_MAX = 4.5; +const getGuideDotColor = (color: string): string => { + const darkColor = GRAY_05; + const lightColor = GRAY_90; + return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor; +}; + +export function getEndpointMarkerColors(gradientStops: GradientStop[], percent = 1): [string, string] { + const [startColor, endColor] = getBarEndcapColors(gradientStops, percent); + return [getGuideDotColor(startColor), getGuideDotColor(endColor)]; +} diff --git a/packages/grafana-ui/src/components/RadialGauge/effects.tsx b/packages/grafana-ui/src/components/RadialGauge/effects.tsx index 354a68a25ba..53a255d4a45 100644 --- a/packages/grafana-ui/src/components/RadialGauge/effects.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/effects.tsx @@ -1,15 +1,18 @@ -import { GrafanaTheme2 } from '@grafana/data'; +import { colorManipulator, GrafanaTheme2 } from '@grafana/data'; -import { GaugeDimensions } from './utils'; +import { RadialGaugeDimensions } from './types'; export interface GlowGradientProps { id: string; barWidth: number; } +const MIN_GLOW_SIZE = 0.75; +const GLOW_FACTOR = 0.08; + export function GlowGradient({ id, barWidth }: GlowGradientProps) { // 0.75 is the minimum glow size, and it scales with bar width - const glowSize = 0.75 + barWidth * 0.08; + const glowSize = MIN_GLOW_SIZE + barWidth * GLOW_FACTOR; return ( @@ -22,69 +25,34 @@ export function GlowGradient({ id, barWidth }: GlowGradientProps) { ); } -export function SpotlightGradient({ - id, - dimensions, - roundedBars, - angle, - theme, -}: { - id: string; - dimensions: GaugeDimensions; - angle: number; - roundedBars: boolean; - theme: GrafanaTheme2; -}) { - const angleRadian = ((angle - 90) * Math.PI) / 180; - - let x1 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian - 0.2); - let y1 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian - 0.2); - let x2 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian); - let y2 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian); - - if (theme.isLight) { - return ( - - - - - - ); - } - - return ( - - - - {roundedBars && } - - ); -} +const CENTER_GLOW_OPACITY = 0.25; export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) { + const transparentColor = colorManipulator.alpha(color, CENTER_GLOW_OPACITY); return ( - - - + + + ); } export interface CenterGlowProps { - dimensions: GaugeDimensions; + dimensions: RadialGaugeDimensions; gaugeId: string; color?: string; } export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps) { const gradientId = `circle-glow-${gaugeId}`; + const transparentColor = color ? colorManipulator.alpha(color, CENTER_GLOW_OPACITY) : color; return ( <> - - - + + + @@ -93,3 +61,36 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps ); } + +export function SpotlightGradient({ + id, + dimensions, + roundedBars, + angle, + theme, +}: { + id: string; + dimensions: RadialGaugeDimensions; + angle: number; + roundedBars: boolean; + theme: GrafanaTheme2; +}) { + if (theme.isLight) { + return null; + } + + const angleRadian = ((angle - 90) * Math.PI) / 180; + + let x1 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian - 0.2); + let y1 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian - 0.2); + let x2 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian); + let y2 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian); + + return ( + + + + {roundedBars && } + + ); +} diff --git a/packages/grafana-ui/src/components/RadialGauge/types.ts b/packages/grafana-ui/src/components/RadialGauge/types.ts new file mode 100644 index 00000000000..cc233dd524c --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/types.ts @@ -0,0 +1,25 @@ +export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none'; +export type RadialShape = 'circle' | 'gauge'; + +export interface RadialGaugeDimensions { + margin: number; + radius: number; + centerX: number; + centerY: number; + barWidth: number; + endAngle?: number; + barIndex: number; + thresholdsBarRadius: number; + thresholdsBarWidth: number; + thresholdsBarSpacing: number; + scaleLabelsFontSize: number; + scaleLabelsSpacing: number; + scaleLabelsRadius: number; + gaugeBottomY: number; +} + +/** @alpha - perhaps this should go in @grafana/data */ +export interface GradientStop { + color: string; + percent: number; +} diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.test.ts b/packages/grafana-ui/src/components/RadialGauge/utils.test.ts index 5e3f34d62cd..b9b2e4ad8f3 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.test.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.test.ts @@ -1,24 +1,111 @@ -import { FieldDisplay } from '@grafana/data'; +import { DataFrameView, FieldDisplay } from '@grafana/data'; import type { RadialGaugeProps } from './RadialGauge'; -import { calculateDimensions, toRad, getValueAngleForValue } from './utils'; +import { RadialGaugeDimensions } from './types'; +import { + calculateDimensions, + toRad, + getValueAngleForValue, + drawRadialArcPath, + getFieldConfigMinMax, + getFieldDisplayProcessor, + getAngleBetweenSegments, + getOptimalSegmentCount, +} from './utils'; describe('RadialGauge utils', () => { - function calc(overrides: Partial = {}) { - return calculateDimensions( - overrides.width ?? 200, - overrides.height ?? 200, - overrides.shape === 'gauge' ? 110 : 360, - overrides.glowBar ?? false, - overrides.roundedBars ?? false, - overrides.barWidthFactor ?? 0.4, - overrides.barIndex ?? 0, - overrides.thresholdsBar ?? false, - overrides.showScaleLabels ?? false - ); - } + describe('getFieldDisplayProcessor', () => { + it('should return display processor from view when available', () => { + const mockProcessor = jest.fn(); + const mockView = { + getFieldDisplayProcessor: jest.fn().mockReturnValue(mockProcessor), + } as unknown as DataFrameView; + + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: {}, + view: mockView, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const dp = getFieldDisplayProcessor(fieldDisplay); + expect(dp).toBe(mockProcessor); + expect(mockView.getFieldDisplayProcessor).toHaveBeenCalledWith(0); + }); + + it('should return default display processor when view is not available', () => { + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: {}, + view: undefined, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const dp = getFieldDisplayProcessor(fieldDisplay); + expect(dp).toBeDefined(); + expect(typeof dp).toBe('function'); + }); + }); + + describe('getFieldConfigMinMax', () => { + it('should return min and max from field config when defined', () => { + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: { min: 10, max: 90 }, + view: undefined, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const [min, max] = getFieldConfigMinMax(fieldDisplay); + expect(min).toBe(10); + expect(max).toBe(90); + }); + + it('should return default min and max when not defined in field config', () => { + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: {}, + view: undefined, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const [min, max] = getFieldConfigMinMax(fieldDisplay); + expect(min).toBe(0); + expect(max).toBe(100); + }); + }); describe('calculateDimensions', () => { + function calc(overrides: Partial = {}) { + return calculateDimensions( + overrides.width ?? 200, + overrides.height ?? 200, + overrides.shape === 'gauge' ? 110 : 360, + overrides.glowBar ?? false, + overrides.roundedBars ?? false, + overrides.barWidthFactor ?? 0.4, + overrides.barIndex ?? 0, + overrides.thresholdsBar ?? false, + overrides.showScaleLabels ?? false + ); + } + it('should calculate basic dimensions for a square gauge', () => { const result = calc(); @@ -194,4 +281,84 @@ describe('RadialGauge utils', () => { expect(result.angle).toBe(240); }); }); + + describe('drawRadialArcPath', () => { + const defaultDims: RadialGaugeDimensions = Object.freeze({ + centerX: 100, + centerY: 100, + radius: 80, + barWidth: 20, + margin: 0, + barIndex: 0, + thresholdsBarWidth: 0, + thresholdsBarSpacing: 0, + thresholdsBarRadius: 0, + scaleLabelsFontSize: 0, + scaleLabelsSpacing: 0, + scaleLabelsRadius: 0, + gaugeBottomY: 0, + }); + + it.each([ + { description: 'quarter arc', startAngle: 0, endAngle: 90 }, + { description: 'half arc', startAngle: 0, endAngle: 180 }, + { description: 'three quarter arc', startAngle: 0, endAngle: 270 }, + { description: 'rounded bars', startAngle: 0, endAngle: 270, roundedBars: true }, + { description: 'wide bar width', startAngle: 0, endAngle: 180, dimensions: { barWidth: 50 } }, + { description: 'narrow bar width', startAngle: 0, endAngle: 180, dimensions: { barWidth: 5 } }, + { description: 'narrow radius', startAngle: 0, endAngle: 180, dimensions: { radius: 50 } }, + { + description: 'center x and y', + startAngle: 0, + endAngle: 360, + roundedBars: true, + dimensions: { centerX: 150, centerY: 200 }, + }, + ])(`should draw correct path for $description`, ({ startAngle, endAngle, dimensions, roundedBars }) => { + const path = drawRadialArcPath(startAngle, endAngle, { ...defaultDims, ...dimensions }, roundedBars); + expect(path).toMatchSnapshot(); + }); + + describe('edge cases', () => { + it('should adjust 360deg or greater arcs to avoid SVG rendering issues', () => { + expect(drawRadialArcPath(0, 360, defaultDims)).toEqual(drawRadialArcPath(0, 359.99, defaultDims)); + expect(drawRadialArcPath(0, 380, defaultDims)).toEqual(drawRadialArcPath(0, 380, defaultDims)); + }); + + it('should return empty string if inner radius collapses to zero or below', () => { + const smallRadiusDims = { ...defaultDims, radius: 5, barWidth: 20 }; + expect(drawRadialArcPath(0, 180, smallRadiusDims)).toBe(''); + }); + }); + }); + + describe('getAngleBetweenSegments', () => { + it('should calculate angle between segments based on spacing and count', () => { + expect(getAngleBetweenSegments(2, 10, 360)).toBe(48); + expect(getAngleBetweenSegments(5, 15, 180)).toBe(40); + }); + }); + + describe('getOptimalSegmentCount', () => { + it('should adjust segment count based on dimensions and spacing', () => { + const dimensions: RadialGaugeDimensions = { + centerX: 100, + centerY: 100, + radius: 80, + barWidth: 20, + margin: 0, + barIndex: 0, + thresholdsBarWidth: 0, + thresholdsBarSpacing: 0, + thresholdsBarRadius: 0, + scaleLabelsFontSize: 0, + scaleLabelsSpacing: 0, + scaleLabelsRadius: 0, + gaugeBottomY: 0, + }; + + expect(getOptimalSegmentCount(dimensions, 2, 10, 360)).toBe(8); + expect(getOptimalSegmentCount(dimensions, 1, 5, 360)).toBe(5); + }); + }); }); diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.ts b/packages/grafana-ui/src/components/RadialGauge/utils.ts index 44f767d89b2..e26cf5eed2a 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.ts @@ -1,11 +1,38 @@ -import { FieldDisplay } from '@grafana/data'; +import { FieldDisplay, getDisplayProcessor } from '@grafana/data'; -export function getValueAngleForValue(fieldDisplay: FieldDisplay, startAngle: number, endAngle: number) { - const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle; +import { RadialGaugeDimensions } from './types'; + +export function getFieldDisplayProcessor(displayValue: FieldDisplay) { + if (displayValue.view && displayValue.colIndex != null) { + const dp = displayValue.view.getFieldDisplayProcessor(displayValue.colIndex); + if (dp) { + return dp; + } + } + + return getDisplayProcessor(); +} + +export function getFieldConfigMinMax(fieldDisplay: FieldDisplay) { const min = fieldDisplay.field.min ?? 0; const max = fieldDisplay.field.max ?? 100; + return [min, max]; +} - let angle = ((fieldDisplay.display.numeric - min) / (max - min)) * angleRange; +export function getValuePercentageForValue(fieldDisplay: FieldDisplay, value = fieldDisplay.display.numeric) { + const [min, max] = getFieldConfigMinMax(fieldDisplay); + return (value - min) / (max - min); +} + +export function getValueAngleForValue( + fieldDisplay: FieldDisplay, + startAngle: number, + endAngle: number, + value = fieldDisplay.display.numeric +) { + const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle; + + let angle = getValuePercentageForValue(fieldDisplay, value) * angleRange; if (angle > angleRange) { angle = angleRange; @@ -26,24 +53,19 @@ export function toRad(angle: number) { return ((angle - 90) * Math.PI) / 180; } -export interface GaugeDimensions { - margin: number; - radius: number; - centerX: number; - centerY: number; - barWidth: number; - endAngle?: number; - barIndex: number; - thresholdsBarRadius: number; - thresholdsBarWidth: number; - thresholdsBarSpacing: number; - showScaleLabels?: boolean; - scaleLabelsFontSize: number; - scaleLabelsSpacing: number; - scaleLabelsRadius: number; - gaugeBottomY: number; -} - +/** + * returns the calculated dimensions for the radial gauge + * @param width + * @param height + * @param endAngle + * @param glow + * @param roundedBars + * @param barWidthFactor + * @param barIndex + * @param thresholdBar + * @param showScaleLabels + * @returns {RadialGaugeDimensions} + */ export function calculateDimensions( width: number, height: number, @@ -54,7 +76,7 @@ export function calculateDimensions( barIndex: number, thresholdBar?: boolean, showScaleLabels?: boolean -): GaugeDimensions { +): RadialGaugeDimensions { const yMaxAngle = endAngle > 180 ? 180 : endAngle; let margin = 0; @@ -97,6 +119,7 @@ export function calculateDimensions( maxRadiusW -= labelsSize; maxRadiusH -= labelsSize; + // FIXME: needs coverage // For gauges the max label needs a bit more vertical space so that it does not get clipped if (maxRadiusIsLimitedByHeight && endAngle < 180) { const amount = outerRadius * 0.07; @@ -155,3 +178,105 @@ export function toCartesian(centerX: number, centerY: number, radius: number, an y: centerY + radius * Math.sin(radian), }; } + +export function drawRadialArcPath( + startAngle: number, + endAngle: number, + dimensions: RadialGaugeDimensions, + roundedBars?: boolean +): string { + const { radius, centerX, centerY, barWidth } = dimensions; + + // For some reason a 100% full arc cannot be rendered + if (endAngle >= 360) { + endAngle = 359.99; + } + + const startRadians = toRad(startAngle); + const endRadians = toRad(startAngle + endAngle); + + const largeArc = endAngle > 180 ? 1 : 0; + + const outerR = radius + barWidth / 2; + const innerR = Math.max(0, radius - barWidth / 2); + if (innerR <= 0) { + return ''; // cannot draw arc with 0 inner radius + } + + // get points for both an inner and outer arc. we draw + // the arc entirely with a path's fill instead of using stroke + // so that it can be used as a clip-path. + const ox1 = centerX + outerR * Math.cos(startRadians); + const oy1 = centerY + outerR * Math.sin(startRadians); + const ox2 = centerX + outerR * Math.cos(endRadians); + const oy2 = centerY + outerR * Math.sin(endRadians); + + const ix1 = centerX + innerR * Math.cos(startRadians); + const iy1 = centerY + innerR * Math.sin(startRadians); + const ix2 = centerX + innerR * Math.cos(endRadians); + const iy2 = centerY + innerR * Math.sin(endRadians); + + // calculate the cap width in case we're drawing rounded bars + const capR = barWidth / 2; + + const pathParts = [ + // start at outer start + 'M', + ox1, + oy1, + // outer arc from start to end (clockwise) + 'A', + outerR, + outerR, + 0, + largeArc, + 1, + ox2, + oy2, + ]; + + if (roundedBars) { + // rounded end cap: small arc connecting outer end to inner end + pathParts.push('A', capR, capR, 0, 0, 1, ix2, iy2); + } else { + // straight line to inner end (square butt) + pathParts.push('L', ix2, iy2); + } + + // inner arc from end back to start (counter-clockwise) + pathParts.push('A', innerR, innerR, 0, largeArc, 0, ix1, iy1); + + if (roundedBars) { + // rounded start cap: small arc connecting inner start back to outer start + pathParts.push('A', capR, capR, 0, 0, 1, ox1, oy1); + } else { + // straight line back to outer start (square butt) + pathParts.push('L', ox1, oy1); + } + + pathParts.push('Z'); + + return pathParts.join(' '); +} + +export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) { + // Max spacing is 8 degrees between segments + // Changing this constant could be considered a breaking change + const maxAngleBetweenSegments = Math.max(range / 1.5 / segmentCount, 2); + return segmentSpacing * maxAngleBetweenSegments; +} + +export function getOptimalSegmentCount( + dimensions: RadialGaugeDimensions, + segmentSpacing: number, + segmentCount: number, + range: number +) { + const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, range); + + const innerRadius = dimensions.radius - dimensions.barWidth / 2; + const circumference = Math.PI * innerRadius * 2 * (range / 360); + const maxSegments = Math.floor(circumference / (angleBetweenSegments + 3)); + + return Math.min(maxSegments, segmentCount); +} diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index c18b235e757..a9d3f039c42 100644 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -14,30 +14,21 @@ export interface SparklineProps extends Themeable2 { height: number; config?: FieldConfig; sparkline: FieldSparkline; + showHighlights?: boolean; } -const SparklineFn: React.FC = memo((props) => { - const { sparkline, config: fieldConfig, theme, width, height } = props; +export const Sparkline: React.FC = memo((props) => { + const { sparkline, config: fieldConfig, theme, width, height, showHighlights } = props; - const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, fieldConfig); + const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights); if (warning) { return null; } const data = preparePlotData2(alignedDataFrame, getStackingGroups(alignedDataFrame)); - const configBuilder = prepareConfig(sparkline, alignedDataFrame, theme); + const configBuilder = prepareConfig(sparkline, alignedDataFrame, theme, showHighlights); 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/Sparkline/utils.test.ts b/packages/grafana-ui/src/components/Sparkline/utils.test.ts index ca49f6da512..0ec65515e0c 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.test.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.test.ts @@ -1,6 +1,6 @@ -import { Field, FieldSparkline, FieldType } from '@grafana/data'; +import { createTheme, Field, FieldSparkline, FieldType, toDataFrame } from '@grafana/data'; -import { getYRange, preparePlotFrame } from './utils'; +import { getYRange, prepareConfig, preparePlotFrame } from './utils'; describe('Prepare Sparkline plot frame', () => { it('should return sorted array if x-axis numeric', () => { @@ -201,3 +201,134 @@ describe('Get y range', () => { expect(actual[0]).toBeLessThan(actual[1]!); }); }); + +describe('prepareConfig', () => { + it('should not throw an error if there are multiple values', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [1, 2, 3, 4, 5], + type: FieldType.number, + config: {}, + }, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme()); + expect(config.series.length).toBe(1); + }); + + it('should not throw an error if there is a single value', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [1679839200000], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [1], + type: FieldType.number, + config: {}, + }, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme()); + expect(config.series.length).toBe(1); + }); + + it('should not throw an error if there are no values', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [], + type: FieldType.number, + config: {}, + }, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme()); + expect(config.series.length).toBe(1); + }); + + it('should set up highlight series if showHighlights is true and highlightIdx exists', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [1, 2, 3, 4, 5], + type: FieldType.number, + config: {}, + }, + highlightIndex: 2, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme(), true); + expect(config.series.length).toBe(1); + expect(config.series[0].getConfig().points).toEqual( + expect.objectContaining({ + show: true, + filter: [2], + }) + ); + }); + + it('should not set up highlight series if showHighlights is false even if highlightIdx exists', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [1, 2, 3, 4, 5], + type: FieldType.number, + config: {}, + }, + highlightIndex: 2, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme(), false); + expect(config.series.length).toBe(1); + expect(config.series[0].getConfig().points?.show).not.toBe(true); + }); +}); diff --git a/packages/grafana-ui/src/components/Sparkline/utils.ts b/packages/grafana-ui/src/components/Sparkline/utils.ts index be24eb6c4e8..c1402c4da2d 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.ts @@ -2,6 +2,7 @@ import { Range } from 'uplot'; import { applyNullInsertThreshold, + // colorManipulator, DataFrame, FieldConfig, FieldSparkline, @@ -22,6 +23,7 @@ import { VisibilityMode, ScaleDirection, ScaleOrientation, + // FieldColorModeId, } from '@grafana/schema'; import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; @@ -112,8 +114,7 @@ export function getYRange(alignedFrame: DataFrame): Range.MinMax { return [roundedMin, roundedMax]; } -// TODO: #112977 enable highlight index -// const HIGHLIGHT_IDX_POINT_SIZE = 6; +const HIGHLIGHT_IDX_POINT_SIZE = 6; const defaultConfig: GraphFieldConfig = { drawStyle: GraphDrawStyle.Line, @@ -124,7 +125,9 @@ const defaultConfig: GraphFieldConfig = { export const prepareSeries = ( sparkline: FieldSparkline, - fieldConfig?: FieldConfig + _theme: GrafanaTheme2, + fieldConfig?: FieldConfig, + _showHighlights?: boolean ): { frame: DataFrame; warning?: string } => { const frame = nullToValue(preparePlotFrame(sparkline, fieldConfig)); if (frame.fields.some((f) => f.values.length <= 1)) { @@ -136,16 +139,41 @@ export const prepareSeries = ( frame, }; } + // TODO:rgb(24, 24, 24) will address this. + // if (showHighlights && typeof sparkline.highlightLine === 'number') { + // const highlightY = sparkline.highlightLine; + // const colorMode = getFieldColorModeForField(sparkline.y); + // const seriesColor = colorMode.getCalculator(sparkline.y, theme)(highlightY, 0); + // frame.fields.push({ + // name: 'highlightLine', + // type: FieldType.number, + // values: new Array(frame.length).fill(highlightY), + // config: { + // color: { + // mode: FieldColorModeId.Fixed, + // fixedColor: colorManipulator.lighten(seriesColor, 0.5), + // }, + // custom: { + // lineStyle: { + // fill: 'dash', + // dash: [5, 2], + // }, + // }, + // }, + // state: {}, + // }); + // } return { frame }; }; export const prepareConfig = ( sparkline: FieldSparkline, dataFrame: DataFrame, - theme: GrafanaTheme2 + theme: GrafanaTheme2, + showHighlights?: boolean ): UPlotConfigBuilder => { const builder = new UPlotConfigBuilder(); - // const rangePad = HIGHLIGHT_IDX_POINT_SIZE / 2; + const rangePad = HIGHLIGHT_IDX_POINT_SIZE / 2; builder.setCursor({ show: false, @@ -206,13 +234,14 @@ export const prepareConfig = ( const colorMode = getFieldColorModeForField(field); const seriesColor = colorMode.getCalculator(field, theme)(0, 0); - // TODO: #112977 enable highlight index and adjust padding accordingly - // const hasHighlightIndex = typeof sparkline.highlightIndex === 'number'; - // if (hasHighlightIndex) { - // builder.setPadding([rangePad, rangePad, rangePad, rangePad]); - // } + + const hasHighlightIndex = showHighlights && typeof sparkline.highlightIndex === 'number'; + if (hasHighlightIndex) { + builder.setPadding([rangePad, rangePad, rangePad, rangePad]); + } + const pointsMode = - customConfig.drawStyle === GraphDrawStyle.Points // || hasHighlightIndex + customConfig.drawStyle === GraphDrawStyle.Points || hasHighlightIndex ? VisibilityMode.Always : customConfig.showPoints; @@ -227,9 +256,8 @@ export const prepareConfig = ( lineWidth: customConfig.lineWidth, lineInterpolation: customConfig.lineInterpolation, showPoints: pointsMode, - // TODO: #112977 enable highlight index - pointSize: /* hasHighlightIndex ? HIGHLIGHT_IDX_POINT_SIZE : */ customConfig.pointSize, - // pointsFilter: hasHighlightIndex ? [sparkline.highlightIndex!] : undefined, + pointSize: hasHighlightIndex ? HIGHLIGHT_IDX_POINT_SIZE : customConfig.pointSize, + pointsFilter: hasHighlightIndex ? [sparkline.highlightIndex!] : undefined, fillOpacity: customConfig.fillOpacity, fillColor: customConfig.fillColor, lineStyle: customConfig.lineStyle, diff --git a/packages/grafana-ui/src/components/Table/CellActions.tsx b/packages/grafana-ui/src/components/Table/CellActions.tsx index e2fe8a658ba..f21be659c4f 100644 --- a/packages/grafana-ui/src/components/Table/CellActions.tsx +++ b/packages/grafana-ui/src/components/Table/CellActions.tsx @@ -1,3 +1,4 @@ +import { isPlainObject } from 'lodash'; import { useCallback } from 'react'; import * as React from 'react'; @@ -63,7 +64,18 @@ export function CellActions({ tooltip={t('grafana-ui.table.cell-inspect', 'Inspect value')} onClick={() => { if (setInspectCell) { - setInspectCell({ value: cell.value, mode: previewMode }); + let mode = TableCellInspectorMode.text; + let inspectValue = cell.value; + try { + const parsed = typeof inspectValue === 'string' ? JSON.parse(inspectValue) : inspectValue; + if (Array.isArray(parsed) || isPlainObject(parsed)) { + inspectValue = JSON.stringify(parsed, null, 2); + mode = TableCellInspectorMode.code; + } + } catch { + // do nothing + } + setInspectCell({ value: inspectValue, mode }); } }} {...commonButtonProps} diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx new file mode 100644 index 00000000000..131133bcdfb --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; + +import { VizLegendTable } from './VizLegendTable'; +import { VizLegendItem } from './types'; + +describe('VizLegendTable', () => { + const mockItems: VizLegendItem[] = [ + { label: 'Series 1', color: 'red', yAxis: 1 }, + { label: 'Series 2', color: 'blue', yAxis: 1 }, + { label: 'Series 3', color: 'green', yAxis: 1 }, + ]; + + it('renders without crashing', () => { + const { container } = render(); + expect(container.querySelector('table')).toBeInTheDocument(); + }); + + it('renders all items', () => { + render(); + expect(screen.getByText('Series 1')).toBeInTheDocument(); + expect(screen.getByText('Series 2')).toBeInTheDocument(); + expect(screen.getByText('Series 3')).toBeInTheDocument(); + }); + + it('renders table headers when items have display values', () => { + const itemsWithStats: VizLegendItem[] = [ + { + label: 'Series 1', + color: 'red', + yAxis: 1, + getDisplayValues: () => [ + { numeric: 100, text: '100', title: 'Max' }, + { numeric: 50, text: '50', title: 'Min' }, + ], + }, + ]; + render(); + expect(screen.getByText('Max')).toBeInTheDocument(); + expect(screen.getByText('Min')).toBeInTheDocument(); + }); + + it('renders sort icon when sorted', () => { + const { container } = render( + + ); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('calls onToggleSort when header is clicked', () => { + const onToggleSort = jest.fn(); + render(); + const header = screen.getByText('Name'); + header.click(); + expect(onToggleSort).toHaveBeenCalledWith('Name'); + }); + + it('does not call onToggleSort when not sortable', () => { + const onToggleSort = jest.fn(); + render(); + const header = screen.getByText('Name'); + header.click(); + expect(onToggleSort).not.toHaveBeenCalled(); + }); + + it('renders with long labels', () => { + const itemsWithLongLabels: VizLegendItem[] = [ + { + label: 'This is a very long series name that should be scrollable within its table cell', + color: 'red', + yAxis: 1, + }, + ]; + render(); + expect( + screen.getByText('This is a very long series name that should be scrollable within its table cell') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index b654a2d3ac6..0c2859453eb 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -119,7 +119,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ table: css({ width: '100%', 'th:first-child': { - width: '100%', borderBottom: `1px solid ${theme.colors.border.weak}`, }, }), diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx new file mode 100644 index 00000000000..4ca95aa395c --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx @@ -0,0 +1,112 @@ +import { render, screen } from '@testing-library/react'; + +import { LegendTableItem } from './VizLegendTableItem'; +import { VizLegendItem } from './types'; + +describe('LegendTableItem', () => { + const mockItem: VizLegendItem = { + label: 'Series 1', + color: 'red', + yAxis: 1, + }; + + it('renders without crashing', () => { + const { container } = render( + + + + +
+ ); + expect(container.querySelector('tr')).toBeInTheDocument(); + }); + + it('renders label text', () => { + render( + + + + +
+ ); + expect(screen.getByText('Series 1')).toBeInTheDocument(); + }); + + it('renders with long label text', () => { + const longLabelItem: VizLegendItem = { + ...mockItem, + label: 'This is a very long series name that should be scrollable in the table cell', + }; + render( + + + + +
+ ); + expect( + screen.getByText('This is a very long series name that should be scrollable in the table cell') + ).toBeInTheDocument(); + }); + + it('renders stat values when provided', () => { + const itemWithStats: VizLegendItem = { + ...mockItem, + getDisplayValues: () => [ + { numeric: 100, text: '100', title: 'Max' }, + { numeric: 50, text: '50', title: 'Min' }, + ], + }; + render( + + + + +
+ ); + expect(screen.getByText('100')).toBeInTheDocument(); + expect(screen.getByText('50')).toBeInTheDocument(); + }); + + it('renders right y-axis indicator when yAxis is 2', () => { + const rightAxisItem: VizLegendItem = { + ...mockItem, + yAxis: 2, + }; + render( + + + + +
+ ); + expect(screen.getByText('(right y-axis)')).toBeInTheDocument(); + }); + + it('calls onLabelClick when label is clicked', () => { + const onLabelClick = jest.fn(); + render( + + + + +
+ ); + const button = screen.getByRole('button'); + button.click(); + expect(onLabelClick).toHaveBeenCalledWith(mockItem, expect.any(Object)); + }); + + it('does not call onClick when readonly', () => { + const onLabelClick = jest.fn(); + render( + + + + +
+ ); + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + }); +}); diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx index 335cf4309e9..56ec6cb733e 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx @@ -69,7 +69,7 @@ export const LegendTableItem = ({ return ( - + - +
+ +
{item.getDisplayValues && @@ -128,6 +130,27 @@ const getStyles = (theme: GrafanaTheme2) => { background: rowHoverBg, }, }), + labelCell: css({ + label: 'LegendLabelCell', + maxWidth: 0, + width: '100%', + }), + labelCellInner: css({ + label: 'LegendLabelCellInner', + display: 'block', + flex: 1, + minWidth: 0, + overflowX: 'auto', + overflowY: 'hidden', + paddingRight: theme.spacing(3), + scrollbarWidth: 'none', + msOverflowStyle: 'none', + maskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`, + WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`, + '&::-webkit-scrollbar': { + display: 'none', + }, + }), label: css({ label: 'LegendLabel', whiteSpace: 'nowrap', @@ -135,9 +158,6 @@ const getStyles = (theme: GrafanaTheme2) => { border: 'none', fontSize: 'inherit', padding: 0, - maxWidth: '600px', - textOverflow: 'ellipsis', - overflow: 'hidden', userSelect: 'text', }), labelDisabled: css({ 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 0 { + extraArgs := make([]any, 0, len(e.Extra)*2) + + for k, v := range e.Extra { + extraArgs = append(extraArgs, "extra_"+k, v) + } + + args = append(args, extraArgs...) + } + + return args +} + +type EventOutcome string + +const ( + EventOutcomeUnknown EventOutcome = "unknown" + EventOutcomeSuccess EventOutcome = "success" + EventOutcomeFailureUnauthorized EventOutcome = "failure_unauthorized" + EventOutcomeFailureNotFound EventOutcome = "failure_not_found" + EventOutcomeFailureGeneric EventOutcome = "failure_generic" +) diff --git a/pkg/apiserver/auditing/event_test.go b/pkg/apiserver/auditing/event_test.go new file mode 100644 index 00000000000..3267936b02a --- /dev/null +++ b/pkg/apiserver/auditing/event_test.go @@ -0,0 +1,64 @@ +package auditing_test + +import ( + "encoding/json" + "strconv" + "strings" + "testing" + "time" + + "github.com/grafana/grafana/pkg/apiserver/auditing" + "github.com/stretchr/testify/require" +) + +func TestEvent_MarshalJSON(t *testing.T) { + t.Parallel() + + t.Run("marshals the event", func(t *testing.T) { + t.Parallel() + + now := time.Now() + + event := auditing.Event{ + ObservedAt: now, + Extra: map[string]string{"k1": "v1", "k2": "v2"}, + } + + data, err := json.Marshal(event) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(data, &result)) + + require.Equal(t, event.Time().UTC().Format(time.RFC3339Nano), result["observedAt"]) + require.NotNil(t, result["extra"]) + require.Len(t, result["extra"], 2) + }) +} + +func TestEvent_KVPairs(t *testing.T) { + t.Parallel() + + t.Run("records extra fields", func(t *testing.T) { + t.Parallel() + + extraFields := 2 + extra := make(map[string]string, 0) + for i := 0; i < extraFields; i++ { + extra[strconv.Itoa(i)] = "value" + } + + event := auditing.Event{Extra: extra} + + kvPairs := event.KVPairs() + + extraCount := 0 + for i := 0; i < len(kvPairs); i += 2 { + if strings.HasPrefix(kvPairs[i].(string), "extra_") { + extraCount++ + } + } + + require.Equal(t, extraCount, extraFields) + }) +} 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/apiserver/auditing/policy.go b/pkg/apiserver/auditing/policy.go index e88acf7c4cc..ed053ca205d 100644 --- a/pkg/apiserver/auditing/policy.go +++ b/pkg/apiserver/auditing/policy.go @@ -46,14 +46,23 @@ func (defaultGrafanaPolicyRuleEvaluator) EvaluatePolicyRule(attrs authorizer.Att } } + // Logging the response object allows us to get the resource name for create requests. + level := auditinternal.LevelMetadata + if attrs.GetVerb() == utils.VerbCreate { + level = auditinternal.LevelRequestResponse + } + return audit.RequestAuditConfig{ - Level: auditinternal.LevelMetadata, + Level: level, + + // Only log on StageResponseComplete, to avoid noisy logs. OmitStages: []auditinternal.Stage{ - // Only log on StageResponseComplete auditinternal.StageRequestReceived, auditinternal.StageResponseStarted, auditinternal.StagePanic, }, - OmitManagedFields: false, // Setting it to true causes extra copying/unmarshalling. + + // Setting it to true causes extra copying/unmarshalling. + OmitManagedFields: false, } } diff --git a/pkg/apiserver/auditing/policy_test.go b/pkg/apiserver/auditing/policy_test.go index af18f9110fd..ccabaa5e6e2 100644 --- a/pkg/apiserver/auditing/policy_test.go +++ b/pkg/apiserver/auditing/policy_test.go @@ -55,7 +55,7 @@ func TestDefaultGrafanaPolicyRuleEvaluator(t *testing.T) { require.Equal(t, auditinternal.LevelNone, config.Level) }) - t.Run("return audit level metadata for other resource requests", func(t *testing.T) { + t.Run("return audit level request+response for create requests", func(t *testing.T) { t.Parallel() attrs := authorizer.AttributesRecord{ @@ -67,6 +67,22 @@ func TestDefaultGrafanaPolicyRuleEvaluator(t *testing.T) { }, } + config := evaluator.EvaluatePolicyRule(attrs) + require.Equal(t, auditinternal.LevelRequestResponse, config.Level) + }) + + t.Run("return audit level metadata for other resource requests", func(t *testing.T) { + t.Parallel() + + attrs := authorizer.AttributesRecord{ + ResourceRequest: true, + Verb: utils.VerbGet, + User: &user.DefaultInfo{ + Name: "test-user", + Groups: []string{"test-group"}, + }, + } + config := evaluator.EvaluatePolicyRule(attrs) require.Equal(t, auditinternal.LevelMetadata, config.Level) }) 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/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/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/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/plugins.go b/pkg/plugins/plugins.go index 950dbfe7849..7f010565c9c 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -19,7 +19,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/util" ) var ( @@ -414,7 +413,7 @@ func (p *Plugin) ConvertObjects(ctx context.Context, req *backend.ConversionRequ } func (p *Plugin) File(name string) (fs.File, error) { - cleanPath, err := util.CleanRelativePath(name) + cleanPath, err := CleanRelativePath(name) if err != nil { // CleanRelativePath should clean and make the path relative so this is not expected to fail return nil, err diff --git a/pkg/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 e651a5716ac..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) @@ -237,7 +243,7 @@ func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, } func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion { - if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts, featuremgmt.FlagKubernetesDashboardsV2) { + if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) { // If dashboards v2 is enabled, we want to use v2beta1 as the default API version. return []schema.GroupVersion{ dashv2beta1.DashboardResourceInfo.GroupVersion(), @@ -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/search.go b/pkg/registry/apis/dashboard/search.go index e28eeedcecc..08a943b8da6 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -115,6 +115,15 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) * Schema: spec.ArrayProperty(spec.StringProperty()), }, }, + { + ParameterProps: spec3.ParameterProps{ + Name: "facetLimit", + In: "query", + Description: "maximum number of terms to return per facet (default 50, max 1000)", + Required: false, + Schema: spec.Int64Property(), + }, + }, { ParameterProps: spec3.ParameterProps{ Name: "tags", @@ -340,6 +349,7 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, user identity.Requester, getDashboardsUIDsSharedWithUser func() ([]string, error)) (*resourcepb.ResourceSearchRequest, error) { // get limit and offset from query params limit := 50 + facetLimit := 50 offset := 0 page := 1 if queryParams.Has("limit") { @@ -422,11 +432,19 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use // The facet term fields if facets, ok := queryParams["facet"]; ok { + if queryParams.Has("facetLimit") { + if parsed, err := strconv.Atoi(queryParams.Get("facetLimit")); err == nil && parsed > 0 { + facetLimit = parsed + if facetLimit > 1000 { + facetLimit = 1000 + } + } + } searchRequest.Facet = make(map[string]*resourcepb.ResourceSearchRequest_Facet) for _, v := range facets { searchRequest.Facet[v] = &resourcepb.ResourceSearchRequest_Facet{ Field: v, - Limit: 50, + Limit: int64(facetLimit), } } } diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index 406494b9d36..3b9935f8247 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -818,6 +818,38 @@ func TestConvertHttpSearchRequestToResourceSearchRequest(t *testing.T) { Federated: []*resourcepb.ResourceKey{folderKey}, }, }, + "facet fields with custom limit": { + queryString: "facet=tags&facetLimit=500", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{ + "tags": {Field: "tags", Limit: 500}, + }, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "facet fields with limit exceeding max": { + queryString: "facet=tags&facetLimit=5000", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{ + "tags": {Field: "tags", Limit: 1000}, + }, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, "tag filter": { queryString: "tag=tag1&tag=tag2", expected: &resourcepb.ResourceSearchRequest{ 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/folders/register.go b/pkg/registry/apis/folders/register.go index 64e51c06312..7c39fbe22ae 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -356,7 +356,7 @@ func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes, if !ok { return fmt.Errorf("obj is not folders.Folder") } - return validateOnUpdate(ctx, f, old, b.storage, b.parents, folder.MaxNestedFolderDepth) + return validateOnUpdate(ctx, f, old, b.storage, b.parents, b.searcher, folder.MaxNestedFolderDepth) default: return nil } diff --git a/pkg/registry/apis/folders/register_test.go b/pkg/registry/apis/folders/register_test.go index 066f8793776..1eb00f0a7e5 100644 --- a/pkg/registry/apis/folders/register_test.go +++ b/pkg/registry/apis/folders/register_test.go @@ -376,6 +376,10 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) { m.On("Get", mock.Anything, "new-parent", mock.Anything).Return( &folders.Folder{}, nil).Once() + // also retrieves old parent for depth difference calculation + m.On("Get", mock.Anything, "valid-parent", mock.Anything).Return( + &folders.Folder{}, + nil).Once() }, }, { diff --git a/pkg/registry/apis/folders/validate.go b/pkg/registry/apis/folders/validate.go index eb0c29b30a1..3c7c30a1aea 100644 --- a/pkg/registry/apis/folders/validate.go +++ b/pkg/registry/apis/folders/validate.go @@ -6,6 +6,7 @@ import ( "slices" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/selection" "k8s.io/apiserver/pkg/registry/rest" folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" @@ -13,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/util" ) @@ -73,6 +75,7 @@ func validateOnUpdate(ctx context.Context, old *folders.Folder, getter rest.Getter, parents parentsGetter, + searcher resourcepb.ResourceIndexClient, maxDepth int, ) error { folderObj, err := utils.MetaAccessor(obj) @@ -95,7 +98,10 @@ func validateOnUpdate(ctx context.Context, // Validate the move operation newParent := folderObj.GetFolder() - // If we move to root, we don't need to validate the depth. + // If we move to root, we don't need to validate the depth, because the folder already existed + // before and wasn't too deep. This move will make it more shallow. + // + // We also don't need to validate circular references because the root folder cannot have a parent. if newParent == folder.RootFolderUID { return nil } @@ -113,9 +119,6 @@ func validateOnUpdate(ctx context.Context, if !ok { return fmt.Errorf("expected folder, found %T", parentObj) } - - //FIXME: until we have a way to represent the tree, we can only - // look at folder parents to check how deep the new folder tree will be info, err := parents(ctx, parent) if err != nil { return err @@ -129,13 +132,162 @@ func validateOnUpdate(ctx context.Context, } } - // if by moving a folder we exceed the max depth, return an error + // if by moving a folder we exceed the max depth just from its parents + itself, return an error if len(info.Items) > maxDepth+1 { return folder.ErrMaximumDepthReached.Errorf("maximum folder depth reached") } + // To try to save some computation, get the parents of the old parent (this is typically cheaper + // than looking at the children of the folder). If the old parent has more parents or the same + // number of parents as the new parent, we can return early, because we know the folder had to be + // safe from the creation validation. If we cannot access the older parent, we will continue to check the children. + if canSkipChildrenCheck(ctx, oldFolder, getter, parents, len(info.Items)) { + return nil + } + + // Now comes the more expensive part: we need to check if moving this folder will cause + // any descendant folders to exceed the max depth. + // + // Calculate the maximum allowed subtree depth after the move. + allowedDepth := (maxDepth + 1) - len(info.Items) + if allowedDepth <= 0 { + return nil + } + + return checkSubtreeDepth(ctx, searcher, obj.Namespace, obj.Name, allowedDepth, maxDepth) +} + +// canSkipChildrenCheck determines if we can skip the expensive children depth check. +// If the old parent depth is >= the new parent depth, the folder was already valid +// and this move won't make descendants exceed max depth. +func canSkipChildrenCheck(ctx context.Context, oldFolder utils.GrafanaMetaAccessor, getter rest.Getter, parents parentsGetter, newParentDepth int) bool { + if oldFolder.GetFolder() == folder.RootFolderUID { + return false + } + + oldParentObj, err := getter.Get(ctx, oldFolder.GetFolder(), &metav1.GetOptions{}) + if err != nil { + return false + } + + oldParent, ok := oldParentObj.(*folders.Folder) + if !ok { + return false + } + + oldInfo, err := parents(ctx, oldParent) + if err != nil { + return false + } + + oldParentDepth := len(oldInfo.Items) + levelDifference := newParentDepth - oldParentDepth + return levelDifference <= 0 +} + +// checkSubtreeDepth uses a hybrid DFS+batching approach: +// 1. fetches one page of children for the current folder(s) +// 2. batches all those children into one request to get their children +// 3. continues depth-first (batching still) until max depth or violation +// 4. only fetches more siblings after fully exploring current batch +func checkSubtreeDepth(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, folderUID string, remainingDepth int, maxDepth int) error { + if remainingDepth <= 0 { + return nil + } + + // Start with the folder being moved + return checkSubtreeDepthBatched(ctx, searcher, namespace, []string{folderUID}, remainingDepth, maxDepth) +} + +// checkSubtreeDepthBatched checks depth for a batch of folders at the same level +func checkSubtreeDepthBatched(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, parentUIDs []string, remainingDepth int, maxDepth int) error { + if remainingDepth <= 0 || len(parentUIDs) == 0 { + return nil + } + + const pageSize int64 = 1000 + var offset int64 + totalPages := 0 + hasMore := true + + // Using an upper limit to ensure no infinite loops can happen + for hasMore && totalPages < 1000 { + totalPages++ + + var err error + var children []string + children, hasMore, err = getChildrenBatch(ctx, searcher, namespace, parentUIDs, pageSize, offset) + if err != nil { + return fmt.Errorf("failed to get children: %w", err) + } + + if len(children) == 0 { + return nil + } + + // if we are at the last allowed depth and children exist, we will hit the max + if remainingDepth == 1 { + return folder.ErrMaximumDepthReached.Errorf("maximum folder depth %d would be exceeded after move", maxDepth) + } + + if err := checkSubtreeDepthBatched(ctx, searcher, namespace, children, remainingDepth-1, maxDepth); err != nil { + return err + } + + if !hasMore { + return nil + } + + offset += pageSize + } + return nil } +// getChildrenBatch fetches children for multiple parents +func getChildrenBatch(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, parentUIDs []string, limit int64, offset int64) ([]string, bool, error) { + if len(parentUIDs) == 0 { + return nil, false, nil + } + + resp, err := searcher.Search(ctx, &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Namespace: namespace, + Group: folders.FolderResourceInfo.GroupVersionResource().Group, + Resource: folders.FolderResourceInfo.GroupVersionResource().Resource, + }, + Fields: []*resourcepb.Requirement{{ + Key: resource.SEARCH_FIELD_FOLDER, + Operator: string(selection.In), + Values: parentUIDs, + }}, + }, + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, false, fmt.Errorf("failed to search folders: %w", err) + } + + if resp.Error != nil { + return nil, false, fmt.Errorf("search error: %s", resp.Error.Message) + } + + if resp.Results == nil || len(resp.Results.Rows) == 0 { + return nil, false, nil + } + + children := make([]string, 0, len(resp.Results.Rows)) + for _, row := range resp.Results.Rows { + if row.Key != nil { + children = append(children, row.Key.Name) + } + } + + hasMore := resp.Results.NextPageToken != "" + return children, hasMore, nil +} + func validateOnDelete(ctx context.Context, f *folders.Folder, searcher resourcepb.ResourceIndexClient, diff --git a/pkg/registry/apis/folders/validate_test.go b/pkg/registry/apis/folders/validate_test.go index 7fdb3cfae12..b9f55571ce0 100644 --- a/pkg/registry/apis/folders/validate_test.go +++ b/pkg/registry/apis/folders/validate_test.go @@ -282,6 +282,7 @@ func TestValidateUpdate(t *testing.T) { old *folders.Folder parents *folders.FolderInfoList parentsError error + allFolders []folders.Folder expectedErr string maxDepth int // defaults to 5 unless set }{ @@ -454,6 +455,74 @@ func TestValidateUpdate(t *testing.T) { }, expectedErr: "cannot move folder under its own descendant", }, + { + name: "error when moving folder from root to level2 with children exceeds max depth", + folder: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folderWithChildren", + Annotations: map[string]string{ + utils.AnnoKeyFolder: "level2", + }, + }, + Spec: folders.FolderSpec{ + Title: "folder with children", + }, + }, + old: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folderWithChildren", + }, + Spec: folders.FolderSpec{ + Title: "folder with children", + }, + }, + parents: &folders.FolderInfoList{ + Items: []folders.FolderInfo{ + {Name: "level2", Parent: "level1"}, + {Name: "level1", Parent: folder.GeneralFolderUID}, + {Name: folder.GeneralFolderUID}, + }, + }, + allFolders: []folders.Folder{ + {ObjectMeta: metav1.ObjectMeta{Name: "child1", Annotations: map[string]string{utils.AnnoKeyFolder: "folderWithChildren"}}}, + {ObjectMeta: metav1.ObjectMeta{Name: "grandchild1", Annotations: map[string]string{utils.AnnoKeyFolder: "child1"}}}, + }, + maxDepth: 4, + expectedErr: "[folder.maximum-depth-reached]", + }, + { + name: "can move folder from root level to level1 with children when within max depth", + folder: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folderWithChildren", + Annotations: map[string]string{ + utils.AnnoKeyFolder: "level1", + }, + }, + Spec: folders.FolderSpec{ + Title: "folder with children", + }, + }, + old: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folderWithChildren", + }, + Spec: folders.FolderSpec{ + Title: "folder with children", + }, + }, + parents: &folders.FolderInfoList{ + Items: []folders.FolderInfo{ + {Name: "level1", Parent: folder.GeneralFolderUID}, + {Name: folder.GeneralFolderUID}, + }, + }, + allFolders: []folders.Folder{ + {ObjectMeta: metav1.ObjectMeta{Name: "child1", Annotations: map[string]string{utils.AnnoKeyFolder: "folderWithChildren"}}}, + {ObjectMeta: metav1.ObjectMeta{Name: "grandchild1", Annotations: map[string]string{utils.AnnoKeyFolder: "child1"}}}, + }, + maxDepth: 4, + }, } for _, tt := range tests { @@ -474,11 +543,17 @@ func TestValidateUpdate(t *testing.T) { }, nil).Maybe() } } + for i := range tt.allFolders { + f := tt.allFolders[i] + m.On("Get", context.Background(), f.Name, &metav1.GetOptions{}).Return(&f, nil).Maybe() + } err := validateOnUpdate(context.Background(), tt.folder, tt.old, m, func(ctx context.Context, folder *folders.Folder) (*folders.FolderInfoList, error) { return tt.parents, tt.parentsError - }, maxDepth) + }, + &mockSearchClient{folders: tt.allFolders}, + maxDepth) if tt.expectedErr == "" { require.NoError(t, err) @@ -693,8 +768,7 @@ type mockSearchClient struct { stats *resourcepb.ResourceStatsResponse statsErr error - search *resourcepb.ResourceSearchResponse - searchErr error + folders []folders.Folder } // GetStats implements resourcepb.ResourceIndexClient. @@ -703,8 +777,37 @@ func (m *mockSearchClient) GetStats(ctx context.Context, in *resourcepb.Resource } // Search implements resourcepb.ResourceIndexClient. -func (m *mockSearchClient) Search(ctx context.Context, in *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) { - return m.search, m.searchErr +func (m *mockSearchClient) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) { + // get the list of parents from the search request + parentSet := make(map[string]bool) + if req.Options != nil && req.Options.Fields != nil { + for _, field := range req.Options.Fields { + if field.Key == "folder" && field.Operator == "in" { + for _, v := range field.Values { + parentSet[v] = true + } + } + } + } + + // find children that match the parent filter + var rows []*resourcepb.ResourceTableRow + for i := range m.folders { + meta, err := utils.MetaAccessor(&m.folders[i]) + if err != nil { + continue + } + parentUID := meta.GetFolder() + if parentSet[parentUID] { + rows = append(rows, &resourcepb.ResourceTableRow{ + Key: &resourcepb.ResourceKey{Name: m.folders[i].Name}, + }) + } + } + + return &resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{Rows: rows}, + }, nil } // RebuildIndexes implements resourcepb.ResourceIndexClient. 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 676b0605e83..a235e71ec10 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -44,7 +44,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/process" "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" - "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/pluginassets" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/repo" @@ -196,6 +195,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginexternal" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller" service6 "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsources" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsso" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" @@ -370,7 +370,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } cacheService := localcache.ProvideService() ossDataSourceRequestValidator := validations.ProvideValidator() - sourcesService := sources.ProvideService(cfg, pluginManagementCfg) + pluginsourcesService := pluginsources.ProvideService(cfg, pluginManagementCfg) discovery := pipeline.ProvideDiscoveryStage(pluginManagementCfg, inMemory) keystoreService := keystore.ProvideService(kvStore) keyRetriever := dynamic.ProvideService(cfg, keystoreService) @@ -390,13 +390,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) @@ -406,7 +406,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api testdatasourceService := testdatasource.ProvideService() postgresService := postgres.ProvideService() mysqlService := mysql.ProvideService() - mssqlService := mssql.ProvideService(cfg) + mssqlService := mssql.ProvideService() entityEventsService := store.ProvideEntityEventsService(cfg, sqlStore, featureToggles) configProvider, err := configprovider.ProvideService(cfg) if err != nil { @@ -556,7 +556,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) @@ -580,7 +580,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } errorRegistry := pluginerrs.ProvideErrorTracker() loaderLoader := loader.ProvideService(pluginManagementCfg, discovery, bootstrap, validate, initialize, terminate, errorRegistry) - pluginstoreService, err := pluginstore.ProvideService(inMemory, sourcesService, loaderLoader, featureToggles) + pluginstoreService, err := pluginstore.ProvideService(inMemory, pluginsourcesService, loaderLoader, featureToggles) if err != nil { return nil, err } @@ -847,7 +847,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 { @@ -875,8 +875,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 } @@ -1030,7 +1030,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } cacheService := localcache.ProvideService() ossDataSourceRequestValidator := validations.ProvideValidator() - sourcesService := sources.ProvideService(cfg, pluginManagementCfg) + pluginsourcesService := pluginsources.ProvideService(cfg, pluginManagementCfg) discovery := pipeline.ProvideDiscoveryStage(pluginManagementCfg, inMemory) keystoreService := keystore.ProvideService(kvStore) keyRetriever := dynamic.ProvideService(cfg, keystoreService) @@ -1050,13 +1050,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) @@ -1066,7 +1066,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac testdatasourceService := testdatasource.ProvideService() postgresService := postgres.ProvideService() mysqlService := mysql.ProvideService() - mssqlService := mssql.ProvideService(cfg) + mssqlService := mssql.ProvideService() entityEventsService := store.ProvideEntityEventsService(cfg, sqlStore, featureToggles) configProvider, err := configprovider.ProvideService(cfg) if err != nil { @@ -1216,7 +1216,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) @@ -1240,7 +1240,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } errorRegistry := pluginerrs.ProvideErrorTracker() loaderLoader := loader.ProvideService(pluginManagementCfg, discovery, bootstrap, validate, initialize, terminate, errorRegistry) - pluginstoreService, err := pluginstore.ProvideService(inMemory, sourcesService, loaderLoader, featureToggles) + pluginstoreService, err := pluginstore.ProvideService(inMemory, pluginsourcesService, loaderLoader, featureToggles) if err != nil { return nil, err } @@ -1509,7 +1509,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 { @@ -1537,8 +1537,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/acimpl/basic_role_db_seed.go b/pkg/services/accesscontrol/acimpl/basic_role_db_seed.go new file mode 100644 index 00000000000..c6128790d1a --- /dev/null +++ b/pkg/services/accesscontrol/acimpl/basic_role_db_seed.go @@ -0,0 +1,44 @@ +package acimpl + +import ( + "context" + "time" + + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +const ( + ossBasicRoleSeedLockName = "oss-ac-basic-role-seeder" + ossBasicRoleSeedTimeout = 2 * time.Minute +) + +// refreshBasicRolePermissionsInDB ensures basic role permissions are fully derived from in-memory registrations +func (s *Service) refreshBasicRolePermissionsInDB(ctx context.Context, rolesSnapshot map[string][]accesscontrol.Permission) error { + if s.sql == nil || s.seeder == nil { + return nil + } + + run := func(ctx context.Context) error { + desired := map[accesscontrol.SeedPermission]struct{}{} + for role, permissions := range rolesSnapshot { + for _, permission := range permissions { + desired[accesscontrol.SeedPermission{BuiltInRole: role, Action: permission.Action, Scope: permission.Scope}] = struct{}{} + } + } + s.seeder.SetDesiredPermissions(desired) + return s.seeder.Seed(ctx) + } + + if s.serverLock == nil { + return run(ctx) + } + + var err error + errLock := s.serverLock.LockExecuteAndRelease(ctx, ossBasicRoleSeedLockName, ossBasicRoleSeedTimeout, func(ctx context.Context) { + err = run(ctx) + }) + if errLock != nil { + return errLock + } + return err +} diff --git a/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go b/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go new file mode 100644 index 00000000000..986a32b66fc --- /dev/null +++ b/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go @@ -0,0 +1,128 @@ +package acimpl + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/localcache" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/database" + "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" + "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/testutil" +) + +func TestIntegration_OSSBasicRolePermissions_PersistAndRefreshOnRegisterFixedRoles(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + ctx := context.Background() + sql := db.InitTestDB(t) + store := database.ProvideService(sql) + + svc := ProvideOSSService( + setting.NewCfg(), + store, + &resourcepermissions.FakeActionSetSvc{}, + localcache.ProvideService(), + featuremgmt.WithFeatures(), + tracing.InitializeTracerForTest(), + sql, + permreg.ProvidePermissionRegistry(), + nil, + ) + + require.NoError(t, svc.DeclareFixedRoles(accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:test:role", + Permissions: []accesscontrol.Permission{ + {Action: "test:read", Scope: ""}, + }, + }, + Grants: []string{string(org.RoleViewer)}, + })) + + require.NoError(t, svc.RegisterFixedRoles(ctx)) + + // verify permission is persisted to DB for basic:viewer + require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { + var role accesscontrol.Role + ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) + require.NoError(t, err) + require.True(t, ok) + + var count int64 + count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count() + require.NoError(t, err) + require.Equal(t, int64(1), count) + return nil + })) + + // ensure RegisterFixedRoles refreshes it back to defaults + require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { + ts := time.Now() + var role accesscontrol.Role + ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) + require.NoError(t, err) + require.True(t, ok) + + _, err = sess.Exec("DELETE FROM permission WHERE role_id = ?", role.ID) + require.NoError(t, err) + p := accesscontrol.Permission{ + RoleID: role.ID, + Action: "custom:keep", + Scope: "", + Created: ts, + Updated: ts, + } + p.Kind, p.Attribute, p.Identifier = accesscontrol.SplitScope(p.Scope) + _, err = sess.Table("permission").Insert(&p) + return err + })) + + svc2 := ProvideOSSService( + setting.NewCfg(), + store, + &resourcepermissions.FakeActionSetSvc{}, + localcache.ProvideService(), + featuremgmt.WithFeatures(), + tracing.InitializeTracerForTest(), + sql, + permreg.ProvidePermissionRegistry(), + nil, + ) + require.NoError(t, svc2.DeclareFixedRoles(accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:test:role", + Permissions: []accesscontrol.Permission{ + {Action: "test:read", Scope: ""}, + }, + }, + Grants: []string{string(org.RoleViewer)}, + })) + require.NoError(t, svc2.RegisterFixedRoles(ctx)) + + require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { + var role accesscontrol.Role + ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) + require.NoError(t, err) + require.True(t, ok) + + var count int64 + count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count() + require.NoError(t, err) + require.Equal(t, int64(1), count) + + count, err = sess.Table("permission").Where("role_id = ? AND action = ?", role.ID, "custom:keep").Count() + require.NoError(t, err) + require.Equal(t, int64(0), count) + return nil + })) +} diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index 1ea8bf95f77..3fd419b1f6c 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -30,6 +30,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/migrator" "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" "github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils" + "github.com/grafana/grafana/pkg/services/accesscontrol/seeding" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -96,6 +97,12 @@ func ProvideOSSService( roles: accesscontrol.BuildBasicRoleDefinitions(), store: store, permRegistry: permRegistry, + sql: db, + serverLock: lock, + } + + if backend, ok := store.(*database.AccessControlStore); ok { + s.seeder = seeding.New(log.New("accesscontrol.seeder"), backend, backend) } return s @@ -112,8 +119,11 @@ type Service struct { rolesMu sync.RWMutex roles map[string]*accesscontrol.RoleDTO store accesscontrol.Store + seeder *seeding.Seeder permRegistry permreg.PermissionRegistry isInitialized bool + sql db.DB + serverLock *serverlock.ServerLockService } func (s *Service) GetUsageStats(_ context.Context) map[string]any { @@ -431,17 +441,54 @@ func (s *Service) RegisterFixedRoles(ctx context.Context) error { defer span.End() s.rolesMu.Lock() - defer s.rolesMu.Unlock() - + registrations := s.registrations.Slice() s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool { s.registerRolesLocked(registration) return true }) s.isInitialized = true + + rolesSnapshot := s.getBasicRolePermissionsLocked() + s.rolesMu.Unlock() + + if s.seeder != nil { + if err := s.seeder.SeedRoles(ctx, registrations); err != nil { + return err + } + if err := s.seeder.RemoveAbsentRoles(ctx); err != nil { + return err + } + } + + if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil { + return err + } + return nil } +// getBasicRolePermissionsSnapshotFromRegistrationsLocked computes the desired basic role permissions from the +// current registration list, using the shared seeding registration logic. +// +// it has to be called while holding the roles lock +func (s *Service) getBasicRolePermissionsLocked() map[string][]accesscontrol.Permission { + desired := map[accesscontrol.SeedPermission]struct{}{} + s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool { + seeding.AppendDesiredPermissions(desired, s.log, ®istration.Role, registration.Grants, registration.Exclude, true) + return true + }) + + out := make(map[string][]accesscontrol.Permission) + for sp := range desired { + out[sp.BuiltInRole] = append(out[sp.BuiltInRole], accesscontrol.Permission{ + Action: sp.Action, + Scope: sp.Scope, + }) + } + return out +} + // registerRolesLocked processes a single role registration and adds permissions to basic roles. // Must be called with s.rolesMu locked. func (s *Service) registerRolesLocked(registration accesscontrol.RoleRegistration) { @@ -474,6 +521,7 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs defer span.End() acRegs := pluginutils.ToRegistrations(ID, name, regs) + updatedBasicRoles := false for _, r := range acRegs { if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil { return err @@ -500,11 +548,23 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs if initialized { s.rolesMu.Lock() s.registerRolesLocked(r) + updatedBasicRoles = true s.rolesMu.Unlock() s.cache.Flush() } } + if updatedBasicRoles { + s.rolesMu.RLock() + rolesSnapshot := s.getBasicRolePermissionsLocked() + s.rolesMu.RUnlock() + + // plugin roles can be declared after startup - keep DB in sync + if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil { + return err + } + } + return nil } diff --git a/pkg/services/accesscontrol/database/seeder.go b/pkg/services/accesscontrol/database/seeder.go new file mode 100644 index 00000000000..2f53d20b514 --- /dev/null +++ b/pkg/services/accesscontrol/database/seeder.go @@ -0,0 +1,623 @@ +package database + +import ( + "context" + "strings" + "time" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/seeding" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/util/xorm/core" +) + +const basicRolePermBatchSize = 500 + +// LoadRoles returns all fixed and plugin roles (global org) with permissions, indexed by role name. +func (s *AccessControlStore) LoadRoles(ctx context.Context) (map[string]*accesscontrol.RoleDTO, error) { + out := map[string]*accesscontrol.RoleDTO{} + + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + type roleRow struct { + ID int64 `xorm:"id"` + OrgID int64 `xorm:"org_id"` + Version int64 `xorm:"version"` + UID string `xorm:"uid"` + Name string `xorm:"name"` + DisplayName string `xorm:"display_name"` + Description string `xorm:"description"` + Group string `xorm:"group_name"` + Hidden bool `xorm:"hidden"` + Updated time.Time `xorm:"updated"` + Created time.Time `xorm:"created"` + } + + roles := []roleRow{} + if err := sess.Table("role"). + Where("org_id = ?", accesscontrol.GlobalOrgID). + Where("(name LIKE ? OR name LIKE ?)", accesscontrol.FixedRolePrefix+"%", accesscontrol.PluginRolePrefix+"%"). + Find(&roles); err != nil { + return err + } + + if len(roles) == 0 { + return nil + } + + roleIDs := make([]any, 0, len(roles)) + roleByID := make(map[int64]*accesscontrol.RoleDTO, len(roles)) + for _, r := range roles { + dto := &accesscontrol.RoleDTO{ + ID: r.ID, + OrgID: r.OrgID, + Version: r.Version, + UID: r.UID, + Name: r.Name, + DisplayName: r.DisplayName, + Description: r.Description, + Group: r.Group, + Hidden: r.Hidden, + Updated: r.Updated, + Created: r.Created, + } + out[dto.Name] = dto + roleByID[dto.ID] = dto + roleIDs = append(roleIDs, dto.ID) + } + + type permRow struct { + RoleID int64 `xorm:"role_id"` + Action string `xorm:"action"` + Scope string `xorm:"scope"` + } + perms := []permRow{} + if err := sess.Table("permission").In("role_id", roleIDs...).Find(&perms); err != nil { + return err + } + + for _, p := range perms { + dto := roleByID[p.RoleID] + if dto == nil { + continue + } + dto.Permissions = append(dto.Permissions, accesscontrol.Permission{ + RoleID: p.RoleID, + Action: p.Action, + Scope: p.Scope, + }) + } + + return nil + }) + + return out, err +} + +func (s *AccessControlStore) SetRole(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error { + if existingRole == nil { + return nil + } + + return s.sql.WithDbSession(ctx, func(sess *db.Session) error { + _, err := sess.Table("role"). + Where("id = ? AND org_id = ?", existingRole.ID, accesscontrol.GlobalOrgID). + Update(map[string]any{ + "display_name": wantedRole.DisplayName, + "description": wantedRole.Description, + "group_name": wantedRole.Group, + "hidden": wantedRole.Hidden, + "updated": time.Now(), + }) + return err + }) +} + +func (s *AccessControlStore) SetPermissions(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error { + if existingRole == nil { + return nil + } + + type key struct{ Action, Scope string } + existing := map[key]struct{}{} + for _, p := range existingRole.Permissions { + existing[key{p.Action, p.Scope}] = struct{}{} + } + desired := map[key]struct{}{} + for _, p := range wantedRole.Permissions { + desired[key{p.Action, p.Scope}] = struct{}{} + } + + toAdd := make([]accesscontrol.Permission, 0) + toRemove := make([]accesscontrol.SeedPermission, 0) + + now := time.Now() + for k := range desired { + if _, ok := existing[k]; ok { + continue + } + perm := accesscontrol.Permission{ + RoleID: existingRole.ID, + Action: k.Action, + Scope: k.Scope, + Created: now, + Updated: now, + } + perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) + toAdd = append(toAdd, perm) + } + + for k := range existing { + if _, ok := desired[k]; ok { + continue + } + toRemove = append(toRemove, accesscontrol.SeedPermission{Action: k.Action, Scope: k.Scope}) + } + + if len(toAdd) == 0 && len(toRemove) == 0 { + return nil + } + + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + if len(toRemove) > 0 { + if err := DeleteRolePermissionTuples(sess, s.sql.GetDBType(), existingRole.ID, toRemove); err != nil { + return err + } + } + + if len(toAdd) > 0 { + _, err := sess.InsertMulti(toAdd) + return err + } + + return nil + }) +} + +func (s *AccessControlStore) CreateRole(ctx context.Context, role accesscontrol.RoleDTO) error { + now := time.Now() + uid := role.UID + if uid == "" && (strings.HasPrefix(role.Name, accesscontrol.FixedRolePrefix) || strings.HasPrefix(role.Name, accesscontrol.PluginRolePrefix)) { + uid = accesscontrol.PrefixedRoleUID(role.Name) + } + r := accesscontrol.Role{ + OrgID: accesscontrol.GlobalOrgID, + Version: role.Version, + UID: uid, + Name: role.Name, + DisplayName: role.DisplayName, + Description: role.Description, + Group: role.Group, + Hidden: role.Hidden, + Created: now, + Updated: now, + } + if r.Version == 0 { + r.Version = 1 + } + + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + if _, err := sess.Insert(&r); err != nil { + return err + } + + if len(role.Permissions) == 0 { + return nil + } + + // De-duplicate permissions on (action, scope) to avoid unique constraint violations. + // Some role definitions may accidentally include duplicates. + type permKey struct{ Action, Scope string } + seen := make(map[permKey]struct{}, len(role.Permissions)) + + perms := make([]accesscontrol.Permission, 0, len(role.Permissions)) + for _, p := range role.Permissions { + k := permKey{Action: p.Action, Scope: p.Scope} + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + + perm := accesscontrol.Permission{ + RoleID: r.ID, + Action: p.Action, + Scope: p.Scope, + Created: now, + Updated: now, + } + perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) + perms = append(perms, perm) + } + _, err := sess.InsertMulti(perms) + return err + }) +} + +func (s *AccessControlStore) DeleteRoles(ctx context.Context, roleUIDs []string) error { + if len(roleUIDs) == 0 { + return nil + } + + uids := make([]any, 0, len(roleUIDs)) + for _, uid := range roleUIDs { + uids = append(uids, uid) + } + + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + type row struct { + ID int64 `xorm:"id"` + UID string `xorm:"uid"` + } + rows := []row{} + if err := sess.Table("role"). + Where("org_id = ?", accesscontrol.GlobalOrgID). + In("uid", uids...). + Find(&rows); err != nil { + return err + } + if len(rows) == 0 { + return nil + } + + roleIDs := make([]any, 0, len(rows)) + for _, r := range rows { + roleIDs = append(roleIDs, r.ID) + } + + // Remove permissions and assignments first to avoid FK issues (if enabled). + { + args := append([]any{"DELETE FROM permission WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + { + args := append([]any{"DELETE FROM user_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + { + args := append([]any{"DELETE FROM team_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + { + args := append([]any{"DELETE FROM builtin_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + + args := append([]any{"DELETE FROM role WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")", accesscontrol.GlobalOrgID}, uids...) + _, err := sess.Exec(args...) + return err + }) +} + +// OSS basic-role permission refresh uses seeding.Seeder.Seed() with a desired set computed in memory. +// These methods implement the permission seeding part of seeding.SeedingBackend against the current permission table. +func (s *AccessControlStore) LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { + var out map[accesscontrol.SeedPermission]struct{} + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + rows, err := LoadBasicRoleSeedPermissions(sess) + if err != nil { + return err + } + + out = make(map[accesscontrol.SeedPermission]struct{}, len(rows)) + for _, r := range rows { + r.Origin = "" + out[r] = struct{}{} + } + return nil + }) + return out, err +} + +func (s *AccessControlStore) Apply(ctx context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error { + rolesToUpgrade := seeding.RolesToUpgrade(added, removed) + + // Run the same OSS apply logic as ossBasicRoleSeedBackend.Apply inside a single transaction. + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + defs := accesscontrol.BuildBasicRoleDefinitions() + builtinToRoleID, err := EnsureBasicRolesExist(sess, defs) + if err != nil { + return err + } + + backend := &ossBasicRoleSeedBackend{ + sess: sess, + now: time.Now(), + builtinToRoleID: builtinToRoleID, + desired: nil, + dbType: s.sql.GetDBType(), + } + if err := backend.Apply(ctx, added, removed, updated); err != nil { + return err + } + + return BumpBasicRoleVersions(sess, rolesToUpgrade) + }) +} + +// EnsureBasicRolesExist ensures the built-in basic roles exist in the role table and are bound in builtin_role. +// It returns a mapping from builtin role name (for example "Admin") to role ID. +func EnsureBasicRolesExist(sess *db.Session, defs map[string]*accesscontrol.RoleDTO) (map[string]int64, error) { + uidToBuiltin := make(map[string]string, len(defs)) + uids := make([]any, 0, len(defs)) + for builtin, def := range defs { + uidToBuiltin[def.UID] = builtin + uids = append(uids, def.UID) + } + + type roleRow struct { + ID int64 `xorm:"id"` + UID string `xorm:"uid"` + } + + rows := []roleRow{} + if err := sess.Table("role"). + Where("org_id = ?", accesscontrol.GlobalOrgID). + In("uid", uids...). + Find(&rows); err != nil { + return nil, err + } + + ts := time.Now() + + builtinToRoleID := make(map[string]int64, len(defs)) + for _, r := range rows { + br, ok := uidToBuiltin[r.UID] + if !ok { + continue + } + builtinToRoleID[br] = r.ID + } + + for builtin, def := range defs { + roleID, ok := builtinToRoleID[builtin] + if !ok { + role := accesscontrol.Role{ + OrgID: def.OrgID, + Version: def.Version, + UID: def.UID, + Name: def.Name, + DisplayName: def.DisplayName, + Description: def.Description, + Group: def.Group, + Hidden: def.Hidden, + Created: ts, + Updated: ts, + } + if _, err := sess.Insert(&role); err != nil { + return nil, err + } + roleID = role.ID + builtinToRoleID[builtin] = roleID + } + + has, err := sess.Table("builtin_role"). + Where("role_id = ? AND role = ? AND org_id = ?", roleID, builtin, accesscontrol.GlobalOrgID). + Exist() + if err != nil { + return nil, err + } + if !has { + br := accesscontrol.BuiltinRole{ + RoleID: roleID, + OrgID: accesscontrol.GlobalOrgID, + Role: builtin, + Created: ts, + Updated: ts, + } + if _, err := sess.Table("builtin_role").Insert(&br); err != nil { + return nil, err + } + } + } + + return builtinToRoleID, nil +} + +// DeleteRolePermissionTuples deletes permissions for a single role by (action, scope) pairs. +// +// It uses a row-constructor IN clause where supported (MySQL, Postgres, SQLite) and falls back +// to a WHERE ... OR ... form for MSSQL. +func DeleteRolePermissionTuples(sess *db.Session, dbType core.DbType, roleID int64, perms []accesscontrol.SeedPermission) error { + if len(perms) == 0 { + return nil + } + + if dbType == migrator.MSSQL { + // MSSQL doesn't support (action, scope) IN ((?,?),(?,?)) row constructors. + where := make([]string, 0, len(perms)) + args := make([]any, 0, 1+len(perms)*2) + args = append(args, roleID) + for _, p := range perms { + where = append(where, "(action = ? AND scope = ?)") + args = append(args, p.Action, p.Scope) + } + _, err := sess.Exec( + append([]any{ + "DELETE FROM permission WHERE role_id = ? AND (" + strings.Join(where, " OR ") + ")", + }, args...)..., + ) + return err + } + + args := make([]any, 0, 1+len(perms)*2) + args = append(args, roleID) + for _, p := range perms { + args = append(args, p.Action, p.Scope) + } + sql := "DELETE FROM permission WHERE role_id = ? AND (action, scope) IN (" + + strings.Repeat("(?, ?),", len(perms)-1) + "(?, ?))" + _, err := sess.Exec(append([]any{sql}, args...)...) + return err +} + +type ossBasicRoleSeedBackend struct { + sess *db.Session + now time.Time + builtinToRoleID map[string]int64 + desired map[accesscontrol.SeedPermission]struct{} + dbType core.DbType +} + +func (b *ossBasicRoleSeedBackend) LoadPrevious(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { + rows, err := LoadBasicRoleSeedPermissions(b.sess) + if err != nil { + return nil, err + } + + out := make(map[accesscontrol.SeedPermission]struct{}, len(rows)) + for _, r := range rows { + // Ensure the key matches what OSS seeding uses (Origin is always empty for basic role refresh). + r.Origin = "" + out[r] = struct{}{} + } + return out, nil +} + +func (b *ossBasicRoleSeedBackend) LoadDesired(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { + return b.desired, nil +} + +func (b *ossBasicRoleSeedBackend) Apply(_ context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error { + // Delete removed permissions (this includes user-defined permissions that aren't in desired). + if len(removed) > 0 { + permsByRoleID := map[int64][]accesscontrol.SeedPermission{} + for _, p := range removed { + roleID, ok := b.builtinToRoleID[p.BuiltInRole] + if !ok { + continue + } + permsByRoleID[roleID] = append(permsByRoleID[roleID], p) + } + + for roleID, perms := range permsByRoleID { + // Chunk to keep statement sizes and parameter counts bounded. + if err := batch(len(perms), basicRolePermBatchSize, func(start, end int) error { + return DeleteRolePermissionTuples(b.sess, b.dbType, roleID, perms[start:end]) + }); err != nil { + return err + } + } + } + + // Insert added permissions and updated-target permissions. + toInsertSeed := make([]accesscontrol.SeedPermission, 0, len(added)+len(updated)) + toInsertSeed = append(toInsertSeed, added...) + for _, v := range updated { + toInsertSeed = append(toInsertSeed, v) + } + if len(toInsertSeed) == 0 { + return nil + } + + // De-duplicate on (role_id, action, scope). This avoids unique constraint violations when: + // - the same permission appears in both added and updated + // - multiple plugin origins grant the same permission (Origin is not persisted in permission table) + type permKey struct { + RoleID int64 + Action string + Scope string + } + seen := make(map[permKey]struct{}, len(toInsertSeed)) + + toInsert := make([]accesscontrol.Permission, 0, len(toInsertSeed)) + for _, p := range toInsertSeed { + roleID, ok := b.builtinToRoleID[p.BuiltInRole] + if !ok { + continue + } + k := permKey{RoleID: roleID, Action: p.Action, Scope: p.Scope} + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + + perm := accesscontrol.Permission{ + RoleID: roleID, + Action: p.Action, + Scope: p.Scope, + Created: b.now, + Updated: b.now, + } + perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) + toInsert = append(toInsert, perm) + } + + return batch(len(toInsert), basicRolePermBatchSize, func(start, end int) error { + // MySQL: ignore conflicts to make seeding idempotent under retries/concurrency. + // Conflicts can happen if the same permission already exists (unique on role_id, action, scope). + if b.dbType == migrator.MySQL { + args := make([]any, 0, (end-start)*8) + for i := start; i < end; i++ { + p := toInsert[i] + args = append(args, p.RoleID, p.Action, p.Scope, p.Kind, p.Attribute, p.Identifier, p.Updated, p.Created) + } + sql := append([]any{`INSERT IGNORE INTO permission (role_id, action, scope, kind, attribute, identifier, updated, created) VALUES ` + + strings.Repeat("(?, ?, ?, ?, ?, ?, ?, ?),", end-start-1) + "(?, ?, ?, ?, ?, ?, ?, ?)"}, args...) + _, err := b.sess.Exec(sql...) + return err + } + + _, err := b.sess.InsertMulti(toInsert[start:end]) + return err + }) +} + +func batch(count, size int, eachFn func(start, end int) error) error { + for i := 0; i < count; { + end := i + size + if end > count { + end = count + } + if err := eachFn(i, end); err != nil { + return err + } + i = end + } + return nil +} + +// BumpBasicRoleVersions increments the role version for the given builtin basic roles (Viewer/Editor/Admin/Grafana Admin). +// Unknown role names are ignored. +func BumpBasicRoleVersions(sess *db.Session, basicRoles []string) error { + if len(basicRoles) == 0 { + return nil + } + + defs := accesscontrol.BuildBasicRoleDefinitions() + uids := make([]any, 0, len(basicRoles)) + for _, br := range basicRoles { + def, ok := defs[br] + if !ok { + continue + } + uids = append(uids, def.UID) + } + if len(uids) == 0 { + return nil + } + + sql := "UPDATE role SET version = version + 1 WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")" + _, err := sess.Exec(append([]any{sql, accesscontrol.GlobalOrgID}, uids...)...) + return err +} + +// LoadBasicRoleSeedPermissions returns the current (builtin_role, action, scope) permissions granted to basic roles. +// It sets Origin to empty. +func LoadBasicRoleSeedPermissions(sess *db.Session) ([]accesscontrol.SeedPermission, error) { + rows := []accesscontrol.SeedPermission{} + err := sess.SQL( + `SELECT role.display_name AS builtin_role, p.action, p.scope, '' AS origin + FROM role INNER JOIN permission AS p ON p.role_id = role.id + WHERE role.org_id = ? AND role.name LIKE 'basic:%'`, + accesscontrol.GlobalOrgID, + ).Find(&rows) + return rows, err +} diff --git a/pkg/services/accesscontrol/dualwrite/reconciler.go b/pkg/services/accesscontrol/dualwrite/reconciler.go index a16f6534082..ab27972e86e 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" @@ -13,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/serverlock" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -33,12 +36,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 +98,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( @@ -118,6 +131,9 @@ func (r *ZanzanaReconciler) Run(ctx context.Context) error { // Reconcile schedules as job that will run and reconcile resources between // legacy access control and zanzana. func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error { + // Ensure we don't reconcile an empty/partial RBAC state before OSS has seeded basic role permissions. + // This matters most during startup where fixed-role loading + basic-role permission refresh runs as another background service. + r.waitForBasicRolesSeeded(ctx) r.reconcile(ctx) // FIXME: @@ -133,6 +149,57 @@ func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error { } } +func (r *ZanzanaReconciler) hasBasicRolePermissions(ctx context.Context) bool { + var count int64 + // Basic role permissions are stored on "basic:%" roles in the global org (0). + // In a fresh DB, this will be empty until fixed roles are registered and the basic role permission refresh runs. + type row struct { + Count int64 `xorm:"count"` + } + _ = r.store.WithDbSession(ctx, func(sess *db.Session) error { + var rr row + _, err := sess.SQL( + `SELECT COUNT(*) AS count + FROM role INNER JOIN permission AS p ON p.role_id = role.id + WHERE role.org_id = ? AND role.name LIKE ?`, + accesscontrol.GlobalOrgID, + accesscontrol.BasicRolePrefix+"%", + ).Get(&rr) + if err != nil { + return err + } + count = rr.Count + return nil + }) + return count > 0 +} + +func (r *ZanzanaReconciler) waitForBasicRolesSeeded(ctx context.Context) { + // Best-effort: don't block forever. If we can't observe basic roles, proceed anyway. + const ( + maxWait = 15 * time.Second + interval = 1 * time.Second + ) + + deadline := time.NewTimer(maxWait) + defer deadline.Stop() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + if r.hasBasicRolePermissions(ctx) { + return + } + select { + case <-ctx.Done(): + return + case <-deadline.C: + return + case <-ticker.C: + } + } +} + func (r *ZanzanaReconciler) reconcile(ctx context.Context) { run := func(ctx context.Context, namespace string) { now := time.Now() @@ -144,6 +211,9 @@ func (r *ZanzanaReconciler) reconcile(ctx context.Context) { r.log.Warn("Failed to perform reconciliation for resource", "err", err) } } + if r.metrics.lastSuccess != nil { + r.metrics.lastSuccess.SetToCurrentTime() + } r.log.Debug("Finished reconciliation", "elapsed", time.Since(now)) } diff --git a/pkg/services/accesscontrol/dualwrite/reconciler_test.go b/pkg/services/accesscontrol/dualwrite/reconciler_test.go new file mode 100644 index 00000000000..0defea011a0 --- /dev/null +++ b/pkg/services/accesscontrol/dualwrite/reconciler_test.go @@ -0,0 +1,67 @@ +package dualwrite + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +func TestZanzanaReconciler_hasBasicRolePermissions(t *testing.T) { + env := setupTestEnv(t) + + r := &ZanzanaReconciler{ + store: env.db, + } + + ctx := context.Background() + require.False(t, r.hasBasicRolePermissions(ctx)) + + err := env.db.WithDbSession(ctx, func(sess *db.Session) error { + now := time.Now() + + _, err := sess.Exec( + `INSERT INTO role (org_id, uid, name, display_name, group_name, description, hidden, version, created, updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + accesscontrol.GlobalOrgID, + "basic_viewer_uid_test", + accesscontrol.BasicRolePrefix+"viewer", + "Viewer", + "Basic", + "Viewer role", + false, + 1, + now, + now, + ) + if err != nil { + return err + } + + var roleID int64 + if _, err := sess.SQL(`SELECT id FROM role WHERE org_id = ? AND uid = ?`, accesscontrol.GlobalOrgID, "basic_viewer_uid_test").Get(&roleID); err != nil { + return err + } + + _, err = sess.Exec( + `INSERT INTO permission (role_id, action, scope, kind, attribute, identifier, created, updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + roleID, + "dashboards:read", + "dashboards:*", + "", + "", + "", + now, + now, + ) + return err + }) + require.NoError(t, err) + + require.True(t, r.hasBasicRolePermissions(ctx)) +} diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index b18fb4134f3..85df44750d2 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -1,6 +1,7 @@ package accesscontrol import ( + "context" "encoding/json" "errors" "fmt" @@ -594,3 +595,18 @@ type QueryWithOrg struct { OrgId *int64 `json:"orgId"` Global bool `json:"global"` } + +type SeedPermission struct { + BuiltInRole string `xorm:"builtin_role"` + Action string `xorm:"action"` + Scope string `xorm:"scope"` + Origin string `xorm:"origin"` +} + +type RoleStore interface { + LoadRoles(ctx context.Context) (map[string]*RoleDTO, error) + SetRole(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error + SetPermissions(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error + CreateRole(ctx context.Context, role RoleDTO) error + DeleteRoles(ctx context.Context, roleUIDs []string) error +} diff --git a/pkg/services/accesscontrol/seeding/seeder.go b/pkg/services/accesscontrol/seeding/seeder.go new file mode 100644 index 00000000000..cad31b7a5d2 --- /dev/null +++ b/pkg/services/accesscontrol/seeding/seeder.go @@ -0,0 +1,451 @@ +package seeding + +import ( + "context" + "fmt" + "regexp" + "slices" + "strings" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" +) + +type Seeder struct { + log log.Logger + roleStore accesscontrol.RoleStore + backend SeedingBackend + builtinsPermissions map[accesscontrol.SeedPermission]struct{} + seededFixedRoles map[string]bool + seededPluginRoles map[string]bool + seededPlugins map[string]bool + hasSeededAlready bool +} + +// SeedingBackend provides the seed-set specific operations needed to seed. +type SeedingBackend interface { + // LoadPrevious returns the currently stored permissions for previously seeded roles. + LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error) + + // Apply updates the database to match the desired permissions. + Apply(ctx context.Context, + added, removed []accesscontrol.SeedPermission, + updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission, + ) error +} + +func New(log log.Logger, roleStore accesscontrol.RoleStore, backend SeedingBackend) *Seeder { + return &Seeder{ + log: log, + roleStore: roleStore, + backend: backend, + builtinsPermissions: map[accesscontrol.SeedPermission]struct{}{}, + seededFixedRoles: map[string]bool{}, + seededPluginRoles: map[string]bool{}, + seededPlugins: map[string]bool{}, + hasSeededAlready: false, + } +} + +// SetDesiredPermissions replaces the in-memory desired permission set used by Seed(). +func (s *Seeder) SetDesiredPermissions(desired map[accesscontrol.SeedPermission]struct{}) { + if desired == nil { + s.builtinsPermissions = map[accesscontrol.SeedPermission]struct{}{} + return + } + s.builtinsPermissions = desired +} + +// Seed loads current and desired permissions, diffs them (including scope updates), applies changes, and bumps versions. +func (s *Seeder) Seed(ctx context.Context) error { + previous, err := s.backend.LoadPrevious(ctx) + if err != nil { + return err + } + + // - Do not remove plugin permissions when the plugin didn't register this run (Origin set but not in seededPlugins). + // - Preserve legacy plugin app access permissions in the persisted seed set (these are granted by default). + if len(previous) > 0 { + filtered := make(map[accesscontrol.SeedPermission]struct{}, len(previous)) + for p := range previous { + if p.Action == pluginaccesscontrol.ActionAppAccess { + continue + } + if p.Origin != "" && !s.seededPlugins[p.Origin] { + continue + } + filtered[p] = struct{}{} + } + previous = filtered + } + + added, removed, updated := s.permissionDiff(previous, s.builtinsPermissions) + + if err := s.backend.Apply(ctx, added, removed, updated); err != nil { + return err + } + return nil +} + +// SeedRoles populates the database with the roles and their assignments +// It will create roles that do not exist and update roles that have changed +// Do not use for provisioning. Validation is not enforced. +func (s *Seeder) SeedRoles(ctx context.Context, registrationList []accesscontrol.RoleRegistration) error { + roleMap, err := s.roleStore.LoadRoles(ctx) + if err != nil { + return err + } + + missingRoles := make([]accesscontrol.RoleRegistration, 0, len(registrationList)) + + // Diff existing roles with the ones we want to seed. + // If a role is missing, we add it to the missingRoles list + for _, registration := range registrationList { + registration := registration + role, ok := roleMap[registration.Role.Name] + switch { + case registration.Role.IsFixed(): + s.seededFixedRoles[registration.Role.Name] = true + case registration.Role.IsPlugin(): + s.seededPluginRoles[registration.Role.Name] = true + // To be resilient to failed plugin loadings, we remember the plugins that have registered, + // later we'll ignore permissions and roles of other plugins + s.seededPlugins[pluginutils.PluginIDFromName(registration.Role.Name)] = true + } + + s.rememberPermissionAssignments(®istration.Role, registration.Grants, registration.Exclude) + + if !ok { + missingRoles = append(missingRoles, registration) + continue + } + + if needsRoleUpdate(role, registration.Role) { + if err := s.roleStore.SetRole(ctx, role, registration.Role); err != nil { + return err + } + } + + if needsPermissionsUpdate(role, registration.Role) { + if err := s.roleStore.SetPermissions(ctx, role, registration.Role); err != nil { + return err + } + } + } + + for _, registration := range missingRoles { + if err := s.roleStore.CreateRole(ctx, registration.Role); err != nil { + return err + } + } + + return nil +} + +func needsPermissionsUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool { + if existingRole == nil { + return true + } + + if len(existingRole.Permissions) != len(wantedRole.Permissions) { + return true + } + + for _, p := range wantedRole.Permissions { + found := false + for _, ep := range existingRole.Permissions { + if ep.Action == p.Action && ep.Scope == p.Scope { + found = true + break + } + } + if !found { + return true + } + } + + return false +} + +func needsRoleUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool { + if existingRole == nil { + return true + } + + if existingRole.Name != wantedRole.Name { + return false + } + + if existingRole.DisplayName != wantedRole.DisplayName { + return true + } + + if existingRole.Description != wantedRole.Description { + return true + } + + if existingRole.Group != wantedRole.Group { + return true + } + + if existingRole.Hidden != wantedRole.Hidden { + return true + } + + return false +} + +// Deprecated: SeedRole is deprecated and should not be used. +// SeedRoles only does boot up seeding and should not be used for runtime seeding. +func (s *Seeder) SeedRole(ctx context.Context, role accesscontrol.RoleDTO, builtInRoles []string) error { + addedPermissions := make(map[string]struct{}, len(role.Permissions)) + permissions := make([]accesscontrol.Permission, 0, len(role.Permissions)) + for _, p := range role.Permissions { + key := fmt.Sprintf("%s:%s", p.Action, p.Scope) + if _, ok := addedPermissions[key]; !ok { + addedPermissions[key] = struct{}{} + permissions = append(permissions, accesscontrol.Permission{Action: p.Action, Scope: p.Scope}) + } + } + + wantedRole := accesscontrol.RoleDTO{ + OrgID: accesscontrol.GlobalOrgID, + Version: role.Version, + UID: role.UID, + Name: role.Name, + DisplayName: role.DisplayName, + Description: role.Description, + Group: role.Group, + Permissions: permissions, + Hidden: role.Hidden, + } + roleMap, err := s.roleStore.LoadRoles(ctx) + if err != nil { + return err + } + + existingRole := roleMap[wantedRole.Name] + if existingRole == nil { + if err := s.roleStore.CreateRole(ctx, wantedRole); err != nil { + return err + } + } else { + if needsRoleUpdate(existingRole, wantedRole) { + if err := s.roleStore.SetRole(ctx, existingRole, wantedRole); err != nil { + return err + } + } + if needsPermissionsUpdate(existingRole, wantedRole) { + if err := s.roleStore.SetPermissions(ctx, existingRole, wantedRole); err != nil { + return err + } + } + } + + // Remember seeded roles + if wantedRole.IsFixed() { + s.seededFixedRoles[wantedRole.Name] = true + } + isPluginRole := wantedRole.IsPlugin() + if isPluginRole { + s.seededPluginRoles[wantedRole.Name] = true + + // To be resilient to failed plugin loadings, we remember the plugins that have registered, + // later we'll ignore permissions and roles of other plugins + s.seededPlugins[pluginutils.PluginIDFromName(role.Name)] = true + } + + s.rememberPermissionAssignments(&wantedRole, builtInRoles, []string{}) + return nil +} + +func (s *Seeder) rememberPermissionAssignments(role *accesscontrol.RoleDTO, builtInRoles []string, excludedRoles []string) { + AppendDesiredPermissions(s.builtinsPermissions, s.log, role, builtInRoles, excludedRoles, true) +} + +// AppendDesiredPermissions accumulates permissions from a role registration onto basic roles (Viewer/Editor/Admin/Grafana Admin). +// - It expands parents via accesscontrol.BuiltInRolesWithParents. +// - It can optionally ignore plugin app access permissions (which are granted by default). +func AppendDesiredPermissions( + out map[accesscontrol.SeedPermission]struct{}, + logger log.Logger, + role *accesscontrol.RoleDTO, + builtInRoles []string, + excludedRoles []string, + ignorePluginAppAccess bool, +) { + if out == nil || role == nil { + return + } + + for builtInRole := range accesscontrol.BuiltInRolesWithParents(builtInRoles) { + // Skip excluded grants + if slices.Contains(excludedRoles, builtInRole) { + continue + } + + for _, perm := range role.Permissions { + if ignorePluginAppAccess && perm.Action == pluginaccesscontrol.ActionAppAccess { + logger.Debug("Role is attempting to grant access permission, but this permission is already granted by default and will be ignored", + "role", role.Name, "permission", perm.Action, "scope", perm.Scope) + continue + } + + sp := accesscontrol.SeedPermission{ + BuiltInRole: builtInRole, + Action: perm.Action, + Scope: perm.Scope, + } + + if role.IsPlugin() { + sp.Origin = pluginutils.PluginIDFromName(role.Name) + } + + out[sp] = struct{}{} + } + } +} + +// permissionDiff returns: +// - added: present in desired permissions, not in previous permissions +// - removed: present in previous permissions, not in desired permissions +// - updated: same role + action, but scope changed +func (s *Seeder) permissionDiff(previous, desired map[accesscontrol.SeedPermission]struct{}) (added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) { + addedSet := make(map[accesscontrol.SeedPermission]struct{}, 0) + for n := range desired { + if _, already := previous[n]; !already { + addedSet[n] = struct{}{} + } else { + delete(previous, n) + } + } + + // Check if any of the new permissions is actually an old permission with an updated scope + updated = make(map[accesscontrol.SeedPermission]accesscontrol.SeedPermission, 0) + for n := range addedSet { + for p := range previous { + if n.BuiltInRole == p.BuiltInRole && n.Action == p.Action { + updated[p] = n + delete(addedSet, n) + } + } + } + + for p := range addedSet { + added = append(added, p) + } + + for p := range previous { + if p.Action == pluginaccesscontrol.ActionAppAccess && + p.Scope != pluginaccesscontrol.ScopeProvider.GetResourceAllScope() { + // Allows backward compatibility with plugins that have been seeded before the grant ignore rule was added + s.log.Info("This permission already existed so it will not be removed", + "role", p.BuiltInRole, "permission", p.Action, "scope", p.Scope) + continue + } + + removed = append(removed, p) + } + + return added, removed, updated +} + +func (s *Seeder) ClearBasicRolesPluginPermissions(ID string) { + removable := []accesscontrol.SeedPermission{} + + for key := range s.builtinsPermissions { + if matchPermissionByPluginID(key, ID) { + removable = append(removable, key) + } + } + + for _, perm := range removable { + delete(s.builtinsPermissions, perm) + } +} + +func matchPermissionByPluginID(perm accesscontrol.SeedPermission, pluginID string) bool { + if perm.Origin != pluginID { + return false + } + actionTemplate := regexp.MustCompile(fmt.Sprintf("%s[.:]", pluginID)) + scopeTemplate := fmt.Sprintf(":%s", pluginID) + return actionTemplate.MatchString(perm.Action) || strings.HasSuffix(perm.Scope, scopeTemplate) +} + +// RolesToUpgrade returns the unique basic roles that should have their version incremented. +func RolesToUpgrade(added, removed []accesscontrol.SeedPermission) []string { + set := map[string]struct{}{} + for _, p := range added { + set[p.BuiltInRole] = struct{}{} + } + for _, p := range removed { + set[p.BuiltInRole] = struct{}{} + } + out := make([]string, 0, len(set)) + for r := range set { + out = append(out, r) + } + return out +} + +func (s *Seeder) ClearPluginRoles(ID string) { + expectedPrefix := fmt.Sprintf("%s%s:", accesscontrol.PluginRolePrefix, ID) + + for roleName := range s.seededPluginRoles { + if strings.HasPrefix(roleName, expectedPrefix) { + delete(s.seededPluginRoles, roleName) + } + } +} + +func (s *Seeder) MarkSeededAlready() { + s.hasSeededAlready = true +} + +func (s *Seeder) HasSeededAlready() bool { + return s.hasSeededAlready +} + +func (s *Seeder) RemoveAbsentRoles(ctx context.Context) error { + roleMap, errGet := s.roleStore.LoadRoles(ctx) + if errGet != nil { + s.log.Error("failed to get fixed roles from store", "err", errGet) + return errGet + } + + toRemove := []string{} + for _, r := range roleMap { + if r == nil { + continue + } + if r.IsFixed() { + if !s.seededFixedRoles[r.Name] { + s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name) + toRemove = append(toRemove, r.UID) + } + continue + } + + if r.IsPlugin() { + if !s.seededPlugins[pluginutils.PluginIDFromName(r.Name)] { + // To be resilient to failed plugin loadings + // ignore stored roles related to plugins that have not registered this time + s.log.Debug("plugin role has not been registered on this run skipping its removal", "role", r.Name) + continue + } + if !s.seededPluginRoles[r.Name] { + s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name) + toRemove = append(toRemove, r.UID) + } + } + } + + if errDelete := s.roleStore.DeleteRoles(ctx, toRemove); errDelete != nil { + s.log.Error("failed to delete absent fixed and plugin roles", "err", errDelete) + return errDelete + } + return nil +} diff --git a/pkg/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/auth/auth.go b/pkg/services/auth/auth.go index cc678914b31..76b60b68517 100644 --- a/pkg/services/auth/auth.go +++ b/pkg/services/auth/auth.go @@ -20,9 +20,10 @@ const ( // Typed errors var ( - ErrUserTokenNotFound = errors.New("user token not found") - ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken - ErrExternalSessionNotFound = errors.New("external session not found") + ErrUserTokenNotFound = errors.New("user token not found") + ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken + ErrExternalSessionNotFound = errors.New("external session not found") + ErrExternalSessionTokenNotFound = errors.New("session token was nil") ) type ( 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/zanzana.go b/pkg/services/authz/zanzana/zanzana.go index 6cadfc0c199..261ea78830d 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 diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d6f2bcbec2e..3748db8e6b4 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -572,13 +572,6 @@ var ( FrontendOnly: false, // The restore backend feature changes behavior based on this flag Owner: grafanaDashboardsSquad, }, - { - Name: "kubernetesDashboardsV2", - Description: "Use the v2 kubernetes API in the frontend for dashboards", - Stage: FeatureStageExperimental, - FrontendOnly: false, - Owner: grafanaDashboardsSquad, - }, { Name: "dashboardUndoRedo", Description: "Enables undo/redo in dynamic dashboards", @@ -688,6 +681,14 @@ var ( HideFromDocs: true, RequiresRestart: true, }, + { + Name: "auditLoggingAppPlatform", + Description: "Enable audit logging with Kubernetes under app platform", + Stage: FeatureStageExperimental, + Owner: grafanaOperatorExperienceSquad, + HideFromDocs: true, + RequiresRestart: true, + }, { Name: "secretsManagementAppPlatform", Description: "Enable the secrets management API and services under app platform", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 179568aa0c4..0c85021cff8 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -79,7 +79,6 @@ dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true dashboardScene,GA,@grafana/dashboards-squad,false,false,true dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false -kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true drilldownRecommendations,experimental,@grafana/dashboards-squad,false,false,true @@ -95,6 +94,7 @@ kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false +auditLoggingAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,true,false secretsManagementAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,false,false secretsManagementAppPlatformUI,experimental,@grafana/grafana-operator-experience-squad,false,false,false alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 2797b046d57..5de71954e2f 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -259,10 +259,6 @@ const ( // Enables experimental new dashboard layouts FlagDashboardNewLayouts = "dashboardNewLayouts" - // FlagKubernetesDashboardsV2 - // Use the v2 kubernetes API in the frontend for dashboards - FlagKubernetesDashboardsV2 = "kubernetesDashboardsV2" - // FlagPdfTables // Enables generating table data as PDF in reporting FlagPdfTables = "pdfTables" @@ -279,6 +275,10 @@ const ( // Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group. Disables sequential evaluation if enabled. FlagJitterAlertRulesWithinGroups = "jitterAlertRulesWithinGroups" + // FlagAuditLoggingAppPlatform + // Enable audit logging with Kubernetes under app platform + FlagAuditLoggingAppPlatform = "auditLoggingAppPlatform" + // FlagSecretsManagementAppPlatform // Enable the secrets management API and services under app platform FlagSecretsManagementAppPlatform = "secretsManagementAppPlatform" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 42922ecf82d..6d55a6ca617 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -658,6 +658,20 @@ "frontend": true } }, + { + "metadata": { + "name": "auditLoggingAppPlatform", + "resourceVersion": "1767013056996", + "creationTimestamp": "2025-12-29T12:57:36Z" + }, + "spec": { + "description": "Enable audit logging with Kubernetes under app platform", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad", + "requiresRestart": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "authZGRPCServer", @@ -2003,8 +2017,9 @@ { "metadata": { "name": "kubernetesDashboardsV2", - "resourceVersion": "1764664939750", - "creationTimestamp": "2025-12-02T08:42:19Z" + "resourceVersion": "1764236054307", + "creationTimestamp": "2025-11-27T09:34:14Z", + "deletionTimestamp": "2025-12-05T13:43:57Z" }, "spec": { "description": "Use the v2 kubernetes API in the frontend for dashboards", diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage.go b/pkg/services/folder/folderimpl/folder_unifiedstorage.go index 9b238b769fe..69b8cb59311 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage.go @@ -726,19 +726,6 @@ func (s *Service) moveOnApiServer(ctx context.Context, cmd *folder.MoveFolderCom return nil, folder.ErrBadRequest.Errorf("k6 project may not be moved") } - f, err := s.unifiedStore.Get(ctx, folder.GetFolderQuery{ - UID: &cmd.UID, - OrgID: cmd.OrgID, - SignedInUser: cmd.SignedInUser, - }) - if err != nil { - return nil, err - } - - if f != nil && f.ParentUID == accesscontrol.K6FolderUID { - return nil, folder.ErrBadRequest.Errorf("k6 project may not be moved") - } - // Check that the user is allowed to move the folder to the destination folder hasAccess, evalErr := s.canMoveViaApiServer(ctx, cmd) if evalErr != nil { @@ -748,30 +735,7 @@ func (s *Service) moveOnApiServer(ctx context.Context, cmd *folder.MoveFolderCom return nil, dashboards.ErrFolderAccessDenied } - // here we get the folder, we need to get the height of current folder - // and the depth of the new parent folder, the sum can't bypass 8 - folderHeight, err := s.unifiedStore.GetHeight(ctx, cmd.UID, cmd.OrgID, &cmd.NewParentUID) - if err != nil { - return nil, err - } - parents, err := s.unifiedStore.GetParents(ctx, folder.GetParentsQuery{UID: cmd.NewParentUID, OrgID: cmd.OrgID}) - if err != nil { - return nil, err - } - - // height of the folder that is being moved + this current folder itself + depth of the NewParent folder should be less than or equal MaxNestedFolderDepth - if folderHeight+len(parents)+1 > folder.MaxNestedFolderDepth { - return nil, folder.ErrMaximumDepthReached.Errorf("failed to move folder") - } - - for _, parent := range parents { - // if the current folder is already a parent of newparent, we should return error - if parent.UID == cmd.UID { - return nil, folder.ErrCircularReference.Errorf("failed to move folder") - } - } - - f, err = s.unifiedStore.Update(ctx, folder.UpdateFolderCommand{ + f, err := s.unifiedStore.Update(ctx, folder.UpdateFolderCommand{ UID: cmd.UID, OrgID: cmd.OrgID, NewParentUID: &cmd.NewParentUID, diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index eefbb6dea30..e2b60ad6e39 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -161,7 +161,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { authz: ruleAuthzService, evaluator: api.EvaluatorFactory, cfg: &api.Cfg.UnifiedAlerting, - backtesting: backtesting.NewEngine(api.AppUrl, api.EvaluatorFactory, api.Tracer), + backtesting: backtesting.NewEngine(api.AppUrl, api.EvaluatorFactory, api.Tracer, api.Cfg.UnifiedAlerting, api.FeatureManager), featureManager: api.FeatureManager, appUrl: api.AppUrl, tracer: api.Tracer, diff --git a/pkg/services/ngalert/api/api_convert_prometheus.go b/pkg/services/ngalert/api/api_convert_prometheus.go index 588f6dee213..b6849f55dfc 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus.go +++ b/pkg/services/ngalert/api/api_convert_prometheus.go @@ -251,7 +251,12 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteRuleGroup(c *contex logger.Info("Deleting Prometheus-imported rule group", "folder_uid", folder.UID, "folder_title", namespaceTitle, "group", group) provenance := getProvenance(c) - err = srv.alertRuleService.DeleteRuleGroup(c.Req.Context(), c.SignedInUser, folder.UID, group, provenance) + filterOpts := &provisioning.FilterOptions{ + NamespaceUIDs: []string{folder.UID}, + RuleGroups: []string{group}, + HasPrometheusRuleDefinition: util.Pointer(true), + } + err = srv.alertRuleService.DeleteRuleGroups(c.Req.Context(), c.SignedInUser, provenance, filterOpts) if errors.Is(err, models.ErrAlertRuleGroupNotFound) { return response.Empty(http.StatusNotFound) } @@ -370,6 +375,9 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroups(c *context } datasourceUID := strings.TrimSpace(c.Req.Header.Get(datasourceUIDHeader)) + if datasourceUID == "" { + datasourceUID = srv.cfg.PrometheusConversion.DefaultDatasourceUID + } if datasourceUID == "" { return response.Err(errDatasourceUIDHeaderMissing) } diff --git a/pkg/services/ngalert/api/api_convert_prometheus_test.go b/pkg/services/ngalert/api/api_convert_prometheus_test.go index 08ce147a5c3..74e851ed195 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus_test.go +++ b/pkg/services/ngalert/api/api_convert_prometheus_test.go @@ -75,6 +75,46 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) { require.Contains(t, string(response.Body()), "Missing datasource UID header") }) + t.Run("without datasource UID header but with config default should succeed", func(t *testing.T) { + srv, _, ruleStore := createConvertPrometheusSrv(t) + // Set the config default + srv.cfg.PrometheusConversion.DefaultDatasourceUID = existingDSUID + + rc := createRequestCtx() + rc.Req.Header.Set(datasourceUIDHeader, "") + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + + require.Equal(t, http.StatusAccepted, response.Status()) + + // Verify that the config default datasource was used + assertRulesUseDatasource(t, ruleStore, existingDSUID, 2) + }) + + t.Run("header should take precedence over config default", func(t *testing.T) { + srv, dsCache, ruleStore := createConvertPrometheusSrv(t) + // Add another datasource + anotherDS := &datasources.DataSource{ + UID: "another-ds", + Type: datasources.DS_PROMETHEUS, + } + dsCache.DataSources = append(dsCache.DataSources, anotherDS) + + // Set the config default to one DS + srv.cfg.PrometheusConversion.DefaultDatasourceUID = "another-ds" + + // But use the header to specify a different one + rc := createRequestCtx() + rc.Req.Header.Set(datasourceUIDHeader, existingDSUID) + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + + require.Equal(t, http.StatusAccepted, response.Status()) + + // Verify that the header datasource was used, not the config default + assertRulesUseDatasource(t, ruleStore, existingDSUID, 2) + }) + t.Run("with invalid datasource should return error", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() @@ -1390,7 +1430,7 @@ func TestRouteConvertPrometheusDeleteRuleGroup(t *testing.T) { t.Run("with disable provenance header should still be able to delete rules", func(t *testing.T) { provenanceStore := fakes.NewFakeProvisioningStore() - srv, ruleStore, fldr, rule := initGroup("", groupName, withProvenanceStore(provenanceStore)) + srv, ruleStore, fldr, rule := initGroup("prometheus definition", groupName, withProvenanceStore(provenanceStore)) // Mark the rule as provisioned with API provenance err := provenanceStore.SetProvenance(context.Background(), rule, 1, models.ProvenanceConvertedPrometheus) @@ -1411,6 +1451,37 @@ func TestRouteConvertPrometheusDeleteRuleGroup(t *testing.T) { require.Nil(t, remaining) }) }) + + t.Run("should not delete non-imported rule groups", func(t *testing.T) { + folderService := foldertest.NewFakeService() + srv, _, ruleStore := createConvertPrometheusSrv(t, withFolderService(folderService)) + rc := createRequestCtx() + + fldr := randFolder() + fldr.ParentUID = "" + folderService.ExpectedFolder = fldr + folderService.ExpectedFolders = []*folder.Folder{fldr} + ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) + + rule := models.RuleGen. + With(models.RuleGen.WithNamespaceUID(fldr.UID)). + With(models.RuleGen.WithOrgID(1)). + With(models.RuleGen.WithGroupName(groupName)). + GenerateRef() + ruleStore.PutRule(context.Background(), rule) + + // Attempt to delete via convert endpoint should return 404 + response := srv.RouteConvertPrometheusDeleteRuleGroup(rc, fldr.Title, groupName) + require.Equal(t, http.StatusNotFound, response.Status()) + + // Verify the rule is still present + remaining, err := ruleStore.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{ + UID: rule.UID, + OrgID: rule.OrgID, + }) + require.NoError(t, err) + require.NotNil(t, remaining) + }) } func TestRouteConvertPrometheusPostRuleGroups(t *testing.T) { @@ -1730,6 +1801,26 @@ func createRequestCtx() *contextmodel.ReqContext { } } +// assertRulesUseDatasource retrieves all alert rules from the store and verifies they use the expected datasource +func assertRulesUseDatasource(t *testing.T, ruleStore *fakes.RuleStore, expectedDatasourceUID string, expectedRuleCount int) { + t.Helper() + + rules, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{ + OrgID: 1, + }) + require.NoError(t, err) + require.Len(t, rules, expectedRuleCount) + + for _, rule := range rules { + if rule.Record == nil { + require.NotEmpty(t, rule.Data) + require.Equal(t, expectedDatasourceUID, rule.Data[0].DatasourceUID, rule.Title, expectedDatasourceUID) + } else { + require.Equal(t, expectedDatasourceUID, rule.Record.TargetDatasourceUID) + } + } +} + // Test parseBooleanHeader function which handles boolean header values func TestParseBooleanHeader(t *testing.T) { headerName := "X-Test-Header" diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 75ec6c901fd..dc0f6d1f13d 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -2369,6 +2369,140 @@ func TestRouteGetRuleStatuses(t *testing.T) { } }) + t.Run("multi-page pagination loads provenance correctly", func(t *testing.T) { + fakeStore, fakeAIM, api, fakeProvisioning := setupAPIFull(t) + + // Create 3 groups with 1 rule each: groups 1 and 3 firing, group 2 normal + for i := 1; i <= 3; i++ { + rule := gen.With(gen.WithOrgID(orgID), func(r *ngmodels.AlertRule) { + r.NamespaceUID = "ns-1" + r.RuleGroup = fmt.Sprintf("group-%d", i) + r.UID = fmt.Sprintf("rule-%d", i) + }, withClassicConditionSingleQuery()).GenerateRef() + + alertState := eval.Normal + if i != 2 { + alertState = eval.Alerting + } + fakeAIM.GenerateAlertInstances(orgID, rule.UID, 1, func(s *state.State) *state.State { + s.State = alertState + s.Labels = data.Labels{"test": "label"} + return s + }) + fakeStore.PutRule(context.Background(), rule) + } + + // Set provenance for all rules + err := fakeProvisioning.SetProvenance(context.Background(), + &ngmodels.AlertRule{UID: "rule-1", OrgID: orgID}, orgID, ngmodels.ProvenanceAPI) + require.NoError(t, err) + err = fakeProvisioning.SetProvenance(context.Background(), + &ngmodels.AlertRule{UID: "rule-3", OrgID: orgID}, orgID, ngmodels.ProvenanceFile) + require.NoError(t, err) + + // Request firing groups with group_limit=2 - fetches multiple pages, skipping group 2 + req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&group_limit=2", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + // Should return 2 firing groups + require.Len(t, res.Data.RuleGroups, 2) + require.Equal(t, "group-1", res.Data.RuleGroups[0].Name) + require.Equal(t, apimodels.Provenance(ngmodels.ProvenanceAPI), res.Data.RuleGroups[0].Rules[0].Provenance) + require.Equal(t, "group-3", res.Data.RuleGroups[1].Name) + require.Equal(t, apimodels.Provenance(ngmodels.ProvenanceFile), res.Data.RuleGroups[1].Rules[0].Provenance) + }) + + t.Run("provenance fetch error returns error response in paginated mode", func(t *testing.T) { + fakeStore, fakeAIM, api, fakeProvisioning := setupAPIFull(t) + + rule := gen.With(gen.WithOrgID(orgID), func(r *ngmodels.AlertRule) { + r.NamespaceUID = "ns-1" + r.RuleGroup = "group-1" + r.UID = "rule-1" + }, withClassicConditionSingleQuery()).GenerateRef() + + fakeAIM.GenerateAlertInstances(orgID, rule.UID, 1, func(s *state.State) *state.State { + s.State = eval.Alerting + s.Labels = data.Labels{"test": "label"} + return s + }) + fakeStore.PutRule(context.Background(), rule) + + fakeProvisioning.GetProvenancesByUIDsFunc = func(ctx context.Context, orgID int64, resourceType string, uids []string) (map[string]ngmodels.Provenance, error) { + return nil, errors.New("database connection failed") + } + + req, err := http.NewRequest("GET", "/api/v1/rules?group_limit=10", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusInternalServerError, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + require.Equal(t, "error", res.Status) + require.Contains(t, res.Error, "failed to load provenance") + }) + + t.Run("provenance fetch error returns error response in non-paginated mode", func(t *testing.T) { + fakeStore, fakeAIM, api, fakeProvisioning := setupAPIFull(t) + + rule := gen.With(gen.WithOrgID(orgID), func(r *ngmodels.AlertRule) { + r.NamespaceUID = "ns-1" + r.RuleGroup = "group-1" + r.UID = "rule-1" + }, withClassicConditionSingleQuery()).GenerateRef() + + fakeAIM.GenerateAlertInstances(orgID, rule.UID, 1, func(s *state.State) *state.State { + s.State = eval.Alerting + s.Labels = data.Labels{"test": "label"} + return s + }) + fakeStore.PutRule(context.Background(), rule) + + fakeProvisioning.GetProvenancesFunc = func(ctx context.Context, orgID int64, resourceType string) (map[string]ngmodels.Provenance, error) { + return nil, errors.New("database connection failed") + } + + req, err := http.NewRequest("GET", "/api/v1/rules", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusInternalServerError, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + require.Equal(t, "error", res.Status) + require.Contains(t, res.Error, "failed to load provenance") + }) + t.Run("state filter continues when first page has no matches", func(t *testing.T) { fakeStore, fakeAIM, api := setupAPI(t) diff --git a/pkg/services/ngalert/api/api_ruler_validation_test.go b/pkg/services/ngalert/api/api_ruler_validation_test.go index 98fbb20cf12..553a02cff30 100644 --- a/pkg/services/ngalert/api/api_ruler_validation_test.go +++ b/pkg/services/ngalert/api/api_ruler_validation_test.go @@ -493,6 +493,7 @@ func TestValidateRuleNode_NoUID(t *testing.T) { r.GrafanaManagedAlert.NoDataState = apimodels.OK r.GrafanaManagedAlert.ExecErrState = apimodels.AlertingErrState r.GrafanaManagedAlert.NotificationSettings = &apimodels.AlertRuleNotificationSettings{} + r.GrafanaManagedAlert.MissingSeriesEvalsToResolve = util.Pointer[int64](1) r.For = func() *model.Duration { five := model.Duration(time.Second * 5); return &five }() r.KeepFiringFor = func() *model.Duration { five := model.Duration(time.Second * 5); return &five }() return &r @@ -502,6 +503,7 @@ func TestValidateRuleNode_NoUID(t *testing.T) { require.Empty(t, alert.NoDataState) require.Empty(t, alert.ExecErrState) require.Nil(t, alert.NotificationSettings) + require.Nil(t, alert.MissingSeriesEvalsToResolve) require.Zero(t, alert.For) require.Zero(t, alert.KeepFiringFor) }, diff --git a/pkg/services/ngalert/api/api_testing.go b/pkg/services/ngalert/api/api_testing.go index 3bda2e3f28f..13bc1a96c24 100644 --- a/pkg/services/ngalert/api/api_testing.go +++ b/pkg/services/ngalert/api/api_testing.go @@ -34,7 +34,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" ) type folderService interface { @@ -230,54 +229,27 @@ func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimo return ErrResp(http.StatusNotFound, nil, "Backgtesting API is not enabled") } - if cmd.From.After(cmd.To) { - return ErrResp(400, nil, "From cannot be greater than To") - } - - noDataState, err := ngmodels.NoDataStateFromString(string(cmd.NoDataState)) - + rule, err := apivalidation.ValidateBacktestConfig(c.GetOrgID(), cmd, apivalidation.RuleLimitsFromConfig(srv.cfg, srv.featureManager)) if err != nil { - return ErrResp(400, err, "") - } - forInterval := time.Duration(cmd.For) - if forInterval < 0 { - return ErrResp(400, nil, "Bad For interval") + return ErrResp(http.StatusBadRequest, err, "") } - intervalSeconds, err := apivalidation.ValidateInterval(time.Duration(cmd.Interval), srv.cfg.BaseInterval) - if err != nil { - return ErrResp(400, err, "") - } - - queries := AlertQueriesFromApiAlertQueries(cmd.Data) - if err := srv.authz.AuthorizeDatasourceAccessForRule(c.Req.Context(), c.SignedInUser, &ngmodels.AlertRule{Data: queries}); err != nil { + if err := srv.authz.AuthorizeDatasourceAccessForRule(c.Req.Context(), c.SignedInUser, rule); err != nil { return errorToResponse(err) } - rule := &ngmodels.AlertRule{ - // ID: 0, - // Updated: time.Time{}, - // Version: 0, - // NamespaceUID: "", - // DashboardUID: nil, - // PanelID: nil, - // RuleGroup: "", - // RuleGroupIndex: 0, - // ExecErrState: "", - Title: cmd.Title, - // prefix backtesting- is to distinguish between executions of regular rule and backtesting in logs (like expression engine, evaluator, state manager etc) - UID: "backtesting-" + util.GenerateShortUID(), - OrgID: c.GetOrgID(), - Condition: cmd.Condition, - Data: queries, - IntervalSeconds: intervalSeconds, - NoDataState: noDataState, - For: forInterval, - Annotations: cmd.Annotations, - Labels: cmd.Labels, + // Fetch folder path for alert labels, fallback to "Backtesting" if not available + var folderTitle string + if cmd.NamespaceUID != "" { + f, err := srv.folderService.GetNamespaceByUID(c.Req.Context(), cmd.NamespaceUID, c.OrgID, c.SignedInUser) + if err != nil { + srv.log.FromContext(c.Req.Context()).Warn("Failed to fetch folder path for alert labels", "error", err) + } else { + folderTitle = f.Fullpath + } } - result, err := srv.backtesting.Test(c.Req.Context(), c.SignedInUser, rule, cmd.From, cmd.To) + result, err := srv.backtesting.Test(c.Req.Context(), c.SignedInUser, rule, cmd.From, cmd.To, folderTitle) if err != nil { if errors.Is(err, backtesting.ErrInvalidInputData) { return ErrResp(400, err, "Failed to evaluate") @@ -285,9 +257,5 @@ func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimo return ErrResp(500, err, "Failed to evaluate") } - body, err := data.FrameToJSON(result, data.IncludeAll) - if err != nil { - return ErrResp(500, err, "Failed to convert frame to JSON") - } - return response.JSON(http.StatusOK, body) + return response.JSONStreaming(http.StatusOK, result) } diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 1c107db22c6..7f8b42bb3a4 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -81,9 +81,15 @@ func (api *API) authorize(method, path string) web.Handler { // additional authorization is done in the request handler eval = ac.EvalPermission(ac.ActionAlertingRuleRead) // Grafana Rules Testing Paths - case http.MethodPost + "/api/v1/rule/backtest": + case http.MethodPost + "/api/v1/rule/backtest": // TODO (yuri) this should be protected by dedicated permission // additional authorization is done in the request handler - eval = ac.EvalPermission(ac.ActionAlertingRuleRead) + eval = ac.EvalAll( + ac.EvalPermission(ac.ActionAlertingRuleRead), + ac.EvalAny( + ac.EvalPermission(ac.ActionAlertingRuleUpdate), + ac.EvalPermission(ac.ActionAlertingRuleCreate), + ), + ) case http.MethodPost + "/api/v1/eval": // additional authorization is done in the request handler eval = ac.EvalPermission(ac.ActionAlertingRuleRead) diff --git a/pkg/services/ngalert/api/compat/compat.go b/pkg/services/ngalert/api/compat/compat.go index 5fda13672ba..37c9a8db6ed 100644 --- a/pkg/services/ngalert/api/compat/compat.go +++ b/pkg/services/ngalert/api/compat/compat.go @@ -189,42 +189,11 @@ func AlertRuleExportFromAlertRule(rule models.AlertRule) (definitions.AlertRuleE data = append(data, query) } - cPtr := &rule.Condition - if rule.Condition == "" { - cPtr = nil - } - - noDataState := definitions.NoDataState(rule.NoDataState) - ndsPtr := &noDataState - if noDataState == "" { - ndsPtr = nil - } - execErrorState := definitions.ExecutionErrorState(rule.ExecErrState) - eesPtr := &execErrorState - if execErrorState == "" { - eesPtr = nil - } - result := definitions.AlertRuleExport{ - UID: rule.UID, - Title: rule.Title, - For: model.Duration(rule.For), - KeepFiringFor: model.Duration(rule.KeepFiringFor), - Condition: cPtr, - Data: data, - DashboardUID: rule.DashboardUID, - PanelID: rule.PanelID, - NoDataState: ndsPtr, - ExecErrState: eesPtr, - IsPaused: rule.IsPaused, - NotificationSettings: AlertRuleNotificationSettingsExportFromNotificationSettings(rule.NotificationSettings), - Record: AlertRuleRecordExportFromRecord(rule.Record), - } - if rule.For.Seconds() > 0 { - result.ForString = util.Pointer(model.Duration(rule.For).String()) - } - if rule.KeepFiringFor.Seconds() > 0 { - result.KeepFiringForString = util.Pointer(model.Duration(rule.KeepFiringFor).String()) + UID: rule.UID, + Title: rule.Title, + Data: data, + IsPaused: rule.IsPaused, } if rule.Annotations != nil { result.Annotations = &rule.Annotations @@ -232,13 +201,54 @@ func AlertRuleExportFromAlertRule(rule models.AlertRule) (definitions.AlertRuleE if rule.Labels != nil { result.Labels = &rule.Labels } - if rule.MissingSeriesEvalsToResolve != nil && *rule.MissingSeriesEvalsToResolve != -1 { - result.MissingSeriesEvalsToResolve = rule.MissingSeriesEvalsToResolve + + if rule.Type() == models.RuleTypeRecording { + populateRecordingRuleExportFields(rule, &result) + } else { + populateAlertingRuleExportFields(rule, &result) } return result, nil } +func populateRecordingRuleExportFields(rule models.AlertRule, result *definitions.AlertRuleExport) { + result.Record = AlertRuleRecordExportFromRecord(rule.Record) +} + +func populateAlertingRuleExportFields(rule models.AlertRule, result *definitions.AlertRuleExport) { + result.DashboardUID = rule.DashboardUID + result.PanelID = rule.PanelID + result.NotificationSettings = AlertRuleNotificationSettingsExportFromNotificationSettings(rule.NotificationSettings) + + if rule.Condition != "" { + result.Condition = &rule.Condition + } + + if rule.NoDataState != "" { + noDataState := definitions.NoDataState(rule.NoDataState) + result.NoDataState = &noDataState + } + + if rule.ExecErrState != "" { + execErrorState := definitions.ExecutionErrorState(rule.ExecErrState) + result.ExecErrState = &execErrorState + } + + result.For = model.Duration(rule.For) + if rule.For > 0 { + result.ForString = util.Pointer(model.Duration(rule.For).String()) + } + + result.KeepFiringFor = model.Duration(rule.KeepFiringFor) + if rule.KeepFiringFor > 0 { + result.KeepFiringForString = util.Pointer(model.Duration(rule.KeepFiringFor).String()) + } + + if rule.MissingSeriesEvalsToResolve != nil && *rule.MissingSeriesEvalsToResolve != -1 { + result.MissingSeriesEvalsToResolve = rule.MissingSeriesEvalsToResolve + } +} + func encodeQueryModel(m map[string]any) (string, error) { var buf bytes.Buffer enc := json.NewEncoder(&buf) diff --git a/pkg/services/ngalert/api/compat/compat_test.go b/pkg/services/ngalert/api/compat/compat_test.go index 4a107335945..8b50c609559 100644 --- a/pkg/services/ngalert/api/compat/compat_test.go +++ b/pkg/services/ngalert/api/compat/compat_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/util" ) func TestToModel(t *testing.T) { @@ -115,6 +116,102 @@ func TestToModel(t *testing.T) { }) } +func TestAlertRuleExportFromAlertRule(t *testing.T) { + alertingRule := models.RuleGen.With( + models.RuleGen.WithNotEmptyLabels(2, "lbl-"), + models.RuleGen.WithAnnotations(map[string]string{"ann-key": "ann-value"}), + models.RuleGen.WithFor(2*time.Minute), + models.RuleGen.WithKeepFiringFor(5*time.Minute), + models.RuleGen.WithNotificationSettingsGen(models.NotificationSettingsGen()), + ).Generate() + recordingRule := models.RuleGen.With( + models.RuleGen.WithAllRecordingRules(), + models.RuleGen.WithNotEmptyLabels(2, "lbl-"), + models.RuleGen.WithAnnotations(map[string]string{"ann-key": "ann-value"}), + ).Generate() + + // Build expected exported recording rule + recordingRuleData, err := AlertQueryExportFromAlertQuery(recordingRule.Data[0]) + require.NoError(t, err) + expectedRecordingRuleExport := definitions.AlertRuleExport{ + UID: recordingRule.UID, + Title: recordingRule.Title, + Data: []definitions.AlertQueryExport{recordingRuleData}, + Annotations: &recordingRule.Annotations, + Labels: &recordingRule.Labels, + Record: &definitions.AlertRuleRecordExport{ + Metric: recordingRule.Record.Metric, + From: recordingRule.Record.From, + TargetDatasourceUID: util.Pointer(recordingRule.Record.TargetDatasourceUID), + }, + } + + // Build expected exported alerting rule + alertingRuleData, err := AlertQueryExportFromAlertQuery(alertingRule.Data[0]) + require.NoError(t, err) + noDataState := definitions.NoDataState(alertingRule.NoDataState) + execErrState := definitions.ExecutionErrorState(alertingRule.ExecErrState) + expectedAlertingRuleExport := definitions.AlertRuleExport{ + UID: alertingRule.UID, + Title: alertingRule.Title, + Condition: &alertingRule.Condition, + Data: []definitions.AlertQueryExport{alertingRuleData}, + DashboardUID: alertingRule.DashboardUID, + PanelID: alertingRule.PanelID, + NoDataState: &noDataState, + ExecErrState: &execErrState, + For: prommodel.Duration(alertingRule.For), + KeepFiringFor: prommodel.Duration(alertingRule.KeepFiringFor), + ForString: util.Pointer(prommodel.Duration(alertingRule.For).String()), + KeepFiringForString: util.Pointer(prommodel.Duration(alertingRule.KeepFiringFor).String()), + Annotations: &alertingRule.Annotations, + Labels: &alertingRule.Labels, + NotificationSettings: AlertRuleNotificationSettingsExportFromNotificationSettings(alertingRule.NotificationSettings), + MissingSeriesEvalsToResolve: alertingRule.MissingSeriesEvalsToResolve, + } + + testCases := []struct { + name string + rule models.AlertRule + expected definitions.AlertRuleExport + }{ + { + name: "export recording rule", + rule: recordingRule, + expected: expectedRecordingRuleExport, + }, + { + name: "export alerting rule", + rule: alertingRule, + expected: expectedAlertingRuleExport, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + exported, err := AlertRuleExportFromAlertRule(tc.rule) + require.NoError(t, err) + require.Equal(t, tc.expected, exported) + }) + } +} + +func TestAlertQueryExportFromAlertQuery(t *testing.T) { + query := models.RuleGen.GenerateQuery() + + exported, err := AlertQueryExportFromAlertQuery(query) + require.NoError(t, err) + + require.Equal(t, query.RefID, exported.RefID) + require.Equal(t, query.DatasourceUID, exported.DatasourceUID) + require.Equal(t, int64(time.Duration(query.RelativeTimeRange.From).Seconds()), exported.RelativeTimeRange.FromSeconds) + require.Equal(t, int64(time.Duration(query.RelativeTimeRange.To).Seconds()), exported.RelativeTimeRange.ToSeconds) + require.NotNil(t, exported.QueryType) + require.Equal(t, query.QueryType, *exported.QueryType) + require.NotNil(t, exported.Model) + require.NotEmpty(t, exported.ModelString) +} + func TestAlertRuleMetadataFromModelMetadata(t *testing.T) { t.Run("should convert model metadata to api metadata", func(t *testing.T) { modelMetadata := models.AlertRuleMetadata{ diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index b4e14a66cfe..077761d0caf 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -54,6 +54,7 @@ type StatusReader interface { type ProvenanceStore interface { GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]ngmodels.Provenance, error) + GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]ngmodels.Provenance, error) } type PrometheusSrv struct { @@ -328,14 +329,6 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon span.AddEvent("User permissions checked") span.SetAttributes(attribute.Int("allowedNamespaces", len(allowedNamespaces))) - provenanceRecords, err := srv.provenanceStore.GetProvenances(c.Req.Context(), c.GetOrgID(), (&ngmodels.AlertRule{}).ResourceType()) - if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = fmt.Sprintf("failed to get provenances visible to the user: %s", err.Error()) - ruleResponse.ErrorType = apiv1.ErrServer - return response.JSON(ruleResponse.HTTPStatusCode(), ruleResponse) - } - ruleResponse = PrepareRuleGroupStatusesV2( srv.log, srv.store, @@ -347,7 +340,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon }, RuleStatusMutatorGenerator(srv.status), RuleAlertStateMutatorGenerator(srv.manager), - provenanceRecords, + srv.provenanceStore, ) return response.JSON(ruleResponse.HTTPStatusCode(), ruleResponse) @@ -454,6 +447,7 @@ func RuleAlertStateMutatorGenerator(manager state.AlertInstanceManager) RuleAler type paginationContext struct { opts RuleGroupStatusesOptions provenanceRecords map[string]ngmodels.Provenance + provenanceStore ProvenanceStore ruleStatusMutator RuleStatusMutator alertStateMutator RuleAlertStateMutator @@ -532,6 +526,37 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert ) span.AddEvent("Alert rules retrieved from store") + // Load provenance for this page's rules + if ctx.provenanceStore != nil { + maxGroups := getInt64WithDefault(ctx.opts.Query, "group_limit", -1) + maxRules := getInt64WithDefault(ctx.opts.Query, "rule_limit", -1) + + if maxGroups > 0 || maxRules > 0 { + // Paginated, fetch and merge provenances for this page + uids := make([]string, 0, len(ruleList)) + for _, rule := range ruleList { + uids = append(uids, rule.UID) + } + pageProvenances, err := ctx.provenanceStore.GetProvenancesByUIDs(ctx.opts.Ctx, ctx.opts.OrgID, (&ngmodels.AlertRule{}).ResourceType(), uids) + if err != nil { + return pageResult{}, fmt.Errorf("failed to load provenance: %w", err) + } + if ctx.provenanceRecords == nil { + ctx.provenanceRecords = pageProvenances + } else { + maps.Copy(ctx.provenanceRecords, pageProvenances) + } + } else if ctx.provenanceRecords == nil { + // Not paginated, fetch all once + var err error + ctx.provenanceRecords, err = ctx.provenanceStore.GetProvenances(ctx.opts.Ctx, ctx.opts.OrgID, (&ngmodels.AlertRule{}).ResourceType()) + if err != nil { + return pageResult{}, fmt.Errorf("failed to load provenance: %w", err) + } + } + } + span.AddEvent("Provenances retrieved from store") + groupedRules := getGroupedRules(log, ruleList, ctx.ruleNamesSet, ctx.opts.AllowedNamespaces) result := pageResult{ @@ -643,7 +668,7 @@ func paginateRuleGroups(log log.Logger, store ListAlertRulesStoreV2, ctx *pagina return allGroups, rulesTotals, continueToken, nil } -func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse { +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() opts.Ctx = ctx @@ -835,7 +860,8 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt span.SetAttributes(attribute.Bool("compact", compact)) pagCtx := &paginationContext{ opts: opts, - provenanceRecords: provenanceRecords, + provenanceRecords: nil, + provenanceStore: provenanceStore, ruleStatusMutator: ruleStatusMutator, alertStateMutator: alertStateMutator, namespaceUIDs: namespaceUIDs, diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl index 3a5882e0a49..5daa4dce406 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl @@ -173,4 +173,49 @@ resource "grafana_rule_group" "rule_group_d3e8424bfbf66bc3" { from = "condition" } } + rule { + name = "recording rule with target" + + data { + ref_id = "query" + + relative_time_range { + from = 18000 + to = 10800 + } + + datasource_uid = "000000002" + model = "{\"expr\":\"rate(http_requests_total[5m])\",\"hide\":false,\"interval\":\"\",\"intervalMs\":1000,\"legendFormat\":\"\",\"maxDataPoints\":100,\"refId\":\"query\"}" + } + data { + ref_id = "reduced" + + relative_time_range { + from = 18000 + to = 10800 + } + + datasource_uid = "__expr__" + model = "{\"expression\":\"query\",\"hide\":false,\"intervalMs\":1000,\"maxDataPoints\":100,\"reducer\":\"mean\",\"refId\":\"reduced\",\"type\":\"reduce\"}" + } + data { + ref_id = "condition" + + relative_time_range { + from = 18000 + to = 10800 + } + + datasource_uid = "__expr__" + model = "{\"expression\":\"$reduced > 5\",\"hide\":false,\"intervalMs\":1000,\"maxDataPoints\":100,\"refId\":\"condition\",\"type\":\"math\"}" + } + + is_paused = false + + record { + metric = "http_requests_rate" + from = "condition" + target_datasource_uid = "000000003" + } + } } diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json index bde4bb31f57..4fe9a2dc75a 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json @@ -233,6 +233,67 @@ "metric": "test_metric", "from": "condition" } + }, + { + "title": "recording rule with target", + "data": [ + { + "refId": "query", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "000000002", + "model": { + "expr": "rate(http_requests_total[5m])", + "hide": false, + "interval": "", + "intervalMs": 1000, + "legendFormat": "", + "maxDataPoints": 100, + "refId": "query" + } + }, + { + "refId": "reduced", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "query", + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 100, + "reducer": "mean", + "refId": "reduced", + "type": "reduce" + } + }, + { + "refId": "condition", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "$reduced \u003e 5", + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 100, + "refId": "condition", + "type": "math" + } + } + ], + "isPaused": false, + "record": { + "metric": "http_requests_rate", + "from": "condition", + "targetDatasourceUid": "000000003" + } } ] } diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml index bdb6e9e8362..242a2204823 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml @@ -187,3 +187,48 @@ groups: record: metric: test_metric from: condition + - title: recording rule with target + data: + - refId: query + relativeTimeRange: + from: 18000 + to: 10800 + datasourceUid: "000000002" + model: + expr: rate(http_requests_total[5m]) + hide: false + interval: "" + intervalMs: 1000 + legendFormat: "" + maxDataPoints: 100 + refId: query + - refId: reduced + relativeTimeRange: + from: 18000 + to: 10800 + datasourceUid: __expr__ + model: + expression: query + hide: false + intervalMs: 1000 + maxDataPoints: 100 + reducer: mean + refId: reduced + type: reduce + - refId: condition + relativeTimeRange: + from: 18000 + to: 10800 + datasourceUid: __expr__ + model: + expression: $reduced > 5 + hide: false + intervalMs: 1000 + maxDataPoints: 100 + refId: condition + type: math + isPaused: false + record: + metric: http_requests_rate + from: condition + targetDatasourceUid: "000000003" diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101.json b/pkg/services/ngalert/api/test-data/post-rulegroup-101.json index f0eaa36d40d..e9936041979 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101.json +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101.json @@ -240,6 +240,71 @@ "from": "condition" } } + }, + { + "grafana_alert": { + "title": "recording rule with target", + "data": [ + { + "refId": "query", + "queryType": "", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "000000002", + "model": { + "expr": "rate(http_requests_total[5m])", + "hide": false, + "interval": "", + "intervalMs": 1000, + "legendFormat": "", + "maxDataPoints": 100, + "refId": "query" + } + }, + { + "refId": "reduced", + "queryType": "", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "query", + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 100, + "reducer": "mean", + "refId": "reduced", + "type": "reduce" + } + }, + { + "refId": "condition", + "queryType": "", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "$reduced > 5", + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 100, + "refId": "condition", + "type": "math" + } + } + ], + "record": { + "metric": "http_requests_rate", + "from": "condition", + "target_datasource_uid": "000000003" + } + } } ] } diff --git a/pkg/services/ngalert/api/tooling/definitions/testing.go b/pkg/services/ngalert/api/tooling/definitions/testing.go index 2c228e94758..e094c8515b3 100644 --- a/pkg/services/ngalert/api/tooling/definitions/testing.go +++ b/pkg/services/ngalert/api/tooling/definitions/testing.go @@ -221,15 +221,21 @@ type BacktestConfig struct { To time.Time `json:"to"` Interval model.Duration `json:"interval,omitempty"` - Condition string `json:"condition"` - Data []AlertQuery `json:"data"` - For model.Duration `json:"for,omitempty"` + Condition string `json:"condition"` + Data []AlertQuery `json:"data"` + For *model.Duration `json:"for,omitempty"` + KeepFiringFor *model.Duration `json:"keep_firing_for,omitempty"` - Title string `json:"title"` - Labels map[string]string `json:"labels,omitempty"` - Annotations map[string]string `json:"annotations,omitempty"` + Title string `json:"title"` + Labels map[string]string `json:"labels,omitempty"` - NoDataState NoDataState `json:"no_data_state"` + NoDataState NoDataState `json:"no_data_state"` + ExecErrState ExecutionErrorState `json:"exec_err_state"` + MissingSeriesEvalsToResolve *int64 `json:"missing_series_evals_to_resolve,omitempty"` + + UID string `json:"uid,omitempty"` + RuleGroup string `json:"rule_group,omitempty"` + NamespaceUID string `json:"namespace_uid,omitempty"` } // swagger:model diff --git a/pkg/services/ngalert/api/validation/api_ruler_validation.go b/pkg/services/ngalert/api/validation/api_ruler_validation.go index c2baf8108ba..09e601a4711 100644 --- a/pkg/services/ngalert/api/validation/api_ruler_validation.go +++ b/pkg/services/ngalert/api/validation/api_ruler_validation.go @@ -193,6 +193,7 @@ func validateRecordingRuleFields(in *apimodels.PostableExtendedRuleNode, newRule newRule.For = 0 newRule.KeepFiringFor = 0 newRule.NotificationSettings = nil + newRule.MissingSeriesEvalsToResolve = nil return newRule, nil } @@ -248,6 +249,21 @@ func ValidateCondition(condition string, queries []apimodels.AlertQuery, canPatc return nil } +func validateGroupInterval(incoming prommodels.Duration, limits RuleLimits) (time.Duration, error) { + interval := time.Duration(incoming) + if interval == 0 { + // if group interval is 0 (undefined) then we automatically fall back to the default interval + interval = limits.DefaultRuleEvaluationInterval + } + + if interval < 0 || int64(interval.Seconds())%int64(limits.BaseInterval.Seconds()) != 0 { + return 0, fmt.Errorf("rule evaluation interval (%d second) should be positive number that is multiple of the base interval of %d seconds", int64(interval.Seconds()), int64(limits.BaseInterval.Seconds())) + } + + // TODO should we validate that interval is >= cfg.MinInterval? Currently, we allow to save but fix the specified interval if it is < cfg.MinInterval + return interval, nil +} + func ValidateInterval(interval, baseInterval time.Duration) (int64, error) { intervalSeconds := int64(interval.Seconds()) @@ -335,18 +351,11 @@ func ValidateRuleGroup( return nil, fmt.Errorf("rule group name is too long. Max length is %d", store.AlertRuleMaxRuleGroupNameLength) } - interval := time.Duration(ruleGroupConfig.Interval) - if interval == 0 { - // if group interval is 0 (undefined) then we automatically fall back to the default interval - interval = limits.DefaultRuleEvaluationInterval + interval, err := validateGroupInterval(ruleGroupConfig.Interval, limits) + if err != nil { + return nil, err } - if interval < 0 || int64(interval.Seconds())%int64(limits.BaseInterval.Seconds()) != 0 { - return nil, fmt.Errorf("rule evaluation interval (%d second) should be positive number that is multiple of the base interval of %d seconds", int64(interval.Seconds()), int64(limits.BaseInterval.Seconds())) - } - - // TODO should we validate that interval is >= cfg.MinInterval? Currently, we allow to save but fix the specified interval if it is < cfg.MinInterval - // If the rule group is reserved for no-group rules, we cannot have multiple rules in it. if isNoGroupRuleGroup && len(ruleGroupConfig.Rules) > 1 { return nil, fmt.Errorf("rule group %s is reserved for no-group rules and cannot be used for rule groups with multiple rules", ruleGroupConfig.Name) @@ -409,3 +418,32 @@ func ValidateNotificationSettings(n *apimodels.AlertRuleNotificationSettings) ([ s, }, nil } + +func ValidateBacktestConfig(orgId int64, config apimodels.BacktestConfig, limits RuleLimits) (*ngmodels.AlertRule, error) { + if config.From.After(config.To) { + return nil, fmt.Errorf("invalid testing range: from %s must be before to %s", config.From, config.To) + } + + interval, err := validateGroupInterval(config.Interval, limits) + if err != nil { + return nil, err + } + + return ValidateRuleNode(&apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: config.For, + KeepFiringFor: config.KeepFiringFor, + Labels: config.Labels, + Annotations: nil, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: config.Title, + Condition: config.Condition, + Data: config.Data, + UID: config.UID, + NoDataState: config.NoDataState, + ExecErrState: config.ExecErrState, + MissingSeriesEvalsToResolve: config.MissingSeriesEvalsToResolve, + }, + }, config.RuleGroup, interval, orgId, config.NamespaceUID, limits) +} diff --git a/pkg/services/ngalert/backtesting/engine.go b/pkg/services/ngalert/backtesting/engine.go index 31c968eb234..b4a534fe134 100644 --- a/pkg/services/ngalert/backtesting/engine.go +++ b/pkg/services/ngalert/backtesting/engine.go @@ -15,10 +15,16 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/schedule" + "github.com/grafana/grafana/pkg/services/ngalert/schedule/ticker" "github.com/grafana/grafana/pkg/services/ngalert/state" + "github.com/grafana/grafana/pkg/services/ngalert/state/historian" + history_model "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) var ( @@ -28,7 +34,7 @@ var ( backtestingEvaluatorFactory = newBacktestingEvaluator ) -type callbackFunc = func(evaluationIndex int, now time.Time, results eval.Results) error +type callbackFunc = func(evaluationIndex int, now time.Time, results eval.Results) (bool, error) type backtestingEvaluator interface { Eval(ctx context.Context, from time.Time, interval time.Duration, evaluations int, callback callbackFunc) error @@ -40,11 +46,17 @@ type stateManager interface { } type Engine struct { - evalFactory eval.EvaluatorFactory - createStateManager func() stateManager + evalFactory eval.EvaluatorFactory + createStateManager func() stateManager + disableGrafanaFolder bool + featureToggles featuremgmt.FeatureToggles + minInterval time.Duration + baseInterval time.Duration + jitterStrategy schedule.JitterStrategy + maxEvaluations int } -func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracing.Tracer) *Engine { +func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracing.Tracer, cfg setting.UnifiedAlertingSettings, toggles featuremgmt.FeatureToggles) *Engine { return &Engine{ evalFactory: evalFactory, createStateManager: func() stateManager { @@ -60,74 +72,139 @@ func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracin } return state.NewManager(cfg, state.NewNoopPersister()) }, + disableGrafanaFolder: false, + featureToggles: toggles, + minInterval: cfg.MinInterval, + baseInterval: cfg.BaseInterval, + maxEvaluations: cfg.BacktestingMaxEvaluations, + jitterStrategy: schedule.JitterStrategyFrom(cfg, toggles), } } -func (e *Engine) Test(ctx context.Context, user identity.Requester, rule *models.AlertRule, from, to time.Time) (*data.Frame, error) { - ruleCtx := models.WithRuleKey(ctx, rule.GetKey()) - logger := logger.FromContext(ctx) - +func (e *Engine) Test(ctx context.Context, user identity.Requester, rule *models.AlertRule, from, to time.Time, folderTitle string) (res *data.Frame, err error) { + if rule == nil { + return nil, fmt.Errorf("%w: rule is not defined", ErrInvalidInputData) + } if !from.Before(to) { - return nil, fmt.Errorf("%w: invalid interval of the backtesting [%d,%d]", ErrInvalidInputData, from.Unix(), to.Unix()) + return nil, fmt.Errorf("%w: invalid interval [%d,%d]", ErrInvalidInputData, from.Unix(), to.Unix()) } - if to.Sub(from).Seconds() < float64(rule.IntervalSeconds) { - return nil, fmt.Errorf("%w: interval of the backtesting [%d,%d] is less than evaluation interval [%ds]", ErrInvalidInputData, from.Unix(), to.Unix(), rule.IntervalSeconds) + + ruleCtx := models.WithRuleKey(ctx, rule.GetKey()) + logger := logger.FromContext(ruleCtx).New("backtesting", util.GenerateShortUID()) + + var warns []string + if rule.GetInterval() < e.minInterval { + logger.Warn("Interval adjusted to minimal interval", "originalInterval", rule.GetInterval(), "adjustedInterval", e.minInterval) + rule = rule.Copy() + rule.IntervalSeconds = int64(e.minInterval.Seconds()) + warns = append(warns, fmt.Sprintf("Interval adjusted to minimal interval %ds", rule.IntervalSeconds)) } - length := int(to.Sub(from).Seconds()) / int(rule.IntervalSeconds) - stateManager := e.createStateManager() + effectiveStrategy := e.jitterStrategy + if e.jitterStrategy == schedule.JitterByGroup && (rule.RuleGroup == "" || rule.NamespaceUID == "") || + e.jitterStrategy == schedule.JitterByRule && rule.UID == "" { + logger.Warn(fmt.Sprintf("Jitter strategy is set to %s, but rule group or namespace is not set. Ignore jitter", e.jitterStrategy)) + warns = append(warns, fmt.Sprintf("Jitter strategy is set to %s, but rule group or namespace is not set. Ignore jitter. The results of testing will be different than real evaluations", e.jitterStrategy)) + effectiveStrategy = schedule.JitterNever + } + jitterOffset := schedule.JitterOffsetInDuration(rule, e.baseInterval, effectiveStrategy) + firstEval, err := getFirstEvaluationTime(from, rule, e.baseInterval, jitterOffset) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidInputData, err) + } - evaluator, err := backtestingEvaluatorFactory(ruleCtx, e.evalFactory, user, rule.GetEvalCondition().WithSource("backtesting"), &schedule.AlertingResultsFromRuleState{ - Manager: stateManager, - Rule: rule, - }) + evaluations := calculateNumberOfEvaluations(firstEval, to, rule.GetInterval()) + if e.maxEvaluations > 0 && evaluations > e.maxEvaluations { + logger.Warn("Evaluations adjusted to maximal number", "originalEvaluations", evaluations, "adjustedEvaluations", e.maxEvaluations) + warns = append(warns, fmt.Sprintf("Number of evaluations are adjusted to the limit of %d evaluations. Requested: %d", e.maxEvaluations, evaluations)) + evaluations = e.maxEvaluations + } + + start := time.Now() + defer func() { + if err == nil { + logger.Info("Rule testing finished successfully", "duration", time.Since(start)) + } else { + logger.Error("Rule testing finished with error", "duration", time.Since(start), "error", err) + } + }() + + stateMgr := e.createStateManager() + + evaluator, err := backtestingEvaluatorFactory(ruleCtx, + e.evalFactory, + user, + rule.GetEvalCondition().WithSource("backtesting"), + &schedule.AlertingResultsFromRuleState{ + Manager: stateMgr, + Rule: rule, + }, + ) if err != nil { return nil, errors.Join(ErrInvalidInputData, err) } - logger.Info("Start testing alert rule", "from", from, "to", to, "interval", rule.IntervalSeconds, "evaluations", length) + logger.Info("Start testing alert rule", "from", from, "to", to, "interval", rule.GetInterval(), "firstTick", firstEval, "evaluations", evaluations, "jitterOffset", jitterOffset, "jitterStrategy", effectiveStrategy) - start := time.Now() + var builder *historian.QueryResultBuilder - tsField := data.NewField("Time", nil, make([]time.Time, length)) - valueFields := make(map[data.Fingerprint]*data.Field) - - err = evaluator.Eval(ruleCtx, from, time.Duration(rule.IntervalSeconds)*time.Second, length, func(idx int, currentTime time.Time, results eval.Results) error { - if idx >= length { - logger.Info("Unexpected evaluation. Skipping", "from", from, "to", to, "interval", rule.IntervalSeconds, "evaluationTime", currentTime, "evaluationIndex", idx, "expectedEvaluations", length) - return nil - } - states := stateManager.ProcessEvalResults(ruleCtx, currentTime, rule, results, nil, nil) - tsField.Set(idx, currentTime) - for _, s := range states { - field, ok := valueFields[s.CacheID] - if !ok { - field = data.NewField("", s.Labels, make([]*string, length)) - valueFields[s.CacheID] = field - } - if s.State.State != eval.NoData { // set nil if NoData - value := s.State.State.String() - if s.StateReason != "" { - value += " (" + s.StateReason + ")" - } - field.Set(idx, &value) - continue - } - } - return nil - }) - fields := make([]*data.Field, 0, len(valueFields)+1) - fields = append(fields, tsField) - for _, f := range valueFields { - fields = append(fields, f) + ruleMeta := history_model.RuleMeta{ + ID: rule.ID, + OrgID: rule.OrgID, + UID: rule.UID, + Title: rule.Title, + Group: rule.RuleGroup, + NamespaceUID: rule.NamespaceUID, + // DashboardUID: "", + // PanelID: 0, + Condition: rule.Condition, } - result := data.NewFrame("Testing results", fields...) - + labels := map[string]string{ + historian.OrgIDLabel: fmt.Sprint(ruleMeta.OrgID), + historian.GroupLabel: fmt.Sprint(ruleMeta.Group), + historian.FolderUIDLabel: fmt.Sprint(rule.NamespaceUID), + } + labelsBytes, err := json.Marshal(labels) if err != nil { return nil, err } - logger.Info("Rule testing finished successfully", "duration", time.Since(start)) - return result, nil + + // Ensure fallback if empty string is passed + if folderTitle == "" { + folderTitle = "Backtesting" + } + extraLabels := state.GetRuleExtraLabels(logger, rule, folderTitle, !e.disableGrafanaFolder, e.featureToggles) + + processFn := func(idx int, currentTime time.Time, results eval.Results) (bool, error) { + // init the builder. Do the best guess for the size of the result + if builder == nil { + builder = historian.NewQueryResultBuilder(evaluations * len(results)) + for _, warn := range warns { + builder.AddWarn(warn) + } + } + states := stateMgr.ProcessEvalResults(ruleCtx, currentTime, rule, results, extraLabels, nil) + for _, s := range states { + if !historian.ShouldRecord(s) { + continue + } + entry := historian.StateTransitionToLokiEntry(ruleMeta, s) + err := builder.AddRow(currentTime, entry, labelsBytes) + if err != nil { + return false, err + } + } + return idx <= evaluations, nil + } + + err = evaluator.Eval(ruleCtx, firstEval, rule.GetInterval(), evaluations, processFn) + if err != nil { + return nil, err + } + if builder == nil { + return nil, errors.New("no results were produced") + } + return builder.ToFrame(), nil } func newBacktestingEvaluator(ctx context.Context, evalFactory eval.EvaluatorFactory, user identity.Requester, condition models.Condition, reader eval.AlertingResultsReader) (backtestingEvaluator, error) { @@ -173,3 +250,53 @@ type NoopImageService struct{} func (s *NoopImageService) NewImage(_ context.Context, _ *models.AlertRule) (*models.Image, error) { return &models.Image{}, nil } + +func getNextEvaluationTime(currentTime time.Time, rule *models.AlertRule, baseInterval time.Duration, jitterOffset time.Duration) (time.Time, error) { + if rule.IntervalSeconds%int64(baseInterval.Seconds()) != 0 { + return time.Time{}, fmt.Errorf("interval %ds is not divisible by base interval %ds", rule.IntervalSeconds, int64(baseInterval.Seconds())) + } + + freq := rule.IntervalSeconds / int64(baseInterval.Seconds()) + + firstTickNum := currentTime.Unix() / int64(baseInterval.Seconds()) + + jitterOffsetTicks := int64(jitterOffset / baseInterval) + + firstEvalTickNum := firstTickNum + (jitterOffsetTicks-(firstTickNum%freq)+freq)%freq + + return time.Unix(firstEvalTickNum*int64(baseInterval.Seconds()), 0), nil +} + +func getFirstEvaluationTime(from time.Time, rule *models.AlertRule, baseInterval time.Duration, jitterOffset time.Duration) (time.Time, error) { + // Now calculate the time of the tick the same way as in the scheduler + firstTick := ticker.GetStartTick(from, baseInterval) + + // calculate time of the first evaluation that is at or after the first tick + firstEval, err := getNextEvaluationTime(firstTick, rule, baseInterval, jitterOffset) + if err != nil { + return time.Time{}, err + } + + // Ensure firstEval is at or after from + // Calculate how many intervals to skip to get past 'from' + if firstEval.Before(from) { + diff := from.Sub(firstEval) + interval := rule.GetInterval() + // Ceiling division: how many intervals needed to cover the difference + intervalsToAdd := (diff + interval - 1) / interval + firstEval = firstEval.Add(interval * intervalsToAdd) + } + + return firstEval, nil +} + +func calculateNumberOfEvaluations(firstEval, to time.Time, interval time.Duration) int { + var evaluations int + if to.After(firstEval) { + evaluations = int(to.Sub(firstEval).Seconds()) / int(interval.Seconds()) + } + if evaluations == 0 { + evaluations = 1 + } + return evaluations +} diff --git a/pkg/services/ngalert/backtesting/engine_test.go b/pkg/services/ngalert/backtesting/engine_test.go index d2685e71535..33441d73f32 100644 --- a/pkg/services/ngalert/backtesting/engine_test.go +++ b/pkg/services/ngalert/backtesting/engine_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "math/rand" "testing" "time" @@ -14,9 +13,11 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/eval/eval_mocks" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/util" ) @@ -158,16 +159,6 @@ func TestNewBacktestingEvaluator(t *testing.T) { } func TestEvaluatorTest(t *testing.T) { - states := []eval.State{eval.Normal, eval.Alerting, eval.Pending} - generateState := func(prefix string) *state.State { - labels := models.GenerateAlertLabels(rand.Intn(5)+1, prefix+"-") - return &state.State{ - CacheID: labels.Fingerprint(), - Labels: labels, - State: states[rand.Intn(len(states))], - } - } - randomResultCallback := func(now time.Time) (eval.Results, error) { return eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen()), nil } @@ -189,84 +180,17 @@ func TestEvaluatorTest(t *testing.T) { createStateManager: func() stateManager { return manager }, + disableGrafanaFolder: false, + featureToggles: featuremgmt.WithFeatures(), + minInterval: 1 * time.Second, + baseInterval: 1 * time.Second, + jitterStrategy: schedule.JitterNever, + maxEvaluations: 10000, } gen := models.RuleGen rule := gen.With(gen.WithInterval(time.Second)).GenerateRef() ruleInterval := time.Duration(rule.IntervalSeconds) * time.Second - t.Run("should return data frame in specific format", func(t *testing.T) { - from := time.Unix(0, 0) - to := from.Add(5 * ruleInterval) - allStates := [...]eval.State{eval.Normal, eval.Alerting, eval.Pending, eval.NoData, eval.Error} - - var states []state.StateTransition - - for _, s := range allStates { - labels := models.GenerateAlertLabels(rand.Intn(5)+1, s.String()+"-") - states = append(states, state.StateTransition{ - State: &state.State{ - CacheID: labels.Fingerprint(), - Labels: labels, - State: s, - StateReason: util.GenerateShortUID(), - }, - }) - } - - manager.stateCallback = func(now time.Time) []state.StateTransition { - return states - } - - frame, err := engine.Test(context.Background(), nil, rule, from, to) - - require.NoError(t, err) - require.Len(t, frame.Fields, len(states)+1) // +1 - timestamp - - t.Run("should contain field Time", func(t *testing.T) { - timestampField, _ := frame.FieldByName("Time") - require.NotNil(t, timestampField, "frame does not contain field 'Time'") - require.Equal(t, data.FieldTypeTime, timestampField.Type()) - }) - - fieldByState := make(map[data.Fingerprint]*data.Field, len(states)) - - t.Run("should contain a field per state", func(t *testing.T) { - for _, s := range states { - var f *data.Field - for _, field := range frame.Fields { - if field.Labels.String() == s.Labels.String() { - f = field - break - } - } - require.NotNilf(t, f, "Cannot find a field by state labels") - fieldByState[s.CacheID] = f - } - }) - - t.Run("should be populated with correct values", func(t *testing.T) { - timestampField, _ := frame.FieldByName("Time") - expectedLength := timestampField.Len() - for _, field := range frame.Fields { - require.Equalf(t, expectedLength, field.Len(), "Field %s should have the size %d", field.Name, expectedLength) - } - for i := 0; i < expectedLength; i++ { - expectedTime := from.Add(time.Duration(int64(i)*rule.IntervalSeconds) * time.Second) - require.Equal(t, expectedTime, timestampField.At(i).(time.Time)) - for _, s := range states { - f := fieldByState[s.CacheID] - if s.State.State == eval.NoData { - require.Nil(t, f.At(i)) - } else { - v := f.At(i).(*string) - require.NotNilf(t, v, "Field [%s] value at index %d should not be nil", s.CacheID, i) - require.Equal(t, fmt.Sprintf("%s (%s)", s.State.State, s.StateReason), *v) - } - } - } - }) - }) - t.Run("should not fail if 'to-from' is not times of interval", func(t *testing.T) { from := time.Unix(0, 0) to := from.Add(5 * ruleInterval) @@ -287,84 +211,26 @@ func TestEvaluatorTest(t *testing.T) { return states } - frame, err := engine.Test(context.Background(), nil, rule, from, to) + frame, err := engine.Test(context.Background(), nil, rule, from, to, "") require.NoError(t, err) expectedLen := frame.Rows() for i := 0; i < 100; i++ { jitter := time.Duration(rand.Int63n(ruleInterval.Milliseconds())) * time.Millisecond - frame, err = engine.Test(context.Background(), nil, rule, from, to.Add(jitter)) + frame, err = engine.Test(context.Background(), nil, rule, from, to.Add(jitter), "") require.NoError(t, err) require.Equalf(t, expectedLen, frame.Rows(), "jitter %v caused result to be different that base-line", jitter) } }) - t.Run("should backfill field with nulls if a new dimension created in the middle", func(t *testing.T) { - from := time.Unix(0, 0) - - state1 := state.StateTransition{ - State: generateState("1"), - } - state2 := state.StateTransition{ - State: generateState("2"), - } - state3 := state.StateTransition{ - State: generateState("3"), - } - stateByTime := map[time.Time][]state.StateTransition{ - from: {state1, state2}, - from.Add(1 * ruleInterval): {state1, state2}, - from.Add(2 * ruleInterval): {state1, state2}, - from.Add(3 * ruleInterval): {state1, state2, state3}, - from.Add(4 * ruleInterval): {state1, state2, state3}, - } - to := from.Add(time.Duration(len(stateByTime)) * ruleInterval) - - manager.stateCallback = func(now time.Time) []state.StateTransition { - return stateByTime[now] - } - - frame, err := engine.Test(context.Background(), nil, rule, from, to) - require.NoError(t, err) - - var field3 *data.Field - for _, field := range frame.Fields { - if field.Labels.String() == state3.Labels.String() { - field3 = field - break - } - } - require.NotNilf(t, field3, "Result for state 3 was not found") - require.Equalf(t, len(stateByTime), field3.Len(), "State3 result has unexpected number of values") - - idx := 0 - for curTime, states := range stateByTime { - value := field3.At(idx).(*string) - if len(states) == 2 { - require.Nilf(t, value, "The result should be nil if state3 was not available for time %v", curTime) - } - } - }) - t.Run("should fail", func(t *testing.T) { manager.stateCallback = func(now time.Time) []state.StateTransition { return nil } - t.Run("when interval is not correct", func(t *testing.T) { from := time.Now() - t.Run("when from=to", func(t *testing.T) { - to := from - _, err := engine.Test(context.Background(), nil, rule, from, to) - require.ErrorIs(t, err, ErrInvalidInputData) - }) t.Run("when from > to", func(t *testing.T) { to := from.Add(-ruleInterval) - _, err := engine.Test(context.Background(), nil, rule, from, to) - require.ErrorIs(t, err, ErrInvalidInputData) - }) - t.Run("when to-from < interval", func(t *testing.T) { - to := from.Add(ruleInterval).Add(-time.Millisecond) - _, err := engine.Test(context.Background(), nil, rule, from, to) + _, err := engine.Test(context.Background(), nil, rule, from, to, "") require.ErrorIs(t, err, ErrInvalidInputData) }) }) @@ -376,7 +242,7 @@ func TestEvaluatorTest(t *testing.T) { } from := time.Now() to := from.Add(ruleInterval) - _, err := engine.Test(context.Background(), nil, rule, from, to) + _, err := engine.Test(context.Background(), nil, rule, from, to, "") require.ErrorIs(t, err, expectedError) }) }) @@ -404,10 +270,188 @@ func (f *fakeBacktestingEvaluator) Eval(_ context.Context, from time.Time, inter if err != nil { return err } - err = callback(idx, now, results) + c, err := callback(idx, now, results) if err != nil { return err } + if !c { + break + } } return nil } + +func TestGetNextEvaluationTime(t *testing.T) { + baseInterval := 10 * time.Second + + testCases := []struct { + name string + ruleInterval int64 + currentTimestamp int64 + jitterOffset time.Duration + expectError bool + expectedNext int64 + }{ + { + name: "interval not divisible by base interval", + ruleInterval: 15, + currentTimestamp: 0, + jitterOffset: 0, + expectError: true, + }, + { + name: "no jitter - from tick 0", + ruleInterval: 20, + currentTimestamp: 0, + jitterOffset: 0, + expectedNext: 0, + }, + { + name: "no jitter - from tick 1", + ruleInterval: 20, + currentTimestamp: 10, + jitterOffset: 0, + expectedNext: 20, + }, + { + name: "no jitter - from tick 2", + ruleInterval: 20, + currentTimestamp: 20, + jitterOffset: 0, + expectedNext: 20, + }, + { + name: "with 20s jitter - from tick 0", + ruleInterval: 60, + currentTimestamp: 0, + jitterOffset: 20 * time.Second, + expectedNext: 20, + }, + { + name: "with 20s jitter - from tick 2", + ruleInterval: 60, + currentTimestamp: 20, + jitterOffset: 20 * time.Second, + expectedNext: 20, + }, + { + name: "with 20s jitter - from tick 3", + ruleInterval: 60, + currentTimestamp: 30, + jitterOffset: 20 * time.Second, + expectedNext: 80, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rule := &models.AlertRule{IntervalSeconds: tc.ruleInterval} + currentTime := time.Unix(tc.currentTimestamp, 0) + result, err := getNextEvaluationTime(currentTime, rule, baseInterval, tc.jitterOffset) + + if tc.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), "is not divisible by base interval") + return + } + + require.NoError(t, err) + require.Equal(t, tc.expectedNext, result.Unix()) + }) + } +} + +func TestGetFirstEvaluationTime(t *testing.T) { + baseInterval := 10 * time.Second + + testCases := []struct { + name string + ruleInterval int64 + fromUnix int64 + jitterOffset time.Duration + expectError bool + expectedUnix int64 + }{ + { + name: "interval not divisible by base interval", + ruleInterval: 15, + fromUnix: 0, + jitterOffset: 0, + expectError: true, + }, + { + name: "no jitter - from at tick 0", + ruleInterval: 20, + fromUnix: 0, + jitterOffset: 0, + expectedUnix: 0, + }, + { + name: "no jitter - from at tick 1", + ruleInterval: 20, + fromUnix: 10, + jitterOffset: 0, + expectedUnix: 20, + }, + { + name: "no jitter - from before first tick", + ruleInterval: 20, + fromUnix: 5, + jitterOffset: 0, + expectedUnix: 20, + }, + { + name: "no jitter - from after first aligned tick", + ruleInterval: 20, + fromUnix: 25, + jitterOffset: 0, + expectedUnix: 40, + }, + { + name: "no jitter - from at tick boundary", + ruleInterval: 10, + fromUnix: 10, + jitterOffset: 0, + expectedUnix: 10, + }, + { + name: "with 20s jitter - from epoch", + ruleInterval: 60, + fromUnix: 0, + jitterOffset: 20 * time.Second, + expectedUnix: 20, + }, + { + name: "with 20s jitter - from 70s", + ruleInterval: 60, + fromUnix: 70, + jitterOffset: 20 * time.Second, + expectedUnix: 80, + }, + { + name: "with 50s jitter - from 25s", + ruleInterval: 60, + fromUnix: 25, + jitterOffset: 50 * time.Second, + expectedUnix: 50, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rule := &models.AlertRule{IntervalSeconds: tc.ruleInterval} + from := time.Unix(tc.fromUnix, 0) + result, err := getFirstEvaluationTime(from, rule, baseInterval, tc.jitterOffset) + + if tc.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), "is not divisible by base interval") + return + } + + require.NoError(t, err) + require.Equal(t, tc.expectedUnix, result.Unix()) + require.GreaterOrEqual(t, result.Unix(), from.Unix(), "first eval should be at or after from") + }) + } +} diff --git a/pkg/services/ngalert/backtesting/eval_data.go b/pkg/services/ngalert/backtesting/eval_data.go index 999c0bc6302..13827e6e757 100644 --- a/pkg/services/ngalert/backtesting/eval_data.go +++ b/pkg/services/ngalert/backtesting/eval_data.go @@ -85,10 +85,13 @@ func (d *dataEvaluator) Eval(_ context.Context, from time.Time, interval time.Du EvaluatedAt: now, }) } - err := callback(i, now, result) + cont, err := callback(i, now, result) if err != nil { return err } + if !cont { + break + } } return nil } diff --git a/pkg/services/ngalert/backtesting/eval_data_test.go b/pkg/services/ngalert/backtesting/eval_data_test.go index 864229b777c..3d80fa9337a 100644 --- a/pkg/services/ngalert/backtesting/eval_data_test.go +++ b/pkg/services/ngalert/backtesting/eval_data_test.go @@ -100,11 +100,11 @@ func TestDataEvaluator_Eval(t *testing.T) { resultsCount := int(to.Sub(from).Seconds() / interval.Seconds()) - err = evaluator.Eval(context.Background(), from, time.Second, resultsCount, func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, time.Second, resultsCount, func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) require.NoError(t, err) @@ -164,11 +164,11 @@ func TestDataEvaluator_Eval(t *testing.T) { size := to.Sub(from).Milliseconds() / interval.Milliseconds() r := make([]results, 0, size) - err = evaluator.Eval(context.Background(), from, interval, int(size), func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, interval, int(size), func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) currentRowIdx := 0 @@ -195,11 +195,11 @@ func TestDataEvaluator_Eval(t *testing.T) { size := int(to.Sub(from).Seconds() / interval.Seconds()) r := make([]results, 0, size) - err = evaluator.Eval(context.Background(), from, interval, size, func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, interval, size, func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) currentRowIdx := 0 @@ -230,11 +230,11 @@ func TestDataEvaluator_Eval(t *testing.T) { t.Run("should be noData until the frame interval", func(t *testing.T) { newFrom := from.Add(-10 * time.Second) r := make([]results, 0, int(to.Sub(newFrom).Seconds())) - err = evaluator.Eval(context.Background(), newFrom, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), newFrom, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) rowIdx := 0 @@ -258,11 +258,11 @@ func TestDataEvaluator_Eval(t *testing.T) { t.Run("should be the last value after the frame interval", func(t *testing.T) { newTo := to.Add(10 * time.Second) r := make([]results, 0, int(newTo.Sub(from).Seconds())) - err = evaluator.Eval(context.Background(), from, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) rowIdx := 0 @@ -282,12 +282,21 @@ func TestDataEvaluator_Eval(t *testing.T) { }) t.Run("should stop if callback error", func(t *testing.T) { expectedError := errors.New("error") - err = evaluator.Eval(context.Background(), from, time.Second, 6, func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, time.Second, 6, func(idx int, now time.Time, res eval.Results) (bool, error) { if idx == 5 { - return expectedError + return false, expectedError } - return nil + return true, nil }) require.ErrorIs(t, err, expectedError) }) + t.Run("should stop if callback does not want to continue", func(t *testing.T) { + evaluated := 0 + err = evaluator.Eval(context.Background(), from, time.Second, 6, func(idx int, now time.Time, res eval.Results) (bool, error) { + evaluated++ + return evaluated < 2, nil + }) + require.NoError(t, err) + require.Equal(t, 2, evaluated) + }) } diff --git a/pkg/services/ngalert/backtesting/eval_query.go b/pkg/services/ngalert/backtesting/eval_query.go index f53e3de86cb..07720f4f265 100644 --- a/pkg/services/ngalert/backtesting/eval_query.go +++ b/pkg/services/ngalert/backtesting/eval_query.go @@ -18,10 +18,13 @@ func (d *queryEvaluator) Eval(ctx context.Context, from time.Time, interval time if err != nil { return err } - err = callback(idx, now, results) + cont, err := callback(idx, now, results) if err != nil { return err } + if !cont { + break + } } return nil } diff --git a/pkg/services/ngalert/backtesting/eval_query_test.go b/pkg/services/ngalert/backtesting/eval_query_test.go index e88948971f0..4c9df9d25b1 100644 --- a/pkg/services/ngalert/backtesting/eval_query_test.go +++ b/pkg/services/ngalert/backtesting/eval_query_test.go @@ -31,9 +31,9 @@ func TestQueryEvaluator_Eval(t *testing.T) { intervals := make([]time.Time, times) - err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error { + err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) { intervals[idx] = now - return nil + return true, nil }) require.NoError(t, err) require.Len(t, intervals, times) @@ -49,7 +49,7 @@ func TestQueryEvaluator_Eval(t *testing.T) { } }) - t.Run("should stop evaluation if error", func(t *testing.T) { + t.Run("should stop evaluation", func(t *testing.T) { t.Run("when evaluation fails", func(t *testing.T) { m := &eval_mocks.ConditionEvaluatorMock{} expectedResults := eval.Results{} @@ -62,9 +62,9 @@ func TestQueryEvaluator_Eval(t *testing.T) { intervals := make([]time.Time, 0, times) - err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error { + err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) { intervals = append(intervals, now) - return nil + return true, nil }) require.ErrorIs(t, err, expectedError) require.Len(t, intervals, 3) @@ -81,14 +81,31 @@ func TestQueryEvaluator_Eval(t *testing.T) { intervals := make([]time.Time, 0, times) - err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error { + err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) { if len(intervals) > 3 { - return expectedError + return false, expectedError } intervals = append(intervals, now) - return nil + return true, nil }) require.ErrorIs(t, err, expectedError) }) + + t.Run("when callback does not want to continue", func(t *testing.T) { + m := &eval_mocks.ConditionEvaluatorMock{} + expectedResults := eval.Results{} + m.EXPECT().Evaluate(mock.Anything, mock.Anything).Return(expectedResults, nil) + evaluator := queryEvaluator{ + eval: m, + } + + evaluated := 0 + err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) { + evaluated++ + return evaluated <= 2, nil + }) + require.NoError(t, err, nil) + require.Equal(t, 3, evaluated) + }) }) } diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index f7bb3d9fcfd..7d6e8a1fab5 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -480,6 +480,10 @@ func (alertRule *AlertRule) GetPanelID() int64 { return -1 } +func (alertRule *AlertRule) GetInterval() time.Duration { + return time.Duration(alertRule.IntervalSeconds) * time.Second +} + type LabelOption func(map[string]string) func WithoutInternalLabels() LabelOption { diff --git a/pkg/services/ngalert/models/receivers_diff.go b/pkg/services/ngalert/models/receivers_diff.go index bfe9328542f..681f07601d9 100644 --- a/pkg/services/ngalert/models/receivers_diff.go +++ b/pkg/services/ngalert/models/receivers_diff.go @@ -169,62 +169,9 @@ func HasIntegrationsDifferentProtectedFields(existing, incoming *Integration) [] var result []schema.IntegrationFieldPath settingsDiff := diff.GetSettingsPaths() for _, path := range settingsDiff { - if IsProtectedField(incoming.Config.Type(), path) { + if incoming.Config.IsProtectedField(path) { result = append(result, path) } } return result } - -// IsProtectedField returns true if the field at the given path is existing protected one. -// This includes: -// 1. URL fields marked as secure in the schema (e.g., webhook URLs with credentials) -// 2. URL fields NOT marked as secure but could contain credentials (e.g., API endpoints) -func IsProtectedField(integrationType schema.IntegrationType, path schema.IntegrationFieldPath) bool { - str := strings.ToLower(string(integrationType)) - pathStr := path.String() - - switch str { - case "prometheus-alertmanager": - return pathStr == "url" - case "dingding": - return pathStr == "url" // marked as secure - case "discord": - return pathStr == "url" // marked as secure (webhook URL) - case "googlechat": - return pathStr == "url" // marked as secure - case "jira": - return pathStr == "api_url" - case "kafka": - return pathStr == "kafkaRestProxy" - case "line": - return false - case "mqtt": - return pathStr == "brokerUrl" - case "oncall": - return pathStr == "url" - case "opsgenie": - return pathStr == "apiUrl" - case "pagerduty": - return pathStr == "url" - case "sensugo": - return pathStr == "url" - case "slack": - return pathStr == "url" || pathStr == "endpointUrl" - case "teams": - return pathStr == "url" - case "victorops": - return pathStr == "url" // marked as secure - case "webex": - return pathStr == "api_url" - case "webhook": - return pathStr == "url" || - pathStr == "http_config.oauth2.token_url" || - pathStr == "http_config.oauth2.proxy_config.proxy_url" - case "wecom": - return pathStr == "url" || // marked as secure - pathStr == "endpointUrl" - default: - return false - } -} diff --git a/pkg/services/ngalert/notifier/alertmanager_config.go b/pkg/services/ngalert/notifier/alertmanager_config.go index 7e755903791..ba8d37809b1 100644 --- a/pkg/services/ngalert/notifier/alertmanager_config.go +++ b/pkg/services/ngalert/notifier/alertmanager_config.go @@ -485,6 +485,7 @@ func assignReceiverConfigsUIDs(c []*definitions.PostableApiReceiver) error { type provisioningStore interface { GetProvenance(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]models.Provenance, error) + GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error DeleteProvenance(ctx context.Context, o models.Provisionable, org int64) error } diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go index 13c95e70aa4..0e8ebf647d4 100644 --- a/pkg/services/ngalert/prom/convert.go +++ b/pkg/services/ngalert/prom/convert.go @@ -272,16 +272,16 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom RuleGroup: promGroup.Name, IsPaused: isPaused, Record: record, + } + + if !isRecordingRule { + result.NotificationSettings = p.cfg.NotificationSettings // MissingSeriesEvalsToResolve is set to 1 to match the Prometheus behaviour. // Prometheus resolves alerts as soon as the series disappears. // By setting this value to 1 we ensure that the alert is resolved on the first evaluation // that doesn't have the series. - MissingSeriesEvalsToResolve: util.Pointer[int64](1), - } - - if !isRecordingRule { - result.NotificationSettings = p.cfg.NotificationSettings + result.MissingSeriesEvalsToResolve = util.Pointer[int64](1) } if p.cfg.KeepOriginalRuleDefinition != nil && *p.cfg.KeepOriginalRuleDefinition { diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go index 503cd76dd64..d9542e24c5e 100644 --- a/pkg/services/ngalert/prom/convert_test.go +++ b/pkg/services/ngalert/prom/convert_test.go @@ -358,7 +358,12 @@ func TestPrometheusRulesToGrafana(t *testing.T) { require.Equal(t, models.Duration(evalOffset), grafanaRule.Data[0].RelativeTimeRange.To) require.Equal(t, models.Duration(10*time.Minute+evalOffset), grafanaRule.Data[0].RelativeTimeRange.From) - require.Equal(t, util.Pointer(int64(1)), grafanaRule.MissingSeriesEvalsToResolve) + + if promRule.Record != "" { + require.Nil(t, grafanaRule.MissingSeriesEvalsToResolve) + } else { + require.Equal(t, util.Pointer(int64(1)), grafanaRule.MissingSeriesEvalsToResolve) + } require.Equal(t, models.OkErrState, grafanaRule.ExecErrState) require.Equal(t, models.OK, grafanaRule.NoDataState) diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go index 914a3644984..e6d6b37fc24 100644 --- a/pkg/services/ngalert/provisioning/persist.go +++ b/pkg/services/ngalert/provisioning/persist.go @@ -19,6 +19,7 @@ type alertmanagerConfigStore interface { type ProvisioningStore interface { GetProvenance(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]models.Provenance, error) + GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error DeleteProvenance(ctx context.Context, o models.Provisionable, org int64) error } diff --git a/pkg/services/ngalert/provisioning/provisioning_store_mock.go b/pkg/services/ngalert/provisioning/provisioning_store_mock.go index 31cc77e26a6..bbc115d87c1 100644 --- a/pkg/services/ngalert/provisioning/provisioning_store_mock.go +++ b/pkg/services/ngalert/provisioning/provisioning_store_mock.go @@ -188,6 +188,67 @@ func (_c *MockProvisioningStore_GetProvenances_Call) RunAndReturn(run func(conte return _c } +// GetProvenancesByUIDs provides a mock function with given fields: ctx, org, resourceType, uids +func (_m *MockProvisioningStore) GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error) { + ret := _m.Called(ctx, org, resourceType, uids) + + if len(ret) == 0 { + panic("no return value specified for GetProvenancesByUIDs") + } + + var r0 map[string]models.Provenance + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64, string, []string) (map[string]models.Provenance, error)); ok { + return rf(ctx, org, resourceType, uids) + } + if rf, ok := ret.Get(0).(func(context.Context, int64, string, []string) map[string]models.Provenance); ok { + r0 = rf(ctx, org, resourceType, uids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]models.Provenance) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64, string, []string) error); ok { + r1 = rf(ctx, org, resourceType, uids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockProvisioningStore_GetProvenancesByUIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProvenancesByUIDs' +type MockProvisioningStore_GetProvenancesByUIDs_Call struct { + *mock.Call +} + +// GetProvenancesByUIDs is a helper method to define mock.On call +// - ctx context.Context +// - org int64 +// - resourceType string +// - uids []string +func (_e *MockProvisioningStore_Expecter) GetProvenancesByUIDs(ctx interface{}, org interface{}, resourceType interface{}, uids interface{}) *MockProvisioningStore_GetProvenancesByUIDs_Call { + return &MockProvisioningStore_GetProvenancesByUIDs_Call{Call: _e.mock.On("GetProvenancesByUIDs", ctx, org, resourceType, uids)} +} + +func (_c *MockProvisioningStore_GetProvenancesByUIDs_Call) Run(run func(ctx context.Context, org int64, resourceType string, uids []string)) *MockProvisioningStore_GetProvenancesByUIDs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int64), args[2].(string), args[3].([]string)) + }) + return _c +} + +func (_c *MockProvisioningStore_GetProvenancesByUIDs_Call) Return(_a0 map[string]models.Provenance, _a1 error) *MockProvisioningStore_GetProvenancesByUIDs_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockProvisioningStore_GetProvenancesByUIDs_Call) RunAndReturn(run func(context.Context, int64, string, []string) (map[string]models.Provenance, error)) *MockProvisioningStore_GetProvenancesByUIDs_Call { + _c.Call.Return(run) + return _c +} + // SetProvenance provides a mock function with given fields: ctx, o, org, p func (_m *MockProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error { ret := _m.Called(ctx, o, org, p) diff --git a/pkg/services/ngalert/schedule/jitter.go b/pkg/services/ngalert/schedule/jitter.go index 3d6c839f372..a805ab9b0b3 100644 --- a/pkg/services/ngalert/schedule/jitter.go +++ b/pkg/services/ngalert/schedule/jitter.go @@ -5,6 +5,7 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/services/featuremgmt" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/setting" @@ -13,6 +14,10 @@ import ( // JitterStrategy represents a modifier to alert rule timing that affects how evaluations are distributed. type JitterStrategy int +func (s JitterStrategy) String() string { + return [...]string{"never", "by group", "by rule"}[s] +} + const ( JitterNever JitterStrategy = iota JitterByGroup @@ -57,6 +62,11 @@ func jitterOffsetInTicks(r *ngmodels.AlertRule, baseInterval time.Duration, stra return res } +// JitterOffsetInDuration gives the jitter offset for a rule, in terms of a duration relative to its interval and a base interval. +func JitterOffsetInDuration(r *ngmodels.AlertRule, baseInterval time.Duration, strategy JitterStrategy) time.Duration { + return time.Duration(jitterOffsetInTicks(r, baseInterval, strategy)) * baseInterval +} + func jitterHash(r *ngmodels.AlertRule, strategy JitterStrategy) uint64 { ls := data.Labels{ "name": r.RuleGroup, diff --git a/pkg/services/ngalert/schedule/registry.go b/pkg/services/ngalert/schedule/registry.go index 892e0af8235..6ce09d49169 100644 --- a/pkg/services/ngalert/schedule/registry.go +++ b/pkg/services/ngalert/schedule/registry.go @@ -101,13 +101,13 @@ func (e *Evaluation) Fingerprint() fingerprint { type alertRulesRegistry struct { rules map[models.AlertRuleKey]*models.AlertRule folderTitles map[models.FolderKey]string - mu sync.Mutex + mu sync.RWMutex } // all returns all rules in the registry. func (r *alertRulesRegistry) all() ([]*models.AlertRule, map[models.FolderKey]string) { - r.mu.Lock() - defer r.mu.Unlock() + r.mu.RLock() + defer r.mu.RUnlock() result := make([]*models.AlertRule, 0, len(r.rules)) for _, rule := range r.rules { result = append(result, rule) @@ -116,8 +116,8 @@ func (r *alertRulesRegistry) all() ([]*models.AlertRule, map[models.FolderKey]st } func (r *alertRulesRegistry) get(k models.AlertRuleKey) *models.AlertRule { - r.mu.Lock() - defer r.mu.Unlock() + r.mu.RLock() + defer r.mu.RUnlock() return r.rules[k] } @@ -157,12 +157,14 @@ func (r *alertRulesRegistry) del(k models.AlertRuleKey) (*models.AlertRule, bool } func (r *alertRulesRegistry) isEmpty() bool { - r.mu.Lock() - defer r.mu.Unlock() + r.mu.RLock() + defer r.mu.RUnlock() return len(r.rules) == 0 } func (r *alertRulesRegistry) needsUpdate(keys []models.AlertRuleKeyWithVersion) bool { + r.mu.RLock() + defer r.mu.RUnlock() if len(r.rules) != len(keys) { return true } diff --git a/pkg/services/ngalert/schedule/ticker/ticker.go b/pkg/services/ngalert/schedule/ticker/ticker.go index a52b9c4e559..c24dc13798a 100644 --- a/pkg/services/ngalert/schedule/ticker/ticker.go +++ b/pkg/services/ngalert/schedule/ticker/ticker.go @@ -44,7 +44,11 @@ func New(c clock.Clock, interval time.Duration, metric *Metrics, logger log.Logg } func getStartTick(clk clock.Clock, interval time.Duration) time.Time { - nano := clk.Now().UnixNano() + return GetStartTick(clk.Now(), interval) +} + +func GetStartTick(t time.Time, interval time.Duration) time.Time { + nano := t.UnixNano() return time.Unix(0, nano-(nano%interval.Nanoseconds())) } diff --git a/pkg/services/ngalert/state/historian/core.go b/pkg/services/ngalert/state/historian/core.go index eabb5214a9e..daf415be25a 100644 --- a/pkg/services/ngalert/state/historian/core.go +++ b/pkg/services/ngalert/state/historian/core.go @@ -17,7 +17,7 @@ import ( const StateHistoryWriteTimeout = time.Minute -func shouldRecord(transition state.StateTransition) bool { +func ShouldRecord(transition state.StateTransition) bool { if !transition.Changed() { return false } @@ -35,9 +35,9 @@ func shouldRecord(transition state.StateTransition) bool { } // ShouldRecordAnnotation returns true if an annotation should be created for a given state transition. -// This is stricter than shouldRecord to avoid cluttering panels with state transitions. +// This is stricter than ShouldRecord to avoid cluttering panels with state transitions. func ShouldRecordAnnotation(t state.StateTransition) bool { - if !shouldRecord(t) { + if !ShouldRecord(t) { return false } diff --git a/pkg/services/ngalert/state/historian/core_test.go b/pkg/services/ngalert/state/historian/core_test.go index f798bd26cef..a5f0c55d816 100644 --- a/pkg/services/ngalert/state/historian/core_test.go +++ b/pkg/services/ngalert/state/historian/core_test.go @@ -92,7 +92,7 @@ func TestShouldRecord(t *testing.T) { } t.Run(fmt.Sprintf("%s -> %s should be %v", trans.PreviousFormatted(), trans.Formatted(), !ok), func(t *testing.T) { - require.Equal(t, !ok, shouldRecord(trans)) + require.Equal(t, !ok, ShouldRecord(trans)) }) } } diff --git a/pkg/services/ngalert/state/historian/loki.go b/pkg/services/ngalert/state/historian/loki.go index 76e3e9025bd..8e98d411c1e 100644 --- a/pkg/services/ngalert/state/historian/loki.go +++ b/pkg/services/ngalert/state/historian/loki.go @@ -41,6 +41,69 @@ const ( dfLabels = "labels" ) +// QueryResultBuilder is a builder for a data frame that represents query results from Loki. +// It contains three fields: time (timestamp), line (JSON data), and labels (JSON labels). +type QueryResultBuilder struct { + frame *data.Frame +} + +// NewQueryResultBuilder creates a new QueryResultBuilder with the specified capacity. +// The capacity is used to pre-allocate the underlying slices for better performance. +func NewQueryResultBuilder(capacity int) *QueryResultBuilder { + frame := data.NewFrame("states") + lbls := data.Labels(map[string]string{}) + + // We represent state history as a single merged history, that roughly corresponds to what you get in the Grafana Explore tab when querying Loki directly. + // The format is composed of the following vectors: + // 1. `time` - timestamp - when the transition happened + // 2. `line` - JSON - the full data of the transition + // 3. `labels` - JSON - the labels associated with that state transition + times := make([]time.Time, 0, capacity) + lines := make([]json.RawMessage, 0, capacity) + labels := make([]json.RawMessage, 0, capacity) + + frame.Fields = append(frame.Fields, data.NewField(dfTime, lbls, times)) + frame.Fields = append(frame.Fields, data.NewField(dfLine, lbls, lines)) + frame.Fields = append(frame.Fields, data.NewField(dfLabels, lbls, labels)) + + return &QueryResultBuilder{frame: frame} +} + +func (qr QueryResultBuilder) AddRowRaw(timestamp time.Time, line json.RawMessage, labels json.RawMessage) { + frame := qr.frame + frame.Fields[0].Append(timestamp) + frame.Fields[1].Append(line) + frame.Fields[2].Append(labels) +} + +func (qr QueryResultBuilder) AddRow(timestamp time.Time, line LokiEntry, labels json.RawMessage) error { + lineBytes, err := json.Marshal(line) + if err != nil { + return err + } + qr.AddRowRaw(timestamp, lineBytes, labels) + return nil +} + +// ToFrame converts the QueryResultBuilder back to a data.Frame. +func (qr QueryResultBuilder) ToFrame() *data.Frame { + return qr.frame +} + +func (qr QueryResultBuilder) AddWarn(s string) { + m := qr.frame.Meta + if m == nil { + m = &data.FrameMeta{} + qr.frame.SetMeta(m) + } + m.Notices = append(m.Notices, data.Notice{ + Severity: data.NoticeSeverityWarning, + Text: s, + Link: "", + Inspect: 0, + }) +} + const ( StateHistoryLabelKey = "from" StateHistoryLabelValue = "state-history" @@ -191,20 +254,7 @@ func (h RemoteLokiBackend) merge(res []lokiclient.Stream, folderUIDToFilter []st totalLen += len(arr.Values) } - // Create a new slice to store the merged elements. - frame := data.NewFrame("states") - - // We merge all series into a single linear history. - lbls := data.Labels(map[string]string{}) - - // We represent state history as a single merged history, that roughly corresponds to what you get in the Grafana Explore tab when querying Loki directly. - // The format is composed of the following vectors: - // 1. `time` - timestamp - when the transition happened - // 2. `line` - JSON - the full data of the transition - // 3. `labels` - JSON - the labels associated with that state transition - times := make([]time.Time, 0, totalLen) - lines := make([]json.RawMessage, 0, totalLen) - labels := make([]json.RawMessage, 0, totalLen) + queryResult := NewQueryResultBuilder(totalLen) // Initialize a slice of pointers to the current position in each array. pointers := make([]int, len(res)) @@ -259,17 +309,10 @@ func (h RemoteLokiBackend) merge(res []lokiclient.Stream, folderUIDToFilter []st pointers[minElStreamIdx]++ continue } - times = append(times, time.Unix(0, tsNano)) - labels = append(labels, lblsJson) - lines = append(lines, json.RawMessage(entryBytes)) + queryResult.AddRowRaw(time.Unix(0, tsNano), entryBytes, lblsJson) pointers[minElStreamIdx]++ } - - frame.Fields = append(frame.Fields, data.NewField(dfTime, lbls, times)) - frame.Fields = append(frame.Fields, data.NewField(dfLine, lbls, lines)) - frame.Fields = append(frame.Fields, data.NewField(dfLabels, lbls, labels)) - - return frame, nil + return queryResult.ToFrame(), nil } func StatesToStream(rule history_model.RuleMeta, states []state.StateTransition, externalLabels map[string]string, logger log.Logger) lokiclient.Stream { @@ -282,28 +325,11 @@ func StatesToStream(rule history_model.RuleMeta, states []state.StateTransition, samples := make([]lokiclient.Sample, 0, len(states)) for _, state := range states { - if !shouldRecord(state) { + if !ShouldRecord(state) { continue } - sanitizedLabels := removePrivateLabels(state.Labels) - entry := LokiEntry{ - SchemaVersion: 1, - Previous: state.PreviousFormatted(), - Current: state.Formatted(), - Values: valuesAsDataBlob(state.State), - Condition: rule.Condition, - DashboardUID: rule.DashboardUID, - PanelID: rule.PanelID, - Fingerprint: labelFingerprint(sanitizedLabels), - RuleTitle: rule.Title, - RuleID: rule.ID, - RuleUID: rule.UID, - InstanceLabels: sanitizedLabels, - } - if state.State.State == eval.Error { - entry.Error = state.Error.Error() - } + entry := StateTransitionToLokiEntry(rule, state) jsn, err := json.Marshal(entry) if err != nil { @@ -324,6 +350,28 @@ func StatesToStream(rule history_model.RuleMeta, states []state.StateTransition, } } +func StateTransitionToLokiEntry(rule history_model.RuleMeta, state state.StateTransition) LokiEntry { + sanitizedLabels := removePrivateLabels(state.Labels) + entry := LokiEntry{ + SchemaVersion: 1, + Previous: state.PreviousFormatted(), + Current: state.Formatted(), + Values: valuesAsDataBlob(state.State), + Condition: rule.Condition, + DashboardUID: rule.DashboardUID, + PanelID: rule.PanelID, + Fingerprint: labelFingerprint(sanitizedLabels), + RuleTitle: rule.Title, + RuleID: rule.ID, + RuleUID: rule.UID, + InstanceLabels: sanitizedLabels, + } + if state.State.State == eval.Error && state.Error != nil { + entry.Error = state.Error.Error() + } + return entry +} + func (h *RemoteLokiBackend) recordStreams(ctx context.Context, stream lokiclient.Stream, logger log.Logger) error { if err := h.client.Push(ctx, []lokiclient.Stream{stream}); err != nil { return err diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index 4d6e066f2bf..45c29f9d684 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -232,9 +232,7 @@ func (st *Manager) Get(orgID int64, alertRuleUID string, stateId data.Fingerprin return st.cache.get(orgID, alertRuleUID, stateId) } -// DeleteStateByRuleUID removes the rule instances from cache and instanceStore. A closed channel is returned to be able -// to gracefully handle the clear state step in scheduler in case we do not need to use the historian to save state -// history. +// DeleteStateByRuleUID removes the rule instances from cache and instanceStore. func (st *Manager) DeleteStateByRuleUID(ctx context.Context, ruleKey ngModels.AlertRuleKeyWithGroup, reason string) []StateTransition { logger := st.log.FromContext(ctx) logger.Debug("Resetting state of the rule") @@ -292,10 +290,14 @@ func (st *Manager) ForgetStateByRuleUID(ctx context.Context, ruleKey ngModels.Al // ResetStateByRuleUID removes the rule instances from cache and instanceStore and saves state history. If the state // history has to be saved, rule must not be nil. func (st *Manager) ResetStateByRuleUID(ctx context.Context, rule *ngModels.AlertRule, reason string) []StateTransition { + if rule == nil { + return nil + } + ruleKey := rule.GetKeyWithGroup() transitions := st.DeleteStateByRuleUID(ctx, ruleKey, reason) - if rule == nil || st.historian == nil || len(transitions) == 0 { + if st.historian == nil || len(transitions) == 0 { return transitions } diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index 9dccbe52d2d..b8b366ac619 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -2051,6 +2051,39 @@ func TestIntegrationResetStateByRuleUID(t *testing.T) { } } +func TestResetStateByRuleUID(t *testing.T) { + ctx := context.Background() + + setupManager := func(historian state.Historian) *state.Manager { + cfg := state.ManagerCfg{ + Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), + ExternalURL: nil, + InstanceStore: &state.FakeInstanceStore{}, + Images: &state.NoopImageService{}, + Clock: clock.NewMock(), + Historian: historian, + Tracer: tracing.InitializeTracerForTest(), + Log: log.New("ngalert.state.manager"), + } + + return state.NewManager(cfg, state.NewNoopPersister()) + } + + t.Run("with nil historian", func(t *testing.T) { + manager := setupManager(nil) + + transitions := manager.ResetStateByRuleUID(ctx, nil, "test reason") + require.Empty(t, transitions) + }) + + t.Run("with historian", func(t *testing.T) { + manager := setupManager(&state.FakeHistorian{}) + + transitions := manager.ResetStateByRuleUID(ctx, nil, "test reason") + require.Empty(t, transitions) + }) +} + func setCacheID(s *state.State) *state.State { if s.CacheID != 0 { return s diff --git a/pkg/services/ngalert/store/alert_rule_labels_test.go b/pkg/services/ngalert/store/alert_rule_labels_test.go index 9b72d8f00f9..2006c49fd7a 100644 --- a/pkg/services/ngalert/store/alert_rule_labels_test.go +++ b/pkg/services/ngalert/store/alert_rule_labels_test.go @@ -73,42 +73,42 @@ func TestBuildLabelMatcherJSON(t *testing.T) { name: "MySQL MatchEqual with non-empty value", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"}, - wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?", + wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ?", wantArgs: []any{"team", "alerting"}, }, { name: "MySQL MatchEqual with empty value", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""}, - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ? OR JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL)", + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ? OR JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL)", wantArgs: []any{"team", "", "team"}, }, { name: "MySQL MatchNotEqual", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"}, - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)", + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) != ?)", wantArgs: []any{"team", "team", "alerting"}, }, { name: "PostgreSQL MatchEqual with non-empty value", dialect: migrator.NewPostgresDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"}, - wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) = ?", wantArgs: []any{"team", "alerting"}, }, { name: "PostgreSQL MatchEqual with empty value", dialect: migrator.NewPostgresDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""}, - wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) = ? OR jsonb_extract_path_text(labels::jsonb, ?) IS NULL)", + wantSQL: "(jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) = ? OR jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL)", wantArgs: []any{"team", "", "team"}, }, { name: "PostgreSQL MatchNotEqual", dialect: migrator.NewPostgresDialect(), matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"}, - wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)", + wantSQL: "(jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL OR jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) != ?)", wantArgs: []any{"team", "team", "alerting"}, }, { diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 29b82f943ba..f7497c7b05a 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -2462,6 +2462,12 @@ func TestIntegration_ListAlertRules(t *testing.T) { ruleNonempty := createRule(t, store, ruleGen.With( ruleGen.WithLabels(map[string]string{"empty": "nonempty"}), ruleGen.WithTitle("rule_nonempty"))) + // include a rule with no labels at all, + // to ensure we handle that case correctly. + // JSON functions need to be able to handle null and empty string values. + ruleNoLabels := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{}), + ruleGen.WithTitle("rule_no_labels"))) tc := []struct { name string @@ -2487,7 +2493,7 @@ func TestIntegration_ListAlertRules(t *testing.T) { labelMatchers: labels.Matchers{ func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchNotEqual, "team", "alerting"); return m }(), }, - expectedRules: []*models.AlertRule{ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty}, + expectedRules: []*models.AlertRule{ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty, ruleNoLabels}, }, { name: "special characters in labels are handled correctly", @@ -2536,7 +2542,7 @@ func TestIntegration_ListAlertRules(t *testing.T) { labelMatchers: labels.Matchers{ func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "empty", ""); return m }(), }, - expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty}, + expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNoLabels}, }, { name: "inequality matcher on non-existent label matches all rules", @@ -2546,7 +2552,7 @@ func TestIntegration_ListAlertRules(t *testing.T) { return m }(), }, - expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty}, + expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty, ruleNoLabels}, }, } diff --git a/pkg/services/ngalert/store/json.go b/pkg/services/ngalert/store/json.go index 7b634246951..e0c71975a76 100644 --- a/pkg/services/ngalert/store/json.go +++ b/pkg/services/ngalert/store/json.go @@ -13,9 +13,9 @@ import ( func jsonEquals(dialect migrator.Dialect, column, key, value string) (string, []any) { switch dialect.DriverName() { case migrator.MySQL: - return fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?))) = ?", column), []any{key, value} + return fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?))) = ?", column), []any{key, value} case migrator.Postgres: - return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) = ?", column), []any{key, value} + return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) = ?", column), []any{key, value} default: return "", nil } @@ -25,9 +25,9 @@ func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string, var jx string switch dialect.DriverName() { case migrator.MySQL: - jx = fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?)))", column) + jx = fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)))", column) case migrator.Postgres: - jx = fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?)", column) + jx = fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?)", column) default: return "", nil } @@ -37,9 +37,9 @@ func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string, func jsonKeyMissing(dialect migrator.Dialect, column, key string) (string, []any) { switch dialect.DriverName() { case migrator.MySQL: - return fmt.Sprintf("JSON_EXTRACT(%s, CONCAT('$.', ?)) IS NULL", column), []any{key} + return fmt.Sprintf("JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)) IS NULL", column), []any{key} case migrator.Postgres: - return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) IS NULL", column), []any{key} + return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) IS NULL", column), []any{key} default: return "", nil } diff --git a/pkg/services/ngalert/store/json_test.go b/pkg/services/ngalert/store/json_test.go index 89f85a027a6..d09d3741c4b 100644 --- a/pkg/services/ngalert/store/json_test.go +++ b/pkg/services/ngalert/store/json_test.go @@ -23,7 +23,7 @@ func TestJsonEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?", + wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ?", wantArgs: []any{"team", "alerting"}, }, { @@ -32,7 +32,7 @@ func TestJsonEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) = ?", wantArgs: []any{"team", "alerting"}, }, } @@ -62,7 +62,7 @@ func TestJsonNotEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)", + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) != ?)", wantArgs: []any{"team", "team", "alerting"}, }, { @@ -71,7 +71,7 @@ func TestJsonNotEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)", + wantSQL: "(jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL OR jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) != ?)", wantArgs: []any{"team", "team", "alerting"}, }, } @@ -99,7 +99,7 @@ func TestJsonKeyMissing(t *testing.T) { dialect: migrator.NewMysqlDialect(), column: "labels", key: "team", - wantSQL: "JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL", + wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL", wantArgs: []any{"team"}, }, { @@ -107,7 +107,7 @@ func TestJsonKeyMissing(t *testing.T) { dialect: migrator.NewPostgresDialect(), column: "labels", key: "team", - wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) IS NULL", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL", wantArgs: []any{"team"}, }, } diff --git a/pkg/services/ngalert/store/provisioning_store.go b/pkg/services/ngalert/store/provisioning_store.go index 27f03143c98..df5d7dc80c6 100644 --- a/pkg/services/ngalert/store/provisioning_store.go +++ b/pkg/services/ngalert/store/provisioning_store.go @@ -62,6 +62,30 @@ func (st DBstore) GetProvenances(ctx context.Context, org int64, resourceType st return resultMap, err } +// GetProvenancesByUIDs gets the provenance status for specific UIDs. +func (st DBstore) GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error) { + if len(uids) == 0 { + return map[string]models.Provenance{}, nil + } + + result := make(map[string]models.Provenance, len(uids)) + err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { + rawData, err := sess.Table(provenanceRecord{}). + Where("record_type = ? AND org_id = ?", resourceType, org). + In("record_key", uids). + Cols("record_key", "provenance"). + QueryString() + if err != nil { + return fmt.Errorf("failed to query for existing provenance status: %w", err) + } + for _, data := range rawData { + result[data["record_key"]] = models.Provenance(data["provenance"]) + } + return nil + }) + return result, err +} + // SetProvenance changes the provenance status for a provisionable object. func (st DBstore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error { recordType := o.ResourceType() diff --git a/pkg/services/ngalert/store/provisioning_store_test.go b/pkg/services/ngalert/store/provisioning_store_test.go index b6f8b8fe5cd..359d594da0a 100644 --- a/pkg/services/ngalert/store/provisioning_store_test.go +++ b/pkg/services/ngalert/store/provisioning_store_test.go @@ -133,6 +133,55 @@ func TestIntegrationProvisioningStore(t *testing.T) { require.Equal(t, models.ProvenanceAPI, p[rule2.UID]) }) + t.Run("Store should return provenances by UIDs", func(t *testing.T) { + const orgID = 124 + rule1 := models.AlertRule{UID: "uid-1", OrgID: orgID} + rule2 := models.AlertRule{UID: "uid-2", OrgID: orgID} + rule3 := models.AlertRule{UID: "uid-3", OrgID: orgID} + + err := store.SetProvenance(context.Background(), &rule1, orgID, models.ProvenanceFile) + require.NoError(t, err) + err = store.SetProvenance(context.Background(), &rule2, orgID, models.ProvenanceAPI) + require.NoError(t, err) + err = store.SetProvenance(context.Background(), &rule3, orgID, models.ProvenanceFile) + require.NoError(t, err) + + // Fetch only rule1 and rule2 + p, err := store.GetProvenancesByUIDs(context.Background(), orgID, rule1.ResourceType(), []string{rule1.UID, rule2.UID}) + require.NoError(t, err) + require.Len(t, p, 2) + require.Equal(t, models.ProvenanceFile, p[rule1.UID]) + require.Equal(t, models.ProvenanceAPI, p[rule2.UID]) + _, exists := p[rule3.UID] + require.False(t, exists) + }) + + t.Run("GetProvenancesByUIDs returns empty map for empty UIDs", func(t *testing.T) { + p, err := store.GetProvenancesByUIDs(context.Background(), 1, "alertRule", []string{}) + require.NoError(t, err) + require.Empty(t, p) + }) + + t.Run("GetProvenancesByUIDs respects org ID", func(t *testing.T) { + const orgID1 = 125 + const orgID2 = 126 + rule := models.AlertRule{UID: "cross-org-uid"} + + err := store.SetProvenance(context.Background(), &rule, orgID1, models.ProvenanceFile) + require.NoError(t, err) + + // Should not find in different org + p, err := store.GetProvenancesByUIDs(context.Background(), orgID2, rule.ResourceType(), []string{rule.UID}) + require.NoError(t, err) + require.Empty(t, p) + + // Should find in correct org + p, err = store.GetProvenancesByUIDs(context.Background(), orgID1, rule.ResourceType(), []string{rule.UID}) + require.NoError(t, err) + require.Len(t, p, 1) + require.Equal(t, models.ProvenanceFile, p[rule.UID]) + }) + t.Run("Store should delete provenance correctly", func(t *testing.T) { const orgID = 1234 ruleOrg := models.AlertRule{ diff --git a/pkg/services/ngalert/tests/fakes/provisioning.go b/pkg/services/ngalert/tests/fakes/provisioning.go index 43de0a6dc68..fce2586f120 100644 --- a/pkg/services/ngalert/tests/fakes/provisioning.go +++ b/pkg/services/ngalert/tests/fakes/provisioning.go @@ -8,12 +8,13 @@ import ( ) type FakeProvisioningStore struct { - Calls []Call - Records map[int64]map[string]models.Provenance - GetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) - GetProvenancesFunc func(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) - SetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error - DeleteProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) error + Calls []Call + Records map[int64]map[string]models.Provenance + GetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) + GetProvenancesFunc func(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) + GetProvenancesByUIDsFunc func(ctx context.Context, orgID int64, resourceType string, uids []string) (map[string]models.Provenance, error) + SetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error + DeleteProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) error } func NewFakeProvisioningStore() *FakeProvisioningStore { @@ -51,6 +52,23 @@ func (f *FakeProvisioningStore) GetProvenances(ctx context.Context, orgID int64, return results, nil } +func (f *FakeProvisioningStore) GetProvenancesByUIDs(ctx context.Context, orgID int64, resourceType string, uids []string) (map[string]models.Provenance, error) { + f.Calls = append(f.Calls, Call{MethodName: "GetProvenancesByUIDs", Arguments: []any{ctx, orgID, resourceType, uids}}) + if f.GetProvenancesByUIDsFunc != nil { + return f.GetProvenancesByUIDsFunc(ctx, orgID, resourceType, uids) + } + results := make(map[string]models.Provenance) + if val, ok := f.Records[orgID]; ok { + for _, uid := range uids { + key := uid + resourceType + if prov, ok := val[key]; ok { + results[uid] = prov + } + } + } + return results, nil +} + func (f *FakeProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error { f.Calls = append(f.Calls, Call{MethodName: "SetProvenance", Arguments: []any{ctx, o, org, p}}) if f.SetProvenanceFunc != nil { diff --git a/pkg/services/oauthtoken/oauth_token.go b/pkg/services/oauthtoken/oauth_token.go index 0efe5e553f3..6d320251ccc 100644 --- a/pkg/services/oauthtoken/oauth_token.go +++ b/pkg/services/oauthtoken/oauth_token.go @@ -660,6 +660,10 @@ func (o *Service) getExternalSession(ctx context.Context, usr identity.Requester return externalSessions[0], nil } + if sessionToken == nil { + return nil, auth.ErrExternalSessionTokenNotFound + } + // For regular users, we use the session token ID to fetch the external session return o.sessionService.GetExternalSession(ctx, sessionToken.ExternalSessionId) } diff --git a/pkg/services/pluginsintegration/dashboards/filestore.go b/pkg/services/pluginsintegration/dashboards/filestore.go index e854e6e1340..9e1545ed051 100644 --- a/pkg/services/pluginsintegration/dashboards/filestore.go +++ b/pkg/services/pluginsintegration/dashboards/filestore.go @@ -7,7 +7,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" - "github.com/grafana/grafana/pkg/util" ) var _ FileStore = (*FileStoreManager)(nil) @@ -87,7 +86,7 @@ func (m *FileStoreManager) GetPluginDashboardFileContents(ctx context.Context, a return nil, errors.New("plugin dashboard file not found") } - cleanPath, err := util.CleanRelativePath(includedFile.Path) + cleanPath, err := plugins.CleanRelativePath(includedFile.Path) if err != nil { // CleanRelativePath should clean and make the path relative so this is not expected to fail return nil, err diff --git a/pkg/services/pluginsintegration/pipeline/steps_test.go b/pkg/services/pluginsintegration/pipeline/steps_test.go index 009d489d2ba..4c9cf07e577 100644 --- a/pkg/services/pluginsintegration/pipeline/steps_test.go +++ b/pkg/services/pluginsintegration/pipeline/steps_test.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/manager/registry" - "github.com/grafana/grafana/pkg/setting" ) func TestSkipPlugins(t *testing.T) { @@ -68,7 +67,7 @@ func TestAsExternal(t *testing.T) { t.Run("should skip a core plugin", func(t *testing.T) { cfg := &config.PluginManagementCfg{ - PluginSettings: setting.PluginSettings{ + PluginSettings: config.PluginSettings{ "plugin1": map[string]string{ "as_external": "true", }, diff --git a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go index 28f9d015fff..192717c34ff 100644 --- a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go +++ b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go @@ -17,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" - "github.com/grafana/grafana/pkg/setting" ) func TestService_Calculate(t *testing.T) { @@ -31,7 +30,7 @@ func TestService_Calculate(t *testing.T) { tcs := []struct { name string - pluginSettings setting.PluginSettings + pluginSettings config.PluginSettings plugin pluginstore.Plugin expected plugins.LoadingStrategy }{ @@ -81,7 +80,7 @@ func TestService_Calculate(t *testing.T) { }, { name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is configured as CDN enabled and plugin is not angular", - pluginSettings: setting.PluginSettings{ + pluginSettings: config.PluginSettings{ "parent-datasource": { "cdn": "true", }, @@ -94,7 +93,7 @@ func TestService_Calculate(t *testing.T) { }, { name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is configured as CDN enabled and plugin is angular", - pluginSettings: setting.PluginSettings{ + pluginSettings: config.PluginSettings{ "parent-datasource": { "cdn": "true", }, @@ -107,7 +106,7 @@ func TestService_Calculate(t *testing.T) { }, { name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is not configured as CDN enabled and plugin is angular", - pluginSettings: setting.PluginSettings{}, + pluginSettings: config.PluginSettings{}, plugin: newPlugin(pluginID, withAngular(true), withFS(plugins.NewFakeFS()), func(p pluginstore.Plugin) pluginstore.Plugin { p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"} return p @@ -375,9 +374,9 @@ func TestService_ModuleHash(t *testing.T) { } t.Run(tc.name, func(t *testing.T) { - var pluginSettings setting.PluginSettings + var pluginSettings config.PluginSettings if tc.cdn { - pluginSettings = setting.PluginSettings{ + pluginSettings = config.PluginSettings{ pluginID: { "cdn": "true", }, @@ -412,7 +411,7 @@ func TestService_ModuleHash(t *testing.T) { func TestService_ModuleHash_Cache(t *testing.T) { pCfg := &config.PluginManagementCfg{ - PluginSettings: setting.PluginSettings{}, + PluginSettings: config.PluginSettings{}, Features: config.Features{SriChecksEnabled: true}, } svc := ProvideService( @@ -448,7 +447,7 @@ func TestService_ModuleHash_Cache(t *testing.T) { pCfg = &config.PluginManagementCfg{ PluginsCDNURLTemplate: "https://cdn.grafana.com", - PluginSettings: setting.PluginSettings{ + PluginSettings: config.PluginSettings{ pluginID: { "cdn": "true", }, @@ -577,14 +576,14 @@ func withClass(class plugins.Class) func(p pluginstore.Plugin) pluginstore.Plugi } } -func newCfg(ps setting.PluginSettings) *config.PluginManagementCfg { +func newCfg(ps config.PluginSettings) *config.PluginManagementCfg { return &config.PluginManagementCfg{ PluginSettings: ps, } } -func newPluginSettings(pluginID string, kv map[string]string) setting.PluginSettings { - return setting.PluginSettings{ +func newPluginSettings(pluginID string, kv map[string]string) config.PluginSettings { + return config.PluginSettings{ pluginID: kv, } } diff --git a/pkg/services/pluginsintegration/pluginconfig/config.go b/pkg/services/pluginsintegration/pluginconfig/config.go index 3207c2b3bd4..288f204cd12 100644 --- a/pkg/services/pluginsintegration/pluginconfig/config.go +++ b/pkg/services/pluginsintegration/pluginconfig/config.go @@ -48,7 +48,7 @@ type PluginInstanceCfg struct { Tracing config.Tracing - PluginSettings setting.PluginSettings + PluginSettings config.PluginSettings AWSAllowedAuthProviders []string AWSAssumeRoleEnabled bool @@ -127,8 +127,8 @@ func ProvidePluginInstanceConfig(cfg *setting.Cfg, settingProvider setting.Provi }, nil } -func extractPluginSettings(settingProvider setting.Provider) setting.PluginSettings { - ps := setting.PluginSettings{} +func extractPluginSettings(settingProvider setting.Provider) config.PluginSettings { + ps := config.PluginSettings{} for sectionName, sectionCopy := range settingProvider.Current() { if !strings.HasPrefix(sectionName, "plugin.") { continue diff --git a/pkg/services/pluginsintegration/pluginexternal/check_test.go b/pkg/services/pluginsintegration/pluginexternal/check_test.go index 68cfb68dcfb..9d3316b51d0 100644 --- a/pkg/services/pluginsintegration/pluginexternal/check_test.go +++ b/pkg/services/pluginsintegration/pluginexternal/check_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" @@ -12,7 +13,7 @@ import ( func TestService_validateExternal(t *testing.T) { cfg := setting.NewCfg() - cfg.PluginSettings = setting.PluginSettings{ + cfg.PluginSettings = config.PluginSettings{ "grafana-testdata-datasource": map[string]string{ "as_external": "true", }, diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index 9c0cf4d10c6..387d9884f9a 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -55,6 +55,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsources" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsso" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" @@ -154,8 +155,8 @@ var WireExtensionSet = wire.NewSet( wire.Bind(new(managedplugins.Manager), new(*managedplugins.Noop)), provisionedplugins.NewNoop, wire.Bind(new(provisionedplugins.Manager), new(*provisionedplugins.Noop)), - sources.ProvideService, - wire.Bind(new(sources.Registry), new(*sources.Service)), + pluginsources.ProvideService, + wire.Bind(new(sources.Registry), new(*pluginsources.Service)), checkregistry.ProvideService, wire.Bind(new(checkregistry.CheckService), new(*checkregistry.Service)), pluginassets2.NewLocalProvider, diff --git a/pkg/plugins/manager/sources/sources.go b/pkg/services/pluginsintegration/pluginsources/pluginsources.go similarity index 78% rename from pkg/plugins/manager/sources/sources.go rename to pkg/services/pluginsintegration/pluginsources/pluginsources.go index addeede1dd7..1d47d0cd1aa 100644 --- a/pkg/plugins/manager/sources/sources.go +++ b/pkg/services/pluginsintegration/pluginsources/pluginsources.go @@ -1,4 +1,4 @@ -package sources +package pluginsources import ( "context" @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/setting" ) @@ -27,7 +28,7 @@ func ProvideService(cfg *setting.Cfg, pCcfg *config.PluginManagementCfg) *Servic func (s *Service) List(_ context.Context) []plugins.PluginSource { r := []plugins.PluginSource{ - NewLocalSource( + sources.NewLocalSource( plugins.ClassCore, s.corePluginPaths(), ), @@ -38,7 +39,7 @@ func (s *Service) List(_ context.Context) []plugins.PluginSource { } func (s *Service) externalPluginSources() []plugins.PluginSource { - localSrcs, err := DirAsLocalSources(s.cfg, s.cfg.PluginsPath, plugins.ClassExternal) + localSrcs, err := sources.DirAsLocalSources(s.cfg, s.cfg.PluginsPath, plugins.ClassExternal) if err != nil { s.log.Error("Failed to load external plugins", "error", err) return []plugins.PluginSource{} @@ -53,20 +54,20 @@ func (s *Service) externalPluginSources() []plugins.PluginSource { } func (s *Service) pluginSettingSources() []plugins.PluginSource { - sources := make([]plugins.PluginSource, 0, len(s.cfg.PluginSettings)) + srcs := make([]plugins.PluginSource, 0, len(s.cfg.PluginSettings)) for _, ps := range s.cfg.PluginSettings { path, exists := ps["path"] if !exists || path == "" { continue } if s.cfg.DevMode { - sources = append(sources, NewUnsafeLocalSource(plugins.ClassExternal, []string{path})) + srcs = append(srcs, sources.NewUnsafeLocalSource(plugins.ClassExternal, []string{path})) } else { - sources = append(sources, NewLocalSource(plugins.ClassExternal, []string{path})) + srcs = append(srcs, sources.NewLocalSource(plugins.ClassExternal, []string{path})) } } - return sources + return srcs } // corePluginPaths provides a list of the Core plugin file system paths diff --git a/pkg/plugins/manager/sources/sources_test.go b/pkg/services/pluginsintegration/pluginsources/pluginsources_test.go similarity index 83% rename from pkg/plugins/manager/sources/sources_test.go rename to pkg/services/pluginsintegration/pluginsources/pluginsources_test.go index f56fbffa52b..ba9e77bbfa2 100644 --- a/pkg/plugins/manager/sources/sources_test.go +++ b/pkg/services/pluginsintegration/pluginsources/pluginsources_test.go @@ -1,4 +1,4 @@ -package sources +package pluginsources import ( "context" @@ -9,13 +9,13 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/setting" ) func TestSources_List(t *testing.T) { t.Run("Plugin sources are populated by default and listed in specific order", func(t *testing.T) { - testdata, err := filepath.Abs("../testdata") - require.NoError(t, err) + testdata := testDataDir(t) cfg := &setting.Cfg{ StaticRootPath: testdata, @@ -23,7 +23,7 @@ func TestSources_List(t *testing.T) { pCfg := &config.PluginManagementCfg{ PluginsPath: filepath.Join(testdata, "pluginRootWithDist"), - PluginSettings: setting.PluginSettings{ + PluginSettings: config.PluginSettings{ "foo": map[string]string{ "path": filepath.Join(testdata, "test-app"), }, @@ -41,7 +41,7 @@ func TestSources_List(t *testing.T) { require.Len(t, srcs, 5) require.Equal(t, srcs[0].PluginClass(ctx), plugins.ClassCore) - if localSrc, ok := srcs[0].(*LocalSource); ok { + if localSrc, ok := srcs[0].(*sources.LocalSource); ok { require.Equal(t, localSrc.Paths(), []string{ filepath.Join(testdata, "app", "plugins", "datasource"), filepath.Join(testdata, "app", "plugins", "panel"), @@ -56,7 +56,7 @@ func TestSources_List(t *testing.T) { require.Equal(t, "", sig.SigningOrg) require.Equal(t, srcs[1].PluginClass(ctx), plugins.ClassExternal) - if localSrc, ok := srcs[1].(*LocalSource); ok { + if localSrc, ok := srcs[1].(*sources.LocalSource); ok { require.Equal(t, localSrc.Paths(), []string{ filepath.Join(testdata, "pluginRootWithDist", "datasource"), }) @@ -68,7 +68,7 @@ func TestSources_List(t *testing.T) { require.Equal(t, plugins.Signature{}, sig) require.Equal(t, srcs[2].PluginClass(ctx), plugins.ClassExternal) - if localSrc, ok := srcs[2].(*LocalSource); ok { + if localSrc, ok := srcs[2].(*sources.LocalSource); ok { require.Equal(t, localSrc.Paths(), []string{ filepath.Join(testdata, "pluginRootWithDist", "dist"), }) @@ -80,7 +80,7 @@ func TestSources_List(t *testing.T) { require.Equal(t, plugins.Signature{}, sig) require.Equal(t, srcs[3].PluginClass(ctx), plugins.ClassExternal) - if localSrc, ok := srcs[3].(*LocalSource); ok { + if localSrc, ok := srcs[3].(*sources.LocalSource); ok { require.Equal(t, localSrc.Paths(), []string{ filepath.Join(testdata, "pluginRootWithDist", "panel"), }) @@ -93,9 +93,7 @@ func TestSources_List(t *testing.T) { }) t.Run("Plugin sources are populated with symbolic links", func(t *testing.T) { - testdata, err := filepath.Abs("../testdata") - require.NoError(t, err) - + testdata := testDataDir(t) cfg := &setting.Cfg{ StaticRootPath: testdata, } @@ -113,7 +111,7 @@ func TestSources_List(t *testing.T) { if _, exists := uris[class]; !exists { uris[class] = map[string]struct{}{} } - if localSrc, ok := src.(*LocalSource); ok { + if localSrc, ok := src.(*sources.LocalSource); ok { for _, path := range localSrc.Paths() { uris[class][path] = struct{}{} } @@ -130,3 +128,12 @@ func TestSources_List(t *testing.T) { }, "should include external symlinked plugin") }) } + +func testDataDir(t *testing.T) string { + dir, err := filepath.Abs("../../../plugins/manager/testdata") + if err != nil { + t.Errorf("could not construct absolute path of current dir") + return "" + } + return dir +} diff --git a/pkg/services/pluginsintegration/plugintest/plugins_test.go b/pkg/services/pluginsintegration/plugintest/plugins_test.go index df4a0fd7b75..de1afb67710 100644 --- a/pkg/services/pluginsintegration/plugintest/plugins_test.go +++ b/pkg/services/pluginsintegration/plugintest/plugins_test.go @@ -163,7 +163,7 @@ func TestIntegrationPluginManager(t *testing.T) { td := testdatasource.ProvideService() pg := postgres.ProvideService() my := mysql.ProvideService() - ms := mssql.ProvideService(cfg) + ms := mssql.ProvideService() db := db.InitTestDB(t, sqlstore.InitTestDBOpt{Cfg: cfg}) sv2 := searchV2.ProvideService(cfg, db, nil, nil, tracer, features, nil, nil, nil) graf := grafanads.ProvideService(sv2, nil, features) diff --git a/pkg/services/pluginsintegration/test_helper.go b/pkg/services/pluginsintegration/test_helper.go index d89f9e393ba..018305a71ad 100644 --- a/pkg/services/pluginsintegration/test_helper.go +++ b/pkg/services/pluginsintegration/test_helper.go @@ -24,12 +24,12 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" - "github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/pluginassets" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsources" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" "github.com/grafana/grafana/pkg/setting" @@ -64,7 +64,7 @@ func CreateIntegrationTestCtx(t *testing.T, cfg *setting.Cfg, coreRegistry *core Terminator: term, }) - ps, err := pluginstore.NewPluginStoreForTest(reg, l, sources.ProvideService(cfg, pCfg)) + ps, err := pluginstore.NewPluginStoreForTest(reg, l, pluginsources.ProvideService(cfg, pCfg)) require.NoError(t, err) return &IntegrationTestCtx{ diff --git a/pkg/services/provisioning/alerting/config_reader_test.go b/pkg/services/provisioning/alerting/config_reader_test.go index c13780633c3..9cb71b4950f 100644 --- a/pkg/services/provisioning/alerting/config_reader_test.go +++ b/pkg/services/provisioning/alerting/config_reader_test.go @@ -19,6 +19,7 @@ const ( testFileDasboardTypoSupport = "./testdata/alert_rules/dasboard-typo-support" testFileMultipleRules = "./testdata/alert_rules/multiple-rules" testFileMultipleFiles = "./testdata/alert_rules/multiple-files" + testFileRecordingRules = "./testdata/alert_rules/recording-rules" testFileCorrectProperties_cp = "./testdata/contact_points/correct-properties" testFileCorrectPropertiesWithOrg_cp = "./testdata/contact_points/correct-properties-with-org" testFileEmptyUID = "./testdata/contact_points/empty-uid" @@ -188,4 +189,43 @@ func TestConfigReader(t *testing.T) { } }) }) + + t.Run("recording rules should parse correctly", func(t *testing.T) { + ruleFiles, err := configReader.readConfig(ctx, testFileRecordingRules) + require.NoError(t, err) + require.Len(t, ruleFiles, 2) + + findRule := func(title string) *AlertingFile { + for _, rf := range ruleFiles { + if rf.Groups[0].Title == title { + return rf + } + } + return nil + } + + ruleWithTarget := findRule("recording_rules_group") + require.NotNil(t, ruleWithTarget) + + require.Len(t, ruleWithTarget.Groups, 1) + require.Len(t, ruleWithTarget.Groups[0].Rules, 1) + + ruleWith := ruleWithTarget.Groups[0].Rules[0] + require.NotNil(t, ruleWith.Record) + require.Equal(t, "my_recorded_metric", ruleWith.Record.Metric) + require.Equal(t, "A", ruleWith.Record.From) + require.Equal(t, "mimir-uid", ruleWith.Record.TargetDatasourceUID) + + ruleWithoutTarget := findRule("recording_rules_group_no_target") + require.NotNil(t, ruleWithoutTarget) + + require.Len(t, ruleWithoutTarget.Groups, 1) + require.Len(t, ruleWithoutTarget.Groups[0].Rules, 1) + + ruleWithout := ruleWithoutTarget.Groups[0].Rules[0] + require.NotNil(t, ruleWithout.Record) + require.Equal(t, "http_requests_rate", ruleWithout.Record.Metric) + require.Equal(t, "A", ruleWithout.Record.From) + require.Equal(t, "", ruleWithout.Record.TargetDatasourceUID) + }) } diff --git a/pkg/services/provisioning/alerting/rules_types.go b/pkg/services/provisioning/alerting/rules_types.go index bac091ababa..46dbbbd6342 100644 --- a/pkg/services/provisioning/alerting/rules_types.go +++ b/pkg/services/provisioning/alerting/rules_types.go @@ -303,13 +303,15 @@ func (nsV1 *NotificationSettingsV1) mapToModel() (models.NotificationSettings, e } type RecordV1 struct { - Metric values.StringValue `json:"metric" yaml:"metric"` - From values.StringValue `json:"from" yaml:"from"` + Metric values.StringValue `json:"metric" yaml:"metric"` + From values.StringValue `json:"from" yaml:"from"` + TargetDatasourceUID values.StringValue `json:"targetDatasourceUid" yaml:"targetDatasourceUid"` } func (record *RecordV1) mapToModel() (models.Record, error) { return models.Record{ - Metric: record.Metric.Value(), - From: record.From.Value(), + Metric: record.Metric.Value(), + From: record.From.Value(), + TargetDatasourceUID: record.TargetDatasourceUID.Value(), }, nil } diff --git a/pkg/services/provisioning/alerting/rules_types_test.go b/pkg/services/provisioning/alerting/rules_types_test.go index c2cff56e419..51db78097ad 100644 --- a/pkg/services/provisioning/alerting/rules_types_test.go +++ b/pkg/services/provisioning/alerting/rules_types_test.go @@ -208,6 +208,15 @@ func TestRecordingRules(t *testing.T) { _, err := rule.mapToModel(1) require.NoError(t, err) }) + + t.Run("a valid rule with empty targetDatasourceUid should not error", func(t *testing.T) { + rule := validRecordingRuleV1(t) + rule.Record.TargetDatasourceUID = stringToStringValue("") + model, err := rule.mapToModel(1) + require.NoError(t, err) + require.NotNil(t, model.Record) + require.Equal(t, "", model.Record.TargetDatasourceUID) + }) } func TestNotificationsSettingsV1MapToModel(t *testing.T) { @@ -307,80 +316,43 @@ func TestNotificationsSettingsV1MapToModel(t *testing.T) { func validRuleGroupV1(t *testing.T) AlertRuleGroupV1 { t.Helper() - var ( - orgID values.Int64Value - name values.StringValue - folder values.StringValue - interval values.StringValue - ) + + var orgID values.Int64Value err := yaml.Unmarshal([]byte("1"), &orgID) require.NoError(t, err) - err = yaml.Unmarshal([]byte("Test"), &name) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("Test"), &folder) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("10s"), &interval) - require.NoError(t, err) + return AlertRuleGroupV1{ OrgID: orgID, - Name: name, - Folder: folder, - Interval: interval, + Name: stringToStringValue("Test"), + Folder: stringToStringValue("Test"), + Interval: stringToStringValue("10s"), Rules: []AlertRuleV1{}, } } func validRuleV1(t *testing.T) AlertRuleV1 { t.Helper() - var ( - title values.StringValue - uid values.StringValue - forDuration values.StringValue - condition values.StringValue - ) - err := yaml.Unmarshal([]byte("test"), &title) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("test_uid"), &uid) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("10s"), &forDuration) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("A"), &condition) - require.NoError(t, err) + return AlertRuleV1{ - Title: title, - UID: uid, - For: forDuration, - Condition: condition, + Title: stringToStringValue("test"), + UID: stringToStringValue("test_uid"), + For: stringToStringValue("10s"), + Condition: stringToStringValue("A"), Data: []QueryV1{{}}, } } func validRecordingRuleV1(t *testing.T) AlertRuleV1 { t.Helper() - var ( - title values.StringValue - uid values.StringValue - forDuration values.StringValue - metric values.StringValue - from values.StringValue - ) - err := yaml.Unmarshal([]byte("test"), &title) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("test_uid"), &uid) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("10s"), &forDuration) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("test_metric"), &metric) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("A"), &from) - require.NoError(t, err) + return AlertRuleV1{ - Title: title, - UID: uid, - For: forDuration, + Title: stringToStringValue("test"), + UID: stringToStringValue("test_uid"), + For: stringToStringValue("10s"), Record: &RecordV1{ - Metric: metric, - From: from, + Metric: stringToStringValue("test_metric"), + From: stringToStringValue("A"), + TargetDatasourceUID: stringToStringValue("test_target_datasource"), }, Data: []QueryV1{{}}, } diff --git a/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/with-target-datasource.yml b/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/with-target-datasource.yml new file mode 100644 index 00000000000..582ddf730ae --- /dev/null +++ b/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/with-target-datasource.yml @@ -0,0 +1,26 @@ +apiVersion: 1 +groups: + - name: recording_rules_group + folder: my_folder + interval: 1m + rules: + - uid: recording_rule_with_target + title: my_recording_rule_with_target + condition: A + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUid: prometheus-uid + model: + expr: up{instance="localhost:9090"} + instant: true + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + record: + metric: my_recorded_metric + from: A + targetDatasourceUid: mimir-uid diff --git a/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/without-target-datasource.yml b/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/without-target-datasource.yml new file mode 100644 index 00000000000..0b8b2760988 --- /dev/null +++ b/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/without-target-datasource.yml @@ -0,0 +1,25 @@ +apiVersion: 1 +groups: + - name: recording_rules_group_no_target + folder: my_folder + interval: 1m + rules: + - uid: recording_rule_without_target + title: my_recording_rule_without_target + condition: A + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUid: prometheus-uid + model: + expr: rate(http_requests_total[5m]) + instant: true + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + record: + metric: http_requests_rate + from: A 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/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index 0733e8241e6..6abaef8bc2e 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -156,6 +156,8 @@ type UnifiedAlertingSettings struct { // AlertmanagerMaxTemplateOutputSize specifies the maximum allowed size for rendered template output in bytes. AlertmanagerMaxTemplateOutputSize int64 + + BacktestingMaxEvaluations int } type RecordingRuleSettings struct { @@ -190,6 +192,8 @@ type UnifiedAlertingReservedLabelSettings struct { type UnifiedAlertingPrometheusConversionSettings struct { // RuleQueryOffset defines a time offset to apply to rule queries during conversion from Prometheus to Grafana format RuleQueryOffset time.Duration + // DefaultDatasourceUID is the default datasource UID to use when converting Prometheus rules if not specified via header + DefaultDatasourceUID string } type UnifiedAlertingLokiSettings struct { @@ -536,7 +540,8 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { prometheusConversion := iniFile.Section("unified_alerting.prometheus_conversion") uaCfg.PrometheusConversion = UnifiedAlertingPrometheusConversionSettings{ - RuleQueryOffset: prometheusConversion.Key("rule_query_offset").MustDuration(time.Minute), + RuleQueryOffset: prometheusConversion.Key("rule_query_offset").MustDuration(time.Minute), + DefaultDatasourceUID: prometheusConversion.Key("default_datasource_uid").MustString(""), } rr := iniFile.Section("recording_rules") @@ -591,6 +596,11 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { return fmt.Errorf("setting 'alertmanager_max_template_output_bytes' is invalid, only 0 or a positive integer are allowed") } + uaCfg.BacktestingMaxEvaluations = ua.Key("backtesting_max_evaluations").MustInt(100) + if uaCfg.BacktestingMaxEvaluations < 0 { + uaCfg.BacktestingMaxEvaluations = 100 + } + cfg.UnifiedAlerting = uaCfg return nil } 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/resource/data/sqlkv_delete_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_delete_legacy_resource.sql new file mode 100644 index 00000000000..e27ee578a41 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_delete_legacy_resource.sql @@ -0,0 +1,5 @@ +DELETE FROM {{ .Ident "resource" }} +WHERE {{ .Ident "group" }} = {{ .Arg .Group }} +AND {{ .Ident "resource" }} = {{ .Arg .Resource }} +AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} +AND {{ .Ident "name" }} = {{ .Arg .Name }}; diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql b/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql new file mode 100644 index 00000000000..8372eb73463 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql @@ -0,0 +1,21 @@ +INSERT INTO {{ .Ident .TableName }} +( + {{ .Ident "guid" }}, + {{ .Ident "key_path" }}, + {{ .Ident "value" }}, + {{ .Ident "group" }}, + {{ .Ident "resource" }}, + {{ .Ident "namespace" }}, + {{ .Ident "name" }}, + {{ .Ident "action" }} +) +VALUES ( + {{ .Arg .GUID }}, + {{ .Arg .KeyPath }}, + COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .Group }}, + {{ .Arg .Resource }}, + {{ .Arg .Namespace }}, + {{ .Arg .Name }}, + {{ .Arg .Action }} +); diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql new file mode 100644 index 00000000000..1f58bd28b43 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql @@ -0,0 +1,31 @@ +INSERT INTO {{ .Ident "resource" }} +( + {{ .Ident "value" }}, + {{ .Ident "guid" }}, + {{ .Ident "group" }}, + {{ .Ident "resource" }}, + {{ .Ident "namespace" }}, + {{ .Ident "name" }}, + {{ .Ident "action" }}, + {{ .Ident "folder" }}, + {{ .Ident "previous_resource_version" }} +) +VALUES ( + COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .GUID }}, + {{ .Arg .Group }}, + {{ .Arg .Resource }}, + {{ .Arg .Namespace }}, + {{ .Arg .Name }}, + {{ .Arg .Action }}, + {{ .Arg .Folder }}, + CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE ( + SELECT {{ .Ident "resource_version" }} + FROM {{ .Ident "resource" }} + WHERE {{ .Ident "group" }} = {{ .Arg .Group }} + AND {{ .Ident "resource" }} = {{ .Arg .Resource }} + AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} + AND {{ .Ident "name" }} = {{ .Arg .Name }} + ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1 + ) END +); diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql new file mode 100644 index 00000000000..d52aac5063d --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql @@ -0,0 +1,44 @@ +INSERT INTO {{ .Ident "resource_history" }} +( + {{ .Ident "value" }}, + {{ .Ident "guid" }}, + {{ .Ident "group" }}, + {{ .Ident "resource" }}, + {{ .Ident "namespace" }}, + {{ .Ident "name" }}, + {{ .Ident "action" }}, + {{ .Ident "folder" }}, + {{ .Ident "previous_resource_version" }}, + {{ .Ident "generation" }} +) +VALUES ( + COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .GUID }}, + {{ .Arg .Group }}, + {{ .Arg .Resource }}, + {{ .Arg .Namespace }}, + {{ .Arg .Name }}, + {{ .Arg .Action }}, + {{ .Arg .Folder }}, + CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE ( + SELECT {{ .Ident "resource_version" }} + FROM {{ .Ident "resource_history" }} + WHERE {{ .Ident "group" }} = {{ .Arg .Group }} + AND {{ .Ident "resource" }} = {{ .Arg .Resource }} + AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} + AND {{ .Ident "name" }} = {{ .Arg .Name }} + ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1 + ) END, + CASE + WHEN {{ .Arg .Action }} = 1 THEN 1 + WHEN {{ .Arg .Action }} = 3 THEN 0 + ELSE 1 + ( + SELECT COUNT(1) + FROM {{ .Ident "resource_history" }} + WHERE {{ .Ident "group" }} = {{ .Arg .Group }} + AND {{ .Ident "resource" }} = {{ .Arg .Resource }} + AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} + AND {{ .Ident "name" }} = {{ .Arg .Name }} + ) + END +); diff --git a/pkg/storage/unified/resource/data/sqlkv_save_event.sql b/pkg/storage/unified/resource/data/sqlkv_save_event.sql new file mode 100644 index 00000000000..669497dbb19 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_save_event.sql @@ -0,0 +1,15 @@ +INSERT INTO {{ .Ident .TableName }} +( + {{ .Ident "key_path" }}, + {{ .Ident "value" }} +) +VALUES ( + {{ .Arg .KeyPath }}, + COALESCE({{ .Arg .Value }}, "") +) +{{- if eq .DialectName "mysql" }} +ON DUPLICATE KEY UPDATE {{ .Ident "value" }} = {{ .Arg .Value }} +{{- else }} +ON CONFLICT ({{ .Ident "key_path" }}) DO UPDATE SET {{ .Ident "value" }} = {{ .Arg .Value }} +{{- end }} +; diff --git a/pkg/storage/unified/resource/data/sqlkv_update_datastore.sql b/pkg/storage/unified/resource/data/sqlkv_update_datastore.sql new file mode 100644 index 00000000000..677666e00a4 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_update_datastore.sql @@ -0,0 +1,3 @@ +UPDATE {{ .Ident .TableName }} +SET {{ .Ident "value" }} = {{ .Arg .Value }} +WHERE {{ .Ident "key_path" }} = {{ .Arg .KeyPath }}; diff --git a/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql new file mode 100644 index 00000000000..1565d0894a4 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql @@ -0,0 +1,9 @@ +UPDATE {{ .Ident "resource" }} +SET + {{ .Ident "value" }} = {{ .Arg .Value }}, + {{ .Ident "action" }} = {{ .Arg .Action }}, + {{ .Ident "folder" }} = {{ .Arg .Folder }} +WHERE {{ .Ident "group" }} = {{ .Arg .Group }} +AND {{ .Ident "resource" }} = {{ .Arg .Resource }} +AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} +AND {{ .Ident "name" }} = {{ .Arg .Name }}; diff --git a/pkg/storage/unified/resource/datastore.go b/pkg/storage/unified/resource/datastore.go index 8a930492420..7a1b614323e 100644 --- a/pkg/storage/unified/resource/datastore.go +++ b/pkg/storage/unified/resource/datastore.go @@ -49,6 +49,9 @@ type DataKey struct { ResourceVersion int64 Action DataAction Folder string + + // needed to maintain backwards compatibility with unified/sql + GUID string } // GroupResource represents a unique group/resource combination @@ -61,6 +64,12 @@ func (k DataKey) String() string { return fmt.Sprintf("%s/%s/%s/%s/%d~%s~%s", k.Group, k.Resource, k.Namespace, k.Name, k.ResourceVersion, k.Action, k.Folder) } +// Temporary while we need to support unified/sql/backend compatibility +// Remove once we stop using RvManager in storage_backend.go +func (k DataKey) StringWithGUID() string { + return fmt.Sprintf("%s/%s/%s/%s/%d~%s~%s~%s", k.Group, k.Resource, k.Namespace, k.Name, k.ResourceVersion, k.Action, k.Folder, k.GUID) +} + func (k DataKey) Equals(other DataKey) bool { return k.Group == other.Group && k.Resource == other.Resource && k.Namespace == other.Namespace && k.Name == other.Name && k.ResourceVersion == other.ResourceVersion && k.Action == other.Action && k.Folder == other.Folder } @@ -516,7 +525,13 @@ func (d *dataStore) Save(ctx context.Context, key DataKey, value io.Reader) erro return fmt.Errorf("invalid data key: %w", err) } - writer, err := d.kv.Save(ctx, dataSection, key.String()) + var writer io.WriteCloser + var err error + if key.GUID != "" { + writer, err = d.kv.Save(ctx, dataSection, key.StringWithGUID()) + } else { + writer, err = d.kv.Save(ctx, dataSection, key.String()) + } if err != nil { return err } @@ -583,6 +598,33 @@ func ParseKey(key string) (DataKey, error) { }, nil } +// Temporary while we need to support unified/sql/backend compatibility +// Remove once we stop using RvManager in storage_backend.go +func ParseKeyWithGUID(key string) (DataKey, error) { + parts := strings.Split(key, "/") + if len(parts) != 5 { + return DataKey{}, fmt.Errorf("invalid key: %s", key) + } + rvActionFolderGUIDParts := strings.Split(parts[4], "~") + if len(rvActionFolderGUIDParts) != 4 { + return DataKey{}, fmt.Errorf("invalid key: %s", key) + } + rv, err := strconv.ParseInt(rvActionFolderGUIDParts[0], 10, 64) + if err != nil { + return DataKey{}, fmt.Errorf("invalid resource version '%s' in key %s: %w", rvActionFolderGUIDParts[0], key, err) + } + return DataKey{ + Group: parts[0], + Resource: parts[1], + Namespace: parts[2], + Name: parts[3], + ResourceVersion: rv, + Action: DataAction(rvActionFolderGUIDParts[1]), + Folder: rvActionFolderGUIDParts[2], + GUID: rvActionFolderGUIDParts[3], + }, nil +} + // SameResource checks if this key represents the same resource as another key. // It compares the identifying fields: Group, Resource, Namespace, and Name. // ResourceVersion, Action, and Folder are ignored as they don't identify the resource itself. diff --git a/pkg/storage/unified/resource/eventstore.go b/pkg/storage/unified/resource/eventstore.go index 7f80fb6b87a..e0c01afb550 100644 --- a/pkg/storage/unified/resource/eventstore.go +++ b/pkg/storage/unified/resource/eventstore.go @@ -32,6 +32,7 @@ type EventKey struct { ResourceVersion int64 Action DataAction Folder string + GUID string } func (k EventKey) String() string { diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go index 3c07403296b..6d406294a96 100644 --- a/pkg/storage/unified/resource/sqlkv.go +++ b/pkg/storage/unified/resource/sqlkv.go @@ -11,9 +11,12 @@ import ( "iter" "strings" "text/template" + "time" + "github.com/google/uuid" "github.com/grafana/grafana/pkg/storage/unified/sql/db" "github.com/grafana/grafana/pkg/storage/unified/sql/dbutil" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) @@ -34,11 +37,18 @@ func mustTemplate(filename string) *template.Template { // Templates. var ( - sqlKVKeys = mustTemplate("sqlkv_keys.sql") - sqlKVGet = mustTemplate("sqlkv_get.sql") - sqlKVBatchGet = mustTemplate("sqlkv_batch_get.sql") - sqlKVDelete = mustTemplate("sqlkv_delete.sql") - sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql") + sqlKVKeys = mustTemplate("sqlkv_keys.sql") + sqlKVGet = mustTemplate("sqlkv_get.sql") + sqlKVBatchGet = mustTemplate("sqlkv_batch_get.sql") + sqlKVSaveEvent = mustTemplate("sqlkv_save_event.sql") + sqlKVInsertData = mustTemplate("sqlkv_insert_datastore.sql") + sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.sql") + sqlKVInsertLegacyResourceHistory = mustTemplate("sqlkv_insert_legacy_resource_history.sql") + sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql") + sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql") + sqlKVDeleteLegacyResource = mustTemplate("sqlkv_delete_legacy_resource.sql") + sqlKVDelete = mustTemplate("sqlkv_delete.sql") + sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql") ) // sqlKVSection can be embedded in structs used when rendering query templates @@ -128,6 +138,45 @@ func (req sqlKVBatchRequest) KeyPaths() []string { return result } +type sqlKVSaveRequest struct { + sqltemplate.SQLTemplate + sqlKVSectionKey + Value []byte + + // old fields that can be removed once we prune resource_history + GUID string + Group string + Resource string + Namespace string + Name string + Action int64 + Folder string +} + +func (req sqlKVSaveRequest) Validate() error { + return req.sqlKVSectionKey.Validate() +} + +type sqlKVLegacySaveRequest struct { + sqltemplate.SQLTemplate + Value []byte + GUID string + Group string + Resource string + Namespace string + Name string + Action int64 + Folder string +} + +func (req sqlKVLegacySaveRequest) Validate() error { + return nil +} + +func (req sqlKVLegacySaveRequest) Results() ([]byte, error) { + return req.Value, nil +} + type sqlKVKeysRequest struct { sqltemplate.SQLTemplate sqlKVSection @@ -285,20 +334,187 @@ func (k *sqlKV) BatchGet(ctx context.Context, section string, keys []string) ite } } -// TODO: this function only exists to support the testing of the sqlkv implementation before -// we have a proper implementation of `Save`. -func (k *sqlKV) TestingSave(ctx context.Context, key string, value []byte) error { - stmt := fmt.Sprintf( - `INSERT INTO resource_events (key_path, value) VALUES (%s, %s)`, - k.dialect.ArgPlaceholder(1), k.dialect.ArgPlaceholder(2), - ) +func (k *sqlKV) Save(ctx context.Context, section string, key string) (io.WriteCloser, error) { + sectionKey := sqlKVSectionKey{sqlKVSection{section}, key} + if err := sectionKey.Validate(); err != nil { + return nil, err + } - _, err := k.db.ExecContext(ctx, stmt, eventsSection+"/"+key, value) - return err + return &sqlWriteCloser{ + kv: k, + ctx: ctx, + sectionKey: sectionKey, + buf: &bytes.Buffer{}, + closed: false, + }, nil } -func (k *sqlKV) Save(ctx context.Context, section string, key string) (io.WriteCloser, error) { - panic("not implemented!") +type sqlWriteCloser struct { + kv *sqlKV + ctx context.Context + sectionKey sqlKVSectionKey + buf *bytes.Buffer + closed bool +} + +func (w *sqlWriteCloser) Write(value []byte) (int, error) { + if w.closed { + return 0, errors.New("write to closed writer") + } + + return w.buf.Write(value) +} + +func (w *sqlWriteCloser) Close() error { + if w.closed { + return nil + } + + w.closed = true + + // do regular kv save: simple key_path + value insert with conflict check. + // can only do this on resource_events for now, until we drop the columns in resource_history + if w.sectionKey.Section == eventsSection { + _, err := dbutil.Exec(w.ctx, w.kv.db, sqlKVSaveEvent, sqlKVSaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + sqlKVSectionKey: w.sectionKey, + Value: w.buf.Bytes(), + }) + + if err != nil { + return fmt.Errorf("failed to save: %w", err) + } + + return nil + } + + // if storage_backend is running with an RvManager, it will inject a transaction into the context + // used to keep backwards compatibility between sql-based kvstore and unified/sql/backend + tx, ok := rvmanager.TxFromCtx(w.ctx) + if !ok { + // temporary save for dataStore without rvmanager + // we can use the same template as the event one after we: + // - move PK from GUID to key_path + // - remove all unnecessary columns (or at least their NOT NULL constraints) + _, err := w.kv.Get(w.ctx, w.sectionKey.Section, w.sectionKey.Key) + if errors.Is(err, ErrNotFound) { + _, err := dbutil.Exec(w.ctx, w.kv.db, sqlKVInsertData, sqlKVSaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + sqlKVSectionKey: w.sectionKey, + GUID: uuid.New().String(), + Value: w.buf.Bytes(), + }) + + if err != nil { + return fmt.Errorf("failed to insert to datastore: %w", err) + } + + return nil + } + + if err != nil { + return fmt.Errorf("failed to get for save: %w", err) + } + + _, err = dbutil.Exec(w.ctx, w.kv.db, sqlKVUpdateData, sqlKVSaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + sqlKVSectionKey: w.sectionKey, + Value: w.buf.Bytes(), + }) + + if err != nil { + return fmt.Errorf("failed to update to datastore: %w", err) + } + + return nil + } + + // special, temporary save that includes all the fields in resource_history that are not relevant for the kvstore, + // as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that + // component will be responsible for populating the resource_version and key_path columns + // note that we are not touching resource_version table, neither the resource_version columns or the key_path column + // as the RvManager will be responsible for this + dataKey, err := ParseKeyWithGUID(w.sectionKey.Key) + if err != nil { + return fmt.Errorf("failed to parse key: %w", err) + } + + var action int64 + switch dataKey.Action { + case DataActionCreated: + action = 1 + case DataActionUpdated: + action = 2 + case DataActionDeleted: + action = 3 + default: + return fmt.Errorf("failed to parse key: %w", err) + } + + _, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + sqlKVSectionKey: w.sectionKey, + Value: w.buf.Bytes(), + GUID: dataKey.GUID, + Group: dataKey.Group, + Resource: dataKey.Resource, + Namespace: dataKey.Namespace, + Name: dataKey.Name, + Action: action, + Folder: dataKey.Folder, + }) + + if err != nil { + return fmt.Errorf("failed to save to resource_history: %w", err) + } + + switch dataKey.Action { + case DataActionCreated: + _, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + Value: w.buf.Bytes(), + GUID: dataKey.GUID, + Group: dataKey.Group, + Resource: dataKey.Resource, + Namespace: dataKey.Namespace, + Name: dataKey.Name, + Action: action, + Folder: dataKey.Folder, + }) + + if err != nil { + return fmt.Errorf("failed to insert to resource: %w", err) + } + case DataActionUpdated: + _, err = dbutil.Exec(w.ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + Value: w.buf.Bytes(), + Group: dataKey.Group, + Resource: dataKey.Resource, + Namespace: dataKey.Namespace, + Name: dataKey.Name, + Action: action, + Folder: dataKey.Folder, + }) + + if err != nil { + return fmt.Errorf("failed to update resource: %w", err) + } + case DataActionDeleted: + _, err = dbutil.Exec(w.ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + Group: dataKey.Group, + Resource: dataKey.Resource, + Namespace: dataKey.Namespace, + Name: dataKey.Name, + }) + + if err != nil { + return fmt.Errorf("failed to delete from resource: %w", err) + } + } + + return nil } func (k *sqlKV) Delete(ctx context.Context, section string, key string) error { @@ -319,6 +535,8 @@ func (k *sqlKV) Delete(ctx context.Context, section string, key string) error { return ErrNotFound } + // TODO reflect change to resource table + return nil } @@ -339,7 +557,7 @@ func (k *sqlKV) BatchDelete(ctx context.Context, section string, keys []string) } func (k *sqlKV) UnixTimestamp(ctx context.Context) (int64, error) { - panic("not implemented!") + return time.Now().Unix(), nil } func closeRows[T any](rows db.Rows, yield func(T, error) bool) { diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go index b0f51702775..13f2b9d6159 100644 --- a/pkg/storage/unified/resource/storage_backend.go +++ b/pkg/storage/unified/resource/storage_backend.go @@ -14,6 +14,7 @@ import ( "time" "github.com/bwmarrin/snowflake" + "github.com/google/uuid" "github.com/grafana/grafana-app-sdk/logging" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/trace" @@ -22,6 +23,8 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/storage/unified/sql/db" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" "github.com/grafana/grafana/pkg/util/debouncer" ) @@ -68,6 +71,8 @@ type kvStorageBackend struct { withExperimentalClusterScope bool //tracer trace.Tracer //reg prometheus.Registerer + + rvManager *rvmanager.ResourceVersionManager } var _ KVBackend = &kvStorageBackend{} @@ -85,6 +90,10 @@ type KVBackendOptions struct { EventPruningInterval time.Duration // How often to run the event pruning (default: 5 minutes) Tracer trace.Tracer // TODO add tracing Reg prometheus.Registerer // TODO add metrics + + // Adding RvManager overrides the RV generated with snowflake in order to keep backwards compatibility with + // unified/sql + RvManager *rvmanager.ResourceVersionManager } func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) { @@ -119,6 +128,7 @@ func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) { eventRetentionPeriod: eventRetentionPeriod, eventPruningInterval: eventPruningInterval, withExperimentalClusterScope: opts.WithExperimentalClusterScope, + rvManager: opts.RvManager, } err = backend.initPruner(ctx) if err != nil { @@ -317,9 +327,28 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in Action: action, Folder: obj.GetFolder(), } - err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value)) - if err != nil { - return 0, fmt.Errorf("failed to write data: %w", err) + + if k.rvManager != nil { + dataKey.GUID = uuid.New().String() + var err error + rv, err = k.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, error) { + err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)) + if err != nil { + return "", fmt.Errorf("failed to write data: %w", err) + } + + return dataKey.GUID, nil + }) + if err != nil { + return 0, fmt.Errorf("failed to write data: %w", err) + } + + dataKey.ResourceVersion = rv + } else { + err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value)) + if err != nil { + return 0, fmt.Errorf("failed to write data: %w", err) + } } // Optimistic concurrency control to verify our write is the latest version @@ -340,14 +369,22 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in } // Check if the RV we just wrote is the latest. If not, a concurrent write with higher RV happened - if latestKey.ResourceVersion != rv { + if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) { // Delete the data we just wrote since it's not the latest + // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete + if k.rvManager != nil { + dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) + } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent modification detected") } - if prevKey.ResourceVersion != event.PreviousRV { + if !rvmanager.IsRvEqual(prevKey.ResourceVersion, event.PreviousRV) { // Another concurrent write happened between our read and write + // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete + if k.rvManager != nil { + dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) + } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: resource was modified concurrently (expected previous RV %d, found %d)", event.PreviousRV, prevKey.ResourceVersion) } @@ -366,8 +403,12 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in } // Check if the RV we just wrote is the latest. If not, a concurrent create with higher RV happened - if latestKey.ResourceVersion != rv { + if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) { // Delete the data we just wrote since it's not the latest + // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete + if k.rvManager != nil { + dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) + } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent create detected") } @@ -375,6 +416,10 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in // Verify that the immediate predecessor is not a create if prevKey.Action == DataActionCreated { // Another concurrent create happened - delete our write and return error + // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete + if k.rvManager != nil { + dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) + } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent create detected") } @@ -391,7 +436,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in Folder: obj.GetFolder(), PreviousRV: event.PreviousRV, } - err = k.eventStore.Save(ctx, eventData) + err := k.eventStore.Save(ctx, eventData) if err != nil { // Clean up the data we wrote since event save failed _ = k.dataStore.Delete(ctx, dataKey) 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/rvmanager/rv_manager.go b/pkg/storage/unified/sql/rvmanager/rv_manager.go index b4f3b5de596..b10685f22ad 100644 --- a/pkg/storage/unified/sql/rvmanager/rv_manager.go +++ b/pkg/storage/unified/sql/rvmanager/rv_manager.go @@ -21,6 +21,19 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) +type contextKey string + +const txKey contextKey = "rvmanager_db_tx" + +func ContextWithTx(ctx context.Context, tx db.Tx) context.Context { + return context.WithValue(ctx, txKey, tx) +} + +func TxFromCtx(ctx context.Context) (db.Tx, bool) { + tx, ok := ctx.Value(txKey).(db.Tx) + return tx, ok +} + var tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager") var ( @@ -294,7 +307,7 @@ func (m *ResourceVersionManager) execBatch(ctx context.Context, group, resource // Allocate the RVs for i, guid := range guids { guidToRV[guid] = rv - guidToSnowflakeRV[guid] = snowflakeFromRv(rv) + guidToSnowflakeRV[guid] = SnowflakeFromRv(rv) rvs[i] = rv rv++ } @@ -353,10 +366,20 @@ func (m *ResourceVersionManager) execBatch(ctx context.Context, group, resource // takes a unix microsecond rv and transforms into a snowflake format. The timestamp is converted from microsecond to // millisecond (the integer division) and the remainder is saved in the stepbits section. machine id is always 0 -func snowflakeFromRv(rv int64) int64 { +func SnowflakeFromRv(rv int64) int64 { return (((rv / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (rv % 1000) } +// helper utility to compare two RVs. The first RV must be in snowflake format. Will convert rv2 to snowflake and retry +// if comparison fails +func IsRvEqual(rv1, rv2 int64) bool { + if rv1 == rv2 { + return true + } + + return rv1 == SnowflakeFromRv(rv2) +} + // Lock locks the resource version for the given key func (m *ResourceVersionManager) Lock(ctx context.Context, x db.ContextExecer, group, resource string) (nextRV int64, err error) { // 1. Lock the row and prevent concurrent updates until the transaction is committed diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index 84eda71ca20..f4a1ee3ce77 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -20,6 +20,8 @@ import ( "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) type QOSEnqueueDequeuer interface { @@ -103,11 +105,34 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { return nil, fmt.Errorf("error creating sqlkv: %s", err) } - kvBackend, err := resource.NewKVStorageBackend(resource.KVBackendOptions{ + kvBackendOpts := resource.KVBackendOptions{ KvStore: sqlkv, Tracer: opts.Tracer, Reg: opts.Reg, + } + + ctx := context.Background() + dbConn, err := eDB.Init(ctx) + if err != nil { + return nil, fmt.Errorf("error initializing DB: %w", err) + } + + dialect := sqltemplate.DialectForDriver(dbConn.DriverName()) + if dialect == nil { + return nil, fmt.Errorf("unsupported database driver: %s", dbConn.DriverName()) + } + + rvManager, err := rvmanager.NewResourceVersionManager(rvmanager.ResourceManagerOptions{ + Dialect: dialect, + DB: dbConn, }) + if err != nil { + return nil, fmt.Errorf("failed to create resource version manager: %w", err) + } + + // TODO add config to decide whether to pass RvManager or not + kvBackendOpts.RvManager = rvManager + kvBackend, err := resource.NewKVStorageBackend(kvBackendOpts) if err != nil { return nil, fmt.Errorf("error creating kv backend: %s", err) } 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/storage/unified/testing/kv.go b/pkg/storage/unified/testing/kv.go index 770bf9ac6e6..2031a3e38b2 100644 --- a/pkg/storage/unified/testing/kv.go +++ b/pkg/storage/unified/testing/kv.go @@ -148,14 +148,15 @@ func runTestKVGet(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) - section := nsPrefix + "-save" + nsPrefix += "-save" t.Run("save new key", func(t *testing.T) { + newKey := namespacedKey(nsPrefix, "new-key") testValue := "new test value" - saveKVHelper(t, kv, ctx, section, "new-key", strings.NewReader(testValue)) + saveKVHelper(t, kv, ctx, testSection, newKey, strings.NewReader(testValue)) // Verify it was saved - reader, err := kv.Get(ctx, section, "new-key") + reader, err := kv.Get(ctx, testSection, newKey) require.NoError(t, err) value, err := io.ReadAll(reader) @@ -166,15 +167,39 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { }) t.Run("save overwrite existing key", func(t *testing.T) { + overwriteKey := namespacedKey(nsPrefix, "overwrite-key") + // First save - saveKVHelper(t, kv, ctx, section, "overwrite-key", strings.NewReader("old value")) + saveKVHelper(t, kv, ctx, testSection, overwriteKey, strings.NewReader("old value")) // Overwrite newValue := "new value" - saveKVHelper(t, kv, ctx, section, "overwrite-key", strings.NewReader(newValue)) + saveKVHelper(t, kv, ctx, testSection, overwriteKey, strings.NewReader(newValue)) // Verify it was updated - reader, err := kv.Get(ctx, section, "overwrite-key") + reader, err := kv.Get(ctx, testSection, overwriteKey) + require.NoError(t, err) + + value, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, newValue, string(value)) + err = reader.Close() + require.NoError(t, err) + }) + + t.Run("save overwrite existing key (datastore)", func(t *testing.T) { + section := "unified/data" + overwriteKey := namespacedKey(nsPrefix, "overwrite-key") + + // First save + saveKVHelper(t, kv, ctx, section, overwriteKey, strings.NewReader("old value")) + + // Overwrite + newValue := "new value" + saveKVHelper(t, kv, ctx, section, overwriteKey, strings.NewReader(newValue)) + + // Verify it was updated + reader, err := kv.Get(ctx, section, overwriteKey) require.NoError(t, err) value, err := io.ReadAll(reader) @@ -191,11 +216,13 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { }) t.Run("save binary data", func(t *testing.T) { + binaryKey := namespacedKey(nsPrefix, "binary-key") + binaryData := []byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD} - saveKVHelper(t, kv, ctx, section, "binary-key", bytes.NewReader(binaryData)) + saveKVHelper(t, kv, ctx, testSection, binaryKey, bytes.NewReader(binaryData)) // Verify binary data - reader, err := kv.Get(ctx, section, "binary-key") + reader, err := kv.Get(ctx, testSection, binaryKey) require.NoError(t, err) value, err := io.ReadAll(reader) @@ -206,11 +233,13 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { }) t.Run("save key with no data", func(t *testing.T) { + emptyKey := namespacedKey(nsPrefix, "empty-key") + // Save a key with empty data - saveKVHelper(t, kv, ctx, section, "empty-key", strings.NewReader("")) + saveKVHelper(t, kv, ctx, testSection, emptyKey, strings.NewReader("")) // Verify it was saved with empty data - reader, err := kv.Get(ctx, section, "empty-key") + reader, err := kv.Get(ctx, testSection, emptyKey) require.NoError(t, err) value, err := io.ReadAll(reader) @@ -476,128 +505,134 @@ func runTestKVKeysWithSort(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVConcurrent(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(60*time.Second)) - section := nsPrefix + "-concurrent" + nsPrefix += "-concurrent" - t.Run("concurrent save and get operations", func(t *testing.T) { - const numGoroutines = 10 - const numOperations = 20 + // Test concurrent operations for both sections, as they have different behaviours + // in the sqlkv implementation. + for _, testSection := range []string{"unified/data", "unified/events"} { + t.Run(testSection, func(t *testing.T) { + t.Run("concurrent save and get operations", func(t *testing.T) { + const numGoroutines = 10 + const numOperations = 20 - done := make(chan error, numGoroutines) + done := make(chan error, numGoroutines) - for i := 0; i < numGoroutines; i++ { - go func(goroutineID int) { - var err error - defer func() { done <- err }() + for goroutineID := range numGoroutines { + go func() { + var err error + defer func() { done <- err }() - for j := 0; j < numOperations; j++ { - key := fmt.Sprintf("concurrent-key-%d-%d", goroutineID, j) - value := fmt.Sprintf("concurrent-value-%d-%d", goroutineID, j) + for j := range numOperations { + key := namespacedKey(nsPrefix, fmt.Sprintf("concurrent-key-%d-%d", goroutineID, j)) + value := fmt.Sprintf("concurrent-value-%d-%d", goroutineID, j) - // Save - writer, err := kv.Save(ctx, section, key) - if err != nil { - return - } - defer func() { - err := writer.Close() - require.NoError(t, err) + // Save + writer, err := kv.Save(ctx, testSection, key) + if err != nil { + return + } + defer func() { + err := writer.Close() + require.NoError(t, err) + }() + _, err = io.Copy(writer, strings.NewReader(value)) + if err != nil { + return + } + err = writer.Close() + if err != nil { + return + } + + // Get immediately + reader, err := kv.Get(ctx, testSection, key) + if err != nil { + return + } + + readValue, err := io.ReadAll(reader) + require.NoError(t, err) + err = reader.Close() + require.NoError(t, err) + assert.Equal(t, value, string(readValue)) + } }() - _, err = io.Copy(writer, strings.NewReader(value)) - if err != nil { - return - } - err = writer.Close() - if err != nil { - return - } + } - // Get immediately - reader, err := kv.Get(ctx, section, key) - if err != nil { - return - } - - readValue, err := io.ReadAll(reader) + // Wait for all goroutines to complete + for range numGoroutines { + err := <-done require.NoError(t, err) - err = reader.Close() + } + }) + + t.Run("concurrent save, delete, and list operations", func(t *testing.T) { + const numGoroutines = 5 + done := make(chan error, numGoroutines) + + for i := range numGoroutines { + go func(goroutineID int) { + var err error + defer func() { done <- err }() + + key := namespacedKey(nsPrefix, fmt.Sprintf("concurrent-ops-key-%d", goroutineID)) + value := fmt.Sprintf("concurrent-ops-value-%d", goroutineID) + + // Save + writer, err := kv.Save(ctx, testSection, key) + if err != nil { + return + } + defer func() { + err := writer.Close() + require.NoError(t, err) + }() + _, err = io.Copy(writer, strings.NewReader(value)) + if err != nil { + return + } + err = writer.Close() + if err != nil { + return + } + + // List to verify it exists + found := false + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{}) { + if err != nil { + return + } + if k == key { + found = true + break + } + } + if !found { + err = fmt.Errorf("key %s not found in list", key) + return + } + + // Delete + err = kv.Delete(ctx, testSection, key) + if err != nil { + return + } + + // Verify it's deleted + _, err = kv.Get(ctx, testSection, key) + require.ErrorIs(t, resource.ErrNotFound, err) + err = nil // Expected error, so clear it + }(i) + } + + // Wait for all goroutines to complete + for range numGoroutines { + err := <-done require.NoError(t, err) - assert.Equal(t, value, string(readValue)) } - }(i) - } - - // Wait for all goroutines to complete - for i := 0; i < numGoroutines; i++ { - err := <-done - require.NoError(t, err) - } - }) - - t.Run("concurrent save, delete, and list operations", func(t *testing.T) { - const numGoroutines = 5 - done := make(chan error, numGoroutines) - - for i := 0; i < numGoroutines; i++ { - go func(goroutineID int) { - var err error - defer func() { done <- err }() - - key := fmt.Sprintf("concurrent-ops-key-%d", goroutineID) - value := fmt.Sprintf("concurrent-ops-value-%d", goroutineID) - - // Save - writer, err := kv.Save(ctx, section, key) - if err != nil { - return - } - defer func() { - err := writer.Close() - require.NoError(t, err) - }() - _, err = io.Copy(writer, strings.NewReader(value)) - if err != nil { - return - } - err = writer.Close() - if err != nil { - return - } - - // List to verify it exists - found := false - for k, err := range kv.Keys(ctx, section, resource.ListOptions{}) { - if err != nil { - return - } - if k == key { - found = true - break - } - } - if !found { - err = fmt.Errorf("key %s not found in list", key) - return - } - - // Delete - err = kv.Delete(ctx, section, key) - if err != nil { - return - } - - // Verify it's deleted - _, err = kv.Get(ctx, section, key) - require.ErrorIs(t, resource.ErrNotFound, err) - err = nil // Expected error, so clear it - }(i) - } - - // Wait for all goroutines to complete - for i := 0; i < numGoroutines; i++ { - err := <-done - require.NoError(t, err) - } - }) + }) + }) + } } func runTestKVUnixTimestamp(t *testing.T, kv resource.KV, nsPrefix string) { @@ -907,18 +942,6 @@ func runTestKVBatchDelete(t *testing.T, kv resource.KV, nsPrefix string) { func saveKVHelper(t *testing.T, kv resource.KV, ctx context.Context, section, key string, value io.Reader) { t.Helper() - // TODO: remove this check once the sqlkv implementation supports `Save`. - type testingSaver interface { - TestingSave(context.Context, string, []byte) error - } - - if saver, ok := kv.(testingSaver); ok { - blob, err := io.ReadAll(value) - require.NoError(t, err) - require.NoError(t, saver.TestingSave(ctx, key, blob)) - return - } - writer, err := kv.Save(ctx, section, key) require.NoError(t, err) _, err = io.Copy(writer, value) diff --git a/pkg/storage/unified/testing/kv_test.go b/pkg/storage/unified/testing/kv_test.go index 3814df17b7e..af7de65e52c 100644 --- a/pkg/storage/unified/testing/kv_test.go +++ b/pkg/storage/unified/testing/kv_test.go @@ -44,12 +44,5 @@ func TestSQLKV(t *testing.T) { kv, err := resource.NewSQLKV(eDB) require.NoError(t, err) return kv - }, &KVTestOptions{ - NSPrefix: "sql-kv-test", - SkipTests: map[string]bool{ - TestKVSave: true, - TestKVConcurrent: true, - TestKVUnixTimestamp: true, - }, - }) + }, &KVTestOptions{NSPrefix: "sql-kv-test"}) } diff --git a/pkg/tests/api/alerting/api_backtesting_test.go b/pkg/tests/api/alerting/api_backtesting_test.go index f07be49ff01..faf50a60da2 100644 --- a/pkg/tests/api/alerting/api_backtesting_test.go +++ b/pkg/tests/api/alerting/api_backtesting_test.go @@ -68,7 +68,7 @@ func TestBacktesting(t *testing.T) { require.Truef(t, ok, "The data file does not contain a field `data`") status, body := apiCli.SubmitRuleForBacktesting(t, request) - require.Equal(t, http.StatusOK, status) + require.Equalf(t, http.StatusOK, status, "Response: %s", body) var result data.Frame require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame") }) @@ -107,6 +107,7 @@ func TestBacktesting(t *testing.T) { resourcepermissions.SetResourcePermissionCommand{ Actions: []string{ accesscontrol.ActionAlertingRuleRead, + accesscontrol.ActionAlertingRuleUpdate, }, Resource: "folders", ResourceID: "*", 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/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 d942144ef9c..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 @@ -93,6 +93,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "", @@ -225,6 +226,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "", @@ -1300,6 +1302,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "", @@ -1405,6 +1408,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -2476,6 +2480,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -2645,6 +2650,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -2935,6 +2941,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -3139,6 +3146,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -4405,6 +4413,7 @@ "is": "" }, "required": false, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -5334,6 +5343,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -6630,6 +6640,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -6928,6 +6939,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "token", @@ -6946,6 +6958,7 @@ "is": "" }, "required": false, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -7004,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 } ] }, @@ -9237,6 +9268,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -11515,6 +11547,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "", @@ -12308,6 +12341,7 @@ "is": "" }, "required": false, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -13001,6 +13035,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -13443,6 +13478,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -13641,6 +13677,7 @@ "is": "" }, "required": false, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -15072,6 +15109,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "secret", diff --git a/pkg/tests/api/alerting/test-data/api_backtesting_data.json b/pkg/tests/api/alerting/test-data/api_backtesting_data.json index d02b6905f0b..5fe0f621126 100644 --- a/pkg/tests/api/alerting/test-data/api_backtesting_data.json +++ b/pkg/tests/api/alerting/test-data/api_backtesting_data.json @@ -12,6 +12,9 @@ }, "condition": "A", "no_data_state": "Alerting", + "title": "test-rule-backtesting-data", + "rule_group": "test-group", + "namespace_uid": "test-namespace", "data": [ { "refId": "A", @@ -193,6 +196,9 @@ }, "condition": "C", "no_data_state": "Alerting", + "title": "test-rule-backtesting-data", + "rule_group": "test-group", + "namespace_uid": "test-namespace", "data": [ { "refId": "A", diff --git a/pkg/tests/api/alerting/test-data/rulegroup-1-export.json b/pkg/tests/api/alerting/test-data/rulegroup-1-export.json index 18d8b8cea40..f8c01a3d883 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-1-export.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-1-export.json @@ -72,6 +72,33 @@ }, "isPaused": false, "missing_series_evals_to_resolve": 2 + }, + { + "uid": "", + "title": "RecordingRule1", + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "1 + 1", + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A", + "type": "math" + } + } + ], + "isPaused": false, + "record": { + "metric": "test_metric", + "from": "A", + "targetDatasourceUid": "test-datasource-uid" + } } ] } diff --git a/pkg/tests/api/alerting/test-data/rulegroup-1-get.json b/pkg/tests/api/alerting/test-data/rulegroup-1-get.json index 333bd32c0d1..d392254d756 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-1-get.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-1-get.json @@ -97,6 +97,44 @@ }, "missing_series_evals_to_resolve": 2 } + }, + { + "expr": "", + "for": "0s", + "keep_firing_for": "0s", + "grafana_alert": { + "title": "RecordingRule1", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "1 + 1", + "intervalMs": 1000, + "maxDataPoints": 43200, + "type": "math" + } + } + ], + "updated": "2023-09-29T17:37:19Z", + "intervalSeconds": 60, + "version": 1, + "uid": "", + "namespace_uid": "", + "rule_group": "Group1", + "is_paused": false, + "record": { + "metric": "test_metric", + "from": "A", + "target_datasource_uid": "test-datasource-uid" + }, + "metadata": {} + } } ] } diff --git a/pkg/tests/api/alerting/test-data/rulegroup-1-post.json b/pkg/tests/api/alerting/test-data/rulegroup-1-post.json index f9f1441eb18..e020299fc42 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-1-post.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-1-post.json @@ -53,6 +53,26 @@ "exec_err_state": "Alerting", "missing_series_evals_to_resolve": 2 } + }, + { + "grafana_alert": { + "title": "RecordingRule1", + "data": [ + { + "refId": "A", + "datasourceUid": "__expr__", + "model": { + "expression": "1 + 1", + "type": "math" + } + } + ], + "record": { + "metric": "test_metric", + "from": "A", + "target_datasource_uid": "test-datasource-uid" + } + } } ] } diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index b49e61d3a4e..3bae37f209a 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -249,6 +249,7 @@ func convertGettableGrafanaRuleToPostable(gettable *apimodels.GettableGrafanaRul ExecErrState: gettable.ExecErrState, IsPaused: &gettable.IsPaused, NotificationSettings: gettable.NotificationSettings, + Record: gettable.Record, Metadata: gettable.Metadata, } } 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..613d021b236 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,33 @@ import ( "github.com/grafana/grafana/pkg/util/testutil" ) +func TestIntegrationFolderTreeZanzana(t *testing.T) { + 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 +75,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 +90,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 +249,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 b65fa2ad0d7..4634143bd45 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -1802,6 +1802,15 @@ } } }, + { + "name": "facetLimit", + "in": "query", + "description": "maximum number of terms to return per facet (default 50, max 1000)", + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "tags", "in": "query", @@ -2160,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/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/api/clients/provisioning/utils/createOnCacheEntryAdded.ts b/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts index c3c44ede267..0d39d4fa402 100644 --- a/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts +++ b/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts @@ -58,6 +58,7 @@ export function createOnCacheEntryAdded(resourceName: string) { }); } catch (error) { console.error('Error in onCacheEntryAdded:', error); + return; } await cacheEntryRemoved; diff --git a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx index 153becab1b9..9de19c4fcfe 100644 --- a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx +++ b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx @@ -14,13 +14,12 @@ export interface Props { } export const FolderRepo = memo(function FolderRepo({ folder }: Props) { - // skip rendering if: - // folder is not present - // folder have parentUID - // folder is not managed - // if whole instance is provisioned - const isProvisionedInstance = useIsProvisionedInstance(); - const skipRender = getShouldSkipRender(folder, isProvisionedInstance); + // Check if we can skip early without needing the useIsProvisionedInstance query + // This reduces RTK Query subscriptions and prevents re-render loops on API errors + const canSkipEarly = getCanSkipEarly(folder); + + const isProvisionedInstance = useIsProvisionedInstance({ skip: canSkipEarly }); + const skipRender = canSkipEarly || isProvisionedInstance; const { isReadOnlyRepo, repoType } = useGetResourceRepositoryView({ folderName: skipRender ? undefined : folder?.uid, @@ -51,11 +50,19 @@ export const FolderRepo = memo(function FolderRepo({ folder }: Props) { ); }); -function getShouldSkipRender(folder: FolderDTO | DashboardViewItem | undefined, isProvisionedInstance?: boolean) { - // Skip render if parentUID is present, then we should skip rendering. we only display icon for root folders - const hasParent = folder && Boolean('parentUID' in folder && folder.parentUID); - // Skip render if folder is not managed by Repo - const isNotManaged = folder && folder.managedBy !== ManagerKind.Repo; - - return !folder || hasParent || isNotManaged || isProvisionedInstance; +// Check conditions that don't require the useIsProvisionedInstance hook +function getCanSkipEarly(folder: FolderDTO | DashboardViewItem | undefined): boolean { + if (!folder) { + return true; + } + // Skip render if parentUID is present - we only display icon for root folders + const hasParent = Boolean('parentUID' in folder && folder.parentUID); + if (hasParent) { + return true; + } + const isNotManaged = folder.managedBy !== ManagerKind.Repo; + if (isNotManaged) { + return true; + } + return false; } 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/utils/object.ts b/public/app/core/utils/object.ts index 7ace78598c4..ba1426b163c 100644 --- a/public/app/core/utils/object.ts +++ b/public/app/core/utils/object.ts @@ -1,23 +1,29 @@ -import { isArray, isPlainObject } from 'lodash'; +import { isArray, isPlainObject, isString } from 'lodash'; /** * @returns A deep clone of the object, but with any null value removed. * @param value - The object to be cloned and cleaned. * @param convertInfinity - If true, -Infinity or Infinity is converted to 0. * This is because Infinity is not a valid JSON value, and sometimes we want to convert it to 0 instead of default null. + * @param stripBOMs - If true, strips Byte Order Mark (BOM) characters from all strings. + * BOMs (U+FEFF) can cause CUE validation errors ("illegal byte order mark"). */ -export function sortedDeepCloneWithoutNulls(value: T, convertInfinity?: boolean): T { +export function sortedDeepCloneWithoutNulls(value: T, convertInfinity?: boolean, stripBOMs?: boolean): T { if (isArray(value)) { - return value.map((item) => sortedDeepCloneWithoutNulls(item, convertInfinity)) as unknown as T; + return value.map((item) => sortedDeepCloneWithoutNulls(item, convertInfinity, stripBOMs)) as unknown as T; } if (isPlainObject(value)) { return Object.keys(value as { [key: string]: any }) .sort() .reduce((acc: any, key) => { - const v = (value as any)[key]; + let v = (value as any)[key]; // Remove null values if (v != null) { - acc[key] = sortedDeepCloneWithoutNulls(v, convertInfinity); + // Strip BOMs from strings + if (stripBOMs && isString(v)) { + v = v.replace(/\ufeff/g, ''); + } + acc[key] = sortedDeepCloneWithoutNulls(v, convertInfinity, stripBOMs); } if (convertInfinity && (v === Infinity || v === -Infinity)) { diff --git a/public/app/features/alerting/unified/api/backtestApi.ts b/public/app/features/alerting/unified/api/backtestApi.ts new file mode 100644 index 00000000000..14a0827cb20 --- /dev/null +++ b/public/app/features/alerting/unified/api/backtestApi.ts @@ -0,0 +1,50 @@ +import { DataFrameJSON } from '@grafana/data'; +import { AlertQuery, GrafanaAlertStateDecision, Labels } from 'app/types/unified-alerting-dto'; + +import { alertingApi } from './alertingApi'; + +/** + * Request body for the backtest API matching the BacktestConfig struct in the backend + */ +export interface BacktestRequest { + // Required time range fields + from: string; // ISO 8601 timestamp + to: string; // ISO 8601 timestamp + interval: string; // e.g., "1m", "5m" + + // Required alert definition fields + condition: string; + data: AlertQuery[]; + title: string; + no_data_state?: GrafanaAlertStateDecision; + exec_err_state?: GrafanaAlertStateDecision; + + // Optional duration fields + for?: string; + keep_firing_for?: string; + + // Optional metadata fields + labels?: Labels; + missing_series_evals_to_resolve?: number; + + // Optional rule identification fields + uid?: string; + rule_group?: string; + namespace_uid?: string; +} + +export const BACKTEST_URL = '/api/v1/rule/backtest'; + +export const backtestApi = alertingApi.injectEndpoints({ + endpoints: (build) => ({ + runBacktest: build.mutation({ + query: (requestBody) => ({ + url: BACKTEST_URL, + method: 'POST', + body: requestBody, + }), + }), + }), +}); + +export const { useRunBacktestMutation } = backtestApi; diff --git a/public/app/features/alerting/unified/components/backtesting/BacktestDropdownButton.tsx b/public/app/features/alerting/unified/components/backtesting/BacktestDropdownButton.tsx new file mode 100644 index 00000000000..7d7c4467780 --- /dev/null +++ b/public/app/features/alerting/unified/components/backtesting/BacktestDropdownButton.tsx @@ -0,0 +1,63 @@ +import { useCallback, useState } from 'react'; + +import { TimeRange, rangeUtil } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { Button, Drawer, Dropdown, Menu, MenuItem } from '@grafana/ui'; + +import { RuleFormValues } from '../../types/rule-form'; + +import { BacktestPanel } from './BacktestPanel'; + +interface BacktestDropdownButtonProps { + ruleDefinition: RuleFormValues; +} + +export function BacktestDropdownButton({ ruleDefinition }: BacktestDropdownButtonProps) { + const [isBacktestPanelOpen, setIsBacktestPanelOpen] = useState(false); + const [backtestTimeRange, setBacktestTimeRange] = useState(); + + const handleTimeRangeSelect = useCallback((rawFrom: string) => { + const timeRange = rangeUtil.convertRawToRange({ from: rawFrom, to: 'now' }); + setBacktestTimeRange(timeRange); + setIsBacktestPanelOpen(true); + }, []); + + const handleCustomSelect = useCallback(() => { + setBacktestTimeRange(undefined); + setIsBacktestPanelOpen(true); + }, []); + + return ( + <> + + handleTimeRangeSelect('now-15m')} + /> + handleTimeRangeSelect('now-1h')} + /> + + + } + > + + + + {isBacktestPanelOpen && ( + setIsBacktestPanelOpen(false)} + size="md" + > + + + )} + + ); +} diff --git a/public/app/features/alerting/unified/components/backtesting/BacktestPanel.tsx b/public/app/features/alerting/unified/components/backtesting/BacktestPanel.tsx new file mode 100644 index 00000000000..1a3a4ae7706 --- /dev/null +++ b/public/app/features/alerting/unified/components/backtesting/BacktestPanel.tsx @@ -0,0 +1,200 @@ +import { css } from '@emotion/css'; +import { fromPairs, isEmpty, isEqual } from 'lodash'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { AlertLabels } from '@grafana/alerting/unstable'; +import { DataFrameJSON, GrafanaTheme2, TimeRange, rangeUtil } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { + Alert, + Icon, + LoadingPlaceholder, + RefreshPicker, + Stack, + Text, + TimeRangePicker, + Tooltip, + useStyles2, +} from '@grafana/ui'; + +import { useRunBacktestMutation } from '../../api/backtestApi'; +import { RuleFormValues } from '../../types/rule-form'; +import { combineMatcherStrings } from '../../utils/alertmanager'; +import { messageFromError } from '../../utils/redux'; +import { formValuesToRulerGrafanaRuleDTO } from '../../utils/rule-form'; +import { LogRecordViewerByTimestamp } from '../rules/state-history/LogRecordViewer'; +import { LogTimelineViewer } from '../rules/state-history/LogTimelineViewer'; +import { useFrameSubset } from '../rules/state-history/LokiStateHistory'; +import { useRuleHistoryRecords } from '../rules/state-history/useRuleHistoryRecords'; + +interface BacktestPanelProps { + ruleDefinition: RuleFormValues; + initialTimeRange?: TimeRange; +} + +export function BacktestPanel({ ruleDefinition, initialTimeRange }: BacktestPanelProps) { + const styles = useStyles2(getStyles); + const [timeRange, setTimeRange] = useState( + initialTimeRange || rangeUtil.convertRawToRange({ from: 'now-15m', to: 'now' }) + ); + const [stateHistory, setStateHistory] = useState(); + const [instancesFilter, setInstancesFilter] = useState(''); + const shouldRunInitialBacktest = useRef(!!initialTimeRange); + + const [runBacktest, { isLoading, error: mutationError }] = useRunBacktestMutation(); + + const handleRunBacktest = useCallback(async () => { + // Convert form values to the proper AlertRule format + const alertRule = formValuesToRulerGrafanaRuleDTO(ruleDefinition); + + // Build requestBody matching BacktestConfig struct + const requestBody = { + // Required time range fields + from: timeRange.from.toISOString(), + to: timeRange.to.toISOString(), + interval: ruleDefinition.evaluateEvery, + + // Required alert definition fields + condition: alertRule.grafana_alert.condition, + data: alertRule.grafana_alert.data, + title: alertRule.grafana_alert.title, + no_data_state: alertRule.grafana_alert.no_data_state, + exec_err_state: alertRule.grafana_alert.exec_err_state, + + // Optional duration fields + for: alertRule.for, + keep_firing_for: alertRule.keep_firing_for, + + // Optional metadata fields + labels: alertRule.labels, + missing_series_evals_to_resolve: alertRule.grafana_alert.missing_series_evals_to_resolve, + + // Optional rule identification fields + uid: alertRule.grafana_alert.uid, + rule_group: ruleDefinition.group, + namespace_uid: ruleDefinition.folder?.uid, + }; + + try { + const result = await runBacktest(requestBody).unwrap(); + setStateHistory(result); + } catch (err) { + // Error is handled by RTK Query and available via mutationError + } + }, [ruleDefinition, timeRange, runBacktest]); + + // Update time range when initialTimeRange prop changes + useEffect(() => { + if (initialTimeRange) { + setTimeRange(initialTimeRange); + } + }, [initialTimeRange]); + + // Run backtest once after initial mount when timeRange is synchronized with initialTimeRange + useEffect(() => { + if (shouldRunInitialBacktest.current && initialTimeRange && isEqual(timeRange, initialTimeRange)) { + shouldRunInitialBacktest.current = false; + handleRunBacktest(); + } + }, [initialTimeRange, timeRange, handleRunBacktest]); + + const { dataFrames, historyRecords, commonLabels } = useRuleHistoryRecords(stateHistory, instancesFilter); + + const { frameSubset, frameTimeRange } = useFrameSubset(dataFrames); + + const onLogRecordLabelClick = useCallback( + (label: string) => { + const matcherString = combineMatcherStrings(instancesFilter, label); + setInstancesFilter(matcherString); + }, + [instancesFilter] + ); + + const hasResults = stateHistory !== undefined; + + const notices = stateHistory?.schema?.meta?.notices || []; + const errorMessage = mutationError ? messageFromError(mutationError) : null; + + return ( +
+ + {}} + onMoveBackward={() => {}} + onMoveForward={() => {}} + onZoom={() => {}} + /> + {}} + isLoading={isLoading} + noIntervalPicker={true} + /> + +
+ {isLoading && } + + {errorMessage && ( + {errorMessage} + )} + + {!isLoading && !mutationError && hasResults && notices.length > 0 && ( + + {notices.map((notice, index) => ( + + {notice.text} + + ))} + + )} + + {!isLoading && !mutationError && hasResults && ( +
+ {!isEmpty(commonLabels) && ( + + + + Common labels + + + + + + + + )} + + +
+ )} +
+
+ ); +} +const getStyles = (theme: GrafanaTheme2) => ({ + scrollableContent: css({ + flex: 1, + display: 'flex', + flexDirection: 'column', + paddingTop: theme.spacing(2), + overflow: 'hidden', + }), + resultsContainer: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + flex: 1, + overflow: 'hidden', + }), +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index 85e72cca2d8..ed05cd8823c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -60,6 +60,7 @@ import { formValuesToRulerRuleDTO, } from '../../../utils/rule-form'; import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier } from '../../../utils/rule-id'; +import { BacktestDropdownButton } from '../../backtesting/BacktestDropdownButton'; import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput'; import AnnotationsStep from '../AnnotationsStep'; @@ -290,6 +291,8 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) => Edit YAML )} + + {config.featureToggles.alertingBacktesting && }
diff --git a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.test.tsx new file mode 100644 index 00000000000..e95b321c02c --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.test.tsx @@ -0,0 +1,1612 @@ +import { render, screen, testWithFeatureToggles, userEvent, waitFor } from 'test/test-utils'; +import { byLabelText, byRole } from 'testing-library-selector'; + +import { useAssistant } from '@grafana/assistant'; +import { GrafanaEdition } from '@grafana/data/internal'; +import { config, setPluginLinksHook } from '@grafana/runtime'; +import AlertRuleMenu from 'app/features/alerting/unified/components/rule-viewer/AlertRuleMenu'; +import { mockFolderApi, setupMswServer } from 'app/features/alerting/unified/mockApi'; +import { + getCloudRule, + getGrafanaRule, + grantUserPermissions, + mockDataSource, + mockFolder, + mockGrafanaRulerRule, + mockPromAlertingRule, + mockPromRecordingRule, + mockRulerGrafanaRecordingRule, +} from 'app/features/alerting/unified/mocks'; +import { setFolderAccessControl } from 'app/features/alerting/unified/mocks/server/configure'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; +import * as miscUtils from 'app/features/alerting/unified/utils/misc'; +import { fromCombinedRule } from 'app/features/alerting/unified/utils/rule-id'; +import { AccessControlAction } from 'app/types/accessControl'; +import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; + +import { AlertRuleAction } from '../../hooks/useAbilities'; + +const mockOpenAssistant = jest.fn(); +jest.mock('@grafana/assistant', () => ({ + useAssistant: jest.fn(), + createAssistantContextItem: jest.fn((type: string, data: T) => ({ + type, + ...data, + })), +})); + +const mockUseAssistant = jest.mocked(useAssistant); + +const mockPauseExecute = jest.fn().mockResolvedValue(undefined); +jest.mock('../../hooks/ruleGroup/usePauseAlertRule', () => ({ + usePauseRuleInGroup: () => [ + { + execute: mockPauseExecute, + }, + { loading: false, error: undefined }, + ], +})); + +const server = setupMswServer(); + +setPluginLinksHook(() => ({ + links: [], + isLoading: false, +})); + +setupDataSources(); + +const user = userEvent.setup(); +const handleSilence = jest.fn(); +const handleDelete = jest.fn(); +const handleDuplicateRule = jest.fn(); + +const ui = { + moreButton: byLabelText(/More/), + menu: byRole('menu'), + menuItems: { + pause: byRole('menuitem', { name: /Pause evaluation/i }), + resume: byRole('menuitem', { name: /Resume evaluation/i }), + silence: byRole('menuitem', { name: /Silence notifications/i }), + duplicate: byRole('menuitem', { name: /Duplicate/i }), + copyLink: byRole('menuitem', { name: /Copy link/i }), + export: byRole('menuitem', { name: /Export/i }), + delete: byRole('menuitem', { name: /Delete/i }), + manageEnrichments: byRole('menuitem', { name: /Manage enrichments/i }), + declareIncident: byRole('link', { name: /Declare incident/i }), + analyzeRule: byRole('menuitem', { name: /Analyze rule/i }), + }, +}; + +const getMenuContents = async () => { + await screen.findByRole('menu'); + const allMenuItems = screen.queryAllByRole('menuitem').map((el) => el.textContent); + const allLinkItems = screen.queryAllByRole('link').map((el) => el.textContent); + + return [...allMenuItems, ...allLinkItems]; +}; + +const openMenu = async () => { + await user.click(await ui.moreButton.find()); + await waitFor(() => { + expect(ui.menu.query()).toBeInTheDocument(); + }); +}; + +// Helper function to create a default rule setup +const createDefaultRuleSetup = () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + return { mockRule, identifier, groupIdentifier }; +}; + +describe('AlertRuleMenu', () => { + const originalBuildInfo = config.buildInfo; + + beforeEach(() => { + jest.clearAllMocks(); + mockPauseExecute.mockResolvedValue(undefined); + mockOpenAssistant.mockClear(); + // Default: assistant unavailable + mockUseAssistant.mockReturnValue({ + isAvailable: false, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + + // Reset config to defaults + config.buildInfo = { ...originalBuildInfo }; + + // Set up default folder mock for Grafana rules (namespace-uid is the default folder UID) + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + // Set up default permissions (no permissions granted by default) + grantUserPermissions([]); + setFolderAccessControl({}); + }); + + afterEach(() => { + config.buildInfo = originalBuildInfo; + }); + + describe('Basic Rendering', () => { + it('renders MoreButton correctly', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + expect(await ui.moreButton.find()).toBeInTheDocument(); + }); + + it('opens menu when MoreButton is clicked', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + + expect(ui.menu.query()).toBeInTheDocument(); + }); + + it('closes menu when clicking outside', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menu.query()).toBeInTheDocument(); + + // Click outside the menu + await user.click(document.body); + + await waitFor(() => { + expect(ui.menu.query()).not.toBeInTheDocument(); + }); + }); + + it('menu contains expected structure', async () => { + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + + const menuContents = await getMenuContents(); + expect(menuContents).toHaveLength(1); + expect(menuContents).toContain('Copy link'); + }); + }); + + describe('Permissions', () => { + // Generalized test to reduce repetition for menu item visibility + type MenuItemTestCase = { + description: string; + action: AlertRuleAction; + menuItem: keyof typeof ui.menuItems; + granted: boolean; + shouldShow: boolean; + }; + + const testMenuItemVisibility = ({ description, action, menuItem, granted, shouldShow }: MenuItemTestCase) => { + it(description, async () => { + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + // Grant permissions based on action + const permissions: AccessControlAction[] = []; + const folderAccessControl: Record = {}; + + if (granted) { + switch (action) { + case AlertRuleAction.Pause: + case AlertRuleAction.Update: + permissions.push(AccessControlAction.AlertingRuleUpdate); + folderAccessControl[AccessControlAction.AlertingRuleUpdate] = true; + break; + case AlertRuleAction.Delete: + permissions.push(AccessControlAction.AlertingRuleDelete); + folderAccessControl[AccessControlAction.AlertingRuleDelete] = true; + break; + case AlertRuleAction.Duplicate: + permissions.push(AccessControlAction.AlertingRuleCreate); + break; + case AlertRuleAction.Silence: + permissions.push(AccessControlAction.AlertingInstanceCreate, AccessControlAction.AlertingSilenceCreate); + break; + case AlertRuleAction.ModifyExport: + permissions.push(AccessControlAction.AlertingRuleRead); + break; + } + } + + grantUserPermissions(permissions); + setFolderAccessControl(folderAccessControl); + + // Set up folder mock for Grafana rules with matching access control + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: folderAccessControl, + }) + ); + + render( + + ); + + await openMenu(); + if (shouldShow) { + expect(await ui.menuItems[menuItem].find()).toBeInTheDocument(); + } else { + expect(ui.menuItems[menuItem].query()).not.toBeInTheDocument(); + } + }); + }; + + describe('Pause/Resume visibility', () => { + testMenuItemVisibility({ + description: 'shows Pause when pause permission is granted', + action: AlertRuleAction.Pause, + menuItem: 'pause', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Pause when pause permission is denied', + action: AlertRuleAction.Pause, + menuItem: 'pause', + granted: false, + shouldShow: false, + }); + }); + + describe('Delete visibility', () => { + testMenuItemVisibility({ + description: 'shows Delete when delete permission is granted', + action: AlertRuleAction.Delete, + menuItem: 'delete', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Delete when delete permission is denied', + action: AlertRuleAction.Delete, + menuItem: 'delete', + granted: false, + shouldShow: false, + }); + }); + + describe('Duplicate visibility', () => { + testMenuItemVisibility({ + description: 'shows Duplicate when duplicate permission is granted', + action: AlertRuleAction.Duplicate, + menuItem: 'duplicate', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Duplicate when duplicate permission is denied', + action: AlertRuleAction.Duplicate, + menuItem: 'duplicate', + granted: false, + shouldShow: false, + }); + }); + + describe('Silence visibility', () => { + testMenuItemVisibility({ + description: 'shows Silence when silence permission is granted', + action: AlertRuleAction.Silence, + menuItem: 'silence', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Silence when silence permission is denied', + action: AlertRuleAction.Silence, + menuItem: 'silence', + granted: false, + shouldShow: false, + }); + }); + + describe('Export visibility', () => { + testMenuItemVisibility({ + description: 'shows Export when export permission is granted', + action: AlertRuleAction.ModifyExport, + menuItem: 'export', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Export when export permission is denied', + action: AlertRuleAction.ModifyExport, + menuItem: 'export', + granted: false, + shouldShow: false, + }); + }); + + describe('Copy Link visibility', () => { + it('shows Copy Link when shareUrl exists', async () => { + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.copyLink.find()).toBeInTheDocument(); + }); + }); + }); + + describe('Rule Types', () => { + beforeEach(() => { + // Grant all permissions for testing rule type differences + grantUserPermissions([ + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleUpdate, + AccessControlAction.AlertingRuleDelete, + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingInstanceCreate, + AccessControlAction.AlertingSilenceCreate, + ]); + setFolderAccessControl({ + [AccessControlAction.AlertingRuleUpdate]: true, + [AccessControlAction.AlertingRuleDelete]: true, + }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { + [AccessControlAction.AlertingRuleUpdate]: true, + [AccessControlAction.AlertingRuleDelete]: true, + }, + }) + ); + }); + + describe('Grafana-managed rules', () => { + it('shows Pause option for Grafana-managed alerting rules', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.pause.find()).toBeInTheDocument(); + }); + + it('does not show Pause option for datasource-managed rules', async () => { + const datasource = mockDataSource({ uid: 'mimir', name: 'Mimir' }); + const mockRule = getCloudRule({}, { rulesSource: datasource }); + const identifier = fromCombinedRule(datasource.name, mockRule); + const groupIdentifier = { + groupOrigin: 'datasource' as const, + rulesSource: { uid: datasource.uid, name: datasource.name, ruleSourceType: 'datasource' as const }, + namespace: { name: 'namespace-name' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.pause.query()).not.toBeInTheDocument(); + }); + }); + + describe('Alerting vs Recording rules', () => { + it('shows Silence option for alerting rules', async () => { + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ type: PromRuleType.Alerting }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.silence.find()).toBeInTheDocument(); + }); + + it('does not show Silence option for recording rules', async () => { + const mockRule = getGrafanaRule({ + promRule: mockPromRecordingRule({ type: PromRuleType.Recording }), + }); + // Override the rulerRule to be a recording rule + mockRule.rulerRule = mockRulerGrafanaRecordingRule( + {}, + { + uid: 'mock-rule-uid-123', + namespace_uid: 'namespace-uid', + } + ); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + }) + ); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.silence.query()).not.toBeInTheDocument(); + }); + }); + + describe('Provisioned rules', () => { + it('hides Delete option for provisioned rules', async () => { + const mockRule = getGrafanaRule({ + rulerRule: mockGrafanaRulerRule({ provenance: 'file' }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.delete.query()).not.toBeInTheDocument(); + }); + + it('hides Pause option for provisioned rules', async () => { + const mockRule = getGrafanaRule({ + rulerRule: mockGrafanaRulerRule({ provenance: 'file' }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.pause.query()).not.toBeInTheDocument(); + }); + }); + + describe('Mixed data scenarios', () => { + it('works with only promRule available', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menu.query()).toBeInTheDocument(); + }); + + it('works with only rulerRule available', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menu.query()).toBeInTheDocument(); + }); + + it('works with both promRule and rulerRule available', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menu.query()).toBeInTheDocument(); + }); + }); + }); + + describe('Handler Callbacks', () => { + describe('handleSilence', () => { + it('calls handleSilence when Silence menu item is clicked', async () => { + grantUserPermissions([AccessControlAction.AlertingInstanceCreate, AccessControlAction.AlertingSilenceCreate]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.silence.find()); + + expect(handleSilence).toHaveBeenCalledTimes(1); + }); + }); + + describe('handleManageEnrichments', () => { + testWithFeatureToggles({ + enable: ['alertEnrichment', 'alertingEnrichmentPerRule'], + }); + + it('calls handleManageEnrichments when Manage enrichments menu item is clicked', async () => { + // Both toggles need to be enabled: alertEnrichment for the hook, alertingEnrichmentPerRule for the component + grantUserPermissions([AccessControlAction.AlertingEnrichmentsRead]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const handleManageEnrichments = jest.fn(); + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.manageEnrichments.find()); + + expect(handleManageEnrichments).toHaveBeenCalledTimes(1); + }); + }); + + describe('handleDelete', () => { + it('calls handleDelete with correct identifier and groupIdentifier when Delete menu item is clicked', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleDelete]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleDelete]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleDelete]: true }, + }) + ); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.delete.find()); + + expect(handleDelete).toHaveBeenCalledTimes(1); + expect(handleDelete).toHaveBeenCalledWith(identifier, groupIdentifier); + }); + + it('does not call handleDelete when identifier is not editable', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleDelete]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleDelete]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleDelete]: true }, + }) + ); + + const { mockRule, groupIdentifier } = createDefaultRuleSetup(); + // Create a non-editable identifier (e.g., Prometheus rule identifier without rulerRule) + // For external rules without rulerRule, the delete button should not appear + const identifier = fromCombinedRule('prometheus', { + ...mockRule, + rulerRule: undefined, + }); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.delete.query()).not.toBeInTheDocument(); + expect(handleDelete).not.toHaveBeenCalled(); + }); + }); + + describe('handleDuplicateRule', () => { + it('calls handleDuplicateRule with correct identifier when Duplicate menu item is clicked', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleCreate]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.duplicate.find()); + + expect(handleDuplicateRule).toHaveBeenCalledTimes(1); + expect(handleDuplicateRule).toHaveBeenCalledWith(identifier); + }); + }); + + describe('onPauseChange', () => { + it('calls onPauseChange after pause state change when Pause menu item is clicked', async () => { + const onPauseChange = jest.fn(); + grantUserPermissions([AccessControlAction.AlertingRuleUpdate]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleUpdate]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleUpdate]: true }, + }) + ); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.pause.find()); + + await waitFor(() => { + expect(mockPauseExecute).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(onPauseChange).toHaveBeenCalledTimes(1); + }); + expect(onPauseChange).toHaveBeenCalledWith(); + }); + + it('calls onPauseChange after resume state change when Resume menu item is clicked', async () => { + const onPauseChange = jest.fn(); + grantUserPermissions([AccessControlAction.AlertingRuleUpdate]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleUpdate]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleUpdate]: true }, + }) + ); + + const mockRule = getGrafanaRule({ + rulerRule: mockGrafanaRulerRule({ is_paused: true }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.resume.find()); + + await waitFor(() => { + expect(mockPauseExecute).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(onPauseChange).toHaveBeenCalledTimes(1); + }); + expect(onPauseChange).toHaveBeenCalledWith(); + }); + }); + }); + + describe('Feature Flags', () => { + describe('alertingEnrichmentPerRule', () => { + describe('when feature flag is disabled', () => { + testWithFeatureToggles({ + disable: ['alertingEnrichmentPerRule'], + }); + + it('hides Manage enrichments when feature flag is disabled', async () => { + grantUserPermissions([AccessControlAction.AlertingEnrichmentsRead]); + + const handleManageEnrichments = jest.fn(); + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.manageEnrichments.query()).not.toBeInTheDocument(); + }); + }); + + describe('when feature flag is enabled', () => { + testWithFeatureToggles({ + enable: ['alertEnrichment', 'alertingEnrichmentPerRule'], + }); + + it('shows Manage enrichments when feature flag is enabled and all conditions are met', async () => { + // Both toggles need to be enabled: alertEnrichment for the hook, alertingEnrichmentPerRule for the component + grantUserPermissions([AccessControlAction.AlertingEnrichmentsRead]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const handleManageEnrichments = jest.fn(); + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.manageEnrichments.find()).toBeInTheDocument(); + }); + }); + + describe('when feature flags are enabled but permission is missing', () => { + testWithFeatureToggles({ + enable: ['alertEnrichment', 'alertingEnrichmentPerRule'], + }); + + it('hides Manage enrichments when enrichment ability is not allowed', async () => { + // Enable both toggles to ensure we're testing the permission check, not the toggles + grantUserPermissions([]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const handleManageEnrichments = jest.fn(); + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.manageEnrichments.query()).not.toBeInTheDocument(); + }); + }); + }); + + describe('Open-source vs Enterprise', () => { + it('hides Declare Incident in open-source edition for firing alerting rules', async () => { + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Firing }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.declareIncident.query()).not.toBeInTheDocument(); + }); + + it('shows Declare Incident in enterprise edition for firing alerting rules', async () => { + config.buildInfo.edition = GrafanaEdition.Enterprise; + config.buildInfo.env = 'production'; + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Firing }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.declareIncident.find()).toBeInTheDocument(); + }); + + it('shows Declare Incident in dev mode even for open-source edition', async () => { + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'development'; + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Firing }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.declareIncident.find()).toBeInTheDocument(); + }); + + it('hides Declare Incident for non-firing alerting rules in enterprise', async () => { + config.buildInfo.edition = GrafanaEdition.Enterprise; + config.buildInfo.env = 'production'; + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.declareIncident.query()).not.toBeInTheDocument(); + }); + + it('hides Declare Incident for recording rules', async () => { + config.buildInfo.edition = GrafanaEdition.Enterprise; + config.buildInfo.env = 'production'; + + const mockRule = getGrafanaRule({ + promRule: mockPromRecordingRule({ type: PromRuleType.Recording }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.declareIncident.query()).not.toBeInTheDocument(); + }); + }); + + describe('Assistant availability', () => { + beforeEach(() => { + // Reset config to ensure clean state for assistant tests + config.buildInfo = { ...originalBuildInfo }; + config.buildInfo.env = 'production'; + config.buildInfo.edition = GrafanaEdition.OpenSource; + // Reset assistant mock to default (unavailable) for each test + mockUseAssistant.mockReturnValue({ + isAvailable: false, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + }); + + it('shows Analyze Rule when assistant is available for Grafana-managed rules', async () => { + // Override mock to return available + mockUseAssistant.mockReturnValue({ + isAvailable: true, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + + // Create a Grafana rule with promRule that has uid and folderUid + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ + uid: 'test-rule-uid', + folderUid: 'test-folder-uid', + }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.analyzeRule.find()).toBeInTheDocument(); + }); + + it('hides Analyze Rule when assistant is unavailable', async () => { + // Mock already set to unavailable in beforeEach, but be explicit + mockUseAssistant.mockReturnValue({ + isAvailable: false, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.analyzeRule.query()).not.toBeInTheDocument(); + }); + + it('hides Analyze Rule for datasource-managed rules even when assistant is available', async () => { + mockUseAssistant.mockReturnValue({ + isAvailable: true, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + + const datasource = mockDataSource({ uid: 'mimir', name: 'Mimir' }); + const mockRule = getCloudRule({}, { rulesSource: datasource }); + const identifier = fromCombinedRule(datasource.name, mockRule); + const groupIdentifier = { + groupOrigin: 'datasource' as const, + rulesSource: { uid: datasource.uid, name: datasource.name, ruleSourceType: 'datasource' as const }, + namespace: { name: 'namespace-name' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.analyzeRule.query()).not.toBeInTheDocument(); + }); + }); + }); + + describe('Edge Cases', () => { + describe('Missing Data', () => { + describe('with enrichment feature flags enabled', () => { + testWithFeatureToggles({ + enable: ['alertEnrichment', 'alertingEnrichmentPerRule'], + }); + + it('hides Manage enrichments when ruleUid is missing even if all other conditions are met', async () => { + grantUserPermissions([AccessControlAction.AlertingEnrichmentsRead]); + // Note: Cloud rules typically don't have a ruleUid, so this tests that case + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const handleManageEnrichments = jest.fn(); + const datasource = mockDataSource({ uid: 'prometheus', name: 'Prometheus' }); + const mockRule = getCloudRule({}, { rulesSource: datasource }); + const identifier = fromCombinedRule(datasource.name, mockRule); + const groupIdentifier = { + groupOrigin: 'datasource' as const, + rulesSource: { uid: datasource.uid, name: datasource.name, ruleSourceType: 'datasource' as const }, + namespace: { name: 'namespace-name' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.manageEnrichments.query()).not.toBeInTheDocument(); + }); + }); + + it('hides Pause option when ruleUid is missing even if pause permission is granted', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleUpdate]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleUpdate]: true }); + + const datasource = mockDataSource({ uid: 'prometheus', name: 'Prometheus' }); + const mockRule = getCloudRule({}, { rulesSource: datasource }); + const identifier = fromCombinedRule(datasource.name, mockRule); + const groupIdentifier = { + groupOrigin: 'datasource' as const, + rulesSource: { uid: datasource.uid, name: datasource.name, ruleSourceType: 'datasource' as const }, + namespace: { name: 'namespace-name' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.pause.query()).not.toBeInTheDocument(); + }); + + it('pause still works when onPauseChange is not provided', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleUpdate]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleUpdate]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleUpdate]: true }, + }) + ); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.pause.find()); + + // Pause should still execute even without callback + await waitFor(() => { + expect(mockPauseExecute).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('Empty States', () => { + it('shows minimal menu with only Copy Link when no permissions are granted', async () => { + // Use a non-firing rule to avoid Declare Incident showing + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + render( + + ); + + await openMenu(); + + expect(await ui.menuItems.copyLink.find()).toBeInTheDocument(); + expect(ui.menuItems.pause.query()).not.toBeInTheDocument(); + expect(ui.menuItems.silence.query()).not.toBeInTheDocument(); + expect(ui.menuItems.duplicate.query()).not.toBeInTheDocument(); + expect(ui.menuItems.export.query()).not.toBeInTheDocument(); + expect(ui.menuItems.delete.query()).not.toBeInTheDocument(); + expect(ui.menuItems.manageEnrichments.query()).not.toBeInTheDocument(); + expect(ui.menuItems.declareIncident.query()).not.toBeInTheDocument(); + expect(ui.menuItems.analyzeRule.query()).not.toBeInTheDocument(); + }); + + it('menu still opens even when no applicable items are available', async () => { + // All abilities denied and no shareUrl + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + // Mock createShareLink to return undefined + const createShareLinkSpy = jest.spyOn(miscUtils, 'createShareLink'); + createShareLinkSpy.mockReturnValue(undefined); + + render( + + ); + + await openMenu(); + + expect(ui.menu.query()).toBeInTheDocument(); + const menuItems = screen.queryAllByRole('menuitem'); + const linkItems = screen.queryAllByRole('link'); + expect(menuItems.length).toBe(0); + expect(linkItems.length).toBe(0); + + createShareLinkSpy.mockRestore(); + }); + }); + + describe('Error Handling', () => { + it('handles gracefully when clipboard API is unavailable', async () => { + const originalClipboard = navigator.clipboard; + // Mock clipboard as undefined to simulate unavailable API + Object.defineProperty(navigator, 'clipboard', { + value: undefined, + writable: true, + configurable: true, + }); + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + render( + + ); + + await openMenu(); + const copyLinkItem = await ui.menuItems.copyLink.find(); + + await expect(user.click(copyLinkItem)).resolves.not.toThrow(); + + // Restore clipboard + Object.defineProperty(navigator, 'clipboard', { + value: originalClipboard, + writable: true, + configurable: true, + }); + }); + + it('handles gracefully when shareUrl is undefined', async () => { + // Use a non-firing rule to avoid Declare Incident showing + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + // Mock createShareLink to return undefined + const createShareLinkSpy = jest.spyOn(require('app/features/alerting/unified/utils/misc'), 'createShareLink'); + createShareLinkSpy.mockReturnValue(undefined); + + render( + + ); + + await openMenu(); + + expect(ui.menuItems.copyLink.query()).not.toBeInTheDocument(); + expect(ui.menu.query()).toBeInTheDocument(); + + createShareLinkSpy.mockRestore(); + }); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx index a90e9dc52a8..cbc5563538f 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx @@ -60,4 +60,76 @@ describe('LogRecordViewerByTimestamp', () => { expect(within(errorRows[1]).getByText(/Error message:/)).toBeInTheDocument(); expect(within(errorRows[1]).getByText(/explicit message/)).toBeInTheDocument(); }); + + describe('Numeric Value Formatting', () => { + it('should format numeric values correctly in AlertInstanceValues', () => { + const records: LogRecord[] = [ + { + timestamp: 1681739580000, + line: { + current: 'Alerting', + previous: 'Pending', + labels: {}, + values: { + cpu_usage: 42.987654321, + memory_mb: 1234567.89, + disk_io: 0.001234, + request_count: 10000, + }, + }, + }, + ]; + + render(); + + expect(screen.getByText(/cpu_usage/)).toBeInTheDocument(); + expect(screen.getByText(/4\.299e\+1/i)).toBeInTheDocument(); + + expect(screen.getByText(/memory_mb/)).toBeInTheDocument(); + expect(screen.getByText(/1\.235e\+6/i)).toBeInTheDocument(); + + expect(screen.getByText(/disk_io/)).toBeInTheDocument(); + expect(screen.getByText(/1\.234e-3/i)).toBeInTheDocument(); + + expect(screen.getByText(/request_count/)).toBeInTheDocument(); + expect(screen.getByText(/10000/)).toBeInTheDocument(); + }); + + it('should format various numeric ranges correctly', () => { + const records: LogRecord[] = [ + { + timestamp: 1681739580000, + line: { + current: 'Alerting', + previous: 'Pending', + labels: {}, + values: { + small: 0.001, + normal: 42.5, + large: 123456, + boundary_low: 0.01, + boundary_high: 10000, + }, + }, + }, + ]; + + render(); + + expect(screen.getByText(/small/)).toBeInTheDocument(); + expect(screen.getByText(/1\.000e-3/i)).toBeInTheDocument(); + + expect(screen.getByText(/normal/)).toBeInTheDocument(); + expect(screen.getByText(/42\.5/)).toBeInTheDocument(); + + expect(screen.getByText(/large/)).toBeInTheDocument(); + expect(screen.getByText(/1\.235e\+5/i)).toBeInTheDocument(); + + expect(screen.getByText(/boundary_low/)).toBeInTheDocument(); + expect(screen.getByText(/0\.01/)).toBeInTheDocument(); + + expect(screen.getByText(/boundary_high/)).toBeInTheDocument(); + expect(screen.getByText(/10000/)).toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx index c1d90347c74..06fcde4a1ae 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx @@ -13,6 +13,7 @@ import { AlertStateTag } from '../AlertStateTag'; import { ErrorMessageRow } from './ErrorMessageRow'; import { LogRecord, omitLabels } from './common'; +import { formatNumericValue } from './numberFormatter'; type LogRecordViewerProps = { records: LogRecord[]; @@ -182,7 +183,7 @@ const AlertInstanceValues = memo(({ record }: { record: Record } return ( <> {values.map(([key, value]) => ( - + ))} ); diff --git a/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts new file mode 100644 index 00000000000..77dfe40df5a --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts @@ -0,0 +1,173 @@ +import { formatNumericValue } from './numberFormatter'; + +describe('formatNumericValue', () => { + describe('Zero and special values', () => { + it('should format zero correctly', () => { + expect(formatNumericValue(0)).toBe('0'); + expect(formatNumericValue(-0)).toBe('0'); + }); + + it('should handle NaN', () => { + expect(formatNumericValue(NaN)).toBe('NaN'); + }); + + it('should handle Infinity', () => { + expect(formatNumericValue(Infinity)).toBe('Infinity'); + expect(formatNumericValue(-Infinity)).toBe('-Infinity'); + }); + }); + + describe('Very small numbers (scientific notation)', () => { + it('should use scientific notation for values less than 1e-2', () => { + const result1 = formatNumericValue(1e-3); + expect(result1).toMatch(/^1\.000e-3$/i); + + const result2 = formatNumericValue(0.001); + expect(result2).toMatch(/^1\.000e-3$/i); + + const result3 = formatNumericValue(0.009); + expect(result3).toMatch(/^9\.000e-3$/i); + }); + + it('should use scientific notation for values just below 1e-2', () => { + const result = formatNumericValue(0.00999); + expect(result).toMatch(/^9\.990e-3$/i); + }); + + it('should format the example from requirements correctly', () => { + // 1.4153928131348452 has > 4 decimal places, so should use scientific notation + const result = formatNumericValue(1.4153928131348452); + expect(result).toMatch(/^1\.415e\+0$/i); + }); + + it('should handle negative very small numbers', () => { + const result = formatNumericValue(-1e-3); + expect(result).toMatch(/^-1\.000e-3$/i); + + const result2 = formatNumericValue(-0.001); + expect(result2).toMatch(/^-1\.000e-3$/i); + }); + }); + + describe('Human-readable range (standard notation)', () => { + it('should use standard notation for boundary value 1e-2', () => { + expect(formatNumericValue(0.01)).toBe('0.01'); + }); + + it('should use standard notation for values in readable range', () => { + expect(formatNumericValue(0.1)).toBe('0.1'); + expect(formatNumericValue(1)).toBe('1'); + expect(formatNumericValue(1.234)).toBe('1.234'); + expect(formatNumericValue(42.5)).toBe('42.5'); + }); + + it('should limit to 4 decimal places without rounding integer parts', () => { + expect(formatNumericValue(123.456)).toBe('123.456'); + expect(formatNumericValue(1234.567)).toBe('1234.567'); + expect(formatNumericValue(9999.9)).toBe('9999.9'); + expect(formatNumericValue(9999.1234)).toBe('9999.1234'); + }); + + it('should use scientific notation for numbers with more than 4 decimal places', () => { + // Numbers with > 4 decimals should use scientific notation even in readable range + const result1 = formatNumericValue(123.456789); + expect(result1).toMatch(/^1\.235e\+2$/i); + + const result2 = formatNumericValue(1.23456789); + expect(result2).toMatch(/^1\.235e\+0$/i); + + const result3 = formatNumericValue(42.987654321); + expect(result3).toMatch(/^4\.299e\+1$/i); + }); + + it('should use standard notation for boundary value 1e4', () => { + expect(formatNumericValue(10000)).toBe('10000'); + }); + + it('should handle negative numbers in readable range', () => { + expect(formatNumericValue(-0.1)).toBe('-0.1'); + expect(formatNumericValue(-123.456)).toBe('-123.456'); + expect(formatNumericValue(-9999.9)).toBe('-9999.9'); + }); + + it('should use scientific notation for negative numbers with excessive precision', () => { + const result = formatNumericValue(-42.987654321); + expect(result).toMatch(/^-4\.299e\+1$/i); + }); + }); + + describe('Very large numbers (scientific notation)', () => { + it('should use scientific notation for values greater than 1e4', () => { + const result1 = formatNumericValue(10001); + expect(result1).toMatch(/^1\.000e\+4$/i); + + const result2 = formatNumericValue(123456); + expect(result2).toMatch(/^1\.235e\+5$/i); + }); + + it('should handle negative very large numbers', () => { + const result = formatNumericValue(-1e5); + expect(result).toMatch(/^-1\.000e\+5$/i); + + const result2 = formatNumericValue(-123456); + expect(result2).toMatch(/^-1\.235e\+5$/i); + }); + }); + + describe('Edge cases', () => { + it('should handle numbers exactly at boundaries', () => { + expect(formatNumericValue(0.01)).toBe('0.01'); + + const justBelow = formatNumericValue(0.009999); + expect(justBelow).toMatch(/^9\.999e-3$/i); + + expect(formatNumericValue(10000)).toBe('10000'); + + const justAbove = formatNumericValue(10001); + expect(justAbove).toMatch(/^1\.000e\+4$/i); + }); + + it('should use scientific notation for very precise decimals with > 4 decimal places', () => { + expect(formatNumericValue(1.23456789)).toMatch(/^1\.235e\+0$/i); + expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i); + expect(formatNumericValue(0.123456789)).toMatch(/^1\.235e-1$/i); + }); + + it('should use standard notation for numbers with exactly 4 or fewer decimal places', () => { + expect(formatNumericValue(1.2345)).toBe('1.2345'); + expect(formatNumericValue(0.1234)).toBe('0.1234'); + expect(formatNumericValue(123.4567)).toBe('123.4567'); + }); + }); + + describe('countDecimalPlaces edge cases', () => { + it('should handle numbers that toString() would convert to scientific notation', () => { + const result = formatNumericValue(1e-10); + expect(result).toMatch(/^1\.000e-10$/i); + + const result2 = formatNumericValue(1e10); + expect(result2).toMatch(/^1\.000e\+10$/i); + }); + + it('should correctly count decimals for numbers with trailing zeros', () => { + expect(formatNumericValue(1.234)).toBe('1.234'); + expect(formatNumericValue(1.2)).toBe('1.2'); + expect(formatNumericValue(1.0)).toBe('1'); + }); + + it('should handle boundary values correctly', () => { + expect(formatNumericValue(0.01)).toBe('0.01'); + expect(formatNumericValue(10000)).toBe('10000'); + + expect(formatNumericValue(0.01001)).toMatch(/^1\.001e-2$/i); + expect(formatNumericValue(9999.1234)).toBe('9999.1234'); + expect(formatNumericValue(9999.12345)).toMatch(/^9\.999e\+3$/i); + }); + + it('should handle numbers in readable range that have many decimals', () => { + expect(formatNumericValue(1.4153928131348452)).toMatch(/^1\.415e\+0$/i); + expect(formatNumericValue(42.987654321)).toMatch(/^4\.299e\+1$/i); + expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts new file mode 100644 index 00000000000..8e518c2c932 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts @@ -0,0 +1,75 @@ +const SCIENTIFIC_NOTATION_THRESHOLD_SMALL = 1e-2; +const SCIENTIFIC_NOTATION_THRESHOLD_LARGE = 1e4; +const MAX_DECIMAL_PLACES = 4; +const EXPONENTIAL_DECIMALS = 3; // 4 significant digits = 1 digit + 3 decimals + +const readableRangeFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: MAX_DECIMAL_PLACES, + useGrouping: false, +}); + +/** + * Counts the number of decimal places in a number. + * Only processes numbers in readable range (1e-2 to 1e4) to avoid + * toString() scientific notation issues for very large/small numbers. + * + * Uses toFixed(10) to ensure standard notation representation. + * 10 decimal places is sufficient to detect if a number has > 4 decimal places. + */ +function countDecimalPlaces(value: number): number { + if (Number.isInteger(value)) { + return 0; + } + + const absValue = Math.abs(value); + + // Only count decimals for numbers in readable range + if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) { + return 0; + } + + const str = value.toFixed(10); + const decimalIndex = str.indexOf('.'); + + if (decimalIndex === -1) { + return 0; + } + + // Count decimal places, removing trailing zeros + const decimalPart = str.substring(decimalIndex + 1).replace(/0+$/, ''); + return decimalPart.length; +} + +/** + * Formats a numeric value for display in alert rule history. + * - For values in human-readable range (1e-2 to 1e4) with ≤ 4 decimal places: shows up to 4 decimal places + * - For very small values (< 1e-2): uses scientific notation with 4 significant digits + * - For very large values (> 1e4): uses scientific notation with 4 significant digits + * - For numbers with > 4 decimal places: uses scientific notation with 4 significant digits + * + * @param value - The number to format + * @returns A formatted string representation of the number + */ +export function formatNumericValue(value: number): string { + if (!Number.isFinite(value)) { + return String(value); + } + + if (value === 0) { + return '0'; + } + + const absValue = Math.abs(value); + + if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) { + return value.toExponential(EXPONENTIAL_DECIMALS); + } + + const decimalPlaces = countDecimalPlaces(value); + + if (decimalPlaces > MAX_DECIMAL_PLACES) { + return value.toExponential(EXPONENTIAL_DECIMALS); + } + + return readableRangeFormatter.format(value); +} diff --git a/public/app/features/browse-dashboards/components/BrowseView.tsx b/public/app/features/browse-dashboards/components/BrowseView.tsx index b28204f4fe2..0b42b79f9ae 100644 --- a/public/app/features/browse-dashboards/components/BrowseView.tsx +++ b/public/app/features/browse-dashboards/components/BrowseView.tsx @@ -43,10 +43,10 @@ export function BrowseView({ folderUID, width, height, permissions, isReadOnlyRe const selectedItems = useCheckboxSelectionState(); const childrenByParentUID = useChildrenByParentUIDState(); const canSelect = canSelectItems(permissions); - const isProvisionedInstance = useIsProvisionedInstance(); const provisioningEnabled = config.featureToggles.provisioning; const hasNoRole = contextSrv.user.orgRole === OrgRole.None; const { data: settingsData } = useGetFrontendSettingsQuery(!provisioningEnabled || hasNoRole ? skipToken : undefined); + const isProvisionedInstance = useIsProvisionedInstance({ settings: settingsData }); const rootItems = useSelector(rootItemsSelector); const [, stateManager] = useSearchStateManager(); diff --git a/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx b/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx index 7b2d58fa423..8109de32145 100644 --- a/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx +++ b/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx @@ -25,6 +25,7 @@ export function RecentlyViewedDashboards() { value: recentDashboards = [], loading, retry, + error, } = useAsyncRetry(async () => { if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) { return []; @@ -62,7 +63,18 @@ export function RecentlyViewedDashboards() { className={styles.title} contentClassName={styles.content} > - {/* placeholder */} + {error && ( + <> + + + Recently viewed dashboards couldn’t be loaded. + + + + + )} {loading && } {/* TODO: Better empty state https://github.com/grafana/grafana/issues/114804 */} {!loading && recentDashboards.length === 0 && ( 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/edit-pane/DashboardOutline.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx index 10bb180a4b1..b543b04537c 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx @@ -94,7 +94,11 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index } // eslint-disable-next-line @typescript-eslint/consistent-type-assertions style={{ '--depth': depth } as React.CSSProperties} > -
+
{isContainer && ( - + ); 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 85813a99f6d..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) { @@ -120,7 +126,7 @@ export class PanelEditor extends SceneObjectBase { dataObject.subscribeToState(async () => { const { data } = dataObject.state; if (hasData(data) && panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) { - const suggestions = await getAllSuggestions(data); + const { suggestions } = await getAllSuggestions(data); if (suggestions.length > 0) { const defaultFirstSuggestion = suggestions[0]; @@ -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 82a20dbcd52..91adc3660a8 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -59,6 +59,7 @@ import { gridItemToGridLayoutItemKind } from '../serialization/layoutSerializers import { getElement } from '../serialization/layoutSerializers/utils'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { gridItemToPanel } from '../serialization/transformSceneToSaveModel'; +import { JsonModelEditView } from '../settings/JsonModelEditView'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { DashboardEditView } from '../settings/utils'; import { historySrv } from '../settings/version-history/HistorySrv'; @@ -89,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'; @@ -198,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(), @@ -855,30 +855,54 @@ export class DashboardScene extends SceneObjectBase impleme return this.serializer.getSaveModel(this); } - // Get the dashboard in native K8s form (using the appropriate apiVersion) - getSaveResource(options: SaveDashboardAsOptions): ResourceForCreate { + // Helper method to build K8s resource structure + private buildResourceForCreate(spec: Dashboard | DashboardV2Spec, isNew: boolean): ResourceForCreate { const { meta } = this.state; - const spec = this.getSaveAsModel(options); - - const apiVersion = this.serializer instanceof V2DashboardSerializer ? 'v2beta1' : 'v1beta1'; // get from the dashboard? + const apiVersion = this.serializer instanceof V2DashboardSerializer ? 'v2beta1' : 'v1beta1'; return { apiVersion: `dashboard.grafana.app/${apiVersion}`, kind: 'Dashboard', metadata: { ...meta.k8s, - name: options.isNew ? undefined : (meta.uid ?? meta.k8s?.name), - generateName: options.isNew ? 'd' : undefined, + name: isNew ? undefined : (meta.uid ?? meta.k8s?.name), + generateName: isNew ? 'd' : undefined, }, spec, }; } + // Get the dashboard in native K8s form (using the appropriate apiVersion) + getSaveResource(options: SaveDashboardAsOptions): ResourceForCreate { + const spec = this.getSaveAsModel(options); + return this.buildResourceForCreate(spec, options.isNew ?? false); + } + + // Wrap a raw dashboard spec in K8s resource format + // Used by JSON model editor for Git sync dashboards + getSaveResourceFromSpec(rawSpec: Dashboard | DashboardV2Spec): ResourceForCreate { + return this.buildResourceForCreate(rawSpec, false); + } + + // Get raw JSON from JSON model editor if currently active + // Returns undefined if not in JSON editor mode or if JSON is invalid + getRawJsonFromEditor(): Dashboard | DashboardV2Spec | undefined { + if (this.state.editview instanceof JsonModelEditView) { + try { + return JSON.parse(this.state.editview.state.jsonText); + } catch { + return undefined; + } + } + return undefined; + } + getSaveAsModel(options: SaveDashboardAsOptions): Dashboard | DashboardV2Spec { return this.serializer.getSaveAsModel(this, options); } getDashboardChanges(saveTimeRange?: boolean, saveVariables?: boolean, saveRefresh?: boolean): DashboardChangeInfo { - return this.serializer.getDashboardChangesFromScene(this, { saveTimeRange, saveVariables, saveRefresh }); + const rawJson = this.getRawJsonFromEditor(); + return this.serializer.getDashboardChangesFromScene(this, { saveTimeRange, saveVariables, saveRefresh, rawJson }); } getManagerKind(): ManagerKind | undefined { 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()} + > - )} - - - {shouldShowViewExplanation ? ( - - ) : ( - - )} - - - {}} // Noop - history is managed via onHistoryUpdate - onHistoryUpdate={handleHistoryUpdate} - queryContext={queryContext} - refIds={vars} - errorContext={errorContext} // Will be added when error tracking is implemented - // schemas={schemas} // Will be added when schema extraction is implemented - /> - - - {suggestions.length > 0 && ( - - - - )} -
- ); - - const renderSQLEditor = (width?: number, height?: number) => ( - <> -
- {renderSQLButtons()} -
( + + + {isSchemasFeatureEnabled && ( + + )} + + ); + + const renderMainContent = () => ( +
+
+ + {({ width, height }) => ( - {({ formatQuery }) => renderToolbox(formatQuery)} + {({ formatQuery }) => ( +
+ +
+ )}
-
- {isSchemaInspectorOpen && isSchemasFeatureEnabled && ( -
- setIsSchemaInspectorOpen(false)} - /> -
)} -
+
- - - - - - - + {isSchemaInspectorOpen && isSchemasFeatureEnabled && ( +
+ +
+ )} +
); - const renderStandaloneEditor = () => ( - - {({ width, height }) => ( - - {({ formatQuery }) => renderToolbox(formatQuery)} - - )} - + const renderSQLEditor = () => ( + + {renderButtons()} + {renderMainContent()} + ); return ( - <> - {renderSQLEditor()} - {isExpanded && ( - setIsExpanded(false)} - > - {renderStandaloneEditor()} - - )} - + +
+ {renderSQLEditor()} + + + + + + +
+
); }; -const getStyles = (theme: GrafanaTheme2, editorHeight: number) => ({ - sqlContainer: css({ - display: 'grid', - gap: theme.spacing(1), - gridTemplateRows: 'auto 1fr', - gridTemplateAreas: ` - "buttons" - "content" - `, +const getStyles = (theme: GrafanaTheme2) => ({ + mainContainer: css({ + marginTop: theme.spacing(0.5), }), - contentContainer: css({ - gridArea: 'content', + minHeight: '250px', + height: '100%', + resize: 'vertical', + overflow: 'hidden', + display: 'grid', - gap: theme.spacing(1), gridTemplateColumns: '1fr 0fr', gridTemplateAreas: '"editor schema"', [theme.transitions.handleMotion('no-preference')]: { @@ -408,67 +325,22 @@ const getStyles = (theme: GrafanaTheme2, editorHeight: number) => ({ }), contentContainerWithSchema: css({ gridTemplateColumns: '1fr 1fr', + gap: theme.spacing(1), }), editorContainer: css({ gridArea: 'editor', - height: editorHeight, // Use dynamic height from ResizeObserver - resize: 'vertical', - overflow: 'auto', - minHeight: '100px', - }), - modal: css({ - width: '95vw', - height: '95vh', - }), - modalContent: css({ height: '100%', - paddingTop: 0, - }), - // This is NOT ideal. The alternative is to expose SQL buttons as a separate component, - // Then consume them in ExpressionQueryEditor. This requires a lot of refactoring and - // can be prioritized later. - sqlButtons: css({ - gridArea: 'buttons', - justifySelf: 'end', - transform: `translateY(${theme.spacing(-4)})`, - marginBottom: theme.spacing(-4), // Prevent affecting editor position - zIndex: 10, // Ensure buttons appear above other elements - position: 'relative', // Required for z-index to work - display: 'flex', - alignItems: 'center', - gap: theme.spacing(1), + width: '100%', + overflow: 'auto', }), schemaInspector: css({ gridArea: 'schema', - height: editorHeight, + height: '100%', overflow: 'hidden', minWidth: 0, - }), - schemaInspectorOpen: css({ border: `1px solid ${theme.colors.border.weak}`, borderRadius: theme.shape.radius.default, }), - schemaFields: css({ - display: 'flex', - flexWrap: 'wrap', - gap: theme.spacing(1), - padding: theme.spacing(1), - maxHeight: '120px', - overflowY: 'auto', - }), - fieldItem: css({ - display: 'flex', - alignItems: 'center', - gap: theme.spacing(0.5), - padding: theme.spacing(1), - backgroundColor: theme.colors.background.secondary, - borderRadius: theme.shape.radius.default, - border: `1px solid ${theme.colors.border.weak}`, - fontSize: theme.typography.bodySmall.fontSize, - }), - responseContainer: css({ - padding: theme.spacing(2), - }), }); async function fetchFields(identifier: TableIdentifier, queries: DataQuery[]) { diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx new file mode 100644 index 00000000000..f95128b5af6 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from 'test/test-utils'; + +import { SqlExprContextValue, SqlExprProvider, useSqlExprContext } from './SqlExprContext'; + +describe('SqlExprContext', () => { + const mockContextValue: SqlExprContextValue = { + explanation: 'Test explanation', + isExplanationOpen: false, + shouldShowViewExplanation: false, + handleExplain: jest.fn(), + handleOpenExplanation: jest.fn(), + handleCloseExplanation: jest.fn(), + suggestions: ['suggestion1', 'suggestion2'], + isDrawerOpen: false, + handleHistoryUpdate: jest.fn(), + handleApplySuggestion: jest.fn(), + handleOpenDrawer: jest.fn(), + handleCloseDrawer: jest.fn(), + }; + + describe('SqlExprProvider', () => { + it('renders children correctly', () => { + render( + +
Test Child
+
+ ); + + expect(screen.getByText('Test Child')).toBeInTheDocument(); + }); + + it('provides context value to children', () => { + const TestConsumer = () => { + const context = useSqlExprContext(); + return
{context.explanation}
; + }; + + render( + + + + ); + + expect(screen.getByText('Test explanation')).toBeInTheDocument(); + }); + }); + + describe('useSqlExprContext', () => { + it('throws error when used outside provider', () => { + const TestComponent = () => { + useSqlExprContext(); + return
Should not render
; + }; + + // Suppress console.error for this test + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + render(); + }).toThrow('useSqlExprContext must be used within SqlExprProvider'); + + consoleSpy.mockRestore(); + }); + + it('returns context value when used inside provider', () => { + const TestComponent = () => { + const context = useSqlExprContext(); + return ( +
+ Explanation: {context.explanation} + Suggestions: {context.suggestions.length} + Is Drawer Open: {context.isDrawerOpen.toString()} +
+ ); + }; + + render( + + + + ); + + expect(screen.getByText('Explanation: Test explanation')).toBeInTheDocument(); + expect(screen.getByText('Suggestions: 2')).toBeInTheDocument(); + expect(screen.getByText('Is Drawer Open: false')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx new file mode 100644 index 00000000000..e05ccff80b6 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx @@ -0,0 +1,38 @@ +import { createContext, useContext, ReactNode } from 'react'; + +export interface SqlExprContextValue { + // Explanations + explanation: string; + isExplanationOpen: boolean; + shouldShowViewExplanation: boolean; + handleExplain: (explanation: string) => void; + handleOpenExplanation: () => void; + handleCloseExplanation: () => void; + + // Suggestions + suggestions: string[]; + isDrawerOpen: boolean; + handleHistoryUpdate: (suggestions: string[]) => void; + handleApplySuggestion: (suggestion: string) => string; + handleOpenDrawer: () => void; + handleCloseDrawer: () => void; +} + +const SqlExprContext = createContext(null); + +export const useSqlExprContext = () => { + const context = useContext(SqlExprContext); + if (!context) { + throw new Error('useSqlExprContext must be used within SqlExprProvider'); + } + return context; +}; + +interface SqlExprProviderProps { + children: ReactNode; + value: SqlExprContextValue; +} + +export const SqlExprProvider = ({ children, value }: SqlExprProviderProps) => { + return {children}; +}; diff --git a/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx new file mode 100644 index 00000000000..93e3285ed83 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, render, waitFor } from 'test/test-utils'; + +import { SqlExprContextValue } from './SqlExprContext'; +import { SqlQueryActions, SqlQueryActionsProps } from './SqlQueryActions'; + +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + useStyles2: jest.fn().mockImplementation(() => ({})), +})); + +// Mock lazy loaded GenAI components +jest.mock('./GenAI/GenAISQLSuggestionsButton', () => ({ + GenAISQLSuggestionsButton: ({ currentQuery, initialQuery }: { currentQuery: string; initialQuery: string }) => { + const text = !currentQuery || currentQuery === initialQuery ? 'Generate suggestion' : 'Improve query'; + return
{text}
; + }, +})); + +jest.mock('./GenAI/GenAISQLExplainButton', () => ({ + GenAISQLExplainButton: () =>
Explain query
, +})); + +jest.mock('./GenAI/SuggestionsDrawerButton', () => ({ + SuggestionsDrawerButton: () =>
Suggestions Badge
, +})); + +// Mock SqlExprContext +const mockContextValue: SqlExprContextValue = { + handleOpenExplanation: jest.fn(), + shouldShowViewExplanation: false, + handleExplain: jest.fn(), + handleHistoryUpdate: jest.fn(), + handleOpenDrawer: jest.fn(), + suggestions: [], + explanation: '', + isExplanationOpen: false, + isDrawerOpen: false, + handleApplySuggestion: jest.fn(), + handleCloseDrawer: jest.fn(), + handleCloseExplanation: jest.fn(), +}; + +jest.mock('./SqlExprContext', () => ({ + useSqlExprContext: () => mockContextValue, + SqlExprProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +describe('SqlQueryActions', () => { + const defaultProps: SqlQueryActionsProps = { + executeQuery: jest.fn(), + currentQuery: `SELECT * FROM A LIMIT 10`, + queryContext: {}, + refIds: ['A'], + initialQuery: `SELECT * FROM A LIMIT 10`, + errorContext: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset mock context to default values + Object.assign(mockContextValue, { + handleOpenExplanation: jest.fn(), + shouldShowViewExplanation: false, + handleExplain: jest.fn(), + handleHistoryUpdate: jest.fn(), + handleOpenDrawer: jest.fn(), + suggestions: [], + explanation: '', + isExplanationOpen: false, + isDrawerOpen: false, + handleApplySuggestion: jest.fn(), + handleCloseDrawer: jest.fn(), + handleCloseExplanation: jest.fn(), + }); + }); + + it('renders GenAI buttons with empty expression', async () => { + const customProps = { ...defaultProps, currentQuery: '' }; + const { findByText } = render(); + expect(await findByText('Generate suggestion')).toBeInTheDocument(); + expect(await findByText('Explain query')).toBeInTheDocument(); + }); + + it('renders GenAI buttons with non-empty expression', async () => { + const { findByText } = render(); + expect(await findByText('Generate suggestion')).toBeInTheDocument(); + expect(await findByText('Explain query')).toBeInTheDocument(); + }); + + it('renders "Improve query" when currentQuery differs from initialQuery', async () => { + const customProps = { + ...defaultProps, + currentQuery: 'SELECT * FROM A WHERE value > 10', + }; + const { findByText } = render(); + expect(await findByText('Improve query')).toBeInTheDocument(); + }); + + it('renders View explanation button when shouldShowViewExplanation is true', async () => { + mockContextValue.shouldShowViewExplanation = true; + + const { findByText } = render(); + expect(await findByText('View explanation')).toBeInTheDocument(); + }); + + it('renders Explain query button when shouldShowViewExplanation is false', async () => { + mockContextValue.shouldShowViewExplanation = false; + + const { findByText } = render(); + expect(await findByText('Explain query')).toBeInTheDocument(); + }); + + it('renders SuggestionsDrawerButton when there are suggestions', async () => { + mockContextValue.suggestions = ['suggestion1', 'suggestion2']; + + const { findByTestId } = render(); + expect(await findByTestId('suggestions-badge')).toBeInTheDocument(); + }); + + it('does not render SuggestionsDrawerButton when there are no suggestions', async () => { + mockContextValue.suggestions = []; + + const { queryByTestId } = render(); + expect(await waitFor(() => queryByTestId('suggestions-badge'))).not.toBeInTheDocument(); + }); + + it('calls handleOpenExplanation when View explanation is clicked', async () => { + const mockHandleOpen = jest.fn(); + mockContextValue.shouldShowViewExplanation = true; + mockContextValue.handleOpenExplanation = mockHandleOpen; + + const { findByText } = render(); + const button = await findByText('View explanation'); + fireEvent.click(button); + expect(mockHandleOpen).toHaveBeenCalled(); + }); +}); diff --git a/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx new file mode 100644 index 00000000000..a96a82a4e72 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx @@ -0,0 +1,91 @@ +import { lazy, Suspense } from 'react'; + +import { t, Trans } from '@grafana/i18n'; +import { Button, Stack } from '@grafana/ui'; + +import { useSqlExprContext } from './SqlExprContext'; + +// Lazy load the GenAI components to avoid circular dependencies +const GenAISQLSuggestionsButton = lazy(() => + import('./GenAI/GenAISQLSuggestionsButton').then((module) => ({ + default: module.GenAISQLSuggestionsButton, + })) +); + +const GenAISQLExplainButton = lazy(() => + import('./GenAI/GenAISQLExplainButton').then((module) => ({ + default: module.GenAISQLExplainButton, + })) +); + +const SuggestionsDrawerButton = lazy(() => + import('./GenAI/SuggestionsDrawerButton').then((module) => ({ + default: module.SuggestionsDrawerButton, + })) +); + +export interface SqlQueryActionsProps { + executeQuery: () => void; + currentQuery: string; + queryContext: Record; + refIds: string[]; + initialQuery: string; + errorContext: string[]; +} + +export const SqlQueryActions = ({ + executeQuery, + currentQuery, + queryContext, + refIds, + initialQuery, + errorContext, +}: SqlQueryActionsProps) => { + const { + handleOpenExplanation, + shouldShowViewExplanation, + handleExplain, + handleHistoryUpdate, + handleOpenDrawer, + suggestions, + } = useSqlExprContext(); + return ( + + + + {shouldShowViewExplanation ? ( + + ) : ( + + )} + + + {}} // Noop - history is managed via onHistoryUpdate + onHistoryUpdate={handleHistoryUpdate} + queryContext={queryContext} + refIds={refIds} + errorContext={errorContext} // Will be added when error tracking is implemented + // schemas={schemas} // Will be added when schema extraction is implemented + /> + + {suggestions.length > 0 && ( + + + + )} + + ); +}; diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx index 7248dbcc1e5..632c143fe3a 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx @@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'; import { cloneDeep } from 'lodash'; import { CSSProperties, HTMLAttributes, ReactNode } from 'react'; -import { colorManipulator, GrafanaTheme2, PanelData, PanelPluginVisualizationSuggestion } from '@grafana/data'; +import { GrafanaTheme2, PanelData, PanelPluginVisualizationSuggestion } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; import { Tooltip, useStyles2 } from '@grafana/ui'; @@ -31,7 +31,11 @@ export function VisualizationSuggestionCard({ const commonButtonProps = { 'aria-label': suggestion.name, - className: cx(className, styles.vizBox), + className: cx( + className, + styles.vizBox, + config.featureToggles.newVizSuggestions && isSelected && styles.selectedBox + ), 'data-testid': selectors.components.VisualizationPreview.card(suggestion.name), style: outerStyles, tabIndex: -1, // selection is handled by parent container @@ -56,7 +60,12 @@ export function VisualizationSuggestionCard({ content = ( ); @@ -82,22 +89,8 @@ export function VisualizationSuggestionCard({ const getStyles = (theme: GrafanaTheme2) => { return { - hoverPane: css({ - position: 'absolute', - top: -4, - left: -4, - right: -2, - bottom: -2, - borderRadius: theme.spacing(0.5), - background: 'transparent', - [theme.transitions.handleMotion('no-preference', 'reduce')]: { - transition: theme.transitions.create(['background'], { - duration: theme.transitions.duration.short, - }), - }, - }), - hoverPaneSelected: css({ - background: colorManipulator.alpha(theme.colors.text.primary, 0.1), + selectedSuggestion: css({ + filter: `blur(1px) ${theme.isDark ? 'brightness(0.5)' : 'opacity(0.3)'}`, }), vizBox: css({ position: 'relative', @@ -149,6 +142,10 @@ const getStyles = (theme: GrafanaTheme2) => { top: '6px', left: '6px', }), + selectedBox: css({ + border: `1px solid ${theme.colors.primary.border}`, + background: theme.colors.action.selected, + }), }; }; diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index e93e74f358d..924b5f3b6bf 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; -import { useAsync, useMeasure } from 'react-use'; +import { useAsyncRetry, useMeasure } from 'react-use'; import { GrafanaTheme2, @@ -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,24 +25,50 @@ 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; } -export function VisualizationSuggestions({ onChange, data, panel }: Props) { - const styles = useStyles2(getStyles); - const { - value: suggestions, - loading, - error, - } = useAsync(async () => { - if (!hasData(data)) { - return []; +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]); + + const filteredValue = useMemo(() => { + if (!value || !searchQuery) { + return value; } - return await getAllSuggestions(data); - }, [data]); + 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, editPreview, data, panel, searchQuery }: Props) { + const styles = useStyles2(getStyles); + + const { value: result, loading, error, retry } = useSuggestions(data, searchQuery); + + const suggestions = result?.suggestions; + const hasLoadingErrors = result?.hasErrors ?? false; const [suggestionHash, setSuggestionHash] = useState(null); const [firstCardRef, { width }] = useMeasure(); const [firstCardHash, setFirstCardHash] = useState(null); @@ -69,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(() => { @@ -131,80 +162,93 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) { } return ( -
- {isNewVizSuggestionsEnabled - ? suggestionsByVizType.map(([vizType, vizTypeSuggestions], groupIndex) => ( - -
- - {vizType?.info && } - {vizType?.name || t('panel.visualization-suggestions.unknown-viz-type', 'Unknown visualization type')} - -
- {vizTypeSuggestions?.map((suggestion, index) => { - const isCardSelected = suggestionHash === suggestion.hash; - return ( -
{ - if (ev.key === 'Enter' || ev.key === ' ') { - ev.preventDefault(); - applySuggestion(suggestion, isNewVizSuggestionsEnabled && !isCardSelected); - } - }} - ref={index === 0 ? firstCardRef : undefined} - > - {isCardSelected && ( - +
+ + )} +
+ {isNewVizSuggestionsEnabled + ? suggestionsByVizType.map(([vizType, vizTypeSuggestions], groupIndex) => ( + +
+ + {vizType?.info && } + {vizType?.name || + t('panel.visualization-suggestions.unknown-viz-type', 'Unknown visualization type')} + +
+ {vizTypeSuggestions?.map((suggestion, index) => { + const isCardSelected = suggestionHash === suggestion.hash; + return ( +
{ + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + applySuggestion(suggestion, isNewVizSuggestionsEnabled && !isCardSelected); } - > - {t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')} - - )} - applySuggestion(suggestion, true)} - /> -
- ); - })} -
- )) - : suggestions?.map((suggestion, index) => ( -
- applySuggestion(suggestion)} - /> -
- ))} -
+ }} + ref={index === 0 ? firstCardRef : undefined} + > + {isCardSelected && ( + + )} + applySuggestion(suggestion, true)} + /> +
+ ); + })} + + )) + : suggestions?.map((suggestion, index) => ( +
+ applySuggestion(suggestion)} + /> +
+ ))} +
+ ); } @@ -217,6 +261,11 @@ const getStyles = (theme: GrafanaTheme2) => { width: '100%', marginTop: theme.spacing(6), }), + alertContent: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }), filterRow: css({ display: 'flex', flexDirection: 'row', diff --git a/public/app/features/panel/suggestions/consts.ts b/public/app/features/panel/suggestions/consts.ts index 76e012a7425..decdf8ac7b4 100644 --- a/public/app/features/panel/suggestions/consts.ts +++ b/public/app/features/panel/suggestions/consts.ts @@ -16,4 +16,5 @@ export const panelsToCheckFirst = [ 'heatmap', 'histogram', 'geomap', + 'text', ]; diff --git a/public/app/features/panel/suggestions/getAllSuggestions.test.ts b/public/app/features/panel/suggestions/getAllSuggestions.test.ts index e76a7df15e3..8657824cc27 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.test.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.test.ts @@ -1,4 +1,5 @@ import { + AppEvents, DataFrame, FieldType, getDefaultTimeRange, @@ -18,10 +19,20 @@ import { StackingMode, VizOrientation, } from '@grafana/schema'; +import { appEvents } from 'app/core/app_events'; import { config } from 'app/core/config'; +import { clearPanelPluginCache } from 'app/features/plugins/importPanelPlugin'; +import { pluginImporter } from 'app/features/plugins/importer/pluginImporter'; import { panelsToCheckFirst } from './consts'; -import { getAllSuggestions, sortSuggestions } from './getAllSuggestions'; +import { getAllSuggestions, loadPlugins, sortSuggestions } from './getAllSuggestions'; + +jest.mock('app/core/app_events', () => ({ + appEvents: { + subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })), + publish: jest.fn(), + }, +})); config.featureToggles.externalVizSuggestions = true; @@ -52,28 +63,6 @@ for (const pluginId of panelsToCheckFirst) { }; } -config.panels.text = { - id: 'text', - module: 'core:plugin/text', - sort: idx++, - name: 'Text', - type: PluginType.panel, - baseUrl: 'public/app/plugins/panel', - skipDataQuery: true, - suggestions: false, - info: { - version: '1.0.0', - updated: '2025-01-01', - links: [], - screenshots: [], - author: { - name: 'Grafana Labs', - }, - description: 'Text panel', - logos: { small: 'small/logo', large: 'large/logo' }, - }, -}; - jest.mock('../state/util', () => { const originalModule = jest.requireActual('../state/util'); return { @@ -103,7 +92,8 @@ class ScenarioContext { timeRange: getDefaultTimeRange(), }; - this.suggestions = await getAllSuggestions(panelData); + const result = await getAllSuggestions(panelData); + this.suggestions = result.suggestions; } names() { @@ -554,6 +544,81 @@ describe('sortSuggestions', () => { }); }); +describe('Visualization suggestions error handling', () => { + it('returns result with hasErrors flag', async () => { + const result = await getAllSuggestions({ + series: [ + toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2] }, + { name: 'Max', type: FieldType.number, values: [1, 10] }, + ], + }), + ], + state: LoadingState.Done, + timeRange: getDefaultTimeRange(), + }); + + expect(result).toHaveProperty('suggestions'); + expect(result).toHaveProperty('hasErrors'); + expect(result.hasErrors).toBe(false); + }); +}); + +// this needs to happen before any +describe('loadPlugins', () => { + beforeEach(() => { + clearPanelPluginCache(); + }); + + afterEach(() => { + if (jest.isMockFunction(pluginImporter.importPanel)) { + jest.mocked(pluginImporter.importPanel).mockRestore(); + } + }); + + it('should swallow errors when failing to load core plugins', async () => { + jest.spyOn(console, 'error').mockImplementation(); + + const _importPanel = pluginImporter.importPanel; + jest.spyOn(pluginImporter, 'importPanel').mockImplementation(async (meta) => { + if (meta.id === 'timeseries') { + throw new Error('Failed to load core panel plugin'); + } + return await _importPanel(meta); + }); + + const panelIds = ['timeseries', 'table']; + const { plugins, hasErrors } = await loadPlugins(panelIds); + + expect(plugins).toEqual([expect.objectContaining({ meta: expect.objectContaining({ id: 'table' }) })]); + expect(hasErrors).toBe(true); + expect(appEvents.publish).not.toHaveBeenCalled(); + }); + + it('should swallow errors when failing to load external plugins', async () => { + jest.spyOn(console, 'error').mockImplementation(); + + const panelIds = ['non-existent-panel']; + const { plugins, hasErrors } = await loadPlugins(panelIds); + + expect(plugins).toEqual([]); + expect(hasErrors).toBe(false); + expect(appEvents.publish).toHaveBeenCalledWith({ + type: AppEvents.alertError.name, + payload: [expect.stringContaining('Failed to load panel plugin: non-existent-panel.')], + }); + }); + + it('should load panel plugins with suggestions', async () => { + const panelIds = ['timeseries', 'table']; + const { plugins, hasErrors } = await loadPlugins(panelIds); + + expect(plugins.map((p) => p.meta.id)).toEqual(expect.arrayContaining(['timeseries', 'table'])); + expect(hasErrors).toBe(false); + }); +}); + function repeatFrame(count: number, frame: DataFrame): DataFrame[] { const frames: DataFrame[] = []; for (let i = 0; i < count; i++) { diff --git a/public/app/features/panel/suggestions/getAllSuggestions.ts b/public/app/features/panel/suggestions/getAllSuggestions.ts index ea94add39ab..d0bb73dfb83 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.ts @@ -1,4 +1,5 @@ import { + AppEvents, getPanelDataSummary, PanelData, PanelDataSummary, @@ -7,41 +8,67 @@ import { PreferredVisualisationType, VisualizationSuggestionScore, } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; +import { appEvents } from 'app/core/app_events'; import { importPanelPlugin, isBuiltInPlugin } from 'app/features/plugins/importPanelPlugin'; import { getAllPanelPluginMeta } from '../state/util'; import { panelsToCheckFirst } from './consts'; -/** - * gather and cache the plugins which provide visualization suggestions so they can be invoked to build suggestions - */ -async function getPanelsWithSuggestions(): Promise { - // list of plugins to load is determined by the feature flag - const pluginIds: string[] = config.featureToggles.externalVizSuggestions +interface PluginLoadResult { + plugins: PanelPlugin[]; + hasErrors: boolean; +} + +function getPanelPluginIds(): string[] { + return config.featureToggles.externalVizSuggestions ? getAllPanelPluginMeta() .filter((panel) => panel.suggestions) .map((m) => m.id) : panelsToCheckFirst; +} +/** + * gather and cache the plugins which provide visualization suggestions so they can be invoked to build suggestions + */ +export async function loadPlugins(pluginIds: string[]): Promise { // import the plugins in parallel using Promise.allSettled const plugins: PanelPlugin[] = []; - const settledPromises = await Promise.allSettled(pluginIds.map((id) => importPanelPlugin(id))); + let hasErrors = false; + const settledPromises = await Promise.allSettled( + pluginIds.map(async (pluginId) => { + return await importPanelPlugin(pluginId); + }) + ); + for (let i = 0; i < settledPromises.length; i++) { const settled = settledPromises[i]; - if (settled.status === 'fulfilled') { plugins.push(settled.value); + } else { + const pluginId = pluginIds[i]; + console.error(`Failed to load ${pluginId} for visualization suggestions:`, settled.reason); + + if (isBuiltInPlugin(pluginId)) { + hasErrors = true; + } else { + appEvents.publish({ + type: AppEvents.alertError.name, + payload: [ + t( + 'panel.visualization-suggestions.error-loading-suggestions.plugin-failed', + 'Failed to load panel plugin: {{ pluginId }}.', + { pluginId } + ), + ], + }); + } } - // TODO: do we want to somehow log if there were errors loading some of the plugins? } - if (plugins.length === 0) { - throw new Error('No panel plugins with visualization suggestions found'); - } - - return plugins; + return { plugins, hasErrors }; } /** @@ -89,41 +116,37 @@ export function sortSuggestions(suggestions: PanelPluginVisualizationSuggestion[ }); } +export interface SuggestionsResult { + suggestions: PanelPluginVisualizationSuggestion[]; + hasErrors: boolean; +} + /** * given PanelData, return a sorted list of Suggestions from all plugins which support it. * @param {PanelData} data queried and transformed data for the panel - * @returns {PanelPluginVisualizationSuggestion[]} sorted list of suggestions + * @returns {SuggestionsResult} sorted list of suggestions and error status */ -export async function getAllSuggestions(data?: PanelData): Promise { +export async function getAllSuggestions(data?: PanelData): Promise { const dataSummary = getPanelDataSummary(data?.series); const list: PanelPluginVisualizationSuggestion[] = []; - for (const plugin of await getPanelsWithSuggestions()) { - const suggestions = plugin.getSuggestions(dataSummary); - if (suggestions) { - list.push(...suggestions); - } - } + const pluginIds: string[] = getPanelPluginIds(); + const { plugins, hasErrors: pluginLoadErrors } = await loadPlugins(pluginIds); - if (dataSummary.fieldCount === 0) { - for (const plugin of Object.values(config.panels)) { - if (!plugin.skipDataQuery || plugin.hideFromList) { - continue; + let pluginSuggestionsError = false; + for (const plugin of plugins) { + try { + const suggestions = plugin.getSuggestions(dataSummary); + if (suggestions) { + list.push(...suggestions); } - - list.push({ - name: plugin.name, - pluginId: plugin.id, - description: plugin.info.description, - hash: 'plugin-empty-' + plugin.id, - cardOptions: { - imgSrc: plugin.info.logos.small, - }, - }); + } catch (e) { + console.warn(`error when loading suggestions from plugin "${plugin.meta.id}"`, e); + pluginSuggestionsError = true; } } sortSuggestions(list, dataSummary); - return list; + return { suggestions: list, hasErrors: pluginLoadErrors || pluginSuggestionsError }; } diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts index e541b8f7893..ef8d955f562 100644 --- a/public/app/features/plugins/importPanelPlugin.ts +++ b/public/app/features/plugins/importPanelPlugin.ts @@ -62,3 +62,9 @@ export function syncGetPanelPlugin(id: string): PanelPlugin | undefined { function getPanelPlugin(meta: PanelPluginMeta): Promise { return pluginImporter.importPanel(meta); } + +export function clearPanelPluginCache(): void { + for (const key of Object.keys(promiseCache)) { + delete promiseCache[key]; + } +} diff --git a/public/app/features/provisioning/Config/ConfigForm.tsx b/public/app/features/provisioning/Config/ConfigForm.tsx index 9c86403fee6..3b1aebc1911 100644 --- a/public/app/features/provisioning/Config/ConfigForm.tsx +++ b/public/app/features/provisioning/Config/ConfigForm.tsx @@ -28,7 +28,6 @@ import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt'; import { DeleteRepositoryButton } from '../Repository/DeleteRepositoryButton'; import { TokenPermissionsInfo } from '../Shared/TokenPermissionsInfo'; import { getGitProviderFields, getLocalProviderFields } from '../Wizard/fields'; -import { InlineSecureValueWarning } from '../components/InlineSecureValueWarning'; import { PROVISIONING_URL } from '../constants'; import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository'; import { RepositoryFormData } from '../types'; @@ -178,7 +177,6 @@ export function ConfigForm({ data }: ConfigFormProps) { {gitFields && ( <> - } > - diff --git a/public/app/features/provisioning/Job/JobStatus.tsx b/public/app/features/provisioning/Job/JobStatus.tsx index 02e0b733bbb..7aec776f8aa 100644 --- a/public/app/features/provisioning/Job/JobStatus.tsx +++ b/public/app/features/provisioning/Job/JobStatus.tsx @@ -1,8 +1,11 @@ +import { useEffect } from 'react'; + import { Trans, t } from '@grafana/i18n'; import { Spinner, Stack, Text } from '@grafana/ui'; import { Job, useListJobQuery } from 'app/api/clients/provisioning/v0alpha1'; import { StepStatusInfo } from '../Wizard/types'; +import { getErrorMessage } from '../utils/httpUtils'; import { FinishedJobStatus } from './FinishedJobStatus'; import { JobContent } from './JobContent'; @@ -25,6 +28,18 @@ export function JobStatus({ jobType, watch, onStatusChange }: JobStatusProps) { const activeQueryCompleted = !activeQuery.isUninitialized && !activeQuery.isLoading; const shouldCheckFinishedJobs = activeQueryCompleted && !activeJob && !!repoLabel; + useEffect(() => { + if (activeQuery.isError) { + onStatusChange?.({ + status: 'error', + error: { + title: t('provisioning.job-status.title.error-fetching-active-job', 'Error fetching active job'), + message: getErrorMessage(activeQuery.error), + }, + }); + } + }, [activeQuery.isError, activeQuery.error, onStatusChange]); + if (activeQuery.isLoading) { return ( @@ -37,12 +52,6 @@ export function JobStatus({ jobType, watch, onStatusChange }: JobStatusProps) { } if (activeQuery.isError) { - onStatusChange?.({ - status: 'error', - error: { - title: t('provisioning.job-status.title.error-fetching-active-job', 'Error fetching active job'), - }, - }); return null; } diff --git a/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx b/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx index a8984eaa51f..e18e47500da 100644 --- a/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx +++ b/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx @@ -10,8 +10,8 @@ import { Page } from 'app/core/components/Page/Page'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { isNotFoundError } from 'app/features/alerting/unified/api/util'; -import { InlineSecureValueWarning } from '../components/InlineSecureValueWarning'; import { PROVISIONING_URL } from '../constants'; +import { getErrorMessage } from '../utils/httpUtils'; import { RepositoryActions } from './RepositoryActions'; import { RepositoryOverview } from './RepositoryOverview'; @@ -36,6 +36,7 @@ export default function RepositoryStatusPage() { const tab = queryParams['tab'] ?? TabSelection.Overview; const notFound = query.isError && isNotFoundError(query.error); + const hasError = query.isError && !notFound; const tabInfo = useMemo>( () => [ @@ -63,7 +64,11 @@ export default function RepositoryStatusPage() { actions={data && } > - + {hasError && ( + + {getErrorMessage(query.error)} + + )} {notFound ? ( 0; - - // Calculate final requiresMigration based on sync target and user selection - // For instance sync: always use baseRequiresMigration (checkbox is disabled and always true) + // Calculate requiresMigration based on sync target and user selection + // For instance sync: migrate if there are resources (checkbox is disabled and always true) // For folder sync: only migrate if user explicitly opts in via checkbox - const requiresMigration = useMemo(() => { - if (syncTarget === 'instance') { - return baseRequiresMigration; - } - if (syncTarget === 'folder') { - return migrateResources ?? false; - } - return baseRequiresMigration; - }, [syncTarget, baseRequiresMigration, migrateResources]); - + const requiresMigration = syncTarget === 'instance' ? resourceCount > 0 : (migrateResources ?? false); const shouldSkipSync = (resourceCount === 0 || syncTarget === 'folder') && fileCount === 0; // Format display strings diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx index bca9ca1939c..69a94c5e9ae 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx @@ -9,7 +9,7 @@ import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScen import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile'; -import { SaveProvisionedDashboardForm, Props } from './SaveProvisionedDashboardForm'; +import { Props, SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm'; jest.mock('@grafana/runtime', () => { const actual = jest.requireActual('@grafana/runtime'); @@ -118,6 +118,7 @@ function setup(props: Partial = {}) { closeModal: jest.fn(), getSaveAsModel: jest.fn().mockReturnValue(mockDashboard), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, drawer: { onClose: jest.fn(), @@ -291,6 +292,7 @@ describe('SaveProvisionedDashboardForm', () => { closeModal: jest.fn(), getSaveResource: jest.fn().mockReturnValue(updatedDashboard), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, }); @@ -385,6 +387,7 @@ describe('SaveProvisionedDashboardForm', () => { closeModal: jest.fn(), getSaveAsModel: jest.fn().mockReturnValue({}), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, }); @@ -431,6 +434,7 @@ describe('SaveProvisionedDashboardForm', () => { closeModal: jest.fn(), getSaveAsModel: jest.fn().mockReturnValue({}), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, }); @@ -445,4 +449,70 @@ describe('SaveProvisionedDashboardForm', () => { expect(saveButton).toBeEnabled(); }); }); + + it('should save dashboard with raw JSON from editor', async () => { + const mockAction = jest.fn(); + const mockRequest = { ...mockRequestBase, isSuccess: true }; + (useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]); + + const rawJson = JSON.stringify({ + title: 'Raw JSON Dashboard', + panels: [], + schemaVersion: 36, + }); + + const dashboardFromRawJson = { + apiVersion: 'dashboard.grafana.app/v1alpha1', + kind: 'Dashboard', + metadata: { + generateName: 'p', + name: undefined, + }, + spec: { + title: 'Raw JSON Dashboard', + panels: [], + schemaVersion: 36, + }, + }; + + const { user } = setup({ + dashboard: { + useState: () => ({ + meta: { + folderUid: 'folder-uid', + slug: 'test-dashboard', + }, + title: 'Test Dashboard', + description: 'Test Description', + isDirty: false, + }), + setState: jest.fn(), + closeModal: jest.fn(), + getSaveAsModel: jest.fn().mockReturnValue({}), + getSaveResource: jest.fn().mockReturnValue(dashboardFromRawJson), + getSaveResourceFromSpec: jest.fn().mockReturnValue(dashboardFromRawJson), + setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(rawJson), + } as unknown as DashboardScene, + }); + + const saveButton = screen.getByRole('button', { name: /save/i }); + expect(saveButton).toBeEnabled(); + + const commentInput = screen.getByRole('textbox', { name: /comment/i }); + await user.clear(commentInput); + await user.type(commentInput, 'Save with raw JSON'); + + await user.click(saveButton); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalledWith({ + ref: 'dashboard/2023-01-01-abcde', + name: 'test-repo', + path: 'test-dashboard.json', + message: 'Save with raw JSON', + body: dashboardFromRawJson, + }); + }); + }); }); diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx index a2df43a1f95..3fee476b189 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx @@ -65,8 +65,9 @@ export function SaveProvisionedDashboardForm({ register, formState: { dirtyFields }, } = methods; - // button enabled if form comment is dirty or dashboard state is dirty - const isDirtyState = Boolean(dirtyFields.comment) || isDirty; + // button enabled if form comment is dirty or dashboard state is dirty or raw JSON was provided from editor + const rawDashboardJSON = dashboard.getRawJsonFromEditor(); + const isDirtyState = Boolean(dirtyFields.comment) || isDirty || Boolean(rawDashboardJSON); const [workflow, ref, path] = watch(['workflow', 'ref', 'path']); // Update the form if default values change @@ -191,13 +192,15 @@ export function SaveProvisionedDashboardForm({ const message = comment || `Save dashboard: ${dashboard.state.title}`; - const body = dashboard.getSaveResource({ - isNew, - title, - description, - copyTags, - saveAsCopy, - }); + const body = rawDashboardJSON + ? dashboard.getSaveResourceFromSpec(rawDashboardJSON) + : dashboard.getSaveResource({ + isNew, + title, + description, + copyTags, + saveAsCopy, + }); reportInteraction('grafana_provisioning_dashboard_save_submitted', { workflow, diff --git a/public/app/features/provisioning/components/InlineSecureValueWarning.tsx b/public/app/features/provisioning/components/InlineSecureValueWarning.tsx deleted file mode 100644 index 9ed4a23b7b1..00000000000 --- a/public/app/features/provisioning/components/InlineSecureValueWarning.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { t } from '@grafana/i18n'; -import { Alert } from '@grafana/ui'; -import { Repository } from 'app/api/clients/provisioning/v0alpha1'; - -interface Props { - repo?: Repository; - items?: Repository[]; -} - -// TODO: remove this after 12.2 -export function InlineSecureValueWarning({ repo, items }: Props) { - const isRepoValid = (r?: Repository) => r?.spec?.type === 'local' || !!r?.secure?.token?.name; - - if (isRepoValid(repo)) { - return null; - } - - // When a list is passed in, show an error if anything is missing - if (items?.every(isRepoValid)) { - return null; - } - - return ( - - ); -} diff --git a/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts b/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts index d612be620dc..9dc8e2b51c1 100644 --- a/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts +++ b/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts @@ -34,9 +34,10 @@ export const useGetResourceRepositoryView = ({ const hasNoRole = contextSrv.user.orgRole === OrgRole.None; const provisioningEnabled = config.featureToggles.provisioning; - const { data: settingsData, isLoading: isSettingsLoading } = useGetFrontendSettingsQuery( - !provisioningEnabled || skipQuery || hasNoRole ? skipToken : undefined - ); + const shouldSkipSettings = !provisioningEnabled || skipQuery || hasNoRole || (!name && !folderName); + const settingsQueryArg = shouldSkipSettings ? skipToken : undefined; + + const { data: settingsData, isLoading: isSettingsLoading } = useGetFrontendSettingsQuery(settingsQueryArg); const skipFolderQuery = !folderName || !provisioningEnabled || skipQuery || hasNoRole; const { data: folder, isLoading: isFolderLoading } = useGetFolderQuery( diff --git a/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts b/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts index 1c4ba12b8e4..61fcbb73da6 100644 --- a/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts +++ b/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts @@ -5,13 +5,22 @@ import { config } from '@grafana/runtime'; import { RepositoryViewList, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1'; import { contextSrv } from 'app/core/services/context_srv'; -export function useIsProvisionedInstance(settings?: RepositoryViewList) { +interface UseIsProvisionedInstanceOptions { + settings?: RepositoryViewList; + skip?: boolean; +} + +export function useIsProvisionedInstance(options: UseIsProvisionedInstanceOptions = {}) { + const { settings, skip: skipQuery } = options; const hasNoRole = contextSrv.user.orgRole === OrgRole.None; - const skip = !config.featureToggles.provisioning || hasNoRole; + const skip = !config.featureToggles.provisioning || hasNoRole || skipQuery; const settingsQuery = useGetFrontendSettingsQuery(settings || skip ? skipToken : undefined); - if (!settings) { - settings = settingsQuery.data; + + if (settingsQuery.isError) { + return false; } - return settings?.items?.some((item) => item.target === 'instance'); + + const effectiveSettings = settings ?? settingsQuery.data; + return effectiveSettings?.items?.some((item) => item.target === 'instance'); } diff --git a/public/app/features/provisioning/utils/httpUtils.ts b/public/app/features/provisioning/utils/httpUtils.ts index 6e9cbc4e933..3fb6bd7704f 100644 --- a/public/app/features/provisioning/utils/httpUtils.ts +++ b/public/app/features/provisioning/utils/httpUtils.ts @@ -1,4 +1,5 @@ import { t } from '@grafana/i18n'; +import { isFetchError } from '@grafana/runtime'; import { HttpError, isHttpError } from '../guards'; @@ -187,6 +188,8 @@ export function getErrorMessage(err: unknown) { } else if (err.message) { errorMessage = err.message; } + } else if (isFetchError(err)) { + errorMessage = err.data.message; } return errorMessage; diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 8d1af58f9d9..146a54d295d 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -106,7 +106,7 @@ export class UnifiedSearcher implements GrafanaSearcher { async tags(query: SearchQuery): Promise { const qry = query.query ?? '*'; - let uri = `${searchURI}?facet=tags&query=${qry}&limit=1`; + let uri = `${searchURI}?facet=tags&facetLimit=1000&query=${qry}&limit=1`; const resp = await getBackendSrv().get(uri); return resp.facets?.tags?.terms || []; } 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/panel/radialbar/EffectsEditor.tsx b/public/app/plugins/panel/radialbar/EffectsEditor.tsx index d7a0f03cbc7..a3c26beca90 100644 --- a/public/app/plugins/panel/radialbar/EffectsEditor.tsx +++ b/public/app/plugins/panel/radialbar/EffectsEditor.tsx @@ -44,11 +44,6 @@ export function EffectsEditor(props: StandardEditorProps) { value={!!props.value?.gradient} onChange={(e) => props.onChange({ ...props.value, gradient: e.currentTarget.checked })} /> - props.onChange({ ...props.value, rounded: e.currentTarget.checked })} - /> ) { value={!!props.value?.centerGlow} onChange={(e) => props.onChange({ ...props.value, centerGlow: e.currentTarget.checked })} /> - props.onChange({ ...props.value, spotlight: e.currentTarget.checked })} - /> ); } diff --git a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx index 86235a3bf68..42c70511977 100644 --- a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx +++ b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx @@ -37,11 +37,10 @@ export function RadialBarPanel({ width={width} height={height} barWidthFactor={options.barWidthFactor} - gradient={options.effects?.gradient ? 'auto' : 'none'} - spotlight={options.effects?.spotlight} + gradient={options.effects?.gradient} glowBar={options.effects?.barGlow} glowCenter={options.effects?.centerGlow} - roundedBars={options.effects?.rounded} + roundedBars={options.barShape === 'rounded'} vizCount={valueProps.count} shape={options.shape} segmentCount={options.segmentCount} @@ -51,7 +50,9 @@ export function RadialBarPanel({ alignmentFactors={valueProps.alignmentFactors} valueManualFontSize={options.text?.valueSize} 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 12aff4966f7..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'; @@ -69,6 +69,52 @@ export const plugin = new PanelPlugin(RadialBarPanel) }, }); + builder.addRadio({ + path: 'barShape', + name: t('radialbar.config.bar-shape', 'Bar Style'), + category, + defaultValue: defaultOptions.barShape, + settings: { + options: [ + { value: 'flat', label: t('radialbar.config.bar-shape-flat', 'Flat') }, + { value: 'rounded', label: t('radialbar.config.bar-shape-rounded', 'Rounded') }, + ], + }, + showIf: (options) => options.segmentCount === 1, + }); + + builder.addRadio({ + path: 'endpointMarker', + name: t('radialbar.config.endpoint-marker', 'Endpoint marker'), + description: t('radialbar.config.endpoint-marker-description', 'Glow is only supported in dark mode'), + category, + defaultValue: defaultOptions.endpointMarker, + settings: { + options: [ + { value: 'point', label: t('radialbar.config.endpoint-marker-point', 'Point') }, + { value: 'glow', label: t('radialbar.config.endpoint-marker-glow', 'Glow') }, + { value: 'none', label: t('radialbar.config.endpoint-marker-none', 'None') }, + ], + }, + 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 a37982dcfe1..6e5fd7eec21 100644 --- a/public/app/plugins/panel/radialbar/panelcfg.cue +++ b/public/app/plugins/panel/radialbar/panelcfg.cue @@ -27,10 +27,8 @@ composableKinds: PanelCfg: { schema: { GaugePanelEffects: { barGlow?: bool | *false - spotlight?: bool | *false - rounded?: bool | *false centerGlow?: bool | *false - gradient?: bool | *true + gradient?: bool | *true } @cuetsy(kind="interface") Options: { @@ -42,6 +40,9 @@ composableKinds: PanelCfg: { sparkline?: bool | *true shape: "circle" | *"gauge" 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 24915c62ef1..594747136ef 100644 --- a/public/app/plugins/panel/radialbar/panelcfg.gen.ts +++ b/public/app/plugins/panel/radialbar/panelcfg.gen.ts @@ -14,36 +14,38 @@ export interface GaugePanelEffects { barGlow?: boolean; centerGlow?: boolean; gradient?: boolean; - rounded?: boolean; - spotlight?: boolean; } export const defaultGaugePanelEffects: Partial = { barGlow: false, centerGlow: false, gradient: true, - rounded: false, - spotlight: false, }; export interface Options extends common.SingleStatBaseOptions { + barShape: ('flat' | 'rounded'); barWidthFactor: number; effects: GaugePanelEffects; + endpointMarker?: ('point' | 'glow' | 'none'); segmentCount: number; segmentSpacing: number; shape: ('circle' | 'gauge'); showThresholdLabels: boolean; showThresholdMarkers: boolean; sparkline?: boolean; + textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none'); } export const defaultOptions: Partial = { + barShape: 'flat', barWidthFactor: 0.5, effects: {}, + endpointMarker: 'point', segmentCount: 1, segmentSpacing: 0.3, shape: 'gauge', showThresholdLabels: false, showThresholdMarkers: true, sparkline: true, + textMode: 'auto', }; diff --git a/public/app/plugins/panel/radialbar/suggestions.ts b/public/app/plugins/panel/radialbar/suggestions.ts index 00896ae8458..eab5334ef40 100644 --- a/public/app/plugins/panel/radialbar/suggestions.ts +++ b/public/app/plugins/panel/radialbar/suggestions.ts @@ -18,19 +18,6 @@ const withDefaults = ( } }, }, - // styles: [{ - // name: t('gauge.suggestions.style.circular', 'Glowing'), - // options: { - // effects: { - // rounded: true, - // barGlow: true, - // centerGlow: true, - // spotlight: true, - // }, - // }, - // }, { - // name: t('gauge.suggestions.style.simple', 'Simple'), - // }] } satisfies VisualizationSuggestion); const MAX_GAUGES = 10; diff --git a/public/app/plugins/panel/table/suggestions.ts b/public/app/plugins/panel/table/suggestions.ts index 8e0d6e44953..260e73b43eb 100644 --- a/public/app/plugins/panel/table/suggestions.ts +++ b/public/app/plugins/panel/table/suggestions.ts @@ -1,4 +1,5 @@ import { PanelDataSummary, VisualizationSuggestionScore, VisualizationSuggestionsSupplier } from '@grafana/data'; +import { config } from 'app/core/config'; import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg'; import { Options, FieldConfig } from './panelcfg.gen'; @@ -29,7 +30,7 @@ export const tableSuggestionsSupplier: VisualizationSuggestionsSupplier(TextPanel) defaultValue: defaultOptions.content, }); }) - .setMigrationHandler(textPanelMigrationHandler); + .setMigrationHandler(textPanelMigrationHandler) + .setSuggestionsSupplier((ds) => + ds.fieldCount === 0 && !config.featureToggles.newVizSuggestions + ? [{ cardOptions: { imgSrc: icnTextPanelSvg } }] + : [] + ); diff --git a/public/app/plugins/panel/text/plugin.json b/public/app/plugins/panel/text/plugin.json index ce437e97bf5..a1acce42c6e 100644 --- a/public/app/plugins/panel/text/plugin.json +++ b/public/app/plugins/panel/text/plugin.json @@ -2,7 +2,7 @@ "type": "panel", "name": "Text", "id": "text", - + "suggestions": true, "skipDataQuery": true, "info": { diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 575bba19ca1..39d34f617a9 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -729,6 +729,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Zadejte obsah vlastní vysvětlivky…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Pravidla byla úspěšně odstraněna ze složky" @@ -2219,11 +2224,15 @@ "min-interval": "Min. Interval = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Vybrané dotazy a výrazy nelze převést na výchozí. Pokud deaktivujete pokročilé možnosti, váš dotaz a podmínka budou obnoveny do výchozího nastavení." }, + "last15m": "", + "last1h": "", "preview": "Náhled", - "previewCondition": "Podmínka pravidla náhledu výstrahy" + "previewCondition": "Podmínka pravidla náhledu výstrahy", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrovat podle kontaktních bodů", @@ -2688,6 +2697,35 @@ "text-federated": "Federované", "text-provisioned": "Zajištěno" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Zdroj dat", @@ -3749,7 +3787,10 @@ "text": "Nebyly nalezeny žádné výsledky pro váš dotaz" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5968,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", @@ -6222,7 +6266,7 @@ "title-someone-else-has-updated-this-dashboard": "Tuto nástěnku aktualizoval jiný uživatel", "would-still-dashboard": "Chcete přesto tuto nástěnku uložit?" }, - "save-and-overwrite": "Uložit a přepsat" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7211,6 +7255,9 @@ "time-range-label": "Zamknout časový rozsah" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Pro tip: {{proTip}}" }, @@ -7691,39 +7738,6 @@ }, "share-span": "Sdílet" }, - "span-filters": { - "aria-label-select-max-span-operator": "Vyberte operátor max. rozsahu", - "aria-label-select-min-span-operator": "Vyberte operátor min. rozsahu", - "aria-label-select-service-name": "Vyberte název služby", - "aria-label-select-service-name-operator": "Vyberte operátor názvu služby", - "aria-label-select-span-name": "Vyberte název rozsahu", - "aria-label-select-span-name-operator": "Vyberte operátor názvu rozsahu", - "ariaLabel-select-max-span-duration": "Vyberte maximální dobu trvání", - "ariaLabel-select-min-span-duration": "Vyberte minimální dobu trvání rozsahu", - "label-collapse": "Filtry rozsahu", - "label-duration": "Doba trvání", - "label-service-name": "Název služby", - "label-span-name": "Název rozsahu", - "label-tags": "Tagy", - "placeholder-all-service-names": "Všechny názvy služby", - "placeholder-all-span-names": "Všechny názvy rozsahu", - "tooltip-collapse": "Filtrujte svá rozpětí níže. Filtry můžete používat tak dlouho, dokud nezúžíte výsledná rozpětí na několik vybraných, které vás nejvíce zajímají.", - "tooltip-duration": "Filtrovat podle doby trvání. Akceptované jednotky jsou {{units}}", - "tooltip-tags": "Filtrujte podle tagů, tagů procesů nebo polí protokolu ve vybraném rozpětí." - }, - "span-filters-tags": { - "aria-label-add-tag": "Přidat tag", - "aria-label-input-tag-value": "Vstupní hodnota tagu", - "aria-label-remove-tag": "Odebrat tag", - "aria-label-select-tag-key": "Vyberte klíč tagu", - "aria-label-select-tag-operator": "Vyberte operátor tagu", - "aria-label-select-tag-value": "Vyberte hodnotu tagu", - "placeholder-select-tag": "Vyberte tag", - "placeholder-select-value": "Vyberte hodnotu", - "placeholder-tag-value": "Hodnota tagu", - "tooltip-add-tag": "Přidat tag", - "tooltip-remove-tag": "Odebrat tag" - }, "span-flame-graph": { "flame-graph": "Graf plamene" }, @@ -7922,23 +7936,18 @@ "label-upsample": "Zvýšit vzorkovací frekvenci", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Spustit dotaz", - "modal-title": "Editor SQL", "tooltip-experimental": "Integrace LLM pro výrazy jazyka SQL je experimentální. Jakékoli problémy nahlaste týmu Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Vstup" @@ -8013,11 +8022,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9463,6 +9468,7 @@ "name-unit": "Jednotka", "name-value-name": "Název hodnoty", "name-y-axis-scale": "Měřítko osy Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9492,6 +9498,18 @@ "label-all": "Vše", "label-hidden": "Skryté", "label-single": "Jednorázový" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11295,9 +11313,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -12074,7 +12097,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Zobrazit podrobnosti", "loading-finished-job": "Načítání dokončené úlohy…", @@ -12180,6 +12206,9 @@ "webhook-last-event": "Poslední událost:", "webhook-url": "Zobrazit webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Zpět na úložiště", "cleaning-up-resources": "Čištění zdrojů úložiště", @@ -12284,6 +12313,9 @@ "tooltip-unhealthy-repository": "Nelze stáhnout nezdravé úložiště" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12303,10 +12335,15 @@ }, "warning-title-default": "Varování", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Doba trvání tohoto procesu závisí na počtu zapojených zdrojů.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12325,6 +12362,7 @@ "step-finish": "Vybrat další nastavení", "step-synchronize": "Synchronizovat s externím úložištěm", "sync-description": "Synchronizujte zdroje s externím úložištěm. Po tomto jednorázovém kroku budou všechny budoucí aktualizace automaticky uloženy do úložiště a zajištěny zpět do instance.", + "sync-option-migrate-resources": "", "title-bootstrap": "Vyberte, co chcete synchronizovat", "title-connect": "Připojit k externímu úložišti", "title-finish": "Vybrat další nastavení", @@ -12564,22 +12602,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 9df0dd8a2da..a2a5dcbb9b4 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Inhalt der benutzerdefinierten Anmerkung eingeben …" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Die Regeln wurden erfolgreich aus dem Ordner gelöscht" @@ -2203,11 +2208,15 @@ "min-interval": "Mind. Intervall = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Die ausgewählten Abfragen und Ausdrücke können nicht in die Standardeinstellung konvertiert werden. Wenn Sie die erweiterten Optionen deaktivieren, werden Ihre Abfrage und Bedingung auf die Standardeinstellungen zurückgesetzt." }, + "last15m": "", + "last1h": "", "preview": "Vorschau", - "previewCondition": "Vorschau der Warnregelbedingung" + "previewCondition": "Vorschau der Warnregelbedingung", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Nach Kontaktpunkten filtern", @@ -2666,6 +2675,35 @@ "text-federated": "Verbunden", "text-provisioned": "Bereitgestellt" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Datenquelle", @@ -3717,7 +3755,10 @@ "text": "Keine Ergebnisse für deine Abfrage gefunden" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5926,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", @@ -6180,7 +6224,7 @@ "title-someone-else-has-updated-this-dashboard": "Eine andere Person hat dieses Dashboard aktualisiert", "would-still-dashboard": "Möchten Sie dieses Dashboard trotzdem speichern?" }, - "save-and-overwrite": "'Speichern und überschreiben'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7209,9 @@ "time-range-label": "Zeitbereich sperren" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Profitipp: {{proTip}}" }, @@ -7641,39 +7688,6 @@ }, "share-span": "Teilen" }, - "span-filters": { - "aria-label-select-max-span-operator": "Höchstspannen-Operator auswählen", - "aria-label-select-min-span-operator": "Mindestspannen-Operator auswählen", - "aria-label-select-service-name": "Dienstname auswählen", - "aria-label-select-service-name-operator": "Dienstnamen-Operator auswählen", - "aria-label-select-span-name": "Spannen-Name auswählen", - "aria-label-select-span-name-operator": "Spannen-Namen-Operator auswählen", - "ariaLabel-select-max-span-duration": "Dauer der max. Spanne auswählen", - "ariaLabel-select-min-span-duration": "Dauer der min. Spanne auswählen", - "label-collapse": "Spannenfilter", - "label-duration": "Dauer", - "label-service-name": "Dienstname", - "label-span-name": "Spannen-Name", - "label-tags": "Tags", - "placeholder-all-service-names": "Alle Dienstnamen", - "placeholder-all-span-names": "Alle Spannen-Namen", - "tooltip-collapse": "Filtern Sie unten Ihre Spannen. Sie können weiterhin Filter anwenden, bis Sie Ihre resultierenden Spannen auf die wenigen eingegrenzt haben, die für Sie am meisten von Interesse sind.", - "tooltip-duration": "Nach Dauer filtern. Zulässige Einheiten sind {{units}}", - "tooltip-tags": "Filtern Sie in Ihren Spannen nach Tags, Prozess-Tags oder Log-Feldern." - }, - "span-filters-tags": { - "aria-label-add-tag": "Tag hinzufügen", - "aria-label-input-tag-value": "Tag-Wert eingeben", - "aria-label-remove-tag": "Tag entfernen", - "aria-label-select-tag-key": "Tag-Key auswählen", - "aria-label-select-tag-operator": "Tag-Operator auswählen", - "aria-label-select-tag-value": "Tag-Wert auswählen", - "placeholder-select-tag": "Tag auswählen", - "placeholder-select-value": "Wert auswählen", - "placeholder-tag-value": "Tag-Wert", - "tooltip-add-tag": "Tag hinzufügen", - "tooltip-remove-tag": "Tag entfernen" - }, "span-flame-graph": { "flame-graph": "Flammendiagramm" }, @@ -7872,23 +7886,18 @@ "label-upsample": "Upsample", "tooltip-s-m-h": "10 s, 1 min., 30 min., 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Abfrage ausführen", - "modal-title": "SQL Editor", "tooltip-experimental": "Die Integration des SQL Expressions LLM ist experimentell. Bitte melden Sie Probleme dem Grafana-Team." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Eingabe" @@ -7963,11 +7972,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9398,7 @@ "name-unit": "Einheit", "name-value-name": "Wertname", "name-y-axis-scale": "Y-Achsenskala", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9422,6 +9428,18 @@ "label-all": "Alles", "label-hidden": "Ausgeblendet", "label-single": "Einzeln" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11221,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11995,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Details anzeigen", "loading-finished-job": "Fertiger Auftrag wird geladen …", @@ -12078,6 +12104,9 @@ "webhook-last-event": "Letztes Ereignis:", "webhook-url": "Webhook anzeigen" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Zurück zu den Repositorys", "cleaning-up-resources": "Bereinigen von Repository-Ressourcen", @@ -12182,6 +12211,9 @@ "tooltip-unhealthy-repository": "Ein fehlerhaftes Repository kann nicht abgerufen werden" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12233,15 @@ }, "warning-title-default": "Warnung", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Die Dauer dieses Prozesses hängt von der Anzahl der betroffenen Ressourcen ab.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12260,7 @@ "step-finish": "Zusätzliche Einstellungen auswählen", "step-synchronize": "Mit externem Speicher synchronisieren", "sync-description": "Synchronisieren Sie Ressourcen mit externem Speicher. Nach diesem einmaligen Schritt werden alle zukünftigen Updates automatisch im Repository gespeichert und wieder in der Instanz bereitgestellt.", + "sync-option-migrate-resources": "", "title-bootstrap": "Wählen Sie aus, was synchronisiert wird", "title-connect": "Mit externem Speicher verbinden", "title-finish": "Zusätzliche Einstellungen auswählen", @@ -12460,22 +12498,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 7a1dd06e31f..10967a7331c 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "Enter a {{key}}...", "placeholder-value-input-default": "Enter custom annotation content..." }, + "backtest": { + "error-title": "Failed to run backtest", + "loading": "Running backtest...", + "panel-title": "Rule Retroactive Testing" + }, "bulk-actions": { "delete": { "success": "Rules successfully deleted from folder" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Interval = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "Custom", "disableAdvancedOptions": { "text": "The selected queries and expressions cannot be converted to default. If you deactivate advanced options, your query and condition will be reset to default settings." }, + "last15m": "Last 15 minutes", + "last1h": "Last 1 hour", "preview": "Preview", - "previewCondition": "Preview alert rule condition" + "previewCondition": "Preview alert rule condition", + "testRule": "Test Rule" }, "receiver-filter": { "aria-label-contact-points": "Filter by contact points", @@ -3748,6 +3757,8 @@ "recently-viewed": { "clear": "Clear history", "empty": "Nothing viewed yet", + "error": "Recently viewed dashboards couldn’t be loaded.", + "retry": "Retry", "title": "Recently viewed" }, "restore": { @@ -5956,6 +5967,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", @@ -6210,7 +6224,7 @@ "title-someone-else-has-updated-this-dashboard": "Someone else has updated this dashboard", "would-still-dashboard": "Would you still like to save this dashboard?" }, - "save-and-overwrite": "'Save and overwrite'" + "save-and-overwrite": "Save and overwrite" }, "library-viz-panel-info": { "last-edited": "{{timeAgo}} by ", @@ -7872,23 +7886,18 @@ "label-upsample": "Upsample", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "Close schema inspector" - }, "sql-expr": { "button-run-query": "Run query", - "modal-title": "SQL Editor", "tooltip-experimental": "SQL Expressions LLM integration is experimental. Please report any issues to the Grafana team." }, "sql-schema": { - "close-schema-inspector": "Close schema inspector", "error-title": "Error", - "inspect-button": "Inspect schema", "loading": "Loading schema information...", "no-data-title": "No schema information available", "no-fields-desc": "This query returned no schema information.", "no-fields-title": "No schema information", - "query-error-title": "Query error" + "query-error-title": "Query error", + "schema-inspector": "Schema inspector" }, "threshold": { "label-input": "Input" @@ -7963,11 +7972,7 @@ "suggestions": { "arc": "Gauge", "circular": "Circular gauge", - "no-thresholds": "Gauge - no thresholds", - "style": { - "circular": "Glowing", - "simple": "Simple" - } + "no-thresholds": "Gauge - no thresholds" }, "threshold": "Threshold {{value}}" }, @@ -11216,9 +11221,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "Apply {{suggestionName}} visualization", + "error-loading-some-suggestions": { + "message": "Some suggestions could not be loaded" + }, "error-loading-suggestions": { "message": "An error occurred when loading visualization suggestions.", - "title": "Error" + "plugin-failed": "Failed to load panel plugin: {{ pluginId }}.", + "title": "Error", + "try-again-button": "Try again" }, "unknown-viz-type": "Unknown visualization type", "use-this-suggestion": "Use this suggestion" @@ -11985,7 +11995,6 @@ "resource-not-found": "Resource not found. Please check the URL or repository.", "unsupported-repository-type": "Unsupported repository type: {{repositoryType}}" }, - "inline-secure-values-warning": "You need to save your access tokens again due to a system update", "instance-sync-deprecation": { "message": "Instance sync is currently not fully supported and breaks library panels and alerts. To use library panels and alerts, disconnect your repository and reconnect it using folder sync instead.", "title": "Instance sync is not fully supported" @@ -12095,6 +12104,9 @@ "webhook-last-event": "Last Event:", "webhook-url": "View Webhook" }, + "repository-status": { + "error": "Failed to load repository" + }, "repository-status-page": { "back-to-repositories": "Back to repositories", "cleaning-up-resources": "Cleaning up repository resources", @@ -12486,22 +12498,33 @@ }, "radialbar": { "config": { + "bar-shape": "Bar Style", + "bar-shape-flat": "Flat", + "bar-shape-rounded": "Rounded", "bar-width": "Bar width", "effects": { "bar-glow": "Bar glow", "center-glow": "Center glow", "gradient": "Gradient", - "label": "Effects", - "rounded-bars": "Rounded bars", - "spotlight": "Spotlight", - "spotlight-tooltip": "Only visible in dark themes" + "label": "Effects" }, + "endpoint-marker": "Endpoint marker", + "endpoint-marker-description": "Glow is only supported in dark mode", + "endpoint-marker-glow": "Glow", + "endpoint-marker-none": "None", + "endpoint-marker-point": "Point", "segment-count": "Segments", "segment-spacing": "Segment spacing", "shape": "Style", "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" } diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 1c8c641cfad..9a9cce82f5f 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Introduce el contenido de la anotación personalizada..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Reglas eliminadas correctamente de la carpeta" @@ -2203,11 +2208,15 @@ "min-interval": "Tamaño min. Intervalo = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Las consultas y expresiones seleccionadas no se pueden convertir a predeterminadas. Si desactivas las opciones avanzadas, tu consulta y condición se restablecerán a la configuración predeterminada." }, + "last15m": "", + "last1h": "", "preview": "Vista previa", - "previewCondition": "Vista previa de la condición de la regla de alerta" + "previewCondition": "Vista previa de la condición de la regla de alerta", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrar por puntos de contacto", @@ -2666,6 +2675,35 @@ "text-federated": "Federado", "text-provisioned": "Provisionado" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Fuente de datos", @@ -3717,7 +3755,10 @@ "text": "No se han encontrado resultados para tu consulta" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5926,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", @@ -6180,7 +6224,7 @@ "title-someone-else-has-updated-this-dashboard": "Otra persona ha actualizado este dashboard", "would-still-dashboard": "¿Seguro que quieres guardar este dashboard?" }, - "save-and-overwrite": "«Guardar y sobrescribir»" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7209,9 @@ "time-range-label": "Bloquear el intervalo de tiempo" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Consejo profesional: {{proTip}}" }, @@ -7641,39 +7688,6 @@ }, "share-span": "Compartir" }, - "span-filters": { - "aria-label-select-max-span-operator": "Seleccionar operador de intervalo máximo", - "aria-label-select-min-span-operator": "Seleccionar operador de intervalo mínimo", - "aria-label-select-service-name": "Seleccionar nombre de servicio", - "aria-label-select-service-name-operator": "Seleccionar operador de nombre de servicio", - "aria-label-select-span-name": "Seleccionar nombre de intervalo", - "aria-label-select-span-name-operator": "Seleccionar operador de nombre de intervalo", - "ariaLabel-select-max-span-duration": "Seleccionar duración máxima del intervalo", - "ariaLabel-select-min-span-duration": "Seleccionar duración mínima del intervalo", - "label-collapse": "Filtros de intervalo", - "label-duration": "Duración", - "label-service-name": "Nombre de servicio", - "label-span-name": "Nombre del intervalo", - "label-tags": "Etiquetas", - "placeholder-all-service-names": "Todos los nombres de servicios", - "placeholder-all-span-names": "Todos los nombres de intervalo", - "tooltip-collapse": "Filtre sus intervalos a continuación. Puede seguir aplicando filtros hasta que haya reducido los intervalos resultantes a los que más le interesen.", - "tooltip-duration": "Filtra por duración. Las unidades aceptadas son {{units}}", - "tooltip-tags": "Filtra por etiquetas, etiquetas de proceso o campos de logs en tus intervalos." - }, - "span-filters-tags": { - "aria-label-add-tag": "Añadir etiqueta", - "aria-label-input-tag-value": "Introducir valor de la etiqueta", - "aria-label-remove-tag": "Quitar etiqueta", - "aria-label-select-tag-key": "Seleccionar clave de etiqueta", - "aria-label-select-tag-operator": "Seleccionar operador de etiqueta", - "aria-label-select-tag-value": "Seleccionar valor de etiqueta", - "placeholder-select-tag": "Seleccionar etiqueta", - "placeholder-select-value": "Seleccionar valor", - "placeholder-tag-value": "Valor de etiqueta", - "tooltip-add-tag": "Añadir etiqueta", - "tooltip-remove-tag": "Quitar etiqueta" - }, "span-flame-graph": { "flame-graph": "Gráfico de llama" }, @@ -7872,23 +7886,18 @@ "label-upsample": "Aumentar tamaño", "tooltip-s-m-h": "10 s, 1 m, 30 m, 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Ejecutar consulta", - "modal-title": "Editor de SQL", "tooltip-experimental": "La integración de LLM de expresiones SQL es experimental. Avisa de cualquier problema al equipo de Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Entrada" @@ -7963,11 +7972,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9398,7 @@ "name-unit": "Unidad", "name-value-name": "Nombre del valor", "name-y-axis-scale": "Escala del eje Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9422,6 +9428,18 @@ "label-all": "Todo", "label-hidden": "Oculto", "label-single": "Único" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11221,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11995,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Ver detalles", "loading-finished-job": "Cargando trabajo finalizado...", @@ -12078,6 +12104,9 @@ "webhook-last-event": "Último evento:", "webhook-url": "Ver webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Volver a los repositorios", "cleaning-up-resources": "Limpiando los recursos del repositorio", @@ -12182,6 +12211,9 @@ "tooltip-unhealthy-repository": "No se puede extraer un repositorio que no está en buen estado" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12233,15 @@ }, "warning-title-default": "Advertencia", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "La duración de este proceso depende del número de recursos involucrados.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12260,7 @@ "step-finish": "Elegir ajustes adicionales", "step-synchronize": "Sincronizar con un almacenamiento externo", "sync-description": "Sincroniza los recursos con un almacenamiento externo. Después de este paso único, todas las actualizaciones futuras se guardarán automáticamente en el repositorio y se aprovisionarán de nuevo en la instancia.", + "sync-option-migrate-resources": "", "title-bootstrap": "Elegir qué sincronizar", "title-connect": "Conectar a un almacenamiento externo", "title-finish": "Elegir ajustes adicionales", @@ -12460,22 +12498,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 d2faa93f8e5..11411891f38 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Saisir le contenu de l’annotation personnalisée..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Règles supprimées du dossier" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Intervalle = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Les requêtes et expressions sélectionnées ne peuvent pas être converties en valeurs par défaut. Si vous désactivez les options avancées, votre requête et votre condition seront réinitialisées aux valeurs par défaut." }, + "last15m": "", + "last1h": "", "preview": "Aperçu", - "previewCondition": "Aperçu de la condition de la règle d'alerte" + "previewCondition": "Aperçu de la condition de la règle d'alerte", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrer par points de contact", @@ -2666,6 +2675,35 @@ "text-federated": "Fédéré", "text-provisioned": "Mis en service" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Source de données", @@ -3717,7 +3755,10 @@ "text": "Aucun résultat n'a été trouvé pour votre requête" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5926,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", @@ -6180,7 +6224,7 @@ "title-someone-else-has-updated-this-dashboard": "Quelqu’un d’autre a mis à jour ce tableau de bord", "would-still-dashboard": "Voulez-vous toujours enregistrer ce tableau de bord ?" }, - "save-and-overwrite": "« Enregistrer et écraser »" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7209,9 @@ "time-range-label": "Verrouiller la période temporelle" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Conseil de pro : {{proTip}}" }, @@ -7641,39 +7688,6 @@ }, "share-span": "Partager" }, - "span-filters": { - "aria-label-select-max-span-operator": "Sélectionner l’opérateur de la durée maximale", - "aria-label-select-min-span-operator": "Sélectionner l’opérateur de la durée minimale", - "aria-label-select-service-name": "Sélectionner le nom de service", - "aria-label-select-service-name-operator": "Sélectionner l’opérateur de nom de service", - "aria-label-select-span-name": "Sélectionner le nom de la durée", - "aria-label-select-span-name-operator": "Sélectionner l’opérateur de nom de la durée", - "ariaLabel-select-max-span-duration": "Sélectionner la durée maximale de segment", - "ariaLabel-select-min-span-duration": "Sélectionner la durée minimale de segment", - "label-collapse": "Filtres de durée", - "label-duration": "Durée", - "label-service-name": "Nom du service", - "label-span-name": "Nom de la durée", - "label-tags": "Étiquettes", - "placeholder-all-service-names": "Tous les noms de service", - "placeholder-all-span-names": "Tous les noms de durée", - "tooltip-collapse": "Filtrez vos plages ci-dessous. Vous pouvez continuer à appliquer des filtres jusqu’à ce que vous ayez réduit votre plage de résultats à ceux qui vous intéressent le plus.", - "tooltip-duration": "Filtrer par durée. Les unités acceptées sont {{units}}", - "tooltip-tags": "Filtrez par balises, balises de processus ou champs de journal dans vos durées." - }, - "span-filters-tags": { - "aria-label-add-tag": "Ajouter une balise", - "aria-label-input-tag-value": "Saisir la valeur de la balise", - "aria-label-remove-tag": "Supprimer la balise", - "aria-label-select-tag-key": "Sélectionner la clé de la balise", - "aria-label-select-tag-operator": "Sélectionner l’opérateur de la balise", - "aria-label-select-tag-value": "Sélectionner la valeur de la balise", - "placeholder-select-tag": "Choisir une balise", - "placeholder-select-value": "Sélectionner une valeur", - "placeholder-tag-value": "Valeur de la balise", - "tooltip-add-tag": "Ajouter une balise", - "tooltip-remove-tag": "Supprimer la balise" - }, "span-flame-graph": { "flame-graph": "Graphique de flamme" }, @@ -7872,23 +7886,18 @@ "label-upsample": "Sur-échantillonner", "tooltip-s-m-h": "10 s, 1 m, 30 m, 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Exécuter la requête", - "modal-title": "Éditeur SQL", "tooltip-experimental": "L’intégration des expressions SQL avec les LLM est expérimentale. Merci de signaler tout problème à l’équipe Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Entrée" @@ -7963,11 +7972,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9398,7 @@ "name-unit": "Unité", "name-value-name": "Nom de la valeur", "name-y-axis-scale": "Échelle de l’axe Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9422,6 +9428,18 @@ "label-all": "Tous", "label-hidden": "Masqué", "label-single": "Unique" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11221,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11995,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Afficher les détails", "loading-finished-job": "Chargement de mission terminée…", @@ -12078,6 +12104,9 @@ "webhook-last-event": "Dernier événement :", "webhook-url": "Voir le webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Retour aux référentiels", "cleaning-up-resources": "Nettoyage des ressources du référentiel", @@ -12182,6 +12211,9 @@ "tooltip-unhealthy-repository": "Impossible de fusionner un référentiel en mauvais état" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12233,15 @@ }, "warning-title-default": "Avertissement", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "La durée de ce processus dépend du nombre de ressources impliquées.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12260,7 @@ "step-finish": "Choisir des paramètres supplémentaires", "step-synchronize": "Synchroniser avec un stockage externe", "sync-description": "Synchronisez les ressources avec un stockage externe. Après cette étape unique, toutes les futures mises à jour seront automatiquement enregistrées dans le référentiel et mises en service dans l’instance.", + "sync-option-migrate-resources": "", "title-bootstrap": "Choisir ce qui doit être synchronisé", "title-connect": "Se connecter à un stockage externe", "title-finish": "Choisir des paramètres supplémentaires", @@ -12460,22 +12498,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 0c8c164742b..7a95606822e 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Adja meg az egyéni jegyzet tartalmát…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "A szabályok sikeresen törlődtek a mappából" @@ -2203,11 +2208,15 @@ "min-interval": "Min. intervallum = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "A kijelölt lekérdezések és kifejezések nem konvertálhatók alapértelmezettre. Ha kikapcsolja a speciális beállításokat, a lekérdezés és a feltétel visszaáll az alapértelmezett beállításokra." }, + "last15m": "", + "last1h": "", "preview": "Előnézet", - "previewCondition": "Riasztási szabály előnézeti feltétele" + "previewCondition": "Riasztási szabály előnézeti feltétele", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Szűrés kapcsolattartási pontok szerint", @@ -2666,6 +2675,35 @@ "text-federated": "Összevont", "text-provisioned": "Kiépítve" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Adatforrás", @@ -3717,7 +3755,10 @@ "text": "Nincs találat a lekérdezésre" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5926,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", @@ -6180,7 +6224,7 @@ "title-someone-else-has-updated-this-dashboard": "Valaki más frissítette ezt az irányítópultot", "would-still-dashboard": "Biztosan menti ezt az irányítópultot?" }, - "save-and-overwrite": "„Mentés és felülírás”" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7209,9 @@ "time-range-label": "Időtartomány zárolása" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "ProTip: {{proTip}}" }, @@ -7641,39 +7688,6 @@ }, "share-span": "Megosztás" }, - "span-filters": { - "aria-label-select-max-span-operator": "Max. terjedelem operátor kijelölése", - "aria-label-select-min-span-operator": "Válasszon operátort a min. terjedelemhez", - "aria-label-select-service-name": "Válasszon szolgáltatásnevet", - "aria-label-select-service-name-operator": "Válasszon operátort a szolgáltatásnévhez", - "aria-label-select-span-name": "Válasszon terjedelemnevet", - "aria-label-select-span-name-operator": "Terjedelemnév operátor kijelölése", - "ariaLabel-select-max-span-duration": "Maximális időtartam kiválasztása", - "ariaLabel-select-min-span-duration": "Minimális időtartam kiválasztása", - "label-collapse": "Terjedelemszűrők", - "label-duration": "Időtartam", - "label-service-name": "Szolgáltatásnév", - "label-span-name": "Terjedelemnév", - "label-tags": "Címkék", - "placeholder-all-service-names": "Összes szolgáltatásnév", - "placeholder-all-span-names": "Összes terjedelemnév", - "tooltip-collapse": "Alább szűrheti a terjedelmeket. Folytathatja a szűrők alkalmazását, amíg a kapott terjedelmeket a leginkább keresett néhány terjedelemre nem szűkíti.", - "tooltip-duration": "Szűrés időtartam szerint. Elfogadott mértékegységek: {{units}}", - "tooltip-tags": "Szűrés címkék, folyamatcímkék vagy naplómezők alapján a terjedelmeiben." - }, - "span-filters-tags": { - "aria-label-add-tag": "Címke hozzáadása", - "aria-label-input-tag-value": "Bemeneti címke értéke", - "aria-label-remove-tag": "Címke eltávolítása", - "aria-label-select-tag-key": "Címkekulcs kijelölése", - "aria-label-select-tag-operator": "Címkeoperátor kijelölése", - "aria-label-select-tag-value": "Címkeérték kijelölése", - "placeholder-select-tag": "Címke kijelölése", - "placeholder-select-value": "Érték kijelölése", - "placeholder-tag-value": "Címke értéke", - "tooltip-add-tag": "Címke hozzáadása", - "tooltip-remove-tag": "Címke eltávolítása" - }, "span-flame-graph": { "flame-graph": "Lángdiagram" }, @@ -7872,23 +7886,18 @@ "label-upsample": "Mintasűrűség növelése", "tooltip-s-m-h": "10 mp., 1 p., 30 p., 1 ó." }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Lekérdezés futtatása", - "modal-title": "SQL-szerkesztő", "tooltip-experimental": "Az SQL-kifejezések LLM-integrációja kísérleti jellegű. Kérjük, jelentse az esetleges problémákat a Grafana csapatának." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Bemenet" @@ -7963,11 +7972,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9398,7 @@ "name-unit": "Egység", "name-value-name": "Érték neve", "name-y-axis-scale": "Y tengely skálája", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automatikus", "placeholder-axis-width": "Automatikus", "placeholder-decimals": "Automatikus", @@ -9422,6 +9428,18 @@ "label-all": "Összes", "label-hidden": "Rejtett", "label-single": "Különálló" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11221,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11995,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Részletek megtekintése", "loading-finished-job": "Befejezett feladat betöltése…", @@ -12078,6 +12104,9 @@ "webhook-last-event": "Legutóbbi esemény:", "webhook-url": "Webkapocs megtekintése" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Vissza az adattárakhoz", "cleaning-up-resources": "Adattári erőforrások tisztítása", @@ -12182,6 +12211,9 @@ "tooltip-unhealthy-repository": "Nem lehet beolvasni egy nem megfelelő állapotú adattárat" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12233,15 @@ }, "warning-title-default": "Figyelmeztetés", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "A folyamat időtartama az érintett erőforrások számától függ.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12260,7 @@ "step-finish": "Válassza ki a további beállításokat", "step-synchronize": "Szinkronizálás külső tárolóval", "sync-description": "Erőforrások szinkronizálása külső tárolóval. Ezután az egyszeri lépés után az összes jövőbeli frissítés automatikusan mentve lesz az adattárba, és vissza lesz építve a példányba.", + "sync-option-migrate-resources": "", "title-bootstrap": "Válassza ki, mit szeretne szinkronizálni", "title-connect": "Csatlakozás külső tárolóhoz", "title-finish": "Válassza ki a további beállításokat", @@ -12460,22 +12498,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 66ad5574fb6..250ee7778c1 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Masukkan konten anotasi kustom..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Aturan berhasil dihapus dari folder" @@ -2195,11 +2200,15 @@ "min-interval": "Min. Interval = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Kueri dan ekspresi yang dipilih tidak dapat dikonversi ke default. Jika Anda menonaktifkan opsi lanjutan, kueri dan kondisi Anda akan diatur ulang ke pengaturan default." }, + "last15m": "", + "last1h": "", "preview": "Pratinjau", - "previewCondition": "Pratinjau kondisi aturan peringatan" + "previewCondition": "Pratinjau kondisi aturan peringatan", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filter berdasarkan titik kontak", @@ -2655,6 +2664,35 @@ "text-federated": "Federasi", "text-provisioned": "Disediakan" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Sumber data", @@ -3701,7 +3739,10 @@ "text": "Hasil untuk kueri Anda tidak ditemukan" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5905,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", @@ -6159,7 +6203,7 @@ "title-someone-else-has-updated-this-dashboard": "Orang lain telah memperbarui dasbor ini", "would-still-dashboard": "Ingin tetap menyimpan dasbor ini?" }, - "save-and-overwrite": "'Simpan dan timpa'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7186,9 @@ "time-range-label": "Kunci rentang waktu" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Kiat Pro: {{proTip}}" }, @@ -7616,39 +7663,6 @@ }, "share-span": "Bagikan" }, - "span-filters": { - "aria-label-select-max-span-operator": "Pilih operator rentang maksimum", - "aria-label-select-min-span-operator": "Pilih operator rentang min", - "aria-label-select-service-name": "Pilih nama layanan", - "aria-label-select-service-name-operator": "Pilih operator nama layanan", - "aria-label-select-span-name": "Pilih nama rentang", - "aria-label-select-span-name-operator": "Pilih operator nama rentang", - "ariaLabel-select-max-span-duration": "Pilih durasi rentang maksimum", - "ariaLabel-select-min-span-duration": "Pilih durasi rentang minimum", - "label-collapse": "Filter Rentang", - "label-duration": "Durasi", - "label-service-name": "Nama layanan", - "label-span-name": "Nama rentang", - "label-tags": "Tag", - "placeholder-all-service-names": "Semua nama layanan", - "placeholder-all-span-names": "Semua nama rentang", - "tooltip-collapse": "Filter rentang Anda di bawah ini. Anda dapat terus menerapkan filter hingga Anda mempersempit rentang hasil Anda menjadi beberapa pilihan yang paling Anda minati.", - "tooltip-duration": "Filter menurut durasi. Unit yang diterima adalah {{units}}", - "tooltip-tags": "Filter berdasarkan tag, tag proses, atau bidang log di rentang Anda." - }, - "span-filters-tags": { - "aria-label-add-tag": "Tambah tag", - "aria-label-input-tag-value": "Masukkan nilai tag", - "aria-label-remove-tag": "Hapus tag", - "aria-label-select-tag-key": "Pilih kunci tag", - "aria-label-select-tag-operator": "Pilih operator tag", - "aria-label-select-tag-value": "Pilih nilai tag", - "placeholder-select-tag": "Pilih tag", - "placeholder-select-value": "Pilih nilai", - "placeholder-tag-value": "Nilai tag", - "tooltip-add-tag": "Tambah tag", - "tooltip-remove-tag": "Hapus tag" - }, "span-flame-graph": { "flame-graph": "Grafik api" }, @@ -7847,23 +7861,18 @@ "label-upsample": "Tingkatkan sample", "tooltip-s-m-h": "10 dtk, 1 mnt, 30 mnt, 1 jm" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Jalankan kueri", - "modal-title": "Editor SQL", "tooltip-experimental": "Integrasi LLM Ekspresi SQL bersifat eksperimental. Harap laporkan masalah apa pun kepada tim Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Input" @@ -7938,11 +7947,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9363,7 @@ "name-unit": "Unit", "name-value-name": "Nama nilai", "name-y-axis-scale": "Skala sumbu Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Otomatis", "placeholder-axis-width": "Otomatis", "placeholder-decimals": "Otomatis", @@ -9387,6 +9393,18 @@ "label-all": "Semua", "label-hidden": "Tersembunyi", "label-single": "Tunggal" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11175,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11944,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Lihat detail", "loading-finished-job": "Memuat pekerjaan yang sudah selesai...", @@ -12027,6 +12053,9 @@ "webhook-last-event": "Peristiwa Terakhir:", "webhook-url": "Lihat Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Kembali ke repositori", "cleaning-up-resources": "Membersihkan sumber daya repositori", @@ -12131,6 +12160,9 @@ "tooltip-unhealthy-repository": "Tidak dapat menerapkan pull pada repositori yang tidak sehat" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12182,15 @@ }, "warning-title-default": "Peringatan", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Durasi proses ini bergantung pada jumlah sumber daya yang terlibat.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12209,7 @@ "step-finish": "Pilih pengaturan tambahan", "step-synchronize": "Sinkronkan dengan penyimpanan eksternal", "sync-description": "Sinkronkan sumber daya dengan penyimpanan eksternal. Setelah langkah satu kali ini, semua pembaruan mendatang akan disimpan secara otomatis ke repositori dan disediakan kembali ke instans.", + "sync-option-migrate-resources": "", "title-bootstrap": "Pilih item yang akan disinkronkan", "title-connect": "Hubungkan ke penyimpanan eksternal", "title-finish": "Pilih pengaturan tambahan", @@ -12408,22 +12446,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 76898afd674..9db051b6aa6 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Inserisci il contenuto dell'annotazione personalizzata..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Regole eliminate dalla cartella" @@ -2203,11 +2208,15 @@ "min-interval": "Min Intervallo = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Le query e le espressioni selezionate non possono essere convertite in predefinite. Se disattivi le opzioni avanzate, la query e la condizione verranno ripristinate alle impostazioni predefinite." }, + "last15m": "", + "last1h": "", "preview": "Anteprima", - "previewCondition": "Anteprima condizione regola di avviso" + "previewCondition": "Anteprima condizione regola di avviso", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtra per punti di contatto", @@ -2666,6 +2675,35 @@ "text-federated": "Federato", "text-provisioned": "Fornito" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Sorgente dati", @@ -3717,7 +3755,10 @@ "text": "Nessun risultato trovato per la ricerca" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5926,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", @@ -6180,7 +6224,7 @@ "title-someone-else-has-updated-this-dashboard": "Qualcun altro ha aggiornato questa dashboard", "would-still-dashboard": "Desideri comunque salvare questa dashboard?" }, - "save-and-overwrite": "\"Salva e sovrascrivi\"" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7209,9 @@ "time-range-label": "Blocca intervallo di tempo" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Suggerimento pro: {{proTip}} " }, @@ -7641,39 +7688,6 @@ }, "share-span": "Condividi" }, - "span-filters": { - "aria-label-select-max-span-operator": "Seleziona l'operatore di intervallo massimo", - "aria-label-select-min-span-operator": "Seleziona operatore di intervallo minimo", - "aria-label-select-service-name": "Seleziona nome servizio", - "aria-label-select-service-name-operator": "Seleziona operatore nome servizio", - "aria-label-select-span-name": "Seleziona nome intervallo", - "aria-label-select-span-name-operator": "Seleziona l'operatore del nome dell'intervallo", - "ariaLabel-select-max-span-duration": "Seleziona la durata dell'intervallo massimo", - "ariaLabel-select-min-span-duration": "Seleziona la durata dell'intervallo minimo", - "label-collapse": "Filtri intervallo", - "label-duration": "Durata", - "label-service-name": "Nome del servizio", - "label-span-name": "Nome intervallo", - "label-tags": "Tag", - "placeholder-all-service-names": "Tutti i nomi dei servizi", - "placeholder-all-span-names": "Tutti i nomi degli intervalli", - "tooltip-collapse": "Filtra i tuoi intervalli qui sotto. Puoi continuare ad applicare i filtri fino a quando non avrai ristretto gli intervalli risultanti ai pochi selezionati a cui sei più interessato.", - "tooltip-duration": "Filtra per durata. Le unità accettate sono {{units}}", - "tooltip-tags": "Filtra per tag, tag di processo o campi di registro nei tuoi intervalli." - }, - "span-filters-tags": { - "aria-label-add-tag": "Aggiungi tag", - "aria-label-input-tag-value": "Valore del tag di inserimento", - "aria-label-remove-tag": "Rimuovi tag", - "aria-label-select-tag-key": "Seleziona chiave tag", - "aria-label-select-tag-operator": "Seleziona operatore tag", - "aria-label-select-tag-value": "Seleziona valore tag", - "placeholder-select-tag": "Seleziona tag", - "placeholder-select-value": "Seleziona valore", - "placeholder-tag-value": "Valore del tag", - "tooltip-add-tag": "Aggiungi tag", - "tooltip-remove-tag": "Rimuovi tag" - }, "span-flame-graph": { "flame-graph": "Grafico a fiamma" }, @@ -7872,23 +7886,18 @@ "label-upsample": "Aumenta campionamento", "tooltip-s-m-h": "10 s, 1 m, 30 m, 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Esegui query", - "modal-title": "Editor SQL", "tooltip-experimental": "L'integrazione LLM delle Espressioni SQL è sperimentale. Segnala eventuali problemi al team Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Inserisci" @@ -7963,11 +7972,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9398,7 @@ "name-unit": "Unità", "name-value-name": "Nome del valore", "name-y-axis-scale": "Scala asse Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automatico", "placeholder-axis-width": "Automatico", "placeholder-decimals": "Automatico", @@ -9422,6 +9428,18 @@ "label-all": "Tutti", "label-hidden": "Nascosto", "label-single": "Singolo" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11221,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11995,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Visualizza dettagli", "loading-finished-job": "Caricamento attività terminata in corso...", @@ -12078,6 +12104,9 @@ "webhook-last-event": "Ultimo evento:", "webhook-url": "Visualizza webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Torna ai repository", "cleaning-up-resources": "Pulizia delle risorse del repository", @@ -12182,6 +12211,9 @@ "tooltip-unhealthy-repository": "Impossibile eseguire il pull di un repository non integro" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12233,15 @@ }, "warning-title-default": "Attenzione", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "La durata di questo processo dipende dal numero di risorse coinvolte.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12260,7 @@ "step-finish": "Scegli impostazioni aggiuntive", "step-synchronize": "Sincronizza con la memoria esterna", "sync-description": "Sincronizza le risorse con la memoria esterna. Dopo questo passaggio una tantum, tutti gli aggiornamenti futuri verranno salvati automaticamente nel repository e ripristinati nell'istanza.", + "sync-option-migrate-resources": "", "title-bootstrap": "Scegli cosa sincronizzare", "title-connect": "Connetti a una memoria esterna", "title-finish": "Scegli impostazioni aggiuntive", @@ -12460,22 +12498,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 cebb4cd6bad..c968ce753bc 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "カスタム注釈内容を入力..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "ルールがフォルダから正常に削除されました" @@ -2195,11 +2200,15 @@ "min-interval": "最小間隔= {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "選択したクエリと式はデフォルトに変換できません。高度なオプションを無効にすると、クエリと条件はデフォルト設定にリセットされます。" }, + "last15m": "", + "last1h": "", "preview": "プレビュー", - "previewCondition": "アラートルール条件をプレビューする" + "previewCondition": "アラートルール条件をプレビューする", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "コンタクトポイントで絞り込む", @@ -2655,6 +2664,35 @@ "text-federated": "フェデレーション", "text-provisioned": "プロビジョニング済み" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "データソース", @@ -3701,7 +3739,10 @@ "text": "クエリに一致する結果が見つかりませんでした。" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5905,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": "アイコン", @@ -6159,7 +6203,7 @@ "title-someone-else-has-updated-this-dashboard": "他のユーザーがこのダッシュボードを更新しました", "would-still-dashboard": "このダッシュボードの保存を続行しますか?" }, - "save-and-overwrite": "「保存して上書き」" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7186,9 @@ "time-range-label": "時間範囲をロック" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "プロのヒント:{{proTip}} " }, @@ -7616,39 +7663,6 @@ }, "share-span": "共有" }, - "span-filters": { - "aria-label-select-max-span-operator": "最大スパン演算子を選択", - "aria-label-select-min-span-operator": "最小スパン演算子を選択", - "aria-label-select-service-name": "サービス名を選択", - "aria-label-select-service-name-operator": "サービス名演算子を選択", - "aria-label-select-span-name": "スパン名を選択", - "aria-label-select-span-name-operator": "スパン名演算子を選択", - "ariaLabel-select-max-span-duration": "最大スパン期間を選択", - "ariaLabel-select-min-span-duration": "最小スパン期間を選択", - "label-collapse": "スパンフィルター", - "label-duration": "継続時間", - "label-service-name": "サービス名", - "label-span-name": "スパン名", - "label-tags": "タグ", - "placeholder-all-service-names": "すべてのサービス名", - "placeholder-all-span-names": "すべてのスパン名", - "tooltip-collapse": "以下のスパンを絞り込みます。最も関心のある少数のスパンに絞り込むまで、フィルターを適用し続けることができます。", - "tooltip-duration": "期間で絞り込みます。使用可能な単位:{{units}}", - "tooltip-tags": "スパン内のタグ、プロセスタグ、またはログフィールドで絞り込みます。" - }, - "span-filters-tags": { - "aria-label-add-tag": "タグを追加", - "aria-label-input-tag-value": "タグ値を入力", - "aria-label-remove-tag": "タグを削除", - "aria-label-select-tag-key": "タグキーを選択", - "aria-label-select-tag-operator": "タグ演算子を選択", - "aria-label-select-tag-value": "タグ値を選択", - "placeholder-select-tag": "タグを選択", - "placeholder-select-value": "値を選択", - "placeholder-tag-value": "タグ値", - "tooltip-add-tag": "タグを追加", - "tooltip-remove-tag": "タグを削除" - }, "span-flame-graph": { "flame-graph": "フレームグラフ" }, @@ -7847,23 +7861,18 @@ "label-upsample": "アップサンプリング", "tooltip-s-m-h": "10秒、1分、30分、1時間" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "クエリを実行", - "modal-title": "SQLエディター", "tooltip-experimental": "SQL Expressions LLMの統合は実験的です。問題が発生した場合は、Grafanaチームに報告してください。" }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "入力" @@ -7938,11 +7947,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9363,7 @@ "name-unit": "単位", "name-value-name": "値の名前", "name-y-axis-scale": "Y軸スケール", + "name-y-bucket-scale": "", "placeholder-axis-label": "自動", "placeholder-axis-width": "自動", "placeholder-decimals": "自動", @@ -9387,6 +9393,18 @@ "label-all": "すべて", "label-hidden": "非表示", "label-single": "単体" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11175,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11944,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "詳細を表示", "loading-finished-job": "完了したジョブを読み込み中...", @@ -12027,6 +12053,9 @@ "webhook-last-event": "最新イベント:", "webhook-url": "Webhookを表示" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "リポジトリに戻る", "cleaning-up-resources": "リポジトリリソースのクリーンアップ中", @@ -12131,6 +12160,9 @@ "tooltip-unhealthy-repository": "問題のあるリポジトリをプルできません" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12182,15 @@ }, "warning-title-default": "警告", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "このプロセスの所要時間は、関連するリソースの数によって異なります。", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12209,7 @@ "step-finish": "追加設定を選択", "step-synchronize": "外部ストレージと同期", "sync-description": "リソースを外部ストレージと同期します。この一度限りの手順が完了すると、その後のすべての更新は自動的にリポジトリに保存され、インスタンスにプロビジョニングされます。", + "sync-option-migrate-resources": "", "title-bootstrap": "同期する内容を選択", "title-connect": "外部ストレージに接続", "title-finish": "追加設定を選択", @@ -12408,22 +12446,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 07e410e67e1..f7c6e29f8f4 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "사용자 지정 주석 내용을 입력하세요..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "폴더에서 규칙이 성공적으로 삭제되었습니다" @@ -2195,11 +2200,15 @@ "min-interval": "최소 간격 = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "선택한 쿼리와 표현식을 기본값으로 변환할 수 없습니다. 고급 옵션을 비활성화하면 쿼리와 조건이 기본 설정으로 재설정됩니다." }, + "last15m": "", + "last1h": "", "preview": "미리보기", - "previewCondition": "경고 규칙 조건 미리보기" + "previewCondition": "경고 규칙 조건 미리보기", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "연락처로 필터링", @@ -2655,6 +2664,35 @@ "text-federated": "연합됨", "text-provisioned": "프로비저닝됨" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "데이터 소스", @@ -3701,7 +3739,10 @@ "text": "쿼리에 대해 찾은 결과 없음" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5905,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": "아이콘", @@ -6159,7 +6203,7 @@ "title-someone-else-has-updated-this-dashboard": "다른 사람이 이 대시보드를 업데이트했습니다", "would-still-dashboard": "그래도 이 대시보드를 저장하시겠어요?" }, - "save-and-overwrite": "'저장 및 덮어쓰기'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7186,9 @@ "time-range-label": "시간 범위 잠그기" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "유용한 팁: {{proTip}} " }, @@ -7616,39 +7663,6 @@ }, "share-span": "공유" }, - "span-filters": { - "aria-label-select-max-span-operator": "최대 스팬 연산자 선택", - "aria-label-select-min-span-operator": "최소 스팬 연산자 선택", - "aria-label-select-service-name": "서비스 이름 선택", - "aria-label-select-service-name-operator": "서비스 이름 연산자 선택", - "aria-label-select-span-name": "스팬 이름 선택", - "aria-label-select-span-name-operator": "스팬 이름 연산자 선택", - "ariaLabel-select-max-span-duration": "최대 스팬 기간 선택", - "ariaLabel-select-min-span-duration": "최소 스팬 기간 선택", - "label-collapse": "스팬 필터", - "label-duration": "지속 시간", - "label-service-name": "서비스 이름", - "label-span-name": "스팬 이름", - "label-tags": "태그", - "placeholder-all-service-names": "모든 서비스 이름", - "placeholder-all-span-names": "모든 스팬 이름", - "tooltip-collapse": "아래에서 스팬을 필터링합니다. 결과 스팬을 가장 관심 있는 몇 가지로 좁힐 때까지 필터를 계속 적용할 수 있습니다.", - "tooltip-duration": "지속 시간을 기준으로 필터링합니다. 허용되는 단위는 {{units}}입니다", - "tooltip-tags": "스팬의 태그, 프로세스 태그 또는 로그 필드를 기준으로 필터링합니다." - }, - "span-filters-tags": { - "aria-label-add-tag": "태그 추가", - "aria-label-input-tag-value": "태그 값 입력", - "aria-label-remove-tag": "태그 제거", - "aria-label-select-tag-key": "태그 키 선택", - "aria-label-select-tag-operator": "태그 연산자 선택", - "aria-label-select-tag-value": "태그 값 선택", - "placeholder-select-tag": "태그 선택", - "placeholder-select-value": "값 선택", - "placeholder-tag-value": "태그 값", - "tooltip-add-tag": "태그 추가", - "tooltip-remove-tag": "태그 제거" - }, "span-flame-graph": { "flame-graph": "불꽃 그래프" }, @@ -7847,23 +7861,18 @@ "label-upsample": "업샘플", "tooltip-s-m-h": "10초, 1분, 30분, 1시간" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "쿼리 실행", - "modal-title": "SQL 편집기", "tooltip-experimental": "SQL 표현식 LLM 통합 기능은 실험 단계입니다. 문제가 발생하면 Grafana 팀에 보고해 주세요." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "입력" @@ -7938,11 +7947,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9363,7 @@ "name-unit": "단위", "name-value-name": "값 이름", "name-y-axis-scale": "Y축 스케일", + "name-y-bucket-scale": "", "placeholder-axis-label": "자동", "placeholder-axis-width": "자동", "placeholder-decimals": "자동", @@ -9387,6 +9393,18 @@ "label-all": "전체", "label-hidden": "숨김", "label-single": "단일" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11175,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11944,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "세부 정보 보기", "loading-finished-job": "완료된 작업 로딩 중...", @@ -12027,6 +12053,9 @@ "webhook-last-event": "마지막 이벤트:", "webhook-url": "웹훅 보기" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "리포지토리로 돌아가기", "cleaning-up-resources": "리포지토리 리소스 정리 및 삭제 중", @@ -12131,6 +12160,9 @@ "tooltip-unhealthy-repository": "상태가 좋지 않은 리포지토리를 가져올 수 없습니다" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12182,15 @@ }, "warning-title-default": "경고", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "이 프로세스의 지속 시간은 관련된 리소스 수에 따라 달라집니다.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12209,7 @@ "step-finish": "추가 설정 선택", "step-synchronize": "외부 스토리지와 동기화", "sync-description": "외부 스토리지와 리소스를 동기화합니다. 이 일회성 단계 후에는 향후 모든 업데이트가 자동으로 리포지토리에 저장되고 인스턴스에 다시 프로비저닝됩니다.", + "sync-option-migrate-resources": "", "title-bootstrap": "동기화할 항목 선택", "title-connect": "외부 스토리지에 연결", "title-finish": "추가 설정 선택", @@ -12408,22 +12446,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 237fbb74953..e2c53a858f1 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Aangepaste annotatie-inhoud invoeren..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Regels zijn verwijderd uit de map" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Interval = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "De geselecteerde query's en expressies kunnen niet worden geconverteerd naar standaard. Als je geavanceerde opties deactiveert, worden je query en voorwaarde teruggezet naar de standaardinstellingen." }, + "last15m": "", + "last1h": "", "preview": "Voorbeeld", - "previewCondition": "Voorbeeld waarschuwingsregel voorwaarde" + "previewCondition": "Voorbeeld waarschuwingsregel voorwaarde", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filteren op contactpunten", @@ -2666,6 +2675,35 @@ "text-federated": "Federatief", "text-provisioned": "Provisioned" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Gegevensbron", @@ -3717,7 +3755,10 @@ "text": "Geen resultaten gevonden voor je zoekopdracht" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5926,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", @@ -6180,7 +6224,7 @@ "title-someone-else-has-updated-this-dashboard": "Iemand anders heeft dit dashboard bijgewerkt", "would-still-dashboard": "Wil je dit dashboard nog steeds opslaan?" }, - "save-and-overwrite": "'Opslaan en overschrijven'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7209,9 @@ "time-range-label": "Tijdsbereik vergrendelen" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "ProTip: {{proTip}} " }, @@ -7641,39 +7688,6 @@ }, "share-span": "Delen" }, - "span-filters": { - "aria-label-select-max-span-operator": "Operator voor maximale span selecteren", - "aria-label-select-min-span-operator": "Selecteer een operator voor een minimumspan", - "aria-label-select-service-name": "Servicenaam selecteren", - "aria-label-select-service-name-operator": "Servicenaam operator selecteren", - "aria-label-select-span-name": "Selecteer een spannaam", - "aria-label-select-span-name-operator": " Operator voor een spannaam selecteren", - "ariaLabel-select-max-span-duration": "Selecteer een maximale spanduur", - "ariaLabel-select-min-span-duration": "Selecteer een minimale spanduur", - "label-collapse": "Spanfilters", - "label-duration": "Duur", - "label-service-name": "Servicenaam", - "label-span-name": "Spannaam", - "label-tags": "Labels", - "placeholder-all-service-names": "Alle servicenamen", - "placeholder-all-span-names": "Alle spannamen", - "tooltip-collapse": "Filter je bereik hieronder. Je kunt filters blijven toepassen totdat je het resulterende bereik hebt beperkt tot de resultaten die het meest interessant voor je zijn.", - "tooltip-duration": "Filteren op duur. Geaccepteerde eenheden zijn {{units}}", - "tooltip-tags": "Filteren op labels, labels verwerken of logvelden in je spans." - }, - "span-filters-tags": { - "aria-label-add-tag": "Label toevoegen", - "aria-label-input-tag-value": "Labelwaarde invoeren", - "aria-label-remove-tag": "Label verwijderen", - "aria-label-select-tag-key": "Labelsleutel selecteren", - "aria-label-select-tag-operator": "Labeloperator selecteren", - "aria-label-select-tag-value": "Labelwaarde selecteren", - "placeholder-select-tag": "Label selecteren", - "placeholder-select-value": "Waarde selecteren", - "placeholder-tag-value": "Labelwaarde", - "tooltip-add-tag": "Label toevoegen", - "tooltip-remove-tag": "Label verwijderen" - }, "span-flame-graph": { "flame-graph": "Vlamgrafiek" }, @@ -7872,23 +7886,18 @@ "label-upsample": "Upsample", "tooltip-s-m-h": "10s, 1m, 30m, 1u" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Query uitvoeren", - "modal-title": "SQL-editor", "tooltip-experimental": "SQL Expressions LLM-integratie is experimenteel. Meld eventuele problemen aan het Grafana-team." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Invoer" @@ -7963,11 +7972,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9398,7 @@ "name-unit": "Eenheid", "name-value-name": "Naam van waarde:", "name-y-axis-scale": "Y-as schaal", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automatisch", "placeholder-axis-width": "Automatisch", "placeholder-decimals": "Automatisch", @@ -9422,6 +9428,18 @@ "label-all": "Alle", "label-hidden": "Verborgen", "label-single": "Enkel" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11221,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11995,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Details bekijken", "loading-finished-job": "Voltooide taak laden...", @@ -12078,6 +12104,9 @@ "webhook-last-event": "Laatste gebeurtenis:", "webhook-url": "Webhook bekijken" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Terug naar repositories", "cleaning-up-resources": "Bronnen van repository opschonen", @@ -12182,6 +12211,9 @@ "tooltip-unhealthy-repository": "Kan geen ongezonde repository ophalen" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12233,15 @@ }, "warning-title-default": "Waarschuwing", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "De duur van dit proces is afhankelijk van het aantal betrokken bronnen.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12260,7 @@ "step-finish": "Kies aanvullende instellingen", "step-synchronize": "Synchroniseren met externe opslag", "sync-description": "Bronnen synchroniseren met externe opslag. Na deze eenmalige stap worden alle toekomstige updates automatisch opgeslagen in de repository en opnieuw ingericht in de instantie.", + "sync-option-migrate-resources": "", "title-bootstrap": "Kies wat je wilt synchroniseren", "title-connect": "Verbinden met externe opslag", "title-finish": "Kies aanvullende instellingen", @@ -12460,22 +12498,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 6ff40c3ab1f..d174debb2cb 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -729,6 +729,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Wpisz treść niestandardowej adnotacji…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Reguły zostały usunięte z folderu" @@ -2219,11 +2224,15 @@ "min-interval": "Min. odstęp czasu = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Nie można przekonwertować wybranych zapytań i wyrażeń na domyślne. Jeśli wyłączysz opcje zaawansowane, zapytanie i warunek zostaną zresetowane do ustawień domyślnych." }, + "last15m": "", + "last1h": "", "preview": "Podgląd", - "previewCondition": "Podgląd warunku reguły alertu" + "previewCondition": "Podgląd warunku reguły alertu", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtruj według punktów kontaktu", @@ -2688,6 +2697,35 @@ "text-federated": "Federacja", "text-provisioned": "Po aprowizacji" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Źródło danych", @@ -3749,7 +3787,10 @@ "text": "Nie znaleziono wyników dla tego zapytania" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5968,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", @@ -6222,7 +6266,7 @@ "title-someone-else-has-updated-this-dashboard": "Ktoś inny zaktualizował ten pulpit", "would-still-dashboard": "Czy nadal chcesz zapisać ten pulpit?" }, - "save-and-overwrite": "„Zapisz i zastąp”" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7211,6 +7255,9 @@ "time-range-label": "Zablokuj zakres czasu" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Wskazówka: {{proTip}}" }, @@ -7691,39 +7738,6 @@ }, "share-span": "Udostępnij" }, - "span-filters": { - "aria-label-select-max-span-operator": "Wybierz operator maksymalnego zakresu", - "aria-label-select-min-span-operator": "Wybierz operator minimalnego zakresu", - "aria-label-select-service-name": "Wybierz nazwę usługi", - "aria-label-select-service-name-operator": "Wybierz operator nazwy usługi", - "aria-label-select-span-name": "Wybierz nazwę zakresu", - "aria-label-select-span-name-operator": "Wybierz operator nazwy zakresu", - "ariaLabel-select-max-span-duration": "Wybierz maksymalny czas trwania zakresu", - "ariaLabel-select-min-span-duration": "Wybierz minimalny czas trwania zakresu", - "label-collapse": "Filtry zakresu", - "label-duration": "Czas trwania", - "label-service-name": "Nazwa usługi", - "label-span-name": "Nazwa zakresu", - "label-tags": "Znaczniki", - "placeholder-all-service-names": "Wszystkie nazwy usług", - "placeholder-all-span-names": "Wszystkie nazwy zakresów", - "tooltip-collapse": "Odfiltruj zakresy poniżej. Możesz dodawać filtry, aż zawęzisz zakres wyników do kilku najbardziej interesujących.", - "tooltip-duration": "Filtrowanie według czasu trwania. Akceptowane jednostki: {{units}}", - "tooltip-tags": "Filtruj według tagów, tagów procesu lub pól logów w swoich zakresach." - }, - "span-filters-tags": { - "aria-label-add-tag": "Dodaj tag", - "aria-label-input-tag-value": "Wpisz wartość tagu", - "aria-label-remove-tag": "Usuń tag", - "aria-label-select-tag-key": "Wybierz klucz tagu", - "aria-label-select-tag-operator": "Wybierz operator tagu", - "aria-label-select-tag-value": "Wybierz wartość tagu", - "placeholder-select-tag": "Wybierz tag", - "placeholder-select-value": "Wybierz wartość", - "placeholder-tag-value": "Wartość tagu", - "tooltip-add-tag": "Dodaj tag", - "tooltip-remove-tag": "Usuń tag" - }, "span-flame-graph": { "flame-graph": "Wykres płomienia" }, @@ -7922,23 +7936,18 @@ "label-upsample": "Zwiększ próbkowanie", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Uruchom zapytanie", - "modal-title": "Edytor SQL", "tooltip-experimental": "Integracja LLM z wyrażeniami SQL jest eksperymentalna. Wszelkie problemy zgłaszaj zespołowi Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Wejście" @@ -8013,11 +8022,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9463,6 +9468,7 @@ "name-unit": "Jednostka", "name-value-name": "Nazwa wartości", "name-y-axis-scale": "Skala osi Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automatycznie", "placeholder-axis-width": "Automatycznie", "placeholder-decimals": "Automatycznie", @@ -9492,6 +9498,18 @@ "label-all": "Wszystkie", "label-hidden": "Ukryte", "label-single": "Pojedyncza" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11295,9 +11313,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -12074,7 +12097,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Zobacz szczegóły", "loading-finished-job": "Wczytywanie zakończonego zadania…", @@ -12180,6 +12206,9 @@ "webhook-last-event": "Ostatnie zdarzenie:", "webhook-url": "Wyświetl element webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Wróć do repozytoriów", "cleaning-up-resources": "Sprzątanie zasobów repozytorium", @@ -12284,6 +12313,9 @@ "tooltip-unhealthy-repository": "Nie można pobrać danych z niesprawnego repozytorium" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12303,10 +12335,15 @@ }, "warning-title-default": "Ostrzeżenie", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Czas trwania tego procesu zależy od liczby zasobów.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12325,6 +12362,7 @@ "step-finish": "Wybierz dodatkowe ustawienia", "step-synchronize": "Synchronizuj z zewnętrzną pamięcią masową", "sync-description": "Zsynchronizuj zasoby z zewnętrzną pamięcią masową. Po tym jednorazowym kroku wszystkie przyszłe aktualizacje zostaną automatycznie zapisane w repozytorium i ponownie aprowizowane w instancji.", + "sync-option-migrate-resources": "", "title-bootstrap": "Wybierz, co chcesz zsynchronizować", "title-connect": "Połączenie z zewnętrzną pamięcią masową", "title-finish": "Wybierz dodatkowe ustawienia", @@ -12564,22 +12602,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 cbaee920be0..550ffe30a1f 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Insira o conteúdo da anotação personalizada…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "As regras foram excluídas da pasta" @@ -2203,11 +2208,15 @@ "min-interval": "Mín. Intervalo = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "As consultas e expressões selecionadas não podem ser convertidas para o padrão. Se você desativar as opções avançadas, sua consulta e condição serão redefinidas para as configurações padrão." }, + "last15m": "", + "last1h": "", "preview": "Visualizar", - "previewCondition": "Visualizar condição de regra de alerta" + "previewCondition": "Visualizar condição de regra de alerta", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrar por pontos de contato", @@ -2666,6 +2675,35 @@ "text-federated": "Federado", "text-provisioned": "Provisionado" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Fonte de dados", @@ -3717,7 +3755,10 @@ "text": "Nenhum resultado encontrado para sua consulta" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5926,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", @@ -6180,7 +6224,7 @@ "title-someone-else-has-updated-this-dashboard": "Outra pessoa atualizou este painel", "would-still-dashboard": "Deseja salvar este painel mesmo assim?" }, - "save-and-overwrite": "\"Salvar e substituir\"" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7209,9 @@ "time-range-label": "Bloquear intervalo de tempo" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Dica de especialistas: {{proTip}}" }, @@ -7641,39 +7688,6 @@ }, "share-span": "Compartilhar" }, - "span-filters": { - "aria-label-select-max-span-operator": "Selecionar operador de intervalo máximo", - "aria-label-select-min-span-operator": "Selecionar operador de intervalo mínimo", - "aria-label-select-service-name": "Selecionar nome do serviço", - "aria-label-select-service-name-operator": "Selecionar operador de nome do serviço", - "aria-label-select-span-name": "Selecionar nome do intervalo", - "aria-label-select-span-name-operator": "Selecione o operador de nome de intervalo", - "ariaLabel-select-max-span-duration": "Selecionar duração máxima do intervalo", - "ariaLabel-select-min-span-duration": "Selecionar duração mínima do intervalo", - "label-collapse": "Filtros de intervalo", - "label-duration": "Duração", - "label-service-name": "Nome do serviço", - "label-span-name": "Nome do intervalo", - "label-tags": "Tags", - "placeholder-all-service-names": "Todos os nomes de serviço", - "placeholder-all-span-names": "Todos os nomes de intervalo", - "tooltip-collapse": "Filtre seus intervalos abaixo. Você pode continuar aplicando filtros até restringir seus intervalos resultantes a um grupo pequeno que contenha aqueles que forem mais pertinentes para você.", - "tooltip-duration": "Filtrar por duração. As unidades aceitas são {{units}}", - "tooltip-tags": "Filtrar por tags, tags de processo ou campos de logs nos seus intervalos." - }, - "span-filters-tags": { - "aria-label-add-tag": "Adicionar tag", - "aria-label-input-tag-value": "Valor da tag de entrada", - "aria-label-remove-tag": "Remover tag", - "aria-label-select-tag-key": "Selecionar chave de tag", - "aria-label-select-tag-operator": "Selecionar operador de tag", - "aria-label-select-tag-value": "Selecionar valor da tag", - "placeholder-select-tag": "Selecionar tag", - "placeholder-select-value": "Selecionar valor", - "placeholder-tag-value": "Valor da tag", - "tooltip-add-tag": "Adicionar tag", - "tooltip-remove-tag": "Remover tag" - }, "span-flame-graph": { "flame-graph": "Gráfico de chama" }, @@ -7872,23 +7886,18 @@ "label-upsample": "Aumentar taxa de amostragem", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Executar consulta", - "modal-title": "Editor de SQL", "tooltip-experimental": "A integração do LLM de expressões SQL está em fase de testes. Informe à equipe do Grafana se surgir algum problema." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Entrada" @@ -7963,11 +7972,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9398,7 @@ "name-unit": "Unidade", "name-value-name": "Nome do valor", "name-y-axis-scale": "Escala do eixo Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automático", "placeholder-axis-width": "Automático", "placeholder-decimals": "Automático", @@ -9422,6 +9428,18 @@ "label-all": "Tudo", "label-hidden": "Oculto", "label-single": "Única" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11221,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11995,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Veja os detalhes", "loading-finished-job": "Carregando tarefa concluída…", @@ -12078,6 +12104,9 @@ "webhook-last-event": "Último evento:", "webhook-url": "Visualizar Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Voltar para os repositórios", "cleaning-up-resources": "Limpando recursos do repositório", @@ -12182,6 +12211,9 @@ "tooltip-unhealthy-repository": "Não é possível fazer extração de um repositório instável" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12233,15 @@ }, "warning-title-default": "Aviso", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "A duração deste processo depende da quantidade de recursos envolvidos.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12260,7 @@ "step-finish": "Escolha configurações adicionais", "step-synchronize": "Sincronizar com armazenamento externo", "sync-description": "Sincronize recursos com armazenamento externo. Após esta única etapa, todas as atualizações futuras serão salvas automaticamente no repositório e provisionadas de volta para a instância.", + "sync-option-migrate-resources": "", "title-bootstrap": "Escolha o que será sincronizado", "title-connect": "Conectar ao armazenamento externo", "title-finish": "Escolha configurações adicionais", @@ -12460,22 +12498,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 4e99ebc24f7..841d71ca037 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Introduzir o conteúdo da anotação personalizada..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Regras eliminadas da pasta com sucesso" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Intervalo = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "As consultas e expressões selecionadas não podem ser convertidas para padrão. Se desativar as opções avançadas, a sua consulta e condição serão repostas para as definições padrão." }, + "last15m": "", + "last1h": "", "preview": "Pré-visualizar", - "previewCondition": "Pré-visualizar a condição da regra de alerta" + "previewCondition": "Pré-visualizar a condição da regra de alerta", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrar por pontos de contacto", @@ -2666,6 +2675,35 @@ "text-federated": "Federado", "text-provisioned": "Aprovisionado" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Origem dos dados", @@ -3717,7 +3755,10 @@ "text": "Não foram encontrados resultados para a sua consulta" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5926,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", @@ -6180,7 +6224,7 @@ "title-someone-else-has-updated-this-dashboard": "Outra pessoa atualizou este painel de controlo", "would-still-dashboard": "Ainda pretende guardar este painel de controlo?" }, - "save-and-overwrite": "\"Guardar e substituir\"" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7209,9 @@ "time-range-label": "Bloquear intervalo de tempo" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Dica Pro: {{proTip}}" }, @@ -7641,39 +7688,6 @@ }, "share-span": "Partilhar" }, - "span-filters": { - "aria-label-select-max-span-operator": "Selecionar o operador de intervalo máximo", - "aria-label-select-min-span-operator": "Selecionar o operador de intervalo mínimo", - "aria-label-select-service-name": "Selecione o nome do serviço", - "aria-label-select-service-name-operator": "Selecionar o operador do nome do serviço", - "aria-label-select-span-name": "Selecionar o nome do intervalo", - "aria-label-select-span-name-operator": "Selecionar o operador do nome do intervalo", - "ariaLabel-select-max-span-duration": "Selecionar a duração máxima do intervalo", - "ariaLabel-select-min-span-duration": "Selecionar a duração mínima do intervalo", - "label-collapse": "Filtros de intervalo", - "label-duration": "Duração", - "label-service-name": "Nome do serviço", - "label-span-name": "Nome do intervalo", - "label-tags": "Etiquetas", - "placeholder-all-service-names": "Todos os nomes de serviços", - "placeholder-all-span-names": "Todos os nomes de intervalos", - "tooltip-collapse": "Filtre os seus intervalos abaixo. Pode continuar a aplicar filtros até ter limitado os seus períodos resultantes para apenas alguns que lhe interessem mais.", - "tooltip-duration": "Filtrar por duração. As unidades aceites são {{units}}", - "tooltip-tags": "Filtrar por etiquetas, etiquetas de processo ou campos de registo nos seus intervalos." - }, - "span-filters-tags": { - "aria-label-add-tag": "Adicionar etiqueta", - "aria-label-input-tag-value": "Valor da etiqueta de entrada", - "aria-label-remove-tag": "Remover controlo", - "aria-label-select-tag-key": "Selecionar a chave da etiqueta", - "aria-label-select-tag-operator": "Selecionar o operador da etiqueta", - "aria-label-select-tag-value": "Selecionar o valor da etiqueta", - "placeholder-select-tag": "Selecionar a etiqueta", - "placeholder-select-value": "Selecionar valor", - "placeholder-tag-value": "Valor da etiqueta", - "tooltip-add-tag": "Adicionar etiqueta", - "tooltip-remove-tag": "Remover controlo" - }, "span-flame-graph": { "flame-graph": "Gráfico de chama" }, @@ -7872,23 +7886,18 @@ "label-upsample": "Aumentar a quantidade de amostras", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Executar consulta", - "modal-title": "Editor SQL", "tooltip-experimental": "A integração de LLM de expressões SQL é experimental. Comunique quaisquer problemas à equipa da Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Entrada" @@ -7963,11 +7972,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9398,7 @@ "name-unit": "Unidade", "name-value-name": "Nome do valor", "name-y-axis-scale": "Escala do eixo Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automático", "placeholder-axis-width": "Automático", "placeholder-decimals": "Automático", @@ -9422,6 +9428,18 @@ "label-all": "Tudo", "label-hidden": "Oculto", "label-single": "Único" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11221,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11995,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Ver detalhes", "loading-finished-job": "A carregar o trabalho concluído...", @@ -12078,6 +12104,9 @@ "webhook-last-event": "Último evento:", "webhook-url": "Visualizar Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Voltar aos repositórios", "cleaning-up-resources": "A limpar recursos do repositório", @@ -12182,6 +12211,9 @@ "tooltip-unhealthy-repository": "Não foi possível obter um repositório que não está em bom estado" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12233,15 @@ }, "warning-title-default": "Aviso", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "A duração deste processo depende do número de recursos envolvidos.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12260,7 @@ "step-finish": "Escolha as definições adicionais", "step-synchronize": "Sincronizar com armazenamento externo", "sync-description": "Sincronize recursos com um armazenamento externo. Após este passo único, todas as atualizações futuras serão guardadas automaticamente no repositório e aprovisionadas novamente para a instância.", + "sync-option-migrate-resources": "", "title-bootstrap": "Escolha o que sincronizar", "title-connect": "Ligar a um armazenamento externo", "title-finish": "Escolha as definições adicionais", @@ -12460,22 +12498,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 3d29410a1bf..19f0c4563d4 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -729,6 +729,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Ввести содержимое пользовательской аннотации..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Правила удалены из папки" @@ -2219,11 +2224,15 @@ "min-interval": "Мин. интервал = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Выбранные запросы и выражения не могут быть преобразованы в используемые по умолчанию. Если вы отключите расширенные параметры, ваш запрос и условие будут сброшены до настроек по умолчанию." }, + "last15m": "", + "last1h": "", "preview": "Предварительный просмотр", - "previewCondition": "Предварительный просмотр условия правила оповещения" + "previewCondition": "Предварительный просмотр условия правила оповещения", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Фильтр по точкам контакта", @@ -2688,6 +2697,35 @@ "text-federated": "Федеративная", "text-provisioned": "Подготовлено" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Источник данных", @@ -3749,7 +3787,10 @@ "text": "По вашему запросу ничего не найдено" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5968,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": "Значок", @@ -6222,7 +6266,7 @@ "title-someone-else-has-updated-this-dashboard": "Дашборд обновлен другим пользователем", "would-still-dashboard": "Все равно сохранить дашборд?" }, - "save-and-overwrite": "'Сохранить и перезаписать'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7211,6 +7255,9 @@ "time-range-label": "Заблокировать временной диапазон" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Совет: {{proTip}}" }, @@ -7691,39 +7738,6 @@ }, "share-span": "Поделиться" }, - "span-filters": { - "aria-label-select-max-span-operator": "Выбрать оператора макс. диапазона", - "aria-label-select-min-span-operator": "Выбрать оператор мин. диапазона", - "aria-label-select-service-name": "Выбрать название службы", - "aria-label-select-service-name-operator": "Выбрать оператор названия службы", - "aria-label-select-span-name": "Выбрать название диапазона", - "aria-label-select-span-name-operator": "Выбрать оператора названия диапазона", - "ariaLabel-select-max-span-duration": "Выбрать макс. продолжительность интервала", - "ariaLabel-select-min-span-duration": "Выбрать мин. продолжительность интервала", - "label-collapse": "Фильтры диапазонов", - "label-duration": "Длительность", - "label-service-name": "Название службы", - "label-span-name": "Название диапазона", - "label-tags": "Теги", - "placeholder-all-service-names": "Все названия служб", - "placeholder-all-span-names": "Все названия диапазонов", - "tooltip-collapse": "Выполните фильтрацию своих диапазонов ниже. Вы можете продолжать применять фильтры, пока не сузите полученные диапазоны до нескольких наиболее важных для вас вариантов.", - "tooltip-duration": "Фильтр по длительности. Допустимые единицы измерения: {{units}}", - "tooltip-tags": "Фильтр по тегам, тегам процессов или полям журнала в ваших диапазонах." - }, - "span-filters-tags": { - "aria-label-add-tag": "Добавить тег", - "aria-label-input-tag-value": "Ввести значение тега", - "aria-label-remove-tag": "Удалить тег", - "aria-label-select-tag-key": "Выбрать ключ тега", - "aria-label-select-tag-operator": "Выбрать оператор тега", - "aria-label-select-tag-value": "Выбрать значение тега", - "placeholder-select-tag": "Выбрать тег", - "placeholder-select-value": "Выбрать значение", - "placeholder-tag-value": "Значение тега", - "tooltip-add-tag": "Добавить тег", - "tooltip-remove-tag": "Удалить тег" - }, "span-flame-graph": { "flame-graph": "Flame-график" }, @@ -7922,23 +7936,18 @@ "label-upsample": "Увеличить частоту выборки", "tooltip-s-m-h": "10 с, 1 мин, 30 мин, 1 ч" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Выполнить запрос", - "modal-title": "Редактор SQL", "tooltip-experimental": "Интеграция LLM с SQL-выражениями является экспериментальной. При обнаружении проблем свяжитесь с командой Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Ввод" @@ -8013,11 +8022,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9463,6 +9468,7 @@ "name-unit": "Единица", "name-value-name": "Имя значения", "name-y-axis-scale": "Шкала оси Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Авто", "placeholder-axis-width": "Авто", "placeholder-decimals": "Авто", @@ -9492,6 +9498,18 @@ "label-all": "Все", "label-hidden": "Скрыты", "label-single": "Один" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11295,9 +11313,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -12074,7 +12097,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Подробнее", "loading-finished-job": "Загрузка завершенного задания...", @@ -12180,6 +12206,9 @@ "webhook-last-event": "Последнее событие:", "webhook-url": "Просмотр веб-перехватчика" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Назад к репозиториям", "cleaning-up-resources": "Очистка ресурсов репозитория", @@ -12284,6 +12313,9 @@ "tooltip-unhealthy-repository": "Невозможно внести изменения в неисправный репозиторий" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12303,10 +12335,15 @@ }, "warning-title-default": "Предупреждение", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Длительность процесса зависит от количества задействованных ресурсов.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12325,6 +12362,7 @@ "step-finish": "Выберите дополнительные параметры", "step-synchronize": "Синхронизировать с внешним хранилищем", "sync-description": "Синхронизируйте ресурсы с внешним хранилищем. После этого разового шага все будущие обновления будут автоматически сохраняться в репозитории и загружаться обратно в экземпляр.", + "sync-option-migrate-resources": "", "title-bootstrap": "Выберите, что синхронизировать", "title-connect": "Подключение к внешнему хранилищу", "title-finish": "Выбор дополнительных параметров", @@ -12564,22 +12602,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 38943db06ed..383543aed5e 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Ange innehåll för anpassad kommentar …" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Reglerna har raderats från mappen" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Intervall = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "De valda frågorna och uttrycken kan inte konverteras till standard. Om du inaktiverar avancerade alternativ kommer din fråga och ditt villkor att återställas till standardinställningarna." }, + "last15m": "", + "last1h": "", "preview": "Förhandsgranska", - "previewCondition": "Förhandsgranska varningsregeltillstånd" + "previewCondition": "Förhandsgranska varningsregeltillstånd", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrera efter kontaktpunkter", @@ -2666,6 +2675,35 @@ "text-federated": "Federerade", "text-provisioned": "Provisionerad" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Datakälla", @@ -3717,7 +3755,10 @@ "text": "Inga resultat hittades för din fråga" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5926,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", @@ -6180,7 +6224,7 @@ "title-someone-else-has-updated-this-dashboard": "Någon annan har uppdaterat denna instrumentpanel", "would-still-dashboard": "Vill du fortfarande spara denna instrumentpanel?" }, - "save-and-overwrite": "”Spara och skriv över”" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7209,9 @@ "time-range-label": "Lås tidsintervall" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "ProTip: {{proTip}}" }, @@ -7641,39 +7688,6 @@ }, "share-span": "Dela" }, - "span-filters": { - "aria-label-select-max-span-operator": "Välj operator för maximalt intervall", - "aria-label-select-min-span-operator": "Välj operator för minsta intervall", - "aria-label-select-service-name": "Välj namn för tjänst", - "aria-label-select-service-name-operator": "Välj operator för tjänstnamn", - "aria-label-select-span-name": "Välj namn på intervall", - "aria-label-select-span-name-operator": "Välj operator för intervallnamn", - "ariaLabel-select-max-span-duration": "Välj maximal varaktighet för tidsspann", - "ariaLabel-select-min-span-duration": "Välj minimal varaktighet för tidsspann", - "label-collapse": "Intervallfilter", - "label-duration": "Varaktighet", - "label-service-name": "Namn på tjänst", - "label-span-name": "Intervallnamn", - "label-tags": "Taggar", - "placeholder-all-service-names": "Alla servicenamn", - "placeholder-all-span-names": "Alla namn på intervall", - "tooltip-collapse": "Filtrera dina spann nedan. Du kan fortsätta att tillämpa filter tills du har begränsat dina resulterande spann till det fåtal som du är mest intresserad av.", - "tooltip-duration": "Filtrera per varaktighet. Accepterade enheter är {{units}}", - "tooltip-tags": "Filtrera efter taggar, processtaggar eller loggfält i dina spår." - }, - "span-filters-tags": { - "aria-label-add-tag": "Lägg till etikett", - "aria-label-input-tag-value": "Ange taggvärde", - "aria-label-remove-tag": "Ta bort tagg", - "aria-label-select-tag-key": "Välj etikettnyckel", - "aria-label-select-tag-operator": "Välj etikettoperator", - "aria-label-select-tag-value": "Välj etikettvärde", - "placeholder-select-tag": "Välj etikett", - "placeholder-select-value": "Välj värde", - "placeholder-tag-value": "Etikettvärde", - "tooltip-add-tag": "Lägg till etikett", - "tooltip-remove-tag": "Ta bort tagg" - }, "span-flame-graph": { "flame-graph": "Flamgraf" }, @@ -7872,23 +7886,18 @@ "label-upsample": "Sampla upp", "tooltip-s-m-h": "10 s, 1 m, 30 m, 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Kör fråga", - "modal-title": "SQL-redigerare", "tooltip-experimental": "Integreringen för SQL Expressions LLM är i ett experimentellt skede. Rapportera alla problem du stöter på till Grafana-teamet." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Ingång" @@ -7963,11 +7972,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9398,7 @@ "name-unit": "Enhet", "name-value-name": "Värdenamn", "name-y-axis-scale": "Y-axelns skala", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9422,6 +9428,18 @@ "label-all": "Alla", "label-hidden": "Dolt", "label-single": "Singel" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11221,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11995,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Visa detaljer", "loading-finished-job": "Läser in färdigt jobb …", @@ -12078,6 +12104,9 @@ "webhook-last-event": "Senaste händelsen:", "webhook-url": "Visa webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Tillbaka till lagringsplatserna", "cleaning-up-resources": "Rensa lagringsplatsresurser", @@ -12182,6 +12211,9 @@ "tooltip-unhealthy-repository": "Det gick inte att hämta en ohälsosam lagringsplats" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12233,15 @@ }, "warning-title-default": "Varning", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Hur lång den här processen är beror på hur många resurser som är inblandade.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12260,7 @@ "step-finish": "Välj ytterligare inställningar", "step-synchronize": "Synkronisera med extern lagring", "sync-description": "Synkronisera resurser med extern lagring. Efter det här engångssteget sparas alla framtida uppdateringar automatiskt på lagringsplatsen och provisioneras tillbaka till instansen.", + "sync-option-migrate-resources": "", "title-bootstrap": "Välj vad du vill synkronisera", "title-connect": "Anslut till extern lagring", "title-finish": "Välj ytterligare inställningar", @@ -12460,22 +12498,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 07a0b6673ac..ff539a2a51f 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Özel ek açıklama içeriği girin..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Kurallar klasörden başarıyla silindi" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Aralık = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Seçilen sorgular ve ifadeler varsayılana dönüştürülemez. Gelişmiş seçenekleri devre dışı bırakırsanız sorgunuz ve koşulunuz varsayılan ayarlara sıfırlanır." }, + "last15m": "", + "last1h": "", "preview": "Ön izleme", - "previewCondition": "Uyarı kuralı koşulunu ön izle" + "previewCondition": "Uyarı kuralı koşulunu ön izle", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "", @@ -2666,6 +2675,35 @@ "text-federated": "Şirket dışı", "text-provisioned": "Sağlanan" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Veri kaynağı", @@ -3717,7 +3755,10 @@ "text": "Sorgunuz için sonuç bulunamadı" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5926,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", @@ -6180,7 +6224,7 @@ "title-someone-else-has-updated-this-dashboard": "Başka biri bu panoyu güncelledi", "would-still-dashboard": "Yine de bu panoyu kaydetmek istiyor musunuz?" }, - "save-and-overwrite": "\"Kaydet ve üzerine yaz\"" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7209,9 @@ "time-range-label": "Zaman aralığını kilitle" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Uzman ipucu: {{proTip}}" }, @@ -7641,39 +7688,6 @@ }, "share-span": "Paylaş" }, - "span-filters": { - "aria-label-select-max-span-operator": "Maksimum zaman aralığı işlecini seçin", - "aria-label-select-min-span-operator": "Minimum zaman aralığı işlecini seçin", - "aria-label-select-service-name": "Hizmet adını seç", - "aria-label-select-service-name-operator": "Hizmet adını işlecini seç", - "aria-label-select-span-name": "Zaman aralığı adı seç", - "aria-label-select-span-name-operator": "Zaman aralığı adı işleci seçin", - "ariaLabel-select-max-span-duration": "Maksimum zaman aralığı süresini seçin", - "ariaLabel-select-min-span-duration": "Minimum zaman aralığı süresini seçin", - "label-collapse": "Zaman aralığı filtreleri", - "label-duration": "Süre", - "label-service-name": "Hizmet adı", - "label-span-name": "Zaman aralığı adı", - "label-tags": "Etiketler", - "placeholder-all-service-names": "Tüm hizmet adları", - "placeholder-all-span-names": "Tüm zaman aralığı adları", - "tooltip-collapse": "Aşağıdaki zaman aralıklarınızı filtreleyin. İlginizi en çok çeken birkaç sonucu daraltana kadar filtre uygulamaya devam edebilirsiniz.", - "tooltip-duration": "Süreye göre filtreleyin. Kabul edilen birimler: {{units}}", - "tooltip-tags": "Zamanlardaki etiketlere, işlem etiketlerine veya günlük kaydı alanlarına göre filtreleyin." - }, - "span-filters-tags": { - "aria-label-add-tag": "Etiket ekle", - "aria-label-input-tag-value": "Etiket değeri girin", - "aria-label-remove-tag": "Etiketi kaldır", - "aria-label-select-tag-key": "Etiket anahtarını seçin", - "aria-label-select-tag-operator": "Etiket işlecini seçin", - "aria-label-select-tag-value": "Etiket değerini seçin", - "placeholder-select-tag": "Etiket seçin", - "placeholder-select-value": "Değer seçin", - "placeholder-tag-value": "Etiket değeri", - "tooltip-add-tag": "Etiket ekle", - "tooltip-remove-tag": "Etiketi kaldır" - }, "span-flame-graph": { "flame-graph": "Alev grafiği" }, @@ -7872,23 +7886,18 @@ "label-upsample": "Örneklemeyi artır", "tooltip-s-m-h": "10 sn, 1 dk, 30 dk, 1 sa" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "", - "modal-title": "", "tooltip-experimental": "" }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Girdi" @@ -7963,11 +7972,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9398,7 @@ "name-unit": "Birim", "name-value-name": "Değer adı", "name-y-axis-scale": "Y ekseni ölçeği", + "name-y-bucket-scale": "", "placeholder-axis-label": "Otomatik", "placeholder-axis-width": "Otomatik", "placeholder-decimals": "Otomatik", @@ -9422,6 +9428,18 @@ "label-all": "Tümü", "label-hidden": "Gizli", "label-single": "Tek" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11221,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11995,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Ayrıntıları görüntüleyin", "loading-finished-job": "Tamamlanan iş yükleniyor...", @@ -12078,6 +12104,9 @@ "webhook-last-event": "Son Olay:", "webhook-url": "Web Kancasını Görüntüle" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Depolara geri dön", "cleaning-up-resources": "Depo kaynakları temizleniyor", @@ -12182,6 +12211,9 @@ "tooltip-unhealthy-repository": "İyi durumda olmayan bir depo çekilemedi" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12233,15 @@ }, "warning-title-default": "Uyarı", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Bu işlemin süresi dâhil olan kaynakların sayısına bağlıdır.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12260,7 @@ "step-finish": "Ek ayarlar seçin", "step-synchronize": "Harici depolama ile senkronize et", "sync-description": "Kaynakları harici depolama ile senkronize edin. Bu tek seferlik adımdan sonra tüm gelecekteki güncellemeler otomatik olarak depoya kaydedilecek ve örneğe geri sağlanacaktır.", + "sync-option-migrate-resources": "", "title-bootstrap": "Nelerin senkronize edileceğini seçin", "title-connect": "Harici depolamaya bağlan", "title-finish": "Ek ayarlar seçin", @@ -12460,22 +12498,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 a1eb5ea1fae..0c3a6206bd7 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "输入自定义注释内容..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "规则已成功从文件夹中删除" @@ -2195,11 +2200,15 @@ "min-interval": "最小间隔 = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "无法将所选查询和表达式转换为默认值。如果停用高级选项,您的查询和条件将重置为默认设置。" }, + "last15m": "", + "last1h": "", "preview": "预览", - "previewCondition": "预览提醒规则条件" + "previewCondition": "预览提醒规则条件", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "按联络点筛选", @@ -2655,6 +2664,35 @@ "text-federated": "联合", "text-provisioned": "已预置" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "数据源", @@ -3701,7 +3739,10 @@ "text": "未找到与您的查询相关的结果" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5905,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": "图标", @@ -6159,7 +6203,7 @@ "title-someone-else-has-updated-this-dashboard": "其他人已更新此数据面板", "would-still-dashboard": "您仍然要保存此数据面板吗?" }, - "save-and-overwrite": "‘保存并覆盖’" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7186,9 @@ "time-range-label": "锁定时间范围" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "专业提示:{{proTip}}" }, @@ -7616,39 +7663,6 @@ }, "share-span": "共享" }, - "span-filters": { - "aria-label-select-max-span-operator": "选择最大跨度运算符", - "aria-label-select-min-span-operator": "选择最小跨度运算符", - "aria-label-select-service-name": "选择服务名称", - "aria-label-select-service-name-operator": "选择服务名称运算符", - "aria-label-select-span-name": "选择跨度名称", - "aria-label-select-span-name-operator": "选择跨度名称运算符", - "ariaLabel-select-max-span-duration": "选择最大跨度持续时间", - "ariaLabel-select-min-span-duration": "选择最小跨度持续时间", - "label-collapse": "跨度筛选器", - "label-duration": "持续时间", - "label-service-name": "服务名称", - "label-span-name": "跨度名称", - "label-tags": "标签", - "placeholder-all-service-names": "所有服务名称", - "placeholder-all-span-names": "所有跨度名称", - "tooltip-collapse": "在下方筛选您的跨度。您可以继续应用筛选条件,直到将结果跨度缩小到您最感兴趣的少数几个。", - "tooltip-duration": "按持续时间筛选。可接受的单位是 {{units}}", - "tooltip-tags": "根据跨度中的标记、流程标记或日志字段进行筛选。" - }, - "span-filters-tags": { - "aria-label-add-tag": "添加标记", - "aria-label-input-tag-value": "输入标记值", - "aria-label-remove-tag": "移除标记", - "aria-label-select-tag-key": "选择标记键", - "aria-label-select-tag-operator": "选择标记运算符", - "aria-label-select-tag-value": "选择标记值", - "placeholder-select-tag": "选择标记", - "placeholder-select-value": "选择值", - "placeholder-tag-value": "标记值", - "tooltip-add-tag": "添加标记", - "tooltip-remove-tag": "移除标记" - }, "span-flame-graph": { "flame-graph": "火焰图" }, @@ -7847,23 +7861,18 @@ "label-upsample": "升高采样", "tooltip-s-m-h": "10 秒、1 分钟、30 分钟、1 小时" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "运行查询", - "modal-title": "SQL 编辑器", "tooltip-experimental": "SQL 表达式 LLM 集成是实验性的。若有任何问题,请向 Grafana 团队报告。" }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "输入" @@ -7938,11 +7947,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9363,7 @@ "name-unit": "单位", "name-value-name": "值名称", "name-y-axis-scale": "Y 轴比例", + "name-y-bucket-scale": "", "placeholder-axis-label": "自动", "placeholder-axis-width": "自动", "placeholder-decimals": "自动", @@ -9387,6 +9393,18 @@ "label-all": "全部", "label-hidden": "隐藏", "label-single": "单一" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11175,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11944,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "查看详情", "loading-finished-job": "正在加载已完成的作业...", @@ -12027,6 +12053,9 @@ "webhook-last-event": "最后一个事件:", "webhook-url": "查看 Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "回到存储库", "cleaning-up-resources": "清理存储库资源", @@ -12131,6 +12160,9 @@ "tooltip-unhealthy-repository": "无法拉取状态不良的存储库" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12182,15 @@ }, "warning-title-default": "警告", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "此过程的持续时间取决于所涉及资源的数量。", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12209,7 @@ "step-finish": "选择附加设置", "step-synchronize": "与外部存储同步", "sync-description": "将资源与外部存储同步。完成此一次性步骤后,所有后续更新都将自动保存到存储库中,并预配回实例。", + "sync-option-migrate-resources": "", "title-bootstrap": "选择要同步的内容", "title-connect": "连接到外部存储", "title-finish": "选择附加设置", @@ -12408,22 +12446,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 df0a64f1c1e..215ad1f150f 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "輸入自訂註解內容…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "已成功從資料夾中刪除規則" @@ -2195,11 +2200,15 @@ "min-interval": "最小間隔 = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "所選查詢和表達式無法轉換為預設值。如果停用進階選項,您的查詢和條件將重設為預設設定。" }, + "last15m": "", + "last1h": "", "preview": "預覽", - "previewCondition": "預覽警報規則條件" + "previewCondition": "預覽警報規則條件", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "按聯絡點篩選", @@ -2655,6 +2664,35 @@ "text-federated": "聯合", "text-provisioned": "已佈建" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "資料來源", @@ -3701,7 +3739,10 @@ "text": "未找到您的查詢結果" }, "recently-viewed": { + "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { @@ -5905,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": "圖示", @@ -6159,7 +6203,7 @@ "title-someone-else-has-updated-this-dashboard": "其他人已更新此儀表板", "would-still-dashboard": "仍要儲存此儀表板嗎?" }, - "save-and-overwrite": "「儲存並覆寫」" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7186,9 @@ "time-range-label": "鎖定時間範圍" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "專業提示:{{proTip}}" }, @@ -7616,39 +7663,6 @@ }, "share-span": "分享" }, - "span-filters": { - "aria-label-select-max-span-operator": "選擇最大範圍的運算子", - "aria-label-select-min-span-operator": "選取最小範圍的運算子", - "aria-label-select-service-name": "選取服務名稱", - "aria-label-select-service-name-operator": "選取服務名稱運算子", - "aria-label-select-span-name": "選取範圍名稱", - "aria-label-select-span-name-operator": "選擇範圍名稱運算子", - "ariaLabel-select-max-span-duration": "選擇最大跨度持續時間", - "ariaLabel-select-min-span-duration": "選取最小跨度持續時間", - "label-collapse": "範圍篩選器", - "label-duration": "持續時間", - "label-service-name": "服務名稱", - "label-span-name": "範圍名稱", - "label-tags": "標籤", - "placeholder-all-service-names": "所有服務名稱", - "placeholder-all-span-names": "所有範圍名稱", - "tooltip-collapse": "篩選下方的範圍。您可以繼續套用篩選條件,直到將結果範圍縮小到您最感興趣的幾個範圍。", - "tooltip-duration": "依持續時間篩選。可接受的單元為 {{units}}", - "tooltip-tags": "按範圍中的標記、流程標記或紀錄欄位篩選。" - }, - "span-filters-tags": { - "aria-label-add-tag": "新增標記", - "aria-label-input-tag-value": "輸入標記值", - "aria-label-remove-tag": "移除標記", - "aria-label-select-tag-key": "選擇標記鍵", - "aria-label-select-tag-operator": "選擇標記運算子", - "aria-label-select-tag-value": "選擇標記值", - "placeholder-select-tag": "選擇標記", - "placeholder-select-value": "選擇值", - "placeholder-tag-value": "標記值", - "tooltip-add-tag": "新增標記", - "tooltip-remove-tag": "移除標記" - }, "span-flame-graph": { "flame-graph": "火焰圖" }, @@ -7847,23 +7861,18 @@ "label-upsample": "向上取樣", "tooltip-s-m-h": "10 秒、1 分鐘、30 分鐘、1 小時" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "執行查詢", - "modal-title": "SQL 編輯器", "tooltip-experimental": "SQL 運算式 LLM 整合為實驗性功能。如有任何問題,請向 Grafana 團隊回報。" }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "輸入" @@ -7938,11 +7947,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9363,7 @@ "name-unit": "單位", "name-value-name": "值名稱", "name-y-axis-scale": "Y 軸刻度", + "name-y-bucket-scale": "", "placeholder-axis-label": "自動", "placeholder-axis-width": "自動", "placeholder-decimals": "自動", @@ -9387,6 +9393,18 @@ "label-all": "全部", "label-hidden": "隱藏", "label-single": "單一" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11175,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11944,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "檢視詳細資料", "loading-finished-job": "正在載入已完成的作業…", @@ -12027,6 +12053,9 @@ "webhook-last-event": "上次事件:", "webhook-url": "檢視 Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "返回至儲存庫", "cleaning-up-resources": "清理儲存庫資源", @@ -12131,6 +12160,9 @@ "tooltip-unhealthy-repository": "無法拉取狀態不佳的儲存庫" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12182,15 @@ }, "warning-title-default": "警告", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "此流程的持續時間取決於所涉及的資源數量。", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12209,7 @@ "step-finish": "選擇附加設定", "step-synchronize": "與外部儲存空間同步", "sync-description": "將資源與外部儲存空間同步。在此一次性步驟之後,未來的所有更新都將自動儲存到儲存庫中,並佈建回執行個體。", + "sync-option-migrate-resources": "", "title-bootstrap": "選擇要同步的內容", "title-connect": "連接到外部儲存空間", "title-finish": "選擇附加設定", @@ -12408,22 +12446,33 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", "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 - $('