mirror of
https://github.com/prymitive/karma
synced 2026-05-07 03:26:52 +00:00
Merge pull request #814 from prymitive/top-labels-2
feat(ui): add a modal with labels overview
This commit is contained in:
42
alerts.go
42
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{}
|
||||
|
||||
|
||||
192
api_test.go
192
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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<MountedLabelWithPercent /> matches snapshot 1`] = `
|
||||
"
|
||||
<div class
|
||||
style=\\"display: inline-block; max-width: 100%;\\"
|
||||
data-tooltipped
|
||||
aria-describedby=\\"tippy-tooltip-1\\"
|
||||
data-original-title=\\"Click to only show alerts with this label or Alt+Click to hide them\\"
|
||||
>
|
||||
<span class=\\"components-label badge badge-warning components-label-dark components-label-with-hover mb-0 pl-0 text-left\\">
|
||||
<span class=\\"mr-1 px-1 bg-primary text-white components-labelWithPercent-percent\\">
|
||||
25
|
||||
</span>
|
||||
<span class=\\"components-label-name\\">
|
||||
foo:
|
||||
</span>
|
||||
<span class=\\"components-label-value\\">
|
||||
bar
|
||||
</span>
|
||||
</span>
|
||||
<div class=\\"progress silence-progress bg-white pr-1\\">
|
||||
<div class=\\"progress-bar bg-success\\"
|
||||
role=\\"progressbar\\"
|
||||
style=\\"width: 50%;\\"
|
||||
aria-valuenow=\\"50\\"
|
||||
aria-valuemin=\\"0\\"
|
||||
aria-valuemax=\\"100\\"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"
|
||||
`;
|
||||
69
ui/src/Components/Labels/LabelWithPercent/index.js
Normal file
69
ui/src/Components/Labels/LabelWithPercent/index.js
Normal file
@@ -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 (
|
||||
<TooltipWrapper title="Click to only show alerts with this label or Alt+Click to hide them">
|
||||
<span
|
||||
className={cs.className}
|
||||
style={cs.style}
|
||||
onClick={e => this.handleClick(e)}
|
||||
>
|
||||
<span className="mr-1 px-1 bg-primary text-white components-labelWithPercent-percent">
|
||||
{hits}
|
||||
</span>
|
||||
<span className="components-label-name">{name}:</span>{" "}
|
||||
<span className="components-label-value">{value}</span>
|
||||
</span>
|
||||
<div className="progress silence-progress bg-white pr-1">
|
||||
<div
|
||||
className={`progress-bar ${progressBarBg}`}
|
||||
role="progressbar"
|
||||
style={{ width: percent + "%" }}
|
||||
aria-valuenow={percent}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
/>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export { LabelWithPercent };
|
||||
4
ui/src/Components/Labels/LabelWithPercent/index.scss
Normal file
4
ui/src/Components/Labels/LabelWithPercent/index.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
.components-labelWithPercent-percent {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
63
ui/src/Components/Labels/LabelWithPercent/index.test.js
Normal file
63
ui/src/Components/Labels/LabelWithPercent/index.test.js
Normal file
@@ -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(
|
||||
<LabelWithPercent
|
||||
alertStore={alertStore}
|
||||
name={name}
|
||||
value={value}
|
||||
hits={25}
|
||||
percent={50}
|
||||
/>
|
||||
).find(".components-label");
|
||||
};
|
||||
|
||||
const RenderAndClick = (name, value, clickOptions) => {
|
||||
const tree = MountedLabelWithPercent(name, value);
|
||||
tree.find(".components-label").simulate("click", clickOptions || {});
|
||||
};
|
||||
|
||||
describe("<MountedLabelWithPercent />", () => {
|
||||
it("matches snapshot", () => {
|
||||
const tree = mount(
|
||||
<LabelWithPercent
|
||||
alertStore={alertStore}
|
||||
name="foo"
|
||||
value="bar"
|
||||
hits={25}
|
||||
percent={50}
|
||||
/>
|
||||
);
|
||||
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")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -45,10 +45,14 @@ const MainModal = observer(
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<li className="nav-item">
|
||||
<li
|
||||
className={`nav-item ${
|
||||
this.toggle.show ? "border-bottom border-info" : ""
|
||||
}`}
|
||||
>
|
||||
<TooltipWrapper title="Settings">
|
||||
<span
|
||||
className="nav-link cursor-pointer"
|
||||
className="nav-link components-navbar-button cursor-pointer"
|
||||
onClick={this.toggle.toggle}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCog} />
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.navbar-brand {
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
@@ -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(
|
||||
>
|
||||
<nav className="navbar fixed-top navbar-expand navbar-dark p-1 bg-primary-transparent d-inline-block">
|
||||
<ReactResizeDetector handleHeight onResize={this.onResize} />
|
||||
<span className="navbar-brand my-0 mx-2 h1 d-none d-sm-block float-left">
|
||||
<Flash spy={alertStore.info.totalAlerts}>
|
||||
<div className="d-inline-block">
|
||||
{alertStore.info.totalAlerts}
|
||||
</div>
|
||||
</Flash>
|
||||
<span className="navbar-brand p-0 my-0 mx-2 h1 d-none d-sm-block float-left">
|
||||
<OverviewModal alertStore={alertStore} />
|
||||
<FetchIndicator alertStore={alertStore} />
|
||||
</span>
|
||||
<ul className={`navbar-nav float-right d-flex ${flexClass}`}>
|
||||
|
||||
11
ui/src/Components/NavBar/index.scss
Normal file
11
ui/src/Components/NavBar/index.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
@import "~bootswatch/dist/flatly/variables";
|
||||
@import "~bootstrap/scss/bootstrap";
|
||||
@import "~bootswatch/dist/flatly/bootswatch";
|
||||
|
||||
.navbar-brand {
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
|
||||
.components-navbar-button:hover {
|
||||
border-bottom: $border-width solid $info;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ describe("<NavBar />", () => {
|
||||
it("navbar-brand shows 15 alerts with totalAlerts=15", () => {
|
||||
alertStore.info.totalAlerts = 15;
|
||||
const tree = MountedNavbar();
|
||||
const brand = tree.find(".navbar-brand");
|
||||
const brand = tree.find("span.navbar-brand");
|
||||
expect(brand.text()).toBe("15");
|
||||
});
|
||||
|
||||
|
||||
81
ui/src/Components/OverviewModal/OverviewModalContent.js
Normal file
81
ui/src/Components/OverviewModal/OverviewModalContent.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { Component } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { LabelWithPercent } from "Components/Labels/LabelWithPercent";
|
||||
|
||||
const LabelsTable = observer(({ alertStore }) => (
|
||||
<table
|
||||
className="table table-borderless top-labels"
|
||||
style={{ tableLayout: "fixed" }}
|
||||
>
|
||||
<tbody className="mw-100">
|
||||
{alertStore.data.counters.map(nameStats => (
|
||||
<tr key={nameStats.name}>
|
||||
<td width="25%" className="text-nowrap mw-100 p-1">
|
||||
<span className="badge badge-light components-label mx-0 my-1 pl-0 text-left">
|
||||
<span className="bg-primary text-white mr-1 px-1 components-labelWithPercent-percent">
|
||||
{nameStats.hits}
|
||||
</span>
|
||||
{nameStats.name}
|
||||
</span>
|
||||
</td>
|
||||
<td width="75%" className="mw-100 p-1">
|
||||
{nameStats.values.slice(0, 9).map(valueStats => (
|
||||
<LabelWithPercent
|
||||
key={valueStats.value}
|
||||
name={nameStats.name}
|
||||
value={valueStats.value}
|
||||
hits={valueStats.hits}
|
||||
percent={valueStats.percent}
|
||||
/>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
|
||||
const NothingToShow = () => (
|
||||
<div className="jumbotron bg-white">
|
||||
<h1 className="display-5 text-secondary text-center">
|
||||
No labels to display
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
|
||||
const OverviewModalContent = observer(
|
||||
class OverviewModalContent extends Component {
|
||||
static propTypes = {
|
||||
alertStore: PropTypes.instanceOf(AlertStore).isRequired,
|
||||
onHide: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
render() {
|
||||
const { alertStore, onHide } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Overview</h5>
|
||||
<button type="button" className="close" onClick={onHide}>
|
||||
<span className="align-middle">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{alertStore.data.counters.length === 0 ? (
|
||||
<NothingToShow />
|
||||
) : (
|
||||
<LabelsTable alertStore={alertStore} />
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { OverviewModalContent };
|
||||
66
ui/src/Components/OverviewModal/OverviewModalContent.test.js
Normal file
66
ui/src/Components/OverviewModal/OverviewModalContent.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
|
||||
import { Provider } from "mobx-react";
|
||||
|
||||
import { mount } from "enzyme";
|
||||
|
||||
import toDiffableHtml from "diffable-html";
|
||||
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { OverviewModalContent } from "./OverviewModalContent";
|
||||
|
||||
let alertStore;
|
||||
const onHide = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
alertStore = new AlertStore([]);
|
||||
onHide.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("<OverviewModalContent />", () => {
|
||||
it("matches snapshot with labels to show", () => {
|
||||
alertStore.data.counters = [
|
||||
{
|
||||
name: "foo",
|
||||
hits: 16,
|
||||
values: [
|
||||
{ value: "bar1", hits: 8, percent: 50 },
|
||||
{ value: "bar2", hits: 4, percent: 25 },
|
||||
{ value: "bar3", hits: 4, percent: 25 }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// we have multiple fragments and enzyme only renders the first one
|
||||
// in html() and text(), debug() would work but it's noisy
|
||||
// https://github.com/airbnb/enzyme/issues/1213
|
||||
const tree = mount(
|
||||
<span>
|
||||
<Provider alertStore={alertStore}>
|
||||
<OverviewModalContent alertStore={alertStore} onHide={onHide} />
|
||||
</Provider>
|
||||
</span>
|
||||
);
|
||||
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("matches snapshot with no labels to show", () => {
|
||||
alertStore.data.counters = [];
|
||||
|
||||
// we have multiple fragments and enzyme only renders the first one
|
||||
// in html() and text(), debug() would work but it's noisy
|
||||
// https://github.com/airbnb/enzyme/issues/1213
|
||||
const tree = mount(
|
||||
<span>
|
||||
<Provider alertStore={alertStore}>
|
||||
<OverviewModalContent alertStore={alertStore} onHide={onHide} />
|
||||
</Provider>
|
||||
</span>
|
||||
);
|
||||
expect(toDiffableHtml(tree.html())).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<OverviewModalContent /> matches snapshot with labels to show 1`] = `
|
||||
"
|
||||
<span>
|
||||
<div class=\\"modal-header\\">
|
||||
<h5 class=\\"modal-title\\">
|
||||
Overview
|
||||
</h5>
|
||||
<button type=\\"button\\"
|
||||
class=\\"close\\"
|
||||
>
|
||||
<span class=\\"align-middle\\">
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class=\\"modal-body\\">
|
||||
<table class=\\"table table-borderless top-labels\\"
|
||||
style=\\"table-layout: fixed;\\"
|
||||
>
|
||||
<tbody class=\\"mw-100\\">
|
||||
<tr>
|
||||
<td width=\\"25%\\"
|
||||
class=\\"text-nowrap mw-100 p-1\\"
|
||||
>
|
||||
<span class=\\"badge badge-light components-label mx-0 my-1 pl-0 text-left\\">
|
||||
<span class=\\"bg-primary text-white mr-1 px-1 components-labelWithPercent-percent\\">
|
||||
16
|
||||
</span>
|
||||
foo
|
||||
</span>
|
||||
</td>
|
||||
<td width=\\"75%\\"
|
||||
class=\\"mw-100 p-1\\"
|
||||
>
|
||||
<div class
|
||||
style=\\"display: inline-block; max-width: 100%;\\"
|
||||
data-tooltipped
|
||||
aria-describedby=\\"tippy-tooltip-1\\"
|
||||
data-original-title=\\"Click to only show alerts with this label or Alt+Click to hide them\\"
|
||||
>
|
||||
<span class=\\"components-label badge badge-warning components-label-dark components-label-with-hover mb-0 pl-0 text-left\\">
|
||||
<span class=\\"mr-1 px-1 bg-primary text-white components-labelWithPercent-percent\\">
|
||||
8
|
||||
</span>
|
||||
<span class=\\"components-label-name\\">
|
||||
foo:
|
||||
</span>
|
||||
<span class=\\"components-label-value\\">
|
||||
bar1
|
||||
</span>
|
||||
</span>
|
||||
<div class=\\"progress silence-progress bg-white pr-1\\">
|
||||
<div class=\\"progress-bar bg-success\\"
|
||||
role=\\"progressbar\\"
|
||||
style=\\"width: 50%;\\"
|
||||
aria-valuenow=\\"50\\"
|
||||
aria-valuemin=\\"0\\"
|
||||
aria-valuemax=\\"100\\"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class
|
||||
style=\\"display: inline-block; max-width: 100%;\\"
|
||||
data-tooltipped
|
||||
aria-describedby=\\"tippy-tooltip-2\\"
|
||||
data-original-title=\\"Click to only show alerts with this label or Alt+Click to hide them\\"
|
||||
>
|
||||
<span class=\\"components-label badge badge-warning components-label-dark components-label-with-hover mb-0 pl-0 text-left\\">
|
||||
<span class=\\"mr-1 px-1 bg-primary text-white components-labelWithPercent-percent\\">
|
||||
4
|
||||
</span>
|
||||
<span class=\\"components-label-name\\">
|
||||
foo:
|
||||
</span>
|
||||
<span class=\\"components-label-value\\">
|
||||
bar2
|
||||
</span>
|
||||
</span>
|
||||
<div class=\\"progress silence-progress bg-white pr-1\\">
|
||||
<div class=\\"progress-bar bg-success\\"
|
||||
role=\\"progressbar\\"
|
||||
style=\\"width: 25%;\\"
|
||||
aria-valuenow=\\"25\\"
|
||||
aria-valuemin=\\"0\\"
|
||||
aria-valuemax=\\"100\\"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class
|
||||
style=\\"display: inline-block; max-width: 100%;\\"
|
||||
data-tooltipped
|
||||
aria-describedby=\\"tippy-tooltip-3\\"
|
||||
data-original-title=\\"Click to only show alerts with this label or Alt+Click to hide them\\"
|
||||
>
|
||||
<span class=\\"components-label badge badge-warning components-label-dark components-label-with-hover mb-0 pl-0 text-left\\">
|
||||
<span class=\\"mr-1 px-1 bg-primary text-white components-labelWithPercent-percent\\">
|
||||
4
|
||||
</span>
|
||||
<span class=\\"components-label-name\\">
|
||||
foo:
|
||||
</span>
|
||||
<span class=\\"components-label-value\\">
|
||||
bar3
|
||||
</span>
|
||||
</span>
|
||||
<div class=\\"progress silence-progress bg-white pr-1\\">
|
||||
<div class=\\"progress-bar bg-success\\"
|
||||
role=\\"progressbar\\"
|
||||
style=\\"width: 25%;\\"
|
||||
aria-valuenow=\\"25\\"
|
||||
aria-valuemin=\\"0\\"
|
||||
aria-valuemax=\\"100\\"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</span>
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<OverviewModalContent /> matches snapshot with no labels to show 1`] = `
|
||||
"
|
||||
<span>
|
||||
<div class=\\"modal-header\\">
|
||||
<h5 class=\\"modal-title\\">
|
||||
Overview
|
||||
</h5>
|
||||
<button type=\\"button\\"
|
||||
class=\\"close\\"
|
||||
>
|
||||
<span class=\\"align-middle\\">
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class=\\"modal-body\\">
|
||||
<div class=\\"jumbotron bg-white\\">
|
||||
<h1 class=\\"display-5 text-secondary text-center\\">
|
||||
No labels to display
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
"
|
||||
`;
|
||||
84
ui/src/Components/OverviewModal/index.js
Normal file
84
ui/src/Components/OverviewModal/index.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { Component } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import { observer, Provider } from "mobx-react";
|
||||
import { observable, action } from "mobx";
|
||||
|
||||
import Flash from "react-reveal/Flash";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons/faSpinner";
|
||||
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { TooltipWrapper } from "Components/TooltipWrapper";
|
||||
import { Modal } from "Components/Modal";
|
||||
|
||||
import "./index.scss";
|
||||
|
||||
// https://github.com/facebook/react/issues/14603
|
||||
const OverviewModalContent = React.lazy(() =>
|
||||
import("./OverviewModalContent").then(module => ({
|
||||
default: module.OverviewModalContent
|
||||
}))
|
||||
);
|
||||
|
||||
const OverviewModal = observer(
|
||||
class OverviewModal extends Component {
|
||||
static propTypes = {
|
||||
alertStore: PropTypes.instanceOf(AlertStore).isRequired
|
||||
};
|
||||
|
||||
toggle = observable(
|
||||
{
|
||||
show: false,
|
||||
toggle() {
|
||||
this.show = !this.show;
|
||||
},
|
||||
hide() {
|
||||
this.show = false;
|
||||
}
|
||||
},
|
||||
{ toggle: action.bound, hide: action.bound }
|
||||
);
|
||||
|
||||
render() {
|
||||
const { alertStore } = this.props;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TooltipWrapper title="Show alert overview">
|
||||
<Flash spy={alertStore.info.totalAlerts}>
|
||||
<div
|
||||
className={`text-center d-inline-block cursor-pointer navbar-brand m-0 components-navbar-button ${
|
||||
this.toggle.show ? "border-bottom border-info" : ""
|
||||
}`}
|
||||
onClick={this.toggle.toggle}
|
||||
>
|
||||
{alertStore.info.totalAlerts}
|
||||
</div>
|
||||
</Flash>
|
||||
</TooltipWrapper>
|
||||
<Modal isOpen={this.toggle.show}>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<h1 className="display-1 text-secondary p-5 m-auto">
|
||||
<FontAwesomeIcon icon={faSpinner} size="lg" spin />
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<Provider alertStore={alertStore}>
|
||||
<OverviewModalContent
|
||||
alertStore={alertStore}
|
||||
onHide={this.toggle.hide}
|
||||
isVisible={this.toggle.show}
|
||||
/>
|
||||
</Provider>
|
||||
</React.Suspense>
|
||||
</Modal>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { OverviewModal };
|
||||
10
ui/src/Components/OverviewModal/index.scss
Normal file
10
ui/src/Components/OverviewModal/index.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
@import "~bootswatch/dist/flatly/variables";
|
||||
@import "~bootstrap/scss/bootstrap";
|
||||
@import "~bootswatch/dist/flatly/bootswatch";
|
||||
|
||||
.navbar-brand {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: $green !important;
|
||||
}
|
||||
}
|
||||
96
ui/src/Components/OverviewModal/index.test.js
Normal file
96
ui/src/Components/OverviewModal/index.test.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from "react";
|
||||
|
||||
import { mount } from "enzyme";
|
||||
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { OverviewModal } from ".";
|
||||
|
||||
let alertStore;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
alertStore = new AlertStore([]);
|
||||
});
|
||||
|
||||
const MountedOverviewModal = () => {
|
||||
return mount(<OverviewModal alertStore={alertStore} />);
|
||||
};
|
||||
|
||||
describe("<OverviewModal />", () => {
|
||||
it("only renders the counter when modal is not shown", () => {
|
||||
const tree = MountedOverviewModal();
|
||||
expect(tree.text()).toBe("0");
|
||||
expect(tree.find("OverviewModalContent")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders a spinner placeholder while modal content is loading", () => {
|
||||
const tree = MountedOverviewModal();
|
||||
const toggle = tree.find("div.navbar-brand");
|
||||
toggle.simulate("click");
|
||||
expect(tree.find("OverviewModalContent")).toHaveLength(0);
|
||||
expect(tree.find(".modal-content").find("svg.fa-spinner")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders modal content if fallback is not used", () => {
|
||||
const tree = MountedOverviewModal();
|
||||
const toggle = tree.find("div.navbar-brand");
|
||||
toggle.simulate("click");
|
||||
expect(tree.find("OverviewModalContent")).toHaveLength(1);
|
||||
expect(tree.find(".modal-content").find("svg.fa-spinner")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("hides the modal when toggle() is called twice", () => {
|
||||
const tree = MountedOverviewModal();
|
||||
const toggle = tree.find("div.navbar-brand");
|
||||
|
||||
toggle.simulate("click");
|
||||
jest.runOnlyPendingTimers();
|
||||
tree.update();
|
||||
expect(tree.find("OverviewModalContent")).toHaveLength(1);
|
||||
|
||||
toggle.simulate("click");
|
||||
jest.runOnlyPendingTimers();
|
||||
tree.update();
|
||||
expect(tree.find("OverviewModalContent")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("hides the modal when hide() is called", () => {
|
||||
const tree = MountedOverviewModal();
|
||||
const toggle = tree.find("div.navbar-brand");
|
||||
|
||||
toggle.simulate("click");
|
||||
expect(tree.find("OverviewModalContent")).toHaveLength(1);
|
||||
|
||||
const instance = tree.instance();
|
||||
instance.toggle.hide();
|
||||
jest.runOnlyPendingTimers();
|
||||
tree.update();
|
||||
expect(tree.find("OverviewModalContent")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("'modal-open' class is appended to body node when modal is visible", () => {
|
||||
const tree = MountedOverviewModal();
|
||||
const toggle = tree.find("div.navbar-brand");
|
||||
toggle.simulate("click");
|
||||
expect(document.body.className.split(" ")).toContain("modal-open");
|
||||
});
|
||||
|
||||
it("'modal-open' class is removed from body node after modal is hidden", () => {
|
||||
const tree = MountedOverviewModal();
|
||||
const toggle = tree.find("div.navbar-brand");
|
||||
toggle.simulate("click");
|
||||
toggle.simulate("click");
|
||||
expect(document.body.className.split(" ")).not.toContain("modal-open");
|
||||
});
|
||||
|
||||
it("'modal-open' class is removed from body node after modal is unmounted", () => {
|
||||
const tree = MountedOverviewModal();
|
||||
const toggle = tree.find("div.navbar-brand");
|
||||
toggle.simulate("click");
|
||||
tree.unmount();
|
||||
expect(document.body.className.split(" ")).not.toContain("modal-open");
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,11 @@ const SilenceModal = observer(
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<li className="nav-item">
|
||||
<li
|
||||
className={`nav-item components-navbar-button ${
|
||||
silenceFormStore.toggle.visible ? "border-bottom border-info" : ""
|
||||
}`}
|
||||
>
|
||||
<TooltipWrapper title="Add new silence">
|
||||
<span
|
||||
className="nav-link cursor-pointer"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
6
views.go
6
views.go
@@ -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