mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-15 06:30:40 +01:00
Compare commits
6 Commits
feat/updat
...
alerts/clo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce53c704e | ||
|
|
17c4fbf2d4 | ||
|
|
fe74d501ad | ||
|
|
49208e7b7e | ||
|
|
475196503b | ||
|
|
bd6a4075c8 |
@@ -24919,6 +24919,75 @@ paths:
|
||||
tags:
|
||||
- rules
|
||||
x-signoz-stability: alpha
|
||||
/api/v2/rules/{id}/clone:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a new alert rule from the stored definition
|
||||
of an existing one. The clone keeps every modeled field of the source, including
|
||||
its enabled/disabled state, and gets the source's name suffixed with " - Copy"
|
||||
(or a bumped " - Copy (n)" counter when the source is itself a copy). The
|
||||
clone is owned by the caller.
|
||||
operationId: CloneRuleByID
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesRule'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Clone alert rule
|
||||
tags:
|
||||
- rules
|
||||
x-signoz-stability: alpha
|
||||
/api/v2/rules/{id}/history/filter_keys:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CloneRuleByID201,
|
||||
CloneRuleByIDPathParameters,
|
||||
CreateRule201,
|
||||
CreateRuleView201,
|
||||
DeleteRuleByIDPathParameters,
|
||||
@@ -963,6 +965,85 @@ export const useUpdateRuleByID = <
|
||||
> => {
|
||||
return useMutation(getUpdateRuleByIDMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with " - Copy" (or a bumped " - Copy (n)" counter when the source is itself a copy). The clone is owned by the caller.
|
||||
* @summary Clone alert rule
|
||||
*/
|
||||
export const cloneRuleByID = (
|
||||
{ id }: CloneRuleByIDPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CloneRuleByID201>({
|
||||
url: `/api/v2/rules/${id}/clone`,
|
||||
method: 'POST',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCloneRuleByIDMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['cloneRuleByID'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
{ pathParams: CloneRuleByIDPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return cloneRuleByID(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CloneRuleByIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>
|
||||
>;
|
||||
|
||||
export type CloneRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Clone alert rule
|
||||
*/
|
||||
export const useCloneRuleByID = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof cloneRuleByID>>,
|
||||
TError,
|
||||
{ pathParams: CloneRuleByIDPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCloneRuleByIDMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns distinct label keys from rule history entries for the selected range.
|
||||
* @summary Get rule history filter keys
|
||||
|
||||
@@ -14580,6 +14580,17 @@ export type PatchRuleByID200 = {
|
||||
export type UpdateRuleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CloneRuleByIDPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CloneRuleByID201 = {
|
||||
data: RuletypesRuleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetRuleHistoryFilterKeysPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -71,6 +71,20 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CloneRuleByID), handler.OpenAPIDef{
|
||||
ID: "CloneRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Clone alert rule",
|
||||
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
|
||||
Response: new(ruletypes.Rule),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
|
||||
ID: "UpdateRuleByID",
|
||||
Tags: []string{"rules"},
|
||||
|
||||
@@ -546,6 +546,24 @@ func (m *Manager) deleteTask(taskName string) {
|
||||
// CreateRule stores rule def into db and also
|
||||
// starts an executor for the rule, unless the rule is disabled
|
||||
func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) {
|
||||
parsedRule := ruletypes.PostableRule{}
|
||||
if err := json.Unmarshal([]byte(ruleStr), &parsedRule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m.createRule(ctx, parsedRule, ruleStr)
|
||||
}
|
||||
|
||||
func (m *Manager) CreatePostableRule(ctx context.Context, rule *ruletypes.PostableRule) (*ruletypes.GettableRule, error) {
|
||||
data, err := json.Marshal(rule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m.createRule(ctx, *rule, string(data))
|
||||
}
|
||||
|
||||
func (m *Manager) createRule(ctx context.Context, parsedRule ruletypes.PostableRule, data string) (*ruletypes.GettableRule, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -555,11 +573,6 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsedRule := ruletypes.PostableRule{}
|
||||
err = json.Unmarshal([]byte(ruleStr), &parsedRule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := parsedRule.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -579,7 +592,7 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
|
||||
CreatedBy: claims.Email,
|
||||
UpdatedBy: claims.Email,
|
||||
},
|
||||
Data: ruleStr,
|
||||
Data: data,
|
||||
OrgID: claims.OrgID,
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ type Handler interface {
|
||||
ListRulesV3(http.ResponseWriter, *http.Request)
|
||||
GetRuleByID(http.ResponseWriter, *http.Request)
|
||||
CreateRule(http.ResponseWriter, *http.Request)
|
||||
CloneRuleByID(http.ResponseWriter, *http.Request)
|
||||
UpdateRuleByID(http.ResponseWriter, *http.Request)
|
||||
DeleteRuleByID(http.ResponseWriter, *http.Request)
|
||||
PatchRuleByID(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -29,6 +29,9 @@ type Ruler interface {
|
||||
// model to store structured data.
|
||||
CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error)
|
||||
|
||||
// CloneRule creates a new rule from the stored definition of id, with a " - Copy" suffixed name.
|
||||
CloneRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error)
|
||||
|
||||
// EditRule replaces the rule identified by id with the given JSON string.
|
||||
// TODO: same as CreateRule — accept PostableRule instead of raw string.
|
||||
EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error
|
||||
|
||||
@@ -105,6 +105,25 @@ func (handler *handler) CreateRule(rw http.ResponseWriter, req *http.Request) {
|
||||
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
|
||||
}
|
||||
|
||||
func (handler *handler) CloneRuleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := handler.ruler.CloneRule(ctx, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateRuleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -128,6 +129,30 @@ func (provider *provider) CreateRule(ctx context.Context, ruleStr string) (*rule
|
||||
return provider.manager.CreateRule(ctx, ruleStr)
|
||||
}
|
||||
|
||||
func (provider *provider) CloneRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storedRule, err := provider.ruleStore.GetStoredRule(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
postableRule, err := storedRule.ToPostableRuleForCloning()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return provider.manager.CreatePostableRule(ctx, postableRule)
|
||||
}
|
||||
|
||||
func (provider *provider) EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error {
|
||||
return provider.manager.EditRule(ctx, ruleStr, id)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ type savedViewData struct {
|
||||
const migrationSavedViewNameSuffixLen = 8
|
||||
|
||||
// slugifySavedViewName turns a pre-existing free-text saved view name and is copy of
|
||||
// dashboardtypes.generateDashboardName.
|
||||
// types.GenerateName.
|
||||
func slugifySavedViewName(displayName string) string {
|
||||
const dns1123LabelMaxLen = 63
|
||||
suffixAlphabet := []byte("abcdefghijklmnopqrstuvwxyz0123456789")
|
||||
|
||||
@@ -131,7 +131,7 @@ func (migration *addChannelDisplayName) Down(context.Context, *bun.DB) error {
|
||||
|
||||
const migrationChannelNameSuffixLen = 8
|
||||
|
||||
// slugifyChannelName is a copy of dashboardtypes.generateDashboardName. The
|
||||
// slugifyChannelName is a copy of types.GenerateName. The
|
||||
// random suffix is what makes the unique index safe to add without a collision
|
||||
// loop over the existing free-text names.
|
||||
func slugifyChannelName(displayName string) string {
|
||||
|
||||
@@ -2,10 +2,8 @@ package alertmanagertypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
@@ -84,47 +82,6 @@ func (c *Channel) fillSpec(spec ChannelSpec) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const channelNameSuffixLen = 8
|
||||
|
||||
// generateChannelName is a copy of dashboardtypes.generateDashboardName: slugify
|
||||
// the display name, then append a random suffix rather than looping on collisions.
|
||||
func generateChannelName(displayName string) string {
|
||||
const dns1123LabelMaxLen = 63
|
||||
suffixAlphabet := []byte("abcdefghijklmnopqrstuvwxyz0123456789")
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(displayName))
|
||||
prevHyphen := false
|
||||
for _, r := range strings.ToLower(displayName) {
|
||||
switch {
|
||||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||||
b.WriteRune(r)
|
||||
prevHyphen = false
|
||||
case b.Len() > 0 && !prevHyphen:
|
||||
b.WriteByte('-')
|
||||
prevHyphen = true
|
||||
}
|
||||
}
|
||||
prefix := strings.TrimRight(b.String(), "-")
|
||||
|
||||
suffix := make([]byte, channelNameSuffixLen)
|
||||
if _, err := rand.Read(suffix); err != nil {
|
||||
panic(errors.WrapInternalf(err, errors.CodeInternal, "read random for channel name suffix"))
|
||||
}
|
||||
for i := range suffix {
|
||||
suffix[i] = suffixAlphabet[int(suffix[i])%len(suffixAlphabet)]
|
||||
}
|
||||
|
||||
maxPrefix := dns1123LabelMaxLen - 1 - channelNameSuffixLen
|
||||
if len(prefix) > maxPrefix {
|
||||
prefix = strings.TrimRight(prefix[:maxPrefix], "-")
|
||||
}
|
||||
if prefix == "" {
|
||||
return string(suffix)
|
||||
}
|
||||
return prefix + "-" + string(suffix)
|
||||
}
|
||||
|
||||
func NewConfigFromChannels(globalConfig GlobalConfig, routeConfig RouteConfig, channels Channels, orgID string) (*Config, error) {
|
||||
cfg, err := NewDefaultConfig(
|
||||
globalConfig,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
)
|
||||
@@ -42,7 +43,7 @@ func (p *PostableNotificationChannel) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
if p.GenerateName {
|
||||
p.Name = generateChannelName(p.DisplayName)
|
||||
p.Name = types.GenerateName(p.DisplayName)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -128,7 +128,7 @@ func newChannelWithoutSpec(receiver *Receiver, orgID string) (*Channel, error) {
|
||||
return &Channel{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
Name: generateChannelName(receiver.Name),
|
||||
Name: types.GenerateName(receiver.Name),
|
||||
DisplayName: receiver.Name,
|
||||
Type: channelType,
|
||||
Data: string(data),
|
||||
|
||||
@@ -2,14 +2,10 @@ package dashboardtypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
@@ -20,9 +16,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
SchemaVersion = "v6"
|
||||
MaxTagsPerDashboard = 10
|
||||
dashboardNameSuffixLen = 8
|
||||
SchemaVersion = "v6"
|
||||
MaxTagsPerDashboard = 10
|
||||
)
|
||||
|
||||
// SystemDashboardNamePrefix is reserved for dashboards SigNoz ships and owns. Generated
|
||||
@@ -161,7 +156,7 @@ func (d *DashboardV2) ErrIfNotClonable() error {
|
||||
|
||||
func (d DashboardV2) ToPostableForCloning() PostableDashboardV2 {
|
||||
spec := d.Spec
|
||||
spec.Display.Name = nextCloneDisplayName(spec.Display.Name)
|
||||
spec.Display.Name = types.GenerateCloneName(spec.Display.Name, MaxDisplayNameLen)
|
||||
return PostableDashboardV2{
|
||||
DashboardV2MetadataBase: d.DashboardV2MetadataBase,
|
||||
GenerateName: true,
|
||||
@@ -170,33 +165,6 @@ func (d DashboardV2) ToPostableForCloning() PostableDashboardV2 {
|
||||
}
|
||||
}
|
||||
|
||||
// cloneCopySuffixRegex matches a " - Copy" or " - Copy (n)" suffix on a display name.
|
||||
var cloneCopySuffixRegex = regexp.MustCompile(`^(.*) - Copy(?: \((\d+)\))?$`)
|
||||
|
||||
// nextCloneDisplayName appends " - Copy" to a clone's display name, bumping an
|
||||
// existing " - Copy (n)" counter, then truncates the base to fit MaxDisplayNameLen.
|
||||
func nextCloneDisplayName(name string) string {
|
||||
base, count := name, 0
|
||||
if m := cloneCopySuffixRegex.FindStringSubmatch(name); m != nil {
|
||||
base = m[1]
|
||||
count = 1 // bare " - Copy"
|
||||
if m[2] != "" {
|
||||
count, _ = strconv.Atoi(m[2])
|
||||
}
|
||||
}
|
||||
|
||||
suffix := " - Copy"
|
||||
if count++; count > 1 {
|
||||
suffix = fmt.Sprintf(" - Copy (%d)", count)
|
||||
}
|
||||
|
||||
limit := max(MaxDisplayNameLen-utf8.RuneCountInString(suffix), 0)
|
||||
if runes := []rune(base); len(runes) > limit {
|
||||
base = strings.TrimRight(string(runes[:limit]), " ")
|
||||
}
|
||||
return base + suffix
|
||||
}
|
||||
|
||||
type DashboardV2MetadataBase struct {
|
||||
SchemaVersion string `json:"schemaVersion" required:"true"`
|
||||
Image string `json:"image"`
|
||||
@@ -233,7 +201,7 @@ func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy
|
||||
|
||||
name := postable.Name
|
||||
if postable.GenerateName {
|
||||
name = generateDashboardName(postable.Spec.Display.Name)
|
||||
name = types.GenerateName(postable.Spec.Display.Name)
|
||||
}
|
||||
// Checked on the final name, here rather than in validateName, because only
|
||||
// the constructor knows the source.
|
||||
@@ -313,43 +281,6 @@ func validateDashboardName(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateDashboardName(displayName string) string {
|
||||
const dns1123LabelMaxLen = 63
|
||||
suffixAlphabet := []byte("abcdefghijklmnopqrstuvwxyz0123456789")
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(displayName))
|
||||
prevHyphen := false
|
||||
for _, r := range strings.ToLower(displayName) {
|
||||
switch {
|
||||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||||
b.WriteRune(r)
|
||||
prevHyphen = false
|
||||
case b.Len() > 0 && !prevHyphen:
|
||||
b.WriteByte('-')
|
||||
prevHyphen = true
|
||||
}
|
||||
}
|
||||
prefix := strings.TrimRight(b.String(), "-")
|
||||
|
||||
suffix := make([]byte, dashboardNameSuffixLen)
|
||||
if _, err := rand.Read(suffix); err != nil {
|
||||
panic(errors.WrapInternalf(err, errors.CodeInternal, "read random for dashboard name suffix"))
|
||||
}
|
||||
for i := range suffix {
|
||||
suffix[i] = suffixAlphabet[int(suffix[i])%len(suffixAlphabet)]
|
||||
}
|
||||
|
||||
maxPrefix := dns1123LabelMaxLen - 1 - dashboardNameSuffixLen
|
||||
if len(prefix) > maxPrefix {
|
||||
prefix = strings.TrimRight(prefix[:maxPrefix], "-")
|
||||
}
|
||||
if prefix == "" {
|
||||
return string(suffix)
|
||||
}
|
||||
return prefix + "-" + string(suffix)
|
||||
}
|
||||
|
||||
func validateDashboardTags(tags []tagtypes.PostableTag) error {
|
||||
if len(tags) > MaxTagsPerDashboard {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "a dashboard can have at most %d tags", MaxTagsPerDashboard)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
@@ -184,7 +183,7 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
|
||||
dashboard, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(dashboard.Name, "my-dashboard-"), "expected slug prefix, got %q", dashboard.Name)
|
||||
assert.Len(t, dashboard.Name, len("my-dashboard-")+dashboardNameSuffixLen)
|
||||
assert.Len(t, dashboard.Name, len("my-dashboard-")+types.GeneratedNameSuffixLen)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -239,83 +238,6 @@ func TestDashboardV2ToPostableForCloning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// nextCloneDisplayName appends " - Copy", bumps an existing " - Copy (n)"
|
||||
// counter, and truncates an over-long base back to MaxDisplayNameLen while
|
||||
// keeping the suffix whole. The long cases are real-ish titles already at the
|
||||
// limit so the truncated output is legible; their literal expectations assume
|
||||
// the 128-character limit.
|
||||
func TestNextCloneDisplayName(t *testing.T) {
|
||||
require.Equal(t, 128, MaxDisplayNameLen, "the literal expectations below are sized for a 128-character limit")
|
||||
|
||||
testCases := []struct {
|
||||
scenario string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
scenario: "plain name gets a Copy suffix",
|
||||
input: "My Dashboard",
|
||||
expected: "My Dashboard - Copy",
|
||||
},
|
||||
{
|
||||
scenario: "Copy suffix bumps to (2)",
|
||||
input: "My Dashboard - Copy",
|
||||
expected: "My Dashboard - Copy (2)",
|
||||
},
|
||||
{
|
||||
scenario: "numbered suffix increments",
|
||||
input: "My Dashboard - Copy (2)",
|
||||
expected: "My Dashboard - Copy (3)",
|
||||
},
|
||||
{
|
||||
scenario: "multi-digit suffix increments",
|
||||
input: "svc - Copy (41)",
|
||||
expected: "svc - Copy (42)",
|
||||
},
|
||||
{
|
||||
scenario: "a name that merely contains Copy is not a suffix",
|
||||
input: "Copy of things",
|
||||
expected: "Copy of things - Copy",
|
||||
},
|
||||
{
|
||||
scenario: "only the trailing Copy marker is stripped",
|
||||
input: "Prod - Copy - Copy",
|
||||
expected: "Prod - Copy - Copy (2)",
|
||||
},
|
||||
{
|
||||
scenario: "empty name",
|
||||
input: "",
|
||||
expected: " - Copy",
|
||||
},
|
||||
{
|
||||
scenario: "first copy at the limit truncates the base, keeps the suffix",
|
||||
input: "Production Kubernetes Cluster Health: CPU, Memory, Disk I/O, and Network Saturation Across Every Namespace and Availability Zone",
|
||||
expected: "Production Kubernetes Cluster Health: CPU, Memory, Disk I/O, and Network Saturation Across Every Namespace and Availabili - Copy",
|
||||
},
|
||||
{
|
||||
scenario: "numbered copy at the limit increments then truncates",
|
||||
input: "API Gateway SLOs: p99 Latency, Error Budget Burn Rate, and Requests per Second by Route, Region, and Upstream Service - Copy (9)",
|
||||
expected: "API Gateway SLOs: p99 Latency, Error Budget Burn Rate, and Requests per Second by Route, Region, and Upstream Servic - Copy (10)",
|
||||
},
|
||||
{
|
||||
scenario: "truncation counts runes, not bytes (é and — are one rune each)",
|
||||
input: "Café Latency — p99 Response Times, Error Rates, and Saturation Across the Ordering, Kitchen, and Delivery Microservices Fleet",
|
||||
expected: "Café Latency — p99 Response Times, Error Rates, and Saturation Across the Ordering, Kitchen, and Delivery Microservices F - Copy",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.scenario, func(t *testing.T) {
|
||||
require.LessOrEqual(t, utf8.RuneCountInString(testCase.input), MaxDisplayNameLen, "a saved source name never exceeds the limit")
|
||||
|
||||
result := nextCloneDisplayName(testCase.input)
|
||||
|
||||
assert.Equal(t, testCase.expected, result)
|
||||
assert.LessOrEqual(t, utf8.RuneCountInString(result), MaxDisplayNameLen, "a clone name must fit the limit")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLegacyListedDashboardV2(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
dashboardID := valuer.GenerateUUID()
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
)
|
||||
|
||||
func unmarshalDashboard(data []byte) (*DashboardSpec, error) {
|
||||
@@ -2117,53 +2116,6 @@ func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDashboardName(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
input string
|
||||
wantPrefix string // expected slug prefix before the "-<suffix>" tail (empty if prefix is dropped)
|
||||
}{
|
||||
{scenario: "simple words with spaces", input: "My Dashboard", wantPrefix: "my-dashboard"},
|
||||
{scenario: "punctuation collapses", input: "Hello, World!", wantPrefix: "hello-world"},
|
||||
{scenario: "leading and trailing whitespace", input: " hello ", wantPrefix: "hello"},
|
||||
{scenario: "leading and trailing hyphens", input: "---abc---", wantPrefix: "abc"},
|
||||
{scenario: "consecutive non-alphanumerics collapse", input: "a___b...c", wantPrefix: "a-b-c"},
|
||||
{scenario: "digits are preserved", input: "Region us-east-1", wantPrefix: "region-us-east-1"},
|
||||
{scenario: "no alphanumerics drops prefix and returns suffix only", input: "!!! ???", wantPrefix: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.scenario, func(t *testing.T) {
|
||||
got := generateDashboardName(tt.input)
|
||||
assert.NotEmpty(t, got)
|
||||
assert.LessOrEqual(t, len(got), 63)
|
||||
assert.Empty(t, validation.IsDNS1123Label(got), "result must be a valid DNS-1123 label")
|
||||
|
||||
if tt.wantPrefix == "" {
|
||||
assert.Len(t, got, dashboardNameSuffixLen, "expected the bare random suffix")
|
||||
return
|
||||
}
|
||||
expectedPrefix := tt.wantPrefix + "-"
|
||||
assert.True(t, strings.HasPrefix(got, expectedPrefix), "expected prefix %q, got %q", expectedPrefix, got)
|
||||
assert.Len(t, got, len(expectedPrefix)+dashboardNameSuffixLen)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("prefix is truncated to leave room for the suffix", func(t *testing.T) {
|
||||
input := strings.Repeat("a", 100)
|
||||
got := generateDashboardName(input)
|
||||
assert.LessOrEqual(t, len(got), 63)
|
||||
assert.Empty(t, validation.IsDNS1123Label(got))
|
||||
assert.Equal(t, len(got), 63, "expected the result to be padded to the max DNS-1123 length")
|
||||
})
|
||||
|
||||
t.Run("suffix differs across calls", func(t *testing.T) {
|
||||
first := generateDashboardName("collision-test")
|
||||
second := generateDashboardName("collision-test")
|
||||
assert.NotEqual(t, first, second, "expected the random suffix to differ across calls")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSpanGaps(t *testing.T) {
|
||||
unmarshal := func(t *testing.T, val string) SpanGaps {
|
||||
t.Helper()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
)
|
||||
|
||||
// V1 → V2 migration. The v1 storable shape is the frontend's `DashboardData`
|
||||
@@ -95,7 +96,7 @@ func (storable StorableDashboard) ConvertV1ToV2() (result *DashboardV2, err erro
|
||||
SchemaVersion: SchemaVersion,
|
||||
Image: image,
|
||||
},
|
||||
Name: generateDashboardName(title),
|
||||
Name: types.GenerateName(title),
|
||||
Tags: tags,
|
||||
Spec: spec,
|
||||
}, nil
|
||||
@@ -104,5 +105,5 @@ func (storable StorableDashboard) ConvertV1ToV2() (result *DashboardV2, err erro
|
||||
// V1Name derives the v2 name column from a v1 dashboard's title. Used to backfill the
|
||||
// name of a dashboard whose data fails to migrate, so it stays findable by name.
|
||||
func (storable StorableDashboard) V1Name() string {
|
||||
return generateDashboardName(storable.Data.readString("title"))
|
||||
return types.GenerateName(storable.Data.readString("title"))
|
||||
}
|
||||
|
||||
79
pkg/types/name.go
Normal file
79
pkg/types/name.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
const GeneratedNameSuffixLen = 8
|
||||
|
||||
var cloneCopySuffixRegex = regexp.MustCompile(`^(.*) - Copy(?: \((\d+)\))?$`)
|
||||
|
||||
// GenerateName slugifies displayName into a DNS-1123 label and appends a random suffix; uniqueness is left to the caller's unique index.
|
||||
func GenerateName(displayName string) string {
|
||||
const dns1123LabelMaxLen = 63
|
||||
suffixAlphabet := []byte("abcdefghijklmnopqrstuvwxyz0123456789")
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(displayName))
|
||||
prevHyphen := false
|
||||
for _, r := range strings.ToLower(displayName) {
|
||||
switch {
|
||||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||||
b.WriteRune(r)
|
||||
prevHyphen = false
|
||||
case b.Len() > 0 && !prevHyphen:
|
||||
b.WriteByte('-')
|
||||
prevHyphen = true
|
||||
}
|
||||
}
|
||||
prefix := strings.TrimRight(b.String(), "-")
|
||||
|
||||
suffix := make([]byte, GeneratedNameSuffixLen)
|
||||
if _, err := rand.Read(suffix); err != nil {
|
||||
panic(errors.WrapInternalf(err, errors.CodeInternal, "read random for generated name suffix"))
|
||||
}
|
||||
for i := range suffix {
|
||||
suffix[i] = suffixAlphabet[int(suffix[i])%len(suffixAlphabet)]
|
||||
}
|
||||
|
||||
maxPrefix := dns1123LabelMaxLen - 1 - GeneratedNameSuffixLen
|
||||
if len(prefix) > maxPrefix {
|
||||
prefix = strings.TrimRight(prefix[:maxPrefix], "-")
|
||||
}
|
||||
if prefix == "" {
|
||||
return string(suffix)
|
||||
}
|
||||
return prefix + "-" + string(suffix)
|
||||
}
|
||||
|
||||
// GenerateCloneName appends " - Copy" or bumps an existing " - Copy (n)" counter, truncating the base in runes to fit maxLen; maxLen 0 means no limit.
|
||||
func GenerateCloneName(name string, maxLen int) string {
|
||||
base, count := name, 0
|
||||
if m := cloneCopySuffixRegex.FindStringSubmatch(name); m != nil {
|
||||
base = m[1]
|
||||
count = 1 // bare " - Copy"
|
||||
if m[2] != "" {
|
||||
count, _ = strconv.Atoi(m[2])
|
||||
}
|
||||
}
|
||||
|
||||
suffix := " - Copy"
|
||||
if count++; count > 1 {
|
||||
suffix = fmt.Sprintf(" - Copy (%d)", count)
|
||||
}
|
||||
|
||||
if maxLen > 0 {
|
||||
limit := max(maxLen-utf8.RuneCountInString(suffix), 0)
|
||||
if runes := []rune(base); len(runes) > limit {
|
||||
base = strings.TrimRight(string(runes[:limit]), " ")
|
||||
}
|
||||
}
|
||||
return base + suffix
|
||||
}
|
||||
97
pkg/types/name_test.go
Normal file
97
pkg/types/name_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
)
|
||||
|
||||
func TestGenerateName(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantPrefix string
|
||||
}{
|
||||
{name: "WordsWithSpaces_Hyphenated", input: "My Dashboard", wantPrefix: "my-dashboard"},
|
||||
{name: "Punctuation_Collapsed", input: "Hello, World!", wantPrefix: "hello-world"},
|
||||
{name: "SurroundingWhitespace_Trimmed", input: " hello ", wantPrefix: "hello"},
|
||||
{name: "SurroundingHyphens_Trimmed", input: "---abc---", wantPrefix: "abc"},
|
||||
{name: "ConsecutiveNonAlphanumerics_Collapsed", input: "a___b...c", wantPrefix: "a-b-c"},
|
||||
{name: "Digits_Preserved", input: "Region us-east-1", wantPrefix: "region-us-east-1"},
|
||||
{name: "NoAlphanumerics_SuffixOnly", input: "!!! ???", wantPrefix: ""},
|
||||
{name: "OverLongPrefix_TruncatedToLabelLimit", input: strings.Repeat("a", 100), wantPrefix: strings.Repeat("a", 63-1-GeneratedNameSuffixLen)},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := GenerateName(testCase.input)
|
||||
|
||||
assert.LessOrEqual(t, len(got), 63)
|
||||
assert.Empty(t, validation.IsDNS1123Label(got), "result must be a valid DNS-1123 label")
|
||||
if testCase.wantPrefix == "" {
|
||||
assert.Len(t, got, GeneratedNameSuffixLen)
|
||||
return
|
||||
}
|
||||
assert.True(t, strings.HasPrefix(got, testCase.wantPrefix+"-"), "expected prefix %q, got %q", testCase.wantPrefix+"-", got)
|
||||
assert.Len(t, got, len(testCase.wantPrefix)+1+GeneratedNameSuffixLen)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateName_SameInput_SuffixDiffers(t *testing.T) {
|
||||
assert.NotEqual(t, GenerateName("collision-test"), GenerateName("collision-test"))
|
||||
}
|
||||
|
||||
func TestGenerateCloneName(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
maxLen int
|
||||
expected string
|
||||
}{
|
||||
{name: "PlainName_CopySuffixAppended", input: "My Dashboard", expected: "My Dashboard - Copy"},
|
||||
{name: "CopySuffix_BumpedToTwo", input: "My Dashboard - Copy", expected: "My Dashboard - Copy (2)"},
|
||||
{name: "NumberedSuffix_Incremented", input: "My Dashboard - Copy (2)", expected: "My Dashboard - Copy (3)"},
|
||||
{name: "MultiDigitSuffix_Incremented", input: "svc - Copy (41)", expected: "svc - Copy (42)"},
|
||||
{name: "ContainsCopyWord_NotTreatedAsSuffix", input: "Copy of things", expected: "Copy of things - Copy"},
|
||||
{name: "RepeatedCopy_OnlyTrailingMarkerParsed", input: "Prod - Copy - Copy", expected: "Prod - Copy - Copy (2)"},
|
||||
{name: "EmptyName_SuffixOnly", input: "", expected: " - Copy"},
|
||||
{
|
||||
name: "NoLimit_LongNameNotTruncated",
|
||||
input: "Muting And Manual Resolving Log Based Alert 7 October With A Very Long Descriptive Name That Goes On And On Past One Hundred Twenty Eight Characters",
|
||||
expected: "Muting And Manual Resolving Log Based Alert 7 October With A Very Long Descriptive Name That Goes On And On Past One Hundred Twenty Eight Characters - Copy",
|
||||
},
|
||||
{
|
||||
name: "AtLimit_FirstCopy_BaseTruncatedSuffixKept",
|
||||
input: "Production Kubernetes Cluster Health: CPU, Memory, Disk I/O, and Network Saturation Across Every Namespace and Availability Zone",
|
||||
maxLen: 128,
|
||||
expected: "Production Kubernetes Cluster Health: CPU, Memory, Disk I/O, and Network Saturation Across Every Namespace and Availabili - Copy",
|
||||
},
|
||||
{
|
||||
name: "AtLimit_NumberedCopy_IncrementedThenTruncated",
|
||||
input: "API Gateway SLOs: p99 Latency, Error Budget Burn Rate, and Requests per Second by Route, Region, and Upstream Service - Copy (9)",
|
||||
maxLen: 128,
|
||||
expected: "API Gateway SLOs: p99 Latency, Error Budget Burn Rate, and Requests per Second by Route, Region, and Upstream Servic - Copy (10)",
|
||||
},
|
||||
{
|
||||
name: "AtLimit_MultiByteRunes_TruncatedByRunes",
|
||||
input: "Café Latency: p99 Response Times, Error Rates, and Saturation Across the Ordering, Kitchen, and Delivery Microservices Für All",
|
||||
maxLen: 128,
|
||||
expected: "Café Latency: p99 Response Times, Error Rates, and Saturation Across the Ordering, Kitchen, and Delivery Microservices Fü - Copy",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got := GenerateCloneName(testCase.input, testCase.maxLen)
|
||||
|
||||
assert.Equal(t, testCase.expected, got)
|
||||
if testCase.maxLen > 0 {
|
||||
assert.LessOrEqual(t, utf8.RuneCountInString(got), testCase.maxLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,15 @@ func NewStatsFromRules(rules []*StorableRule) map[string]any {
|
||||
return stats
|
||||
}
|
||||
|
||||
func (rule *StorableRule) ToPostableRuleForCloning() (*PostableRule, error) {
|
||||
postable := &PostableRule{}
|
||||
if err := json.Unmarshal([]byte(rule.Data), postable); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
postable.AlertName = types.GenerateCloneName(postable.AlertName, 0)
|
||||
return postable, nil
|
||||
}
|
||||
|
||||
// RuleAlert represents an alert associated with a rule, used when filtering by metric name.
|
||||
type RuleAlert struct {
|
||||
AlertName string
|
||||
|
||||
93
pkg/types/ruletypes/rule_test.go
Normal file
93
pkg/types/ruletypes/rule_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStorableRule_ToPostableRuleForCloning(t *testing.T) {
|
||||
v1Stored := `{
|
||||
"alert": "cpu high",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"description": "watches cpu",
|
||||
"ruleType": "threshold_rule",
|
||||
"version": "v5",
|
||||
"evalWindow": "10m",
|
||||
"frequency": "2m",
|
||||
"disabled": true,
|
||||
"source": "https://signoz.example.com/alerts/new",
|
||||
"labels": {"team": "infra", "severity": "warning"},
|
||||
"annotations": {"summary": "cpu above {{$threshold}}"},
|
||||
"preferredChannels": ["slack-infra"],
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "aggregations": [{"metricName": "cpu_usage", "timeAggregation": "avg", "spaceAggregation": "max"}]}}]
|
||||
},
|
||||
"selectedQueryName": "A",
|
||||
"op": "1",
|
||||
"target": 90,
|
||||
"matchType": "1",
|
||||
"alertOnAbsent": true,
|
||||
"absentFor": 5
|
||||
}
|
||||
}`
|
||||
v2Stored := `{
|
||||
"alert": "cpu high - Copy",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"ruleType": "promql_rule",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"disabled": false,
|
||||
"labels": {"team": "infra"},
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
"queries": [{"type": "promql", "spec": {"name": "A", "query": "avg(cpu_usage)"}}],
|
||||
"panelType": "graph",
|
||||
"queryType": "promql"
|
||||
},
|
||||
"selectedQueryName": "A",
|
||||
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": ["slack-critical"]}]}
|
||||
},
|
||||
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "90m", "frequency": "90s"}},
|
||||
"notificationSettings": {"groupBy": ["service.name"], "renotify": {"enabled": true, "interval": "45m", "alertStates": ["firing"]}, "usePolicy": true}
|
||||
}`
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
stored string
|
||||
expectedName string
|
||||
expectErr bool
|
||||
}{
|
||||
{name: "V1Rule_Cloned_NameSuffixedRestIntact", stored: v1Stored, expectedName: "cpu high - Copy"},
|
||||
{name: "V2Alpha1Rule_Cloned_NameBumpedRestIntact", stored: v2Stored, expectedName: "cpu high - Copy (2)"},
|
||||
{name: "InvalidJSON_Error", stored: `{"alert": `, expectErr: true},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
rule := &StorableRule{Data: testCase.stored}
|
||||
|
||||
cloned, err := rule.ToPostableRuleForCloning()
|
||||
if testCase.expectErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := &PostableRule{}
|
||||
require.NoError(t, json.Unmarshal([]byte(testCase.stored), expected))
|
||||
expected.AlertName = testCase.expectedName
|
||||
|
||||
marshaled, err := json.Marshal(cloned)
|
||||
require.NoError(t, err)
|
||||
roundTripped := &PostableRule{}
|
||||
require.NoError(t, json.Unmarshal(marshaled, roundTripped))
|
||||
assert.Equal(t, expected, roundTripped)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package savedviewtypes
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -19,9 +18,6 @@ var (
|
||||
ErrCodeSavedViewNotFound = errors.MustNewCode("saved_view_not_found")
|
||||
)
|
||||
|
||||
// savedViewNameSuffixLen mirrors dashboardtypes' generated-name logic.
|
||||
const savedViewNameSuffixLen = 8
|
||||
|
||||
var (
|
||||
SourceTraces = Source{valuer.NewString("traces")}
|
||||
SourceLogs = Source{valuer.NewString("logs")}
|
||||
@@ -136,7 +132,7 @@ func (postable PostableSavedView) ToSavedView(orgID string, createdBy string) *S
|
||||
|
||||
name := postable.Name
|
||||
if postable.GenerateName {
|
||||
name = generateSavedViewName(postable.Spec.DisplayName)
|
||||
name = types.GenerateName(postable.Spec.DisplayName)
|
||||
}
|
||||
|
||||
return &SavedView{
|
||||
@@ -243,43 +239,3 @@ func validateSavedViewName(name string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSavedViewName is a copy of dashboardtypes.generateDashboardName: slugify
|
||||
// the display name and append a random suffix for practical collision avoidance
|
||||
// (the DB unique index on (org_id, name) is what actually guarantees uniqueness).
|
||||
func generateSavedViewName(displayName string) string {
|
||||
const dns1123LabelMaxLen = 63
|
||||
suffixAlphabet := []byte("abcdefghijklmnopqrstuvwxyz0123456789")
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(displayName))
|
||||
prevHyphen := false
|
||||
for _, r := range strings.ToLower(displayName) {
|
||||
switch {
|
||||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||||
b.WriteRune(r)
|
||||
prevHyphen = false
|
||||
case b.Len() > 0 && !prevHyphen:
|
||||
b.WriteByte('-')
|
||||
prevHyphen = true
|
||||
}
|
||||
}
|
||||
prefix := strings.TrimRight(b.String(), "-")
|
||||
|
||||
suffix := make([]byte, savedViewNameSuffixLen)
|
||||
if _, err := rand.Read(suffix); err != nil {
|
||||
panic(errors.WrapInternalf(err, errors.CodeInternal, "read random for saved view name suffix"))
|
||||
}
|
||||
for i := range suffix {
|
||||
suffix[i] = suffixAlphabet[int(suffix[i])%len(suffixAlphabet)]
|
||||
}
|
||||
|
||||
maxPrefix := dns1123LabelMaxLen - 1 - savedViewNameSuffixLen
|
||||
if len(prefix) > maxPrefix {
|
||||
prefix = strings.TrimRight(prefix[:maxPrefix], "-")
|
||||
}
|
||||
if prefix == "" {
|
||||
return string(suffix)
|
||||
}
|
||||
return prefix + "-" + string(suffix)
|
||||
}
|
||||
|
||||
@@ -186,45 +186,6 @@ func TestNewSavedView_GeneratesNameWhenEmpty(t *testing.T) {
|
||||
assert.Equal(t, "My View!", savedView.Spec.DisplayName)
|
||||
}
|
||||
|
||||
func TestGenerateSavedViewName(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
input string
|
||||
wantPrefix string
|
||||
}{
|
||||
{scenario: "simple words with spaces", input: "My View", wantPrefix: "my-view"},
|
||||
{scenario: "punctuation collapses", input: "Hello, World!", wantPrefix: "hello-world"},
|
||||
{scenario: "leading and trailing whitespace", input: " hello ", wantPrefix: "hello"},
|
||||
{scenario: "leading and trailing hyphens", input: "---abc---", wantPrefix: "abc"},
|
||||
{scenario: "consecutive non-alphanumerics collapse", input: "a___b...c", wantPrefix: "a-b-c"},
|
||||
{scenario: "digits are preserved", input: "Region us-east-1", wantPrefix: "region-us-east-1"},
|
||||
{scenario: "no alphanumerics drops prefix and returns suffix only", input: "!!! ???", wantPrefix: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.scenario, func(t *testing.T) {
|
||||
got := generateSavedViewName(tt.input)
|
||||
assert.NotEmpty(t, got)
|
||||
assert.LessOrEqual(t, len(got), 63)
|
||||
assert.Empty(t, validation.IsDNS1123Label(got), "result must be a valid DNS-1123 label")
|
||||
|
||||
if tt.wantPrefix == "" {
|
||||
assert.Len(t, got, savedViewNameSuffixLen, "expected the bare random suffix")
|
||||
return
|
||||
}
|
||||
expectedPrefix := tt.wantPrefix + "-"
|
||||
assert.True(t, strings.HasPrefix(got, expectedPrefix), "expected prefix %q, got %q", expectedPrefix, got)
|
||||
assert.Len(t, got, len(expectedPrefix)+savedViewNameSuffixLen)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("suffix differs across calls", func(t *testing.T) {
|
||||
first := generateSavedViewName("collision-test")
|
||||
second := generateSavedViewName("collision-test")
|
||||
assert.NotEqual(t, first, second, "expected the random suffix to differ across calls")
|
||||
})
|
||||
}
|
||||
|
||||
func TestStorableSavedView_ToSavedView(t *testing.T) {
|
||||
t.Run("round trip preserves populated fields", func(t *testing.T) {
|
||||
view := &SavedView{
|
||||
|
||||
161
tests/integration/tests/ruler/07_clone_rule.py
Normal file
161
tests/integration/tests/ruler/07_clone_rule.py
Normal file
@@ -0,0 +1,161 @@
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures.alerts import delete_all_rules
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.types import Operation, SigNoz
|
||||
|
||||
BASE_URL = "/api/v2/rules"
|
||||
|
||||
CHANNEL_NAME = f"clone-rule-channel-{uuid.uuid4()}"
|
||||
|
||||
SOURCE_RULE = {
|
||||
"alert": "clone me",
|
||||
"description": "source rule for clone",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"ruleType": "threshold_rule",
|
||||
"condition": {
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": [CHANNEL_NAME]}],
|
||||
},
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": [{"metricName": "clone_rule_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"selectedQueryName": "A",
|
||||
},
|
||||
"labels": {"severity": "critical", "team": "payments"},
|
||||
"annotations": {"summary": "s", "description": "d"},
|
||||
"disabled": True,
|
||||
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m0s", "frequency": "1m"}},
|
||||
"notificationSettings": {
|
||||
"groupBy": [],
|
||||
"usePolicy": False,
|
||||
"renotify": {"enabled": False, "interval": "30m", "alertStates": []},
|
||||
},
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
}
|
||||
|
||||
|
||||
def test_clone_rule(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
delete_all_rules(signoz, token)
|
||||
create_notification_channel({"name": CHANNEL_NAME, "webhook_configs": [{"url": "http://localhost:9/alert", "send_resolved": False}]})
|
||||
source_id = create_alert_rule(SOURCE_RULE)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{source_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
source = response.json()["data"]
|
||||
|
||||
# clone keeps everything but the id and name
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{source_id}/clone"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
clone = response.json()["data"]
|
||||
assert clone["id"] != source_id
|
||||
assert clone["alert"] == "clone me - Copy"
|
||||
for field in ["description", "alertType", "ruleType", "disabled", "labels", "annotations", "condition", "evaluation", "notificationSettings"]:
|
||||
assert clone[field] == source[field], field
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{clone['id']}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["alert"] == "clone me - Copy"
|
||||
assert response.json()["data"]["state"] == "disabled"
|
||||
assert response.json()["data"]["createdBy"] == USER_ADMIN_EMAIL
|
||||
|
||||
# cloning a copy bumps the counter
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{clone['id']}/clone"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
second_clone = response.json()["data"]
|
||||
assert second_clone["alert"] == "clone me - Copy (2)"
|
||||
assert second_clone["condition"] == source["condition"]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}/clone"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid/clone"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
# a source whose channel was deleted since no longer passes create validation
|
||||
stale_channel_name = f"clone-rule-stale-{uuid.uuid4()}"
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels"),
|
||||
json={"name": stale_channel_name, "webhook_configs": [{"url": "http://localhost:9/alert", "send_resolved": False}]},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
stale_channel_id = response.json()["data"]["id"]
|
||||
stale_rule = {
|
||||
**SOURCE_RULE,
|
||||
"alert": "stale channel",
|
||||
"condition": {**SOURCE_RULE["condition"], "thresholds": {"kind": "basic", "spec": [{**SOURCE_RULE["condition"]["thresholds"]["spec"][0], "channels": [stale_channel_name]}]}},
|
||||
"notificationSettings": {**SOURCE_RULE["notificationSettings"], "usePolicy": True},
|
||||
}
|
||||
stale_rule_id = create_alert_rule(stale_rule)
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/channels/{stale_channel_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{stale_rule_id}/clone"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert stale_channel_name in response.json()["error"]["message"]
|
||||
|
||||
for rule_id in [clone["id"], second_clone["id"]]:
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/rules/{rule_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
Reference in New Issue
Block a user