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 && (