Merge pull request #279 from prymitive/ha2

fix(ui): improve support for HA clusters
This commit is contained in:
Łukasz Mierzwa
2018-12-01 18:51:16 +00:00
committed by GitHub
28 changed files with 383 additions and 198 deletions
-1
View File
@@ -8,7 +8,6 @@ linters:
- golint
- dupl
- goconst
- gocyclo
linters-settings:
govet:
+10 -9
View File
@@ -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
View File
@@ -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))
}
+14
View File
@@ -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
}
+4 -2
View File
@@ -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"`
}
+21
View File
@@ -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
}
+19
View File
@@ -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: "",
+1 -1
View File
@@ -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;
};
+4 -4
View File
@@ -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"]
});
});
+6 -13
View File
@@ -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();
@@ -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>
"
`;
+2
View File
@@ -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,
+1 -1
View File
@@ -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);
},
+2
View File
@@ -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: "",
+17 -4
View File
@@ -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
}
}
}
}