diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index c1b2c7acd25..646ceed9a86 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -157,7 +157,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-20251223160021-926c74910196 // 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..750d9f97fc5 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -619,8 +619,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-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/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..fb624d65db3 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-20251223160021-926c74910196 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..0835100976a 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-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/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/iam/go.mod b/apps/iam/go.mod index 24769ca825f..54689bc54f3 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -223,7 +223,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-20251223160021-926c74910196 // 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..28bf1486774 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -827,8 +827,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-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index edcf18ea3e3..a2657edda7a 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -90,7 +90,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-20251223160021-926c74910196 // 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/plugins/go.sum b/apps/plugins/go.sum index c5fbc7a39a5..f0c923083af 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -213,8 +213,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-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/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/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/docs/sources/alerting/alerting-rules/alerting-migration.md b/docs/sources/alerting/alerting-rules/alerting-migration.md index 3afa3aec453..ab2a5e995cd 100644 --- a/docs/sources/alerting/alerting-rules/alerting-migration.md +++ b/docs/sources/alerting/alerting-rules/alerting-migration.md @@ -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/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/go.mod b/go.mod index fb1ab1ce189..f22d410c51f 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-20251223160021-926c74910196 // @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 diff --git a/go.sum b/go.sum index c1a8d8ad808..069d53dd5e9 100644 --- a/go.sum +++ b/go.sum @@ -1622,8 +1622,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-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/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..b50a074e4a2 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, @@ -663,6 +664,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 */ 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-ui/src/components/RadialGauge/RadialSparkline.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx index 2d6c45a14bf..4a52d5241d5 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx @@ -67,7 +67,7 @@ export const RadialSparkline = memo( return (
- +
); } diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index c18b235e757..d1fb4f3b0e0 100644 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -14,18 +14,18 @@ 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; - - const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, fieldConfig); +export const SparklineFn: React.FC = memo((props) => { + const { sparkline, config: fieldConfig, theme, width, height, showHighlights } = props; + const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights); if (warning) { return null; } const data = preparePlotData2(alignedDataFrame, getStackingGroups(alignedDataFrame)); - const configBuilder = prepareConfig(sparkline, alignedDataFrame, theme); + const configBuilder = prepareConfig(sparkline, alignedDataFrame, theme, showHighlights); return ; }); 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/pkg/api/alerting.go b/pkg/api/alerting.go index 8fb2f366bf2..27abb6ebbfc 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/api/response" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" - "github.com/grafana/grafana/pkg/services/ngalert/models" ) func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) response.Response { @@ -24,13 +23,13 @@ func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) respons } type NotifierPlugin struct { - Type string `json:"type"` - TypeAlias string `json:"typeAlias,omitempty"` - Name string `json:"name"` - Heading string `json:"heading"` - Description string `json:"description"` - Info string `json:"info"` - Options []Field `json:"options"` + Type string `json:"type"` + TypeAlias string `json:"typeAlias,omitempty"` + Name string `json:"name"` + Heading string `json:"heading"` + Description string `json:"description"` + Info string `json:"info"` + Options []schema.Field `json:"options"` } result := make([]*NotifierPlugin, 0, len(v2)) @@ -45,56 +44,9 @@ func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) respons Description: s.Description, Heading: s.Heading, Info: s.Info, - Options: schemaFieldsToFields(s.Type, nil, v1.Options), + Options: v1.Options, }) } return response.JSON(http.StatusOK, result) } } - -type Field struct { - Element schema.ElementType `json:"element"` - InputType schema.InputType `json:"inputType"` - Label string `json:"label"` - Description string `json:"description"` - Placeholder string `json:"placeholder"` - PropertyName string `json:"propertyName"` - SelectOptions []schema.SelectOption `json:"selectOptions"` - ShowWhen schema.ShowWhen `json:"showWhen"` - Required bool `json:"required"` - Protected bool `json:"protected,omitempty"` - ValidationRule string `json:"validationRule"` - Secure bool `json:"secure"` - DependsOn string `json:"dependsOn"` - SubformOptions []Field `json:"subformOptions"` -} - -func schemaFieldsToFields(iType schema.IntegrationType, parent schema.IntegrationFieldPath, fields []schema.Field) []Field { - if fields == nil { - return nil - } - result := make([]Field, 0, len(fields)) - for _, f := range fields { - result = append(result, schemaFieldToField(iType, parent, f)) - } - return result -} - -func schemaFieldToField(iType schema.IntegrationType, parent schema.IntegrationFieldPath, f schema.Field) Field { - return Field{ - Element: f.Element, - InputType: f.InputType, - Label: f.Label, - Description: f.Description, - Placeholder: f.Placeholder, - PropertyName: f.PropertyName, - SelectOptions: f.SelectOptions, - ShowWhen: f.ShowWhen, - Required: f.Required, - ValidationRule: f.ValidationRule, - Secure: f.Secure, - DependsOn: f.DependsOn, - SubformOptions: schemaFieldsToFields(iType, append(parent, f.PropertyName), f.SubformOptions), - Protected: models.IsProtectedField(iType, append(parent, f.PropertyName)), - } -} 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/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/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/validation/api_ruler_validation.go b/pkg/services/ngalert/api/validation/api_ruler_validation.go index c2baf8108ba..5a74c58f90e 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 } 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/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/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/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_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index 0733e8241e6..743f386ff52 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -190,6 +190,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 +538,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") 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..50c92e4d069 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": "", @@ -9237,6 +9250,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -11515,6 +11529,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "", @@ -12308,6 +12323,7 @@ "is": "" }, "required": false, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -13001,6 +13017,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -13443,6 +13460,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -13641,6 +13659,7 @@ "is": "" }, "required": false, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -15072,6 +15091,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "secret", 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/openapi_snapshots/dashboard.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json index b65fa2ad0d7..61834093866 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", 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/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/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 && (