mirror of
https://github.com/prymitive/karma
synced 2026-08-23 11:56:20 +00:00
feat(api): add labels stats to the api response
These will be used by an overview modal, showing top labels.
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"github.com/prymitive/karma/internal/alertmanager"
|
||||
"github.com/prymitive/karma/internal/filters"
|
||||
"github.com/prymitive/karma/internal/models"
|
||||
@@ -22,6 +25,45 @@ func getFiltersFromQuery(filterStrings []string) ([]filters.FilterT, bool) {
|
||||
return matchFilters, validFilters
|
||||
}
|
||||
|
||||
func countLabel(countStore map[string]map[string]int, key string, val string) {
|
||||
if _, found := countStore[key]; !found {
|
||||
countStore[key] = make(map[string]int)
|
||||
}
|
||||
if _, found := countStore[key][val]; found {
|
||||
countStore[key][val]++
|
||||
} else {
|
||||
countStore[key][val] = 1
|
||||
}
|
||||
}
|
||||
|
||||
func countersToLabelStats(counters map[string]map[string]int) models.LabelNameStatsList {
|
||||
data := models.LabelNameStatsList{}
|
||||
|
||||
for name, valueMap := range counters {
|
||||
nameStats := models.LabelNameStats{
|
||||
Name: name,
|
||||
Values: models.LabelValueStatsList{},
|
||||
}
|
||||
for value, hits := range valueMap {
|
||||
nameStats.Hits += hits
|
||||
valueStats := models.LabelValueStats{
|
||||
Value: value,
|
||||
Hits: hits,
|
||||
}
|
||||
nameStats.Values = append(nameStats.Values, valueStats)
|
||||
}
|
||||
for i, value := range nameStats.Values {
|
||||
nameStats.Values[i].Percent = int(math.Round((float64(value.Hits) / float64(nameStats.Hits)) * 100.0))
|
||||
}
|
||||
sort.Sort(nameStats.Values)
|
||||
data = append(data, nameStats)
|
||||
}
|
||||
|
||||
sort.Sort(data)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func getUpstreams() models.AlertmanagerAPISummary {
|
||||
summary := models.AlertmanagerAPISummary{}
|
||||
|
||||
|
||||
+192
@@ -647,6 +647,160 @@ var groupTests = []groupTest{
|
||||
},
|
||||
}
|
||||
|
||||
var countsMap = models.LabelNameStatsList{
|
||||
{
|
||||
Name: "@receiver",
|
||||
Hits: 24,
|
||||
Values: models.LabelValueStatsList{
|
||||
models.LabelValueStats{
|
||||
Value: "by-cluster-service",
|
||||
Hits: 12,
|
||||
Percent: 50,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "by-name",
|
||||
Hits: 12,
|
||||
Percent: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "@state",
|
||||
Hits: 24,
|
||||
Values: models.LabelValueStatsList{
|
||||
models.LabelValueStats{
|
||||
Value: "active",
|
||||
Hits: 16,
|
||||
Percent: 67,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "suppressed",
|
||||
Hits: 8,
|
||||
Percent: 33,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "alertname",
|
||||
Hits: 24,
|
||||
Values: models.LabelValueStatsList{
|
||||
models.LabelValueStats{
|
||||
Value: "Free_Disk_Space_Too_Low",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "HTTP_Probe_Failed",
|
||||
Hits: 4,
|
||||
Percent: 17,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "Host_Down",
|
||||
Hits: 16,
|
||||
Percent: 67,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "Memory_Usage_Too_High",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cluster",
|
||||
Hits: 24,
|
||||
Values: models.LabelValueStatsList{
|
||||
models.LabelValueStats{
|
||||
Value: "dev",
|
||||
Hits: 10,
|
||||
Percent: 42,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "prod",
|
||||
Hits: 6,
|
||||
Percent: 25,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "staging",
|
||||
Hits: 8,
|
||||
Percent: 33,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "instance",
|
||||
Hits: 24,
|
||||
Values: models.LabelValueStatsList{
|
||||
models.LabelValueStats{
|
||||
Value: "server1",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "server2",
|
||||
Hits: 4,
|
||||
Percent: 17,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "server3",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "server4",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "server5",
|
||||
Hits: 4,
|
||||
Percent: 17,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "server6",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "server7",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "server8",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "web1",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "web2",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "job",
|
||||
Hits: 24,
|
||||
Values: models.LabelValueStatsList{
|
||||
models.LabelValueStats{
|
||||
Value: "node_exporter",
|
||||
Hits: 8,
|
||||
Percent: 33,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "node_ping",
|
||||
Hits: 16,
|
||||
Percent: 67,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var filtersExpected = []models.Filter{}
|
||||
|
||||
func compareAlertGroups(testCase groupTest, group models.APIAlertGroup) bool {
|
||||
@@ -836,6 +990,44 @@ func TestVerifyAllGroups(t *testing.T) {
|
||||
t.Errorf("[%s] Silences mismatch, expected >0 but got %d", version, len(am))
|
||||
}
|
||||
|
||||
for _, expectedNameStats := range countsMap {
|
||||
var foundName bool
|
||||
for _, nameStats := range ur.Counters {
|
||||
if nameStats.Name == expectedNameStats.Name {
|
||||
if nameStats.Hits != expectedNameStats.Hits {
|
||||
t.Errorf("[%s] Counters mismatch for '%s', expected %v hits but got %v",
|
||||
version, nameStats.Name, expectedNameStats.Hits, nameStats.Hits)
|
||||
}
|
||||
for _, expectedValueStats := range expectedNameStats.Values {
|
||||
var foundValue bool
|
||||
for _, valueStats := range nameStats.Values {
|
||||
if valueStats.Value == expectedValueStats.Value {
|
||||
if valueStats.Hits != expectedValueStats.Hits {
|
||||
t.Errorf("[%s] Counters mismatch for '%s: %s', expected %v hits but got %v",
|
||||
version, nameStats.Name, valueStats.Value, expectedValueStats.Hits, valueStats.Hits)
|
||||
}
|
||||
if valueStats.Percent != expectedValueStats.Percent {
|
||||
t.Errorf("[%s] Percent mismatch for '%s: %s', expected %v%% but got %v%%",
|
||||
version, nameStats.Name, valueStats.Value, expectedValueStats.Percent, valueStats.Percent)
|
||||
}
|
||||
foundValue = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundValue {
|
||||
if !foundName {
|
||||
t.Errorf("[%s] Counters missing for label '%s: %s'", version, expectedNameStats.Name, expectedValueStats.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
foundName = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundName {
|
||||
t.Errorf("[%s] Counters missing for label '%s'", version, expectedNameStats.Name)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(ur.Filters, filtersExpected) {
|
||||
t.Errorf("[%s] Filters mismatch, expected %v but got %v", version, filtersExpected, ur.Filters)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,52 @@ type LabelColors struct {
|
||||
// LabelsColorMap is a map of "Label Key" -> "Label Value" -> karmaLabelColors
|
||||
type LabelsColorMap map[string]map[string]LabelColors
|
||||
|
||||
// LabelsCountMap is a map of "Label Key" -> "Label Value" -> number of occurence
|
||||
type LabelsCountMap map[string]map[string]int
|
||||
|
||||
type LabelValueStats struct {
|
||||
Value string `json:"value"`
|
||||
Hits int `json:"hits"`
|
||||
Percent int `json:"percent"`
|
||||
}
|
||||
|
||||
type LabelValueStatsList []LabelValueStats
|
||||
|
||||
func (lvsl LabelValueStatsList) Len() int {
|
||||
return len(lvsl)
|
||||
}
|
||||
func (lvsl LabelValueStatsList) Swap(i, j int) {
|
||||
lvsl[i], lvsl[j] = lvsl[j], lvsl[i]
|
||||
}
|
||||
func (lvsl LabelValueStatsList) Less(i, j int) bool {
|
||||
if lvsl[i].Hits == lvsl[j].Hits {
|
||||
return lvsl[i].Value > lvsl[j].Value
|
||||
}
|
||||
return lvsl[i].Hits > lvsl[j].Hits
|
||||
}
|
||||
|
||||
// LabelStats is used in the overview modal, it shows top labels across alerts
|
||||
type LabelNameStats struct {
|
||||
Name string `json:"name"`
|
||||
Values LabelValueStatsList `json:"values"`
|
||||
Hits int `json:"hits"`
|
||||
}
|
||||
|
||||
type LabelNameStatsList []LabelNameStats
|
||||
|
||||
func (lnsl LabelNameStatsList) Len() int {
|
||||
return len(lnsl)
|
||||
}
|
||||
func (lnsl LabelNameStatsList) Swap(i, j int) {
|
||||
lnsl[i], lnsl[j] = lnsl[j], lnsl[i]
|
||||
}
|
||||
func (lnsl LabelNameStatsList) Less(i, j int) bool {
|
||||
if lnsl[i].Hits == lnsl[j].Hits {
|
||||
return lnsl[i].Name > lnsl[j].Name
|
||||
}
|
||||
return lnsl[i].Hits > lnsl[j].Hits
|
||||
}
|
||||
|
||||
// APIAlertGroupSharedMaps defines shared part of APIAlertGroup
|
||||
type APIAlertGroupSharedMaps struct {
|
||||
Annotations Annotations `json:"annotations"`
|
||||
@@ -248,6 +294,7 @@ type AlertsResponse struct {
|
||||
TotalAlerts int `json:"totalAlerts"`
|
||||
Colors LabelsColorMap `json:"colors"`
|
||||
Filters []Filter `json:"filters"`
|
||||
Counters LabelNameStatsList `json:"counters"`
|
||||
Settings Settings `json:"settings"`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package models_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/pmezard/go-difflib/difflib"
|
||||
@@ -255,3 +256,180 @@ func TestDedupSharedMapsWithSingleAlert(t *testing.T) {
|
||||
}
|
||||
ag.DedupSharedMaps()
|
||||
}
|
||||
|
||||
func TestNameStatsSort(t *testing.T) {
|
||||
var nameStats = models.LabelNameStatsList{
|
||||
{
|
||||
Name: "@state",
|
||||
Hits: 24,
|
||||
Values: models.LabelValueStatsList{
|
||||
models.LabelValueStats{
|
||||
Value: "suppressed",
|
||||
Hits: 8,
|
||||
Percent: 33,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "active",
|
||||
Hits: 16,
|
||||
Percent: 67,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cluster",
|
||||
Hits: 24,
|
||||
Values: models.LabelValueStatsList{
|
||||
models.LabelValueStats{
|
||||
Value: "dev",
|
||||
Hits: 10,
|
||||
Percent: 42,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "prod",
|
||||
Hits: 6,
|
||||
Percent: 25,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "staging",
|
||||
Hits: 8,
|
||||
Percent: 33,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "alertname",
|
||||
Hits: 24,
|
||||
Values: models.LabelValueStatsList{
|
||||
models.LabelValueStats{
|
||||
Value: "HTTP_Probe_Failed",
|
||||
Hits: 4,
|
||||
Percent: 17,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "Host_Down",
|
||||
Hits: 16,
|
||||
Percent: 67,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "Free_Disk_Space_Too_Low",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "Memory_Usage_Too_High",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "instance",
|
||||
Hits: 24,
|
||||
Values: models.LabelValueStatsList{
|
||||
models.LabelValueStats{
|
||||
Value: "server4",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "server5",
|
||||
Hits: 4,
|
||||
Percent: 17,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "server6",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "server1",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "server2",
|
||||
Hits: 4,
|
||||
Percent: 17,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "server3",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "server7",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "server8",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "web1",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "web2",
|
||||
Hits: 2,
|
||||
Percent: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "@receiver",
|
||||
Hits: 24,
|
||||
Values: models.LabelValueStatsList{
|
||||
models.LabelValueStats{
|
||||
Value: "by-name",
|
||||
Hits: 12,
|
||||
Percent: 50,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "by-cluster-service",
|
||||
Hits: 12,
|
||||
Percent: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "job",
|
||||
Hits: 16,
|
||||
Values: models.LabelValueStatsList{
|
||||
models.LabelValueStats{
|
||||
Value: "node_exporter",
|
||||
Hits: 8,
|
||||
Percent: 50,
|
||||
},
|
||||
models.LabelValueStats{
|
||||
Value: "node_ping",
|
||||
Hits: 8,
|
||||
Percent: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b, err := json.Marshal(nameStats)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
before := string(b)
|
||||
|
||||
for _, n := range nameStats {
|
||||
sort.Sort(n.Values)
|
||||
}
|
||||
sort.Sort(nameStats)
|
||||
|
||||
a, err := json.Marshal(nameStats)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
after := string(a)
|
||||
|
||||
if after == before {
|
||||
t.Errorf("Sorting LabelNameStatsList produces the same output as unsorted instance")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ class AlertStore {
|
||||
data = observable(
|
||||
{
|
||||
colors: {},
|
||||
counters: {},
|
||||
groups: {},
|
||||
silences: {},
|
||||
upstreams: { instances: [], clusters: {} },
|
||||
@@ -302,7 +303,7 @@ class AlertStore {
|
||||
|
||||
let updates = {};
|
||||
// update data dicts if they changed
|
||||
for (const key of ["colors", "silences", "upstreams"]) {
|
||||
for (const key of ["colors", "counters", "silences", "upstreams"]) {
|
||||
if (!equal(this.data[key], result[key])) {
|
||||
updates[key] = result[key];
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const EmptyAPIResponse = () => ({
|
||||
isValid: true
|
||||
}
|
||||
],
|
||||
counters: {},
|
||||
settings: {
|
||||
sorting: {
|
||||
grid: {
|
||||
|
||||
@@ -122,6 +122,7 @@ func alerts(c *gin.Context) {
|
||||
// set pointers for data store objects, need a lock until end of view is reached
|
||||
alerts := map[string]models.APIAlertGroup{}
|
||||
colors := models.LabelsColorMap{}
|
||||
counters := map[string]map[string]int{}
|
||||
|
||||
dedupedAlerts := alertmanager.DedupAlerts()
|
||||
dedupedColors := alertmanager.DedupColors()
|
||||
@@ -170,6 +171,9 @@ func alerts(c *gin.Context) {
|
||||
alert.UpdateFingerprints()
|
||||
agCopy.Alerts = append(agCopy.Alerts, alert)
|
||||
|
||||
countLabel(counters, "@state", alert.State)
|
||||
|
||||
countLabel(counters, "@receiver", alert.Receiver)
|
||||
if ck, foundKey := dedupedColors["@receiver"]; foundKey {
|
||||
if cv, foundVal := ck[alert.Receiver]; foundVal {
|
||||
if _, found := colors["@receiver"]; !found {
|
||||
@@ -209,6 +213,7 @@ func alerts(c *gin.Context) {
|
||||
colors[key][value] = color
|
||||
}
|
||||
}
|
||||
countLabel(counters, key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,6 +253,7 @@ func alerts(c *gin.Context) {
|
||||
resp.AlertGroups = alerts
|
||||
resp.Silences = silences
|
||||
resp.Colors = colors
|
||||
resp.Counters = countersToLabelStats(counters)
|
||||
resp.Filters = populateAPIFilters(matchFilters)
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
|
||||
@@ -136,6 +136,9 @@ func TestAlerts(t *testing.T) {
|
||||
if ur.Status != "success" {
|
||||
t.Errorf("[%s] Invalid status in response: %s", version, ur.Status)
|
||||
}
|
||||
if len(ur.Counters) != 6 {
|
||||
t.Errorf("[%s] Invalid number of counters in response (%d): %v", version, len(ur.Counters), ur.Counters)
|
||||
}
|
||||
for _, ag := range ur.AlertGroups {
|
||||
for _, a := range ag.Alerts {
|
||||
linkCount := 0
|
||||
|
||||
Reference in New Issue
Block a user