Compare commits

...

6 Commits

Author SHA1 Message Date
nikhilmantri0902
9ce53c704e refactor: share name helpers in pkg/types, create cloned rule from PostableRule 2026-10-14 22:45:14 +05:30
Nikhil Mantri
17c4fbf2d4 Merge branch 'main' into alerts/clone_alert_rule_api 2026-10-14 22:00:29 +05:30
nikhilmantri0902
fe74d501ad chore(ruler): regenerate openapi spec for clone rule 2026-10-09 14:33:40 +05:30
nikhilmantri0902
49208e7b7e fix(ruler): name the source rule when clone validation fails 2026-10-09 14:33:39 +05:30
nikhilmantri0902
475196503b chore(ruler): trim comments in clone rule code 2026-10-09 14:25:32 +05:30
nikhilmantri0902
bd6a4075c8 chore: clone alert rule API 2026-10-09 13:59:19 +05:30
25 changed files with 695 additions and 339 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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"},

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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

View File

@@ -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),

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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
View 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
View 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)
}
})
}
}

View File

@@ -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

View 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)
})
}
}

View File

@@ -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)
}

View File

@@ -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{

View 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