mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Update rule API to address folders by UID (#74600)
* Change ruler API to expect the folder UID as namespace * Update example requests * Fix tests * Update swagger * Modify FIle field in /api/prometheus/grafana/api/v1/rules * Fix ruler export * Modify folder in responses to be formatted as <parent UID>/<title> * Add alerting test with nested folders * Apply suggestion from code review * Alerting: use folder UID instead of title in rule API (#77166) Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com> * Drop a few more latent uses of namespace_id * move getNamespaceKey to models package * switch GetAlertRulesForScheduling to use folder table * update GetAlertRulesForScheduling to return folder titles in format `parent_uid/title`. * fi tests * add tests for GetAlertRulesForScheduling when parent uid * fix integration tests after merge * fix test after merge * change format of the namespace to JSON array this is needed for forward compatibility, when we migrate to full paths * update EF code to decode nested folder --------- Co-authored-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com> Co-authored-by: Virginia Cepeda <virginia.cepeda@grafana.com> Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com> Co-authored-by: Alex Weaver <weaver.alex.d@gmail.com> Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
parent
ec1d4274ed
commit
d1dab5828d
@ -304,7 +304,7 @@ func (srv PrometheusSrv) toRuleGroup(groupKey ngmodels.AlertRuleGroupKey, folder
|
||||
newGroup := &apimodels.RuleGroup{
|
||||
Name: groupKey.RuleGroup,
|
||||
// file is what Prometheus uses for provisioning, we replace it with namespace which is the folder in Grafana.
|
||||
File: folder.Title,
|
||||
File: ngmodels.GetNamespaceKey(folder.ParentUID, folder.Title),
|
||||
}
|
||||
|
||||
rulesTotals := make(map[string]int64, len(rules))
|
||||
|
@ -52,8 +52,8 @@ var (
|
||||
// or, if non-empty, a specific group of rules in the namespace.
|
||||
// Returns http.StatusForbidden if user does not have access to any of the rules that match the filter.
|
||||
// Returns http.StatusBadRequest if all rules that match the filter and the user is authorized to delete are provisioned.
|
||||
func (srv RulerSrv) RouteDeleteAlertRules(c *contextmodel.ReqContext, namespaceTitle string, group string) response.Response {
|
||||
namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser)
|
||||
func (srv RulerSrv) RouteDeleteAlertRules(c *contextmodel.ReqContext, namespaceUID string, group string) response.Response {
|
||||
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser)
|
||||
if err != nil {
|
||||
return toNamespaceErrorResponse(err)
|
||||
}
|
||||
@ -144,8 +144,8 @@ func (srv RulerSrv) RouteDeleteAlertRules(c *contextmodel.ReqContext, namespaceT
|
||||
}
|
||||
|
||||
// RouteGetNamespaceRulesConfig returns all rules in a specific folder that user has access to
|
||||
func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, namespaceTitle string) response.Response {
|
||||
namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser)
|
||||
func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, namespaceUID string) response.Response {
|
||||
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser)
|
||||
if err != nil {
|
||||
return toNamespaceErrorResponse(err)
|
||||
}
|
||||
@ -162,8 +162,9 @@ func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, nam
|
||||
result := apimodels.NamespaceConfigResponse{}
|
||||
|
||||
for groupKey, rules := range ruleGroups {
|
||||
key := ngmodels.GetNamespaceKey(namespace.ParentUID, namespace.Title)
|
||||
// nolint:staticcheck
|
||||
result[namespaceTitle] = append(result[namespaceTitle], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
|
||||
result[key] = append(result[key], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusAccepted, result)
|
||||
@ -171,8 +172,8 @@ func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *contextmodel.ReqContext, nam
|
||||
|
||||
// RouteGetRulesGroupConfig returns rules that belong to a specific group in a specific namespace (folder).
|
||||
// If user does not have access to at least one of the rule in the group, returns status 403 Forbidden
|
||||
func (srv RulerSrv) RouteGetRulesGroupConfig(c *contextmodel.ReqContext, namespaceTitle string, ruleGroup string) response.Response {
|
||||
namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser)
|
||||
func (srv RulerSrv) RouteGetRulesGroupConfig(c *contextmodel.ReqContext, namespaceUID string, ruleGroup string) response.Response {
|
||||
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser)
|
||||
if err != nil {
|
||||
return toNamespaceErrorResponse(err)
|
||||
}
|
||||
@ -241,15 +242,15 @@ func (srv RulerSrv) RouteGetRulesConfig(c *contextmodel.ReqContext) response.Res
|
||||
srv.log.Error("Namespace not visible to the user", "user", id, "userNamespace", userNamespace, "namespace", groupKey.NamespaceUID)
|
||||
continue
|
||||
}
|
||||
namespace := folder.Title
|
||||
key := ngmodels.GetNamespaceKey(folder.ParentUID, folder.Title)
|
||||
// nolint:staticcheck
|
||||
result[namespace] = append(result[namespace], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
|
||||
result[key] = append(result[key], toGettableRuleGroupConfig(groupKey.RuleGroup, rules, provenanceRecords))
|
||||
}
|
||||
return response.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceTitle string) response.Response {
|
||||
namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser)
|
||||
func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceUID string) response.Response {
|
||||
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser)
|
||||
if err != nil {
|
||||
return toNamespaceErrorResponse(err)
|
||||
}
|
||||
|
@ -13,9 +13,9 @@ import (
|
||||
)
|
||||
|
||||
// ExportFromPayload converts the rule groups from the argument `ruleGroupConfig` to export format. All rules are expected to be fully specified. The access to data sources mentioned in the rules is not enforced.
|
||||
// Can return 403 StatusForbidden if user is not authorized to read folder `namespaceTitle`
|
||||
func (srv RulerSrv) ExportFromPayload(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceTitle string) response.Response {
|
||||
namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser)
|
||||
// Can return 403 StatusForbidden if user is not authorized to read folder `namespaceUID`
|
||||
func (srv RulerSrv) ExportFromPayload(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceUID string) response.Response {
|
||||
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser)
|
||||
if err != nil {
|
||||
return toNamespaceErrorResponse(err)
|
||||
}
|
||||
|
@ -52,7 +52,7 @@ func TestExportFromPayload(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Header.Add("Accept", "application/yaml")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response := srv.ExportFromPayload(rc, body, folder.UID)
|
||||
|
||||
response.WriteTo(rc)
|
||||
|
||||
@ -64,7 +64,7 @@ func TestExportFromPayload(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Form.Set("format", "yaml")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response := srv.ExportFromPayload(rc, body, folder.UID)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
@ -75,7 +75,7 @@ func TestExportFromPayload(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Form.Set("format", "foo")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response := srv.ExportFromPayload(rc, body, folder.UID)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
@ -86,7 +86,7 @@ func TestExportFromPayload(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Header.Add("Accept", "application/json")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response := srv.ExportFromPayload(rc, body, folder.UID)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
@ -97,7 +97,7 @@ func TestExportFromPayload(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Header.Add("Accept", "application/json, application/yaml")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response := srv.ExportFromPayload(rc, body, folder.UID)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
@ -108,7 +108,7 @@ func TestExportFromPayload(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Form.Set("download", "true")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response := srv.ExportFromPayload(rc, body, folder.UID)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
@ -119,7 +119,7 @@ func TestExportFromPayload(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Form.Set("download", "false")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response := srv.ExportFromPayload(rc, body, folder.UID)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
@ -129,7 +129,7 @@ func TestExportFromPayload(t *testing.T) {
|
||||
t.Run("query param download not set, GET returns empty content disposition", func(t *testing.T) {
|
||||
rc := createRequest()
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response := srv.ExportFromPayload(rc, body, folder.UID)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
@ -143,7 +143,7 @@ func TestExportFromPayload(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Header.Add("Accept", "application/json")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response := srv.ExportFromPayload(rc, body, folder.UID)
|
||||
response.WriteTo(rc)
|
||||
t.Log(string(response.Body()))
|
||||
|
||||
@ -158,7 +158,7 @@ func TestExportFromPayload(t *testing.T) {
|
||||
rc := createRequest()
|
||||
rc.Context.Req.Header.Add("Accept", "application/yaml")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response := srv.ExportFromPayload(rc, body, folder.UID)
|
||||
response.WriteTo(rc)
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Equal(t, string(expectedResponse), string(response.Body()))
|
||||
@ -172,7 +172,7 @@ func TestExportFromPayload(t *testing.T) {
|
||||
rc.Context.Req.Form.Set("format", "hcl")
|
||||
rc.Context.Req.Form.Set("download", "false")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response := srv.ExportFromPayload(rc, body, folder.UID)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
@ -184,7 +184,7 @@ func TestExportFromPayload(t *testing.T) {
|
||||
rc.Context.Req.Form.Set("format", "hcl")
|
||||
rc.Context.Req.Form.Set("download", "true")
|
||||
|
||||
response := srv.ExportFromPayload(rc, body, folder.Title)
|
||||
response := srv.ExportFromPayload(rc, body, folder.UID)
|
||||
response.WriteTo(rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
|
@ -79,7 +79,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
|
||||
|
||||
request := createRequestContextWithPerms(orgID, map[int64]map[string][]string{}, nil)
|
||||
|
||||
response := createService(ruleStore).RouteDeleteAlertRules(request, folder.Title, "")
|
||||
response := createService(ruleStore).RouteDeleteAlertRules(request, folder.UID, "")
|
||||
require.Equalf(t, http.StatusForbidden, response.Status(), "Expected 403 but got %d: %v", response.Status(), string(response.Body()))
|
||||
|
||||
require.Empty(t, getRecordedCommand(ruleStore))
|
||||
@ -102,7 +102,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
|
||||
permissions := createPermissionsForRules(append(authorizedRulesInFolder, provisionedRulesInFolder...), orgID)
|
||||
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
|
||||
|
||||
response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.Title, "")
|
||||
response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.UID, "")
|
||||
|
||||
require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body()))
|
||||
assertRulesDeleted(t, authorizedRulesInFolder, ruleStore)
|
||||
@ -122,7 +122,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
|
||||
permissions := createPermissionsForRules(provisionedRulesInFolder, orgID)
|
||||
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
|
||||
|
||||
response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.Title, "")
|
||||
response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.UID, "")
|
||||
|
||||
require.Equalf(t, 400, response.Status(), "Expected 400 but got %d: %v", response.Status(), string(response.Body()))
|
||||
require.Empty(t, getRecordedCommand(ruleStore))
|
||||
@ -131,7 +131,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
|
||||
ruleStore := initFakeRuleStore(t)
|
||||
|
||||
requestCtx := createRequestContext(orgID, nil)
|
||||
response := createService(ruleStore).RouteDeleteAlertRules(requestCtx, folder.Title, "")
|
||||
response := createService(ruleStore).RouteDeleteAlertRules(requestCtx, folder.UID, "")
|
||||
|
||||
require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body()))
|
||||
require.Empty(t, getRecordedCommand(ruleStore))
|
||||
@ -150,7 +150,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
|
||||
permissions := createPermissionsForRules(authorizedRulesInGroup, orgID)
|
||||
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
|
||||
|
||||
response := createService(ruleStore).RouteDeleteAlertRules(requestCtx, folder.Title, groupName)
|
||||
response := createService(ruleStore).RouteDeleteAlertRules(requestCtx, folder.UID, groupName)
|
||||
|
||||
require.Equalf(t, http.StatusForbidden, response.Status(), "Expected 403 but got %d: %v", response.Status(), string(response.Body()))
|
||||
deleteCommands := getRecordedCommand(ruleStore)
|
||||
@ -169,7 +169,7 @@ func TestRouteDeleteAlertRules(t *testing.T) {
|
||||
permissions := createPermissionsForRules(provisionedRulesInFolder, orgID)
|
||||
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
|
||||
|
||||
response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.Title, groupName)
|
||||
response := createServiceWithProvenanceStore(ruleStore, provisioningStore).RouteDeleteAlertRules(requestCtx, folder.UID, groupName)
|
||||
|
||||
require.Equalf(t, 400, response.Status(), "Expected 400 but got %d: %v", response.Status(), string(response.Body()))
|
||||
deleteCommands := getRecordedCommand(ruleStore)
|
||||
@ -193,14 +193,14 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) {
|
||||
permissions := createPermissionsForRules(expectedRules, orgID)
|
||||
req := createRequestContextWithPerms(orgID, permissions, nil)
|
||||
|
||||
response := createService(ruleStore).RouteGetNamespaceRulesConfig(req, folder.Title)
|
||||
response := createService(ruleStore).RouteGetNamespaceRulesConfig(req, folder.UID)
|
||||
|
||||
require.Equal(t, http.StatusAccepted, response.Status())
|
||||
result := &apimodels.NamespaceConfigResponse{}
|
||||
require.NoError(t, json.Unmarshal(response.Body(), result))
|
||||
require.NotNil(t, result)
|
||||
for namespace, groups := range *result {
|
||||
require.Equal(t, folder.Title, namespace)
|
||||
require.Equal(t, models.GetNamespaceKey(folder.ParentUID, folder.Title), namespace)
|
||||
for _, group := range groups {
|
||||
grouploop:
|
||||
for _, actualRule := range group.Rules {
|
||||
@ -235,7 +235,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
req := createRequestContext(orgID, nil)
|
||||
response := svc.RouteGetNamespaceRulesConfig(req, folder.Title)
|
||||
response := svc.RouteGetNamespaceRulesConfig(req, folder.UID)
|
||||
|
||||
require.Equal(t, http.StatusAccepted, response.Status())
|
||||
result := &apimodels.NamespaceConfigResponse{}
|
||||
@ -243,7 +243,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) {
|
||||
require.NotNil(t, result)
|
||||
found := false
|
||||
for namespace, groups := range *result {
|
||||
require.Equal(t, folder.Title, namespace)
|
||||
require.Equal(t, models.GetNamespaceKey(folder.ParentUID, folder.Title), namespace)
|
||||
for _, group := range groups {
|
||||
for _, actualRule := range group.Rules {
|
||||
if actualRule.GrafanaManagedAlert.UID == expectedRules[0].UID {
|
||||
@ -269,7 +269,7 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) {
|
||||
ruleStore.PutRule(context.Background(), expectedRules...)
|
||||
|
||||
req := createRequestContext(orgID, nil)
|
||||
response := createService(ruleStore).RouteGetNamespaceRulesConfig(req, folder.Title)
|
||||
response := createService(ruleStore).RouteGetNamespaceRulesConfig(req, folder.UID)
|
||||
|
||||
require.Equal(t, http.StatusAccepted, response.Status())
|
||||
result := &apimodels.NamespaceConfigResponse{}
|
||||
@ -278,8 +278,8 @@ func TestRouteGetNamespaceRulesConfig(t *testing.T) {
|
||||
|
||||
models.RulesGroup(expectedRules).SortByGroupIndex()
|
||||
|
||||
require.Contains(t, *result, folder.Title)
|
||||
groups := (*result)[folder.Title]
|
||||
groups, ok := (*result)[models.GetNamespaceKey(folder.ParentUID, folder.Title)]
|
||||
require.True(t, ok)
|
||||
require.Len(t, groups, 1)
|
||||
group := groups[0]
|
||||
require.Equal(t, groupKey.RuleGroup, group.Name)
|
||||
@ -329,10 +329,10 @@ func TestRouteGetRulesConfig(t *testing.T) {
|
||||
require.NoError(t, json.Unmarshal(response.Body(), result))
|
||||
require.NotNil(t, result)
|
||||
|
||||
require.Contains(t, *result, folder1.Title)
|
||||
require.NotContains(t, *result, folder2.Title)
|
||||
require.Contains(t, *result, models.GetNamespaceKey(folder1.ParentUID, folder1.Title))
|
||||
require.NotContains(t, *result, folder2.UID)
|
||||
|
||||
groups := (*result)[folder1.Title]
|
||||
groups := (*result)[models.GetNamespaceKey(folder1.ParentUID, folder1.Title)]
|
||||
require.Len(t, groups, 1)
|
||||
require.Equal(t, group1Key.RuleGroup, groups[0].Name)
|
||||
require.Len(t, groups[0].Rules, len(group1))
|
||||
@ -361,8 +361,8 @@ func TestRouteGetRulesConfig(t *testing.T) {
|
||||
|
||||
models.RulesGroup(expectedRules).SortByGroupIndex()
|
||||
|
||||
require.Contains(t, *result, folder.Title)
|
||||
groups := (*result)[folder.Title]
|
||||
groups, ok := (*result)[models.GetNamespaceKey(folder.ParentUID, folder.Title)]
|
||||
require.True(t, ok)
|
||||
require.Len(t, groups, 1)
|
||||
group := groups[0]
|
||||
require.Equal(t, groupKey.RuleGroup, group.Name)
|
||||
@ -399,20 +399,20 @@ func TestRouteGetRulesGroupConfig(t *testing.T) {
|
||||
t.Run("and return Forbidden if user does not have access one of rules", func(t *testing.T) {
|
||||
permissions := createPermissionsForRules(expectedRules[1:], orgID)
|
||||
request := createRequestContextWithPerms(orgID, permissions, map[string]string{
|
||||
":Namespace": folder.Title,
|
||||
":Namespace": folder.UID,
|
||||
":Groupname": groupKey.RuleGroup,
|
||||
})
|
||||
response := createService(ruleStore).RouteGetRulesGroupConfig(request, folder.Title, groupKey.RuleGroup)
|
||||
response := createService(ruleStore).RouteGetRulesGroupConfig(request, folder.UID, groupKey.RuleGroup)
|
||||
require.Equal(t, http.StatusForbidden, response.Status())
|
||||
})
|
||||
|
||||
t.Run("and return rules if user has access to all of them", func(t *testing.T) {
|
||||
permissions := createPermissionsForRules(expectedRules, orgID)
|
||||
request := createRequestContextWithPerms(orgID, permissions, map[string]string{
|
||||
":Namespace": folder.Title,
|
||||
":Namespace": folder.UID,
|
||||
":Groupname": groupKey.RuleGroup,
|
||||
})
|
||||
response := createService(ruleStore).RouteGetRulesGroupConfig(request, folder.Title, groupKey.RuleGroup)
|
||||
response := createService(ruleStore).RouteGetRulesGroupConfig(request, folder.UID, groupKey.RuleGroup)
|
||||
|
||||
require.Equal(t, http.StatusAccepted, response.Status())
|
||||
result := &apimodels.RuleGroupConfigResponse{}
|
||||
@ -435,7 +435,7 @@ func TestRouteGetRulesGroupConfig(t *testing.T) {
|
||||
ruleStore.PutRule(context.Background(), expectedRules...)
|
||||
|
||||
req := createRequestContext(orgID, nil)
|
||||
response := createService(ruleStore).RouteGetRulesGroupConfig(req, folder.Title, groupKey.RuleGroup)
|
||||
response := createService(ruleStore).RouteGetRulesGroupConfig(req, folder.UID, groupKey.RuleGroup)
|
||||
|
||||
require.Equal(t, http.StatusAccepted, response.Status())
|
||||
result := &apimodels.RuleGroupConfigResponse{}
|
||||
|
@ -104,7 +104,8 @@ func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext,
|
||||
now,
|
||||
rule,
|
||||
results,
|
||||
state.GetRuleExtraLabels(rule, body.NamespaceTitle, includeFolder),
|
||||
// TODO remove when switched to full path https://github.com/grafana/grafana/issues/80324
|
||||
state.GetRuleExtraLabels(rule, ngmodels.GetNamespaceKey("", body.NamespaceTitle), includeFolder),
|
||||
)
|
||||
|
||||
alerts := make([]*amv2.PostableAlert, 0, len(transitions))
|
||||
|
@ -10,8 +10,9 @@ import (
|
||||
|
||||
// RuleStore is the interface for persisting alert rules and instances
|
||||
type RuleStore interface {
|
||||
// TODO after deprecating namespace_id field in GettableGrafanaRule we can simplify this interface
|
||||
// by returning map[string]struct{} instead of map[string]*folder.Folder
|
||||
GetUserVisibleNamespaces(context.Context, int64, identity.Requester) (map[string]*folder.Folder, error)
|
||||
GetNamespaceByTitle(context.Context, string, int64, identity.Requester) (*folder.Folder, error)
|
||||
GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user identity.Requester) (*folder.Folder, error)
|
||||
GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmodels.GetAlertRulesGroupByRuleUIDQuery) ([]*ngmodels.AlertRule, error)
|
||||
ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) (ngmodels.RulesGroup, error)
|
||||
|
@ -1,7 +1,7 @@
|
||||
@grafanaRecipient = grafana
|
||||
|
||||
// should point to an existing folder named alerting
|
||||
@namespace1 = foo%20bar
|
||||
// should point to an existing folder UID
|
||||
@namespace1 = baf2c548-5e1e-42e8-8fde-8320e50d801e
|
||||
|
||||
// create group42 under unknown namespace - it should fail
|
||||
POST http://admin:admin@localhost:3000/api/ruler/{{grafanaRecipient}}/api/v1/rules/unknown
|
||||
|
@ -4652,6 +4652,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"gettableSilences": {
|
||||
"description": "GettableSilences gettable silences",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableSilence"
|
||||
},
|
||||
|
@ -166,6 +166,7 @@ import (
|
||||
|
||||
// swagger:parameters RoutePostNameRulesConfig RoutePostNameGrafanaRulesConfig RoutePostRulesGroupForExport
|
||||
type NamespaceConfig struct {
|
||||
// The UID of the rule folder
|
||||
// in:path
|
||||
Namespace string
|
||||
// in:body
|
||||
@ -174,12 +175,14 @@ type NamespaceConfig struct {
|
||||
|
||||
// swagger:parameters RouteGetNamespaceRulesConfig RouteDeleteNamespaceRulesConfig RouteGetNamespaceGrafanaRulesConfig RouteDeleteNamespaceGrafanaRulesConfig
|
||||
type PathNamespaceConfig struct {
|
||||
// The UID of the rule folder
|
||||
// in: path
|
||||
Namespace string
|
||||
}
|
||||
|
||||
// swagger:parameters RouteGetRulegGroupConfig RouteDeleteRuleGroupConfig RouteGetGrafanaRuleGroupConfig RouteDeleteGrafanaRuleGroupConfig
|
||||
type PathRouleGroupConfig struct {
|
||||
// The UID of the rule folder
|
||||
// in: path
|
||||
Namespace string
|
||||
// in: path
|
||||
|
@ -6254,6 +6254,7 @@
|
||||
"operationId": "RouteDeleteNamespaceGrafanaRulesConfig",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The UID of the rule folder",
|
||||
"in": "path",
|
||||
"name": "Namespace",
|
||||
"required": true,
|
||||
@ -6283,6 +6284,7 @@
|
||||
"operationId": "RouteGetNamespaceGrafanaRulesConfig",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The UID of the rule folder",
|
||||
"in": "path",
|
||||
"name": "Namespace",
|
||||
"required": true,
|
||||
@ -6319,6 +6321,7 @@
|
||||
"operationId": "RoutePostNameGrafanaRulesConfig",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The UID of the rule folder",
|
||||
"in": "path",
|
||||
"name": "Namespace",
|
||||
"required": true,
|
||||
@ -6361,6 +6364,7 @@
|
||||
"operationId": "RoutePostRulesGroupForExport",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The UID of the rule folder",
|
||||
"in": "path",
|
||||
"name": "Namespace",
|
||||
"required": true,
|
||||
@ -6416,6 +6420,7 @@
|
||||
"operationId": "RouteDeleteGrafanaRuleGroupConfig",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The UID of the rule folder",
|
||||
"in": "path",
|
||||
"name": "Namespace",
|
||||
"required": true,
|
||||
@ -6451,6 +6456,7 @@
|
||||
"operationId": "RouteGetGrafanaRuleGroupConfig",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "The UID of the rule folder",
|
||||
"in": "path",
|
||||
"name": "Namespace",
|
||||
"required": true,
|
||||
@ -6550,6 +6556,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The UID of the rule folder",
|
||||
"in": "path",
|
||||
"name": "Namespace",
|
||||
"required": true,
|
||||
@ -6592,6 +6599,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The UID of the rule folder",
|
||||
"in": "path",
|
||||
"name": "Namespace",
|
||||
"required": true,
|
||||
@ -6641,6 +6649,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The UID of the rule folder",
|
||||
"in": "path",
|
||||
"name": "Namespace",
|
||||
"required": true,
|
||||
@ -6692,6 +6701,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The UID of the rule folder",
|
||||
"in": "path",
|
||||
"name": "Namespace",
|
||||
"required": true,
|
||||
@ -6740,6 +6750,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "The UID of the rule folder",
|
||||
"in": "path",
|
||||
"name": "Namespace",
|
||||
"required": true,
|
||||
|
@ -1316,6 +1316,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The UID of the rule folder",
|
||||
"name": "Namespace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@ -1349,6 +1350,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The UID of the rule folder",
|
||||
"name": "Namespace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@ -1385,6 +1387,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The UID of the rule folder",
|
||||
"name": "Namespace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@ -1420,6 +1423,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The UID of the rule folder",
|
||||
"name": "Namespace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@ -1478,6 +1482,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The UID of the rule folder",
|
||||
"name": "Namespace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@ -1513,6 +1518,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The UID of the rule folder",
|
||||
"name": "Namespace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@ -1612,6 +1618,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The UID of the rule folder",
|
||||
"name": "Namespace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@ -1658,6 +1665,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The UID of the rule folder",
|
||||
"name": "Namespace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@ -1707,6 +1715,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The UID of the rule folder",
|
||||
"name": "Namespace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@ -1754,6 +1763,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The UID of the rule folder",
|
||||
"name": "Namespace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@ -1802,6 +1812,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "The UID of the rule folder",
|
||||
"name": "Namespace",
|
||||
"in": "path",
|
||||
"required": true
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@ -545,7 +546,8 @@ type GetAlertRulesForSchedulingQuery struct {
|
||||
PopulateFolders bool
|
||||
RuleGroups []string
|
||||
|
||||
ResultRules []*AlertRule
|
||||
ResultRules []*AlertRule
|
||||
// A map of folder UID to folder Title in NamespaceKey format (see GetNamespaceKey)
|
||||
ResultFoldersTitles map[string]string
|
||||
}
|
||||
|
||||
@ -683,3 +685,29 @@ func GroupByAlertRuleGroupKey(rules []*AlertRule) map[AlertRuleGroupKey]RulesGro
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetNamespaceKey concatenates two strings with / as separator. If the latter string contains '/' it gets escaped with \/
|
||||
func GetNamespaceKey(parentUID, title string) string {
|
||||
if parentUID == "" {
|
||||
return title
|
||||
}
|
||||
b, err := json.Marshal([]string{parentUID, title})
|
||||
if err != nil {
|
||||
return title // this should not really happen
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// GetNamespaceTitleFromKey extracts the latter part from the string produced by GetNamespaceKey
|
||||
func GetNamespaceTitleFromKey(ns string) string {
|
||||
// the expected format of the string is a JSON array ["parentUID","title"]
|
||||
if !strings.HasPrefix(ns, "[") {
|
||||
return ns
|
||||
}
|
||||
var arr []string
|
||||
err := json.Unmarshal([]byte(ns), &arr)
|
||||
if err != nil || len(arr) != 2 {
|
||||
return ns
|
||||
}
|
||||
return arr[1]
|
||||
}
|
||||
|
@ -729,3 +729,66 @@ func TestTimeRangeYAML(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, yamlRaw, string(serialized))
|
||||
}
|
||||
|
||||
func TestGetNamespaceTitleFromKey(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"just title", "title with space", "title with space"},
|
||||
{"title and uid", `["parentUID","title"]`, "title"},
|
||||
{"wrong input-empty array", "[]", "[]"},
|
||||
{"wrong input-incorrect json", "[", "["},
|
||||
{"wrong input-long array", `["parentUID","title","title"]`, `["parentUID","title","title"]`},
|
||||
{"empty string", "", ""},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
actual := GetNamespaceTitleFromKey(tc.input)
|
||||
require.Equal(t, actual, tc.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNamespaceKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
parentUID string
|
||||
title string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Parent UID and title",
|
||||
parentUID: "parentUID",
|
||||
title: "Title/Title",
|
||||
expected: `["parentUID","Title/Title"]`,
|
||||
},
|
||||
{
|
||||
name: "EmptyTitle",
|
||||
parentUID: "parentUID",
|
||||
title: "",
|
||||
expected: `["parentUID",""]`,
|
||||
},
|
||||
{
|
||||
name: "EmptyParentUID",
|
||||
parentUID: "",
|
||||
title: "Title",
|
||||
expected: "Title",
|
||||
},
|
||||
{
|
||||
name: "BothEmpty",
|
||||
parentUID: "",
|
||||
title: "",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actual := GetNamespaceKey(tt.parentUID, tt.title)
|
||||
require.Equal(t, actual, tt.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -512,7 +512,8 @@ func GetRuleExtraLabels(rule *models.AlertRule, folderTitle string, includeFolde
|
||||
extraLabels[alertingModels.RuleUIDLabel] = rule.UID
|
||||
|
||||
if includeFolder {
|
||||
extraLabels[models.FolderTitleLabel] = folderTitle
|
||||
// TODO remove when title will contain the full path https://github.com/grafana/grafana/issues/80324
|
||||
extraLabels[models.FolderTitleLabel] = models.GetNamespaceTitleFromKey(folderTitle)
|
||||
}
|
||||
return extraLabels
|
||||
}
|
||||
|
@ -461,8 +461,9 @@ func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, use
|
||||
continue
|
||||
}
|
||||
namespaceMap[hit.UID] = &folder.Folder{
|
||||
UID: hit.UID,
|
||||
Title: hit.Title,
|
||||
UID: hit.UID,
|
||||
Title: hit.Title,
|
||||
ParentUID: hit.FolderUID,
|
||||
}
|
||||
}
|
||||
page += 1
|
||||
@ -470,16 +471,6 @@ func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, use
|
||||
return namespaceMap, nil
|
||||
}
|
||||
|
||||
// GetNamespaceByTitle is a handler for retrieving a namespace by its title. Alerting rules follow a Grafana folder-like structure which we call namespaces.
|
||||
func (st DBstore) GetNamespaceByTitle(ctx context.Context, namespace string, orgID int64, user identity.Requester) (*folder.Folder, error) {
|
||||
folder, err := st.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, Title: &namespace, SignedInUser: user})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return folder, nil
|
||||
}
|
||||
|
||||
// GetNamespaceByUID is a handler for retrieving a namespace by its UID. Alerting rules follow a Grafana folder-like structure which we call namespaces.
|
||||
func (st DBstore) GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user identity.Requester) (*folder.Folder, error) {
|
||||
folder, err := st.FolderService.Get(ctx, &folder.GetFolderQuery{OrgID: orgID, UID: &uid, SignedInUser: user})
|
||||
@ -516,8 +507,9 @@ func (st DBstore) GetAlertRulesKeysForScheduling(ctx context.Context) ([]ngmodel
|
||||
// GetAlertRulesForScheduling returns a short version of all alert rules except those that belong to an excluded list of organizations
|
||||
func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodels.GetAlertRulesForSchedulingQuery) error {
|
||||
var folders []struct {
|
||||
Uid string
|
||||
Title string
|
||||
Uid string
|
||||
Title string
|
||||
ParentUid string
|
||||
}
|
||||
var rules []*ngmodels.AlertRule
|
||||
return st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
@ -566,7 +558,7 @@ func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodel
|
||||
query.ResultRules = rules
|
||||
|
||||
if query.PopulateFolders {
|
||||
foldersSql := sess.Table("folder").Alias("d").Select("d.uid, d.title").
|
||||
foldersSql := sess.Table("folder").Alias("d").Select("d.uid, d.title, d.parent_uid").
|
||||
Where(`EXISTS (SELECT 1 FROM alert_rule a WHERE d.uid = a.namespace_uid AND d.org_id = a.org_id)`)
|
||||
if len(disabledOrgs) > 0 {
|
||||
foldersSql.NotIn("org_id", disabledOrgs)
|
||||
@ -577,7 +569,7 @@ func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodel
|
||||
}
|
||||
query.ResultFoldersTitles = make(map[string]string, len(folders))
|
||||
for _, folder := range folders {
|
||||
query.ResultFoldersTitles[folder.Uid] = folder.Title
|
||||
query.ResultFoldersTitles[folder.Uid] = ngmodels.GetNamespaceKey(folder.ParentUid, folder.Title)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
@ -339,10 +339,13 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) {
|
||||
generator := models.AlertRuleGen(withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueID(), models.WithUniqueOrgID())
|
||||
rule1 := createRule(t, store, generator)
|
||||
rule2 := createRule(t, store, generator)
|
||||
createFolder(t, store, rule1.NamespaceUID, rule1.Title, rule1.OrgID)
|
||||
createFolder(t, store, rule2.NamespaceUID, rule2.Title, rule2.OrgID)
|
||||
|
||||
createFolder(t, store, rule2.NamespaceUID, "same UID folder", generator().OrgID) // create a folder with the same UID but in the different org
|
||||
parentFolderUid := uuid.NewString()
|
||||
createFolder(t, store, parentFolderUid, "Very Parent Folder", rule1.OrgID, "")
|
||||
createFolder(t, store, rule1.NamespaceUID, rule1.Title, rule1.OrgID, parentFolderUid)
|
||||
createFolder(t, store, rule2.NamespaceUID, rule2.Title, rule2.OrgID, "")
|
||||
|
||||
createFolder(t, store, rule2.NamespaceUID, "same UID folder", generator().OrgID, "") // create a folder with the same UID but in the different org
|
||||
|
||||
tc := []struct {
|
||||
name string
|
||||
@ -368,13 +371,13 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) {
|
||||
{
|
||||
name: "with populate folders enabled, it returns them",
|
||||
rules: []string{rule1.Title, rule2.Title},
|
||||
folders: map[string]string{rule1.NamespaceUID: rule1.Title, rule2.NamespaceUID: rule2.Title},
|
||||
folders: map[string]string{rule1.NamespaceUID: models.GetNamespaceKey(parentFolderUid, rule1.Title), rule2.NamespaceUID: rule2.Title},
|
||||
},
|
||||
{
|
||||
name: "with populate folders enabled and a filter on orgs, it only returns selected information",
|
||||
rules: []string{rule1.Title},
|
||||
disabledOrgs: []int64{rule2.OrgID},
|
||||
folders: map[string]string{rule1.NamespaceUID: rule1.Title},
|
||||
folders: map[string]string{rule1.NamespaceUID: models.GetNamespaceKey(parentFolderUid, rule1.Title)},
|
||||
},
|
||||
}
|
||||
|
||||
@ -522,7 +525,7 @@ func TestIntegration_GetNamespaceByUID(t *testing.T) {
|
||||
|
||||
uid := uuid.NewString()
|
||||
title := "folder-title"
|
||||
createFolder(t, store, uid, title, 1)
|
||||
createFolder(t, store, uid, title, 1, "")
|
||||
|
||||
actual, err := store.GetNamespaceByUID(context.Background(), uid, 1, u)
|
||||
require.NoError(t, err)
|
||||
@ -604,7 +607,7 @@ func createRule(t *testing.T, store *DBstore, generate func() *models.AlertRule)
|
||||
return rule
|
||||
}
|
||||
|
||||
func createFolder(t *testing.T, store *DBstore, uid, title string, orgID int64) {
|
||||
func createFolder(t *testing.T, store *DBstore, uid, title string, orgID int64, parentUID string) {
|
||||
t.Helper()
|
||||
u := &user.SignedInUser{
|
||||
UserID: 1,
|
||||
@ -619,6 +622,7 @@ func createFolder(t *testing.T, store *DBstore, uid, title string, orgID int64)
|
||||
Title: title,
|
||||
Description: "",
|
||||
SignedInUser: u,
|
||||
ParentUID: parentUID,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
@ -245,16 +245,6 @@ func (f *RuleStore) GetUserVisibleNamespaces(_ context.Context, orgID int64, _ i
|
||||
return namespacesMap, nil
|
||||
}
|
||||
|
||||
func (f *RuleStore) GetNamespaceByTitle(_ context.Context, title string, orgID int64, _ identity.Requester) (*folder.Folder, error) {
|
||||
folders := f.Folders[orgID]
|
||||
for _, folder := range folders {
|
||||
if folder.Title == title {
|
||||
return folder, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64, _ identity.Requester) (*folder.Folder, error) {
|
||||
f.RecordedOps = append(f.RecordedOps, GenericRecordedQuery{
|
||||
Name: "GetNamespaceByUID",
|
||||
|
@ -321,6 +321,362 @@ func TestIntegrationAlertRulePermissions(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationAlertRuleNestedPermissions(t *testing.T) {
|
||||
testinfra.SQLiteIntegrationTest(t)
|
||||
|
||||
// Setup Grafana and its Database
|
||||
dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
|
||||
EnableFeatureToggles: []string{featuremgmt.FlagNestedFolders},
|
||||
DisableLegacyAlerting: true,
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
})
|
||||
|
||||
grafanaListedAddr, store := testinfra.StartGrafana(t, dir, p)
|
||||
permissionsStore := resourcepermissions.NewStore(store, featuremgmt.WithFeatures())
|
||||
|
||||
// Create a user to make authenticated requests
|
||||
userID := createUser(t, store, user.CreateUserCommand{
|
||||
DefaultOrgRole: string(org.RoleEditor),
|
||||
Password: "password",
|
||||
Login: "grafana",
|
||||
})
|
||||
|
||||
apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password")
|
||||
|
||||
// Create the namespace we'll save our alerts to.
|
||||
apiClient.CreateFolder(t, "folder1", "folder1")
|
||||
// Create the namespace we'll save our alerts to.
|
||||
apiClient.CreateFolder(t, "folder2", "folder2")
|
||||
// Create a subfolder
|
||||
apiClient.CreateFolder(t, "subfolder", "subfolder", "folder1")
|
||||
|
||||
postGroupRaw, err := testData.ReadFile(path.Join("test-data", "rulegroup-1-post.json"))
|
||||
require.NoError(t, err)
|
||||
var group1 apimodels.PostableRuleGroupConfig
|
||||
require.NoError(t, json.Unmarshal(postGroupRaw, &group1))
|
||||
|
||||
// Create rule under folder1
|
||||
_, status, response := apiClient.PostRulesGroupWithStatus(t, "folder1", &group1)
|
||||
require.Equalf(t, http.StatusAccepted, status, response)
|
||||
|
||||
postGroupRaw, err = testData.ReadFile(path.Join("test-data", "rulegroup-2-post.json"))
|
||||
require.NoError(t, err)
|
||||
var group2 apimodels.PostableRuleGroupConfig
|
||||
require.NoError(t, json.Unmarshal(postGroupRaw, &group2))
|
||||
|
||||
// Create rule under folder2
|
||||
_, status, response = apiClient.PostRulesGroupWithStatus(t, "folder2", &group2)
|
||||
require.Equalf(t, http.StatusAccepted, status, response)
|
||||
|
||||
postGroupRaw, err = testData.ReadFile(path.Join("test-data", "rulegroup-3-post.json"))
|
||||
require.NoError(t, err)
|
||||
var group3 apimodels.PostableRuleGroupConfig
|
||||
require.NoError(t, json.Unmarshal(postGroupRaw, &group3))
|
||||
|
||||
// Create rule under subfolder
|
||||
_, status, response = apiClient.PostRulesGroupWithStatus(t, "subfolder", &group3)
|
||||
require.Equalf(t, http.StatusAccepted, status, response)
|
||||
|
||||
// With the rules created, let's make sure that rule definitions are stored.
|
||||
allRules, status, _ := apiClient.GetAllRulesWithStatus(t)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
status, allExportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
|
||||
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
var allExport apimodels.AlertingFileExport
|
||||
require.NoError(t, json.Unmarshal([]byte(allExportRaw), &allExport))
|
||||
|
||||
t.Run("when user has all permissions", func(t *testing.T) {
|
||||
t.Run("Get all returns all rules", func(t *testing.T) {
|
||||
var group1, group2, group3 apimodels.GettableRuleGroupConfig
|
||||
|
||||
getGroup1Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-1-get.json"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(getGroup1Raw, &group1))
|
||||
getGroup2Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-2-get.json"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(getGroup2Raw, &group2))
|
||||
getGroup3Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-3-get.json"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(getGroup3Raw, &group3))
|
||||
|
||||
nestedKey := ngmodels.GetNamespaceKey("folder1", "subfolder")
|
||||
|
||||
expected := apimodels.NamespaceConfigResponse{
|
||||
"folder1": []apimodels.GettableRuleGroupConfig{
|
||||
group1,
|
||||
},
|
||||
"folder2": []apimodels.GettableRuleGroupConfig{
|
||||
group2,
|
||||
},
|
||||
nestedKey: []apimodels.GettableRuleGroupConfig{
|
||||
group3,
|
||||
},
|
||||
}
|
||||
|
||||
pathsToIgnore := []string{
|
||||
"GrafanaManagedAlert.Updated",
|
||||
"GrafanaManagedAlert.UID",
|
||||
"GrafanaManagedAlert.ID",
|
||||
"GrafanaManagedAlert.Data.Model",
|
||||
"GrafanaManagedAlert.NamespaceUID",
|
||||
"GrafanaManagedAlert.NamespaceID",
|
||||
}
|
||||
|
||||
// compare expected and actual and ignore the dynamic fields
|
||||
diff := cmp.Diff(expected, allRules, cmp.FilterPath(func(path cmp.Path) bool {
|
||||
for _, s := range pathsToIgnore {
|
||||
if strings.Contains(path.String(), s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, cmp.Ignore()))
|
||||
|
||||
require.Empty(t, diff)
|
||||
|
||||
for _, rule := range allRules["folder1"][0].Rules {
|
||||
assert.Equal(t, "folder1", rule.GrafanaManagedAlert.NamespaceUID)
|
||||
}
|
||||
|
||||
for _, rule := range allRules["folder2"][0].Rules {
|
||||
assert.Equal(t, "folder2", rule.GrafanaManagedAlert.NamespaceUID)
|
||||
}
|
||||
|
||||
for _, rule := range allRules[nestedKey][0].Rules {
|
||||
assert.Equal(t, "subfolder", rule.GrafanaManagedAlert.NamespaceUID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Get by folder returns groups in folder", func(t *testing.T) {
|
||||
rules, status, _ := apiClient.GetAllRulesGroupInFolderWithStatus(t, "folder1")
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Contains(t, rules, "folder1")
|
||||
require.Len(t, rules["folder1"], 1)
|
||||
require.Equal(t, allRules["folder1"], rules["folder1"])
|
||||
})
|
||||
|
||||
t.Run("Get group returns a single group", func(t *testing.T) {
|
||||
rules := apiClient.GetRulesGroup(t, "folder2", allRules["folder2"][0].Name)
|
||||
cmp.Diff(allRules["folder2"][0], rules.GettableRuleGroupConfig)
|
||||
})
|
||||
|
||||
t.Run("Get by folder returns groups in folder with nested folder format", func(t *testing.T) {
|
||||
rules, status, _ := apiClient.GetAllRulesGroupInFolderWithStatus(t, "subfolder")
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
nestedKey := ngmodels.GetNamespaceKey("folder1", "subfolder")
|
||||
require.Contains(t, rules, nestedKey)
|
||||
require.Len(t, rules[nestedKey], 1)
|
||||
require.Equal(t, allRules[nestedKey], rules[nestedKey])
|
||||
})
|
||||
|
||||
t.Run("Export returns all rules", func(t *testing.T) {
|
||||
var group1File, group2File, group3File apimodels.AlertingFileExport
|
||||
getGroup1Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-1-export.json"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(getGroup1Raw, &group1File))
|
||||
getGroup2Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-2-export.json"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(getGroup2Raw, &group2File))
|
||||
getGroup3Raw, err := testData.ReadFile(path.Join("test-data", "rulegroup-3-export.json"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(getGroup3Raw, &group3File))
|
||||
|
||||
group1File.Groups = append(group1File.Groups, group2File.Groups...)
|
||||
group1File.Groups = append(group1File.Groups, group3File.Groups...)
|
||||
expected := group1File
|
||||
|
||||
pathsToIgnore := []string{
|
||||
"Groups.Rules.UID",
|
||||
"Groups.Folder",
|
||||
}
|
||||
|
||||
// compare expected and actual and ignore the dynamic fields
|
||||
diff := cmp.Diff(expected, allExport, cmp.FilterPath(func(path cmp.Path) bool {
|
||||
for _, s := range pathsToIgnore {
|
||||
if strings.Contains(path.String(), s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, cmp.Ignore()))
|
||||
|
||||
require.Empty(t, diff)
|
||||
|
||||
require.Equal(t, "folder1", allExport.Groups[0].Folder)
|
||||
require.Equal(t, "folder2", allExport.Groups[1].Folder)
|
||||
require.Equal(t, "subfolder", allExport.Groups[2].Folder)
|
||||
})
|
||||
|
||||
t.Run("Export from one folder", func(t *testing.T) {
|
||||
expected := allExport.Groups[0]
|
||||
status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
|
||||
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
|
||||
FolderUID: []string{"folder1"},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
var export apimodels.AlertingFileExport
|
||||
require.NoError(t, json.Unmarshal([]byte(exportRaw), &export))
|
||||
|
||||
require.Len(t, export.Groups, 1)
|
||||
require.Equal(t, expected, export.Groups[0])
|
||||
})
|
||||
|
||||
t.Run("Export from a subfolder", func(t *testing.T) {
|
||||
expected := allExport.Groups[2]
|
||||
status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
|
||||
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
|
||||
FolderUID: []string{"subfolder"},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
var export apimodels.AlertingFileExport
|
||||
require.NoError(t, json.Unmarshal([]byte(exportRaw), &export))
|
||||
|
||||
require.Len(t, export.Groups, 1)
|
||||
require.Equal(t, expected, export.Groups[0])
|
||||
})
|
||||
|
||||
t.Run("Export from one group", func(t *testing.T) {
|
||||
expected := allExport.Groups[0]
|
||||
status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
|
||||
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
|
||||
FolderUID: []string{"folder1"},
|
||||
GroupName: expected.Name,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
var export apimodels.AlertingFileExport
|
||||
require.NoError(t, json.Unmarshal([]byte(exportRaw), &export))
|
||||
|
||||
require.Len(t, export.Groups, 1)
|
||||
require.Equal(t, expected, export.Groups[0])
|
||||
})
|
||||
|
||||
t.Run("Export from one group under subfolder", func(t *testing.T) {
|
||||
expected := allExport.Groups[2]
|
||||
status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
|
||||
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
|
||||
FolderUID: []string{"subfolder"},
|
||||
GroupName: expected.Name,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
var export apimodels.AlertingFileExport
|
||||
require.NoError(t, json.Unmarshal([]byte(exportRaw), &export))
|
||||
|
||||
require.Len(t, export.Groups, 1)
|
||||
require.Equal(t, expected, export.Groups[0])
|
||||
})
|
||||
|
||||
t.Run("Export single rule", func(t *testing.T) {
|
||||
expected := allExport.Groups[0]
|
||||
expected.Rules = []apimodels.AlertRuleExport{
|
||||
expected.Rules[0],
|
||||
}
|
||||
status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
|
||||
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
|
||||
RuleUID: expected.Rules[0].UID,
|
||||
})
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
var export apimodels.AlertingFileExport
|
||||
t.Log(exportRaw)
|
||||
require.NoError(t, json.Unmarshal([]byte(exportRaw), &export))
|
||||
|
||||
require.Len(t, export.Groups, 1)
|
||||
require.Equal(t, expected, export.Groups[0])
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("when permissions for folder2 removed", func(t *testing.T) {
|
||||
// remove permissions for folder2
|
||||
removeFolderPermission(t, permissionsStore, 1, userID, org.RoleEditor, "folder2")
|
||||
// remove permissions for subfolder (inherits from folder1)
|
||||
removeFolderPermission(t, permissionsStore, 1, userID, org.RoleEditor, "subfolder")
|
||||
apiClient.ReloadCachedPermissions(t)
|
||||
|
||||
t.Run("Get all returns all rules", func(t *testing.T) {
|
||||
newAll, status, _ := apiClient.GetAllRulesWithStatus(t)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Contains(t, newAll, "folder1")
|
||||
require.NotContains(t, newAll, "folder2")
|
||||
require.Contains(t, newAll, ngmodels.GetNamespaceKey("folder1", "subfolder"))
|
||||
})
|
||||
|
||||
t.Run("Get by folder returns groups in folder", func(t *testing.T) {
|
||||
_, status, _ := apiClient.GetAllRulesGroupInFolderWithStatus(t, "folder2")
|
||||
require.Equal(t, http.StatusForbidden, status)
|
||||
})
|
||||
|
||||
t.Run("Get group returns a single group", func(t *testing.T) {
|
||||
u := fmt.Sprintf("%s/api/ruler/grafana/api/v1/rules/folder2/arulegroup", apiClient.url)
|
||||
// nolint:gosec
|
||||
resp, err := http.Get(u)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("Export returns all rules", func(t *testing.T) {
|
||||
status, exportRaw := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
|
||||
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
var export apimodels.AlertingFileExport
|
||||
require.NoError(t, json.Unmarshal([]byte(exportRaw), &export))
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Len(t, export.Groups, 2)
|
||||
require.Equal(t, "folder1", export.Groups[0].Folder)
|
||||
require.Equal(t, "subfolder", export.Groups[1].Folder)
|
||||
})
|
||||
|
||||
t.Run("Export from one folder", func(t *testing.T) {
|
||||
status, _ := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
|
||||
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
|
||||
FolderUID: []string{"folder2"},
|
||||
})
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
})
|
||||
|
||||
t.Run("Export from one group", func(t *testing.T) {
|
||||
status, _ := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
|
||||
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
|
||||
FolderUID: []string{"folder2"},
|
||||
GroupName: "arulegroup",
|
||||
})
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
})
|
||||
|
||||
t.Run("Export single rule", func(t *testing.T) {
|
||||
uid := allRules["folder2"][0].Rules[0].GrafanaManagedAlert.UID
|
||||
status, _ := apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
|
||||
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
|
||||
RuleUID: uid,
|
||||
})
|
||||
require.Equal(t, http.StatusForbidden, status)
|
||||
})
|
||||
|
||||
t.Run("when all permissions are revoked", func(t *testing.T) {
|
||||
removeFolderPermission(t, permissionsStore, 1, userID, org.RoleEditor, "folder1")
|
||||
apiClient.ReloadCachedPermissions(t)
|
||||
|
||||
rules, status, _ := apiClient.GetAllRulesWithStatus(t)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Empty(t, rules)
|
||||
|
||||
status, _ = apiClient.ExportRulesWithStatus(t, &apimodels.AlertRulesExportParameters{
|
||||
ExportQueryParams: apimodels.ExportQueryParams{Format: "json"},
|
||||
})
|
||||
require.Equal(t, http.StatusNotFound, status)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func createRule(t *testing.T, client apiClient, folder string) (apimodels.PostableRuleGroupConfig, string) {
|
||||
t.Helper()
|
||||
|
||||
@ -895,19 +1251,21 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
|
||||
})
|
||||
|
||||
client := newAlertingApiClient(grafanaListedAddr, "grafana", "password")
|
||||
folder1Title := "folder1"
|
||||
client.CreateFolder(t, util.GenerateShortUID(), folder1Title)
|
||||
parentFolderUID := util.GenerateShortUID()
|
||||
client.CreateFolder(t, parentFolderUID, "parent")
|
||||
folderUID := util.GenerateShortUID()
|
||||
client.CreateFolder(t, folderUID, "folder1", parentFolderUID)
|
||||
|
||||
group1 := generateAlertRuleGroup(5, alertRuleGen())
|
||||
group2 := generateAlertRuleGroup(5, alertRuleGen())
|
||||
|
||||
_, status, _ := client.PostRulesGroupWithStatus(t, folder1Title, &group1)
|
||||
_, status, _ := client.PostRulesGroupWithStatus(t, folderUID, &group1)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
_, status, _ = client.PostRulesGroupWithStatus(t, folder1Title, &group2)
|
||||
_, status, _ = client.PostRulesGroupWithStatus(t, folderUID, &group2)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
t.Run("should persist order of the rules in a group", func(t *testing.T) {
|
||||
group1Get := client.GetRulesGroup(t, folder1Title, group1.Name)
|
||||
group1Get := client.GetRulesGroup(t, folderUID, group1.Name)
|
||||
assert.Equal(t, group1.Name, group1Get.Name)
|
||||
assert.Equal(t, group1.Interval, group1Get.Interval)
|
||||
assert.Len(t, group1Get.Rules, len(group1.Rules))
|
||||
@ -926,10 +1284,10 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
|
||||
for _, rule := range postableGroup1.Rules {
|
||||
expectedUids = append(expectedUids, rule.GrafanaManagedAlert.UID)
|
||||
}
|
||||
_, status, _ := client.PostRulesGroupWithStatus(t, folder1Title, &postableGroup1)
|
||||
_, status, _ := client.PostRulesGroupWithStatus(t, folderUID, &postableGroup1)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
group1Get = client.GetRulesGroup(t, folder1Title, group1.Name)
|
||||
group1Get = client.GetRulesGroup(t, folderUID, group1.Name)
|
||||
|
||||
require.Len(t, group1Get.Rules, len(postableGroup1.Rules))
|
||||
|
||||
@ -941,8 +1299,8 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("should be able to move a rule from another group in a specific position", func(t *testing.T) {
|
||||
group1Get := client.GetRulesGroup(t, folder1Title, group1.Name)
|
||||
group2Get := client.GetRulesGroup(t, folder1Title, group2.Name)
|
||||
group1Get := client.GetRulesGroup(t, folderUID, group1.Name)
|
||||
group2Get := client.GetRulesGroup(t, folderUID, group2.Name)
|
||||
|
||||
movedRule := convertGettableRuleToPostable(group2Get.Rules[3])
|
||||
// now shuffle the rules
|
||||
@ -952,10 +1310,10 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
|
||||
for _, rule := range postableGroup1.Rules {
|
||||
expectedUids = append(expectedUids, rule.GrafanaManagedAlert.UID)
|
||||
}
|
||||
_, status, _ := client.PostRulesGroupWithStatus(t, folder1Title, &postableGroup1)
|
||||
_, status, _ := client.PostRulesGroupWithStatus(t, folderUID, &postableGroup1)
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
|
||||
group1Get = client.GetRulesGroup(t, folder1Title, group1.Name)
|
||||
group1Get = client.GetRulesGroup(t, folderUID, group1.Name)
|
||||
|
||||
require.Len(t, group1Get.Rules, len(postableGroup1.Rules))
|
||||
|
||||
@ -965,7 +1323,7 @@ func TestIntegrationRuleGroupSequence(t *testing.T) {
|
||||
}
|
||||
assert.Equal(t, expectedUids, actualUids)
|
||||
|
||||
group2Get = client.GetRulesGroup(t, folder1Title, group2.Name)
|
||||
group2Get = client.GetRulesGroup(t, folderUID, group2.Name)
|
||||
assert.Len(t, group2Get.Rules, len(group2.Rules)-1)
|
||||
for _, rule := range group2Get.Rules {
|
||||
require.NotEqual(t, movedRule.GrafanaManagedAlert.UID, rule.GrafanaManagedAlert.UID)
|
||||
@ -1019,26 +1377,26 @@ func TestIntegrationRuleUpdate(t *testing.T) {
|
||||
adminClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
|
||||
|
||||
client := newAlertingApiClient(grafanaListedAddr, "grafana", "password")
|
||||
folder1Title := "folder1"
|
||||
client.CreateFolder(t, util.GenerateShortUID(), folder1Title)
|
||||
folderUID := util.GenerateShortUID()
|
||||
client.CreateFolder(t, folderUID, "folder1")
|
||||
|
||||
t.Run("should be able to reset 'for' to 0", func(t *testing.T) {
|
||||
group := generateAlertRuleGroup(1, alertRuleGen())
|
||||
expected := model.Duration(10 * time.Second)
|
||||
group.Rules[0].ApiRuleNode.For = &expected
|
||||
|
||||
_, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
|
||||
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, expected, *getGroup.Rules[0].ApiRuleNode.For)
|
||||
|
||||
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
expected = 0
|
||||
group.Rules[0].ApiRuleNode.For = &expected
|
||||
_, status, body = client.PostRulesGroupWithStatus(t, folder1Title, &group)
|
||||
_, status, body = client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
|
||||
getGroup = client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, expected, *getGroup.Rules[0].ApiRuleNode.For)
|
||||
})
|
||||
t.Run("when data source missing", func(t *testing.T) {
|
||||
@ -1047,10 +1405,10 @@ func TestIntegrationRuleUpdate(t *testing.T) {
|
||||
ds1 := adminClient.CreateTestDatasource(t)
|
||||
group := generateAlertRuleGroup(3, alertRuleGen(withDatasourceQuery(ds1.Body.Datasource.UID)))
|
||||
|
||||
_, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
|
||||
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
require.Len(t, group.Rules, 3)
|
||||
@ -1064,59 +1422,59 @@ func TestIntegrationRuleUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("noop should not fail", func(t *testing.T) {
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, groupName)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, groupName)
|
||||
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
_, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
|
||||
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body)
|
||||
})
|
||||
t.Run("should not let update rule if it does not fix datasource", func(t *testing.T) {
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, groupName)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, groupName)
|
||||
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
group.Rules[0].GrafanaManagedAlert.Title = uuid.NewString()
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
|
||||
if status == http.StatusAccepted {
|
||||
assert.Len(t, resp.Deleted, 1)
|
||||
getGroup = client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
assert.NotEqualf(t, group.Rules[0].GrafanaManagedAlert.Title, getGroup.Rules[0].GrafanaManagedAlert.Title, "group was updated")
|
||||
}
|
||||
require.Equalf(t, http.StatusBadRequest, status, "expected BadRequest. Response: %s", body)
|
||||
assert.Contains(t, body, "data source not found")
|
||||
})
|
||||
t.Run("should let delete broken rule", func(t *testing.T) {
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, groupName)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, groupName)
|
||||
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
// remove the last rule.
|
||||
group.Rules = group.Rules[0 : len(group.Rules)-1]
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to delete last rule from group. Response: %s", body)
|
||||
assert.Len(t, resp.Deleted, 1)
|
||||
|
||||
getGroup = client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
require.Len(t, group.Rules, 2)
|
||||
})
|
||||
t.Run("should let fix single rule", func(t *testing.T) {
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, groupName)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, groupName)
|
||||
group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
|
||||
ds2 := adminClient.CreateTestDatasource(t)
|
||||
withDatasourceQuery(ds2.Body.Datasource.UID)(&group.Rules[0])
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body)
|
||||
assert.Len(t, resp.Deleted, 0)
|
||||
assert.Len(t, resp.Updated, 2)
|
||||
assert.Len(t, resp.Created, 0)
|
||||
|
||||
getGroup = client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
require.Equal(t, ds2.Body.Datasource.UID, group.Rules[0].GrafanaManagedAlert.Data[0].DatasourceUID)
|
||||
})
|
||||
t.Run("should let delete group", func(t *testing.T) {
|
||||
status, body := client.DeleteRulesGroup(t, folder1Title, groupName)
|
||||
status, body := client.DeleteRulesGroup(t, folderUID, groupName)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body)
|
||||
})
|
||||
})
|
||||
@ -1210,18 +1568,18 @@ func TestIntegrationRulePause(t *testing.T) {
|
||||
})
|
||||
|
||||
client := newAlertingApiClient(grafanaListedAddr, "grafana", "password")
|
||||
folder1Title := "folder1"
|
||||
client.CreateFolder(t, util.GenerateShortUID(), folder1Title)
|
||||
folderUID := util.GenerateShortUID()
|
||||
client.CreateFolder(t, folderUID, "folder1")
|
||||
|
||||
t.Run("should create a paused rule if isPaused is true", func(t *testing.T) {
|
||||
group := generateAlertRuleGroup(1, alertRuleGen())
|
||||
expectedIsPaused := true
|
||||
group.Rules[0].GrafanaManagedAlert.IsPaused = &expectedIsPaused
|
||||
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
require.Len(t, resp.Created, 1)
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body)
|
||||
require.Equal(t, expectedIsPaused, getGroup.Rules[0].GrafanaManagedAlert.IsPaused)
|
||||
})
|
||||
@ -1231,10 +1589,10 @@ func TestIntegrationRulePause(t *testing.T) {
|
||||
expectedIsPaused := false
|
||||
group.Rules[0].GrafanaManagedAlert.IsPaused = &expectedIsPaused
|
||||
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
require.Len(t, resp.Created, 1)
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body)
|
||||
require.Equal(t, expectedIsPaused, getGroup.Rules[0].GrafanaManagedAlert.IsPaused)
|
||||
})
|
||||
@ -1243,10 +1601,10 @@ func TestIntegrationRulePause(t *testing.T) {
|
||||
group := generateAlertRuleGroup(1, alertRuleGen())
|
||||
group.Rules[0].GrafanaManagedAlert.IsPaused = nil
|
||||
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
|
||||
resp, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
require.Len(t, resp.Created, 1)
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body)
|
||||
require.False(t, getGroup.Rules[0].GrafanaManagedAlert.IsPaused)
|
||||
})
|
||||
@ -1301,17 +1659,17 @@ func TestIntegrationRulePause(t *testing.T) {
|
||||
group := generateAlertRuleGroup(1, alertRuleGen())
|
||||
group.Rules[0].GrafanaManagedAlert.IsPaused = &tc.isPausedInDb
|
||||
|
||||
_, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group)
|
||||
_, status, body := client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
getGroup := client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
getGroup := client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body)
|
||||
|
||||
group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig)
|
||||
group.Rules[0].GrafanaManagedAlert.IsPaused = tc.isPausedInBody
|
||||
_, status, body = client.PostRulesGroupWithStatus(t, folder1Title, &group)
|
||||
_, status, body = client.PostRulesGroupWithStatus(t, folderUID, &group)
|
||||
require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body)
|
||||
|
||||
getGroup = client.GetRulesGroup(t, folder1Title, group.Name)
|
||||
getGroup = client.GetRulesGroup(t, folderUID, group.Name)
|
||||
require.Equal(t, tc.expectedIsPausedInDb, getGroup.Rules[0].GrafanaManagedAlert.IsPaused)
|
||||
})
|
||||
}
|
||||
|
75
pkg/tests/api/alerting/test-data/rulegroup-3-export.json
Normal file
75
pkg/tests/api/alerting/test-data/rulegroup-3-export.json
Normal file
@ -0,0 +1,75 @@
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"groups": [
|
||||
{
|
||||
"orgId": 1,
|
||||
"name": "Group3",
|
||||
"folder": "<dynamic>",
|
||||
"interval": "1m",
|
||||
"rules": [
|
||||
{
|
||||
"uid": "<dynamic>",
|
||||
"title": "Rule1",
|
||||
"condition": "A",
|
||||
"data": [
|
||||
{
|
||||
"refId": "A",
|
||||
"relativeTimeRange": {
|
||||
"from": 0,
|
||||
"to": 0
|
||||
},
|
||||
"datasourceUid": "__expr__",
|
||||
"model": {
|
||||
"expression": "0 \u003e 0",
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"type": "math"
|
||||
}
|
||||
}
|
||||
],
|
||||
"noDataState": "NoData",
|
||||
"execErrState": "Alerting",
|
||||
"for": "5m",
|
||||
"annotations": {
|
||||
"annotation": "test-annotation"
|
||||
},
|
||||
"labels": {
|
||||
"label1": "test-label"
|
||||
},
|
||||
"isPaused": false
|
||||
},
|
||||
{
|
||||
"uid": "<dynamic>",
|
||||
"title": "Rule2",
|
||||
"condition": "A",
|
||||
"data": [
|
||||
{
|
||||
"refId": "A",
|
||||
"relativeTimeRange": {
|
||||
"from": 0,
|
||||
"to": 0
|
||||
},
|
||||
"datasourceUid": "__expr__",
|
||||
"model": {
|
||||
"expression": "0 == 0",
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"type": "math"
|
||||
}
|
||||
}
|
||||
],
|
||||
"noDataState": "NoData",
|
||||
"execErrState": "Alerting",
|
||||
"for": "5m",
|
||||
"annotations": {
|
||||
"annotation": "test-annotation"
|
||||
},
|
||||
"labels": {
|
||||
"label1": "test-label"
|
||||
},
|
||||
"isPaused": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
90
pkg/tests/api/alerting/test-data/rulegroup-3-get.json
Normal file
90
pkg/tests/api/alerting/test-data/rulegroup-3-get.json
Normal file
@ -0,0 +1,90 @@
|
||||
{
|
||||
"name": "Group3",
|
||||
"interval": "1m",
|
||||
"rules": [
|
||||
{
|
||||
"expr": "",
|
||||
"for": "5m",
|
||||
"labels": {
|
||||
"label1": "test-label"
|
||||
},
|
||||
"annotations": {
|
||||
"annotation": "test-annotation"
|
||||
},
|
||||
"grafana_alert": {
|
||||
"id": 1,
|
||||
"orgId": 1,
|
||||
"title": "Rule1",
|
||||
"condition": "A",
|
||||
"data": [
|
||||
{
|
||||
"refId": "A",
|
||||
"queryType": "",
|
||||
"relativeTimeRange": {
|
||||
"from": 0,
|
||||
"to": 0
|
||||
},
|
||||
"datasourceUid": "__expr__",
|
||||
"model": {
|
||||
"expression": "0 > 0",
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"type": "math"
|
||||
}
|
||||
}
|
||||
],
|
||||
"updated": "2023-09-29T17:37:19Z",
|
||||
"intervalSeconds": 60,
|
||||
"version": 1,
|
||||
"uid": "<dynamic>",
|
||||
"namespace_uid": "<dynamic>",
|
||||
"rule_group": "Group3",
|
||||
"no_data_state": "NoData",
|
||||
"exec_err_state": "Alerting",
|
||||
"is_paused": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"expr": "",
|
||||
"for": "5m",
|
||||
"labels": {
|
||||
"label1": "test-label"
|
||||
},
|
||||
"annotations": {
|
||||
"annotation": "test-annotation"
|
||||
},
|
||||
"grafana_alert": {
|
||||
"id": 2,
|
||||
"orgId": 1,
|
||||
"title": "Rule2",
|
||||
"condition": "A",
|
||||
"data": [
|
||||
{
|
||||
"refId": "A",
|
||||
"queryType": "",
|
||||
"relativeTimeRange": {
|
||||
"from": 0,
|
||||
"to": 0
|
||||
},
|
||||
"datasourceUid": "__expr__",
|
||||
"model": {
|
||||
"expression": "0 == 0",
|
||||
"intervalMs": 1000,
|
||||
"maxDataPoints": 43200,
|
||||
"type": "math"
|
||||
}
|
||||
}
|
||||
],
|
||||
"updated": "2023-09-29T17:37:19Z",
|
||||
"intervalSeconds": 60,
|
||||
"version": 1,
|
||||
"uid": "<dynamic>",
|
||||
"namespace_uid": "<dynamic>",
|
||||
"rule_group": "Group3",
|
||||
"no_data_state": "NoData",
|
||||
"exec_err_state": "Alerting",
|
||||
"is_paused": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
56
pkg/tests/api/alerting/test-data/rulegroup-3-post.json
Normal file
56
pkg/tests/api/alerting/test-data/rulegroup-3-post.json
Normal file
@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "Group3",
|
||||
"interval": "1m",
|
||||
"rules": [
|
||||
{
|
||||
"for": "5m",
|
||||
"labels": {
|
||||
"label1": "test-label"
|
||||
},
|
||||
"annotations": {
|
||||
"annotation": "test-annotation"
|
||||
},
|
||||
"grafana_alert": {
|
||||
"title": "Rule1",
|
||||
"condition": "A",
|
||||
"data": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasourceUid": "__expr__",
|
||||
"model": {
|
||||
"expression": "0 > 0",
|
||||
"type": "math"
|
||||
}
|
||||
}
|
||||
],
|
||||
"no_data_state": "NoData",
|
||||
"exec_err_state": "Alerting"
|
||||
}
|
||||
},
|
||||
{
|
||||
"for": "5m",
|
||||
"labels": {
|
||||
"label1": "test-label"
|
||||
},
|
||||
"annotations": {
|
||||
"annotation": "test-annotation"
|
||||
},
|
||||
"grafana_alert": {
|
||||
"title": "Rule2",
|
||||
"condition": "A",
|
||||
"data": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasourceUid": "__expr__",
|
||||
"model": {
|
||||
"expression": "0 == 0",
|
||||
"type": "math"
|
||||
}
|
||||
}
|
||||
],
|
||||
"no_data_state": "NoData",
|
||||
"exec_err_state": "Alerting"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/api"
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
@ -260,9 +261,20 @@ func (a apiClient) ReloadCachedPermissions(t *testing.T) {
|
||||
}
|
||||
|
||||
// CreateFolder creates a folder for storing our alerts, and then refreshes the permission cache to make sure that following requests will be accepted
|
||||
func (a apiClient) CreateFolder(t *testing.T, uID string, title string) {
|
||||
func (a apiClient) CreateFolder(t *testing.T, uID string, title string, parentUID ...string) {
|
||||
t.Helper()
|
||||
payload := fmt.Sprintf(`{"uid": "%s","title": "%s"}`, uID, title)
|
||||
cmd := folder.CreateFolderCommand{
|
||||
UID: uID,
|
||||
Title: title,
|
||||
}
|
||||
if len(parentUID) > 0 {
|
||||
cmd.ParentUID = parentUID[0]
|
||||
}
|
||||
|
||||
blob, err := json.Marshal(cmd)
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := string(blob)
|
||||
u := fmt.Sprintf("%s/api/folders", a.url)
|
||||
r := strings.NewReader(payload)
|
||||
// nolint:gosec
|
||||
|
@ -21720,6 +21720,7 @@
|
||||
}
|
||||
},
|
||||
"gettableSilences": {
|
||||
"description": "GettableSilences gettable silences",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableSilence"
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { css } from '@emotion/css';
|
||||
import debounce from 'debounce-promise';
|
||||
import React, { useState, useEffect, useMemo, useCallback, FormEvent } from 'react';
|
||||
import React, { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { AppEvents, SelectableValue, GrafanaTheme2 } from '@grafana/data';
|
||||
import { AppEvents, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { useStyles2, ActionMeta, Input, InputActionMeta, AsyncVirtualizedSelect } from '@grafana/ui';
|
||||
import { ActionMeta, AsyncVirtualizedSelect, Input, InputActionMeta, useStyles2 } from '@grafana/ui';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
@ -54,6 +54,7 @@ export interface Props {
|
||||
skipInitialLoad?: boolean;
|
||||
/** The id of the search input. Use this to set a matching label with htmlFor */
|
||||
inputId?: string;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
export type SelectedFolder = SelectableValue<string>;
|
||||
@ -78,6 +79,7 @@ export function OldFolderPicker(props: Props) {
|
||||
searchQueryType,
|
||||
customAdd,
|
||||
folderWarning,
|
||||
invalid,
|
||||
} = props;
|
||||
|
||||
const rootName = rootNameProp ?? 'Dashboards';
|
||||
@ -349,6 +351,7 @@ export function OldFolderPicker(props: Props) {
|
||||
loadOptions={debouncedSearch}
|
||||
onChange={onFolderChange}
|
||||
onCreateOption={createNewFolder}
|
||||
invalid={invalid}
|
||||
isClearable={isClearable}
|
||||
/>
|
||||
</div>
|
||||
|
@ -8,9 +8,10 @@ import { byRole, byTestId, byText } from 'testing-library-selector';
|
||||
import { selectors } from '@grafana/e2e-selectors/src';
|
||||
import { config, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { DashboardSearchItem, DashboardSearchItemType } from 'app/features/search/types';
|
||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||
import 'whatwg-fetch';
|
||||
import { RuleWithLocation } from 'app/types/unified-alerting';
|
||||
import 'whatwg-fetch';
|
||||
|
||||
import {
|
||||
RulerAlertingRuleDTO,
|
||||
@ -156,7 +157,9 @@ describe('CloneRuleEditor', function () {
|
||||
'folder-one': [{ name: 'group1', interval: '20s', rules: [originRule] }],
|
||||
});
|
||||
|
||||
mockSearchApi(server).search([]);
|
||||
mockSearchApi(server).search([
|
||||
mockDashboardSearchItem({ title: 'folder-one', uid: '123', type: DashboardSearchItemType.DashDB }),
|
||||
]);
|
||||
mockAlertmanagerConfigResponse(server, GRAFANA_RULES_SOURCE_NAME, amConfig);
|
||||
|
||||
render(<CloneRuleEditor sourceRuleId={{ uid: 'grafana-rule-1', ruleSourceName: 'grafana' }} />, {
|
||||
@ -209,7 +212,15 @@ describe('CloneRuleEditor', function () {
|
||||
rules: [originRule],
|
||||
});
|
||||
|
||||
mockSearchApi(server).search([]);
|
||||
mockSearchApi(server).search([
|
||||
mockDashboardSearchItem({
|
||||
title: 'folder-one',
|
||||
uid: '123',
|
||||
type: DashboardSearchItemType.DashDB,
|
||||
folderTitle: 'folder-one',
|
||||
folderUid: '123',
|
||||
}),
|
||||
]);
|
||||
mockAlertmanagerConfigResponse(server, GRAFANA_RULES_SOURCE_NAME, amConfig);
|
||||
|
||||
render(
|
||||
@ -362,3 +373,18 @@ describe('CloneRuleEditor', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockDashboardSearchItem(searchItem: Partial<DashboardSearchItem>) {
|
||||
return {
|
||||
title: '',
|
||||
uid: '',
|
||||
type: DashboardSearchItemType.DashDB,
|
||||
url: '',
|
||||
uri: '',
|
||||
items: [],
|
||||
tags: [],
|
||||
slug: '',
|
||||
isStarred: false,
|
||||
...searchItem,
|
||||
};
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { render, waitFor, screen, within } from '@testing-library/react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
@ -7,7 +7,7 @@ import { ui } from 'test/helpers/alertingRuleEditor';
|
||||
|
||||
import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { DashboardSearchHit } from 'app/features/search/types';
|
||||
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
|
||||
import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions';
|
||||
@ -101,6 +101,7 @@ describe('RuleEditor grafana managed rules', () => {
|
||||
title: 'Folder A',
|
||||
uid: 'abcd',
|
||||
id: 1,
|
||||
type: DashboardSearchItemType.DashDB,
|
||||
};
|
||||
|
||||
const slashedFolder = {
|
||||
@ -136,7 +137,7 @@ describe('RuleEditor grafana managed rules', () => {
|
||||
[folder.title]: [
|
||||
{
|
||||
interval: '1m',
|
||||
name: 'my great new rule',
|
||||
name: 'group1',
|
||||
rules: [
|
||||
{
|
||||
annotations: { description: 'some description', summary: 'some summary' },
|
||||
@ -199,10 +200,10 @@ describe('RuleEditor grafana managed rules', () => {
|
||||
|
||||
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
|
||||
{ dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
|
||||
'Folder A',
|
||||
'abcd',
|
||||
{
|
||||
interval: '1m',
|
||||
name: 'my great new rule',
|
||||
name: 'group1',
|
||||
rules: [
|
||||
{
|
||||
annotations: { description: 'some description', summary: 'some summary', custom: 'value' },
|
||||
|
@ -7,7 +7,7 @@ import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { setDataSourceSrv } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { DashboardSearchHit } from 'app/features/search/types';
|
||||
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { GrafanaAlertStateDecision, PromApplication } from 'app/types/unified-alerting-dto';
|
||||
|
||||
@ -16,7 +16,7 @@ import { searchFolders } from '../../../../app/features/manage-dashboards/state/
|
||||
import { discoverFeatures } from './api/buildInfo';
|
||||
import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
|
||||
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
|
||||
import { grantUserPermissions, mockDataSource, MockDataSourceSrv } from './mocks';
|
||||
import { MockDataSourceSrv, grantUserPermissions, mockDataSource } from './mocks';
|
||||
import { fetchRulerRulesIfNotFetchedYet } from './state/actions';
|
||||
import * as config from './utils/config';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||
@ -105,29 +105,67 @@ describe('RuleEditor grafana managed rules', () => {
|
||||
mocks.api.fetchRulerRules.mockResolvedValue({
|
||||
'Folder A': [
|
||||
{
|
||||
interval: '1m',
|
||||
name: 'group1',
|
||||
rules: [],
|
||||
rules: [
|
||||
{
|
||||
annotations: { description: 'some description', summary: 'some summary' },
|
||||
labels: { severity: 'warn', team: 'the a-team' },
|
||||
for: '5m',
|
||||
grafana_alert: {
|
||||
uid: '23',
|
||||
namespace_uid: 'abcd',
|
||||
condition: 'B',
|
||||
data: getDefaultQueries(),
|
||||
exec_err_state: GrafanaAlertStateDecision.Error,
|
||||
no_data_state: GrafanaAlertStateDecision.NoData,
|
||||
title: 'my great new rule',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
namespace2: [
|
||||
{
|
||||
name: 'group2',
|
||||
rules: [],
|
||||
interval: '1m',
|
||||
name: 'group1',
|
||||
rules: [
|
||||
{
|
||||
annotations: { description: 'some description', summary: 'some summary' },
|
||||
labels: { severity: 'warn', team: 'the a-team' },
|
||||
for: '5m',
|
||||
grafana_alert: {
|
||||
uid: '23',
|
||||
namespace_uid: 'b',
|
||||
condition: 'B',
|
||||
data: getDefaultQueries(),
|
||||
exec_err_state: GrafanaAlertStateDecision.Error,
|
||||
no_data_state: GrafanaAlertStateDecision.NoData,
|
||||
title: 'my great new rule',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.searchFolders.mockResolvedValue([
|
||||
{
|
||||
title: 'Folder A',
|
||||
uid: 'abcd',
|
||||
id: 1,
|
||||
type: DashboardSearchItemType.DashDB,
|
||||
},
|
||||
{
|
||||
title: 'Folder B',
|
||||
id: 2,
|
||||
uid: 'b',
|
||||
type: DashboardSearchItemType.DashDB,
|
||||
},
|
||||
{
|
||||
title: 'Folder / with slash',
|
||||
uid: 'c',
|
||||
id: 2,
|
||||
type: DashboardSearchItemType.DashDB,
|
||||
},
|
||||
] as DashboardSearchHit[]);
|
||||
|
||||
@ -163,7 +201,7 @@ describe('RuleEditor grafana managed rules', () => {
|
||||
// 9seg
|
||||
expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith(
|
||||
{ dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' },
|
||||
'Folder A',
|
||||
'abcd',
|
||||
{
|
||||
interval: '1m',
|
||||
name: 'group1',
|
||||
|
@ -229,10 +229,10 @@ export const alertRuleApi = alertingApi.injectEndpoints({
|
||||
}),
|
||||
exportModifiedRuleGroup: build.mutation<
|
||||
string,
|
||||
{ payload: ModifyExportPayload; format: ExportFormats; nameSpace: string }
|
||||
{ payload: ModifyExportPayload; format: ExportFormats; nameSpaceUID: string }
|
||||
>({
|
||||
query: ({ payload, format, nameSpace }) => ({
|
||||
url: `/api/ruler/grafana/api/v1/rules/${nameSpace}/export/`,
|
||||
query: ({ payload, format, nameSpaceUID }) => ({
|
||||
url: `/api/ruler/grafana/api/v1/rules/${nameSpaceUID}/export/`,
|
||||
params: { format: format },
|
||||
responseType: 'text',
|
||||
data: payload,
|
||||
|
@ -41,8 +41,8 @@ export function rulerUrlBuilder(rulerConfig: RulerDataSourceConfig) {
|
||||
path: `${rulerPath}/${encodeURIComponent(namespace)}`,
|
||||
params: Object.fromEntries(rulerSearchParams),
|
||||
}),
|
||||
namespaceGroup: (namespace: string, group: string): RulerRequestUrl => ({
|
||||
path: `${rulerPath}/${encodeURIComponent(namespace)}/${encodeURIComponent(group)}`,
|
||||
namespaceGroup: (namespaceUID: string, group: string): RulerRequestUrl => ({
|
||||
path: `${rulerPath}/${encodeURIComponent(namespaceUID)}/${encodeURIComponent(group)}`,
|
||||
params: Object.fromEntries(rulerSearchParams),
|
||||
}),
|
||||
};
|
||||
@ -51,10 +51,10 @@ export function rulerUrlBuilder(rulerConfig: RulerDataSourceConfig) {
|
||||
// upsert a rule group. use this to update rule
|
||||
export async function setRulerRuleGroup(
|
||||
rulerConfig: RulerDataSourceConfig,
|
||||
namespace: string,
|
||||
namespaceIdentifier: string,
|
||||
group: PostableRulerRuleGroupDTO
|
||||
): Promise<void> {
|
||||
const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespace);
|
||||
const { path, params } = rulerUrlBuilder(rulerConfig).namespace(namespaceIdentifier);
|
||||
await lastValueFrom(
|
||||
getBackendSrv().fetch<unknown>({
|
||||
method: 'POST',
|
||||
@ -102,10 +102,10 @@ export async function fetchTestRulerRulesGroup(dataSourceName: string): Promise<
|
||||
|
||||
export async function fetchRulerRulesGroup(
|
||||
rulerConfig: RulerDataSourceConfig,
|
||||
namespace: string,
|
||||
namespaceIdentifier: string, // can be the namespace name or namespace UID
|
||||
group: string
|
||||
): Promise<RulerRuleGroupDTO | null> {
|
||||
const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespace, group);
|
||||
const { path, params } = rulerUrlBuilder(rulerConfig).namespaceGroup(namespaceIdentifier, group);
|
||||
return rulerGetRequest<RulerRuleGroupDTO | null>(path, null, params);
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,6 @@ import { Route } from 'react-router-dom';
|
||||
import { AutoSizerProps } from 'react-virtualized-auto-sizer';
|
||||
import { byRole, byTestId, byText } from 'testing-library-selector';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
|
||||
import { TestProvider } from '../../../../../../test/helpers/TestProvider';
|
||||
@ -36,7 +35,6 @@ const ui = {
|
||||
form: {
|
||||
nameInput: byRole('textbox', { name: 'name' }),
|
||||
folder: byTestId('folder-picker'),
|
||||
folderContainer: byTestId(selectors.components.FolderPicker.containerV2),
|
||||
group: byTestId('group-picker'),
|
||||
annotationKey: (idx: number) => byTestId(`annotation-key-${idx}`),
|
||||
annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`),
|
||||
@ -75,7 +73,7 @@ describe('GrafanaModifyExport', () => {
|
||||
const grafanaRule = getGrafanaRule(undefined, {
|
||||
uid: 'test-rule-uid',
|
||||
title: 'cpu-usage',
|
||||
namespace_uid: 'folder-test-uid',
|
||||
namespace_uid: 'folderUID1',
|
||||
data: [
|
||||
{
|
||||
refId: 'A',
|
||||
@ -97,21 +95,23 @@ describe('GrafanaModifyExport', () => {
|
||||
mockSearchApi(server).search([
|
||||
mockDashboardSearchItem({
|
||||
title: grafanaRule.namespace.name,
|
||||
uid: 'folder-test-uid',
|
||||
uid: 'folderUID1',
|
||||
url: '',
|
||||
tags: [],
|
||||
type: DashboardSearchItemType.DashFolder,
|
||||
}),
|
||||
]);
|
||||
mockAlertRuleApi(server).rulerRules(GRAFANA_RULES_SOURCE_NAME, {
|
||||
[grafanaRule.namespace.name]: [{ name: grafanaRule.group.name, interval: '1m', rules: [grafanaRule.rulerRule!] }],
|
||||
});
|
||||
mockAlertRuleApi(server).rulerRuleGroup(
|
||||
GRAFANA_RULES_SOURCE_NAME,
|
||||
grafanaRule.namespace.name,
|
||||
grafanaRule.group.name,
|
||||
{ name: grafanaRule.group.name, interval: '1m', rules: [grafanaRule.rulerRule!] }
|
||||
);
|
||||
mockExportApi(server).modifiedExport(grafanaRule.namespace.name, {
|
||||
mockAlertRuleApi(server).rulerRuleGroup(GRAFANA_RULES_SOURCE_NAME, 'folderUID1', grafanaRule.group.name, {
|
||||
name: grafanaRule.group.name,
|
||||
interval: '1m',
|
||||
rules: [grafanaRule.rulerRule!],
|
||||
});
|
||||
mockExportApi(server).modifiedExport('folderUID1', {
|
||||
yaml: 'Yaml Export Content',
|
||||
json: 'Json Export Content',
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
@ -127,6 +127,7 @@ describe('GrafanaModifyExport', () => {
|
||||
expect(drawer).toBeInTheDocument();
|
||||
|
||||
expect(ui.exportDrawer.yamlTab.get(drawer)).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ui.exportDrawer.editor.get(drawer)).toHaveTextContent('Yaml Export Content');
|
||||
});
|
||||
|
@ -1,6 +1,17 @@
|
||||
import { DataFrame, FieldType, toDataFrame } from '@grafana/data';
|
||||
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||
|
||||
import { getSeriesName, formatLabels, getSeriesValue, isEmptySeries, getSeriesLabels } from './util';
|
||||
import { mockDataSource } from '../../mocks';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||
|
||||
import {
|
||||
decodeGrafanaNamespace,
|
||||
formatLabels,
|
||||
getSeriesLabels,
|
||||
getSeriesName,
|
||||
getSeriesValue,
|
||||
isEmptySeries,
|
||||
} from './util';
|
||||
|
||||
const EMPTY_FRAME: DataFrame = toDataFrame([]);
|
||||
const NAMED_FRAME: DataFrame = {
|
||||
@ -34,6 +45,103 @@ describe('formatLabels', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeGrafanaNamespace', () => {
|
||||
it('should work for regular Grafana namespaces', () => {
|
||||
const grafanaNamespace: CombinedRuleNamespace = {
|
||||
name: `my_rule_namespace`,
|
||||
rulesSource: GRAFANA_RULES_SOURCE_NAME,
|
||||
groups: [
|
||||
{
|
||||
name: 'group1',
|
||||
rules: [],
|
||||
totals: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe('my_rule_namespace');
|
||||
});
|
||||
|
||||
it('should work for Grafana namespaces in nested folders format', () => {
|
||||
const grafanaNamespace: CombinedRuleNamespace = {
|
||||
name: `["parentUID","my_rule_namespace"]`,
|
||||
rulesSource: GRAFANA_RULES_SOURCE_NAME,
|
||||
groups: [
|
||||
{
|
||||
name: 'group1',
|
||||
rules: [],
|
||||
totals: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe('my_rule_namespace');
|
||||
});
|
||||
|
||||
it('should default to name if format is invalid: invalid JSON', () => {
|
||||
const grafanaNamespace: CombinedRuleNamespace = {
|
||||
name: `["parentUID"`,
|
||||
rulesSource: GRAFANA_RULES_SOURCE_NAME,
|
||||
groups: [
|
||||
{
|
||||
name: 'group1',
|
||||
rules: [],
|
||||
totals: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe(`["parentUID"`);
|
||||
});
|
||||
|
||||
it('should default to name if format is invalid: empty array', () => {
|
||||
const grafanaNamespace: CombinedRuleNamespace = {
|
||||
name: `[]`,
|
||||
rulesSource: GRAFANA_RULES_SOURCE_NAME,
|
||||
groups: [
|
||||
{
|
||||
name: 'group1',
|
||||
rules: [],
|
||||
totals: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe(`[]`);
|
||||
});
|
||||
|
||||
it('grab folder name if format is long array', () => {
|
||||
const grafanaNamespace: CombinedRuleNamespace = {
|
||||
name: `["parentUID","my_rule_namespace","another_part"]`,
|
||||
rulesSource: GRAFANA_RULES_SOURCE_NAME,
|
||||
groups: [
|
||||
{
|
||||
name: 'group1',
|
||||
rules: [],
|
||||
totals: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(decodeGrafanaNamespace(grafanaNamespace)).toBe('another_part');
|
||||
});
|
||||
|
||||
it('should not change output for cloud namespaces', () => {
|
||||
const cloudNamespace: CombinedRuleNamespace = {
|
||||
name: `["parentUID","my_rule_namespace"]`,
|
||||
rulesSource: mockDataSource(),
|
||||
groups: [
|
||||
{
|
||||
name: 'Prom group',
|
||||
rules: [],
|
||||
totals: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(decodeGrafanaNamespace(cloudNamespace)).toBe(`["parentUID","my_rule_namespace"]`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmptySeries', () => {
|
||||
it('should be true for empty series', () => {
|
||||
expect(isEmptySeries([EMPTY_FRAME])).toBe(true);
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { DataFrame, Labels, roundDecimals } from '@grafana/data';
|
||||
import { CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||
|
||||
import { isCloudRulesSource } from '../../utils/datasource';
|
||||
|
||||
/**
|
||||
* ⚠️ `frame.fields` could be an empty array ⚠️
|
||||
@ -37,10 +40,29 @@ const formatLabels = (labels: Labels): string => {
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
/**
|
||||
* After https://github.com/grafana/grafana/pull/74600,
|
||||
* Grafana folder names will be returned from the API as a combination of the folder name and parent UID in a format of JSON array,
|
||||
* where first element is parent UID and the second element is Title.
|
||||
*/
|
||||
const decodeGrafanaNamespace = (namespace: CombinedRuleNamespace): string => {
|
||||
const namespaceName = namespace.name;
|
||||
|
||||
if (isCloudRulesSource(namespace.rulesSource)) {
|
||||
return namespaceName;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(namespaceName).at(-1) ?? namespaceName;
|
||||
} catch {
|
||||
return namespaceName;
|
||||
}
|
||||
};
|
||||
|
||||
const isEmptySeries = (series: DataFrame[]): boolean => {
|
||||
const isEmpty = series.every((serie) => serie.fields.every((field) => field.values.every((value) => value == null)));
|
||||
|
||||
return isEmpty;
|
||||
};
|
||||
|
||||
export { getSeriesName, getSeriesValue, getSeriesLabels, formatLabels, isEmptySeries };
|
||||
export { decodeGrafanaNamespace, formatLabels, getSeriesLabels, getSeriesName, getSeriesValue, isEmptySeries };
|
||||
|
@ -39,7 +39,7 @@ import { checkForPathSeparator } from './util';
|
||||
|
||||
export const MAX_GROUP_RESULTS = 1000;
|
||||
|
||||
export const useFolderGroupOptions = (folderTitle: string, enableProvisionedGroups: boolean) => {
|
||||
export const useFolderGroupOptions = (folderUid: string, enableProvisionedGroups: boolean) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// fetch the ruler rules from the database so we can figure out what other "groups" are already defined
|
||||
@ -52,7 +52,7 @@ export const useFolderGroupOptions = (folderTitle: string, enableProvisionedGrou
|
||||
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
|
||||
|
||||
const grafanaFolders = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME);
|
||||
const folderGroups = grafanaFolders.find((f) => f.name === folderTitle)?.groups ?? [];
|
||||
const folderGroups = grafanaFolders.find((f) => f.uid === folderUid)?.groups ?? [];
|
||||
|
||||
const groupOptions = folderGroups
|
||||
.map<SelectableValue<string>>((group) => {
|
||||
@ -105,7 +105,7 @@ export function FolderAndGroup({
|
||||
const folder = watch('folder');
|
||||
const group = watch('group');
|
||||
|
||||
const { groupOptions, loading } = useFolderGroupOptions(folder?.title ?? '', enableProvisionedGroups);
|
||||
const { groupOptions, loading } = useFolderGroupOptions(folder?.uid ?? '', enableProvisionedGroups);
|
||||
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||
const [isCreatingEvaluationGroup, setIsCreatingEvaluationGroup] = useState(false);
|
||||
@ -146,55 +146,62 @@ export function FolderAndGroup({
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Stack alignItems="center">
|
||||
<Field
|
||||
label={
|
||||
<Label htmlFor="folder" description={'Select a folder to store your rule.'}>
|
||||
Folder
|
||||
</Label>
|
||||
}
|
||||
className={styles.formInput}
|
||||
error={errors.folder?.message}
|
||||
invalid={!!errors.folder?.message}
|
||||
data-testid="folder-picker"
|
||||
>
|
||||
{(!isCreatingFolder && (
|
||||
<InputControl
|
||||
render={({ field: { ref, ...field } }) => (
|
||||
<div style={{ width: 420 }}>
|
||||
<RuleFolderPicker
|
||||
inputId="folder"
|
||||
{...field}
|
||||
enableReset={true}
|
||||
onChange={({ title, uid }) => {
|
||||
field.onChange({ title, uid });
|
||||
resetGroup();
|
||||
{
|
||||
<Field
|
||||
label={
|
||||
<Label htmlFor="folder" description={'Select a folder to store your rule.'}>
|
||||
Folder
|
||||
</Label>
|
||||
}
|
||||
className={styles.formInput}
|
||||
error={errors.folder?.message}
|
||||
data-testid="folder-picker"
|
||||
>
|
||||
<Stack direction="row" alignItems="center">
|
||||
{(!isCreatingFolder && (
|
||||
<>
|
||||
<InputControl
|
||||
render={({ field: { ref, ...field } }) => (
|
||||
<div style={{ width: 420 }}>
|
||||
<RuleFolderPicker
|
||||
inputId="folder"
|
||||
invalid={!!errors.folder?.message}
|
||||
{...field}
|
||||
enableReset={true}
|
||||
onChange={({ title, uid }) => {
|
||||
field.onChange({ title, uid });
|
||||
resetGroup();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
name="folder"
|
||||
rules={{
|
||||
required: { value: true, message: 'Select a folder' },
|
||||
validate: {
|
||||
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.uid),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
name="folder"
|
||||
rules={{
|
||||
required: { value: true, message: 'Select a folder' },
|
||||
validate: {
|
||||
pathSeparator: (folder: Folder) => checkForPathSeparator(folder.title),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)) || <div>Creating new folder...</div>}
|
||||
</Field>
|
||||
<Box marginTop={2.5} gap={1} display={'flex'} alignItems={'center'}>
|
||||
<Text color="secondary">or</Text>
|
||||
<Button
|
||||
onClick={onOpenFolderCreationModal}
|
||||
type="button"
|
||||
icon="plus"
|
||||
fill="outline"
|
||||
variant="secondary"
|
||||
disabled={!contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
|
||||
>
|
||||
New folder
|
||||
</Button>
|
||||
</Box>
|
||||
<Text color="secondary">or</Text>
|
||||
<Button
|
||||
onClick={onOpenFolderCreationModal}
|
||||
type="button"
|
||||
icon="plus"
|
||||
fill="outline"
|
||||
variant="secondary"
|
||||
disabled={!contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
|
||||
>
|
||||
New folder
|
||||
</Button>
|
||||
</>
|
||||
)) || <div>Creating new folder...</div>}
|
||||
</Stack>
|
||||
</Field>
|
||||
}
|
||||
{isCreatingFolder && (
|
||||
<FolderCreationModal onCreate={handleFolderCreation} onClose={() => setIsCreatingFolder(false)} />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{isCreatingFolder && (
|
||||
|
@ -92,16 +92,16 @@ function FolderGroupAndEvaluationInterval({
|
||||
const { watch, setValue, getValues } = useFormContext<RuleFormValues>();
|
||||
const [isEditingGroup, setIsEditingGroup] = useState(false);
|
||||
|
||||
const [groupName, folderName] = watch(['group', 'folder.title']);
|
||||
const [groupName, folderUid, folderName] = watch(['group', 'folder.uid', 'folder.title']);
|
||||
|
||||
const rulerRuleRequests = useUnifiedAlertingSelector((state) => state.rulerRules);
|
||||
const groupfoldersForGrafana = rulerRuleRequests[GRAFANA_RULES_SOURCE_NAME];
|
||||
|
||||
const grafanaNamespaces = useCombinedRuleNamespaces(GRAFANA_RULES_SOURCE_NAME);
|
||||
const existingNamespace = grafanaNamespaces.find((ns) => ns.name === folderName);
|
||||
const existingNamespace = grafanaNamespaces.find((ns) => ns.uid === folderUid);
|
||||
const existingGroup = existingNamespace?.groups.find((g) => g.name === groupName);
|
||||
|
||||
const isNewGroup = useIsNewGroup(folderName ?? '', groupName);
|
||||
const isNewGroup = useIsNewGroup(folderUid ?? '', groupName);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNewGroup && existingGroup?.interval) {
|
||||
@ -118,7 +118,7 @@ function FolderGroupAndEvaluationInterval({
|
||||
|
||||
const onOpenEditGroupModal = () => setIsEditingGroup(true);
|
||||
|
||||
const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderName || !groupName;
|
||||
const editGroupDisabled = groupfoldersForGrafana?.loading || isNewGroup || !folderUid || !groupName;
|
||||
|
||||
const emptyNamespace: CombinedRuleNamespace = {
|
||||
name: folderName,
|
||||
|
@ -2,11 +2,11 @@ import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Icon, Tooltip, useStyles2, Stack } from '@grafana/ui';
|
||||
import { OldFolderPicker, Props as FolderPickerProps } from 'app/core/components/Select/OldFolderPicker';
|
||||
import { Icon, Stack, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { Props as FolderPickerProps, OldFolderPicker } from 'app/core/components/Select/OldFolderPicker';
|
||||
import { PermissionLevelString, SearchQueryType } from 'app/types';
|
||||
|
||||
import { FolderWarning, CustomAdd } from '../../../../../core/components/Select/OldFolderPicker';
|
||||
import { CustomAdd, FolderWarning } from '../../../../../core/components/Select/OldFolderPicker';
|
||||
|
||||
export interface Folder {
|
||||
title: string;
|
||||
@ -15,6 +15,7 @@ export interface Folder {
|
||||
|
||||
export interface RuleFolderPickerProps extends Omit<FolderPickerProps, 'initialTitle' | 'initialFolderId'> {
|
||||
value?: Folder;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
const SlashesWarning = () => {
|
||||
@ -51,7 +52,6 @@ export function RuleFolderPicker(props: RuleFolderPickerProps) {
|
||||
showRoot={false}
|
||||
rootName=""
|
||||
allowEmpty={true}
|
||||
initialTitle={value?.title}
|
||||
initialFolderUid={value?.uid}
|
||||
searchQueryType={SearchQueryType.AlertFolder}
|
||||
{...props}
|
||||
|
@ -111,14 +111,14 @@ export function ModifyExportRuleForm({ ruleForm, alertUid }: ModifyExportRuleFor
|
||||
);
|
||||
}
|
||||
|
||||
const useGetGroup = (nameSpace: string, group: string) => {
|
||||
const useGetGroup = (nameSpaceUID: string, group: string) => {
|
||||
const { dsFeatures } = useDataSourceFeatures(GRAFANA_RULES_SOURCE_NAME);
|
||||
|
||||
const rulerConfig = dsFeatures?.rulerConfig;
|
||||
|
||||
const targetGroup = useAsync(async () => {
|
||||
return rulerConfig ? await fetchRulerRulesGroup(rulerConfig, nameSpace, group) : undefined;
|
||||
}, [rulerConfig, nameSpace, group]);
|
||||
return rulerConfig ? await fetchRulerRulesGroup(rulerConfig, nameSpaceUID, group) : undefined;
|
||||
}, [rulerConfig, nameSpaceUID, group]);
|
||||
|
||||
return targetGroup;
|
||||
};
|
||||
@ -166,7 +166,7 @@ export const getPayloadToExport = (
|
||||
};
|
||||
|
||||
const useGetPayloadToExport = (values: RuleFormValues, uid: string) => {
|
||||
const rulerGroupDto = useGetGroup(values.folder?.title ?? '', values.group);
|
||||
const rulerGroupDto = useGetGroup(values.folder?.uid ?? '', values.group);
|
||||
const payload: ModifyExportPayload = useMemo(() => {
|
||||
return getPayloadToExport(uid, values, rulerGroupDto?.value);
|
||||
}, [uid, rulerGroupDto, values]);
|
||||
@ -182,11 +182,11 @@ const GrafanaRuleDesignExportPreview = ({
|
||||
const [getExport, exportData] = alertRuleApi.endpoints.exportModifiedRuleGroup.useMutation();
|
||||
const { loadingGroup, payload } = useGetPayloadToExport(exportValues, uid);
|
||||
|
||||
const nameSpace = exportValues.folder?.title ?? '';
|
||||
const nameSpaceUID = exportValues.folder?.uid ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
!loadingGroup && getExport({ payload, format: exportFormat, nameSpace: nameSpace });
|
||||
}, [nameSpace, exportFormat, payload, getExport, loadingGroup]);
|
||||
!loadingGroup && getExport({ payload, format: exportFormat, nameSpaceUID });
|
||||
}, [nameSpaceUID, exportFormat, payload, getExport, loadingGroup]);
|
||||
|
||||
if (exportData.isLoading) {
|
||||
return <LoadingPlaceholder text="Loading...." />;
|
||||
|
@ -28,6 +28,7 @@ import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
|
||||
import { AlertLabels } from '../AlertLabels';
|
||||
import { DetailsField } from '../DetailsField';
|
||||
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
|
||||
import { decodeGrafanaNamespace } from '../expressions/util';
|
||||
import { RuleViewerLayout } from '../rule-viewer/RuleViewerLayout';
|
||||
import { RuleDetailsActionButtons } from '../rules/RuleDetailsActionButtons';
|
||||
import { RuleDetailsAnnotations } from '../rules/RuleDetailsAnnotations';
|
||||
@ -153,7 +154,7 @@ export function RuleViewer({ match }: RuleViewerProps) {
|
||||
<RuleDetailsDataSources rule={rule} rulesSource={rulesSource} />
|
||||
{isFederatedRule && <RuleDetailsFederatedSources group={rule.group} />}
|
||||
<DetailsField label="Namespace / Group" className={styles.rightSideDetails}>
|
||||
{rule.namespace.name} / {rule.group.name}
|
||||
{decodeGrafanaNamespace(rule.namespace)} / {rule.group.name}
|
||||
</DetailsField>
|
||||
{isGrafanaRulerRule(rule.rulerRule) && <GrafanaRuleUID rule={rule.rulerRule.grafana_alert} />}
|
||||
</div>
|
||||
|
@ -29,6 +29,7 @@ import { AlertingPageWrapper } from '../../AlertingPageWrapper';
|
||||
import MoreButton from '../../MoreButton';
|
||||
import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning';
|
||||
import { DeclareIncidentMenuItem } from '../../bridges/DeclareIncidentButton';
|
||||
import { decodeGrafanaNamespace } from '../../expressions/util';
|
||||
import { Details } from '../tabs/Details';
|
||||
import { History } from '../tabs/History';
|
||||
import { InstancesList } from '../tabs/Instances';
|
||||
@ -336,6 +337,9 @@ function usePageNav(rule: CombinedRule) {
|
||||
const isAlertType = isAlertingRule(promRule);
|
||||
const numberOfInstance = isAlertType ? (promRule.alerts ?? []).length : undefined;
|
||||
|
||||
const namespaceName = decodeGrafanaNamespace(rule.namespace);
|
||||
const groupName = rule.group.name;
|
||||
|
||||
const pageNav: NavModelItem = {
|
||||
...defaultPageNav,
|
||||
text: rule.name,
|
||||
@ -372,14 +376,14 @@ function usePageNav(rule: CombinedRule) {
|
||||
},
|
||||
],
|
||||
parentItem: {
|
||||
text: rule.group.name,
|
||||
text: groupName,
|
||||
url: createListFilterLink([
|
||||
['namespace', rule.namespace.name],
|
||||
['group', rule.group.name],
|
||||
['namespace', namespaceName],
|
||||
['group', groupName],
|
||||
]),
|
||||
parentItem: {
|
||||
text: rule.namespace.name,
|
||||
url: createListFilterLink([['namespace', rule.namespace.name]]),
|
||||
text: namespaceName,
|
||||
url: createListFilterLink([['namespace', namespaceName]]),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -158,11 +158,12 @@ export interface ModalProps {
|
||||
onClose: (saved?: boolean) => void;
|
||||
intervalEditOnly?: boolean;
|
||||
folderUrl?: string;
|
||||
folderUid?: string;
|
||||
hideFolder?: boolean;
|
||||
}
|
||||
|
||||
export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
||||
const { namespace, group, onClose, intervalEditOnly } = props;
|
||||
const { namespace, group, onClose, intervalEditOnly, folderUid } = props;
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const dispatch = useDispatch();
|
||||
@ -201,6 +202,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
|
||||
namespaceName: namespace.name,
|
||||
newNamespaceName: values.namespaceName,
|
||||
groupInterval: values.groupInterval || undefined,
|
||||
folderUid,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
@ -27,12 +27,13 @@ interface ModalProps {
|
||||
namespace: CombinedRuleNamespace;
|
||||
group: CombinedRuleGroup;
|
||||
onClose: () => void;
|
||||
folderUid?: string;
|
||||
}
|
||||
|
||||
type CombinedRuleWithUID = { uid: string } & CombinedRule;
|
||||
|
||||
export const ReorderCloudGroupModal = (props: ModalProps) => {
|
||||
const { group, namespace, onClose } = props;
|
||||
const { group, namespace, onClose, folderUid } = props;
|
||||
const [pending, setPending] = useState<boolean>(false);
|
||||
const [rulesList, setRulesList] = useState<CombinedRule[]>(group.rules);
|
||||
|
||||
@ -63,6 +64,7 @@ export const ReorderCloudGroupModal = (props: ModalProps) => {
|
||||
groupName: group.name,
|
||||
rulesSourceName: rulesSourceName,
|
||||
newRules: rulerRules,
|
||||
folderUid: folderUid || namespace.name,
|
||||
})
|
||||
)
|
||||
.unwrap()
|
||||
@ -70,7 +72,7 @@ export const ReorderCloudGroupModal = (props: ModalProps) => {
|
||||
setPending(false);
|
||||
});
|
||||
},
|
||||
[group.name, namespace.name, namespace.rulesSource, rulesList]
|
||||
[group.name, namespace.name, namespace.rulesSource, rulesList, folderUid]
|
||||
);
|
||||
|
||||
// assign unique but stable identifiers to each (alerting / recording) rule
|
||||
|
@ -3,11 +3,11 @@ import pluralize from 'pluralize';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Badge, ConfirmModal, HorizontalGroup, Icon, Spinner, Tooltip, useStyles2, Stack } from '@grafana/ui';
|
||||
import { Badge, ConfirmModal, HorizontalGroup, Icon, Spinner, Stack, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { useDispatch } from 'app/types';
|
||||
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||
|
||||
import { logInfo, LogMessages } from '../../Analytics';
|
||||
import { LogMessages, logInfo } from '../../Analytics';
|
||||
import { useFolder } from '../../hooks/useFolder';
|
||||
import { useHasRuler } from '../../hooks/useHasRuler';
|
||||
import { deleteRulesGroupAction } from '../../state/actions';
|
||||
@ -19,6 +19,7 @@ import { CollapseToggle } from '../CollapseToggle';
|
||||
import { RuleLocation } from '../RuleLocation';
|
||||
import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter';
|
||||
import { GrafanaRuleGroupExporter } from '../export/GrafanaRuleGroupExporter';
|
||||
import { decodeGrafanaNamespace } from '../expressions/util';
|
||||
|
||||
import { ActionIcon } from './ActionIcon';
|
||||
import { EditCloudGroupModal } from './EditRuleGroupModal';
|
||||
@ -204,9 +205,9 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
|
||||
|
||||
// ungrouped rules are rules that are in the "default" group name
|
||||
const groupName = isListView ? (
|
||||
<RuleLocation namespace={namespace.name} />
|
||||
<RuleLocation namespace={decodeGrafanaNamespace(namespace)} />
|
||||
) : (
|
||||
<RuleLocation namespace={namespace.name} group={group.name} />
|
||||
<RuleLocation namespace={decodeGrafanaNamespace(namespace)} group={group.name} />
|
||||
);
|
||||
|
||||
const closeEditModal = (saved = false) => {
|
||||
@ -278,10 +279,16 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }:
|
||||
group={group}
|
||||
onClose={() => closeEditModal()}
|
||||
folderUrl={folder?.canEdit ? makeFolderSettingsLink(folder) : undefined}
|
||||
folderUid={folderUID}
|
||||
/>
|
||||
)}
|
||||
{isReorderingGroup && (
|
||||
<ReorderCloudGroupModal group={group} namespace={namespace} onClose={() => setIsReorderingGroup(false)} />
|
||||
<ReorderCloudGroupModal
|
||||
group={group}
|
||||
folderUid={folderUID}
|
||||
namespace={namespace}
|
||||
onClose={() => setIsReorderingGroup(false)}
|
||||
/>
|
||||
)}
|
||||
<ConfirmModal
|
||||
isOpen={isDeletingGroup}
|
||||
|
@ -91,6 +91,13 @@ export function useCombinedRuleNamespaces(
|
||||
name: namespaceName,
|
||||
groups: [],
|
||||
};
|
||||
|
||||
// We need to set the namespace_uid for grafana rules as it's required to obtain the rule's groups
|
||||
// All rules from all groups have the same namespace_uid so we're taking the first one.
|
||||
if (isGrafanaRulerRule(groups[0].rules[0])) {
|
||||
namespace.uid = groups[0].rules[0].grafana_alert.namespace_uid;
|
||||
}
|
||||
|
||||
namespaces[namespaceName] = namespace;
|
||||
addRulerGroupsToCombinedNamespace(namespace, groups);
|
||||
});
|
||||
|
@ -377,9 +377,9 @@ export function mockExportApi(server: SetupServer) {
|
||||
})
|
||||
);
|
||||
},
|
||||
modifiedExport: (namespace: string, response: Record<string, string>) => {
|
||||
modifiedExport: (namespaceUID: string, response: Record<string, string>) => {
|
||||
server.use(
|
||||
rest.post(`/api/ruler/grafana/api/v1/rules/${namespace}/export`, (req, res, ctx) => {
|
||||
rest.post(`/api/ruler/grafana/api/v1/rules/${namespaceUID}/export`, (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.text(response[req.url.searchParams.get('format') ?? 'yaml']));
|
||||
})
|
||||
);
|
||||
|
@ -745,6 +745,7 @@ interface UpdateNamespaceAndGroupOptions {
|
||||
newNamespaceName: string;
|
||||
newGroupName: string;
|
||||
groupInterval?: string;
|
||||
folderUid?: string;
|
||||
}
|
||||
|
||||
export const rulesInSameGroupHaveInvalidFor = (rules: RulerRuleDTO[], everyDuration: string) => {
|
||||
@ -768,13 +769,22 @@ export const updateLotexNamespaceAndGroupAction: AsyncThunk<
|
||||
return withAppEvents(
|
||||
withSerializedError(
|
||||
(async () => {
|
||||
const { rulesSourceName, namespaceName, groupName, newNamespaceName, newGroupName, groupInterval } = options;
|
||||
const {
|
||||
rulesSourceName,
|
||||
namespaceName,
|
||||
groupName,
|
||||
newNamespaceName,
|
||||
newGroupName,
|
||||
groupInterval,
|
||||
folderUid,
|
||||
} = options;
|
||||
|
||||
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, rulesSourceName);
|
||||
// fetch rules and perform sanity checks
|
||||
const rulesResult = await fetchRulerRules(rulerConfig);
|
||||
|
||||
const existingNamespace = Boolean(rulesResult[namespaceName]);
|
||||
|
||||
if (!existingNamespace) {
|
||||
throw new Error(`Namespace "${namespaceName}" not found.`);
|
||||
}
|
||||
@ -834,19 +844,19 @@ export const updateLotexNamespaceAndGroupAction: AsyncThunk<
|
||||
: group
|
||||
);
|
||||
}
|
||||
await deleteNamespace(rulerConfig, namespaceName);
|
||||
await deleteNamespace(rulerConfig, folderUid || namespaceName);
|
||||
|
||||
// if only modifying group...
|
||||
} else {
|
||||
// save updated group
|
||||
await setRulerRuleGroup(rulerConfig, namespaceName, {
|
||||
await setRulerRuleGroup(rulerConfig, folderUid || namespaceName, {
|
||||
...existingGroup,
|
||||
name: newGroupName,
|
||||
interval: groupInterval,
|
||||
});
|
||||
// if group name was changed, delete old group
|
||||
if (newGroupName !== groupName) {
|
||||
await deleteRulerRulesGroup(rulerConfig, namespaceName, groupName);
|
||||
await deleteRulerRulesGroup(rulerConfig, folderUid || namespaceName, groupName);
|
||||
}
|
||||
}
|
||||
|
||||
@ -867,6 +877,7 @@ interface UpdateRulesOrderOptions {
|
||||
namespaceName: string;
|
||||
groupName: string;
|
||||
newRules: RulerRuleDTO[];
|
||||
folderUid: string;
|
||||
}
|
||||
|
||||
export const updateRulesOrder = createAsyncThunk(
|
||||
@ -875,7 +886,7 @@ export const updateRulesOrder = createAsyncThunk(
|
||||
return withAppEvents(
|
||||
withSerializedError(
|
||||
(async () => {
|
||||
const { rulesSourceName, namespaceName, groupName, newRules } = options;
|
||||
const { rulesSourceName, namespaceName, groupName, newRules, folderUid } = options;
|
||||
|
||||
const rulerConfig = getDataSourceRulerConfig(thunkAPI.getState, rulesSourceName);
|
||||
const rulesResult = await fetchRulerRules(rulerConfig);
|
||||
@ -891,7 +902,7 @@ export const updateRulesOrder = createAsyncThunk(
|
||||
rules: newRules,
|
||||
};
|
||||
|
||||
await setRulerRuleGroup(rulerConfig, namespaceName, payload);
|
||||
await setRulerRuleGroup(rulerConfig, folderUid ?? namespaceName, payload);
|
||||
|
||||
await thunkAPI.dispatch(fetchRulerRulesAction({ rulesSourceName }));
|
||||
})()
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
RulerRuleGroupDTO,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { deleteRulerRulesGroup, fetchRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from '../api/ruler';
|
||||
import { deleteRulerRulesGroup, fetchRulerRules, fetchRulerRulesGroup, setRulerRuleGroup } from '../api/ruler';
|
||||
import { RuleFormValues } from '../types/rule-form';
|
||||
import * as ruleId from '../utils/rule-id';
|
||||
|
||||
@ -41,6 +41,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
|
||||
group,
|
||||
ruleSourceName: GRAFANA_RULES_SOURCE_NAME,
|
||||
namespace: namespace,
|
||||
namespace_uid: (isGrafanaRulerRule(rule) && rule.grafana_alert.namespace_uid) || undefined,
|
||||
rule,
|
||||
};
|
||||
}
|
||||
@ -81,15 +82,15 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
|
||||
};
|
||||
|
||||
const deleteRule = async (ruleWithLocation: RuleWithLocation): Promise<void> => {
|
||||
const { namespace, group, rule } = ruleWithLocation;
|
||||
const { namespace, group, rule, namespace_uid } = ruleWithLocation;
|
||||
|
||||
// it was the last rule, delete the entire group
|
||||
if (group.rules.length === 1) {
|
||||
await deleteRulerRulesGroup(rulerConfig, namespace, group.name);
|
||||
await deleteRulerRulesGroup(rulerConfig, namespace_uid || namespace, group.name);
|
||||
return;
|
||||
}
|
||||
// post the group with rule removed
|
||||
await setRulerRuleGroup(rulerConfig, namespace, {
|
||||
await setRulerRuleGroup(rulerConfig, namespace_uid || namespace, {
|
||||
...group,
|
||||
rules: group.rules.filter((r) => r !== rule),
|
||||
});
|
||||
@ -159,11 +160,11 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
|
||||
}
|
||||
|
||||
const newRule = formValuesToRulerGrafanaRuleDTO(values);
|
||||
const namespace = folder.title;
|
||||
const namespaceUID = folder.uid;
|
||||
const groupSpec = { name: group, interval: evaluateEvery };
|
||||
|
||||
if (!existingRule) {
|
||||
return addRuleToNamespaceAndGroup(namespace, groupSpec, newRule);
|
||||
return addRuleToNamespaceAndGroup(namespaceUID, groupSpec, newRule);
|
||||
}
|
||||
|
||||
// we'll fetch the existing group again, someone might have updated it while we were editing a rule
|
||||
@ -172,7 +173,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
|
||||
throw new Error('Rule not found.');
|
||||
}
|
||||
|
||||
const sameNamespace = freshExisting.namespace === namespace;
|
||||
const sameNamespace = freshExisting.namespace_uid === namespaceUID;
|
||||
const sameGroup = freshExisting.group.name === values.group;
|
||||
const sameLocation = sameNamespace && sameGroup;
|
||||
|
||||
@ -181,16 +182,16 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
|
||||
return updateGrafanaRule(freshExisting, newRule, evaluateEvery);
|
||||
} else {
|
||||
// we're moving a rule to either a different group or namespace
|
||||
return moveGrafanaRule(namespace, groupSpec, freshExisting, newRule);
|
||||
return moveGrafanaRule(namespaceUID, groupSpec, freshExisting, newRule);
|
||||
}
|
||||
};
|
||||
|
||||
const addRuleToNamespaceAndGroup = async (
|
||||
namespace: string,
|
||||
namespaceUID: string,
|
||||
group: { name: string; interval: string },
|
||||
newRule: PostableRuleGrafanaRuleDTO
|
||||
): Promise<RuleIdentifier> => {
|
||||
const existingGroup = await fetchRulerRulesGroup(rulerConfig, namespace, group.name);
|
||||
const existingGroup = await fetchRulerRulesGroup(rulerConfig, namespaceUID, group.name);
|
||||
if (!existingGroup) {
|
||||
throw new Error(`No group found with name "${group.name}"`);
|
||||
}
|
||||
@ -201,7 +202,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
|
||||
rules: (existingGroup.rules ?? []).concat(newRule as RulerGrafanaRuleDTO),
|
||||
};
|
||||
|
||||
await setRulerRuleGroup(rulerConfig, namespace, payload);
|
||||
await setRulerRuleGroup(rulerConfig, namespaceUID, payload);
|
||||
|
||||
return { uid: newRule.grafana_alert.uid ?? '', ruleSourceName: GRAFANA_RULES_SOURCE_NAME };
|
||||
};
|
||||
@ -242,7 +243,7 @@ export function getRulerClient(rulerConfig: RulerDataSourceConfig): RulerClient
|
||||
return rule;
|
||||
});
|
||||
|
||||
await setRulerRuleGroup(rulerConfig, existingRule.namespace, {
|
||||
await setRulerRuleGroup(rulerConfig, existingRule.namespace_uid ?? '', {
|
||||
name: existingRule.group.name,
|
||||
interval: interval,
|
||||
rules: newRules,
|
||||
|
@ -128,6 +128,7 @@ export interface CombinedRuleNamespace {
|
||||
rulesSource: RulesSource;
|
||||
name: string;
|
||||
groups: CombinedRuleGroup[];
|
||||
uid?: string; //available only in grafana rules
|
||||
}
|
||||
|
||||
export interface RuleWithLocation<T = RulerRuleDTO> {
|
||||
@ -135,6 +136,7 @@ export interface RuleWithLocation<T = RulerRuleDTO> {
|
||||
namespace: string;
|
||||
group: RulerRuleGroupDTO;
|
||||
rule: T;
|
||||
namespace_uid?: string;
|
||||
}
|
||||
|
||||
export interface CombinedRuleWithLocation extends CombinedRule {
|
||||
|
@ -12232,6 +12232,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"gettableSilences": {
|
||||
"description": "GettableSilences gettable silences",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/gettableSilence"
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user