0
0
Fork 0
mirror of https://github.com/healthchecks/healthchecks.git synced 2025-04-15 09:24:11 +00:00

Add Gotify integration

Fixes: 
This commit is contained in:
Pēteris Caune 2022-06-01 16:13:41 +03:00
parent 03dea07ae2
commit b19ddab1bd
No known key found for this signature in database
GPG key ID: E28D7679E9A9EDE2
19 changed files with 357 additions and 6 deletions

View file

@ -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)

View file

@ -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)

View 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"])

View file

@ -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)

View file

@ -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)

View 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)

View file

@ -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"),
]

View file

@ -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

View file

@ -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.

Binary file not shown.

After

(image error) Size: 4.4 KiB

Binary file not shown.

After

(image error) Size: 46 KiB

Binary file not shown.

After

(image error) Size: 24 KiB

View file

@ -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' %}"

View 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 %}

View 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 %}

View file

@ -0,0 +1 @@
{{ check.name_then_code|safe }} is {{ check.status|upper }}