Merge pull request #7 from prymitive/rc-fixes

Collection of small fixes found via live generator (included)
This commit is contained in:
Łukasz Mierzwa
2018-09-09 23:18:48 +01:00
committed by GitHub
11 changed files with 300 additions and 50 deletions
+149
View File
@@ -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)
@@ -38,12 +38,6 @@ const MenuContent = onClickOutside(
style={popperStyle}
data-placement={popperPlacement}
>
<div
className="dropdown-item cursor-pointer"
onClick={() => onSilenceClick(silenceFormStore, group, alert)}
>
<FontAwesomeIcon icon={faBellSlash} /> Silence this alert
</div>
<h6 className="dropdown-header">Alert source links:</h6>
{alert.alertmanager.map(am => (
<a
@@ -57,6 +51,13 @@ const MenuContent = onClickOutside(
{am.name}
</a>
))}
<div className="dropdown-divider" />
<div
className="dropdown-item cursor-pointer"
onClick={() => onSilenceClick(silenceFormStore, group, alert)}
>
<FontAwesomeIcon icon={faBellSlash} /> Silence this alert
</div>
</div>
);
}
@@ -70,7 +70,7 @@ const MountedMenuContent = group => {
describe("<MenuContent />", () => {
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);
});
@@ -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 }) => {
<div
className={progressClass}
role="progressbar"
style={{ width: durationPercent + "%" }}
aria-valuenow={durationPercent}
style={{ width: progress + "%" }}
aria-valuenow={progress}
aria-valuemin="0"
aria-valuemax="100"
/>
@@ -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 (
<FallbackSilenceDesciption
alertmanager={alertmanager}
silenceID={silenceID}
/>
);
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 (
<FallbackSilenceDesciption
@@ -213,7 +251,10 @@ const Silence = inject("alertStore")(
{silence.createdBy}
</cite>
{this.collapse.value ? (
<SilenceExpiryBadgeWithProgress silence={silence} />
<SilenceExpiryBadgeWithProgress
silence={silence}
progress={this.progress.value}
/>
) : null}
</span>
</span>
@@ -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("<Silence />", () => {
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("<SilenceDetails />", () => {
});
});
const ShallowSilenceExpiryBadgeWithProgress = () => {
return shallow(<SilenceExpiryBadgeWithProgress silence={silence} />);
};
describe("<SilenceExpiryBadgeWithProgress />", () => {
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 <t />");
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);
});
});
@@ -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();
}
@@ -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(
<div className="modal d-block bg-primary-transparent-80" role="dialog">
@@ -34,11 +40,7 @@ const SilenceModalContent = observer(
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Add new silence</h5>
<button
type="button"
className="close"
onClick={silenceFormStore.toggle.hide}
>
<button type="button" className="close" onClick={onHide}>
<span className="align-middle">&times;</span>
</button>
</div>
+14 -4
View File
@@ -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 (
<React.Fragment>
<li className="nav-item">
<a
className="nav-link cursor-pointer"
onClick={silenceFormStore.toggle.toggle}
>
<a className="nav-link cursor-pointer" onClick={this.toggleModal}>
<FontAwesomeIcon icon={faBellSlash} />
</a>
</li>
@@ -49,6 +58,7 @@ const SilenceModal = observer(
alertStore={alertStore}
silenceFormStore={silenceFormStore}
settingsStore={settingsStore}
onHide={this.toggleModal}
/>
) : null}
</React.Fragment>
@@ -93,4 +93,13 @@ describe("<SilenceModal />", () => {
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);
});
});
+6
View File
@@ -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,
+10
View File
@@ -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);