fix(backend): refactor autocomplete

This commit is contained in:
Lukasz Mierzwa
2026-03-11 09:31:08 +00:00
committed by Łukasz Mierzwa
parent 1cf2e5760f
commit 22847e24be
19 changed files with 381 additions and 280 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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