mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-15 09:24:11 +00:00
parent
03dea07ae2
commit
b19ddab1bd
19 changed files with 357 additions and 6 deletions
|
@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file.
|
|||
- Add Ctrl+C handler in sendalerts and sendreports management commands
|
||||
- Add notes in docs about configuring uWSGI via UWSGI_ env vars (#656)
|
||||
- Implement login link expiration (login links will now expire in 1 hour)
|
||||
- Add Gotify integration (#270)
|
||||
|
||||
### Bug Fixes
|
||||
- Update hc.front.views.channels to handle empty strings in settings (#635)
|
||||
|
|
|
@ -63,6 +63,7 @@ CHANNEL_KINDS = (
|
|||
("call", "Phone Call"),
|
||||
("linenotify", "LINE Notify"),
|
||||
("signal", "Signal"),
|
||||
("gotify", "Gotify"),
|
||||
)
|
||||
|
||||
PO_PRIORITIES = {-2: "lowest", -1: "low", 0: "normal", 1: "high", 2: "emergency"}
|
||||
|
@ -575,6 +576,8 @@ class Channel(models.Model):
|
|||
return transports.LineNotify(self)
|
||||
elif self.kind == "signal":
|
||||
return transports.Signal(self)
|
||||
elif self.kind == "gotify":
|
||||
return transports.Gotify(self)
|
||||
else:
|
||||
raise NotImplementedError("Unknown channel kind: %s" % self.kind)
|
||||
|
||||
|
@ -876,6 +879,18 @@ class Channel(models.Model):
|
|||
assert self.kind == "linenotify"
|
||||
return self.value
|
||||
|
||||
@property
|
||||
def gotify_url(self):
|
||||
assert self.kind == "gotify"
|
||||
doc = json.loads(self.value)
|
||||
return doc["url"]
|
||||
|
||||
@property
|
||||
def gotify_token(self):
|
||||
assert self.kind == "gotify"
|
||||
doc = json.loads(self.value)
|
||||
return doc["token"]
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
code = models.UUIDField(default=uuid.uuid4, null=True, editable=False)
|
||||
|
|
91
hc/api/tests/test_notify_gotify.py
Normal file
91
hc/api/tests/test_notify_gotify.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
# coding: utf-8
|
||||
|
||||
from datetime import timedelta as td
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.utils.timezone import now
|
||||
from hc.api.models import Channel, Check, Notification
|
||||
from hc.test import BaseTestCase
|
||||
from django.test.utils import override_settings
|
||||
|
||||
|
||||
class NotifyGotidyTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.check = Check(project=self.project)
|
||||
self.check.name = "Foo"
|
||||
self.check.status = "down"
|
||||
self.check.last_ping = now() - td(minutes=61)
|
||||
self.check.save()
|
||||
|
||||
self.channel = Channel(project=self.project)
|
||||
self.channel.kind = "gotify"
|
||||
self.channel.value = json.dumps({"url": "https://example.org", "token": "abc"})
|
||||
self.channel.save()
|
||||
self.channel.checks.add(self.check)
|
||||
|
||||
@patch("hc.api.transports.requests.request")
|
||||
def test_it_works(self, mock_post):
|
||||
mock_post.return_value.status_code = 200
|
||||
|
||||
self.channel.notify(self.check)
|
||||
assert Notification.objects.count() == 1
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
payload = kwargs["json"]
|
||||
self.assertEqual(payload["title"], "Foo is DOWN")
|
||||
self.assertIn(self.check.cloaked_url(), payload["message"])
|
||||
|
||||
@patch("hc.api.transports.requests.request")
|
||||
def test_it_shows_all_other_checks_up_note(self, mock_post):
|
||||
mock_post.return_value.status_code = 200
|
||||
|
||||
other = Check(project=self.project)
|
||||
other.name = "Foobar"
|
||||
other.status = "up"
|
||||
other.last_ping = now() - td(minutes=61)
|
||||
other.save()
|
||||
|
||||
self.channel.notify(self.check)
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
payload = kwargs["json"]
|
||||
self.assertIn("All the other checks are up.", payload["message"])
|
||||
|
||||
@patch("hc.api.transports.requests.request")
|
||||
def test_it_lists_other_down_checks(self, mock_post):
|
||||
mock_post.return_value.status_code = 200
|
||||
|
||||
other = Check(project=self.project)
|
||||
other.name = "Foobar"
|
||||
other.status = "down"
|
||||
other.last_ping = now() - td(minutes=61)
|
||||
other.save()
|
||||
|
||||
self.channel.notify(self.check)
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
payload = kwargs["json"]
|
||||
self.assertIn("The following checks are also down", payload["message"])
|
||||
self.assertIn("Foobar", payload["message"])
|
||||
self.assertIn(other.cloaked_url(), payload["message"])
|
||||
|
||||
@patch("hc.api.transports.requests.request")
|
||||
def test_it_does_not_show_more_than_10_other_checks(self, mock_post):
|
||||
mock_post.return_value.status_code = 200
|
||||
|
||||
for i in range(0, 11):
|
||||
other = Check(project=self.project)
|
||||
other.name = f"Foobar #{i}"
|
||||
other.status = "down"
|
||||
other.last_ping = now() - td(minutes=61)
|
||||
other.save()
|
||||
|
||||
self.channel.notify(self.check)
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
payload = kwargs["json"]
|
||||
self.assertNotIn("Foobar", payload["message"])
|
||||
self.assertIn("11 other checks are also down.", payload["message"])
|
|
@ -3,6 +3,7 @@ import logging
|
|||
import os
|
||||
import socket
|
||||
import time
|
||||
from urllib.parse import quote, urlencode, urljoin
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
|
@ -10,7 +11,6 @@ from django.template.loader import render_to_string
|
|||
from django.utils import timezone
|
||||
from django.utils.html import escape
|
||||
import requests
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from hc.accounts.models import Profile
|
||||
from hc.api.schemas import telegram_migration
|
||||
|
@ -953,3 +953,20 @@ class Signal(Transport):
|
|||
ctx = {"check": check, "down_checks": self.down_checks(check)}
|
||||
text = tmpl("signal_message.html", **ctx)
|
||||
self.send(self.channel.phone_number, text)
|
||||
|
||||
|
||||
class Gotify(HttpTransport):
|
||||
def notify(self, check, notification=None) -> None:
|
||||
url = urljoin(self.channel.gotify_url, "/message")
|
||||
url += "?" + urlencode({"token": self.channel.gotify_token})
|
||||
|
||||
ctx = {"check": check, "down_checks": self.down_checks(check)}
|
||||
payload = {
|
||||
"title": tmpl("gotify_title.html", **ctx),
|
||||
"message": tmpl("gotify_message.html", **ctx),
|
||||
"extras": {
|
||||
"client::display": {"contentType": "text/markdown"},
|
||||
},
|
||||
}
|
||||
|
||||
self.post(url, json=payload)
|
||||
|
|
|
@ -320,3 +320,12 @@ class AddTrelloForm(forms.Form):
|
|||
|
||||
def get_value(self):
|
||||
return json.dumps(dict(self.cleaned_data), sort_keys=True)
|
||||
|
||||
|
||||
class AddGotifyForm(forms.Form):
|
||||
error_css_class = "has-error"
|
||||
token = forms.CharField(max_length=50)
|
||||
url = forms.URLField(max_length=1000, validators=[WebhookValidator()])
|
||||
|
||||
def get_value(self):
|
||||
return json.dumps(dict(self.cleaned_data), sort_keys=True)
|
||||
|
|
41
hc/front/tests/test_add_gotify.py
Normal file
41
hc/front/tests/test_add_gotify.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
from hc.api.models import Channel
|
||||
from hc.test import BaseTestCase
|
||||
|
||||
|
||||
class AddGotifyTestCase(BaseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.url = f"/projects/{self.project.code}/add_gotify/"
|
||||
|
||||
def test_instructions_work(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "Gotify")
|
||||
|
||||
def test_it_works(self):
|
||||
form = {"url": "http://example.org", "token": "abc"}
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertRedirects(r, self.channels_url)
|
||||
|
||||
c = Channel.objects.get()
|
||||
self.assertEqual(c.kind, "gotify")
|
||||
self.assertEqual(c.gotify_url, "http://example.org")
|
||||
self.assertEqual(c.gotify_token, "abc")
|
||||
self.assertEqual(c.project, self.project)
|
||||
|
||||
def test_it_rejects_bad_url(self):
|
||||
form = {"url": "not an URL", "token": "abc"}
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.post(self.url, form)
|
||||
self.assertContains(r, "Enter a valid URL")
|
||||
|
||||
def test_it_requires_rw_access(self):
|
||||
self.bobs_membership.role = "r"
|
||||
self.bobs_membership.save()
|
||||
|
||||
self.client.login(username="bob@example.org", password="password")
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 403)
|
|
@ -57,6 +57,7 @@ project_urls = [
|
|||
path("add_call/", views.add_call, name="hc-add-call"),
|
||||
path("add_discord/", views.add_discord, name="hc-add-discord"),
|
||||
path("add_email/", views.email_form, name="hc-add-email"),
|
||||
path("add_gotify/", views.add_gotify, name="hc-add-gotify"),
|
||||
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"),
|
||||
|
@ -81,8 +82,15 @@ project_urls = [
|
|||
path("badges/", views.badges, name="hc-badges"),
|
||||
path("checks/", views.my_checks, name="hc-checks"),
|
||||
path("checks/add/", views.add_check, name="hc-add-check"),
|
||||
path("checks/metrics/<slug:key>", views.metrics,),
|
||||
path("metrics/<slug:key>", views.metrics, name="hc-metrics",),
|
||||
path(
|
||||
"checks/metrics/<slug:key>",
|
||||
views.metrics,
|
||||
),
|
||||
path(
|
||||
"metrics/<slug:key>",
|
||||
views.metrics,
|
||||
name="hc-metrics",
|
||||
),
|
||||
path("checks/status/", views.status, name="hc-status"),
|
||||
path("integrations/", views.channels, name="hc-channels"),
|
||||
]
|
||||
|
|
|
@ -2089,4 +2089,24 @@ def add_linenotify_complete(request):
|
|||
return redirect("hc-channels", project.code)
|
||||
|
||||
|
||||
@login_required
|
||||
def add_gotify(request, code):
|
||||
project = _get_rw_project_for_user(request, code)
|
||||
|
||||
if request.method == "POST":
|
||||
form = forms.AddGotifyForm(request.POST)
|
||||
if form.is_valid():
|
||||
channel = Channel(project=project, kind="gotify")
|
||||
channel.value = form.get_value()
|
||||
channel.save()
|
||||
|
||||
channel.assign_all_checks()
|
||||
return redirect("hc-channels", project.code)
|
||||
else:
|
||||
form = forms.AddGotifyForm()
|
||||
|
||||
ctx = {"page": "channels", "project": project, "form": form}
|
||||
return render(request, "integrations/add_gotify.html", ctx)
|
||||
|
||||
|
||||
# Forks: add custom views after this line
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
@font-face {
|
||||
font-family: 'icomoon';
|
||||
src:
|
||||
url('../fonts/icomoon.ttf?h8x72b') format('truetype'),
|
||||
url('../fonts/icomoon.woff?h8x72b') format('woff'),
|
||||
url('../fonts/icomoon.svg?h8x72b#icomoon') format('svg');
|
||||
url('../fonts/icomoon.ttf?tkwenv') format('truetype'),
|
||||
url('../fonts/icomoon.woff?tkwenv') format('woff'),
|
||||
url('../fonts/icomoon.svg?tkwenv#icomoon') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
|
@ -24,6 +24,15 @@
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.ic-gotify:before {
|
||||
content: "\e925";
|
||||
color: rgb(255, 255, 255);
|
||||
}
|
||||
.ic-gotify:after {
|
||||
content: "\e926";
|
||||
margin-left: -1em;
|
||||
color: rgb(0, 181, 255);
|
||||
}
|
||||
.ic-call:before {
|
||||
content: "\e91a";
|
||||
color: rgb(255, 255, 255);
|
||||
|
|
File diff suppressed because one or more lines are too long
Before (image error) Size: 48 KiB After (image error) Size: 50 KiB |
Binary file not shown.
Binary file not shown.
BIN
static/img/integrations/gotify.png
Normal file
BIN
static/img/integrations/gotify.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 4.4 KiB |
BIN
static/img/integrations/setup_gotify_1.png
Normal file
BIN
static/img/integrations/setup_gotify_1.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 46 KiB |
BIN
static/img/integrations/setup_gotify_2.png
Normal file
BIN
static/img/integrations/setup_gotify_2.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 24 KiB |
|
@ -248,6 +248,13 @@
|
|||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li>
|
||||
<img src="{% static 'img/integrations/gotify.png' %}" class="icon" alt="Gotify icon" />
|
||||
<h2>Gotify</h2>
|
||||
<p> Self-hosted push notification service.</p>
|
||||
<a href="{% url 'hc-add-gotify' project.code %}" class="btn btn-primary">Add Integration</a>
|
||||
</li>
|
||||
|
||||
{% if enable_linenotify %}
|
||||
<li>
|
||||
<img src="{% static 'img/integrations/linenotify.png' %}"
|
||||
|
|
107
templates/integrations/add_gotify.html
Normal file
107
templates/integrations/add_gotify.html
Normal file
|
@ -0,0 +1,107 @@
|
|||
{% extends "base.html" %}
|
||||
{% load humanize static hc_extras %}
|
||||
|
||||
{% block title %}Gotify Integration for {{ site_name }}{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<h1>Gotify</h1>
|
||||
|
||||
<p>
|
||||
<a href="https://gotify.net/">Gotify</a> is an open-source, self-hosted
|
||||
push notification service. If you use or plan on using Gotify,
|
||||
you can can integrate it with your {{ site_name }} account in few simple steps.
|
||||
</p>
|
||||
|
||||
<h2>Setup Guide</h2>
|
||||
|
||||
<div class="row ai-step">
|
||||
<div class="col-sm-6">
|
||||
<span class="step-no"></span>
|
||||
<p>
|
||||
Log into your Gotify instance, go to
|
||||
<strong>Apps</strong>, and create a new application.
|
||||
</p>
|
||||
<p>
|
||||
Pick a descriptive name and short description.
|
||||
After creating the app, you can also upload an icon for it.
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<img class="ai-guide-screenshot" alt="Click create integration button"
|
||||
src="{% static 'img/integrations/setup_gotify_1.png' %}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row ai-step">
|
||||
<div class="col-sm-6">
|
||||
<span class="step-no"></span>
|
||||
<p>
|
||||
After you have created the application, copy its
|
||||
<strong>Token</strong>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<img class="ai-guide-screenshot" alt="Create Healthchecks.io integration with details"
|
||||
src="{% static 'img/integrations/setup_gotify_2.png' %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row ai-step">
|
||||
<div class="col-sm-6">
|
||||
<span class="step-no"></span>
|
||||
<p>
|
||||
Enter the URL of your Gotify instance, and the
|
||||
application token in the form below.
|
||||
Save the integration, and you are done!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Integration Settings</h2>
|
||||
|
||||
<form method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<div class="form-group {{ form.url.css_classes }}">
|
||||
<label for="url" class="col-sm-2 control-label">Gotify Server URL</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
id="url"
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="url"
|
||||
placeholder="https://"
|
||||
value="{{ form.url.value|default:"" }}">
|
||||
|
||||
{% if form.url.errors %}
|
||||
<div class="help-block">{{ form.url.errors|join:"" }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group {{ form.token.css_classes }}">
|
||||
<label for="url" class="col-sm-2 control-label">Application Token</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
id="token"
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="token"
|
||||
value="{{ form.token.value|default:"" }}">
|
||||
|
||||
{% if form.token.errors %}
|
||||
<div class="help-block">{{ form.token.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 %}
|
23
templates/integrations/gotify_message.html
Normal file
23
templates/integrations/gotify_message.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
{% load humanize linemode %}{% linemode %}
|
||||
{% if check.status == "down" %}
|
||||
{% line %}🔴 The check [{{ check.name_then_code }}]({{ check.cloaked_url }}) is **DOWN**. Last ping was {{ check.last_ping|naturaltime }}.{% endline %}
|
||||
{% else %}
|
||||
{% line %}🟢 The check [{{ check.name_then_code }}]({{ check.cloaked_url }}) is now **UP**.{% endline %}
|
||||
{% endif %}
|
||||
|
||||
{% if down_checks is not None %}
|
||||
{% line %}{% endline %}
|
||||
{% if down_checks %}
|
||||
{% if down_checks|length > 10 %}
|
||||
{% line %}{{ down_checks|length }} other checks are {% if check.status == "down" %}also{% else %}still{% endif %} down.{% endline %}
|
||||
{% else %}
|
||||
{% line %}The following checks are {% if check.status == "down" %}also{% else %}still{% endif %} down:{% endline %}
|
||||
{% for c in down_checks %}
|
||||
{% line %}- [{{ c.name_then_code }}]({{ c.cloaked_url }}) (last ping: {{ c.last_ping|naturaltime }}){% endline %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% line %}All the other checks are up.{% endline %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endlinemode %}
|
1
templates/integrations/gotify_title.html
Normal file
1
templates/integrations/gotify_title.html
Normal file
|
@ -0,0 +1 @@
|
|||
{{ check.name_then_code|safe }} is {{ check.status|upper }}
|
Loading…
Add table
Reference in a new issue