From 809c6c6fab5b672e15c67760795fd65f7fd73613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Mon, 22 Apr 2019 23:38:02 +0100 Subject: [PATCH] feat(ui): use api/v2 silence endpoints for alertmanager 0.16+ --- ui/package.json | 1 + .../AlertGroup/Silence/DeleteSilence.js | 28 ++++++++-- .../AlertGroup/Silence/DeleteSilence.test.js | 30 +++++++++- .../SilenceSubmit/SilenceSubmitProgress.js | 44 +++++++++++++-- .../SilenceSubmitProgress.test.js | 55 ++++++++++++++++++- 5 files changed, 142 insertions(+), 16 deletions(-) diff --git a/ui/package.json b/ui/package.json index 90ed8f0b3..d8afad5e4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -50,6 +50,7 @@ "react-tippy": "1.2.3", "react-transition-group": "4.0.0", "react-truncate": "2.4.0", + "semver": "6.0.0", "whatwg-fetch": "3.0.0" }, "scripts": { diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js index 586933a09..256297fac 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js @@ -4,6 +4,8 @@ import PropTypes from "prop-types"; import { observable, action } from "mobx"; import { observer } from "mobx-react"; +import semver from "semver"; + import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faTrash } from "@fortawesome/free-solid-svg-icons/faTrash"; import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle"; @@ -119,9 +121,7 @@ const DeleteSilenceModalContent = observer( ]); this.previewState.fetch = fetch(alertsURI, { credentials: "include" }) - .then(result => { - return result.json(); - }) + .then(result => result.json()) .then(result => { this.previewState.groupsToUniqueLabels(Object.values(result.groups)); this.previewState.setError(null); @@ -140,13 +140,29 @@ const DeleteSilenceModalContent = observer( // if it's already deleted then do nothing if (this.deleteState.done && this.deleteState.error === null) return; - const uri = `${alertmanager.publicURI}/api/v1/silence/${silenceID}`; + const isOpenAPI = semver.satisfies(alertmanager.version, ">=0.16.0"); + + const uri = isOpenAPI + ? `${alertmanager.publicURI}/api/v2/silence/${silenceID}` + : `${alertmanager.publicURI}/api/v1/silence/${silenceID}`; + this.deleteState.fetch = fetch(uri, { method: "DELETE", credentials: "include" }) - .then(result => result.json()) - .then(result => this.parseAlertmanagerResponse(result)) + .then(result => { + if (isOpenAPI) { + if (result.ok) { + this.deleteState.setError(null); + this.deleteState.setDone(); + } else { + result.text().then(this.deleteState.setError); + this.deleteState.setDone(); + } + } else { + result.json().then(this.parseAlertmanagerResponse); + } + }) .catch(err => { console.trace(err); this.deleteState.setDone(); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js index 9da08c88d..a119c9309 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js @@ -13,6 +13,7 @@ let alertStore; beforeEach(() => { alertmanager = MockAlertmanager(); alertStore = new AlertStore([]); + alertStore.data.upstreams.instances[0] = alertmanager; fetch.mockResponseOnce(JSON.stringify(MockAPIResponse())); jest.restoreAllMocks(); @@ -121,7 +122,7 @@ describe("", () => { expect(tree.find("ErrorMessage")).toHaveLength(1); }); - it("sends a DELETE request after clicking 'Confirm' button", async () => { + it("[v1] sends a DELETE request after clicking 'Confirm' button ", async () => { await VerifyResponse({ status: "success" }); expect(fetch.mock.calls[1][0]).toBe( "http://am.example.com/api/v1/silence/123456789" @@ -129,6 +130,15 @@ describe("", () => { expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" }); }); + it("[v2] sends a DELETE request after clicking 'Confirm' button ", async () => { + alertmanager.version = "0.16.2"; + await VerifyResponse({ status: "success" }); + expect(fetch.mock.calls[1][0]).toBe( + "http://am.example.com/api/v2/silence/123456789" + ); + expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" }); + }); + it("'Confirm' button is no-op after successful DELETE", async () => { const tree = await VerifyResponse({ status: "success" }); expect(fetch.mock.calls[1][0]).toBe( @@ -165,7 +175,7 @@ describe("", () => { expect(tree.find("ErrorMessage")).toHaveLength(1); }); - it("renders ErrorMessage on failed fetch request", async () => { + it("[v1] renders ErrorMessage on failed fetch request", async () => { const tree = MountedDeleteSilenceModalContent(); await expect(tree.instance().previewState.fetch).resolves.toBeUndefined(); @@ -179,4 +189,20 @@ describe("", () => { tree.update(); expect(tree.find("ErrorMessage")).toHaveLength(1); }); + + it("[v2] renders ErrorMessage on failed fetch request", async () => { + alertmanager.version = "0.16.2"; + const tree = MountedDeleteSilenceModalContent(); + await expect(tree.instance().previewState.fetch).resolves.toBeUndefined(); + + jest.spyOn(console, "trace").mockImplementation(() => {}); + fetch.resetMocks(); + fetch.mockResponseOnce("500 Internal Server Error", { status: 500 }); + + tree.find(".btn-outline-danger").simulate("click"); + await expect(tree.instance().deleteState.fetch).resolves.toBeUndefined(); + + tree.update(); + expect(tree.find("ErrorMessage")).toHaveLength(1); + }); }); diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js index bbfec10d4..45c322ae7 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js @@ -4,6 +4,8 @@ import PropTypes from "prop-types"; import { action, observable } from "mobx"; import { observer } from "mobx-react"; +import semver from "semver"; + import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch"; import { faCheckCircle } from "@fortawesome/free-regular-svg-icons/faCheckCircle"; @@ -54,7 +56,8 @@ const SilenceSubmitProgress = observer( startsAt: PropTypes.string.isRequired, endsAt: PropTypes.string.isRequired, createdBy: PropTypes.string.isRequired, - comment: PropTypes.string.isRequired + comment: PropTypes.string.isRequired, + id: PropTypes.string }).isRequired, alertStore: PropTypes.instanceOf(AlertStore).isRequired }; @@ -99,7 +102,13 @@ const SilenceSubmitProgress = observer( return; } - this.submitState.fetch = fetch(`${am.publicURI}/api/v1/silences`, { + const isOpenAPI = semver.satisfies(am.version, ">=0.16.0"); + + const uri = isOpenAPI + ? `${am.publicURI}/api/v2/silences` + : `${am.publicURI}/api/v1/silences`; + + this.submitState.fetch = fetch(uri, { method: "POST", body: JSON.stringify(payload), headers: { @@ -107,9 +116,34 @@ const SilenceSubmitProgress = observer( }, credentials: "include" }) - .then(result => result.json()) - .then(result => this.parseAlertmanagerResponse(am.uri, result)) - .catch(err => this.maybeTryAgainAfterError(err)); + .then(result => { + if (isOpenAPI) { + if (result.ok) { + return result + .json() + .then(r => this.parseOpenAPIResponse(am.uri, r)); + } else { + return result.text().then(text => { + this.submitState.markFailed(text); + return text; + }); + } + } else { + return result + .json() + .then(r => this.parseAlertmanagerResponse(am.uri, r)); + } + }) + .catch(err => { + this.maybeTryAgainAfterError(err); + }); + }; + + parseOpenAPIResponse = (uri, response) => { + const link = ; + this.submitState.markDone(link); + // return silenceId so we can assert it in tests + return response.silenceID; }; parseAlertmanagerResponse = (uri, response) => { diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js index 6e18f0de2..446cd2d2d 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js @@ -48,7 +48,15 @@ describe("", () => { expect(fetch.mock.calls).toHaveLength(1); }); - it("appends /api/v1/silences to the passed URI", async () => { + it("[v1] 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://example.com/api/v1/silences"); + }); + + it("[v2] appends /api/v2/silences to the passed URI", async () => { + alertStore.data.upstreams.instances[0].version = "0.16.2"; const tree = MountedSilenceSubmitProgress(); await expect(tree.instance().submitState.fetch).resolves.toBeUndefined(); const uri = fetch.mock.calls[0][0]; @@ -188,7 +196,7 @@ describe("", () => { expect(tree.text()).toBe("mockAlertmanagermock error message"); }); - it("renders success icon on successful fetch", async () => { + it("[v1] renders success icon on successful fetch", async () => { fetch.mockResponseOnce( JSON.stringify({ status: "success", data: { silenceId: "123" } }) ); @@ -199,7 +207,36 @@ describe("", () => { expect(tree.find("FontAwesomeIcon.text-danger")).toHaveLength(0); }); - it("renders error icon on failed fetch", async () => { + it("[v1] renders silence link on successful fetch", async () => { + fetch.mockResponseOnce( + JSON.stringify({ status: "success", data: { silenceId: "123" } }) + ); + const tree = MountedSilenceSubmitProgress(); + await expect(tree.instance().submitState.fetch).resolves.toBe("success"); + tree.update(); + expect(tree.find("a").getDOMNode().getAttribute("href")).toBe("file:///mock/#/silences/123"); + }); + + it("[v2] renders success icon on successful fetch", async () => { + alertStore.data.upstreams.instances[0].version = "0.16.2"; + fetch.mockResponseOnce(JSON.stringify({ silenceID: "123" })); + const tree = MountedSilenceSubmitProgress(); + await expect(tree.instance().submitState.fetch).resolves.toBe("123"); + tree.update(); + expect(tree.find("FontAwesomeIcon.text-success")).toHaveLength(1); + expect(tree.find("FontAwesomeIcon.text-danger")).toHaveLength(0); + }); + + it("[v2] renders silence link on successful fetch", async () => { + alertStore.data.upstreams.instances[0].version = "0.16.2"; + fetch.mockResponseOnce(JSON.stringify({ silenceID: "123" })); + const tree = MountedSilenceSubmitProgress(); + await expect(tree.instance().submitState.fetch).resolves.toBe("123"); + tree.update(); + expect(tree.find("a").getDOMNode().getAttribute("href")).toBe("file:///mock/#/silences/123"); + }); + + it("[v1] renders error icon on failed fetch", async () => { fetch.mockResponseOnce(JSON.stringify({ status: "error" })); const tree = MountedSilenceSubmitProgress(); await expect(tree.instance().submitState.fetch).resolves.toBe("error"); @@ -208,6 +245,18 @@ describe("", () => { expect(tree.find("FontAwesomeIcon.text-danger")).toHaveLength(1); }); + it("[v2] renders error icon on failed fetch", async () => { + alertStore.data.upstreams.instances[0].version = "0.16.2"; + fetch.mockResponseOnce("error message", { status: 500 }); + const tree = MountedSilenceSubmitProgress(); + await expect(tree.instance().submitState.fetch).resolves.toBe( + "error message" + ); + tree.update(); + expect(tree.find("FontAwesomeIcon.text-success")).toHaveLength(0); + expect(tree.find("FontAwesomeIcon.text-danger")).toHaveLength(1); + }); + it("renders unhandled 'status' values in the response as error", async () => { fetch.mockResponseOnce(JSON.stringify({ status: "unhandled" })); const tree = MountedSilenceSubmitProgress();