mirror of
https://github.com/prymitive/karma
synced 2026-08-28 11:17:28 +00:00
Merge pull request #279 from prymitive/ha2
fix(ui): improve support for HA clusters
This commit is contained in:
@@ -8,7 +8,6 @@ linters:
|
||||
- golint
|
||||
- dupl
|
||||
- goconst
|
||||
- gocyclo
|
||||
|
||||
linters-settings:
|
||||
govet:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/prymitive/karma/internal/alertmanager"
|
||||
"github.com/prymitive/karma/internal/filters"
|
||||
"github.com/prymitive/karma/internal/models"
|
||||
"github.com/prymitive/karma/internal/slices"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func getFiltersFromQuery(filterStrings []string) ([]filters.FilterT, bool) {
|
||||
@@ -40,8 +40,11 @@ func getUpstreams() models.AlertmanagerAPISummary {
|
||||
upstreams := alertmanager.GetAlertmanagers()
|
||||
for _, upstream := range upstreams {
|
||||
members := upstream.ClusterMemberNames()
|
||||
sort.Strings(members)
|
||||
key := strings.Join(members[:], "\n")
|
||||
key, err := slices.StringSliceToSHA1(members)
|
||||
if err != nil {
|
||||
log.Errorf("slices.StringSliceToSHA1 error: %s", err)
|
||||
continue
|
||||
}
|
||||
if _, found := clusters[key]; !found {
|
||||
clusters[key] = members
|
||||
}
|
||||
@@ -52,6 +55,7 @@ func getUpstreams() models.AlertmanagerAPISummary {
|
||||
PublicURI: upstream.PublicURI(),
|
||||
Error: upstream.Error(),
|
||||
Version: upstream.Version(),
|
||||
Cluster: upstream.ClusterID(),
|
||||
ClusterMembers: members,
|
||||
}
|
||||
summary.Instances = append(summary.Instances, u)
|
||||
@@ -63,10 +67,7 @@ func getUpstreams() models.AlertmanagerAPISummary {
|
||||
summary.Counters.Failed++
|
||||
}
|
||||
}
|
||||
|
||||
for _, cluster := range clusters {
|
||||
summary.Clusters = append(summary.Clusters, cluster)
|
||||
}
|
||||
summary.Clusters = clusters
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
+2
-2
@@ -867,9 +867,9 @@ func TestVerifyAllGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
am, foundAM := ur.Silences["default"]
|
||||
am, foundAM := ur.Silences["843c4a11660fe38ea61e6960a29d4f4796da6488"]
|
||||
if !foundAM {
|
||||
t.Errorf("[%s] Alertmanager 'default' missing from silences", version)
|
||||
t.Errorf("[%s] Alertmanager cluster '843c4a11660fe38ea61e6960a29d4f4796da6488' (default) missing from silences", version)
|
||||
} else if len(am) == 0 {
|
||||
t.Errorf("[%s] Silences mismatch, expected >0 but got %d", version, len(am))
|
||||
}
|
||||
|
||||
@@ -284,6 +284,7 @@ func (am *Alertmanager) pullAlerts(version string) error {
|
||||
alert.Alertmanager = []models.AlertmanagerInstance{
|
||||
models.AlertmanagerInstance{
|
||||
Name: am.Name,
|
||||
Cluster: am.ClusterID(),
|
||||
State: alert.State,
|
||||
StartsAt: alert.StartsAt,
|
||||
EndsAt: alert.EndsAt,
|
||||
@@ -500,5 +501,18 @@ func (am *Alertmanager) ClusterMemberNames() []string {
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(members)
|
||||
return members
|
||||
}
|
||||
|
||||
// ClusterID returns the ID (sha1) of the cluster this Alertmanager instance
|
||||
// belongs to
|
||||
func (am *Alertmanager) ClusterID() string {
|
||||
members := am.ClusterMemberNames()
|
||||
id, err := slices.StringSliceToSHA1(members)
|
||||
if err != nil {
|
||||
log.Errorf("slices.StringSliceToSHA1 error: %s", err)
|
||||
return am.Name
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import "time"
|
||||
// AlertmanagerInstance describes the Alertmanager instance alert was collected
|
||||
// from
|
||||
type AlertmanagerInstance struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Cluster string `json:"cluster"`
|
||||
// per instance alert state
|
||||
State string `json:"state"`
|
||||
// timestamp collected from this instance, those on the alert itself
|
||||
@@ -32,6 +33,7 @@ type AlertmanagerAPIStatus struct {
|
||||
PublicURI string `json:"publicURI"`
|
||||
Error string `json:"error"`
|
||||
Version string `json:"version"`
|
||||
Cluster string `json:"cluster"`
|
||||
ClusterMembers []string `json:"clusterMembers"`
|
||||
}
|
||||
|
||||
@@ -47,5 +49,5 @@ type AlertmanagerAPICounters struct {
|
||||
type AlertmanagerAPISummary struct {
|
||||
Counters AlertmanagerAPICounters `json:"counters"`
|
||||
Instances []AlertmanagerAPIStatus `json:"instances"`
|
||||
Clusters [][]string `json:"clusters"`
|
||||
Clusters map[string][]string `json:"clusters"`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package slices
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// BoolInSlice returns true if given bool is found in a slice of bools
|
||||
func BoolInSlice(boolArray []bool, value bool) bool {
|
||||
for _, s := range boolArray {
|
||||
@@ -19,3 +24,19 @@ func StringInSlice(stringArray []string, value string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StringSliceToSHA1 returns a SHA1 hash computed from a slice of strings
|
||||
func StringSliceToSHA1(stringArray []string) (string, error) {
|
||||
h := sha1.New()
|
||||
for _, s := range stringArray {
|
||||
_, err := h.Write([]byte(s))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = h.Write([]byte("\n"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
@@ -111,3 +111,22 @@ func TestBoolInSlice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringSliceToSHA1(t *testing.T) {
|
||||
s, err := slices.StringSliceToSHA1([]string{"a", "b", "c"})
|
||||
if err != nil {
|
||||
t.Errorf("StringSliceToSHA1() returned error: %s", err)
|
||||
}
|
||||
if s == "" {
|
||||
t.Errorf("StringSliceToSHA1() returned empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStringSliceToSHA1(b *testing.B) {
|
||||
for _, stringSliceTest := range stringSliceTests {
|
||||
_, err := slices.StringSliceToSHA1(stringSliceTest.array)
|
||||
if err != nil {
|
||||
b.Errorf("StringSliceToSHA1() returned error: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,18 +45,35 @@ const Alert = observer(
|
||||
BorderClassMap[alert.state] || "border-warning"
|
||||
];
|
||||
|
||||
let silences = {};
|
||||
for (let am of alert.alertmanager) {
|
||||
if (!silences[am.cluster]) {
|
||||
silences[am.cluster] = {
|
||||
alertmanager: am,
|
||||
silences: []
|
||||
};
|
||||
}
|
||||
for (let silenceID of am.silencedBy) {
|
||||
if (!silences[am.cluster].silences.includes(silenceID)) {
|
||||
silences[am.cluster].silences.push(silenceID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={classNames.join(" ")}>
|
||||
<div>
|
||||
{alert.annotations.filter(a => a.isLink === false).map(a => (
|
||||
<RenderNonLinkAnnotation
|
||||
key={a.name}
|
||||
name={a.name}
|
||||
value={a.value}
|
||||
visible={a.visible}
|
||||
afterUpdate={afterUpdate}
|
||||
/>
|
||||
))}
|
||||
{alert.annotations
|
||||
.filter(a => a.isLink === false)
|
||||
.map(a => (
|
||||
<RenderNonLinkAnnotation
|
||||
key={a.name}
|
||||
name={a.name}
|
||||
value={a.value}
|
||||
visible={a.visible}
|
||||
afterUpdate={afterUpdate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<AlertMenu
|
||||
group={group}
|
||||
@@ -81,15 +98,21 @@ const Alert = observer(
|
||||
value={alert.receiver}
|
||||
/>
|
||||
) : null}
|
||||
{alert.annotations.filter(a => a.isLink === true).map(a => (
|
||||
<RenderLinkAnnotation key={a.name} name={a.name} value={a.value} />
|
||||
))}
|
||||
{alert.alertmanager.map(am =>
|
||||
am.silencedBy.map(silenceID => (
|
||||
{alert.annotations
|
||||
.filter(a => a.isLink === true)
|
||||
.map(a => (
|
||||
<RenderLinkAnnotation
|
||||
key={a.name}
|
||||
name={a.name}
|
||||
value={a.value}
|
||||
/>
|
||||
))}
|
||||
{Object.values(silences).map(clusterSilences =>
|
||||
clusterSilences.silences.map(silenceID => (
|
||||
<Silence
|
||||
key={silenceID}
|
||||
silenceFormStore={silenceFormStore}
|
||||
alertmanagerState={am}
|
||||
alertmanagerState={clusterSilences.alertmanager}
|
||||
silenceID={silenceID}
|
||||
afterUpdate={afterUpdate}
|
||||
/>
|
||||
|
||||
@@ -96,6 +96,35 @@ describe("<Alert />", () => {
|
||||
expect(silence.html()).toMatch(/silence123456789/);
|
||||
});
|
||||
|
||||
it("renders only one silence for HA cluster", () => {
|
||||
const alert = MockedAlert();
|
||||
alert.alertmanager = [
|
||||
{
|
||||
name: "am1",
|
||||
cluster: "ha",
|
||||
state: "suppressed",
|
||||
startsAt: "2018-08-14T17:36:40.017867056Z",
|
||||
endsAt: "0001-01-01T00:00:00Z",
|
||||
source: "localhost/am1",
|
||||
silencedBy: ["silence123456789"]
|
||||
},
|
||||
{
|
||||
name: "am2",
|
||||
cluster: "ha",
|
||||
state: "suppressed",
|
||||
startsAt: "2018-08-14T17:36:40.017867056Z",
|
||||
endsAt: "0001-01-01T00:00:00Z",
|
||||
source: "localhost/am2",
|
||||
silencedBy: ["silence123456789"]
|
||||
}
|
||||
];
|
||||
const group = MockAlertGroup({}, [alert], [], {});
|
||||
const tree = MountedAlert(alert, group, false, false);
|
||||
const silence = tree.find("Silence");
|
||||
expect(silence).toHaveLength(1);
|
||||
expect(silence.html()).toMatch(/silence123456789/);
|
||||
});
|
||||
|
||||
it("uses BorderClassMap.active when @state=active", () => {
|
||||
const alert = MockedAlert();
|
||||
alert.state = "active";
|
||||
|
||||
@@ -249,7 +249,7 @@ const Silence = inject("alertStore")(
|
||||
// and we need to lookup the actual silence data in the store.
|
||||
// Data might be missing from the store so first check if we have
|
||||
// anything for this alertmanager instance
|
||||
const amSilences = alertStore.data.silences[alertmanagerState.name];
|
||||
const amSilences = alertStore.data.silences[alertmanagerState.cluster];
|
||||
if (!amSilences) return null;
|
||||
|
||||
// next check if alertmanager has our silence ID
|
||||
|
||||
@@ -17,6 +17,7 @@ const mockAfterUpdate = jest.fn();
|
||||
|
||||
const alertmanager = {
|
||||
name: "default",
|
||||
cluster: "default",
|
||||
state: "suppressed",
|
||||
startsAt: "2000-01-01T10:00:00Z",
|
||||
endsAt: "0001-01-01T00:00:00Z",
|
||||
@@ -62,6 +63,7 @@ beforeEach(() => {
|
||||
instances: [
|
||||
{
|
||||
name: "default",
|
||||
cluster: "default",
|
||||
uri: "file:///mock",
|
||||
publicURI: "http://example.com",
|
||||
error: "",
|
||||
@@ -69,7 +71,7 @@ beforeEach(() => {
|
||||
clusterMembers: ["default"]
|
||||
}
|
||||
],
|
||||
clusters: [["default"]]
|
||||
clusters: { default: ["default"] }
|
||||
};
|
||||
alertStore.data.silences = {
|
||||
default: {
|
||||
@@ -187,6 +189,7 @@ describe("<Silence />", () => {
|
||||
const am = instance.getAlertmanager();
|
||||
expect(am).toEqual({
|
||||
name: "default",
|
||||
cluster: "default",
|
||||
uri: "file:///mock",
|
||||
publicURI: "http://example.com",
|
||||
error: "",
|
||||
|
||||
@@ -102,7 +102,7 @@ const AlertGrid = observer(
|
||||
key={id}
|
||||
group={alertStore.data.groups[id]}
|
||||
showAlertmanagers={
|
||||
alertStore.data.upstreams.clusters.length > 1
|
||||
Object.keys(alertStore.data.upstreams.clusters).length > 1
|
||||
}
|
||||
afterUpdate={this.masonryRepack}
|
||||
settingsStore={settingsStore}
|
||||
|
||||
@@ -59,7 +59,7 @@ const MockGroupList = count => {
|
||||
alertStore.data.upstreams = {
|
||||
counters: { total: 0, healthy: 1, failed: 0 },
|
||||
instances: [{ name: "am", uri: "http://am", error: "" }],
|
||||
clusters: [["am"]]
|
||||
clusters: { am: ["am"] }
|
||||
};
|
||||
alertStore.data.groups = groups;
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("<Grid />", () => {
|
||||
alertStore.data.upstreams = {
|
||||
counters: { total: 1, healthy: 0, failed: 1 },
|
||||
instances: [{ name: "am1", uri: "http://am1", error: "error" }],
|
||||
clusters: [["am1"]]
|
||||
clusters: { am1: ["am1"] }
|
||||
};
|
||||
const tree = ShallowGrid();
|
||||
expect(tree.text()).toBe("<FatalError />");
|
||||
@@ -47,7 +47,7 @@ describe("<Grid />", () => {
|
||||
alertStore.data.upstreams = {
|
||||
counters: { total: 1, healthy: 0, failed: 1 },
|
||||
instances: [{ name: "am1", uri: "http://am1", error: "" }],
|
||||
clusters: [["am1"]]
|
||||
clusters: { am1: ["am1"] }
|
||||
};
|
||||
const tree = ShallowGrid();
|
||||
expect(tree.text()).toBe("<AlertGrid />");
|
||||
@@ -61,7 +61,7 @@ describe("<Grid />", () => {
|
||||
{ name: "am2", uri: "file:///mock", error: "" },
|
||||
{ name: "am3", uri: "http://am1", error: "error 2" }
|
||||
],
|
||||
clusters: [["am1"], ["am2"], ["am3"]]
|
||||
clusters: { am1: ["am1"], am2: ["am2"], am3: ["am3"] }
|
||||
};
|
||||
const tree = ShallowGrid();
|
||||
expect(tree.text()).toBe("<UpstreamError /><UpstreamError /><AlertGrid />");
|
||||
@@ -72,7 +72,7 @@ describe("<Grid />", () => {
|
||||
alertStore.data.upstreams = {
|
||||
counters: { total: 0, healthy: 0, failed: 1 },
|
||||
instances: [{ name: "am", uri: "http://am1", error: "error" }],
|
||||
clusters: [["am"]]
|
||||
clusters: { am1: ["am1"] }
|
||||
};
|
||||
const tree = ShallowGrid();
|
||||
expect(tree.text()).toBe("<FatalError />");
|
||||
|
||||
@@ -7,24 +7,7 @@ exports[`<AlertManagerInput /> matches snapshot 1`] = `
|
||||
<div class=\\"css-10war8y\\">
|
||||
<div class=\\"css-1y5uxcf\\">
|
||||
<div class=\\"css-yagan3\\">
|
||||
am1
|
||||
</div>
|
||||
<div class=\\"css-n82uvk\\">
|
||||
<svg height=\\"14\\"
|
||||
width=\\"14\\"
|
||||
viewbox=\\"0 0 20 20\\"
|
||||
aria-hidden=\\"true\\"
|
||||
focusable=\\"false\\"
|
||||
class=\\"css-19bqh2r\\"
|
||||
>
|
||||
<path d=\\"M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z\\">
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"css-1y5uxcf\\">
|
||||
<div class=\\"css-yagan3\\">
|
||||
am2
|
||||
am1 | am2
|
||||
</div>
|
||||
<div class=\\"css-n82uvk\\">
|
||||
<svg height=\\"14\\"
|
||||
|
||||
@@ -11,10 +11,10 @@ import { SilenceFormStore } from "Stores/SilenceFormStore";
|
||||
import { MultiSelect, ReactSelectStyles } from "Components/MultiSelect";
|
||||
import { ValidationError } from "Components/MultiSelect/ValidationError";
|
||||
|
||||
const AlertmanagerInstancesToOptions = instances =>
|
||||
instances.map(i => ({
|
||||
label: i.name,
|
||||
value: i.publicURI
|
||||
const AlertmanagerClustersToOption = clusterDict =>
|
||||
Object.entries(clusterDict).map(([clusterID, clusterMembers]) => ({
|
||||
label: clusterMembers.join(" | "),
|
||||
value: clusterMembers
|
||||
}));
|
||||
|
||||
const AlertManagerInput = observer(
|
||||
@@ -30,8 +30,8 @@ const AlertManagerInput = observer(
|
||||
const { alertStore, silenceFormStore } = props;
|
||||
|
||||
if (silenceFormStore.data.alertmanagers.length === 0) {
|
||||
silenceFormStore.data.alertmanagers = AlertmanagerInstancesToOptions(
|
||||
alertStore.data.upstreams.instances
|
||||
silenceFormStore.data.alertmanagers = AlertmanagerClustersToOption(
|
||||
alertStore.data.upstreams.clusters
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -46,23 +46,17 @@ const AlertManagerInput = observer(
|
||||
const { alertStore, silenceFormStore } = this.props;
|
||||
|
||||
// get the list of last known alertmanagers
|
||||
const currentAlertmanagers = AlertmanagerInstancesToOptions(
|
||||
alertStore.data.upstreams.instances
|
||||
const currentAlertmanagers = AlertmanagerClustersToOption(
|
||||
alertStore.data.upstreams.clusters
|
||||
);
|
||||
|
||||
// now iterate what's set as silence form values and reset it if any
|
||||
// mismatch is detected (uri changed for example)
|
||||
// mismatch is detected
|
||||
for (const silenceAM of silenceFormStore.data.alertmanagers) {
|
||||
for (const currentAM of currentAlertmanagers) {
|
||||
if (
|
||||
silenceAM.label === currentAM.label &&
|
||||
silenceAM.value !== currentAM.value
|
||||
) {
|
||||
silenceFormStore.data.alertmanagers = AlertmanagerInstancesToOptions(
|
||||
alertStore.data.upstreams.instances
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!currentAlertmanagers.map(am => am.label).includes(silenceAM.label)
|
||||
) {
|
||||
silenceFormStore.data.alertmanagers = currentAlertmanagers;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,8 +74,8 @@ const AlertManagerInput = observer(
|
||||
styles={ReactSelectStyles}
|
||||
instanceId="silence-input-alertmanagers"
|
||||
defaultValue={silenceFormStore.data.alertmanagers}
|
||||
options={AlertmanagerInstancesToOptions(
|
||||
alertStore.data.upstreams.instances
|
||||
options={AlertmanagerClustersToOption(
|
||||
alertStore.data.upstreams.clusters
|
||||
)}
|
||||
placeholder={
|
||||
silenceFormStore.data.wasValidated ? (
|
||||
|
||||
@@ -11,13 +11,12 @@ import { AlertManagerInput } from ".";
|
||||
let alertStore;
|
||||
let silenceFormStore;
|
||||
|
||||
const AlertmanagerOption = index => ({
|
||||
label: `am${index}`,
|
||||
value: `http://am${index}.example.com`
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
alertStore = new AlertStore([]);
|
||||
alertStore.data.upstreams.clusters = {
|
||||
ha: ["am1", "am2"],
|
||||
am3: ["am3"]
|
||||
};
|
||||
alertStore.data.upstreams.instances = [
|
||||
{
|
||||
name: "am1",
|
||||
@@ -25,7 +24,8 @@ beforeEach(() => {
|
||||
publicURI: "http://am1.example.com",
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
clusterMembers: ["am1"]
|
||||
cluster: "ha",
|
||||
clusterMembers: ["am1", "am2"]
|
||||
},
|
||||
{
|
||||
name: "am2",
|
||||
@@ -33,7 +33,8 @@ beforeEach(() => {
|
||||
publicURI: "http://am2.example.com",
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
clusterMembers: ["am2"]
|
||||
cluster: "ha",
|
||||
clusterMembers: ["am1", "am2"]
|
||||
},
|
||||
{
|
||||
name: "am3",
|
||||
@@ -41,6 +42,7 @@ beforeEach(() => {
|
||||
publicURI: "http://am3.example.com",
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
cluster: "am3",
|
||||
clusterMembers: ["am3"]
|
||||
}
|
||||
];
|
||||
@@ -100,60 +102,62 @@ describe("<AlertManagerInput />", () => {
|
||||
|
||||
it("all available Alertmanager instances are selected by default", () => {
|
||||
ShallowAlertManagerInput();
|
||||
expect(silenceFormStore.data.alertmanagers).toHaveLength(3);
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
expect(silenceFormStore.data.alertmanagers).toContainEqual(
|
||||
AlertmanagerOption(i)
|
||||
);
|
||||
}
|
||||
expect(silenceFormStore.data.alertmanagers).toHaveLength(2);
|
||||
expect(silenceFormStore.data.alertmanagers).toContainEqual({
|
||||
label: "am1 | am2",
|
||||
value: ["am1", "am2"]
|
||||
});
|
||||
expect(silenceFormStore.data.alertmanagers).toContainEqual({
|
||||
label: "am3",
|
||||
value: ["am3"]
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't override last selected Alertmanager instances on mount", () => {
|
||||
silenceFormStore.data.alertmanagers = [AlertmanagerOption(1)];
|
||||
silenceFormStore.data.alertmanagers = [{ label: "am3", value: ["am3"] }];
|
||||
ShallowAlertManagerInput();
|
||||
expect(silenceFormStore.data.alertmanagers).toHaveLength(1);
|
||||
expect(silenceFormStore.data.alertmanagers).toContainEqual(
|
||||
AlertmanagerOption(1)
|
||||
);
|
||||
expect(silenceFormStore.data.alertmanagers).toContainEqual({
|
||||
label: "am3",
|
||||
value: ["am3"]
|
||||
});
|
||||
});
|
||||
|
||||
it("renders all 3 suggestions", () => {
|
||||
const tree = ValidateSuggestions();
|
||||
const options = tree.find("[role='option']");
|
||||
expect(options).toHaveLength(3);
|
||||
expect(options.at(0).text()).toBe("am1");
|
||||
expect(options.at(1).text()).toBe("am2");
|
||||
expect(options.at(2).text()).toBe("am3");
|
||||
expect(options).toHaveLength(2);
|
||||
expect(options.at(0).text()).toBe("am1 | am2");
|
||||
expect(options.at(1).text()).toBe("am3");
|
||||
});
|
||||
|
||||
it("clicking on options appends them to silenceFormStore.data.alertmanagers", () => {
|
||||
silenceFormStore.data.alertmanagers = [];
|
||||
const tree = ValidateSuggestions();
|
||||
const options = tree.find("[role='option']");
|
||||
options.at(0).simulate("click");
|
||||
options.at(2).simulate("click");
|
||||
options.at(1).simulate("click");
|
||||
expect(silenceFormStore.data.alertmanagers).toHaveLength(2);
|
||||
expect(silenceFormStore.data.alertmanagers).toContainEqual(
|
||||
AlertmanagerOption(1)
|
||||
);
|
||||
expect(silenceFormStore.data.alertmanagers).toContainEqual(
|
||||
AlertmanagerOption(3)
|
||||
);
|
||||
expect(silenceFormStore.data.alertmanagers).toContainEqual({
|
||||
label: "am1 | am2",
|
||||
value: ["am1", "am2"]
|
||||
});
|
||||
expect(silenceFormStore.data.alertmanagers).toContainEqual({
|
||||
label: "am3",
|
||||
value: ["am3"]
|
||||
});
|
||||
});
|
||||
|
||||
it("silenceFormStore.data.alertmanagers gets updated from alertStore.data.upstreams.instances on mismatch", () => {
|
||||
const tree = ShallowAlertManagerInput();
|
||||
alertStore.data.upstreams.instances[0] = {
|
||||
name: "am1",
|
||||
publicURI: "http://am1.example.com/new",
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
clusterMembers: ["am1"]
|
||||
alertStore.data.upstreams.clusters = {
|
||||
amNew: ["amNew"]
|
||||
};
|
||||
// force update since this is where the mismatch check lives
|
||||
tree.instance().componentDidUpdate();
|
||||
expect(silenceFormStore.data.alertmanagers).toContainEqual({
|
||||
label: "am1",
|
||||
value: "http://am1.example.com/new"
|
||||
label: "amNew",
|
||||
value: ["amNew"]
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,19 +16,12 @@ const MatcherToFilter = matcher => {
|
||||
};
|
||||
|
||||
const AlertManagersToFilter = alertmanagers => {
|
||||
if (alertmanagers.length > 1) {
|
||||
return FormatQuery(
|
||||
StaticLabels.AlertManager,
|
||||
QueryOperators.Regex,
|
||||
`^(${alertmanagers.map(am => am.label).join("|")})$`
|
||||
);
|
||||
} else if (alertmanagers.length === 1) {
|
||||
return FormatQuery(
|
||||
StaticLabels.AlertManager,
|
||||
QueryOperators.Equal,
|
||||
alertmanagers[0].label
|
||||
);
|
||||
}
|
||||
let amNames = [].concat(...alertmanagers.map(am => am.value));
|
||||
return FormatQuery(
|
||||
StaticLabels.AlertManager,
|
||||
QueryOperators.Regex,
|
||||
`^(${amNames.join("|")})$`
|
||||
);
|
||||
};
|
||||
|
||||
export { MatcherToFilter, AlertManagersToFilter };
|
||||
|
||||
@@ -132,7 +132,7 @@ describe("<MatchCounter />", () => {
|
||||
const tree = MountedMatchCounter();
|
||||
await expect(tree.instance().matchedAlerts.fetch).resolves.toBeUndefined();
|
||||
expect(fetch.mock.calls[0][0]).toBe(
|
||||
"./alerts.json?q=foo%3Dbar&q=%40alertmanager%3Dam1"
|
||||
"./alerts.json?q=foo%3Dbar&q=%40alertmanager%3D~%5E%28am1%29%24"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ class SilenceSubmitController extends Component {
|
||||
{silenceFormStore.data.alertmanagers.map(am => (
|
||||
<SilenceSubmitProgress
|
||||
key={am.label}
|
||||
name={am.label}
|
||||
uri={am.value}
|
||||
cluster={am.label}
|
||||
members={am.value}
|
||||
payload={silenceFormStore.data.toAlertmanagerPayload}
|
||||
alertStore={alertStore}
|
||||
/>
|
||||
|
||||
@@ -3,11 +3,7 @@ import React from "react";
|
||||
import { shallow } from "enzyme";
|
||||
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import {
|
||||
SilenceFormStore,
|
||||
SilenceFormStage,
|
||||
MatcherValueToObject
|
||||
} from "Stores/SilenceFormStore";
|
||||
import { SilenceFormStore, SilenceFormStage } from "Stores/SilenceFormStore";
|
||||
import { SilenceSubmitController } from "./SilenceSubmitController";
|
||||
|
||||
let alertStore;
|
||||
@@ -29,8 +25,11 @@ const ShallowSilenceSubmitController = () => {
|
||||
|
||||
describe("<SilenceSubmitController />", () => {
|
||||
it("renders all passed SilenceSubmitProgress", () => {
|
||||
silenceFormStore.data.alertmanagers.push(MatcherValueToObject("am1"));
|
||||
silenceFormStore.data.alertmanagers.push(MatcherValueToObject("am2"));
|
||||
silenceFormStore.data.alertmanagers.push({ label: "am1", value: ["am1"] });
|
||||
silenceFormStore.data.alertmanagers.push({
|
||||
label: "ha",
|
||||
value: ["am2", "am3"]
|
||||
});
|
||||
const tree = ShallowSilenceSubmitController();
|
||||
const alertmanagers = tree.find("SilenceSubmitProgress");
|
||||
expect(alertmanagers).toHaveLength(2);
|
||||
|
||||
@@ -47,8 +47,8 @@ SilenceLink.propTypes = {
|
||||
const SilenceSubmitProgress = observer(
|
||||
class SilenceSubmitProgress extends Component {
|
||||
static propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
uri: PropTypes.string.isRequired,
|
||||
cluster: PropTypes.string.isRequired,
|
||||
members: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
payload: PropTypes.exact({
|
||||
matchers: PropTypes.arrayOf(APISilenceMatcher).isRequired,
|
||||
startsAt: PropTypes.string.isRequired,
|
||||
@@ -63,6 +63,7 @@ const SilenceSubmitProgress = observer(
|
||||
{
|
||||
// store fetch result here, useful for testing
|
||||
fetch: null,
|
||||
membersToTry: [],
|
||||
value: SubmitState.InProgress,
|
||||
result: null,
|
||||
markDone(result) {
|
||||
@@ -77,10 +78,28 @@ const SilenceSubmitProgress = observer(
|
||||
{ markDone: action.bound, markFailed: action.bound }
|
||||
);
|
||||
|
||||
handleAlertmanagerRequest = () => {
|
||||
const { uri, payload } = this.props;
|
||||
maybeTryAgainAfterError = err => {
|
||||
if (this.submitState.membersToTry.length) {
|
||||
this.handleAlertmanagerRequest();
|
||||
} else {
|
||||
this.submitState.markFailed(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
this.submitState.fetch = fetch(`${uri}/api/v1/silences`, {
|
||||
handleAlertmanagerRequest = () => {
|
||||
const { payload, alertStore } = this.props;
|
||||
|
||||
const member = this.submitState.membersToTry.pop();
|
||||
|
||||
const am = alertStore.data.getAlertmanagerByName(member);
|
||||
if (am === undefined) {
|
||||
const err = `Alertmanager instance "${member} not found`;
|
||||
console.error(err);
|
||||
this.maybeTryAgainAfterError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitState.fetch = fetch(`${am.publicURI}/api/v1/silences`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
@@ -88,27 +107,16 @@ const SilenceSubmitProgress = observer(
|
||||
}
|
||||
})
|
||||
.then(result => result.json())
|
||||
.then(result => this.parseAlertmanagerResponse(result))
|
||||
.catch(err => this.submitState.markFailed(err.message));
|
||||
.then(result => this.parseAlertmanagerResponse(am.uri, result))
|
||||
.catch(err => this.maybeTryAgainAfterError(err));
|
||||
};
|
||||
|
||||
parseAlertmanagerResponse = response => {
|
||||
const { name, alertStore } = this.props;
|
||||
|
||||
const alertmanager = alertStore.data.getAlertmanagerByName(name);
|
||||
|
||||
parseAlertmanagerResponse = (uri, response) => {
|
||||
if (response.status === "success") {
|
||||
if (alertmanager) {
|
||||
const link = (
|
||||
<SilenceLink
|
||||
uri={alertmanager.uri}
|
||||
silenceId={response.data.silenceId}
|
||||
/>
|
||||
);
|
||||
this.submitState.markDone(link);
|
||||
} else {
|
||||
this.submitState.markDone(response.data.silenceId);
|
||||
}
|
||||
const link = (
|
||||
<SilenceLink uri={uri} silenceId={response.data.silenceId} />
|
||||
);
|
||||
this.submitState.markDone(link);
|
||||
} else if (response.status === "error") {
|
||||
this.submitState.markFailed(response.error);
|
||||
} else {
|
||||
@@ -120,18 +128,20 @@ const SilenceSubmitProgress = observer(
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { members } = this.props;
|
||||
this.submitState.membersToTry = [...members];
|
||||
this.handleAlertmanagerRequest();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { name } = this.props;
|
||||
const { cluster } = this.props;
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<div className="p-2 flex-fill">
|
||||
<SubmitIcon stateValue={this.submitState.value} />
|
||||
</div>
|
||||
<div className="p-2 flex-fill">{name}</div>
|
||||
<div className="p-2 flex-fill">{cluster}</div>
|
||||
<div className="p-2 flex-fill">{this.submitState.result}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,6 @@ import React from "react";
|
||||
|
||||
import { mount } from "enzyme";
|
||||
|
||||
import toDiffableHtml from "diffable-html";
|
||||
|
||||
import { AlertStore } from "Stores/AlertStore";
|
||||
import { SilenceSubmitProgress } from "./SilenceSubmitProgress";
|
||||
|
||||
@@ -19,6 +17,7 @@ beforeEach(() => {
|
||||
publicURI: "http://example.com",
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
cluster: "mockAlertmanager",
|
||||
clusterMembers: ["mockAlertmanager"]
|
||||
}
|
||||
]
|
||||
@@ -28,8 +27,8 @@ beforeEach(() => {
|
||||
const MountedSilenceSubmitProgress = () => {
|
||||
return mount(
|
||||
<SilenceSubmitProgress
|
||||
name="mockAlertmanager"
|
||||
uri="http://localhost/mock"
|
||||
cluster="mockAlertmanager"
|
||||
members={["mockAlertmanager"]}
|
||||
payload={{
|
||||
matchers: [],
|
||||
startsAt: "now",
|
||||
@@ -43,15 +42,17 @@ const MountedSilenceSubmitProgress = () => {
|
||||
};
|
||||
|
||||
describe("<SilenceSubmitProgress />", () => {
|
||||
it("sends a request on mount", () => {
|
||||
MountedSilenceSubmitProgress();
|
||||
it("sends a request on mount", async () => {
|
||||
const tree = MountedSilenceSubmitProgress();
|
||||
await expect(tree.instance().submitState.fetch).resolves.toBeUndefined();
|
||||
expect(fetch.mock.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("appends /api/v1/silences to the passed URI", () => {
|
||||
MountedSilenceSubmitProgress();
|
||||
it("appends /api/v1/silences to the passed URI", async () => {
|
||||
const tree = MountedSilenceSubmitProgress();
|
||||
await expect(tree.instance().submitState.fetch).resolves.toBeUndefined();
|
||||
const uri = fetch.mock.calls[0][0];
|
||||
expect(uri).toBe("http://localhost/mock/api/v1/silences");
|
||||
expect(uri).toBe("http://example.com/api/v1/silences");
|
||||
});
|
||||
|
||||
it("sends correct JSON payload", () => {
|
||||
@@ -70,6 +71,103 @@ describe("<SilenceSubmitProgress />", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("will retry on another cluster member after fetch failure", async () => {
|
||||
fetch.resetMocks();
|
||||
fetch
|
||||
.mockRejectOnce(new Error("mock error message"))
|
||||
.mockResponseOnce(
|
||||
JSON.stringify({ status: "success", data: { silenceId: "123456789" } })
|
||||
);
|
||||
alertStore.data.upstreams = {
|
||||
clusters: { ha: ["am1", "am2"] },
|
||||
instances: [
|
||||
{
|
||||
name: "am1",
|
||||
uri: "file:///mock",
|
||||
publicURI: "http://am1.example.com",
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
cluster: "ha",
|
||||
clusterMembers: ["am1", "am2"]
|
||||
},
|
||||
{
|
||||
name: "am2",
|
||||
uri: "file:///mock",
|
||||
publicURI: "http://am2.example.com",
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
cluster: "ha",
|
||||
clusterMembers: ["am1", "am2"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const tree = mount(
|
||||
<SilenceSubmitProgress
|
||||
cluster="ha"
|
||||
members={["am1", "am2"]}
|
||||
payload={{
|
||||
matchers: [],
|
||||
startsAt: "now",
|
||||
endsAt: "later",
|
||||
createdBy: "me@example.com",
|
||||
comment: "fake payload"
|
||||
}}
|
||||
alertStore={alertStore}
|
||||
/>
|
||||
);
|
||||
await expect(tree.instance().submitState.fetch).resolves.toBeUndefined();
|
||||
expect(fetch.mock.calls[0][0]).toBe(
|
||||
"http://am2.example.com/api/v1/silences"
|
||||
);
|
||||
await expect(tree.instance().submitState.fetch).resolves.toBe("success");
|
||||
expect(fetch.mock.calls[1][0]).toBe(
|
||||
"http://am1.example.com/api/v1/silences"
|
||||
);
|
||||
});
|
||||
|
||||
it("will log an error if Alertmanager instance is missing from instances and try the next one", async () => {
|
||||
fetch.resetMocks();
|
||||
fetch.mockReject(new Error("mock error message"));
|
||||
const consoleSpy = jest
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
alertStore.data.upstreams = {
|
||||
clusters: { ha: ["am1", "am2"] },
|
||||
instances: [
|
||||
{
|
||||
name: "am1",
|
||||
uri: "file:///mock",
|
||||
publicURI: "http://am1.example.com",
|
||||
error: "",
|
||||
version: "0.15.0",
|
||||
cluster: "ha",
|
||||
clusterMembers: ["am1", "am2"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const tree = mount(
|
||||
<SilenceSubmitProgress
|
||||
cluster="ha"
|
||||
members={["am1", "am2"]}
|
||||
payload={{
|
||||
matchers: [],
|
||||
startsAt: "now",
|
||||
endsAt: "later",
|
||||
createdBy: "me@example.com",
|
||||
comment: "fake payload"
|
||||
}}
|
||||
alertStore={alertStore}
|
||||
/>
|
||||
);
|
||||
await expect(tree.instance().submitState.fetch).resolves.toBeUndefined();
|
||||
expect(fetch.mock.calls[0][0]).toBe(
|
||||
"http://am1.example.com/api/v1/silences"
|
||||
);
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders returned silence ID on successful fetch", async () => {
|
||||
fetch.mockResponseOnce(
|
||||
JSON.stringify({ status: "success", data: { silenceId: "123456789" } })
|
||||
@@ -83,21 +181,6 @@ describe("<SilenceSubmitProgress />", () => {
|
||||
expect(silenceLink.text()).toBe("123456789");
|
||||
});
|
||||
|
||||
it("renders returned silence ID as text if alertmanager is not found in AlertStore", async () => {
|
||||
fetch.mockResponseOnce(
|
||||
JSON.stringify({ status: "success", data: { silenceId: "123456789" } })
|
||||
);
|
||||
alertStore.data.upstreams.instances = [];
|
||||
const tree = MountedSilenceSubmitProgress();
|
||||
await expect(tree.instance().submitState.fetch).resolves.toBe("success");
|
||||
// force re-render
|
||||
tree.update();
|
||||
const silenceLink = tree.find("a");
|
||||
expect(silenceLink).toHaveLength(0);
|
||||
const idDiv = tree.find("div.flex-fill").at(2);
|
||||
expect(toDiffableHtml(idDiv.html())).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders returned error message on failed fetch", async () => {
|
||||
fetch.mockRejectOnce(new Error("mock error message"));
|
||||
const tree = MountedSilenceSubmitProgress();
|
||||
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<SilenceSubmitProgress /> renders returned silence ID as text if alertmanager is not found in AlertStore 1`] = `
|
||||
"
|
||||
<div class=\\"p-2 flex-fill\\">
|
||||
123456789
|
||||
</div>
|
||||
"
|
||||
`;
|
||||
@@ -11,6 +11,7 @@ const Annotation = PropTypes.exact({
|
||||
|
||||
const APIAlertAlertmanagerState = PropTypes.exact({
|
||||
name: PropTypes.string.isRequired,
|
||||
cluster: PropTypes.string.isRequired,
|
||||
state: AlertState.isRequired,
|
||||
startsAt: PropTypes.string.isRequired,
|
||||
endsAt: PropTypes.string.isRequired,
|
||||
@@ -66,6 +67,7 @@ const APISilence = PropTypes.exact({
|
||||
|
||||
const APIAlertmanagerUpstream = PropTypes.exact({
|
||||
name: PropTypes.string.isRequired,
|
||||
cluster: PropTypes.string.isRequired,
|
||||
uri: PropTypes.string.isRequired,
|
||||
publicURI: PropTypes.string.isRequired,
|
||||
error: PropTypes.string.isRequired,
|
||||
|
||||
@@ -140,7 +140,7 @@ class AlertStore {
|
||||
counters: {},
|
||||
groups: {},
|
||||
silences: {},
|
||||
upstreams: { instances: [] },
|
||||
upstreams: { instances: [], clusters: {} },
|
||||
getAlertmanagerByName(name) {
|
||||
return this.upstreams.instances.find(am => am.name === name);
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ const MockAlert = (annotations, labels, state) => ({
|
||||
alertmanager: [
|
||||
{
|
||||
name: "default",
|
||||
cluster: "default",
|
||||
state: "active",
|
||||
startsAt: "2018-08-14T17:36:40.017867056Z",
|
||||
endsAt: "0001-01-01T00:00:00Z",
|
||||
@@ -66,6 +67,7 @@ const MockSilence = () => ({
|
||||
|
||||
const MockAlertmanager = () => ({
|
||||
name: "default",
|
||||
cluster: "default",
|
||||
uri: "http://localhost",
|
||||
publicURI: "http://am.example.com",
|
||||
error: "",
|
||||
|
||||
@@ -111,9 +111,15 @@ func alerts(c *gin.Context) {
|
||||
dedupedAlerts := alertmanager.DedupAlerts()
|
||||
dedupedColors := alertmanager.DedupColors()
|
||||
|
||||
amNameToCluster := map[string]string{}
|
||||
silences := map[string]map[string]models.Silence{}
|
||||
for _, am := range alertmanager.GetAlertmanagers() {
|
||||
silences[am.Name] = map[string]models.Silence{}
|
||||
key := am.ClusterID()
|
||||
amNameToCluster[am.Name] = key
|
||||
_, found := silences[key]
|
||||
if !found {
|
||||
silences[key] = map[string]models.Silence{}
|
||||
}
|
||||
}
|
||||
|
||||
var matches int
|
||||
@@ -186,11 +192,18 @@ func alerts(c *gin.Context) {
|
||||
}
|
||||
|
||||
if len(agCopy.Alerts) > 0 {
|
||||
for _, alert := range agCopy.Alerts {
|
||||
for i, alert := range agCopy.Alerts {
|
||||
if alert.IsSilenced() {
|
||||
for _, am := range alert.Alertmanager {
|
||||
for j, am := range alert.Alertmanager {
|
||||
key := amNameToCluster[am.Name]
|
||||
// cluster might be wrong when collecting (races between fetches)
|
||||
// update is with current cluster discovery state
|
||||
agCopy.Alerts[i].Alertmanager[j].Cluster = key
|
||||
for _, silence := range am.Silences {
|
||||
silences[am.Name][silence.ID] = *silence
|
||||
_, found := silences[key][silence.ID]
|
||||
if !found {
|
||||
silences[key][silence.ID] = *silence
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user