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(
>