feat(api): sort alert groups in the backend

This commit is contained in:
Łukasz Mierzwa
2019-08-08 15:45:00 +01:00
parent 68b6e17e64
commit 86506563e9
7 changed files with 205 additions and 29 deletions
+59 -19
View File
@@ -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
//
}
+127 -5
View File
@@ -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)
}
}
}
}
+4
View File
@@ -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
+7
View File
@@ -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=
+1 -1
View File
@@ -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"`
}
}
+5 -3
View File
@@ -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 {
+2 -1
View File
@@ -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)
}