Alerting: Rule version history API (#99041)

* implement store method to read rule versions

* implement request handler

* declare a new endpoint

* fix fake to return correct response

* add tests

* add integration tests

* rename history to versions

* apply diff from swagger CI step

Signed-off-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com>

---------

Signed-off-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com>
This commit is contained in:
Yuri Tseretyan 2025-02-03 13:26:18 -05:00 committed by GitHub
parent 8a259ecafa
commit ac41c19350
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 772 additions and 16 deletions

View File

@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"slices"
"sort"
"strings"
"time"
@ -330,6 +331,30 @@ func (srv RulerSrv) RouteGetRuleByUID(c *contextmodel.ReqContext, ruleUID string
return response.JSON(http.StatusOK, result)
}
func (srv RulerSrv) RouteGetRuleVersionsByUID(c *contextmodel.ReqContext, ruleUID string) response.Response {
ctx := c.Req.Context()
// make sure the user has access to the current version of the rule. Also, check if it exists
_, err := srv.getAuthorizedRuleByUid(ctx, c, ruleUID)
if err != nil {
if errors.Is(err, ngmodels.ErrAlertRuleNotFound) {
return response.Empty(http.StatusNotFound)
}
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get rule by UID", err)
}
rules, err := srv.store.GetAlertRuleVersions(ctx, ngmodels.AlertRuleKey{OrgID: c.OrgID, UID: ruleUID})
if err != nil {
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get rule history", err)
}
sort.Slice(rules, func(i, j int) bool { return rules[i].ID > rules[j].ID })
result := make(apimodels.GettableRuleVersions, 0, len(rules))
for _, rule := range rules {
// do not provide provenance status because we do not have historical changes for it
result = append(result, toGettableExtendedRuleNode(*rule, map[string]ngmodels.Provenance{}, srv.resolveUserIdToNameFn(ctx)))
}
return response.JSON(http.StatusOK, result)
}
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 {

View File

@ -448,6 +448,109 @@ func TestRouteGetRuleByUID(t *testing.T) {
})
}
func TestRouteGetRuleHistoryByUID(t *testing.T) {
orgID := rand.Int63()
f := randFolder()
groupKey := models.GenerateGroupKey(orgID)
groupKey.NamespaceUID = f.UID
gen := models.RuleGen.With(models.RuleGen.WithGroupKey(groupKey), models.RuleGen.WithUniqueID())
t.Run("rule history is successfully fetched with the correct UID", func(t *testing.T) {
ruleStore := fakes.NewRuleStore(t)
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], f)
rule := gen.GenerateRef()
history := gen.With(gen.WithUID(rule.UID)).GenerateManyRef(3)
// simulate order of the history
rule.ID = 100
for i, alertRule := range history {
alertRule.ID = rule.ID - int64(i) - 1
}
ruleStore.PutRule(context.Background(), rule)
ruleStore.History[rule.GetKey()] = append(ruleStore.History[rule.GetKey()], history...)
perms := createPermissionsForRules([]*models.AlertRule{rule}, orgID)
req := createRequestContextWithPerms(orgID, perms, nil)
svc := createService(ruleStore)
response := svc.RouteGetRuleVersionsByUID(req, rule.UID)
require.Equal(t, http.StatusOK, response.Status())
var result apimodels.GettableRuleVersions
require.NoError(t, json.Unmarshal(response.Body(), &result))
require.NotNil(t, result)
require.Len(t, result, len(history)+1) // history + current version
t.Run("should be in correct order", func(t *testing.T) {
expectedHistory := append([]*models.AlertRule{rule}, history...)
for i, rul := range expectedHistory {
assert.Equal(t, rul.UID, result[i].GrafanaManagedAlert.UID)
}
})
})
t.Run("NotFound when rule does not exist", func(t *testing.T) {
ruleStore := fakes.NewRuleStore(t)
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], f)
ruleKey := models.AlertRuleKey{
OrgID: orgID,
UID: "test",
}
history := gen.With(gen.WithKey(ruleKey)).GenerateManyRef(3)
ruleStore.History[ruleKey] = append(ruleStore.History[ruleKey], history...) // even if history is full of records
perms := createPermissionsForRules(history, orgID)
req := createRequestContextWithPerms(orgID, perms, nil)
response := createService(ruleStore).RouteGetRuleVersionsByUID(req, ruleKey.UID)
require.Equal(t, http.StatusNotFound, response.Status())
})
t.Run("Empty result when rule history is empty", func(t *testing.T) {
ruleStore := fakes.NewRuleStore(t)
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], f)
ruleKey := models.AlertRuleKey{
OrgID: orgID,
UID: "test",
}
rule := gen.With(gen.WithKey(ruleKey)).GenerateRef()
ruleStore.PutRule(context.Background(), rule)
ruleStore.History[ruleKey] = nil
perms := createPermissionsForRules([]*models.AlertRule{rule}, orgID)
req := createRequestContextWithPerms(orgID, perms, nil)
response := createService(ruleStore).RouteGetRuleVersionsByUID(req, ruleKey.UID)
require.Equal(t, http.StatusOK, response.Status())
var result apimodels.GettableRuleVersions
require.NoError(t, json.Unmarshal(response.Body(), &result))
require.Empty(t, result)
})
t.Run("Unauthorized if user does not have access to the current rule", func(t *testing.T) {
ruleStore := fakes.NewRuleStore(t)
anotherFolder := randFolder()
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], f, anotherFolder)
ruleKey := models.AlertRuleKey{
OrgID: orgID,
UID: "test",
}
rule := gen.With(gen.WithKey(ruleKey), gen.WithNamespaceUID(anotherFolder.UID)).GenerateRef()
ruleStore.PutRule(context.Background(), rule)
history := gen.With(gen.WithKey(ruleKey)).GenerateManyRef(3)
ruleStore.History[ruleKey] = history
perms := createPermissionsForRules(history, orgID) // grant permissions to all records in history but not the rule itself
req := createRequestContextWithPerms(orgID, perms, nil)
response := createService(ruleStore).RouteGetRuleVersionsByUID(req, ruleKey.UID)
require.Equal(t, http.StatusForbidden, response.Status())
})
}
func TestRouteGetRulesConfig(t *testing.T) {
gen := models.RuleGen
t.Run("fine-grained access is enabled", func(t *testing.T) {

View File

@ -41,7 +41,8 @@ func (api *API) authorize(method, path string) web.Handler {
case http.MethodGet + "/api/ruler/grafana/api/v1/rules",
http.MethodGet + "/api/ruler/grafana/api/v1/export/rules":
eval = ac.EvalPermission(ac.ActionAlertingRuleRead)
case http.MethodGet + "/api/ruler/grafana/api/v1/rule/{RuleUID}":
case http.MethodGet + "/api/ruler/grafana/api/v1/rule/{RuleUID}",
http.MethodGet + "/api/ruler/grafana/api/v1/rule/{RuleUID}/versions":
eval = ac.EvalAll(
ac.EvalPermission(ac.ActionAlertingRuleRead),
ac.EvalPermission(dashboards.ActionFoldersRead),

View File

@ -41,7 +41,7 @@ func TestAuthorize(t *testing.T) {
}
paths[p] = methods
}
require.Len(t, paths, 59)
require.Len(t, paths, 60)
ac := acmock.New()
api := &API{AccessControl: ac, FeatureManager: featuremgmt.WithFeatures()}

View File

@ -124,3 +124,7 @@ func (f *RulerApiHandler) getService(ctx *contextmodel.ReqContext) (*LotexRuler,
}
return f.LotexRuler, nil
}
func (f *RulerApiHandler) handleRouteGetRuleVersionsByUID(ctx *contextmodel.ReqContext, ruleUID string) response.Response {
return f.GrafanaRuler.RouteGetRuleVersionsByUID(ctx, ruleUID)
}

View File

@ -29,6 +29,7 @@ type RulerApi interface {
RouteGetNamespaceGrafanaRulesConfig(*contextmodel.ReqContext) response.Response
RouteGetNamespaceRulesConfig(*contextmodel.ReqContext) response.Response
RouteGetRuleByUID(*contextmodel.ReqContext) response.Response
RouteGetRuleVersionsByUID(*contextmodel.ReqContext) response.Response
RouteGetRulegGroupConfig(*contextmodel.ReqContext) response.Response
RouteGetRulesConfig(*contextmodel.ReqContext) response.Response
RouteGetRulesForExport(*contextmodel.ReqContext) response.Response
@ -86,6 +87,11 @@ func (f *RulerApiHandler) RouteGetRuleByUID(ctx *contextmodel.ReqContext) respon
ruleUIDParam := web.Params(ctx.Req)[":RuleUID"]
return f.handleRouteGetRuleByUID(ctx, ruleUIDParam)
}
func (f *RulerApiHandler) RouteGetRuleVersionsByUID(ctx *contextmodel.ReqContext) response.Response {
// Parse Path Parameters
ruleUIDParam := web.Params(ctx.Req)[":RuleUID"]
return f.handleRouteGetRuleVersionsByUID(ctx, ruleUIDParam)
}
func (f *RulerApiHandler) RouteGetRulegGroupConfig(ctx *contextmodel.ReqContext) response.Response {
// Parse Path Parameters
datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"]
@ -243,6 +249,18 @@ func (api *API) RegisterRulerApiEndpoints(srv RulerApi, m *metrics.API) {
m,
),
)
group.Get(
toMacaronPath("/api/ruler/grafana/api/v1/rule/{RuleUID}/versions"),
requestmeta.SetOwner(requestmeta.TeamAlerting),
requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow),
api.authorize(http.MethodGet, "/api/ruler/grafana/api/v1/rule/{RuleUID}/versions"),
metrics.Instrument(
http.MethodGet,
"/api/ruler/grafana/api/v1/rule/{RuleUID}/versions",
api.Hooks.Wrap(srv.RouteGetRuleVersionsByUID),
m,
),
)
group.Get(
toMacaronPath("/api/ruler/{DatasourceUID}/api/v1/rules/{Namespace}/{Groupname}"),
requestmeta.SetOwner(requestmeta.TeamAlerting),

View File

@ -28,6 +28,6 @@ type RuleStore interface {
// IncreaseVersionForAllRulesInNamespaces Increases version for all rules that have specified namespace uids
IncreaseVersionForAllRulesInNamespaces(ctx context.Context, orgID int64, namespaceUIDs []string) ([]ngmodels.AlertRuleKeyWithVersion, error)
GetAlertRuleVersions(ctx context.Context, key ngmodels.AlertRuleKey) ([]*ngmodels.AlertRule, error)
accesscontrol.RuleUIDToNamespaceStore
}

View File

@ -459,6 +459,9 @@
"format": "double",
"type": "number"
},
"folderUid": {
"type": "string"
},
"health": {
"type": "string"
},
@ -498,10 +501,15 @@
},
"type": {
"type": "string"
},
"uid": {
"type": "string"
}
},
"required": [
"uid",
"name",
"folderUid",
"query",
"health",
"type",
@ -1166,6 +1174,14 @@
"CounterResetHint": {
"$ref": "#/definitions/CounterResetHint"
},
"CustomValues": {
"description": "Holds the custom (usually upper) bounds for bucket definitions, otherwise nil.\nThis slice is interned, to be treated as immutable and copied by reference.\nThese numbers should be strictly increasing. This field is only used when the\nschema is for custom buckets, and the ZeroThreshold, ZeroCount, NegativeSpans\nand NegativeBuckets fields are not used in that case.",
"items": {
"format": "double",
"type": "number"
},
"type": "array"
},
"PositiveBuckets": {
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
"items": {
@ -1182,7 +1198,7 @@
"type": "array"
},
"Schema": {
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8 for exponential buckets.\nThey are all for base-2 bucket schemas, where 1 is a bucket boundary in\neach case, and then each power of two is divided into 2^n logarithmic buckets.\nOr in other words, each bucket boundary is the previous boundary times\n2^(2^-n). Another valid schema number is -53 for custom buckets, defined by\nthe CustomValues field.",
"format": "int32",
"type": "integer"
},
@ -1628,6 +1644,9 @@
"format": "date-time",
"type": "string"
},
"updated_by": {
"$ref": "#/definitions/UserInfo"
},
"version": {
"format": "int64",
"type": "integer"
@ -1712,6 +1731,12 @@
},
"type": "object"
},
"GettableRuleVersions": {
"items": {
"$ref": "#/definitions/GettableExtendedRuleNode"
},
"type": "array"
},
"GettableStatus": {
"properties": {
"cluster": {
@ -3572,6 +3597,9 @@
"format": "double",
"type": "number"
},
"folderUid": {
"type": "string"
},
"health": {
"type": "string"
},
@ -3593,10 +3621,15 @@
},
"type": {
"type": "string"
},
"uid": {
"type": "string"
}
},
"required": [
"uid",
"name",
"folderUid",
"query",
"health",
"type"
@ -3636,6 +3669,9 @@
"file": {
"type": "string"
},
"folderUid": {
"type": "string"
},
"interval": {
"format": "double",
"type": "number"
@ -3665,6 +3701,7 @@
"required": [
"name",
"file",
"folderUid",
"rules",
"interval"
],
@ -3767,6 +3804,10 @@
"Sample": {
"description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.",
"properties": {
"DropName": {
"description": "DropName is used to indicate whether the __name__ label should be dropped\nas part of the query evaluation.",
"type": "boolean"
},
"F": {
"format": "double",
"type": "number"
@ -4382,7 +4423,6 @@
"type": "object"
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nThe Host field contains the host and port subcomponents of the URL.\nWhen the port is present, it is separated from the host with a colon.\nWhen the host is an IPv6 address, it must be enclosed in square brackets:\n\"[fe80::1]:80\". The [net.JoinHostPort] function combines a host and port\ninto a string suitable for the Host field, adding square brackets to\nthe host when necessary.\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the [URL.EscapedPath] method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
"properties": {
"ForceQuery": {
"type": "boolean"
@ -4418,7 +4458,7 @@
"$ref": "#/definitions/Userinfo"
}
},
"title": "A URL represents a parsed URL (technically, a URI reference).",
"title": "URL is a custom URL type that allows validation at configuration load time.",
"type": "object"
},
"UpdateRuleGroupResponse": {
@ -4447,6 +4487,18 @@
},
"type": "object"
},
"UserInfo": {
"properties": {
"name": {
"type": "string"
},
"uid": {
"type": "string"
}
},
"title": "UserInfo represents user-related information, including a unique identifier and a name.",
"type": "object"
},
"Userinfo": {
"description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a [URL]. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.",
"type": "object"
@ -4648,6 +4700,7 @@
"type": "object"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup",
"type": "object"
@ -4934,6 +4987,7 @@
"type": "object"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"items": {
"$ref": "#/definitions/gettableSilence",
"type": "object"

View File

@ -20,6 +20,18 @@ import (
// 403: ForbiddenError
// 404: description: Not found.
// swagger:route Get /ruler/grafana/api/v1/rule/{RuleUID}/versions ruler RouteGetRuleVersionsByUID
//
// Get rule versions by UID
//
// Produces:
// - application/json
//
// Responses:
// 202: GettableRuleVersions
// 403: ForbiddenError
// 404: description: Not found.
// swagger:route Get /ruler/grafana/api/v1/rules ruler RouteGetGrafanaRulesConfig
//
// List rule groups
@ -218,7 +230,7 @@ type PathGetRulesParams struct {
PanelID int64
}
// swagger:parameters RouteGetRuleByUID
// swagger:parameters RouteGetRuleByUID RouteGetRuleVersionsByUID
type PathGetRuleByUIDParams struct {
// in: path
RuleUID string
@ -290,6 +302,9 @@ func (c *PostableRuleGroupConfig) validate() error {
return nil
}
// swagger:model
type GettableRuleVersions []GettableExtendedRuleNode
// swagger:model
type GettableRuleGroupConfig struct {
Name string `yaml:"name" json:"name"`

View File

@ -459,6 +459,9 @@
"format": "double",
"type": "number"
},
"folderUid": {
"type": "string"
},
"health": {
"type": "string"
},
@ -498,10 +501,15 @@
},
"type": {
"type": "string"
},
"uid": {
"type": "string"
}
},
"required": [
"uid",
"name",
"folderUid",
"query",
"health",
"type",
@ -1166,6 +1174,14 @@
"CounterResetHint": {
"$ref": "#/definitions/CounterResetHint"
},
"CustomValues": {
"description": "Holds the custom (usually upper) bounds for bucket definitions, otherwise nil.\nThis slice is interned, to be treated as immutable and copied by reference.\nThese numbers should be strictly increasing. This field is only used when the\nschema is for custom buckets, and the ZeroThreshold, ZeroCount, NegativeSpans\nand NegativeBuckets fields are not used in that case.",
"items": {
"format": "double",
"type": "number"
},
"type": "array"
},
"PositiveBuckets": {
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
"items": {
@ -1182,7 +1198,7 @@
"type": "array"
},
"Schema": {
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8 for exponential buckets.\nThey are all for base-2 bucket schemas, where 1 is a bucket boundary in\neach case, and then each power of two is divided into 2^n logarithmic buckets.\nOr in other words, each bucket boundary is the previous boundary times\n2^(2^-n). Another valid schema number is -53 for custom buckets, defined by\nthe CustomValues field.",
"format": "int32",
"type": "integer"
},
@ -1628,6 +1644,9 @@
"format": "date-time",
"type": "string"
},
"updated_by": {
"$ref": "#/definitions/UserInfo"
},
"version": {
"format": "int64",
"type": "integer"
@ -1712,6 +1731,12 @@
},
"type": "object"
},
"GettableRuleVersions": {
"items": {
"$ref": "#/definitions/GettableExtendedRuleNode"
},
"type": "array"
},
"GettableStatus": {
"properties": {
"cluster": {
@ -3572,6 +3597,9 @@
"format": "double",
"type": "number"
},
"folderUid": {
"type": "string"
},
"health": {
"type": "string"
},
@ -3593,10 +3621,15 @@
},
"type": {
"type": "string"
},
"uid": {
"type": "string"
}
},
"required": [
"uid",
"name",
"folderUid",
"query",
"health",
"type"
@ -3636,6 +3669,9 @@
"file": {
"type": "string"
},
"folderUid": {
"type": "string"
},
"interval": {
"format": "double",
"type": "number"
@ -3665,6 +3701,7 @@
"required": [
"name",
"file",
"folderUid",
"rules",
"interval"
],
@ -3767,6 +3804,10 @@
"Sample": {
"description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.",
"properties": {
"DropName": {
"description": "DropName is used to indicate whether the __name__ label should be dropped\nas part of the query evaluation.",
"type": "boolean"
},
"F": {
"format": "double",
"type": "number"
@ -4446,6 +4487,18 @@
},
"type": "object"
},
"UserInfo": {
"properties": {
"name": {
"type": "string"
},
"uid": {
"type": "string"
}
},
"title": "UserInfo represents user-related information, including a unique identifier and a name.",
"type": "object"
},
"Userinfo": {
"description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a [URL]. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.",
"type": "object"
@ -4647,6 +4700,7 @@
"type": "object"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup",
"type": "object"
@ -4808,6 +4862,7 @@
"type": "object"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": {
"$ref": "#/definitions/gettableAlert",
"type": "object"
@ -4932,6 +4987,7 @@
"type": "object"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"items": {
"$ref": "#/definitions/gettableSilence",
"type": "object"
@ -6515,6 +6571,43 @@
]
}
},
"/ruler/grafana/api/v1/rule/{RuleUID}/versions": {
"get": {
"description": "Get rule versions by UID",
"operationId": "RouteGetRuleVersionsByUID",
"parameters": [
{
"in": "path",
"name": "RuleUID",
"required": true,
"type": "string"
}
],
"produces": [
"application/json"
],
"responses": {
"202": {
"description": "GettableRuleVersions",
"schema": {
"$ref": "#/definitions/GettableRuleVersions"
}
},
"403": {
"description": "ForbiddenError",
"schema": {
"$ref": "#/definitions/ForbiddenError"
}
},
"404": {
"description": " Not found."
}
},
"tags": [
"ruler"
]
}
},
"/ruler/grafana/api/v1/rules": {
"get": {
"description": "List rule groups",

View File

@ -1343,6 +1343,43 @@
}
}
},
"/ruler/grafana/api/v1/rule/{RuleUID}/versions": {
"get": {
"description": "Get rule versions by UID",
"produces": [
"application/json"
],
"tags": [
"ruler"
],
"operationId": "RouteGetRuleVersionsByUID",
"parameters": [
{
"type": "string",
"name": "RuleUID",
"in": "path",
"required": true
}
],
"responses": {
"202": {
"description": "GettableRuleVersions",
"schema": {
"$ref": "#/definitions/GettableRuleVersions"
}
},
"403": {
"description": "ForbiddenError",
"schema": {
"$ref": "#/definitions/ForbiddenError"
}
},
"404": {
"description": " Not found."
}
}
}
},
"/ruler/grafana/api/v1/rules": {
"get": {
"description": "List rule groups",
@ -4088,7 +4125,9 @@
"description": "adapted from cortex",
"type": "object",
"required": [
"uid",
"name",
"folderUid",
"query",
"health",
"type",
@ -4118,6 +4157,9 @@
"type": "number",
"format": "double"
},
"folderUid": {
"type": "string"
},
"health": {
"type": "string"
},
@ -4157,6 +4199,9 @@
},
"type": {
"type": "string"
},
"uid": {
"type": "string"
}
}
},
@ -4818,6 +4863,14 @@
"CounterResetHint": {
"$ref": "#/definitions/CounterResetHint"
},
"CustomValues": {
"description": "Holds the custom (usually upper) bounds for bucket definitions, otherwise nil.\nThis slice is interned, to be treated as immutable and copied by reference.\nThese numbers should be strictly increasing. This field is only used when the\nschema is for custom buckets, and the ZeroThreshold, ZeroCount, NegativeSpans\nand NegativeBuckets fields are not used in that case.",
"type": "array",
"items": {
"type": "number",
"format": "double"
}
},
"PositiveBuckets": {
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
"type": "array",
@ -4834,7 +4887,7 @@
}
},
"Schema": {
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8 for exponential buckets.\nThey are all for base-2 bucket schemas, where 1 is a bucket boundary in\neach case, and then each power of two is divided into 2^n logarithmic buckets.\nOr in other words, each bucket boundary is the previous boundary times\n2^(2^-n). Another valid schema number is -53 for custom buckets, defined by\nthe CustomValues field.",
"type": "integer",
"format": "int32"
},
@ -5279,6 +5332,9 @@
"type": "string",
"format": "date-time"
},
"updated_by": {
"$ref": "#/definitions/UserInfo"
},
"version": {
"type": "integer",
"format": "int64"
@ -5362,6 +5418,12 @@
}
}
},
"GettableRuleVersions": {
"type": "array",
"items": {
"$ref": "#/definitions/GettableExtendedRuleNode"
}
},
"GettableStatus": {
"type": "object",
"required": [
@ -7220,7 +7282,9 @@
"description": "adapted from cortex",
"type": "object",
"required": [
"uid",
"name",
"folderUid",
"query",
"health",
"type"
@ -7230,6 +7294,9 @@
"type": "number",
"format": "double"
},
"folderUid": {
"type": "string"
},
"health": {
"type": "string"
},
@ -7251,6 +7318,9 @@
},
"type": {
"type": "string"
},
"uid": {
"type": "string"
}
}
},
@ -7283,6 +7353,7 @@
"required": [
"name",
"file",
"folderUid",
"rules",
"interval"
],
@ -7294,6 +7365,9 @@
"file": {
"type": "string"
},
"folderUid": {
"type": "string"
},
"interval": {
"type": "number",
"format": "double"
@ -7419,6 +7493,10 @@
"description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.",
"type": "object",
"properties": {
"DropName": {
"description": "DropName is used to indicate whether the __name__ label should be dropped\nas part of the query evaluation.",
"type": "boolean"
},
"F": {
"type": "number",
"format": "double"
@ -8097,6 +8175,18 @@
}
}
},
"UserInfo": {
"type": "object",
"title": "UserInfo represents user-related information, including a unique identifier and a name.",
"properties": {
"name": {
"type": "string"
},
"uid": {
"type": "string"
}
}
},
"Userinfo": {
"description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a [URL]. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.",
"type": "object"
@ -8298,6 +8388,7 @@
}
},
"alertGroups": {
"description": "AlertGroups alert groups",
"type": "array",
"items": {
"type": "object",
@ -8459,6 +8550,7 @@
}
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"type": "array",
"items": {
"type": "object",
@ -8583,6 +8675,7 @@
}
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"type": "array",
"items": {
"type": "object",

View File

@ -550,6 +550,19 @@ func (a *AlertRuleMutators) WithUpdatedBy(uid *UserUID) AlertRuleMutator {
}
}
func (a *AlertRuleMutators) WithUID(uid string) AlertRuleMutator {
return func(r *AlertRule) {
r.UID = uid
}
}
func (a *AlertRuleMutators) WithKey(key AlertRuleKey) AlertRuleMutator {
return func(r *AlertRule) {
r.UID = key.UID
r.OrgID = key.OrgID
}
}
func (g *AlertRuleGenerator) GenerateLabels(min, max int, prefix string) data.Labels {
count := max
if min > max {

View File

@ -118,6 +118,36 @@ func (st DBstore) GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAler
return result, err
}
func (st DBstore) GetAlertRuleVersions(ctx context.Context, key ngmodels.AlertRuleKey) ([]*ngmodels.AlertRule, error) {
alertRules := make([]*ngmodels.AlertRule, 0)
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
rows, err := sess.Table(new(alertRuleVersion)).Where("rule_org_id = ? AND rule_uid = ?", key.OrgID, key.UID).Desc("id").Rows(new(alertRuleVersion))
if err != nil {
return err
}
// Deserialize each rule separately in case any of them contain invalid JSON.
for rows.Next() {
rule := new(alertRuleVersion)
err = rows.Scan(rule)
if err != nil {
st.Logger.Error("Invalid rule version found in DB store, ignoring it", "func", "GetAlertRuleVersions", "error", err)
continue
}
converted, err := alertRuleToModelsAlertRule(alertRuleVersionToAlertRule(*rule), st.Logger)
if err != nil {
st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "GetAlertRuleVersions", "error", err, "version_id", rule.ID)
continue
}
alertRules = append(alertRules, &converted)
}
return nil
})
if err != nil {
return nil, err
}
return alertRules, nil
}
// GetRuleByID retrieves models.AlertRule by ID.
// It returns models.ErrAlertRuleNotFound if no alert rule is found for the provided ID.
func (st DBstore) GetRuleByID(ctx context.Context, query ngmodels.GetAlertRuleByIDQuery) (result *ngmodels.AlertRule, err error) {

View File

@ -204,3 +204,34 @@ func alertRuleToAlertRuleVersion(rule alertRule) alertRuleVersion {
Metadata: rule.Metadata,
}
}
func alertRuleVersionToAlertRule(version alertRuleVersion) alertRule {
return alertRule{
ID: version.ID,
OrgID: version.RuleOrgID,
Title: version.Title,
Condition: version.Condition,
Data: version.Data,
Updated: version.Created,
UpdatedBy: version.CreatedBy,
IntervalSeconds: version.IntervalSeconds,
Version: version.Version,
UID: version.RuleUID,
NamespaceUID: version.RuleNamespaceUID,
// Versions do not store Dashboard\Panel as separate column.
// However, these fields are part of annotations and information in these fields is redundant
DashboardUID: nil,
PanelID: nil,
RuleGroup: version.RuleGroup,
RuleGroupIndex: version.RuleGroupIndex,
Record: version.Record,
NoDataState: version.NoDataState,
ExecErrState: version.ExecErrState,
For: version.For,
Annotations: version.Annotations,
Labels: version.Labels,
IsPaused: version.IsPaused,
NotificationSettings: version.NotificationSettings,
Metadata: version.Metadata,
}
}

