0
0
Fork 0
mirror of https://github.com/healthchecks/healthchecks.git synced 2025-01-11 03:18:33 +00:00

Notification groups. Fixes ()

* MVP for notification groups.

* Addressed review comments.

* Push notification group to the front.

* Updated icons.

* Reduce code duplication.

* Show groups at the top.

* Add label to group forms.

* Add checkboxes for integration selection.

* CSS for checkboxes.

* Added tests for group notify.
This commit is contained in:
Florian Apolloner 2023-10-06 16:02:41 +02:00 committed by GitHub
parent 24bad5af52
commit 7057f6d3a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 294 additions and 21 deletions

View file

@ -45,6 +45,7 @@ TRANSPORTS: dict[str, tuple[str, type[transports.Transport]]] = {
"discord": ("Discord", transports.Discord),
"email": ("Email", transports.Email),
"gotify": ("Gotify", transports.Gotify),
"group": ("Group", transports.Group),
"hipchat": ("HipChat", transports.RemovedTransport),
"linenotify": ("LINE Notify", transports.LineNotify),
"matrix": ("Matrix", transports.Matrix),
@ -793,7 +794,15 @@ class Channel(models.Model):
return {"id": str(self.code), "name": self.name, "kind": self.kind}
def is_editable(self) -> bool:
return self.kind in ("email", "webhook", "sms", "signal", "whatsapp", "ntfy")
return self.kind in (
"email",
"webhook",
"sms",
"signal",
"whatsapp",
"ntfy",
"group",
)
def assign_all_checks(self) -> None:
checks = Check.objects.filter(project=self.project)
@ -1043,6 +1052,13 @@ class Channel(models.Model):
gotify_url = json_property("gotify", "url")
gotify_token = json_property("gotify", "token")
@property
def group_channels(self) -> QuerySet[Channel]:
assert self.kind == "group"
return Channel.objects.filter(
project=self.project, code__in=self.value.split(",")
)
ntfy_topic = json_property("ntfy", "topic")
ntfy_url = json_property("ntfy", "url")
ntfy_priority = json_property("ntfy", "priority")

View file

@ -0,0 +1,72 @@
# coding: utf-8
from __future__ import annotations
import json
from datetime import timedelta as td
from unittest.mock import Mock, patch
from django.utils.timezone import now
from django.test.utils import override_settings
from hc.api.models import Channel, Check, Notification
from hc.test import BaseTestCase
class NotifyGroupTestCase(BaseTestCase):
def setUp(self) -> None:
super().setUp()
self.check = Check(project=self.project)
self.check.name = "Foobar"
self.check.status = "down"
self.check.last_ping = now() - td(minutes=61)
self.check.save()
self.channel_shell = Channel(project=self.project)
self.channel_shell.kind = "shell"
self.channel_shell.value = json.dumps({"cmd_down": "", "cmd_up": ""})
self.channel_shell.save()
self.channel_zulip = Channel(project=self.project)
self.channel_zulip.kind = "zulip"
self.channel_zulip.value = json.dumps(
{
"bot_email": "bot@example.org",
"api_key": "fake-key",
"mtype": "stream",
"to": "general",
}
)
self.channel_zulip.save()
self.channel_zulip.checks.add(self.check)
self.channel = Channel(project=self.project)
self.channel.kind = "group"
self.channel.value = ",".join(
[str(self.channel_shell.code), str(self.channel_zulip.code)]
)
self.channel.save()
self.channel.checks.add(self.check)
def test_it_invalid_group_ids(self) -> None:
self.channel.value = (
"bda20a83-409c-4b2c-8e9b-589d408cd57b,40500bf8-0f37-4bb3-970c-9fe64b7ef39d"
)
self.channel.save()
self.channel.notify(self.check)
assert Notification.objects.count() == 1
assert self.channel.last_error == ""
@patch("hc.api.transports.curl.request")
@patch("hc.api.transports.os.system")
@override_settings(SHELL_ENABLED=True)
def test_it_group(self, mock_system: Mock, mock_post: Mock) -> None:
mock_system.return_value = 123
mock_post.return_value.status_code = 200
self.channel.notify(self.check)
self.channel.refresh_from_db()
assert self.channel.last_error == "1 out of 2 notifications failed"

View file

@ -1214,6 +1214,20 @@ class Gotify(HttpTransport):
self.post(url, json=payload)
class Group(Transport):
def notify(self, check: Check, notification: Notification | None = None) -> None:
channels = self.channel.group_channels
error_count = 0
for channel in channels:
error = channel.notify(check)
if error:
error_count += 1
if error_count:
raise TransportError(
f"{error_count} out of {len(channels)} notifications failed"
)
class Ntfy(HttpTransport):
def priority(self, check: Check) -> int:
if check.status == "up":

View file

@ -10,7 +10,10 @@ from urllib.parse import quote, urlencode
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from hc.api.models import Channel
from hc.front.validators import (
CronExpressionValidator,
TimezoneValidator,
@ -335,6 +338,27 @@ class AddGotifyForm(forms.Form):
return json.dumps(dict(self.cleaned_data), sort_keys=True)
class GroupForm(forms.Form):
def __init__(self, *args, **kwargs):
project = kwargs.pop("project")
super().__init__(*args, **kwargs)
self.fields["channels"].choices = (
(
c.code,
format_html('<span class="ic-{}"></span> {}', mark_safe(c.kind), c),
)
for c in Channel.objects.filter(project=project).exclude(kind="group")
)
error_css_class = "has-error"
label = forms.CharField(max_length=100, required=False)
channels = forms.MultipleChoiceField(widget=forms.CheckboxSelectMultiple)
def get_value(self) -> str:
return ",".join(self.cleaned_data["channels"])
class NtfyForm(forms.Form):
error_css_class = "has-error"
topic = forms.CharField(max_length=50)

View file

@ -63,6 +63,7 @@ project_urls = [
path("add_discord/", views.add_discord, name="hc-add-discord"),
path("add_email/", views.add_email, name="hc-add-email"),
path("add_gotify/", views.add_gotify, name="hc-add-gotify"),
path("add_group/", views.add_group, name="hc-add-group"),
path("add_linenotify/", views.add_linenotify, name="hc-add-linenotify"),
path("add_matrix/", views.add_matrix, name="hc-add-matrix"),
path("add_mattermost/", views.add_mattermost, name="hc-add-mattermost"),

View file

@ -24,7 +24,7 @@ from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.core import signing
from django.core.exceptions import PermissionDenied
from django.db.models import Count, F, QuerySet
from django.db.models import Case, Count, F, QuerySet, When
from django.http import (
Http404,
HttpRequest,
@ -900,7 +900,12 @@ def details(request: AuthenticatedHttpRequest, code: UUID) -> HttpResponse:
check.project.show_slugs = request.GET["urls"] == "slug"
check.project.save()
channels = list(check.project.channel_set.order_by("created"))
all_channels = check.project.channel_set.order_by("created")
regular_channels = []
group_channels = []
for channel in all_channels:
channels = group_channels if channel.kind == "group" else regular_channels
channels.append(channel)
all_tags = set()
q = Check.objects.filter(project=check.project).exclude(tags="")
@ -912,7 +917,8 @@ def details(request: AuthenticatedHttpRequest, code: UUID) -> HttpResponse:
"project": check.project,
"check": check,
"rw": rw,
"channels": channels,
"channels": regular_channels,
"group_channels": group_channels,
"enabled_channels": list(check.channel_set.all()),
"timezones": all_timezones,
"downtimes": check.downtimes(3, request.profile.tz),
@ -1096,8 +1102,10 @@ def channels(request: AuthenticatedHttpRequest, code: UUID) -> HttpResponse:
return redirect("hc-channels", project.code)
channels = Channel.objects.filter(project=project)
channels = channels.order_by("created")
channels = channels.annotate(n_checks=Count("checks"))
channels = channels.annotate(
is_group=Case(When(kind="group", then=0), default=1), n_checks=Count("checks")
)
channels = channels.order_by("is_group", "created")
ctx = {
"page": "channels",
@ -1312,16 +1320,18 @@ def edit_channel(request: AuthenticatedHttpRequest, code: UUID) -> HttpResponse:
channel = _get_rw_channel_for_user(request, code)
if channel.kind == "email":
return email_form(request, channel)
if channel.kind == "webhook":
elif channel.kind == "webhook":
return webhook_form(request, channel)
if channel.kind == "sms":
elif channel.kind == "sms":
return sms_form(request, channel)
if channel.kind == "signal":
elif channel.kind == "signal":
return signal_form(request, channel)
if channel.kind == "whatsapp":
elif channel.kind == "whatsapp":
return whatsapp_form(request, channel)
if channel.kind == "ntfy":
elif channel.kind == "ntfy":
return ntfy_form(request, channel)
elif channel.kind == "group":
return group_form(request, channel)
return HttpResponseBadRequest()
@ -2458,6 +2468,36 @@ def add_gotify(request: AuthenticatedHttpRequest, code: UUID) -> HttpResponse:
return render(request, "integrations/add_gotify.html", ctx)
def group_form(request: HttpRequest, channel: Channel) -> HttpResponse:
adding = channel._state.adding
if request.method == "POST":
form = forms.GroupForm(request.POST, project=channel.project)
if form.is_valid():
channel.name = form.cleaned_data["label"]
channel.value = form.get_value()
channel.save()
if adding:
channel.assign_all_checks()
return redirect("hc-channels", channel.project.code)
elif adding:
form = forms.GroupForm(project=channel.project)
else:
# Filter out unavailable channsl
channels = list(channel.group_channels.values_list("code", flat=True))
form = forms.GroupForm({"channels": channels}, project=channel.project)
ctx = {"page": "channels", "project": channel.project, "form": form}
return render(request, "integrations/group_form.html", ctx)
@login_required
def add_group(request: AuthenticatedHttpRequest, code: UUID) -> HttpResponse:
project = _get_rw_project_for_user(request, code)
channel = Channel(project=project, kind="group")
return group_form(request, channel)
def ntfy_form(request: HttpRequest, channel: Channel) -> HttpResponse:
adding = channel._state.adding
if request.method == "POST":

View file

@ -247,4 +247,20 @@ body.dark img.kind-matrix {
img.kind-ntfy {
filter: drop-shadow(1px 1px 1px rgba(0,0,0,0.2));
}
input[name="channels"] {
display: none;
}
label[for^="id_channels"] {
cursor: pointer;
}
input[name="channels"]:not(:checked) ~ span:before {
color: var(--channel-off-inside-color);
}
input[name="channels"]:not(:checked) ~ span:after {
color: var(--channel-off-color);
}

View file

@ -60,20 +60,20 @@
color: #999;
}
#details-integrations th {
.details-integrations th {
width: 50px;
text-align: center;
}
#details-integrations .label {
.details-integrations .label {
background: #777777;
}
#details-integrations .on .label {
.details-integrations .on .label {
background: #22bc66;
}
#details-integrations.rw tr:hover th, #details-integrations.rw tr:hover td {
.details-integrations.rw tr:hover th, .details-integrations.rw tr:hover td {
cursor: pointer;
background-color: var(--table-bg-hover);
}

View file

@ -1,9 +1,9 @@
@font-face {
font-family: 'icomoon';
src:
url('../fonts/icomoon.ttf?omvtqt') format('truetype'),
url('../fonts/icomoon.woff?omvtqt') format('woff'),
url('../fonts/icomoon.svg?omvtqt#icomoon') format('svg');
url('../fonts/icomoon.ttf?fw1vj7') format('truetype'),
url('../fonts/icomoon.woff?fw1vj7') format('woff'),
url('../fonts/icomoon.svg?fw1vj7#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
@ -24,6 +24,10 @@
-moz-osx-font-smoothing: grayscale;
}
.ic-group:after {
content: "\e932";
color: #f0ad4e;
}
.ic-rocketchat:before {
content: "\e930";
color: #f5455c;

View file

@ -69,4 +69,5 @@
<glyph unicode="&#xe92f;" d="M803.157 338.987l-66.347-22.229 23.040-68.693c9.301-27.733-5.76-57.813-33.536-67.157-6.4-1.92-12.16-2.859-18.56-2.688-21.803 0.64-41.643 14.421-49.28 36.224l-23.040 68.565-136.96-45.781 22.997-68.608c9.003-27.819-5.76-57.941-33.877-67.2-6.4-2.048-12.16-2.859-18.56-2.731-21.76 0.64-41.643 14.507-49.323 36.267l-22.997 69.077-66.603-22.357c-6.4-1.92-12.16-2.603-18.56-2.603-21.803 0.683-41.643 14.72-49.28 36.48-9.6 28.16 5.76 58.197 33.28 67.2l66.56 22.4-42.112 131.755-66.176-22.4c-6.016-2.048-12.16-2.816-18.261-2.731-21.12 0.683-41.6 14.421-48.683 36.181-8.917 27.733 5.76 57.771 33.963 67.157l66.56 22.187-23.040 68.48c-8.96 27.904 5.803 57.984 33.963 67.2 28.117 9.387 58.155-5.803 67.157-33.408l22.997-68.608 136.32 45.824-23.040 68.48c-8.917 27.52 5.76 57.6 33.664 67.157 27.819 9.003 57.984-5.76 67.2-33.749l23.040-69.163 66.347 21.76c27.776 9.344 57.856-5.76 67.2-33.237 9.301-27.904-5.76-57.984-33.451-67.2l-66.432-22.357 44.16-131.669 66.176 22.016c27.819 9.003 57.941-5.76 67.2-33.92 9.387-28.16-5.76-58.24-33.237-67.157zM981.12 567.51c-105.6 351.701-258.091 433.92-609.963 328.277-351.701-105.557-433.92-257.963-328.277-609.963 105.6-351.787 257.963-433.92 609.963-328.277 351.787 105.6 433.92 257.963 328.277 609.963zM421.504 469.547l44.16-131.627 136.747 45.824-44.16 131.157-136.747-46.080z" />
<glyph unicode="&#xe930;" glyph-name="rocketchat" d="M977.361 602.351c-27.77 43.124-66.694 81.3-115.625 113.501-94.54 62.115-218.758 96.335-349.775 96.335-43.776 0-86.9-3.808-128.765-11.343-25.972 25.007-56.34 47.5-88.503 65.274-119.328 59.492-224.486 37.341-277.619 18.234-17.456-6.281-22.842-28.358-9.931-41.679 37.471-38.671 99.466-115.098 84.226-184.599-59.245-60.48-91.369-133.408-91.369-209.341 0-77.379 32.124-150.307 91.369-210.787 15.241-69.501-46.755-145.967-84.226-184.637-12.873-13.283-7.525-35.36 9.931-41.641 53.134-19.107 158.291-41.297 277.657 18.193 32.163 17.775 62.531 40.271 88.503 65.277 41.865-7.537 84.992-11.343 128.765-11.343 131.055 0 255.276 34.179 349.775 96.297 48.931 32.201 87.855 70.339 115.625 113.501 30.941 48.032 46.601 99.759 46.601 153.655-0.038 55.343-15.7 107.028-46.639 155.101zM506.615 173.431c-56.649 0-110.659 7.308-159.895 20.515l-35.983-34.598c-19.558-18.801-42.476-35.817-66.388-49.213-31.665-15.491-62.95-23.98-93.888-26.528 1.757 3.156 3.36 6.353 5.079 9.553 36.058 66.225 45.798 125.754 29.184 178.545-58.979 46.321-94.348 105.623-94.348 170.214 0 148.25 186.365 268.451 416.239 268.451 229.871 0 416.276-120.201 416.276-268.451 0-148.288-186.365-268.489-416.276-268.489zM307.491 558.929c-33.882 0-61.347-27.252-61.347-60.861s27.465-60.861 61.347-60.861c33.879 0 61.344 27.252 61.344 60.861s-27.465 60.861-61.344 60.861zM504.934 558.929c-33.879 0-61.344-27.252-61.344-60.861s27.465-60.861 61.344-60.861c33.882 0 61.347 27.252 61.347 60.861s-27.465 60.861-61.347 60.861zM702.415 558.929c-33.882 0-61.347-27.252-61.347-60.861s27.465-60.861 61.347-60.861c33.879 0 61.344 27.252 61.344 60.861s-27.465 60.861-61.344 60.861z" />
<glyph unicode="&#xe931;" d="M896 938.667h-768c-70.699 0-128-57.301-128-128v-768c0-70.656 57.301-128 128-128h768c70.656 0 128 57.344 128 128v768c0 70.699-57.344 128-128 128zM445.44 162.987c0-33.92-27.52-61.44-61.44-61.44h-189.44c-33.92 0-61.44 27.563-61.44 61.44v581.12c0 33.92 27.52 61.44 61.44 61.44h189.44c33.92 0 61.44-27.52 61.44-61.44v-581.12zM890.88 418.987c0-33.877-27.52-61.44-61.44-61.44h-189.44c-33.92 0-61.44 27.563-61.44 61.44v325.12c0 33.92 27.563 61.44 61.44 61.44h189.44c33.92 0 61.44-27.52 61.44-61.44v-325.12z" />
<glyph unicode="&#xe932;" glyph-name="group" d="M56.396 786.262c-28.625 0-51.828-23.207-51.831-51.831v-439.072l126.261 310.141c8.136 19.99 24.97 32.656 43.407 32.656h646.498v21.728c0 28.625-23.206 51.828-51.831 51.831h-369.431l-50.13 56.963c-9.843 11.18-24.020 17.586-38.916 17.584zM820.731 390.123l-115.611-274.87c-8.248-19.613-24.914-31.967-43.123-31.968h-622.301c5.246-1.788 10.849-2.804 16.7-2.804h712.504c28.625 0 51.828 23.206 51.831 51.831zM247.475 572.639c-19.758 0-37.798-11.232-46.517-28.963l-191.079-388.484c-16.937-34.445 8.133-74.71 46.517-74.71h713.789c19.514 0 37.374 10.959 46.213 28.356l197.416 388.484c17.52 34.481-7.527 75.31-46.203 75.318z" />
</font></defs></svg>

Before

(image error) Size: 52 KiB

After

(image error) Size: 53 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

(image error) Size: 3.6 KiB

View file

@ -62,7 +62,7 @@ $(function () {
}, 300);
});
$("#details-integrations.rw tr").click(function() {
$(".details-integrations.rw tr").click(function() {
var isOn = $(this).toggleClass("on").hasClass("on");
$(".label", this).text(isOn ? "ON" : "OFF");

View file

@ -258,6 +258,13 @@
<a href="{% url 'hc-add-gotify' project.code %}" class="btn btn-primary">Add Integration</a>
</li>
<li>
<img src="{% static 'img/integrations/group.png' %}" class="icon" alt="Group icon" />
<h2>Group</h2>
<p> Deliver to multiple integrations at once.</p>
<a href="{% url 'hc-add-group' project.code %}" class="btn btn-primary">Add Integration</a>
</li>
{% if enable_linenotify %}
<li>
<img src="{% static 'img/integrations/linenotify.png' %}"

View file

@ -207,9 +207,27 @@
</div>
<div class="details-block">
{% if group_channels %}
<h2>Notification Groups</h2>
<table class="details-integrations table {% if rw %}rw{% endif %}">
{% for channel in group_channels %}
<tr data-url="{% url 'hc-switch-channel' check.code channel.code %}" {% if channel in enabled_channels %}class="on"{% endif %}>
<th>
<span class="label">
{% if channel in enabled_channels %}ON{% else %}OFF{% endif %}
</span>
</th>
<td>
<span class="ic-{{ channel.kind }}"></span>
{{ channel }}
</td>
</tr>
{% endfor %}
</table>
{% endif %}
<h2>Notification Methods</h2>
{% if channels %}
<table id="details-integrations" class="table {% if rw %}rw{% endif %}">
<table class="details-integrations table {% if rw %}rw{% endif %}">
{% for channel in channels %}
<tr data-url="{% url 'hc-switch-channel' check.code channel.code %}" {% if channel in enabled_channels %}class="on"{% endif %}>
<th>

View file

@ -82,7 +82,7 @@
</div>
</div>
<div class="form-group {{ form.token.css_classes }}">
<label for="url" class="col-sm-2 control-label">Application Token</label>
<label for="token" class="col-sm-2 control-label">Application Token</label>
<div class="col-sm-10">
<input
id="token"

View file

@ -0,0 +1,60 @@
{% extends "base_project.html" %}
{% load humanize static hc_extras %}
{% block title %}Group Integration for {{ site_name }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-12">
<h1>Group</h1>
<p>Dispatch a notification to multiple integrations at once.</p>
<h2>Integration Settings</h2>
<form method="post" class="form-horizontal">
{% csrf_token %}
<div class="form-group {{ form.label.css_classes }}">
<label for="id_label" class="col-sm-2 control-label">Label</label>
<div class="col-sm-6">
<input
id="id_label"
type="text"
class="form-control"
name="label"
placeholder="VIP group"
value="{{ form.label.value|default:"" }}">
{% if form.label.errors %}
<div class="help-block">
{{ form.label.errors|join:"" }}
</div>
{% else %}
<span class="help-block">
Optional. If you add multiple groups,
the labels will help you tell them apart.
</span>
{% endif %}
</div>
</div>
<div class="form-group {{ form.channels.css_classes }}">
<label for="channels" class="col-sm-2 control-label">Integrations</label>
<div class="col-sm-10">
{{ form.channels }}
{% if form.channels.errors %}
<div class="help-block">{{ form.channels.errors|join:"" }}</div>
{% endif %}
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary">Save Integration</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}