0
0
Fork 0
mirror of https://github.com/healthchecks/healthchecks.git synced 2025-04-07 22:25:35 +00:00

Add ntfy integration

Fixes: 
This commit is contained in:
Pēteris Caune 2022-11-24 12:09:53 +02:00
parent 9977789cac
commit 3dcc7d60a2
No known key found for this signature in database
GPG key ID: E28D7679E9A9EDE2
23 changed files with 588 additions and 23 deletions

View file

@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file.
- Update Mattermost setup instructions
- Add support for specifying a run ID via a "rid" query parameter (#722)
- Add last ping body in Slack notifications (#735)
- Add ntfy integration (#728)
### Bug Fixes
- Fix the most recent ping lookup in the "Ping Details" dialog

View file

@ -53,6 +53,7 @@ CHANNEL_KINDS = (
("matrix", "Matrix"),
("mattermost", "Mattermost"),
("msteams", "Microsoft Teams"),
("ntfy", "ntfy"),
("opsgenie", "Opsgenie"),
("pagerteam", "Pager Team"),
("pagertree", "PagerTree"),
@ -82,6 +83,15 @@ PO_PRIORITIES = {
2: "emergency",
}
NTFY_PRIORITIES = {
5: "max",
4: "high",
3: "default",
2: "low",
1: "min",
0: "disabled",
}
def isostring(dt) -> str | None:
"""Convert the datetime to ISO 8601 format with no microseconds."""
@ -611,7 +621,7 @@ class Channel(models.Model):
return {"id": str(self.code), "name": self.name, "kind": self.kind}
def is_editable(self):
return self.kind in ("email", "webhook", "sms", "signal", "whatsapp")
return self.kind in ("email", "webhook", "sms", "signal", "whatsapp", "ntfy")
def assign_all_checks(self):
checks = Check.objects.filter(project=self.project)
@ -672,6 +682,8 @@ class Channel(models.Model):
return transports.Mattermost(self)
elif self.kind == "msteams":
return transports.MsTeams(self)
elif self.kind == "ntfy":
return transports.Ntfy(self)
elif self.kind == "opsgenie":
return transports.Opsgenie(self)
elif self.kind == "pagertree":
@ -1024,6 +1036,34 @@ class Channel(models.Model):
doc = json.loads(self.value)
return doc["token"]
@property
def ntfy_topic(self):
assert self.kind == "ntfy"
doc = json.loads(self.value)
return doc["topic"]
@property
def ntfy_url(self):
assert self.kind == "ntfy"
doc = json.loads(self.value)
return doc["url"]
@property
def ntfy_priority(self):
assert self.kind == "ntfy"
doc = json.loads(self.value)
return doc["priority"]
@property
def ntfy_priority_up(self):
assert self.kind == "ntfy"
doc = json.loads(self.value)
return doc["priority_up"]
@property
def ntfy_priority_display(self):
return NTFY_PRIORITIES[self.ntfy_priority]
class Notification(models.Model):
code = models.UUIDField(default=uuid.uuid4, null=True, editable=False)

View file

@ -0,0 +1,100 @@
# coding: utf-8
from __future__ import annotations
import json
from datetime import timedelta as td
from unittest.mock import patch
from django.utils.timezone import now
from hc.api.models import Channel, Check, Notification
from hc.test import BaseTestCase
class NotifyNtfyTestCase(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 = "ntfy"
self.channel.value = json.dumps(
{
"url": "https://example.org",
"topic": "foo",
"priority": 5,
"priority_up": 1,
}
)
self.channel.save()
self.channel.checks.add(self.check)
@patch("hc.api.transports.curl.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.assertEqual(payload["actions"][0]["url"], self.check.cloaked_url())
self.assertNotIn("All the other checks are up.", payload["message"])
@patch("hc.api.transports.curl.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.curl.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"])
@patch("hc.api.transports.curl.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

@ -1000,3 +1000,33 @@ class Gotify(HttpTransport):
}
self.post(url, json=payload)
class Ntfy(HttpTransport):
def priority(self, check):
if check.status == "up":
return self.channel.ntfy_priority_up
return self.channel.ntfy_priority
def is_noop(self, check) -> bool:
return self.priority(check) == 0
def notify(self, check, notification=None) -> None:
ctx = {"check": check, "down_checks": self.down_checks(check)}
payload = {
"topic": self.channel.ntfy_topic,
"priority": self.priority(check),
"title": tmpl("ntfy_title.html", **ctx),
"message": tmpl("ntfy_message.html", **ctx),
"tags": ["red_circle" if check.status == "down" else "green_circle"],
"actions": [
{
"action": "view",
"label": f"View on {settings.SITE_NAME}",
"url": check.cloaked_url(),
}
],
}
self.post(self.channel.ntfy_url, json=payload)

View file

@ -341,6 +341,17 @@ class AddGotifyForm(forms.Form):
return json.dumps(dict(self.cleaned_data), sort_keys=True)
class NtfyForm(forms.Form):
error_css_class = "has-error"
topic = forms.CharField(max_length=50)
url = forms.URLField(max_length=1000, validators=[WebhookValidator()])
priority = forms.IntegerField(initial=3, min_value=0, max_value=5)
priority_up = forms.IntegerField(initial=3, min_value=0, max_value=5)
def get_value(self):
return json.dumps(dict(self.cleaned_data), sort_keys=True)
class SearchForm(forms.Form):
q = forms.RegexField(regex=r"^[0-9a-zA-Z\s]{3,100}$")

View file

@ -0,0 +1,72 @@
from __future__ import annotations
from django.test.utils import override_settings
from hc.api.models import Channel, Check
from hc.test import BaseTestCase
class AddNtfyTestCase(BaseTestCase):
def setUp(self):
super().setUp()
self.check = Check.objects.create(project=self.project)
self.url = f"/projects/{self.project.code}/add_ntfy/"
def test_instructions_work(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url)
self.assertContains(r, "simple HTTP-based pub-sub")
def test_it_creates_channel(self):
form = {
"topic": "foo",
"url": "https://example.org",
"priority": "5",
"priority_up": "1",
}
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, "ntfy")
self.assertEqual(c.ntfy_topic, "foo")
self.assertEqual(c.ntfy_url, "https://example.org")
self.assertEqual(c.ntfy_priority, 5)
self.assertEqual(c.ntfy_priority_up, 1)
self.assertEqual(c.project, self.project)
# Make sure it calls assign_all_checks
self.assertEqual(c.checks.count(), 1)
def test_it_requires_topic(self):
form = {
"url": "https://example.org",
"priority": "5",
"priority_up": "1",
}
self.client.login(username="alice@example.org", password="password")
r = self.client.post(self.url, form)
self.assertContains(r, "This field is required")
def test_it_validates_url(self):
form = {
"topic": "foo",
"url": "this is not an url",
"priority": "5",
"priority_up": "1",
}
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

@ -0,0 +1,61 @@
from __future__ import annotations
import json
from hc.api.models import Channel, Check
from hc.test import BaseTestCase
class EditNtfyTestCase(BaseTestCase):
def setUp(self):
super().setUp()
self.check = Check.objects.create(project=self.project)
self.channel = Channel(project=self.project, kind="ntfy")
self.channel.value = json.dumps(
{
"topic": "foo-bar-baz",
"url": "https://example.org",
"priority": 3,
"priority_up": 0,
}
)
self.channel.save()
self.url = f"/integrations/{self.channel.code}/edit/"
def test_instructions_work(self):
self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url)
self.assertContains(r, "Save Integration")
self.assertContains(r, "https://example.org")
self.assertContains(r, "foo-bar-baz")
def test_it_updates_channel(self):
form = {
"topic": "updated-topic",
"url": "https://example.com",
"priority": "4",
"priority_up": "1",
}
self.client.login(username="alice@example.org", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, self.channels_url)
self.channel.refresh_from_db()
self.assertEqual(self.channel.ntfy_topic, "updated-topic")
self.assertEqual(self.channel.ntfy_url, "https://example.com")
self.assertEqual(self.channel.ntfy_priority, 4)
self.assertEqual(self.channel.ntfy_priority_up, 1)
# Make sure it does not call assign_all_checks
self.assertFalse(self.channel.checks.exists())
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

@ -66,6 +66,7 @@ project_urls = [
path("add_matrix/", views.add_matrix, name="hc-add-matrix"),
path("add_mattermost/", views.add_mattermost, name="hc-add-mattermost"),
path("add_msteams/", views.add_msteams, name="hc-add-msteams"),
path("add_ntfy/", views.ntfy_form, name="hc-add-ntfy"),
path("add_opsgenie/", views.add_opsgenie, name="hc-add-opsgenie"),
path("add_pagertree/", views.add_pagertree, name="hc-add-pagertree"),
path("add_pd/", views.add_pd, name="hc-add-pd"),

View file

@ -1210,6 +1210,8 @@ def edit_channel(request: HttpRequest, code: UUID) -> HttpResponse:
return signal_form(request, channel=channel)
if channel.kind == "whatsapp":
return whatsapp_form(request, channel=channel)
if channel.kind == "ntfy":
return ntfy_form(request, channel=channel)
return HttpResponseBadRequest()
@ -2304,6 +2306,43 @@ def add_gotify(request, code):
return render(request, "integrations/add_gotify.html", ctx)
@login_required
def ntfy_form(request, channel=None, code=None):
is_new = channel is None
if is_new:
project = _get_rw_project_for_user(request, code)
channel = Channel(project=project, kind="ntfy")
if request.method == "POST":
form = forms.NtfyForm(request.POST)
if form.is_valid():
channel.value = form.get_value()
channel.save()
if is_new:
channel.assign_all_checks()
return redirect("hc-channels", channel.project.code)
elif is_new:
form = forms.NtfyForm()
else:
form = forms.NtfyForm(
{
"topic": channel.ntfy_topic,
"url": channel.ntfy_url,
"priority": channel.ntfy_priority,
}
)
ctx = {
"page": "channels",
"project": channel.project,
"form": form,
"profile": channel.project.owner_profile,
"is_new": is_new,
}
return render(request, "integrations/ntfy_form.html", ctx)
@require_setting("SIGNAL_CLI_SOCKET")
@login_required
def signal_captcha(request: HttpRequest) -> HttpResponse:

View file

@ -1,3 +0,0 @@
#add-pushover .help {
opacity: 0.6;
}

View file

@ -270,3 +270,8 @@ input[type=number]::-webkit-inner-spin-button {
background-color: var(--btn-remove-hover);
color: var(--btn-remove-color);
}
/* Greyed out help text in bootstrap-select dropdowns */
.dropdown .help {
opacity: 0.6;
}

View file

@ -240,3 +240,7 @@ body.dark .icon.mattermost,
body.dark .icon.matrix {
filter: invert();
}
img.kind-ntfy {
filter: drop-shadow(1px 1px 1px rgba(0,0,0,0.2));
}

View file

@ -1,9 +1,9 @@
@font-face {
font-family: 'icomoon';
src:
url('../fonts/icomoon.ttf?tkwenv') format('truetype'),
url('../fonts/icomoon.woff?tkwenv') format('woff'),
url('../fonts/icomoon.svg?tkwenv#icomoon') format('svg');
url('../fonts/icomoon.ttf?bncoc2') format('truetype'),
url('../fonts/icomoon.woff?bncoc2') format('woff'),
url('../fonts/icomoon.svg?bncoc2#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
@ -23,6 +23,15 @@
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.ic-ntfy:before {
content: "\e927";
color: rgb(51, 133, 116);
}
.ic-ntfy:after {
content: "\e928";
margin-left: -1em;
color: rgb(255, 255, 255);
}
.ic-gotify:before {
content: "\e925";

File diff suppressed because one or more lines are too long

Before

(image error) Size: 50 KiB

After

(image error) Size: 50 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

(image error) Size: 2.6 KiB

View file

@ -29,7 +29,6 @@
<link rel="stylesheet" href="{% static 'css/add_credential.css' %}" type="text/css">
<link rel="stylesheet" href="{% static 'css/add_project_modal.css' %}" type="text/css">
<link rel="stylesheet" href="{% static 'css/add_pushover.css' %}" type="text/css">
<link rel="stylesheet" href="{% static 'css/appearance.css' %}" type="text/css">
<link rel="stylesheet" href="{% static 'css/webhook_form.css' %}" type="text/css">
<link rel="stylesheet" href="{% static 'css/badges.css' %}" type="text/css">

View file

@ -28,7 +28,7 @@
{% for ch in channels %}
<tr class="channel-row kind-{{ ch.kind }}">
<td class="icon-cell">
<img src="{% static ch.icon_path %}" alt="{{ ch.get_kind_display }}" />
<img src="{% static ch.icon_path %}" alt="{{ ch.get_kind_display }}" class="kind-{{ ch.kind }}" />
</td>
<td>
<div class="edit-name" data-toggle="modal" data-target="#name-{{ ch.code }}">
@ -104,6 +104,9 @@
{% if ch.signal_notify_up and not ch.signal_notify_down %}
(up only)
{% endif %}
{% elif ch.kind == "ntfy" %}
ntfy topic <span>{{ ch.ntfy_topic }}</span>,
{{ ch.ntfy_priority_display }} priority
{% else %}
{{ ch.get_kind_display }}
{% endif %}
@ -299,6 +302,15 @@
</li>
{% endif %}
<li>
<img src="{% static 'img/integrations/ntfy.png' %}"
class="icon kind-ntfy" alt="ntfy" />
<h2>ntfy</h2>
<p>Send push notifications to your phone or desktop via PUT/POST.</p>
<a href="{% url 'hc-add-ntfy' project.code %}" class="btn btn-primary">Add Integration</a>
</li>
{% if enable_opsgenie %}
<li>
<img src="{% static 'img/integrations/opsgenie.png' %}"

View file

@ -32,12 +32,12 @@
</option>
<option
value="-2"
data-content="Lowest Priority. <span class='help'>Generates no notification on your device.</span>">
data-content="Lowest priority. <span class='help'>Generates no notification on your device.</span>">
Lowest Priority
</option>
<option
value="-1"
data-content="Low Priority. <span class='help'>Sends a quiet notification.</span>">
data-content="Low priority. <span class='help'>Sends a quiet notification.</span>">
Low Priority
</option>
<option value="0" selected="selected">
@ -45,12 +45,12 @@
</option>
<option
value="1"
data-content="High Priority. <span class='help'>Bypasses user's quiet hours.</span>">
data-content="High priority. <span class='help'>Bypasses user's quiet hours.</span>">
High Priority
</option>
<option
value="2"
data-content="Emergency Priority. <span class='help'>Repeated every {{po_retry_delay|hc_duration }} for at most {{ po_expiration|hc_duration }} until you acknowledge them.</span>">
data-content="Emergency priority. <span class='help'>Repeated every {{po_retry_delay|hc_duration }} for at most {{ po_expiration|hc_duration }} until you acknowledge them.</span>">
Emergency Priority
</option>
</select>
@ -68,26 +68,26 @@
</option>
<option
value="-2"
data-content="Lowest Priority. <span class='help'>Generates no notification on your device.</span>">
Lowest Priority
data-content="Lowest priority. <span class='help'>Generates no notification on your device.</span>">
Lowest priority
</option>
<option
value="-1"
data-content="Low Priority. <span class='help'>Sends a quiet notification.</span>">
Low Priority
data-content="Low priority. <span class='help'>Sends a quiet notification.</span>">
Low priority
</option>
<option value="0" selected="selected">
Normal Priority
Normal priority
</option>
<option
value="1"
data-content="High Priority. <span class='help'>Bypasses user's quiet hours.</span>">
High Priority
data-content="High priority. <span class='help'>Bypasses user's quiet hours.</span>">
High priority
</option>
<option
value="2"
data-content="Emergency Priority. <span class='help'>Repeated every {{po_retry_delay|hc_duration }} for at most {{ po_expiration|hc_duration }} until you acknowledge them.</span>">
Emergency Priority
data-content="Emergency priority. <span class='help'>Repeated every {{po_retry_delay|hc_duration }} for at most {{ po_expiration|hc_duration }} until you acknowledge them.</span>">
Emergency priority
</option>
</select>
</div>

View file

@ -0,0 +1,159 @@
{% extends "base.html" %}
{% load compress humanize static hc_extras %}
{% block title %}ntfy Integration for {{ site_name }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-12">
<h1>ntfy</h1>
<div class="jumbotron">
<p>
<a href="https://ntfy.sh/">ntfy</a> is is a simple HTTP-based pub-sub
notification service. If you use or plan on using ntfy,
you can can integrate it with your {{ site_name }} account in few simple steps.
</p>
</div>
<h2>Integration Settings</h2>
<form method="post" class="form-horizontal">
{% csrf_token %}
<div class="form-group {{ form.topic.css_classes }}">
<label for="topic" class="col-sm-3 control-label">Topic</label>
<div class="col-sm-6">
<input
id="topic"
name="topic"
type="text"
class="form-control"
value="{{ form.topic.value|default:'' }}"
required>
{% if form.topic.errors %}
<div class="help-block">{{ form.topic.errors|join:"" }}</div>
{% endif %}
</div>
</div>
<div class="form-group {{ form.url.css_classes }}">
<label for="url" class="col-sm-3 control-label">Server URL</label>
<div class="col-sm-6">
<input
id="url"
name="url"
type="text"
class="form-control"
placeholder="https://"
value="{{ form.url.value|default:'https://ntfy.sh' }}"
required>
{% if form.url.errors %}
<div class="help-block">{{ form.url.errors|join:"" }}</div>
{% endif %}
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">Priority for "down" events</label>
<div class="col-sm-8">
<select name="priority" class="selectpicker form-control">
<option
value="5"
{% if form.priority.value == 5 %}selected="selected"{% endif %}
data-content="Max priority. <span class='help'>Really long vibration bursts, default notification sound with a pop-over notification.</span>">
Max priority
</option>
<option
value="4"
{% if form.priority.value == 4 %}selected="selected"{% endif %}
data-content="High priority. <span class='help'>Long vibration burst, default notification sound with a pop-over notification.</span>">
High priority
</option>
<option value="3"
{% if form.priority.value == 3 %}selected="selected"{% endif %}
data-content="Default priority. <span class='help'>Short default vibration and sound. Default notification behavior.</span>">
Default priority
</option>
<option
value="2"
{% if form.priority.value == 2 %}selected="selected"{% endif %}
data-content="Low priority. <span class='help'>No vibration or sound. Notification will not visibly show up until notification drawer is pulled down.</span>">
Low priority
</option>
<option
value="1"
{% if form.priority.value == 1 %}selected="selected"{% endif %}
data-content="Min priority. <span class='help'>No vibration or sound. The notification will be under the fold in 'Other notifications'.</span>">
Min priority
</option>
<option
value="0"
{% if form.priority.value == 0 %}selected="selected"{% endif %}
data-content="Disabled. <span class='help'>Does not notify about Down events.</span>">
Disabled
</option>
</select>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">Priority for "up" events</label>
<div class="col-sm-8">
<select name="priority_up" class="selectpicker form-control">
<option
value="5"
{% if form.priority_up.value == 5 %}selected="selected"{% endif %}
data-content="Max priority. <span class='help'>Really long vibration bursts, default notification sound with a pop-over notification.</span>">
Max priority
</option>
<option
value="4"
{% if form.priority_up.value == 4 %}selected="selected"{% endif %}
data-content="High priority. <span class='help'>Long vibration burst, default notification sound with a pop-over notification.</span>">
High priority
</option>
<option value="3"
{% if form.priority_up.value == 3 %}selected="selected"{% endif %}
data-content="Default priority. <span class='help'>Short default vibration and sound. Default notification behavior.</span>">
Default priority
</option>
<option
value="2"
{% if form.priority_up.value == 2 %}selected="selected"{% endif %}
data-content="Low priority. <span class='help'>No vibration or sound. Notification will not visibly show up until notification drawer is pulled down.</span>">
Low priority
</option>
<option
value="1"
{% if form.priority_up.value == 1 %}selected="selected"{% endif %}
data-content="Min priority. <span class='help'>No vibration or sound. The notification will be under the fold in 'Other notifications'.</span>">
Min priority
</option>
<option
value="0"
{% if form.priority_up.value == 0 %}selected="selected"{% endif %}
data-content="Disabled. <span class='help'>Does not notify about Up events.</span>">
Disabled
</option>
</select>
</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 %}
{% block scripts %}
{% compress js %}
<script src="{% static 'js/jquery-3.6.0.min.js' %}"></script>
<script src="{% static 'js/bootstrap.min.js' %}"></script>
<script src="{% static 'js/bootstrap-select.min.js' %}"></script>
{% endcompress %}
{% endblock %}

View file

@ -0,0 +1,21 @@
{% load humanize linemode %}{% linemode %}
{% if check.status == "down" %}
{% line %}The last ping was {{ check.last_ping|naturaltime }}.{% 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|safe }} (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 }}