View File

@ -43,3 +43,20 @@ func TestAlertRuleToModelsAlertRule(t *testing.T) {
require.Equal(t, ngmodels.ErrorErrState, converted.ExecErrState)
})
}
func TestAlertRuleVersionToAlertRule(t *testing.T) {
g := ngmodels.RuleGen
t.Run("make sure no data is lost between conversions", func(t *testing.T) {
for _, rule := range g.GenerateMany(100) {
// ignore fields
rule.DashboardUID = nil
rule.PanelID = nil
r, err := alertRuleFromModelsAlertRule(rule)
require.NoError(t, err)
r2 := alertRuleVersionToAlertRule(alertRuleToAlertRuleVersion(r))
require.Equal(t, r, r2)
}
})
}

View File

@ -22,6 +22,7 @@ type RuleStore struct {
mtx sync.Mutex
// OrgID -> RuleGroup -> Namespace -> Rules
Rules map[int64][]*models.AlertRule
History map[models.AlertRuleKey][]*models.AlertRule
Hook func(cmd any) error // use Hook if you need to intercept some query and return an error
RecordedOps []any
Folders map[int64][]*folder.Folder
@ -40,6 +41,7 @@ func NewRuleStore(t *testing.T) *RuleStore {
return nil
},
Folders: map[int64][]*folder.Folder{},
History: map[models.AlertRuleKey][]*models.AlertRule{},
}
}
@ -50,6 +52,8 @@ func (f *RuleStore) PutRule(_ context.Context, rules ...*models.AlertRule) {
mainloop:
for _, r := range rules {
rgs := f.Rules[r.OrgID]
cp := models.CopyRule(r)
f.History[r.GetKey()] = append(f.History[r.GetKey()], cp)
for idx, rulePtr := range rgs {
if rulePtr.UID == r.UID {
rgs[idx] = r
@ -131,11 +135,7 @@ func (f *RuleStore) GetAlertRuleByUID(_ context.Context, q *models.GetAlertRuleB
if err := f.Hook(*q); err != nil {
return nil, err
}
rules, ok := f.Rules[q.OrgID]
if !ok {
return nil, nil
}
rules := f.Rules[q.OrgID]
for _, rule := range rules {
if rule.UID == q.UID {
return rule, nil
@ -372,3 +372,22 @@ func (f *RuleStore) GetNamespacesByRuleUID(ctx context.Context, orgID int64, uid
return namespacesMap, nil
}
func (f *RuleStore) GetAlertRuleVersions(_ context.Context, key models.AlertRuleKey) ([]*models.AlertRule, error) {
f.mtx.Lock()
defer f.mtx.Unlock()
q := GenericRecordedQuery{
Name: "GetAlertRuleVersions",
Params: []any{key},
}
defer func() {
f.RecordedOps = append(f.RecordedOps, q)
}()
if err := f.Hook(key); err != nil {
return nil, err
}
return f.History[key], nil
}

View File

@ -241,6 +241,18 @@ func TestIntegrationAlertRulePermissions(t *testing.T) {
require.Len(t, export.Groups, 1)
require.Equal(t, expected, export.Groups[0])
})
t.Run("Get versions of any rule", func(t *testing.T) {
for _, groups := range allRules { // random rule from each folder
group := groups[rand.Intn(len(groups))]
rule := group.Rules[rand.Intn(len(group.Rules))]
versions, status, raw := apiClient.GetRuleVersionsWithStatus(t, rule.GrafanaManagedAlert.UID)
if assert.Equalf(t, http.StatusOK, status, "Expected status 200, got %d: %s", status, raw) {
assert.NotEmpty(t, versions)
assert.Equal(t, rule, versions[0]) // the first version in the collection should always be the current
}
}
})
})
t.Run("when permissions for folder2 removed", func(t *testing.T) {
@ -310,6 +322,12 @@ func TestIntegrationAlertRulePermissions(t *testing.T) {
require.Equal(t, http.StatusForbidden, status)
})
t.Run("Versions of rule", func(t *testing.T) {
uid := allRules["folder2"][0].Rules[0].GrafanaManagedAlert.UID
_, status, raw := apiClient.GetRuleVersionsWithStatus(t, uid)
require.Equalf(t, http.StatusForbidden, status, "Expected status 403, got %d: %s", status, raw)
})
t.Run("when all permissions are revoked", func(t *testing.T) {
removeFolderPermission(t, permissionsStore, 1, userID, org.RoleEditor, "folder1")
apiClient.ReloadCachedPermissions(t)
@ -4315,6 +4333,95 @@ func TestIntegrationRuleUpdateAllDatabases(t *testing.T) {
})
}
func TestIntegrationRuleVersions(t *testing.T) {
testinfra.SQLiteIntegrationTest(t)
// Setup Grafana and its Database
dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
EnableQuota: true,
DisableAnonymous: true,
AppModeProduction: true,
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p)
createUser(t, env.SQLStore, env.Cfg, 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")
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
response := apiClient.PostRulesGroup(t, "folder1", &group1)
require.NotEmptyf(t, response.Created, "Expected created to be set")
uid := response.Created[0]
ruleV1 := apiClient.GetRuleByUID(t, uid)
t.Run("should return 1 version right after creation", func(t *testing.T) {
versions, status, raw := apiClient.GetRuleVersionsWithStatus(t, uid)
require.Equalf(t, http.StatusOK, status, "Expected status 200, got %d: %s", status, raw)
require.Lenf(t, versions, 1, "Expected 1 version, got %d", len(versions))
assert.Equal(t, ruleV1, versions[0])
})
group1Gettable := apiClient.GetRulesGroup(t, "folder1", group1.Name)
group1 = convertGettableRuleGroupToPostable(group1Gettable.GettableRuleGroupConfig)
group1.Rules[0].Annotations[util.GenerateShortUID()] = util.GenerateShortUID()
_ = apiClient.PostRulesGroup(t, "folder1", &group1)
ruleV2 := apiClient.GetRuleByUID(t, uid)
t.Run("should return previous versions after update", func(t *testing.T) {
versions, status, raw := apiClient.GetRuleVersionsWithStatus(t, uid)
require.Equalf(t, http.StatusOK, status, "Expected status 200, got %d: %s", status, raw)
require.Lenf(t, versions, 2, "Expected 2 versions, got %d", len(versions))
pathsToIgnore := []string{
"GrafanaManagedAlert.ID", // In versions ID has different value
}
// compare expected and actual and ignore the dynamic fields
diff := cmp.Diff(apimodels.GettableRuleVersions{ruleV2, ruleV1}, versions, cmp.FilterPath(func(path cmp.Path) bool {
for _, s := range pathsToIgnore {
if strings.Contains(path.String(), s) {
return true
}
}
return false
}, cmp.Ignore()))
assert.Empty(t, diff)
})
_ = apiClient.PostRulesGroup(t, "folder1", &group1) // Noop update
t.Run("should not add new version if rule was not changed", func(t *testing.T) {
versions, status, raw := apiClient.GetRuleVersionsWithStatus(t, uid)
require.Equalf(t, http.StatusOK, status, "Expected status 200, got %d: %s", status, raw)
require.Lenf(t, versions, 2, "Expected 2 versions, got %d", len(versions))
})
apiClient.DeleteRulesGroup(t, "folder1", group1.Name)
t.Run("should NotFound after rule was deleted", func(t *testing.T) {
_, status, raw := apiClient.GetRuleVersionsWithStatus(t, uid)
require.Equalf(t, http.StatusNotFound, status, "Expected status 404, got %d: %s", status, raw)
})
}
func newTestingRuleConfig(t *testing.T) apimodels.PostableRuleGroupConfig {
interval, err := model.ParseDuration("1m")
require.NoError(t, err)

View File

@ -475,6 +475,13 @@ func (a apiClient) PostRulesGroupWithStatus(t *testing.T, folder string, group *
return m, resp.StatusCode, string(b)
}
func (a apiClient) PostRulesGroup(t *testing.T, folder string, group *apimodels.PostableRuleGroupConfig) apimodels.UpdateRuleGroupResponse {
t.Helper()
m, status, raw := a.PostRulesGroupWithStatus(t, folder, group)
requireStatusCode(t, http.StatusAccepted, status, raw)
return m
}
func (a apiClient) PostRulesExportWithStatus(t *testing.T, folder string, group *apimodels.PostableRuleGroupConfig, params *apimodels.ExportQueryParams) (int, string) {
t.Helper()
buf := bytes.Buffer{}
@ -1048,6 +1055,22 @@ func (a apiClient) GetActiveAlertsWithStatus(t *testing.T) (apimodels.AlertGroup
return sendRequest[apimodels.AlertGroups](t, req, http.StatusOK)
}
func (a apiClient) GetRuleVersionsWithStatus(t *testing.T, ruleUID string) (apimodels.GettableRuleVersions, int, string) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/ruler/grafana/api/v1/rule/%s/versions", a.url, ruleUID), nil)
require.NoError(t, err)
return sendRequest[apimodels.GettableRuleVersions](t, req, http.StatusOK)
}
func (a apiClient) GetRuleByUID(t *testing.T, ruleUID string) apimodels.GettableExtendedRuleNode {
t.Helper()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/ruler/grafana/api/v1/rule/%s", a.url, ruleUID), nil)
require.NoError(t, err)
rule, status, raw := sendRequest[apimodels.GettableExtendedRuleNode](t, req, http.StatusOK)
requireStatusCode(t, http.StatusOK, status, raw)
return rule
}
func sendRequest[T any](t *testing.T, req *http.Request, successStatusCode int) (T, int, string) {
t.Helper()
client := &http.Client{}

View File

@ -12861,7 +12861,9 @@
"description": "adapted from cortex",
"type": "object",
"required": [
"uid",
"name",
"folderUid",
"query",
"health",
"type",
@ -12891,6 +12893,9 @@
"type": "number",
"format": "double"
},
"folderUid": {
"type": "string"
},
"health": {
"type": "string"
},
@ -12930,6 +12935,9 @@
},
"type": {
"type": "string"
},
"uid": {
"type": "string"
}
}
},
@ -15392,6 +15400,14 @@
"CounterResetHint": {
"$ref": "#/definitions/CounterResetHint"
},
"CustomValues": {
"description": "Holds the custom (usually upper) bounds for bucket definitions, otherwise nil.\nThis slice is interned, to be treated as immutable and copied by reference.\nThese numbers should be strictly increasing. This field is only used when the\nschema is for custom buckets, and the ZeroThreshold, ZeroCount, NegativeSpans\nand NegativeBuckets fields are not used in that case.",
"type": "array",
"items": {
"type": "number",
"format": "double"
}
},
"PositiveBuckets": {
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
"type": "array",
@ -15408,7 +15424,7 @@
}
},
"Schema": {
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8 for exponential buckets.\nThey are all for base-2 bucket schemas, where 1 is a bucket boundary in\neach case, and then each power of two is divided into 2^n logarithmic buckets.\nOr in other words, each bucket boundary is the previous boundary times\n2^(2^-n). Another valid schema number is -53 for custom buckets, defined by\nthe CustomValues field.",
"type": "integer",
"format": "int32"
},
@ -16048,6 +16064,9 @@
"type": "string",
"format": "date-time"
},
"updated_by": {
"$ref": "#/definitions/UserInfo"
},
"version": {
"type": "integer",
"format": "int64"
@ -16131,6 +16150,12 @@
}
}
},
"GettableRuleVersions": {
"type": "array",
"items": {
"$ref": "#/definitions/GettableExtendedRuleNode"
}
},
"GettableStatus": {
"type": "object",
"required": [
@ -19810,7 +19835,9 @@
"description": "adapted from cortex",
"type": "object",
"required": [
"uid",
"name",
"folderUid",
"query",
"health",
"type"
@ -19820,6 +19847,9 @@
"type": "number",
"format": "double"
},
"folderUid": {
"type": "string"
},
"health": {
"type": "string"
},
@ -19841,6 +19871,9 @@
},
"type": {
"type": "string"
},
"uid": {
"type": "string"
}
}
},
@ -19873,6 +19906,7 @@
"required": [
"name",
"file",
"folderUid",
"rules",
"interval"
],
@ -19884,6 +19918,9 @@
"file": {
"type": "string"
},
"folderUid": {
"type": "string"
},
"interval": {
"type": "number",
"format": "double"
@ -20009,6 +20046,10 @@
"description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.",
"type": "object",
"properties": {
"DropName": {
"description": "DropName is used to indicate whether the __name__ label should be dropped\nas part of the query evaluation.",
"type": "boolean"
},
"F": {
"type": "number",
"format": "double"
@ -22032,6 +22073,18 @@
}
}
},
"UserInfo": {
"type": "object",
"title": "UserInfo represents user-related information, including a unique identifier and a name.",
"properties": {
"name": {
"type": "string"
},
"uid": {
"type": "string"
}
}
},
"UserLookupDTO": {
"type": "object",
"properties": {
@ -22437,6 +22490,7 @@
}
},
"alertGroups": {
"description": "AlertGroups alert groups",
"type": "array",
"items": {
"type": "object",
@ -22766,6 +22820,7 @@
}
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"type": "array",
"items": {
"type": "object",

View File

@ -2956,6 +2956,9 @@
"format": "double",
"type": "number"
},
"folderUid": {
"type": "string"
},
"health": {
"type": "string"
},
@ -2995,10 +2998,15 @@
},
"type": {
"type": "string"
},
"uid": {
"type": "string"
}
},
"required": [
"uid",
"name",
"folderUid",
"query",
"health",
"type",
@ -5465,6 +5473,14 @@
"CounterResetHint": {
"$ref": "#/components/schemas/CounterResetHint"
},
"CustomValues": {
"description": "Holds the custom (usually upper) bounds for bucket definitions, otherwise nil.\nThis slice is interned, to be treated as immutable and copied by reference.\nThese numbers should be strictly increasing. This field is only used when the\nschema is for custom buckets, and the ZeroThreshold, ZeroCount, NegativeSpans\nand NegativeBuckets fields are not used in that case.",
"items": {
"format": "double",
"type": "number"
},
"type": "array"
},
"PositiveBuckets": {
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
"items": {
@ -5481,7 +5497,7 @@
"type": "array"
},
"Schema": {
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8 for exponential buckets.\nThey are all for base-2 bucket schemas, where 1 is a bucket boundary in\neach case, and then each power of two is divided into 2^n logarithmic buckets.\nOr in other words, each bucket boundary is the previous boundary times\n2^(2^-n). Another valid schema number is -53 for custom buckets, defined by\nthe CustomValues field.",
"format": "int32",
"type": "integer"
},
@ -6122,6 +6138,9 @@
"format": "date-time",
"type": "string"
},
"updated_by": {
"$ref": "#/components/schemas/UserInfo"
},
"version": {
"format": "int64",
"type": "integer"
@ -6206,6 +6225,12 @@
},
"type": "object"
},
"GettableRuleVersions": {
"items": {
"$ref": "#/components/schemas/GettableExtendedRuleNode"
},
"type": "array"
},
"GettableStatus": {
"properties": {
"cluster": {
@ -9888,6 +9913,9 @@
"format": "double",
"type": "number"
},
"folderUid": {
"type": "string"
},
"health": {
"type": "string"
},
@ -9909,10 +9937,15 @@
},
"type": {
"type": "string"
},
"uid": {
"type": "string"
}
},
"required": [
"uid",
"name",
"folderUid",
"query",
"health",
"type"
@ -9952,6 +9985,9 @@
"file": {
"type": "string"
},
"folderUid": {
"type": "string"
},
"interval": {
"format": "double",
"type": "number"
@ -9981,6 +10017,7 @@
"required": [
"name",
"file",
"folderUid",
"rules",
"interval"
],
@ -10083,6 +10120,10 @@
"Sample": {
"description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.",
"properties": {
"DropName": {
"description": "DropName is used to indicate whether the __name__ label should be dropped\nas part of the query evaluation.",
"type": "boolean"
},
"F": {
"format": "double",
"type": "number"
@ -12106,6 +12147,18 @@
},
"type": "object"
},
"UserInfo": {
"properties": {
"name": {
"type": "string"
},
"uid": {
"type": "string"
}
},
"title": "UserInfo represents user-related information, including a unique identifier and a name.",
"type": "object"
},
"UserLookupDTO": {
"properties": {
"avatarUrl": {
@ -12511,6 +12564,7 @@
"type": "object"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"items": {
"$ref": "#/components/schemas/alertGroup"
},
@ -12838,6 +12892,7 @@
"type": "object"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"items": {
"$ref": "#/components/schemas/gettableSilence"
},