mirror of
https://github.com/prymitive/karma
synced 2026-08-23 11:56:20 +00:00
feat(api): sort alert groups in the backend
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user