diff --git a/ui/src/Components/AlertAck/index.js b/ui/src/Components/AlertAck/index.js index 17a1fbd82..4f6839bc5 100644 --- a/ui/src/Components/AlertAck/index.js +++ b/ui/src/Components/AlertAck/index.js @@ -192,7 +192,7 @@ const AlertAck = observer( .filter(([amName, alertCount]) => alertCount > 0) .map(([amName, _]) => amName); const clusters = Object.entries( - alertStore.data.upstreams.clusters + alertStore.data.clustersWithoutReadOnly ).filter(([clusterName, clusterMembers]) => alertmanagers.some(m => clusterMembers.includes(m)) ); diff --git a/ui/src/Components/AlertAck/index.test.js b/ui/src/Components/AlertAck/index.test.js index 9512f7dd8..98b33af45 100644 --- a/ui/src/Components/AlertAck/index.test.js +++ b/ui/src/Components/AlertAck/index.test.js @@ -35,6 +35,7 @@ beforeEach(() => { name: "default", uri: "http://localhost", publicURI: "http://example.com", + readonly: false, headers: { foo: "bar" }, error: "", version: "0.15.0", @@ -287,6 +288,7 @@ describe("", () => { name: "default", uri: "http://am1.example.com", publicURI: "http://am1.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", @@ -297,6 +299,7 @@ describe("", () => { name: "fallback", uri: "http://am2.example.com", publicURI: "http://am2.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", @@ -334,6 +337,7 @@ describe("", () => { name: "default", uri: "http://am1.example.com", publicURI: "http://am1.example.com", + readonly: false, headers: {}, error: "", version: "0.16.2", @@ -344,6 +348,7 @@ describe("", () => { name: "fallback", uri: "http://am2.example.com", publicURI: "http://am2.example.com", + readonly: false, headers: {}, error: "", version: "0.16.2", @@ -381,6 +386,7 @@ describe("", () => { name: "default", uri: "http://am1.example.com", publicURI: "http://am1.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", diff --git a/ui/src/Components/ManagedSilence/DeleteSilence.js b/ui/src/Components/ManagedSilence/DeleteSilence.js index 20589f1db..ecac4df05 100644 --- a/ui/src/Components/ManagedSilence/DeleteSilence.js +++ b/ui/src/Components/ManagedSilence/DeleteSilence.js @@ -113,6 +113,7 @@ const DeleteSilenceModalContent = observer( getAlertmanager = () => this.props.alertStore.data.upstreams.instances .filter(u => u.cluster === this.props.cluster) + .filter(u => u.readonly === false) .slice(0, 1)[0]; parseAlertmanagerResponse = response => { @@ -296,11 +297,16 @@ const DeleteSilence = observer( onModalExit } = this.props; + const members = alertStore.data.getClusterAlertmanagersWithoutReadOnly( + cluster + ); + return ( { name: "am1", cluster: "am", uri: "http://localhost:9093", + readonly: false, error: "", version: "0.15.3", headers: {} diff --git a/ui/src/Components/ManagedSilence/SilenceComment.test.js b/ui/src/Components/ManagedSilence/SilenceComment.test.js index 85163e669..f1f575a0e 100644 --- a/ui/src/Components/ManagedSilence/SilenceComment.test.js +++ b/ui/src/Components/ManagedSilence/SilenceComment.test.js @@ -44,6 +44,7 @@ const MockMultipleClusters = () => { name: "default", uri: "http://am1.example.com", publicURI: "http://am1.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", @@ -54,6 +55,7 @@ const MockMultipleClusters = () => { name: "fallback", uri: "http://am2.example.com", publicURI: "http://am2.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", @@ -64,6 +66,7 @@ const MockMultipleClusters = () => { name: "second", uri: "http://am3.example.com", publicURI: "http://am3.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", diff --git a/ui/src/Components/ManagedSilence/SilenceDetails.js b/ui/src/Components/ManagedSilence/SilenceDetails.js index c5e33f236..68a9fffa6 100644 --- a/ui/src/Components/ManagedSilence/SilenceDetails.js +++ b/ui/src/Components/ManagedSilence/SilenceDetails.js @@ -65,6 +65,10 @@ const SilenceDetails = ({ u => u.cluster === cluster ); + const isReadOnly = + alertStore.data.getClusterAlertmanagersWithoutReadOnly(cluster).length === + 0; + return ( @@ -154,7 +158,8 @@ const SilenceDetails = ({ { cluster: "am", uri: "http://localhost:9093", publicURI: "http://example.com", + readonly: false, error: "", version: "0.15.3", headers: {} diff --git a/ui/src/Components/ManagedSilence/index.js b/ui/src/Components/ManagedSilence/index.js index a5c65f346..43bce27cf 100644 --- a/ui/src/Components/ManagedSilence/index.js +++ b/ui/src/Components/ManagedSilence/index.js @@ -47,6 +47,7 @@ const ManagedSilence = observer( getAlertmanager = () => this.props.alertStore.data.upstreams.instances .filter(u => u.cluster === this.props.cluster) + .filter(u => u.readonly === false) .slice(0, 1)[0]; onEditSilence = () => { diff --git a/ui/src/Components/ManagedSilence/index.stories.js b/ui/src/Components/ManagedSilence/index.stories.js index 225019b47..fdbfb9f7e 100644 --- a/ui/src/Components/ManagedSilence/index.stories.js +++ b/ui/src/Components/ManagedSilence/index.stories.js @@ -30,6 +30,7 @@ storiesOf("ManagedSilence", module) clusterMembers: ["am1"], uri: "http://localhost:9093", publicURI: "http://example.com", + readonly: false, error: "", version: "0.15.3", headers: {} @@ -38,6 +39,24 @@ storiesOf("ManagedSilence", module) clusters: { am: ["am1"] } }; + const alertStoreReadOnly = new AlertStore([]); + alertStoreReadOnly.data.upstreams = { + clusters: { ro: ["readonly"] }, + instances: [ + { + name: "readonly", + uri: "http://localhost:8080", + publicURI: "http://example.com", + readonly: true, + headers: {}, + error: "", + version: "0.15.0", + cluster: "ro", + clusterMembers: ["readonly"] + } + ] + }; + const silence = MockSilence(); silence.startsAt = "2018-08-14T16:00:00Z"; silence.endsAt = "2018-08-14T18:00:00Z"; @@ -67,6 +86,16 @@ storiesOf("ManagedSilence", module) onDidUpdate={() => {}} isOpen={true} /> + {}} + isOpen={true} + /> {}} isOpen={true} /> + {}} + isOpen={true} + /> ); }); diff --git a/ui/src/Components/ManagedSilence/index.test.js b/ui/src/Components/ManagedSilence/index.test.js index 63dc58d00..790f0f29e 100644 --- a/ui/src/Components/ManagedSilence/index.test.js +++ b/ui/src/Components/ManagedSilence/index.test.js @@ -33,6 +33,7 @@ beforeEach(() => { clusterMembers: ["am1"], uri: "http://localhost:9093", publicURI: "http://example.com", + readonly: false, error: "", version: "0.15.3", headers: {} @@ -95,6 +96,7 @@ describe("", () => { clusterMembers: ["am1"], uri: "http://localhost:9093", publicURI: "http://example.com", + readonly: false, error: "", version: "0.15.3", headers: {} diff --git a/ui/src/Components/SilenceModal/AlertManagerInput/index.js b/ui/src/Components/SilenceModal/AlertManagerInput/index.js index 0af26a784..145e96dcc 100644 --- a/ui/src/Components/SilenceModal/AlertManagerInput/index.js +++ b/ui/src/Components/SilenceModal/AlertManagerInput/index.js @@ -30,7 +30,7 @@ const AlertManagerInput = observer( if (silenceFormStore.data.alertmanagers.length === 0) { silenceFormStore.data.alertmanagers = AlertmanagerClustersToOption( - alertStore.data.upstreams.clusters + alertStore.data.clustersWithoutReadOnly ); } } @@ -46,7 +46,7 @@ const AlertManagerInput = observer( // get the list of last known alertmanagers const currentAlertmanagers = AlertmanagerClustersToOption( - alertStore.data.upstreams.clusters + alertStore.data.clustersWithoutReadOnly ); // now iterate what's set as silence form values and reset it if any @@ -75,7 +75,7 @@ const AlertManagerInput = observer( instanceId="silence-input-alertmanagers" defaultValue={silenceFormStore.data.alertmanagers} options={AlertmanagerClustersToOption( - alertStore.data.upstreams.clusters + alertStore.data.clustersWithoutReadOnly )} getOptionValue={JSON.stringify} placeholder={ diff --git a/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js b/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js index 21927decb..a6cf97797 100644 --- a/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js +++ b/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js @@ -27,6 +27,7 @@ beforeEach(() => { name: "am1", uri: "http://am1.example.com", publicURI: "http://am1.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", @@ -37,6 +38,7 @@ beforeEach(() => { name: "am2", uri: "http://am2.example.com", publicURI: "http://am2.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", @@ -47,6 +49,7 @@ beforeEach(() => { name: "am3", uri: "http://am3.example.com", publicURI: "http://am3.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", @@ -202,4 +205,15 @@ describe("", () => { expect(silenceFormStore.data.alertmanagers).toHaveLength(0); expect(silenceFormStore.data.alertmanagers).toEqual([]); }); + + it("doesn't include readonly instances", () => { + alertStore.data.upstreams.instances[0].readonly = true; + alertStore.data.upstreams.instances[2].readonly = true; + MountedAlertManagerInput(); + expect(silenceFormStore.data.alertmanagers).toHaveLength(1); + expect(silenceFormStore.data.alertmanagers).toContainEqual({ + label: "am2", + value: ["am2"] + }); + }); }); diff --git a/ui/src/Components/SilenceModal/Browser/index.test.js b/ui/src/Components/SilenceModal/Browser/index.test.js index 2c0642fdc..006616411 100644 --- a/ui/src/Components/SilenceModal/Browser/index.test.js +++ b/ui/src/Components/SilenceModal/Browser/index.test.js @@ -36,6 +36,7 @@ beforeEach(() => { clusterMembers: ["am1"], uri: "http://localhost:9093", publicURI: "http://example.com", + readonly: false, error: "", version: "0.15.3", headers: {} diff --git a/ui/src/Components/SilenceModal/SilenceModalContent.js b/ui/src/Components/SilenceModal/SilenceModalContent.js index cf7efad8e..fa0c63ef6 100644 --- a/ui/src/Components/SilenceModal/SilenceModalContent.js +++ b/ui/src/Components/SilenceModal/SilenceModalContent.js @@ -3,6 +3,9 @@ import PropTypes from "prop-types"; import { observer } from "mobx-react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faLock } from "@fortawesome/free-solid-svg-icons/faLock"; + import { AlertStore } from "Stores/AlertStore"; import { SilenceFormStore, @@ -16,6 +19,15 @@ import { SilencePreview } from "./SilencePreview"; import { SilenceSubmitController } from "./SilenceSubmit/SilenceSubmitController"; import { Browser } from "./Browser"; +const ReadOnlyPlaceholder = () => ( + + + + Read only mode + + +); + const SilenceModalContent = observer( class SilenceModalContent extends Component { static propTypes = { @@ -82,12 +94,17 @@ const SilenceModalContent = observer( {silenceFormStore.tab.current === SilenceTabNames.Editor ? ( silenceFormStore.data.currentStage === SilenceFormStage.UserInput ? ( - + Object.keys(alertStore.data.clustersWithoutReadOnly).length > + 0 ? ( + + ) : ( + + ) ) : silenceFormStore.data.currentStage === SilenceFormStage.Preview ? ( { settingsStore = new Settings(); silenceFormStore = new SilenceFormStore(); + alertStore.data.upstreams = { + instances: [ + { + name: "am1", + cluster: "am", + uri: "http://localhost:9093", + readonly: false, + error: "", + version: "0.15.3", + headers: {} + } + ], + clusters: { am: ["am1"] } + }; + silenceFormStore.tab.current = SilenceTabNames.Editor; }); +afterEach(() => { + jest.restoreAllMocks(); +}); + const MockOnHide = jest.fn(); const ShallowSilenceModalContent = () => { @@ -38,6 +57,13 @@ const ShallowSilenceModalContent = () => { }; describe("", () => { + it("Renders ReadOnlyPlaceholder when there are no writable Alertmanager upstreams", () => { + alertStore.data.upstreams.instances[0].readonly = true; + const tree = ShallowSilenceModalContent(); + const placeholder = tree.find("ReadOnlyPlaceholder"); + expect(placeholder).toHaveLength(1); + }); + it("Clicking on the Browser tab changes content", () => { const tree = ShallowSilenceModalContent(); const tabs = tree.find("Tab"); diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js index 56f047992..8e094b666 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js @@ -95,9 +95,16 @@ const SilenceSubmitProgress = observer( const member = this.submitState.membersToTry.pop(); + if (alertStore.data.isReadOnlyAlertmanager(member)) { + const err = `Alertmanager instance "${member}" is read-only`; + console.error(err); + this.maybeTryAgainAfterError(err); + return; + } + const am = alertStore.data.getAlertmanagerByName(member); if (am === undefined) { - const err = `Alertmanager instance "${member} not found`; + const err = `Alertmanager instance "${member}" not found`; console.error(err); this.maybeTryAgainAfterError(err); return; diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js index 4fe66a414..8af0ca694 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js @@ -15,6 +15,7 @@ beforeEach(() => { name: "mockAlertmanager", uri: "http://localhost", publicURI: "http://example.com", + readonly: false, headers: { foo: "bar" }, error: "", version: "0.15.0", @@ -25,6 +26,10 @@ beforeEach(() => { }; }); +afterEach(() => { + jest.restoreAllMocks(); +}); + const MountedSilenceSubmitProgress = () => { return mount( ", () => { name: "am1", uri: "http://am1.example.com", publicURI: "http://am1.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", @@ -104,6 +110,7 @@ describe("", () => { name: "am2", uri: "http://am2.example.com", publicURI: "http://am2.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", @@ -150,6 +157,7 @@ describe("", () => { name: "am1", uri: "http://am1.example.com", publicURI: "http://am1.example.com", + readonly: false, headers: {}, error: "", version: "0.15.0", @@ -180,6 +188,66 @@ describe("", () => { expect(consoleSpy).toHaveBeenCalledTimes(1); }); + it("will refuse to send requests to an alertmanager instance that is readonly", async () => { + fetch.resetMocks(); + const logs = []; + const consoleSpy = jest + .spyOn(console, "error") + .mockImplementation((message, ...args) => { + logs.push(message); + }); + + alertStore.data.upstreams = { + clusters: { ha: ["am1", "am2"] }, + instances: [ + { + name: "am1", + uri: "http://am1.example.com", + publicURI: "http://am1.example.com", + readonly: false, + headers: {}, + error: "", + version: "0.15.0", + cluster: "ha", + clusterMembers: ["am1", "am2"] + }, + { + name: "am2", + uri: "http://am2.example.com", + publicURI: "http://am2.example.com", + readonly: true, + headers: {}, + error: "", + version: "0.15.0", + cluster: "ha", + clusterMembers: ["am1", "am2"] + } + ] + }; + + const tree = mount( + + ); + await expect(tree.instance().submitState.fetch).resolves.toBeUndefined(); + expect(fetch.mock.calls).toHaveLength(1); + expect(fetch.mock.calls[0][0]).toBe( + "http://am1.example.com/api/v1/silences" + ); + expect(logs).toEqual(['Alertmanager instance "am2" is read-only']); + expect(consoleSpy).toHaveBeenCalledTimes(1); + }); + it("renders returned silence ID on successful fetch", async () => { fetch.mockResponseOnce( JSON.stringify({ status: "success", data: { silenceId: "123456789" } }) diff --git a/ui/src/Components/SilenceModal/index.stories.js b/ui/src/Components/SilenceModal/index.stories.js index ca59f129e..7afee41d6 100644 --- a/ui/src/Components/SilenceModal/index.stories.js +++ b/ui/src/Components/SilenceModal/index.stories.js @@ -40,6 +40,23 @@ storiesOf("SilenceModal", module) const settingsStore = new Settings(); const silenceFormStore = new SilenceFormStore(); + alertStore.data.upstreams = { + clusters: { default: ["default"] }, + instances: [ + { + name: "default", + uri: "http://localhost:8080", + publicURI: "http://example.com", + readonly: false, + headers: {}, + error: "", + version: "0.15.0", + cluster: "default", + clusterMembers: ["default"] + } + ] + }; + silenceFormStore.toggle.visible = true; silenceFormStore.data.matchers = [ MockMatcher("cluster", ["prod"], false), @@ -96,8 +113,36 @@ storiesOf("SilenceModal", module) } ); + const alertStoreReadOnly = new AlertStore([]); + alertStoreReadOnly.data.upstreams = { + clusters: { default: ["readonly"] }, + instances: [ + { + name: "readonly", + uri: "http://localhost:8080", + publicURI: "http://example.com", + readonly: true, + headers: {}, + error: "", + version: "0.15.0", + cluster: "default", + clusterMembers: ["readonly"] + } + ] + }; + return ( + + {}} + previewOpen={true} + onDeleteModalClose={() => {}} + /> + am.name === name); }, + isReadOnlyAlertmanager(name) { + return this.readOnlyAlertmanagers.map(am => am.name).includes(name); + }, + getClusterAlertmanagersWithoutReadOnly(clusterID) { + return this.clustersWithoutReadOnly[clusterID] || []; + }, + get readOnlyAlertmanagers() { + return this.upstreams.instances.filter(am => am.readonly === true); + }, + get clustersWithoutReadOnly() { + const clusters = {}; + for (const clusterID of Object.keys(this.upstreams.clusters)) { + const members = this.upstreams.clusters[clusterID].filter( + member => this.isReadOnlyAlertmanager(member) === false + ); + if (members.length > 0) { + clusters[clusterID] = members; + } + } + return clusters; + }, getColorData(name, value) { if (this.colors[name] !== undefined) { return this.colors[name][value]; } } }, - {}, + { + readOnlyAlertmanagers: computed, + clustersWithoutReadOnly: computed + }, { name: "API Response data" } ); diff --git a/ui/src/Stores/AlertStore.test.js b/ui/src/Stores/AlertStore.test.js index 6b95b35d9..98c5e67c7 100644 --- a/ui/src/Stores/AlertStore.test.js +++ b/ui/src/Stores/AlertStore.test.js @@ -20,6 +20,78 @@ afterEach(() => { delete process.env.REACT_APP_BACKEND_URI; }); +describe("AlertStore.data", () => { + it("getClusterAlertmanagersWithoutReadOnly filters out readonly instances", () => { + const store = new AlertStore([]); + store.data.upstreams = { + clusters: { default: ["default", "readonly"] }, + instances: [ + { + name: "default", + uri: "http://localhost", + publicURI: "http://example.com:8080", + readonly: false, + headers: { foo: "bar" }, + error: "", + version: "0.15.0", + cluster: "default", + clusterMembers: ["default", "readonly"] + }, + { + name: "readonly", + uri: "http://localhost:8081", + publicURI: "http://example.com", + readonly: true, + headers: {}, + error: "", + version: "0.15.0", + cluster: "default", + clusterMembers: ["default", "readonly"] + } + ] + }; + expect( + store.data.getClusterAlertmanagersWithoutReadOnly("default") + ).toEqual(["default"]); + }); +}); + +describe("AlertStore.data", () => { + it("getClusterAlertmanagersWithoutReadOnly handles clusters with no writable instances", () => { + const store = new AlertStore([]); + store.data.upstreams = { + clusters: { default: ["ro1", "ro2"] }, + instances: [ + { + name: "ro1", + uri: "http://localhost", + publicURI: "http://example.com:8080", + readonly: true, + headers: {}, + error: "", + version: "0.15.0", + cluster: "default", + clusterMembers: ["ro1", "ro2"] + }, + { + name: "ro2", + uri: "http://localhost:8081", + publicURI: "http://example.com", + readonly: true, + headers: {}, + error: "", + version: "0.15.0", + cluster: "default", + clusterMembers: ["ro1", "ro2"] + } + ] + }; + expect( + store.data.getClusterAlertmanagersWithoutReadOnly("default") + ).toEqual([]); + }); +}); + describe("AlertStore.status", () => { it("status is initially idle with no error", () => { const store = new AlertStore([]); diff --git a/ui/src/__mocks__/Alerts.js b/ui/src/__mocks__/Alerts.js index d09fce329..9b6225f18 100644 --- a/ui/src/__mocks__/Alerts.js +++ b/ui/src/__mocks__/Alerts.js @@ -70,6 +70,7 @@ const MockAlertmanager = () => ({ cluster: "default", uri: "http://localhost", publicURI: "http://am.example.com", + readonly: false, headers: { Authorization: "Basic foo bar" },