diff --git a/alerts.go b/alerts.go index 5d06bd338..0b50f20be 100644 --- a/alerts.go +++ b/alerts.go @@ -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{} diff --git a/api_test.go b/api_test.go index a94bc076c..fc94dd7ad 100644 --- a/api_test.go +++ b/api_test.go @@ -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) } diff --git a/demo/generator.py b/demo/generator.py index 370e7ca6d..2628cb1dd 100755 --- a/demo/generator.py +++ b/demo/generator.py @@ -321,7 +321,8 @@ class LongNameAlerts(AlertGenerator): return [newAlert( self._labels(instance="server{}".format(i), cluster=cluster, severity="info", job="textfile_exporter", - region="CN"), + region="CN", + thisIsAVeryLongLabelNameToTestLabelTruncationInAllThePlacesWeRenderItLoremIpsumDolorSitAmet="1"), self._annotations( verylong="Lorem ipsum dolor sit amet, consectetur " "adipiscing elit, sed do eiusmod tempor incididunt" diff --git a/demo/karma.yaml b/demo/karma.yaml index 1f69348a7..2edca4575 100644 --- a/demo/karma.yaml +++ b/demo/karma.yaml @@ -53,7 +53,7 @@ labels: color: "#ff220c" log: config: false - level: warning + level: debug sentry: private: https://84a9ef37a6ed4fdb80e9ea2310d1ed26:8c6ee6f0ab02406482ff4b4e824e2c27@sentry.io/1279017 public: https://84a9ef37a6ed4fdb80e9ea2310d1ed26@sentry.io/1279017 diff --git a/internal/models/api.go b/internal/models/api.go index 41999007d..9284d3349 100644 --- a/internal/models/api.go +++ b/internal/models/api.go @@ -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"` } diff --git a/internal/models/api_test.go b/internal/models/api_test.go index 8e1797798..557ab9c9a 100644 --- a/internal/models/api_test.go +++ b/internal/models/api_test.go @@ -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") + } +} diff --git a/ui/src/Components/Labels/LabelWithPercent/__snapshots__/index.test.js.snap b/ui/src/Components/Labels/LabelWithPercent/__snapshots__/index.test.js.snap new file mode 100644 index 000000000..d87242fe6 --- /dev/null +++ b/ui/src/Components/Labels/LabelWithPercent/__snapshots__/index.test.js.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` matches snapshot 1`] = ` +" +
+ + + 25 + + + foo: + + + bar + + +
+
+
+
+
+" +`; diff --git a/ui/src/Components/Labels/LabelWithPercent/index.js b/ui/src/Components/Labels/LabelWithPercent/index.js new file mode 100644 index 000000000..c4f135e95 --- /dev/null +++ b/ui/src/Components/Labels/LabelWithPercent/index.js @@ -0,0 +1,69 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import { inject, observer } from "mobx-react"; + +import { AlertStore } from "Stores/AlertStore"; +import { TooltipWrapper } from "Components/TooltipWrapper"; +import { BaseLabel } from "Components/Labels/BaseLabel"; + +import "./index.scss"; + +const LabelWithPercent = inject("alertStore")( + observer( + class FilteringLabel extends BaseLabel { + static propTypes = { + alertStore: PropTypes.instanceOf(AlertStore).isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + hits: PropTypes.number.isRequired, + percent: PropTypes.number.isRequired + }; + + render() { + const { name, value, hits, percent } = this.props; + + let cs = this.getClassAndStyle( + name, + value, + "components-label-with-hover mb-0 pl-0 text-left" + ); + + const progressBarBg = + percent > 66 + ? "bg-danger" + : percent > 66 + ? "bg-warning" + : "bg-success"; + + return ( + + this.handleClick(e)} + > + + {hits} + + {name}:{" "} + {value} + +
+
+
+ + ); + } + } + ) +); + +export { LabelWithPercent }; diff --git a/ui/src/Components/Labels/LabelWithPercent/index.scss b/ui/src/Components/Labels/LabelWithPercent/index.scss new file mode 100644 index 000000000..c6589aa4d --- /dev/null +++ b/ui/src/Components/Labels/LabelWithPercent/index.scss @@ -0,0 +1,4 @@ +.components-labelWithPercent-percent { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} diff --git a/ui/src/Components/Labels/LabelWithPercent/index.test.js b/ui/src/Components/Labels/LabelWithPercent/index.test.js new file mode 100644 index 000000000..8d2a8e18f --- /dev/null +++ b/ui/src/Components/Labels/LabelWithPercent/index.test.js @@ -0,0 +1,63 @@ +import React from "react"; + +import { mount } from "enzyme"; + +import toDiffableHtml from "diffable-html"; + +import { AlertStore, NewUnappliedFilter } from "Stores/AlertStore"; + +import { LabelWithPercent } from "."; + +let alertStore; + +beforeEach(() => { + alertStore = new AlertStore([]); +}); + +const MountedLabelWithPercent = (name, value) => { + return mount( + + ).find(".components-label"); +}; + +const RenderAndClick = (name, value, clickOptions) => { + const tree = MountedLabelWithPercent(name, value); + tree.find(".components-label").simulate("click", clickOptions || {}); +}; + +describe("", () => { + it("matches snapshot", () => { + const tree = mount( + + ); + expect(toDiffableHtml(tree.html())).toMatchSnapshot(); + }); + + it("calling onClick() adds a new filter 'foo=bar'", () => { + RenderAndClick("foo", "bar"); + expect(alertStore.filters.values).toHaveLength(1); + expect(alertStore.filters.values).toContainEqual( + NewUnappliedFilter("foo=bar") + ); + }); + + it("calling onClick() while holding Alt key adds a new filter 'foo!=bar'", () => { + RenderAndClick("foo", "bar", { altKey: true }); + expect(alertStore.filters.values).toHaveLength(1); + expect(alertStore.filters.values).toContainEqual( + NewUnappliedFilter("foo!=bar") + ); + }); +}); diff --git a/ui/src/Components/MainModal/index.js b/ui/src/Components/MainModal/index.js index bba66f8c2..4a4118bd2 100644 --- a/ui/src/Components/MainModal/index.js +++ b/ui/src/Components/MainModal/index.js @@ -45,10 +45,14 @@ const MainModal = observer( return ( -
  • +
  • diff --git a/ui/src/Components/NavBar/index.css b/ui/src/Components/NavBar/index.css deleted file mode 100644 index fcce3cd3b..000000000 --- a/ui/src/Components/NavBar/index.css +++ /dev/null @@ -1,3 +0,0 @@ -.navbar-brand { - min-width: 2.5rem; -} diff --git a/ui/src/Components/NavBar/index.js b/ui/src/Components/NavBar/index.js index 5d7a05a20..83e8a03e1 100644 --- a/ui/src/Components/NavBar/index.js +++ b/ui/src/Components/NavBar/index.js @@ -8,19 +8,18 @@ import ReactResizeDetector from "react-resize-detector"; import IdleTimer from "react-idle-timer"; -import Flash from "react-reveal/Flash"; - import { AlertStore } from "Stores/AlertStore"; import { Settings } from "Stores/Settings"; import { SilenceFormStore } from "Stores/SilenceFormStore"; import { IsMobile } from "Common/Device"; import { NavBarSlide } from "Components/Animations/NavBarSlide"; +import { OverviewModal } from "Components/OverviewModal"; import { MainModal } from "Components/MainModal"; import { SilenceModal } from "Components/SilenceModal"; import { FetchIndicator } from "./FetchIndicator"; import { FilterInput } from "./FilterInput"; -import "./index.css"; +import "./index.scss"; const DesktopIdleTimeout = 1000 * 60 * 3; const MobileIdleTimeout = 1000 * 12; @@ -141,12 +140,8 @@ const NavBar = observer( >