diff --git a/internal/mock/generator.py b/internal/mock/generator.py new file mode 100755 index 000000000..08d238c38 --- /dev/null +++ b/internal/mock/generator.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python +""" +Generates alerts and sends to Alertmanager API. + +1. Start Alertmanager: + +$ docker run \ + --rm \ + --name prom \ + -p 9093:9093 \ + -v $(pwd)/alertmanager.yml:/etc/alertmanager/alertmanager.yml \ + prom/alertmanager + +2. Start this script +3. Start karma: + +$ karma \ + --alertmanager.uri http://localhost:9093 \ + --alertmanager.interval 10s \ + --annotations.hidden help \ + --labels.color.unique "@receiver instance cluster" \ + --labels.color.static job \ + --filters.default "@receiver=by-cluster-service" +""" + + +import random +import json +import time +import urllib2 + + +API = "http://localhost:9093" + + +def jsonPostRequest(uri, data): + req = urllib2.Request(uri) + req.add_header("Content-Type", "application/json") + response = urllib2.urlopen(req, json.dumps(data)) + + +def addSilence(matchers, startsAt, endsAt, createdBy, comment): + jsonPostRequest("{}/api/v1/silences".format(API), { + "matchers": matchers, + "startsAt": startsAt, + "endsAt": endsAt, + "createdBy": createdBy, + "comment": comment + }) + + +def addAlerts(alerts): + jsonPostRequest("{}/api/v1/alerts".format(API), alerts) + + +def newMatcher(name, value, isRegex): + return {"name": name, "value": value, "isRegex": isRegex} + + +def newAlert(labels, annotations=None, generatorURL="http://localhost:9093"): + return { + "labels": labels, + "annotations": annotations or {}, + "generatorURL": generatorURL + } + + +class AlertGenerator(object): + name = "Fake Alert" + + def __init__(self, interval=15): + self._interval = interval + self._lastSend = 1 + + def _labels(self, **kwargs): + labels = {"alertname": self.name} + labels.update(kwargs) + return labels + + def _send(self): + alerts = self.generate() + if alerts: + print("{} sending {} alert(s)".format(self.name, len(alerts))) + addAlerts(alerts) + + def tick(self): + if time.time() - self._lastSend >= self._interval: + self._send() + self._lastSend = time.time() + + +class AlwaysOnAlert(AlertGenerator): + name = "Always On Alert" + + def generate(self): + return [ + newAlert( + self._labels(instance="server{}".format(i)) + ) for i in xrange(0, 10) + ] + + +class RandomInstances(AlertGenerator): + name = "Random Instances" + + def generate(self): + instances = random.randint(0, 30) + return [ + newAlert( + self._labels(instance="server{}".format(i)) + ) for i in xrange(0, instances) + ] + + +class LowChance(AlertGenerator): + name = "Low Chance" + + def generate(self): + throw = random.randint(0, 100) + if throw > 10: + return [] + return [ + newAlert( + self._labels(instance="server{}".format(i)) + ) for i in xrange(0, 3) + ] + + +class TimeAnnotation(AlertGenerator): + name = "Time Annotation" + + def generate(self): + annotations = {"time": str(int(time.time()))} + return [ + newAlert(self._labels(instance="server1"), annotations) + ] + + +if __name__ == "__main__": + generators = [ + AlwaysOnAlert(15), + RandomInstances(30), + LowChance(60), + TimeAnnotation(5), + ] + while True: + for g in generators: + g.tick() + time.sleep(1) diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.js index eadb02bdd..7ac4e5446 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.js @@ -38,12 +38,6 @@ const MenuContent = onClickOutside( style={popperStyle} data-placement={popperPlacement} > -
onSilenceClick(silenceFormStore, group, alert)} - > - Silence this alert -
Alert source links:
{alert.alertmanager.map(am => ( ))} +
+
onSilenceClick(silenceFormStore, group, alert)} + > + Silence this alert +
); } diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.js index 963e67be6..d8bca5f3d 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Alert/AlertMenu.test.js @@ -70,7 +70,7 @@ const MountedMenuContent = group => { describe("", () => { it("clicking on 'Silence' icon opens the silence form modal", () => { const tree = MountedMenuContent(group); - const button = tree.find(".dropdown-item").at(0); + const button = tree.find(".dropdown-item").at(1); button.simulate("click"); expect(silenceFormStore.toggle.visible).toBe(true); }); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js index a265583e9..40742cb7f 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.js @@ -34,7 +34,7 @@ SilenceComment.propTypes = { silence: PropTypes.object.isRequired }; -const SilenceExpiryBadgeWithProgress = ({ silence }) => { +const SilenceExpiryBadgeWithProgress = ({ silence, progress }) => { // if silence is expired we can skip progress value calculation if (moment(silence.endsAt) < moment()) { return ( @@ -44,15 +44,10 @@ const SilenceExpiryBadgeWithProgress = ({ silence }) => { ); } - const durationDone = moment().unix() - moment(silence.startsAt).unix(); - const durationTotal = - moment(silence.endsAt).unix() - moment(silence.startsAt).unix(); - const durationPercent = (durationDone / durationTotal) * 100; - let progressClass; - if (durationPercent > 90) { + if (progress > 90) { progressClass = "progress-bar bg-danger"; - } else if (durationPercent > 75) { + } else if (progress > 75) { progressClass = "progress-bar bg-warning"; } else { progressClass = "progress-bar bg-success"; @@ -65,8 +60,8 @@ const SilenceExpiryBadgeWithProgress = ({ silence }) => {
@@ -75,7 +70,8 @@ const SilenceExpiryBadgeWithProgress = ({ silence }) => { ); }; SilenceExpiryBadgeWithProgress.propTypes = { - silence: PropTypes.object.isRequired + silence: PropTypes.object.isRequired, + progress: PropTypes.number.isRequired }; const SilenceDetails = ({ alertmanager, silence }) => { @@ -164,12 +160,34 @@ const Silence = inject("alertStore")( { name: "Silence collpase toggle" } ); - componentDidUpdate() { - const { afterUpdate } = this.props; - afterUpdate(); + progress = observable( + { + value: 0, + calculate(startsAt, endsAt) { + const durationDone = moment().unix() - moment(startsAt).unix(); + const durationTotal = + moment(endsAt).unix() - moment(startsAt).unix(); + const durationPercent = Math.floor( + (durationDone / durationTotal) * 100 + ); + if (this.value !== durationPercent) { + this.value = durationPercent; + } + } + }, + { + calculate: action.bound + } + ); + + constructor(props) { + super(props); + + this.recalculateProgress(); + this.progressTimer = setInterval(this.recalculateProgress, 30 * 1000); } - render() { + getSilence = () => { const { alertStore, alertmanager, silenceID } = this.props; // We pass alertmanager name and silence ID to Silence component @@ -177,16 +195,36 @@ const Silence = inject("alertStore")( // Data might be missing from the store so first check if we have // anything for this alertmanager instance const amSilences = alertStore.data.silences[alertmanager.name]; - if (!amSilences) - return ( - - ); + if (!amSilences) return null; // next check if alertmanager has our silence ID const silence = amSilences[silenceID]; + if (!silence) return null; + + return silence; + }; + + recalculateProgress = () => { + const silence = this.getSilence(); + if (silence !== null) { + this.progress.calculate(silence.startsAt, silence.endsAt); + } + }; + + componentDidUpdate() { + const { afterUpdate } = this.props; + afterUpdate(); + } + + componentWillUnmount() { + clearInterval(this.progressTimer); + this.progressTimer = null; + } + + render() { + const { alertmanager, silenceID } = this.props; + + const silence = this.getSilence(); if (!silence) return ( {this.collapse.value ? ( - + ) : null} diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js index 4eb9b29de..dd23a3930 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js @@ -1,5 +1,6 @@ import React from "react"; +import { toJS } from "mobx"; import { Provider } from "mobx-react"; import { mount, shallow } from "enzyme"; @@ -9,7 +10,7 @@ import toDiffableHtml from "diffable-html"; import { advanceTo, clear } from "jest-date-mock"; import { AlertStore } from "Stores/AlertStore"; -import { Silence, SilenceDetails, SilenceExpiryBadgeWithProgress } from "."; +import { Silence, SilenceDetails } from "."; const mockAfterUpdate = jest.fn(); @@ -150,6 +151,14 @@ describe("", () => { expect(link).toHaveLength(1); expect(link.text()).toBe("Fake silence"); }); + + it("clears progress timer on unmount", () => { + const tree = MountedSilence().find("Silence"); + const instance = tree.instance(); + expect(instance.progressTimer).toBeTruthy(); + instance.componentWillUnmount(); + expect(instance.progressTimer).toBeNull(); + }); }); const ShallowSilenceDetails = () => { @@ -173,27 +182,37 @@ describe("", () => { }); }); -const ShallowSilenceExpiryBadgeWithProgress = () => { - return shallow(); -}; - describe("", () => { it("renders with class 'danger' and no progressbar when expired", () => { advanceTo(new Date(2001, 0, 1, 23, 0, 0)); - const tree = ShallowSilenceExpiryBadgeWithProgress(); + const tree = MountedSilence(); expect(tree.html()).toMatch(/badge-danger/); - expect(tree.text()).toBe("Expired "); + expect(tree.text()).toMatch(/Expired a year ago/); }); it("progressbar uses class 'danger' when > 90%", () => { advanceTo(new Date(2000, 0, 1, 19, 30, 0)); - const tree = ShallowSilenceExpiryBadgeWithProgress(); + const tree = MountedSilence(); expect(tree.html()).toMatch(/progress-bar bg-danger/); }); it("progressbar uses class 'danger' when > 75%", () => { advanceTo(new Date(2000, 0, 1, 17, 45, 0)); - const tree = ShallowSilenceExpiryBadgeWithProgress(); + const tree = MountedSilence(); expect(tree.html()).toMatch(/progress-bar bg-warning/); }); + + it("calling calculate() on progress multiple times in a row doesn't change the value", () => { + const startsAt = new Date(2000, 0, 1, 10, 0, 0); + const endsAt = new Date(2000, 0, 1, 20, 0, 0); + + const tree = MountedSilence().find("Silence"); + const instance = tree.instance(); + + const value = toJS(instance.progress.value); + instance.progress.calculate(startsAt, endsAt); + instance.progress.calculate(startsAt, endsAt); + instance.progress.calculate(startsAt, endsAt); + expect(toJS(instance.progress.value)).toBe(value); + }); }); diff --git a/ui/src/Components/SilenceModal/SilenceForm.js b/ui/src/Components/SilenceModal/SilenceForm.js index b71384f35..9bdcf0ca3 100644 --- a/ui/src/Components/SilenceModal/SilenceForm.js +++ b/ui/src/Components/SilenceModal/SilenceForm.js @@ -75,6 +75,9 @@ const SilenceForm = observer( componentDidMount() { const { silenceFormStore, settingsStore } = this.props; + // reset startsAt & endsAt on every mount + silenceFormStore.data.resetStartEnd(); + if (silenceFormStore.data.matchers.length === 0) { silenceFormStore.data.addEmptyMatcher(); } diff --git a/ui/src/Components/SilenceModal/SilenceModalContent.js b/ui/src/Components/SilenceModal/SilenceModalContent.js index 038307d90..a975870ff 100644 --- a/ui/src/Components/SilenceModal/SilenceModalContent.js +++ b/ui/src/Components/SilenceModal/SilenceModalContent.js @@ -14,7 +14,8 @@ const SilenceModalContent = observer( static propTypes = { alertStore: PropTypes.object.isRequired, silenceFormStore: PropTypes.object.isRequired, - settingsStore: PropTypes.object.isRequired + settingsStore: PropTypes.object.isRequired, + onHide: PropTypes.func.isRequired }; componentDidMount() { @@ -26,7 +27,12 @@ const SilenceModalContent = observer( } render() { - const { alertStore, silenceFormStore, settingsStore } = this.props; + const { + alertStore, + silenceFormStore, + settingsStore, + onHide + } = this.props; return ReactDOM.createPortal(
@@ -34,11 +40,7 @@ const SilenceModalContent = observer(
Add new silence
-
diff --git a/ui/src/Components/SilenceModal/index.js b/ui/src/Components/SilenceModal/index.js index 77c24c20b..5ada556d4 100644 --- a/ui/src/Components/SilenceModal/index.js +++ b/ui/src/Components/SilenceModal/index.js @@ -18,6 +18,18 @@ const SilenceModal = observer( settingsStore: PropTypes.object.isRequired }; + toggleModal = () => { + const { silenceFormStore } = this.props; + + silenceFormStore.toggle.toggle(); + if (silenceFormStore.toggle.visible === false) { + // need to reset progress if we're hiding modal + // SilenceSubmitProgress sends a fetch on mount which would result in + // duplicate silences if we didn't reset state of the form on destroy + silenceFormStore.data.resetProgress(); + } + }; + componentDidUpdate() { const { silenceFormStore } = this.props; @@ -37,10 +49,7 @@ const SilenceModal = observer( return (
  • - +
  • @@ -49,6 +58,7 @@ const SilenceModal = observer( alertStore={alertStore} silenceFormStore={silenceFormStore} settingsStore={settingsStore} + onHide={this.toggleModal} /> ) : null} diff --git a/ui/src/Components/SilenceModal/index.test.js b/ui/src/Components/SilenceModal/index.test.js index ad858cf0e..b1aa3d359 100644 --- a/ui/src/Components/SilenceModal/index.test.js +++ b/ui/src/Components/SilenceModal/index.test.js @@ -93,4 +93,13 @@ describe("", () => { tree.unmount(); expect(document.body.className.split(" ")).not.toContain("modal-open"); }); + + it("inProgress is set to false after modal is hidden", () => { + silenceFormStore.toggle.visible = true; + const tree = MountedSilenceModal(); + silenceFormStore.data.inProgress = true; + const toggle = tree.find("button.close"); + toggle.simulate("click"); + expect(silenceFormStore.data.inProgress).toBe(false); + }); }); diff --git a/ui/src/Stores/SilenceFormStore.js b/ui/src/Stores/SilenceFormStore.js index a78e198cd..e8f993619 100644 --- a/ui/src/Stores/SilenceFormStore.js +++ b/ui/src/Stores/SilenceFormStore.js @@ -69,6 +69,11 @@ class SilenceFormStore { return true; }, + resetStartEnd() { + this.startsAt = moment(); + this.endsAt = moment().add(1, "hour"); + }, + resetProgress() { this.inProgress = false; this.wasValidated = false; @@ -193,6 +198,7 @@ class SilenceFormStore { } }, { + resetStartEnd: action.bound, resetProgress: action.bound, addEmptyMatcher: action.bound, deleteMatcher: action.bound, diff --git a/ui/src/Stores/SilenceFormStore.test.js b/ui/src/Stores/SilenceFormStore.test.js index f7566f069..fab7d93b0 100644 --- a/ui/src/Stores/SilenceFormStore.test.js +++ b/ui/src/Stores/SilenceFormStore.test.js @@ -45,6 +45,16 @@ const MockGroup = () => { }; describe("SilenceFormStore.data", () => { + it("resetStartEnd() sets startsAt and endsAt to defaults", () => { + store.data.startsAt = moment([2000, 1, 1, 0, 1, 0]); + store.data.endsAt = moment([2000, 1, 1, 1, 2, 0]); + expect(store.data.startsAt.isSame([2000, 1, 1], "day")).toBe(true); + expect(store.data.endsAt.isSame([2000, 1, 1], "day")).toBe(true); + store.data.resetStartEnd(); + expect(store.data.startsAt.isSame([2000, 1, 1], "day")).toBe(false); + expect(store.data.endsAt.isSame([2000, 1, 1], "day")).toBe(false); + }); + it("resetProgress() sets 'inProgress' to false", () => { store.data.inProgress = true; expect(store.data.inProgress).toBe(true);