diff --git a/ui/src/Components/AlertAck/index.js b/ui/src/Components/AlertAck/index.js index 74df2fafc..a9125b214 100644 --- a/ui/src/Components/AlertAck/index.js +++ b/ui/src/Components/AlertAck/index.js @@ -1,8 +1,8 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import PropTypes from "prop-types"; import { toJS } from "mobx"; -import { useObserver, useLocalStore } from "mobx-react"; +import { useObserver } from "mobx-react"; import moment from "moment"; @@ -19,131 +19,19 @@ import { MatchersFromGroup, GenerateAlertmanagerSilenceData, } from "Stores/SilenceFormStore"; -import { FetchPost } from "Common/Fetch"; +import { useFetchAny } from "Hooks/useFetchAny"; import { TooltipWrapper } from "Components/TooltipWrapper"; -const SubmitState = Object.freeze({ - Idle: "Idle", - InProgress: "InProgress", - Done: "Done", - Failed: "Failed", -}); - -const newPendingSilence = ( - group, - members, - durationSeconds, - author, - commentPrefix -) => ({ - payload: GenerateAlertmanagerSilenceData( - moment.utc(), - moment.utc().add(durationSeconds, "seconds"), - MatchersFromGroup(group, [], group.alerts, true), - author, - `${ - commentPrefix ? commentPrefix + " " : "" - }This alert was acknowledged using karma on ${moment.utc().toString()}` - ), - membersToTry: members, - submitState: SubmitState.Idle, - submitResult: null, - isDone: false, - isFailed: false, - error: null, -}); - const AlertAck = ({ alertStore, silenceFormStore, group }) => { - const submitState = useLocalStore(() => ({ - silencesByCluster: {}, - reset() { - this.silencesByCluster = {}; - }, - pushSilence(cluster, silence) { - this.silencesByCluster[cluster] = silence; - }, - markDone(cluster) { - this.silencesByCluster[cluster].isDone = true; - }, - markFailed(cluster, err) { - this.silencesByCluster[cluster].isDone = true; - this.silencesByCluster[cluster].isFailed = true; - this.silencesByCluster[cluster].error = err; - }, - get isIdle() { - return Object.keys(this.silencesByCluster).length === 0; - }, - get isInprogress() { - return ( - Object.values(this.silencesByCluster).filter( - (pendingSilence) => pendingSilence.isDone === false - ).length > 0 - ); - }, - get isDone() { - return ( - Object.values(this.silencesByCluster).filter( - (pendingSilence) => pendingSilence.isDone === true - ).length > 0 - ); - }, - get isFailed() { - return ( - Object.values(this.silencesByCluster).filter( - (pendingSilence) => pendingSilence.isFailed === true - ).length > 0 - ); - }, - get errorMessages() { - return Object.values(this.silencesByCluster) - .filter((pendingSilence) => pendingSilence.error !== null) - .map((s) => s.error); - }, - })); + const [clusters, setClusters] = useState([]); + const [upstreams, setUpstreams] = useState([]); + const [currentCluster, setCurrentCluster] = useState(0); + const [isAcking, setIsAcking] = useState(false); - const maybeTryAgainAfterError = (cluster, err) => { - if (submitState.silencesByCluster[cluster].membersToTry.length) { - handleAlertmanagerRequest(cluster); - } else { - submitState.markFailed(cluster, err); - } - }; - - const handleAlertmanagerRequest = (cluster) => { - const member = submitState.silencesByCluster[cluster].membersToTry.pop(); - - const am = alertStore.data.getAlertmanagerByName(member); - if (am === undefined) { - const err = `Alertmanager instance "${member} not found`; - console.error(err); - maybeTryAgainAfterError(cluster, err); - return; - } - - FetchPost(`${am.uri}/api/v2/silences`, { - body: JSON.stringify(submitState.silencesByCluster[cluster].payload), - credentials: am.corsCredentials, - headers: { - "Content-Type": "application/json", - ...am.headers, - }, - }) - .then((result) => { - if (result.ok) { - return result.json().then((r) => submitState.markDone(cluster)); - } else { - result.text().then((text) => maybeTryAgainAfterError(cluster, text)); - } - }) - .catch((err) => { - maybeTryAgainAfterError(cluster, err); - }); - }; + const { response, error, inProgress, reset } = useFetchAny(upstreams); const onACK = () => { - if (submitState.isInprogress || submitState.isDone) { - return; - } + setIsAcking(true); let author = silenceFormStore.data.author !== "" @@ -166,47 +54,103 @@ const AlertAck = ({ alertStore, silenceFormStore, group }) => { alertmanagers.some((m) => clusterMembers.includes(m)) ); - submitState.reset(); + let c = []; for (const [clusterName, clusterMembers] of clusters) { - const pendingSilence = newPendingSilence( - toJS(group), - toJS(clusterMembers), - toJS(alertStore.settings.values.alertAcknowledgement.durationSeconds), - author, - toJS(alertStore.settings.values.alertAcknowledgement.commentPrefix) + const durationSeconds = toJS( + alertStore.settings.values.alertAcknowledgement.durationSeconds ); - submitState.pushSilence(clusterName, pendingSilence); - handleAlertmanagerRequest(clusterName); + const commentPrefix = toJS( + alertStore.settings.values.alertAcknowledgement.commentPrefix + ); + c.push({ + payload: GenerateAlertmanagerSilenceData( + moment.utc(), + moment.utc().add(durationSeconds, "seconds"), + MatchersFromGroup(group, [], group.alerts, true), + author, + `${ + commentPrefix ? commentPrefix + " " : "" + }This alert was acknowledged using karma on ${moment + .utc() + .toString()}` + ), + clusterName: clusterName, + members: clusterMembers, + }); } + setClusters(c); }; + useEffect(() => { + if (upstreams.length && !inProgress && (error || response)) { + if (clusters.length > currentCluster + 1) { + setCurrentCluster(currentCluster + 1); + } else { + setIsAcking(false); + } + } + }, [clusters, upstreams, currentCluster, inProgress, error, response]); + + useEffect(() => { + if (clusters.length) { + reset(); + const cluster = clusters[currentCluster]; + let u = []; + cluster.members.forEach((amName) => { + const am = alertStore.data.getAlertmanagerByName(amName); + if (am !== undefined) { + u.push({ + uri: `${am.uri}/api/v2/silences`, + options: { + method: "POST", + body: JSON.stringify(cluster.payload), + credentials: am.corsCredentials, + headers: { + "Content-Type": "application/json", + ...am.headers, + }, + }, + }); + } else { + console.error(`Alertmanager "${amName}" not found`); + } + }); + setUpstreams(u); + } + }, [alertStore.data, clusters, currentCluster, reset]); + return useObserver(() => alertStore.settings.values.alertAcknowledgement.enabled === false ? null : ( { + if (!isAcking && !(response || error)) { + setIsAcking(true); + onACK(); + } + }} > - {submitState.isIdle ? ( - - ) : submitState.isInprogress ? ( - - ) : submitState.isFailed ? ( + {!isAcking && error ? ( - ) : ( + ) : !isAcking && response ? ( + ) : isAcking ? ( + + ) : ( + )} diff --git a/ui/src/Components/AlertAck/index.test.js b/ui/src/Components/AlertAck/index.test.js index ff67e95da..008392c12 100644 --- a/ui/src/Components/AlertAck/index.test.js +++ b/ui/src/Components/AlertAck/index.test.js @@ -1,4 +1,5 @@ import React from "react"; +import { act } from "react-dom/test-utils"; import { mount } from "enzyme"; @@ -89,7 +90,9 @@ const MountAndClick = async () => { const tree = MountedAlertAck(); const button = tree.find("span.badge"); button.simulate("click"); - await fetchMock.flush(true); + await act(async () => { + await fetchMock.flush(true); + }); }; describe("", () => { @@ -117,7 +120,9 @@ describe("", () => { const tree = MountedAlertAck(); const button = tree.find("span.badge"); button.simulate("click"); - await fetchMock.flush(true); + await act(async () => { + await fetchMock.flush(true); + }); expect(toDiffableHtml(tree.html())).toMatch(/fa-exclamation-circle/); }); @@ -125,22 +130,95 @@ describe("", () => { const tree = MountedAlertAck(); const button = tree.find("span.badge"); button.simulate("click"); - await fetchMock.flush(true); + await act(async () => { + await fetchMock.flush(true); + }); expect(toDiffableHtml(tree.html())).toMatch(/fa-check-circle/); }); - it("sends a request on click", () => { - MountAndClick(); + it("sends a POST request on click", async () => { + await MountAndClick(); expect(fetchMock.calls()).toHaveLength(1); + expect(fetchMock.lastCall()[1]).toMatchObject({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }); }); - it("doesn't send any request on click when already in progress", async () => { - const tree = MountedAlertAck(); - const button = tree.find("span.badge"); - button.simulate("click"); - button.simulate("click"); - await fetchMock.flush(true); - expect(fetchMock.calls()).toHaveLength(1); + it("sends a POST request to every cluster", async () => { + alertStore.data.upstreams = { + clusters: { c1: ["m1", "m2"], c2: ["m3", "m4"] }, + instances: ["m1", "m2", "m3", "m4"].map((a) => ({ + name: a, + uri: `http://${a}.example.com`, + publicURI: `http://${a}.example.com`, + readonly: false, + headers: { "X-Cluster": a === "m1" || a === "2" ? "c1" : "c2" }, + corsCredentials: a === "m1" || a === "2" ? "same-site" : "include", + error: "", + version: "0.17.0", + cluster: a === "m1" || a === "2" ? "c1" : "c2", + clusterMembers: a === "m1" || a === "2" ? ["m1", "m2"] : ["m3", "m4"], + })), + }; + group.alertmanagerCount = { + m1: 1, + m2: 1, + m3: 1, + m4: 1, + }; + + await MountAndClick(); + expect(fetchMock.calls()).toHaveLength(2); + expect(fetchMock.calls()[0][0]).toBe( + "http://m1.example.com/api/v2/silences" + ); + expect(fetchMock.calls()[0][1]).toMatchObject({ + method: "POST", + credentials: "same-site", + headers: { "X-Cluster": "c1" }, + }); + expect(fetchMock.calls()[1][0]).toBe( + "http://m3.example.com/api/v2/silences" + ); + expect(fetchMock.calls()[1][1]).toMatchObject({ + method: "POST", + credentials: "include", + headers: { "X-Cluster": "c2" }, + }); + }); + + it("skips readonly alertmanagers", async () => { + alertStore.data.upstreams = { + clusters: { c1: ["m1", "m2"], c2: ["m3", "m4"] }, + instances: ["m1", "m2", "m3", "m4"].map((a) => ({ + name: a, + uri: `http://${a}.example.com`, + publicURI: `http://${a}.example.com`, + readonly: a === "m1" || a === "m3" ? true : false, + headers: { "X-Cluster": a === "m1" || a === "2" ? "c1" : "c2" }, + corsCredentials: a === "m1" || a === "m2" ? "same-site" : "include", + error: "", + version: "0.17.0", + cluster: a === "m1" || a === "2" ? "c1" : "c2", + clusterMembers: a === "m1" || a === "2" ? ["m1", "m2"] : ["m3", "m4"], + })), + }; + group.alertmanagerCount = { + m1: 1, + m2: 1, + m3: 1, + m4: 1, + }; + + await MountAndClick(); + expect(fetchMock.calls()).toHaveLength(2); + expect(fetchMock.calls()[0][0]).toBe( + "http://m2.example.com/api/v2/silences" + ); + expect(fetchMock.calls()[1][0]).toBe( + "http://m4.example.com/api/v2/silences" + ); }); it("doesn't send any request on click when already done", async () => { @@ -148,19 +226,16 @@ describe("", () => { const button = tree.find("span.badge"); button.simulate("click"); - await fetchMock.flush(true); + await act(async () => { + await fetchMock.flush(true); + }); expect(fetchMock.calls()).toHaveLength(1); button.simulate("click"); expect(fetchMock.calls()).toHaveLength(1); }); - it("sends POST requests", () => { - MountAndClick(); - expect(fetchMock.calls()[0][1].method).toBe("POST"); - }); - - it("sends correct payload", () => { + it("sends correct payload", async () => { fetchMock.any( { headers: { "Content-Type": "application/json" }, @@ -172,7 +247,7 @@ describe("", () => { ); silenceFormStore.data.author = "karma/ui"; - MountAndClick(); + await MountAndClick(); expect(JSON.parse(fetchMock.calls()[0][1].body)).toEqual({ comment: "PREFIX This alert was acknowledged using karma on Tue Feb 01 2000 00:00:00 GMT+0000", @@ -186,11 +261,11 @@ describe("", () => { }); }); - it("uses settings when generating payload", () => { + it("uses settings when generating payload", async () => { alertStore.settings.values.alertAcknowledgement.durationSeconds = 237; alertStore.settings.values.alertAcknowledgement.author = "me"; alertStore.settings.values.alertAcknowledgement.commentPrefix = ""; - MountAndClick(); + await MountAndClick(); expect(JSON.parse(fetchMock.calls()[0][1].body)).toEqual({ comment: "This alert was acknowledged using karma on Tue Feb 01 2000 00:00:00 GMT+0000", @@ -204,13 +279,13 @@ describe("", () => { }); }); - it("uses author from authentication info when auth is enabled", () => { + it("uses author from authentication info when auth is enabled", async () => { alertStore.info.authentication.enabled = true; alertStore.info.authentication.username = "auth@example.com"; alertStore.settings.values.alertAcknowledgement.durationSeconds = 222; alertStore.settings.values.alertAcknowledgement.author = "me"; alertStore.settings.values.alertAcknowledgement.commentPrefix = "FOO:"; - MountAndClick(); + await MountAndClick(); expect(JSON.parse(fetchMock.calls()[0][1].body)).toEqual({ comment: "FOO: This alert was acknowledged using karma on Tue Feb 01 2000 00:00:00 GMT+0000", @@ -224,14 +299,14 @@ describe("", () => { }); }); - it("uses author from silenceFormStore if authentication is disabled", () => { + it("uses author from silenceFormStore if authentication is disabled", async () => { alertStore.info.authentication.enabled = false; alertStore.info.authentication.username = "wrong"; alertStore.settings.values.alertAcknowledgement.durationSeconds = 222; alertStore.settings.values.alertAcknowledgement.author = "me"; alertStore.settings.values.alertAcknowledgement.commentPrefix = "FOO:"; silenceFormStore.data.author = "bob@example.com"; - MountAndClick(); + await MountAndClick(); expect(JSON.parse(fetchMock.calls()[0][1].body)).toEqual({ comment: "FOO: This alert was acknowledged using karma on Tue Feb 01 2000 00:00:00 GMT+0000", @@ -245,12 +320,12 @@ describe("", () => { }); }); - it("uses default author as fallback", () => { + it("uses default author as fallback", async () => { alertStore.settings.values.alertAcknowledgement.durationSeconds = 222; alertStore.settings.values.alertAcknowledgement.author = "me"; alertStore.settings.values.alertAcknowledgement.commentPrefix = "FOO:"; silenceFormStore.data.author = ""; - MountAndClick(); + await MountAndClick(); expect(JSON.parse(fetchMock.calls()[0][1].body)).toEqual({ comment: "FOO: This alert was acknowledged using karma on Tue Feb 01 2000 00:00:00 GMT+0000", @@ -264,118 +339,136 @@ describe("", () => { }); }); - it("sends POST request to /api/v2/silences", () => { - MountAndClick(); + it("sends POST request to /api/v2/silences", async () => { + await MountAndClick(); const uri = fetchMock.calls()[0][0]; expect(uri).toBe("http://localhost/api/v2/silences"); }); it("will retry on another cluster member after 500 response", async () => { fetchMock.reset(); - fetchMock.mock("http://am2.example.com/api/v2/silences", { + fetchMock.mock("http://m1.example.com/api/v2/silences", { status: 500, body: "error message", }); - fetchMock.mock("http://am1.example.com/api/v2/silences", { + fetchMock.mock("http://m2.example.com/api/v2/silences", { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ silenceID: "123" }), }); + fetchMock.mock("http://m3.example.com/api/v2/silences", { + status: 500, + body: "error message", + }); + fetchMock.mock("http://m4.example.com/api/v2/silences", { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ silenceID: "456" }), + }); alertStore.data.upstreams = { - clusters: { default: ["default", "fallback"] }, - instances: [ - { - name: "default", - uri: "http://am1.example.com", - publicURI: "http://am1.example.com", - readonly: false, - headers: {}, - corsCredentials: "include", - error: "", - version: "0.17.0", - cluster: "default", - clusterMembers: ["default", "fallback"], - }, - { - name: "fallback", - uri: "http://am2.example.com", - publicURI: "http://am2.example.com", - readonly: false, - headers: {}, - corsCredentials: "include", - error: "", - version: "0.17.0", - cluster: "default", - clusterMembers: ["default", "fallback"], - }, - ], + clusters: { c1: ["m1", "m2"], c2: ["m3", "m4"] }, + instances: ["m1", "m2", "m3", "m4"].map((a) => ({ + name: a, + uri: `http://${a}.example.com`, + publicURI: `http://${a}.example.com`, + readonly: false, + headers: { "X-Cluster": a === "m1" || a === "2" ? "c1" : "c2" }, + corsCredentials: a === "m1" || a === "m2" ? "same-site" : "include", + error: "", + version: "0.17.0", + cluster: a === "m1" || a === "2" ? "c1" : "c2", + clusterMembers: a === "m1" || a === "2" ? ["m1", "m2"] : ["m3", "m4"], + })), + }; + group.alertmanagerCount = { + m1: 1, + m2: 1, + m3: 1, + m4: 1, }; const tree = MountedAlertAck(); const button = tree.find("span.badge"); button.simulate("click"); - await fetchMock.flush(true); - await fetchMock.flush(true); + await act(async () => { + await fetchMock.flush(true); + }); + expect(fetchMock.calls()).toHaveLength(4); expect(fetchMock.calls()[0][0]).toBe( - "http://am2.example.com/api/v2/silences" + "http://m1.example.com/api/v2/silences" ); expect(fetchMock.calls()[1][0]).toBe( - "http://am1.example.com/api/v2/silences" + "http://m2.example.com/api/v2/silences" + ); + expect(fetchMock.calls()[2][0]).toBe( + "http://m3.example.com/api/v2/silences" + ); + expect(fetchMock.calls()[3][0]).toBe( + "http://m4.example.com/api/v2/silences" ); }); it("will retry on another cluster member after fetch failure", async () => { fetchMock.reset(); - fetchMock.mock("http://am2.example.com/api/v2/silences", { + fetchMock.mock("http://m1.example.com/api/v2/silences", { throws: new TypeError("failed to fetch"), }); - fetchMock.mock("http://am1.example.com/api/v2/silences", { + fetchMock.mock("http://m2.example.com/api/v2/silences", { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ silenceID: "123" }), + }); + fetchMock.mock("http://m3.example.com/api/v2/silences", { + throws: new TypeError("failed to fetch"), + }); + fetchMock.mock("http://m4.example.com/api/v2/silences", { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ silenceID: "123" }), }); alertStore.data.upstreams = { - clusters: { default: ["default", "fallback"] }, - instances: [ - { - name: "default", - uri: "http://am1.example.com", - publicURI: "http://am1.example.com", - readonly: false, - headers: {}, - corsCredentials: "include", - error: "", - version: "0.17.0", - cluster: "default", - clusterMembers: ["default", "fallback"], - }, - { - name: "fallback", - uri: "http://am2.example.com", - publicURI: "http://am2.example.com", - readonly: false, - headers: {}, - corsCredentials: "include", - error: "", - version: "0.17.0", - cluster: "default", - clusterMembers: ["default", "fallback"], - }, - ], + clusters: { c1: ["m1", "m2"], c2: ["m3", "m4"] }, + instances: ["m1", "m2", "m3", "m4"].map((a) => ({ + name: a, + uri: `http://${a}.example.com`, + publicURI: `http://${a}.example.com`, + readonly: false, + headers: { "X-Cluster": a === "m1" || a === "2" ? "c1" : "c2" }, + corsCredentials: a === "m1" || a === "m2" ? "same-site" : "include", + error: "", + version: "0.17.0", + cluster: a === "m1" || a === "2" ? "c1" : "c2", + clusterMembers: a === "m1" || a === "2" ? ["m1", "m2"] : ["m3", "m4"], + })), + }; + group.alertmanagerCount = { + m1: 1, + m2: 1, + m3: 1, + m4: 1, }; const tree = MountedAlertAck(); const button = tree.find("span.badge"); button.simulate("click"); - await fetchMock.flush(true); - await fetchMock.flush(true); + await act(async () => { + await fetchMock.flush(true); + }); + await act(async () => { + await fetchMock.flush(true); + }); + expect(fetchMock.calls()).toHaveLength(4); expect(fetchMock.calls()[0][0]).toBe( - "http://am2.example.com/api/v2/silences" + "http://m1.example.com/api/v2/silences" ); expect(fetchMock.calls()[1][0]).toBe( - "http://am1.example.com/api/v2/silences" + "http://m2.example.com/api/v2/silences" + ); + expect(fetchMock.calls()[2][0]).toBe( + "http://m3.example.com/api/v2/silences" + ); + expect(fetchMock.calls()[3][0]).toBe( + "http://m4.example.com/api/v2/silences" ); }); - it("will log an error if Alertmanager instance is missing from instances and try the next one", () => { + it("will log an error if Alertmanager instance is missing from instances and try the next one", async () => { const consoleSpy = jest .spyOn(console, "error") .mockImplementation(() => {}); @@ -400,6 +493,10 @@ describe("", () => { const tree = MountedAlertAck(); const button = tree.find("span.badge"); button.simulate("click"); + await act(async () => { + await fetchMock.flush(true); + }); + expect(fetchMock.calls()).toHaveLength(1); expect(fetchMock.calls()[0][0]).toBe( "http://am1.example.com/api/v2/silences" ); diff --git a/ui/src/Hooks/useFetchAny.js b/ui/src/Hooks/useFetchAny.js index 26e3df806..841be7ecc 100644 --- a/ui/src/Hooks/useFetchAny.js +++ b/ui/src/Hooks/useFetchAny.js @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import merge from "lodash.merge"; @@ -6,10 +6,22 @@ import { CommonOptions } from "Common/Fetch"; const useFetchAny = (upstreams) => { const [index, setIndex] = useState(0); - const [response, setResponse] = useState(null); - const [error, setError] = useState(null); - const [inProgress, setInProgress] = useState(true); - const [responseURI, setResponseURI] = useState(null); + const [response, setResponse] = useState({ + response: null, + error: null, + responseURI: null, + inProgress: false, + }); + + const reset = useCallback(() => { + setIndex(0); + setResponse({ + response: null, + error: null, + responseURI: null, + inProgress: false, + }); + }, []); useEffect(() => { // https://dev.to/pallymore/clean-up-async-requests-in-useeffect-hooks-90h @@ -18,8 +30,13 @@ const useFetchAny = (upstreams) => { const fetchData = async () => { const { uri, options } = upstreams[index]; + setResponse({ + response: null, + error: null, + responseURI: null, + inProgress: true, + }); try { - setInProgress(true); const res = await fetch( uri, merge({}, { method: "GET" }, CommonOptions, options) @@ -35,15 +52,22 @@ const useFetchAny = (upstreams) => { } if (res.ok) { - setResponse(body); - setResponseURI(uri); - setInProgress(false); + setResponse({ + response: body, + error: null, + responseURI: uri, + inProgress: false, + }); } else { if (upstreams.length > index + 1) { setIndex(index + 1); } else { - setError(body); - setInProgress(false); + setResponse({ + response: null, + error: body, + responseURI: null, + inProgress: false, + }); } } } @@ -52,8 +76,12 @@ const useFetchAny = (upstreams) => { if (upstreams.length > index + 1) { setIndex(index + 1); } else { - setError(error.message); - setInProgress(false); + setResponse({ + response: null, + error: error.message, + responseURI: null, + inProgress: false, + }); } } } @@ -62,15 +90,15 @@ const useFetchAny = (upstreams) => { if (upstreams.length > 0) { fetchData(); } else { - setInProgress(false); + reset(); } return () => { isCancelled = true; }; - }, [upstreams, index]); + }, [upstreams, index, reset]); - return { response, error, inProgress, responseURI }; + return { ...response, reset }; }; export { useFetchAny };