fix(ui): drop useLocalStore from SilenceSubmitProgress

This commit is contained in:
Łukasz Mierzwa
2020-06-06 19:54:33 +01:00
committed by Łukasz Mierzwa
parent b09dd6f8c9
commit f063038c30
2 changed files with 125 additions and 139 deletions

View File

@@ -1,8 +1,6 @@
import React, { useEffect, useCallback } from "react";
import React, { useEffect, useState } from "react";
import PropTypes from "prop-types";
import { useObserver, useLocalStore } from "mobx-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons/faCheckCircle";
@@ -10,139 +8,80 @@ import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclama
import { APISilenceMatcher } from "Models/API";
import { AlertStore } from "Stores/AlertStore";
import { FetchPost } from "Common/Fetch";
const SubmitState = Object.freeze({
InProgress: "InProgress",
Done: "Done",
Failed: "Failed",
});
const SubmitIcon = ({ stateValue }) => {
return useObserver(() =>
stateValue === SubmitState.Done ? (
<FontAwesomeIcon icon={faCheckCircle} className="text-success" />
) : stateValue === SubmitState.Failed ? (
<FontAwesomeIcon icon={faExclamationCircle} className="text-danger" />
) : (
<FontAwesomeIcon icon={faCircleNotch} spin />
)
);
};
const SilenceLink = ({ uri, silenceID }) => (
<a
href={`${uri}/#/silences/${silenceID}`}
target="_blank"
rel="noopener noreferrer"
>
{silenceID}
</a>
);
SilenceLink.propTypes = {
uri: PropTypes.string.isRequired,
silenceID: PropTypes.string.isRequired,
};
import { useFetchAny } from "Hooks/useFetchAny";
const SilenceSubmitProgress = ({ alertStore, cluster, members, payload }) => {
const submitState = useLocalStore(() => ({
membersToTry: [],
value: SubmitState.InProgress,
result: null,
markDone(result) {
this.result = result;
this.value = SubmitState.Done;
},
markFailed(result) {
this.result = result;
this.value = SubmitState.Failed;
},
}));
const handleAlertmanagerRequest = useCallback(() => {
const member = submitState.membersToTry.pop();
if (alertStore.data.isReadOnlyAlertmanager(member)) {
const err = `Alertmanager instance "${member}" is read-only`;
console.error(err);
if (submitState.membersToTry.length) {
return handleAlertmanagerRequest();
} else {
submitState.markFailed(err.message);
}
return;
}
const am = alertStore.data.getAlertmanagerByName(member);
if (am === undefined) {
const err = `Alertmanager instance "${member}" not found`;
console.error(err);
if (submitState.membersToTry.length) {
return handleAlertmanagerRequest();
} else {
submitState.markFailed(err.message);
}
return;
}
const parseOpenAPIResponse = (uri, response) => {
const link = <SilenceLink uri={uri} silenceID={response.silenceID} />;
submitState.markDone(link);
// return silenceID so we can assert it in tests
return response.silenceID;
};
FetchPost(`${am.uri}/api/v2/silences`, {
body: JSON.stringify(payload),
credentials: am.corsCredentials,
headers: {
"Content-Type": "application/json",
...am.headers,
},
})
.then((result) => {
if (result.ok) {
return result
.json()
.then((r) => parseOpenAPIResponse(am.publicURI, r));
} else {
return result.text().then((text) => {
submitState.markFailed(text);
return text;
});
}
})
.catch((err) => {
if (submitState.membersToTry.length) {
return handleAlertmanagerRequest();
} else {
submitState.markFailed(err.message);
}
});
}, [alertStore.data, payload, submitState]);
const [upstreams, setUpstreams] = useState([]);
const { response, error, inProgress, responseURI } = useFetchAny(upstreams);
const [publicURIs, setPublicURIs] = useState({});
useEffect(() => {
submitState.membersToTry = [...members];
handleAlertmanagerRequest();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
let uris = {};
let membersToTry = [];
for (const member of members) {
if (alertStore.data.isReadOnlyAlertmanager(member)) {
console.error(`Alertmanager instance "${member}" is read-only`);
} else {
const am = alertStore.data.getAlertmanagerByName(member);
if (am === undefined) {
console.error(`Alertmanager instance "${member}" not found`);
} else {
const uri = `${am.uri}/api/v2/silences`;
membersToTry.push({
uri: uri,
options: {
method: "POST",
body: JSON.stringify(payload),
credentials: am.corsCredentials,
headers: {
"Content-Type": "application/json",
...am.headers,
},
},
});
uris[uri] = am.publicURI;
}
}
}
if (membersToTry.length) {
setPublicURIs(uris);
setUpstreams(membersToTry);
}
}, [alertStore.data, members, payload]);
return useObserver(() => (
return (
<div className="d-flex mb-2">
<div className="p-2 flex-fill my-auto flex-grow-0 flex-shrink-0">
<SubmitIcon stateValue={submitState.value} />
{inProgress ? (
<FontAwesomeIcon icon={faCircleNotch} spin />
) : error ? (
<FontAwesomeIcon icon={faExclamationCircle} className="text-danger" />
) : (
<FontAwesomeIcon icon={faCheckCircle} className="text-success" />
)}
</div>
<div className="p-2 mr-1 flex-fill my-auto flex-grow-0 flex-shrink-0">
{cluster}
</div>
<div
className={`p-2 flex-fill flex-grow-1 flex-shrink-1 rounded text-center ${
submitState.value === SubmitState.Failed ? "bg-light" : ""
error ? "bg-light" : ""
}`}
>
{submitState.result}
{error ? (
error
) : response && responseURI ? (
<a
href={`${publicURIs[responseURI]}/#/silences/${response.silenceID}`}
target="_blank"
rel="noopener noreferrer"
>
{response.silenceID}
</a>
) : null}
</div>
</div>
));
);
};
SilenceSubmitProgress.propTypes = {
cluster: PropTypes.string.isRequired,

View File

@@ -1,4 +1,5 @@
import React from "react";
import { act } from "react-dom/test-utils";
import { mount } from "enzyme";
@@ -65,20 +66,26 @@ const MountedSilenceSubmitProgress = () => {
describe("<SilenceSubmitProgress />", () => {
it("sends a request on mount", async () => {
MountedSilenceSubmitProgress();
await fetchMock.flush(true);
await act(async () => {
await fetchMock.flush(true);
});
expect(fetchMock.calls()).toHaveLength(1);
});
it("appends /api/v2/silences to the passed URI", async () => {
MountedSilenceSubmitProgress();
await fetchMock.flush(true);
await act(async () => {
await fetchMock.flush(true);
});
const uri = fetchMock.calls()[0][0];
expect(uri).toBe("http://localhost/api/v2/silences");
});
it("sends correct JSON payload", async () => {
MountedSilenceSubmitProgress();
await fetchMock.flush(true);
await act(async () => {
await fetchMock.flush(true);
});
const payload = fetchMock.calls()[0][1];
expect(payload).toMatchObject({
method: "POST",
@@ -96,7 +103,9 @@ describe("<SilenceSubmitProgress />", () => {
it("uses CORS credentials from alertmanager config", async () => {
alertStore.data.upstreams.instances[0].corsCredentials = "same-origin";
MountedSilenceSubmitProgress();
await fetchMock.flush(true);
await act(async () => {
await fetchMock.flush(true);
});
expect(fetchMock.calls()[0][0]).toBe("http://localhost/api/v2/silences");
expect(fetchMock.calls()[0][1]).toMatchObject({
credentials: "same-origin",
@@ -146,7 +155,7 @@ describe("<SilenceSubmitProgress />", () => {
mount(
<SilenceSubmitProgress
cluster="ha"
members={["am1", "am2"]}
members={["am2", "am1"]}
payload={{
matchers: [],
startsAt: "now",
@@ -157,7 +166,9 @@ describe("<SilenceSubmitProgress />", () => {
alertStore={alertStore}
/>
);
await fetchMock.flush(true);
await act(async () => {
await fetchMock.flush(true);
});
expect(fetchMock.calls()[0][0]).toBe(
"http://am2.example.com/api/v2/silences"
);
@@ -208,7 +219,7 @@ describe("<SilenceSubmitProgress />", () => {
const tree = mount(
<SilenceSubmitProgress
cluster="ha"
members={["am1", "am2"]}
members={["am2", "am1"]}
payload={{
matchers: [],
startsAt: "now",
@@ -219,7 +230,11 @@ describe("<SilenceSubmitProgress />", () => {
alertStore={alertStore}
/>
);
await fetchMock.flush(true);
await act(async () => {
await act(async () => {
await fetchMock.flush(true);
});
});
expect(fetchMock.calls()).toHaveLength(2);
expect(tree.text()).toBe("hafailed to fetch from am1");
});
@@ -249,7 +264,7 @@ describe("<SilenceSubmitProgress />", () => {
mount(
<SilenceSubmitProgress
cluster="ha"
members={["am1", "am2"]}
members={["am2", "am1"]}
payload={{
matchers: [],
startsAt: "now",
@@ -260,7 +275,11 @@ describe("<SilenceSubmitProgress />", () => {
alertStore={alertStore}
/>
);
await fetchMock.flush(true);
await act(async () => {
await act(async () => {
await fetchMock.flush(true);
});
});
expect(fetchMock.calls()[0][0]).toBe(
"http://am1.example.com/api/v2/silences"
);
@@ -279,7 +298,7 @@ describe("<SilenceSubmitProgress />", () => {
mount(
<SilenceSubmitProgress
cluster="ha"
members={["am1", "am2"]}
members={["am2", "am1"]}
payload={{
matchers: [],
startsAt: "now",
@@ -290,7 +309,11 @@ describe("<SilenceSubmitProgress />", () => {
alertStore={alertStore}
/>
);
await fetchMock.flush(true);
await act(async () => {
await act(async () => {
await fetchMock.flush(true);
});
});
expect(fetchMock.calls()).toHaveLength(0);
expect(consoleSpy).toHaveBeenCalledTimes(2);
});
@@ -336,7 +359,7 @@ describe("<SilenceSubmitProgress />", () => {
mount(
<SilenceSubmitProgress
cluster="ha"
members={["am1", "am2"]}
members={["am2", "am1"]}
payload={{
matchers: [],
startsAt: "now",
@@ -347,7 +370,11 @@ describe("<SilenceSubmitProgress />", () => {
alertStore={alertStore}
/>
);
await fetchMock.flush(true);
await act(async () => {
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"
@@ -397,7 +424,7 @@ describe("<SilenceSubmitProgress />", () => {
mount(
<SilenceSubmitProgress
cluster="ha"
members={["am1", "am2"]}
members={["am2", "am1"]}
payload={{
matchers: [],
startsAt: "now",
@@ -418,7 +445,11 @@ describe("<SilenceSubmitProgress />", () => {
it("renders returned silence ID on successful fetch", async () => {
const tree = MountedSilenceSubmitProgress();
await fetchMock.flush(true);
await act(async () => {
await act(async () => {
await fetchMock.flush(true);
});
});
// force re-render
tree.update();
const silenceLink = tree.find("a");
@@ -433,13 +464,21 @@ describe("<SilenceSubmitProgress />", () => {
body: "mock error message",
});
const tree = MountedSilenceSubmitProgress();
await fetchMock.flush(true);
await act(async () => {
await act(async () => {
await fetchMock.flush(true);
});
});
expect(tree.text()).toBe("mockAlertmanagermock error message");
});
it("renders success icon on successful fetch", async () => {
const tree = MountedSilenceSubmitProgress();
await fetchMock.flush(true);
await act(async () => {
await act(async () => {
await fetchMock.flush(true);
});
});
tree.update();
expect(tree.find("FontAwesomeIcon.text-success")).toHaveLength(1);
expect(tree.find("FontAwesomeIcon.text-danger")).toHaveLength(0);
@@ -447,7 +486,11 @@ describe("<SilenceSubmitProgress />", () => {
it("renders silence link on successful fetch", async () => {
const tree = MountedSilenceSubmitProgress();
await fetchMock.flush(true);
await act(async () => {
await act(async () => {
await fetchMock.flush(true);
});
});
tree.update();
expect(tree.find("a").getDOMNode().getAttribute("href")).toBe(
"http://example.com/#/silences/123456789"
@@ -461,7 +504,11 @@ describe("<SilenceSubmitProgress />", () => {
body: "error message",
});
const tree = MountedSilenceSubmitProgress();
await fetchMock.flush(true);
await act(async () => {
await act(async () => {
await fetchMock.flush(true);
});
});
tree.update();
expect(tree.find("FontAwesomeIcon.text-success")).toHaveLength(0);
expect(tree.find("FontAwesomeIcon.text-danger")).toHaveLength(1);