feat(ui): disable silence actions for read-only alertmanagers

This commit is contained in:
Łukasz Mierzwa
2020-01-28 22:26:56 +00:00
parent 572f9975b8
commit 3df4d27efc
22 changed files with 357 additions and 15 deletions
+1 -1
View File
@@ -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))
);
+6
View File
@@ -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("<AlertAck />", () => {
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("<AlertAck />", () => {
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("<AlertAck />", () => {
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("<AlertAck />", () => {
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("<AlertAck />", () => {
name: "default",
uri: "http://am1.example.com",
publicURI: "http://am1.example.com",
readonly: false,
headers: {},
error: "",
version: "0.15.0",
@@ -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 (
<React.Fragment>
<button
className="btn btn-danger btn-sm"
onClick={this.toggle.toggle}
disabled={members.length === 0}
onClick={members.length && this.toggle.toggle}
>
<FontAwesomeIcon
className="mr-1 d-none d-sm-inline-block"
@@ -26,6 +26,7 @@ beforeEach(() => {
name: "am1",
cluster: "am",
uri: "http://localhost:9093",
readonly: false,
error: "",
version: "0.15.3",
headers: {}
@@ -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",
@@ -65,6 +65,10 @@ const SilenceDetails = ({
u => u.cluster === cluster
);
const isReadOnly =
alertStore.data.getClusterAlertmanagersWithoutReadOnly(cluster).length ===
0;
return (
<div className="mt-1">
<div className="d-flex flex-fill flex-lg-row flex-column justify-content-between">
@@ -154,7 +158,8 @@ const SilenceDetails = ({
<div className="d-flex flex-fill flex-lg-column flex-row justify-content-around">
<button
className="btn btn-primary btn-sm mb-lg-2 mb-0"
onClick={onEditSilence}
disabled={isReadOnly}
onClick={!isReadOnly && onEditSilence}
>
<FontAwesomeIcon
className="mr-1 d-none d-sm-inline-block"
@@ -34,6 +34,7 @@ beforeEach(() => {
cluster: "am",
uri: "http://localhost:9093",
publicURI: "http://example.com",
readonly: false,
error: "",
version: "0.15.3",
headers: {}
@@ -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 = () => {
@@ -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}
/>
<ManagedSilence
cluster={"ro"}
alertCount={123}
alertCountAlwaysVisible={true}
silence={silence}
alertStore={alertStoreReadOnly}
silenceFormStore={silenceFormStore}
onDidUpdate={() => {}}
isOpen={true}
/>
<ManagedSilence
cluster={cluster}
alertCount={123}
@@ -86,6 +115,16 @@ storiesOf("ManagedSilence", module)
onDidUpdate={() => {}}
isOpen={true}
/>
<ManagedSilence
cluster={"ro"}
alertCount={123}
alertCountAlwaysVisible={true}
silence={expiredSilence}
alertStore={alertStoreReadOnly}
silenceFormStore={silenceFormStore}
onDidUpdate={() => {}}
isOpen={true}
/>
</React.Fragment>
);
});
@@ -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("<ManagedSilence />", () => {
clusterMembers: ["am1"],
uri: "http://localhost:9093",
publicURI: "http://example.com",
readonly: false,
error: "",
version: "0.15.3",
headers: {}
@@ -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={
@@ -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("<AlertManagerInput />", () => {
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"]
});
});
});
@@ -36,6 +36,7 @@ beforeEach(() => {
clusterMembers: ["am1"],
uri: "http://localhost:9093",
publicURI: "http://example.com",
readonly: false,
error: "",
version: "0.15.3",
headers: {}
@@ -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 = () => (
<div className="jumbotron bg-transparent">
<h1 className="display-5 text-placeholder text-center">
<FontAwesomeIcon icon={faLock} className="mr-3" />
Read only mode
</h1>
</div>
);
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 ? (
<SilenceForm
alertStore={alertStore}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
previewOpen={previewOpen}
/>
Object.keys(alertStore.data.clustersWithoutReadOnly).length >
0 ? (
<SilenceForm
alertStore={alertStore}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
previewOpen={previewOpen}
/>
) : (
<ReadOnlyPlaceholder />
)
) : silenceFormStore.data.currentStage ===
SilenceFormStage.Preview ? (
<SilencePreview
@@ -20,9 +20,28 @@ beforeEach(() => {
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("<SilenceModalContent />", () => {
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");
@@ -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;
@@ -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(
<SilenceSubmitProgress
@@ -94,6 +99,7 @@ describe("<SilenceSubmitProgress />", () => {
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("<SilenceSubmitProgress />", () => {
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("<SilenceSubmitProgress />", () => {
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("<SilenceSubmitProgress />", () => {
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(
<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).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" } })
@@ -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 (
<React.Fragment>
<Modal>
<SilenceModalContent
alertStore={alertStoreReadOnly}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
onHide={() => {}}
previewOpen={true}
onDeleteModalClose={() => {}}
/>
</Modal>
<Modal>
<SilenceModalContent
alertStore={alertStore}
@@ -142,6 +187,7 @@ storiesOf("SilenceModal", module)
clusterMembers: ["am1"],
uri: "http://localhost:9093",
publicURI: "http://example.com",
readonly: false,
error: "",
version: "0.15.3",
headers: {}
@@ -204,6 +250,7 @@ storiesOf("SilenceModal", module)
clusterMembers: ["am1"],
uri: "http://localhost:9093",
publicURI: "http://example.com",
readonly: false,
error: "",
version: "0.15.3",
headers: {}
+1
View File
@@ -69,6 +69,7 @@ const APIAlertmanagerUpstream = PropTypes.exact({
cluster: PropTypes.string.isRequired,
uri: PropTypes.string.isRequired,
publicURI: PropTypes.string.isRequired,
readonly: PropTypes.bool.isRequired,
headers: PropTypes.object.isRequired,
error: PropTypes.string.isRequired,
version: PropTypes.string.isRequired,
+26 -2
View File
@@ -1,4 +1,4 @@
import { observable, action, toJS } from "mobx";
import { observable, action, computed, toJS } from "mobx";
import throttle from "lodash/throttle";
@@ -167,13 +167,37 @@ class AlertStore {
getAlertmanagerByName(name) {
return this.upstreams.instances.find(am => 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" }
);
+72
View File
@@ -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([]);
+1
View File
@@ -70,6 +70,7 @@ const MockAlertmanager = () => ({
cluster: "default",
uri: "http://localhost",
publicURI: "http://am.example.com",
readonly: false,
headers: {
Authorization: "Basic foo bar"
},