diff --git a/alerts.go b/alerts.go index 22d457fd8..63198aa3b 100644 --- a/alerts.go +++ b/alerts.go @@ -6,6 +6,8 @@ import ( "sort" "github.com/gin-gonic/gin" + "vbom.ml/util/sortorder" + "github.com/prymitive/karma/internal/alertmanager" "github.com/prymitive/karma/internal/config" "github.com/prymitive/karma/internal/filters" @@ -61,7 +63,7 @@ func countersToLabelStats(counters map[string]map[string]int) models.LabelNameSt // now that we have total hits we can calculate % var totalPercent int for i, value := range nameStats.Values { - nameStats.Values[i].Percent = int(math.Round((float64(value.Hits) / float64(nameStats.Hits)) * 100.0)) + nameStats.Values[i].Percent = int(math.Floor((float64(value.Hits) / float64(nameStats.Hits)) * 100.0)) totalPercent += nameStats.Values[i].Percent } sort.Sort(nameStats.Values) @@ -128,17 +130,17 @@ func getUpstreams() models.AlertmanagerAPISummary { return summary } -func resolveLabelValue(name, value string) (int, bool) { +func resolveLabelValue(name, value string) string { valueReplacements, found := config.Config.Grid.Sorting.CustomValues.Labels[name] if found { if replacement, ok := valueReplacements[value]; ok { - return replacement, true + return replacement } } - return value, false + return value } -func getGroupLabel(group *models.APIAlertGroup, label string) int { +func getGroupLabel(group *models.APIAlertGroup, label string) string { if v, found := group.Labels[label]; found { return resolveLabelValue(label, v) } @@ -148,7 +150,15 @@ func getGroupLabel(group *models.APIAlertGroup, label string) int { if v, found := group.Alerts[0].Labels[label]; found { return resolveLabelValue(label, v) } - return 0 + return "" +} + +func sortByStartsAt(i, j int, groups []models.APIAlertGroup, sortReverse bool) bool { + if sortReverse { + return groups[i].LatestStartsAt.After(groups[j].LatestStartsAt) + } else { + return groups[i].LatestStartsAt.Before(groups[j].LatestStartsAt) + } } func sortAlertGroups(c *gin.Context, groupsMap map[string]models.APIAlertGroup) []models.APIAlertGroup { @@ -160,7 +170,7 @@ func sortAlertGroups(c *gin.Context, groupsMap map[string]models.APIAlertGroup) } sortReverse, found := c.GetQuery("sortReverse") - if !found { + if !found || (sortReverse != "0" && sortReverse != "1") { if config.Config.Grid.Sorting.Reverse { sortReverse = "1" } else { @@ -179,26 +189,56 @@ func sortAlertGroups(c *gin.Context, groupsMap map[string]models.APIAlertGroup) switch sortOrder { case "startsAt": - sort.SliceStable(groups, func(i, j int) bool { - return groups[i].LatestStartsAt.After(groups[j].LatestStartsAt) + sort.Slice(groups, func(i, j int) bool { + return sortByStartsAt(i, j, groups, sortReverse == "1") }) case "label": - sort.SliceStable(groups, func(i, j int) bool { - return getGroupLabel(&groups[i], sortLabel) < getGroupLabel(&groups[j], sortLabel) + sort.Slice(groups, func(i, j int) bool { + vi := getGroupLabel(&groups[i], sortLabel) + vj := getGroupLabel(&groups[j], sortLabel) + if vi == "" && vj == "" { + // both groups lack this label, fallback to timestamp sort + return sortByStartsAt(i, j, groups, true) + } + + if vi == "" { + // first label is missing + if sortReverse == "0" { + return false + } else { + return true + } + } + if vj == "" { + // second label is missing + if sortReverse == "0" { + return true + } else { + return false + } + } + if vi == vj { + // both labels are equal fallback to timestamp sort + return sortByStartsAt(i, j, groups, true) + } + // finnally return groups sorted by label + if sortReverse == "1" { + return !sortorder.NaturalLess(vi, vj) + } else { + return sortorder.NaturalLess(vi, vj) + } }) default: // sort alert groups so they are always returned in the same order // use group ID which is unique and immutable - sort.SliceStable(groups, func(i, j int) bool { - return groups[i].ID < groups[j].ID + sort.Slice(groups, func(i, j int) bool { + if sortReverse == "1" { + return groups[i].ID < groups[j].ID + } else { + return groups[i].ID > groups[j].ID + } }) } - if sortReverse == "1" { - sort.Reverse(groups) - } - return groups - - // } diff --git a/api_test.go b/api_test.go index 41fc64cf3..15f8f6acb 100644 --- a/api_test.go +++ b/api_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "reflect" @@ -9,6 +10,7 @@ import ( "time" "github.com/blang/semver" + "github.com/google/go-cmp/cmp" "github.com/prymitive/karma/internal/mock" "github.com/prymitive/karma/internal/models" @@ -786,17 +788,17 @@ var countsMap = models.LabelNameStatsList{ models.LabelValueStats{ Value: "server1", Hits: 2, - Percent: 8, + Percent: 9, }, models.LabelValueStats{ Value: "server2", Hits: 4, - Percent: 18, + Percent: 17, }, models.LabelValueStats{ Value: "server3", Hits: 2, - Percent: 8, + Percent: 9, }, models.LabelValueStats{ Value: "server4", @@ -806,7 +808,7 @@ var countsMap = models.LabelNameStatsList{ models.LabelValueStats{ Value: "server5", Hits: 4, - Percent: 18, + Percent: 17, }, models.LabelValueStats{ Value: "server6", @@ -837,7 +839,7 @@ var countsMap = models.LabelNameStatsList{ }, { Name: "ip", - Hits: 8, + Hits: 16, Values: models.LabelValueStatsList{ models.LabelValueStats{ Value: "127.0.0.1", @@ -1141,3 +1143,123 @@ func TestVerifyAllGroups(t *testing.T) { } } } + +type sortTest struct { + filter string + sortOrder string + sortLabel string + sortReverse string + expectedLabel string + expectedValues []string +} + +var sortTests = []sortTest{ + { + filter: "q=@receiver=by-cluster-service", + sortOrder: "label", + sortLabel: "cluster", + sortReverse: "0", + expectedLabel: "cluster", + expectedValues: []string{"dev", "dev", "prod", "prod", "staging", "staging"}, + }, + { + filter: "q=@receiver=by-cluster-service", + sortOrder: "label", + sortLabel: "cluster", + sortReverse: "1", + expectedLabel: "cluster", + expectedValues: []string{"staging", "staging", "prod", "prod", "dev", "dev"}, + }, + { + filter: "q=cluster=dev", + sortOrder: "label", + sortLabel: "cluster", + sortReverse: "0", + expectedLabel: "cluster", + expectedValues: []string{"dev", "dev", "dev", "dev"}, + }, + { + filter: "q=@receiver=by-cluster-service", + sortOrder: "label", + sortLabel: "disk", + sortReverse: "0", + expectedLabel: "disk", + expectedValues: []string{"sda", "", "", "", "", "", "", "", "", "", "", ""}, + }, + { + filter: "q=@receiver=by-cluster-service", + sortOrder: "label", + sortLabel: "disk", + sortReverse: "1", + expectedLabel: "disk", + expectedValues: []string{"", "", "", "", "", "", "", "", "", "", "", "sda"}, + }, + { + filter: "q=@receiver=by-cluster-service", + sortOrder: "disabled", + sortLabel: "", + sortReverse: "0", + expectedLabel: "cluster", + expectedValues: []string{"dev", "prod", "staging", "dev", "staging", "prod"}, + }, + { + filter: "q=@receiver=by-cluster-service", + sortOrder: "disabled", + sortLabel: "", + sortReverse: "1", + expectedLabel: "cluster", + expectedValues: []string{"prod", "staging", "dev", "staging", "prod", "dev"}, + }, +} + +func TestSortOrder(t *testing.T) { + mockConfig() + for _, version := range mock.ListAllMocks() { + t.Logf("Testing API using mock files from Alertmanager %s", version) + mockAlerts(version) + r := ginTestEngine() + + for _, testCase := range sortTests { + uri := fmt.Sprintf( + "/alerts.json?sortOrder=%s&sortLabel=%s&sortReverse=%s&%s", + testCase.sortOrder, + testCase.sortLabel, + testCase.sortReverse, + testCase.filter, + ) + t.Logf("Request URI: %s", uri) + req := httptest.NewRequest("GET", uri, nil) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Errorf("GET /alerts.json returned status %d", resp.Code) + } + + ur := models.AlertsResponse{} + err := json.Unmarshal(resp.Body.Bytes(), &ur) + if err != nil { + t.Errorf("Failed to unmarshal response: %s", err) + } + + values := []string{} + for _, ag := range ur.AlertGroups { + v := ag.Labels[testCase.expectedLabel] + if v == "" { + v = ag.Shared.Labels[testCase.expectedLabel] + } + if v != "" { + values = append(values, v) + } else { + for _, alert := range ag.Alerts { + v = alert.Labels[testCase.expectedLabel] + values = append(values, v) + } + } + } + + if diff := cmp.Diff(testCase.expectedValues, values); diff != "" { + t.Errorf("Incorrectly sorted values (-want +got):\n%s", diff) + } + } + } +} diff --git a/go.mod b/go.mod index 6006fae23..7da133fed 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,8 @@ require ( github.com/go-openapi/swag v0.19.4 github.com/go-openapi/validate v0.19.2 github.com/golangci/golangci-lint v1.17.1 + github.com/google/go-cmp v0.3.0 + github.com/google/pprof v0.0.0-20190723021845-34ac40c74b70 // indirect github.com/hansrodtang/randomcolor v0.0.0-20160512071917-d27108b3d7a5 github.com/jarcoal/httpmock v1.0.4 github.com/mcuadros/go-gin-prometheus v0.1.1-0.20190723203314-c7374e9082f8 @@ -31,8 +33,10 @@ require ( github.com/spf13/pflag v1.0.3 github.com/spf13/viper v1.4.0 github.com/terinjokes/bakelite v0.2.0 + golang.org/x/arch v0.0.0-20190312162104-788fe5ffcd8c // indirect gopkg.in/go-playground/colors.v1 v1.2.0 gopkg.in/yaml.v2 v2.2.2 + vbom.ml/util v0.0.0-20180919145318-efcd4e0f9787 ) replace github.com/ugorji/go v1.1.4 => github.com/ugorji/go/codec v0.0.0-20190204201341-e444a5086c43 diff --git a/go.sum b/go.sum index c14698286..74bd691f3 100644 --- a/go.sum +++ b/go.sum @@ -225,6 +225,8 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20190723021845-34ac40c74b70 h1:XTnP8fJpa4Kvpw2qARB4KS9izqxPS0Sd92cDlY3uk+w= +github.com/google/pprof v0.0.0-20190723021845-34ac40c74b70/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -427,6 +429,8 @@ go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qL go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/arch v0.0.0-20190312162104-788fe5ffcd8c h1:Rx/HTKi09myZ25t1SOlDHmHOy/mKxNAcu0hP1oPX9qM= +golang.org/x/arch v0.0.0-20190312162104-788fe5ffcd8c/go.mod h1:flIaEI6LNU6xOCD5PaJvn9wGP0agmIOqjrtsKGRguv4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a h1:YX8ljsm6wXlHZO+aRz9Exqr0evNhKRNe5K/gi+zKh4U= @@ -533,5 +537,8 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4 h1:JPJh2pk3+X4lXAkZIk2RuE/7/FoK9maXw+TNPJhVS/c= sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= +vbom.ml/util v0.0.0-20180919145318-efcd4e0f9787 h1:O69FD9pJA4WUZlEwYatBEEkRWKQ5cKodWpdKTrCS/iQ= +vbom.ml/util v0.0.0-20180919145318-efcd4e0f9787/go.mod h1:so/NYdZXCz+E3ZpW0uAoCj6uzU2+8OWDFv/HxUSs7kI= diff --git a/internal/config/models.go b/internal/config/models.go index 1df0acb00..1cbc845b3 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -61,7 +61,7 @@ type configSchema struct { Reverse bool Label string CustomValues struct { - Labels map[string]map[string]int + Labels map[string]map[string]string } `yaml:"customValues" mapstructure:"customValues"` } } diff --git a/internal/models/api.go b/internal/models/api.go index 88f227208..f6a9f7b40 100644 --- a/internal/models/api.go +++ b/internal/models/api.go @@ -4,6 +4,8 @@ import ( "fmt" "sort" + "vbom.ml/util/sortorder" + "github.com/prymitive/karma/internal/slices" ) @@ -56,7 +58,7 @@ func (lvsl LabelValueStatsList) Swap(i, j int) { } func (lvsl LabelValueStatsList) Less(i, j int) bool { if lvsl[i].Hits == lvsl[j].Hits { - return lvsl[i].Value > lvsl[j].Value + return sortorder.NaturalLess(lvsl[i].Value, lvsl[j].Value) } return lvsl[i].Hits > lvsl[j].Hits } @@ -263,8 +265,8 @@ type GridSettings struct { // SortSettings nests all settings specific to sorting type SortSettings struct { - Grid GridSettings `json:"grid"` - ValueMapping map[string]map[string]int `json:"valueMapping"` + Grid GridSettings `json:"grid"` + ValueMapping map[string]map[string]string `json:"valueMapping"` } type SilenceFormStripSettings struct { diff --git a/views.go b/views.go index 5ac5de515..8df93e159 100644 --- a/views.go +++ b/views.go @@ -144,7 +144,7 @@ func alerts(c *gin.Context) { Reverse: config.Config.Grid.Sorting.Reverse, Label: config.Config.Grid.Sorting.Label, }, - ValueMapping: map[string]map[string]int{}, + ValueMapping: map[string]map[string]string{}, }, StaticColorLabels: config.Config.Labels.Color.Static, AnnotationsDefaultHidden: config.Config.Annotations.Default.Hidden, @@ -316,6 +316,7 @@ func alerts(c *gin.Context) { agCopy.Hash = agCopy.ContentFingerprint() apiAG := models.APIAlertGroup{AlertGroup: agCopy} apiAG.DedupSharedMaps() + sort.Sort(apiAG.Alerts) alerts[agCopy.ID] = apiAG resp.TotalAlerts += len(agCopy.Alerts) }