Merge pull request #814 from prymitive/top-labels-2

feat(ui): add a modal with labels overview
This commit is contained in:
Łukasz Mierzwa
2019-07-10 23:39:38 +01:00
committed by GitHub
26 changed files with 1162 additions and 19 deletions

View File

@@ -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{}

View File

@@ -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)
}

View File

@@ -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"

View File

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

View File

@@ -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"`
}

View File

@@ -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")
}
}

View File

@@ -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>
"
`;

View 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 };

View File

@@ -0,0 +1,4 @@
.components-labelWithPercent-percent {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}

View 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")
);
});
});

View File

@@ -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} />

View File

@@ -1,3 +0,0 @@
.navbar-brand {
min-width: 2.5rem;
}

View File

@@ -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}`}>

View 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;
}

View File

@@ -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");
});

View 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">&times;</span>
</button>
</div>
<div className="modal-body">
{alertStore.data.counters.length === 0 ? (
<NothingToShow />
) : (
<LabelsTable alertStore={alertStore} />
)}
</div>
</React.Fragment>
);
}
}
);
export { OverviewModalContent };

View 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();
});
});

View File

@@ -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>
"
`;

View 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 };

View 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;
}
}

View 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");
});
});

View File

@@ -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"

View File

@@ -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];
}

View File

@@ -22,6 +22,7 @@ const EmptyAPIResponse = () => ({
isValid: true
}
],
counters: {},
settings: {
sorting: {
grid: {

View File

@@ -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)

View File

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