From 22847e24be27248e3116fd00a507649196412d9f Mon Sep 17 00:00:00 2001 From: Lukasz Mierzwa Date: Wed, 11 Mar 2026 09:31:08 +0000 Subject: [PATCH] fix(backend): refactor autocomplete --- internal/alertmanager/models.go | 31 +++---- internal/filters/autocomplete.go | 19 ++-- internal/filters/autocomplete_test.go | 33 ++++--- internal/filters/filter_age.go | 30 +++---- internal/filters/filter_alertmanager.go | 24 ++---- internal/filters/filter_cluster.go | 24 ++---- internal/filters/filter_inhibited.go | 12 +-- internal/filters/filter_inhibited_by.go | 23 ++--- internal/filters/filter_label.go | 100 ++++++++++++---------- internal/filters/filter_limit.go | 30 +++---- internal/filters/filter_receiver.go | 45 +++------- internal/filters/filter_silence_author.go | 23 ++--- internal/filters/filter_silence_ticket.go | 23 ++--- internal/filters/filter_silenced_by.go | 23 ++--- internal/filters/filter_state.go | 24 ++---- internal/filters/matcher_test.go | 36 ++++++++ internal/models/alert_test.go | 87 +++++++++++++++++++ internal/models/annotation_test.go | 17 ++++ internal/models/marshal_jsonv2_test.go | 57 ++++++++++++ 19 files changed, 381 insertions(+), 280 deletions(-) diff --git a/internal/alertmanager/models.go b/internal/alertmanager/models.go index ee3a756e7..29ad84db3 100644 --- a/internal/alertmanager/models.go +++ b/internal/alertmanager/models.go @@ -62,10 +62,11 @@ type Alertmanager struct { // CORS credentials CORSCredentials string `json:"corsCredentials"` // fields for storing pulled data - alertGroups []models.AlertGroup - autocomplete []models.Autocomplete - knownLabels []string - RequestTimeout time.Duration `json:"timeout"` + alertGroups []models.AlertGroup + autocomplete []models.Autocomplete + autocompleteMap map[string]models.Autocomplete + knownLabels []string + RequestTimeout time.Duration `json:"timeout"` // lock protects data access while updating lock sync.RWMutex // whenever this instance should be proxied @@ -238,7 +239,11 @@ func (am *Alertmanager) pullAlerts(version string) error { dedupedGroups := make([]models.AlertGroup, 0, len(uniqueGroups)) colors := models.LabelsColorMap{} - autocompleteMap := map[string]*models.Autocomplete{} + if am.autocompleteMap == nil { + am.autocompleteMap = map[string]models.Autocomplete{} + } else { + clear(am.autocompleteMap) + } expiredSilences := am.ExpiredSilences() log.Info(). @@ -310,12 +315,8 @@ func (am *Alertmanager) pullAlerts(version string) error { alerts = append(alerts, alert) } - for _, hint := range filters.BuildAutocomplete(alerts) { - autocompleteMap[hint.Value] = &hint - } - for _, hint := range filters.LabelAutocomplete(labelPairs) { - autocompleteMap[hint.Value] = &hint - } + filters.BuildAutocomplete(alerts, am.autocompleteMap) + filters.LabelAutocomplete(labelPairs, am.autocompleteMap) slices.SortFunc(alerts, models.CompareAlerts) ag.Alerts = alerts @@ -328,11 +329,11 @@ func (am *Alertmanager) pullAlerts(version string) error { log.Info(). Str("alertmanager", am.Name). - Int("hints", len(autocompleteMap)). + Int("hints", len(am.autocompleteMap)). Msg("Merging autocomplete hints") - autocomplete := make([]models.Autocomplete, 0, len(autocompleteMap)) - for _, hint := range autocompleteMap { - autocomplete = append(autocomplete, *hint) + autocomplete := make([]models.Autocomplete, 0, len(am.autocompleteMap)) + for _, hint := range am.autocompleteMap { + autocomplete = append(autocomplete, hint) } knownLabels := make([]string, 0, len(knownLabelsMap)) diff --git a/internal/filters/autocomplete.go b/internal/filters/autocomplete.go index b19e044d7..d53ede38b 100644 --- a/internal/filters/autocomplete.go +++ b/internal/filters/autocomplete.go @@ -4,27 +4,26 @@ import ( "github.com/prymitive/karma/internal/models" ) -type autocompleteFactory func(name string, operators []string, alerts []models.Alert) []models.Autocomplete +type autocompleteFactory func(name string, operators []string, alerts []models.Alert, dst map[string]models.Autocomplete) -func makeAC(value string, tokens []string) models.Autocomplete { +func setAC(dst map[string]models.Autocomplete, value string, tokens []string) { + if _, ok := dst[value]; ok { + return + } t := make([]string, len(tokens)+1) copy(t, tokens) t[len(tokens)] = value - acHint := models.Autocomplete{ + dst[value] = models.Autocomplete{ Value: value, Tokens: t, } - return acHint } -// BuildAutocomplete takes an alert object and generates list of autocomplete -// strings for it -func BuildAutocomplete(alerts []models.Alert) []models.Autocomplete { - acHints := []models.Autocomplete{} +// BuildAutocomplete takes an alert list and populates dst with autocomplete hints. +func BuildAutocomplete(alerts []models.Alert, dst map[string]models.Autocomplete) { for _, filterConfig := range AllFilters { if filterConfig.Autocomplete != nil { - acHints = append(acHints, filterConfig.Autocomplete(filterConfig.Label, filterConfig.SupportedOperators, alerts)...) + filterConfig.Autocomplete(filterConfig.Label, filterConfig.SupportedOperators, alerts, dst) } } - return acHints } diff --git a/internal/filters/autocomplete_test.go b/internal/filters/autocomplete_test.go index 71cb9d6f3..72487a1d3 100644 --- a/internal/filters/autocomplete_test.go +++ b/internal/filters/autocomplete_test.go @@ -136,11 +136,12 @@ func TestBuildAutocomplete(t *testing.T) { labelPairs = append(labelPairs, pairs) } - result := make([]string, 0, len(acTest.Alerts)) - for _, hint := range filters.BuildAutocomplete(acTest.Alerts) { - result = append(result, hint.Value) - } - for _, hint := range filters.LabelAutocomplete(labelPairs) { + dst := map[string]models.Autocomplete{} + filters.BuildAutocomplete(acTest.Alerts, dst) + filters.LabelAutocomplete(labelPairs, dst) + + result := make([]string, 0, len(dst)) + for _, hint := range dst { result = append(result, hint.Value) } @@ -174,9 +175,21 @@ func BenchmarkAutocomplete(b *testing.B) { }) labelPairs = append(labelPairs, pairs) } - b.ReportAllocs() - for i := 0; i < b.N; i++ { - filters.BuildAutocomplete(alerts) - filters.LabelAutocomplete(labelPairs) - } + b.Run("fresh", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + dst := map[string]models.Autocomplete{} + filters.BuildAutocomplete(alerts, dst) + filters.LabelAutocomplete(labelPairs, dst) + } + }) + b.Run("reuse", func(b *testing.B) { + dst := map[string]models.Autocomplete{} + b.ReportAllocs() + for i := 0; i < b.N; i++ { + clear(dst) + filters.BuildAutocomplete(alerts, dst) + filters.LabelAutocomplete(labelPairs, dst) + } + }) } diff --git a/internal/filters/filter_age.go b/internal/filters/filter_age.go index 8e6576e9f..338ac14db 100644 --- a/internal/filters/filter_age.go +++ b/internal/filters/filter_age.go @@ -60,25 +60,17 @@ func newAgeFilter() FilterT { return &f } -func ageAutocomplete(name string, operators []string, _ []models.Alert) []models.Autocomplete { - tokens := make([]models.Autocomplete, 0, len(operators)*2) +func ageAutocomplete(name string, operators []string, _ []models.Alert, dst map[string]models.Autocomplete) { for _, operator := range operators { - tokens = append(tokens, makeAC( - name+operator+"10m", - []string{ - name, - strings.TrimPrefix(name, "@"), - name + operator, - }, - )) - tokens = append(tokens, makeAC( - name+operator+"1h", - []string{ - name, - strings.TrimPrefix(name, "@"), - name + operator, - }, - )) + setAC(dst, name+operator+"10m", []string{ + name, + strings.TrimPrefix(name, "@"), + name + operator, + }) + setAC(dst, name+operator+"1h", []string{ + name, + strings.TrimPrefix(name, "@"), + name + operator, + }) } - return tokens } diff --git a/internal/filters/filter_alertmanager.go b/internal/filters/filter_alertmanager.go index c1cff5cd0..e1dc6b245 100644 --- a/internal/filters/filter_alertmanager.go +++ b/internal/filters/filter_alertmanager.go @@ -53,32 +53,20 @@ func newAlertmanagerInstanceFilter() FilterT { return &f } -func alertmanagerInstanceAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete { - tokens := map[string]*models.Autocomplete{} +func alertmanagerInstanceAutocomplete(name string, operators []string, alerts []models.Alert, dst map[string]models.Autocomplete) { for _, alert := range alerts { for _, am := range alert.Alertmanager { for _, operator := range operators { switch operator { case equalOperator, notEqualOperator: token := name + operator + am.Name - if _, ok := tokens[token]; !ok { - hint := makeAC( - token, - []string{ - name, - strings.TrimPrefix(name, "@"), - name + operator, - }, - ) - tokens[token] = &hint - } + setAC(dst, token, []string{ + name, + strings.TrimPrefix(name, "@"), + name + operator, + }) } } } } - acData := make([]models.Autocomplete, 0, len(tokens)) - for _, token := range tokens { - acData = append(acData, *token) - } - return acData } diff --git a/internal/filters/filter_cluster.go b/internal/filters/filter_cluster.go index a9bca47f7..85f2f17c5 100644 --- a/internal/filters/filter_cluster.go +++ b/internal/filters/filter_cluster.go @@ -53,32 +53,20 @@ func newAlertmanagerClusterFilter() FilterT { return &f } -func alertmanagerClusterAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete { - tokens := map[string]*models.Autocomplete{} +func alertmanagerClusterAutocomplete(name string, operators []string, alerts []models.Alert, dst map[string]models.Autocomplete) { for _, alert := range alerts { for _, am := range alert.Alertmanager { for _, operator := range operators { switch operator { case equalOperator, notEqualOperator: token := name + operator + am.Cluster - if _, ok := tokens[token]; !ok { - hint := makeAC( - token, - []string{ - name, - strings.TrimPrefix(name, "@"), - name + operator, - }, - ) - tokens[token] = &hint - } + setAC(dst, token, []string{ + name, + strings.TrimPrefix(name, "@"), + name + operator, + }) } } } } - acData := make([]models.Autocomplete, 0, len(tokens)) - for _, token := range tokens { - acData = append(acData, *token) - } - return acData } diff --git a/internal/filters/filter_inhibited.go b/internal/filters/filter_inhibited.go index e3bc12a5e..338218f83 100644 --- a/internal/filters/filter_inhibited.go +++ b/internal/filters/filter_inhibited.go @@ -65,22 +65,14 @@ func newInhibitedFilter() FilterT { return &f } -func inhibitedAutocomplete(name string, _ []string, _ []models.Alert) []models.Autocomplete { - tokens := map[string]*models.Autocomplete{} - +func inhibitedAutocomplete(name string, _ []string, _ []models.Alert, dst map[string]models.Autocomplete) { for _, val := range []string{trueValue, falseValue} { token := name + equalOperator + val - hint := makeAC(token, []string{ + setAC(dst, token, []string{ name, strings.TrimPrefix(name, "@"), name + equalOperator, val, }) - tokens[token] = &hint } - acData := make([]models.Autocomplete, 0, len(tokens)) - for _, token := range tokens { - acData = append(acData, *token) - } - return acData } diff --git a/internal/filters/filter_inhibited_by.go b/internal/filters/filter_inhibited_by.go index 5c85fd0e5..a419ede1c 100644 --- a/internal/filters/filter_inhibited_by.go +++ b/internal/filters/filter_inhibited_by.go @@ -63,29 +63,20 @@ func newInhibitedByFilter() FilterT { return &f } -func inhibitedByAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete { - tokens := map[string]*models.Autocomplete{} +func inhibitedByAutocomplete(name string, operators []string, alerts []models.Alert, dst map[string]models.Autocomplete) { for _, alert := range alerts { for _, am := range alert.Alertmanager { for _, silenceID := range am.InhibitedBy { for _, operator := range operators { token := name + operator + silenceID - if _, ok := tokens[token]; !ok { - hint := makeAC(token, []string{ - name, - strings.TrimPrefix(name, "@"), - name + operator, - silenceID, - }) - tokens[token] = &hint - } + setAC(dst, token, []string{ + name, + strings.TrimPrefix(name, "@"), + name + operator, + silenceID, + }) } } } } - acData := make([]models.Autocomplete, 0, len(tokens)) - for _, token := range tokens { - acData = append(acData, *token) - } - return acData } diff --git a/internal/filters/filter_label.go b/internal/filters/filter_label.go index 782125b88..f1f591c31 100644 --- a/internal/filters/filter_label.go +++ b/internal/filters/filter_label.go @@ -2,7 +2,6 @@ package filters import ( "fmt" - "strconv" "strings" "github.com/prometheus/prometheus/model/labels" @@ -10,6 +9,22 @@ import ( "github.com/prymitive/karma/internal/models" ) +func isDigits(s string) bool { + if s == "" { + return false + } + for i := range len(s) { + c := s[i] + if c < '0' || c > '9' { + if i == 0 && (c == '+' || c == '-') && len(s) > 1 { + continue + } + return false + } + } + return true +} + type labelFilter struct { value string alertFilter @@ -46,68 +61,59 @@ func newLabelFilter() FilterT { return &f } -func LabelAutocomplete(labelPairs [][]labels.Label) []models.Autocomplete { - tokens := map[string]*models.Autocomplete{} +func LabelAutocomplete(labelPairs [][]labels.Label, dst map[string]models.Autocomplete) { + var b strings.Builder for _, pairs := range labelPairs { for _, l := range pairs { for _, operator := range labelFilterOperators { switch operator { case equalOperator, notEqualOperator: - token := l.Name + operator + l.Value - if _, ok := tokens[token]; !ok { - hint := makeAC( - token, - []string{ + b.Reset() + b.Grow(len(l.Name) + len(operator) + len(l.Value)) + b.WriteString(l.Name) + b.WriteString(operator) + b.WriteString(l.Value) + token := b.String() + setAC(dst, token, []string{ + l.Name, + l.Name + operator, + l.Value, + }) + case regexpOperator, negativeRegexOperator: + if strings.Contains(l.Value, " ") { + for substring := range strings.SplitSeq(l.Value, " ") { + b.Reset() + b.Grow(len(l.Name) + len(operator) + len(substring)) + b.WriteString(l.Name) + b.WriteString(operator) + b.WriteString(substring) + token := b.String() + setAC(dst, token, []string{ l.Name, l.Name + operator, l.Value, - }, - ) - tokens[token] = &hint - } - case regexpOperator, negativeRegexOperator: - substrings := strings.Split(l.Value, " ") - if len(substrings) > 1 { - for _, substring := range substrings { - token := l.Name + operator + substring - if _, ok := tokens[token]; !ok { - hint := makeAC( - token, - []string{ - l.Name, - l.Name + operator, - l.Value, - substring, - }, - ) - tokens[token] = &hint - } + substring, + }) } } case moreThanOperator, lessThanOperator: - if _, err := strconv.Atoi(l.Value); err == nil { - token := l.Name + operator + l.Value - if _, ok := tokens[token]; !ok { - hint := makeAC( - token, - []string{ - l.Name, - l.Name + operator, - l.Value, - }, - ) - tokens[token] = &hint - } + if isDigits(l.Value) { + b.Reset() + b.Grow(len(l.Name) + len(operator) + len(l.Value)) + b.WriteString(l.Name) + b.WriteString(operator) + b.WriteString(l.Value) + token := b.String() + setAC(dst, token, []string{ + l.Name, + l.Name + operator, + l.Value, + }) } } } } } - acData := make([]models.Autocomplete, 0, len(tokens)) - for _, token := range tokens { - acData = append(acData, *token) - } - return acData } var labelFilterOperators = []string{regexpOperator, negativeRegexOperator, equalOperator, notEqualOperator, lessThanOperator, moreThanOperator} diff --git a/internal/filters/filter_limit.go b/internal/filters/filter_limit.go index 883e4892a..2d8db399c 100644 --- a/internal/filters/filter_limit.go +++ b/internal/filters/filter_limit.go @@ -51,25 +51,17 @@ func newLimitFilter() FilterT { return &f } -func limitAutocomplete(name string, operators []string, _ []models.Alert) []models.Autocomplete { - tokens := make([]models.Autocomplete, 0, len(operators)*2) +func limitAutocomplete(name string, operators []string, _ []models.Alert, dst map[string]models.Autocomplete) { for _, operator := range operators { - tokens = append(tokens, makeAC( - fmt.Sprintf("%s%s10", name, operator), - []string{ - name, - strings.TrimPrefix(name, "@"), - name + operator, - }, - )) - tokens = append(tokens, makeAC( - fmt.Sprintf("%s%s50", name, operator), - []string{ - name, - strings.TrimPrefix(name, "@"), - name + operator, - }, - )) + setAC(dst, fmt.Sprintf("%s%s10", name, operator), []string{ + name, + strings.TrimPrefix(name, "@"), + name + operator, + }) + setAC(dst, fmt.Sprintf("%s%s50", name, operator), []string{ + name, + strings.TrimPrefix(name, "@"), + name + operator, + }) } - return tokens } diff --git a/internal/filters/filter_receiver.go b/internal/filters/filter_receiver.go index 21da138c9..8d38939f7 100644 --- a/internal/filters/filter_receiver.go +++ b/internal/filters/filter_receiver.go @@ -43,51 +43,32 @@ func newreceiverFilter() FilterT { return &f } -func receiverAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete { - tokens := map[string]*models.Autocomplete{} +func receiverAutocomplete(name string, operators []string, alerts []models.Alert, dst map[string]models.Autocomplete) { for _, alert := range alerts { if alert.Receiver != "" { for _, operator := range operators { switch operator { case equalOperator, notEqualOperator: token := name + operator + alert.Receiver - if _, ok := tokens[token]; !ok { - hint := makeAC( - token, - []string{ + setAC(dst, token, []string{ + name, + strings.TrimPrefix(name, "@"), + name + operator, + }) + case regexpOperator, negativeRegexOperator: + if strings.Contains(alert.Receiver, " ") { + for substring := range strings.SplitSeq(alert.Receiver, " ") { + token := name + operator + substring + setAC(dst, token, []string{ name, strings.TrimPrefix(name, "@"), name + operator, - }, - ) - tokens[token] = &hint - } - case regexpOperator, negativeRegexOperator: - substrings := strings.Split(alert.Receiver, " ") - if len(substrings) > 1 { - for _, substring := range substrings { - token := name + operator + substring - if _, ok := tokens[token]; !ok { - hint := makeAC( - token, - []string{ - name, - strings.TrimPrefix(name, "@"), - name + operator, - substring, - }, - ) - tokens[token] = &hint - } + substring, + }) } } } } } } - acData := make([]models.Autocomplete, 0, len(tokens)) - for _, token := range tokens { - acData = append(acData, *token) - } - return acData } diff --git a/internal/filters/filter_silence_author.go b/internal/filters/filter_silence_author.go index 4774068c9..159c4a46a 100644 --- a/internal/filters/filter_silence_author.go +++ b/internal/filters/filter_silence_author.go @@ -69,8 +69,7 @@ func newSilenceAuthorFilter() FilterT { return &f } -func silenceAuthorAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete { - tokens := map[string]*models.Autocomplete{} +func silenceAuthorAutocomplete(name string, operators []string, alerts []models.Alert, dst map[string]models.Autocomplete) { for _, alert := range alerts { for _, am := range alert.Alertmanager { for _, silenceID := range am.SilencedBy { @@ -79,24 +78,16 @@ func silenceAuthorAutocomplete(name string, operators []string, alerts []models. if found { for _, operator := range operators { token := name + operator + silence.CreatedBy - if _, ok := tokens[token]; !ok { - hint := makeAC(token, []string{ - name, - strings.TrimPrefix(name, "@"), - name + operator, - silence.CreatedBy, - }) - tokens[token] = &hint - } + setAC(dst, token, []string{ + name, + strings.TrimPrefix(name, "@"), + name + operator, + silence.CreatedBy, + }) } } } } } } - acData := make([]models.Autocomplete, 0, len(tokens)) - for _, token := range tokens { - acData = append(acData, *token) - } - return acData } diff --git a/internal/filters/filter_silence_ticket.go b/internal/filters/filter_silence_ticket.go index 24964d5dc..f32f021cd 100644 --- a/internal/filters/filter_silence_ticket.go +++ b/internal/filters/filter_silence_ticket.go @@ -69,8 +69,7 @@ func newSilenceTicketFilter() FilterT { return &f } -func silenceTicketIDAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete { - tokens := map[string]*models.Autocomplete{} +func silenceTicketIDAutocomplete(name string, operators []string, alerts []models.Alert, dst map[string]models.Autocomplete) { for _, alert := range alerts { for _, am := range alert.Alertmanager { for _, silenceID := range am.SilencedBy { @@ -79,24 +78,16 @@ func silenceTicketIDAutocomplete(name string, operators []string, alerts []model if found && silence.TicketID != "" { for _, operator := range operators { token := name + operator + silence.TicketID - if _, ok := tokens[token]; !ok { - hint := makeAC(token, []string{ - name, - strings.TrimPrefix(name, "@"), - name + operator, - silence.TicketID, - }) - tokens[token] = &hint - } + setAC(dst, token, []string{ + name, + strings.TrimPrefix(name, "@"), + name + operator, + silence.TicketID, + }) } } } } } } - acData := make([]models.Autocomplete, 0, len(tokens)) - for _, token := range tokens { - acData = append(acData, *token) - } - return acData } diff --git a/internal/filters/filter_silenced_by.go b/internal/filters/filter_silenced_by.go index 7e5863d42..340a65489 100644 --- a/internal/filters/filter_silenced_by.go +++ b/internal/filters/filter_silenced_by.go @@ -63,29 +63,20 @@ func newsilenceIDFilter() FilterT { return &f } -func silenceIDAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete { - tokens := map[string]*models.Autocomplete{} +func silenceIDAutocomplete(name string, operators []string, alerts []models.Alert, dst map[string]models.Autocomplete) { for _, alert := range alerts { for _, am := range alert.Alertmanager { for _, silenceID := range am.SilencedBy { for _, operator := range operators { token := name + operator + silenceID - if _, ok := tokens[token]; !ok { - hint := makeAC(token, []string{ - name, - strings.TrimPrefix(name, "@"), - name + operator, - silenceID, - }) - tokens[token] = &hint - } + setAC(dst, token, []string{ + name, + strings.TrimPrefix(name, "@"), + name + operator, + silenceID, + }) } } } } - acData := make([]models.Autocomplete, 0, len(tokens)) - for _, token := range tokens { - acData = append(acData, *token) - } - return acData } diff --git a/internal/filters/filter_state.go b/internal/filters/filter_state.go index fd99fc767..b3de28777 100644 --- a/internal/filters/filter_state.go +++ b/internal/filters/filter_state.go @@ -56,27 +56,15 @@ func newStateFilter() FilterT { return &f } -func stateAutocomplete(name string, operators []string, alerts []models.Alert) []models.Autocomplete { - tokens := map[string]*models.Autocomplete{} +func stateAutocomplete(name string, operators []string, alerts []models.Alert, dst map[string]models.Autocomplete) { for _, operator := range operators { for _, alert := range alerts { token := name + operator + alert.State.String() - if _, ok := tokens[token]; !ok { - hint := makeAC( - token, - []string{ - name, - strings.TrimPrefix(name, "@"), - name + operator, - }, - ) - tokens[token] = &hint - } + setAC(dst, token, []string{ + name, + strings.TrimPrefix(name, "@"), + name + operator, + }) } } - acData := make([]models.Autocomplete, 0, len(tokens)) - for _, token := range tokens { - acData = append(acData, *token) - } - return acData } diff --git a/internal/filters/matcher_test.go b/internal/filters/matcher_test.go index d9ee5cd89..954f16e63 100644 --- a/internal/filters/matcher_test.go +++ b/internal/filters/matcher_test.go @@ -184,6 +184,42 @@ func TestNegativeRegexpMatcherWithInvalidPattern(t *testing.T) { } } +func TestIsDigits(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + // empty string returns false + {input: "", expected: false}, + // plain digits + {input: "0", expected: true}, + {input: "123", expected: true}, + // positive sign prefix + {input: "+1", expected: true}, + {input: "+999", expected: true}, + // negative sign prefix + {input: "-1", expected: true}, + {input: "-42", expected: true}, + // bare sign with no digits returns false + {input: "+", expected: false}, + {input: "-", expected: false}, + // non-digit characters return false + {input: "abc", expected: false}, + {input: "12a", expected: false}, + {input: "1.5", expected: false}, + // sign in the middle returns false + {input: "1+2", expected: false}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := isDigits(tt.input) + if got != tt.expected { + t.Errorf("isDigits(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} + func TestNewMatcher(t *testing.T) { operators := []string{ equalOperator, diff --git a/internal/models/alert_test.go b/internal/models/alert_test.go index 6c1cdbd9c..9bbf86fae 100644 --- a/internal/models/alert_test.go +++ b/internal/models/alert_test.go @@ -132,6 +132,18 @@ func TestSortOrderedLabels(t *testing.T) { {Name: "bar", Value: "1"}, }, }, + // verifies that two distinct labels both in config order sort by their position in the order list + { + order: []string{"bar", "foo"}, + in: models.OrderedLabels{ + {Name: "foo", Value: "1"}, + {Name: "bar", Value: "1"}, + }, + out: models.OrderedLabels{ + {Name: "bar", Value: "1"}, + {Name: "foo", Value: "1"}, + }, + }, // verifies that identical labels stay in their original positions { order: []string{}, @@ -186,6 +198,17 @@ func TestSortOrderedLabels(t *testing.T) { } } +func TestCompareOrderedLabelsIdentical(t *testing.T) { + // verifies that comparing two identical labels returns 0 + config.Config.Labels.Order = []string{} + a := models.OrderedLabel{Name: "foo", Value: "bar"} + b := models.OrderedLabel{Name: "foo", Value: "bar"} + got := models.CompareOrderedLabels(a, b) + if got != 0 { + t.Errorf("CompareOrderedLabels(%v, %v) = %d, want 0", a, b, got) + } +} + func TestLabelsMap(t *testing.T) { type testCaseT struct { labels labels.Labels @@ -268,6 +291,70 @@ func TestAlertStateUnmarshalJSONError(t *testing.T) { } } +func TestAlertStateStringUnknown(t *testing.T) { + // verifies that an out-of-range AlertState falls back to "unprocessed" + unknown := models.AlertState(99) + if unknown.String() != "unprocessed" { + t.Errorf("AlertState(99).String() = %q, want %q", unknown.String(), "unprocessed") + } +} + +func TestParseAlertStateUnknown(t *testing.T) { + // verifies that parsing an unknown string returns AlertStateUnprocessed + got := models.ParseAlertState("bogus") + if got != models.AlertStateUnprocessed { + t.Errorf("ParseAlertState(%q) = %v, want %v", "bogus", got, models.AlertStateUnprocessed) + } +} + +func TestAlertStateMarshalText(t *testing.T) { + // verifies that MarshalText produces the expected string representation + tests := []struct { + state models.AlertState + expected string + }{ + {state: models.AlertStateUnprocessed, expected: "unprocessed"}, + {state: models.AlertStateActive, expected: "active"}, + {state: models.AlertStateSuppressed, expected: "suppressed"}, + } + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + data, err := tt.state.MarshalText() + if err != nil { + t.Fatalf("MarshalText() error: %v", err) + } + if string(data) != tt.expected { + t.Errorf("MarshalText() = %q, want %q", string(data), tt.expected) + } + }) + } +} + +func TestAlertStateUnmarshalText(t *testing.T) { + // verifies that UnmarshalText correctly parses known and unknown state strings + tests := []struct { + input string + expected models.AlertState + }{ + {input: "active", expected: models.AlertStateActive}, + {input: "suppressed", expected: models.AlertStateSuppressed}, + {input: "unprocessed", expected: models.AlertStateUnprocessed}, + {input: "unknown", expected: models.AlertStateUnprocessed}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + var s models.AlertState + err := s.UnmarshalText([]byte(tt.input)) + if err != nil { + t.Fatalf("UnmarshalText(%q) error: %v", tt.input, err) + } + if s != tt.expected { + t.Errorf("UnmarshalText(%q) = %v, want %v", tt.input, s, tt.expected) + } + }) + } +} + func TestUpdateFingerprints(t *testing.T) { // verifies that UpdateFingerprints produces stable, non-empty fingerprints // including the alertmanager instance, silenced-by, and inhibited-by branches diff --git a/internal/models/annotation_test.go b/internal/models/annotation_test.go index f3e7e7de3..f9b0ec312 100644 --- a/internal/models/annotation_test.go +++ b/internal/models/annotation_test.go @@ -257,6 +257,23 @@ func TestAnnotationsCustomOrderSort(t *testing.T) { } } +func TestAnnotationsSortBothInOrder(t *testing.T) { + // verifies that when both annotations appear in the configured order list, + // they sort strictly by their position in that list regardless of alphabetical order + config.Config.Annotations.Order = []string{"zzz", "aaa"} + annotations := models.Annotations{ + {Name: "aaa", Value: "v1", Visible: true}, + {Name: "zzz", Value: "v2", Visible: true}, + } + models.SortAnnotations(annotations) + if annotations[0].Name != "zzz" { + t.Errorf("Expected 'zzz' (order index 0) to be first, got %q", annotations[0].Name) + } + if annotations[1].Name != "aaa" { + t.Errorf("Expected 'aaa' (order index 1) to be second, got %q", annotations[1].Name) + } +} + func TestAnnotationsSortIdenticalNames(t *testing.T) { // verifies that annotations with the same name are treated as equal by the comparator config.Config.Annotations.Order = []string{} diff --git a/internal/models/marshal_jsonv2_test.go b/internal/models/marshal_jsonv2_test.go index cc25d7ab6..04799440b 100644 --- a/internal/models/marshal_jsonv2_test.go +++ b/internal/models/marshal_jsonv2_test.go @@ -430,6 +430,63 @@ func TestMarshalJSONTo_MatchesV1(t *testing.T) { TotalGroups: 1, }, }, + { + // API grid with populated alert groups containing actual alerts, + // exercising the inner alert marshaling loop in APIGrid.MarshalJSONTo + name: "APIGrid/with_alerts", + val: models.APIGrid{ + StateCount: map[string]int{"active": 1}, + LabelName: "cluster", + LabelValue: "prod", + AlertGroups: []models.APIAlertGroup{ + { + AllLabels: map[string]map[string][]string{ + "active": {"alertname": {"Test"}}, + }, + AlertmanagerCount: map[string]int{"am1": 1}, + StateCount: map[string]int{"active": 1}, + Receiver: "default", + ID: "group-1", + Shared: models.APIAlertGroupSharedMaps{ + Annotations: models.Annotations{}, + Labels: models.OrderedLabels{{Name: "job", Value: "test"}}, + Silences: map[string][]string{}, + Sources: []string{"http://am1:9093"}, + Clusters: []string{"cluster1"}, + }, + Labels: models.OrderedLabels{{Name: "alertname", Value: "Test"}}, + Alerts: []models.APIAlert{ + { + StartsAt: ts, + State: "active", + Receiver: "default", + LabelsFP: "fp1", + Annotations: models.Annotations{ + {Name: "summary", Value: "test", Visible: true}, + }, + Labels: models.OrderedLabels{ + {Name: "instance", Value: "localhost:9090"}, + }, + Alertmanager: []models.AlertmanagerInstance{ + { + StartsAt: ts, + Fingerprint: "fp1", + Name: "am1", + Cluster: "cluster1", + Source: "http://am1:9093", + SilencedBy: []string{}, + InhibitedBy: []string{}, + State: models.AlertStateActive, + }, + }, + }, + }, + TotalAlerts: 1, + }, + }, + TotalGroups: 1, + }, + }, { // full alert group with all nested structures populated name: "APIAlertGroup/full",