From 1322bb11238fcec8e896878a3cd0d4f1bac5c4a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=93teris=20Caune?= <cuu508@gmail.com> Date: Tue, 27 Feb 2024 12:55:51 +0200 Subject: [PATCH] Add support for per-check status badges Fixes: #853 --- CHANGELOG.md | 1 + hc/api/migrations/0103_check_badge_key.py | 18 +++++ hc/api/models.py | 7 ++ hc/api/tests/test_check_badge.py | 88 +++++++++++++++++++++++ hc/api/urls.py | 5 ++ hc/api/views.py | 63 ++++++++++++---- hc/front/forms.py | 3 +- hc/front/views.py | 23 ++++-- static/css/badges.css | 3 +- templates/front/badges.html | 17 +++++ 10 files changed, 207 insertions(+), 21 deletions(-) create mode 100644 hc/api/migrations/0103_check_badge_key.py create mode 100644 hc/api/tests/test_check_badge.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f31725ca..8a5be213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. - Update the WhatsApp integration to use Twilio Content Templates - Add auto-refresh functionality to the Log page (#957, @mickBoat00) - Redesign the "Status Badges" page +- Add support for per-check status badges (#853) ### Bug Fixes - Fix Gotify integration to handle Gotify server URLs with paths (#964) diff --git a/hc/api/migrations/0103_check_badge_key.py b/hc/api/migrations/0103_check_badge_key.py new file mode 100644 index 00000000..6e16ed02 --- /dev/null +++ b/hc/api/migrations/0103_check_badge_key.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-02-27 08:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0102_alter_check_kind"), + ] + + operations = [ + migrations.AddField( + model_name="check", + name="badge_key", + field=models.UUIDField(null=True, unique=True), + ), + ] diff --git a/hc/api/models.py b/hc/api/models.py index 57594e9d..15146dff 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -192,6 +192,7 @@ class Check(models.Model): failure_kw = models.CharField(max_length=200, blank=True) methods = models.CharField(max_length=30, blank=True) manual_resume = models.BooleanField(default=False) + badge_key = models.UUIDField(null=True, unique=True) n_pings = models.IntegerField(default=0) last_ping = models.DateTimeField(null=True, blank=True) @@ -373,6 +374,12 @@ class Check(models.Model): code_half = self.code.hex[:16] return hashlib.sha1(code_half.encode()).hexdigest() + def prepare_badge_key(self) -> uuid.UUID: + if not self.badge_key: + self.badge_key = uuid.uuid4() + Check.objects.filter(id=self.id).update(badge_key=self.badge_key) + return self.badge_key + def to_dict(self, *, readonly: bool = False, v: int = 3) -> CheckDict: with_started = v == 1 result: CheckDict = { diff --git a/hc/api/tests/test_check_badge.py b/hc/api/tests/test_check_badge.py new file mode 100644 index 00000000..5457ee4e --- /dev/null +++ b/hc/api/tests/test_check_badge.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from datetime import timedelta as td + +from django.utils.timezone import now + +from hc.api.models import Check +from hc.test import BaseTestCase + + +class CheckBadgeTestCase(BaseTestCase): + def setUp(self) -> None: + super().setUp() + self.check = Check.objects.create(project=self.project, name="foobar") + badge_key = self.check.prepare_badge_key() + + self.svg_url = f"/b/2/{badge_key}.svg" + self.json_url = f"/b/2/{badge_key}.json" + self.with_late_url = f"/b/3/{badge_key}.json" + self.shields_url = f"/b/2/{badge_key}.shields" + + def test_it_handles_bad_badge_key(self) -> None: + r = self.client.get("/b/2/869fe06a-a604-4140-b15a-118637c25f3e.svg") + self.assertEqual(r.status_code, 404) + + def test_it_returns_svg(self) -> None: + r = self.client.get(self.svg_url) + self.assertEqual(r["Access-Control-Allow-Origin"], "*") + self.assertIn("no-cache", r["Cache-Control"]) + self.assertContains(r, "#4c1") + + def test_it_rejects_bad_format(self) -> None: + r = self.client.get(self.json_url + "foo") + self.assertEqual(r.status_code, 404) + + def test_it_handles_options(self) -> None: + r = self.client.options(self.svg_url) + self.assertEqual(r.status_code, 204) + self.assertEqual(r["Access-Control-Allow-Origin"], "*") + + def test_it_handles_new(self) -> None: + doc = self.client.get(self.json_url).json() + self.assertEqual(doc, {"status": "up", "total": 1, "grace": 0, "down": 0}) + + def test_it_ignores_started_when_down(self) -> None: + self.check.last_start = now() + self.check.status = "down" + self.check.save() + + doc = self.client.get(self.json_url).json() + self.assertEqual(doc, {"status": "down", "total": 1, "grace": 0, "down": 1}) + + def test_it_treats_late_as_up(self) -> None: + self.check.last_ping = now() - td(days=1, minutes=10) + self.check.status = "up" + self.check.save() + + doc = self.client.get(self.json_url).json() + self.assertEqual(doc, {"status": "up", "total": 1, "grace": 1, "down": 0}) + + def test_late_mode_returns_late_status(self) -> None: + self.check.last_ping = now() - td(days=1, minutes=10) + self.check.status = "up" + self.check.save() + + doc = self.client.get(self.with_late_url).json() + self.assertEqual(doc, {"status": "late", "total": 1, "grace": 1, "down": 0}) + + def test_late_mode_ignores_started_when_late(self) -> None: + self.check.last_start = now() + self.check.last_ping = now() - td(days=1, minutes=10) + self.check.status = "up" + self.check.save() + + doc = self.client.get(self.with_late_url).json() + self.assertEqual(doc, {"status": "late", "total": 1, "grace": 1, "down": 0}) + + def test_it_returns_shields_json(self) -> None: + doc = self.client.get(self.shields_url).json() + self.assertEqual( + doc, + { + "schemaVersion": 1, + "label": "foobar", + "message": "up", + "color": "success", + }, + ) diff --git a/hc/api/urls.py b/hc/api/urls.py index d79f7697..32175248 100644 --- a/hc/api/urls.py +++ b/hc/api/urls.py @@ -90,4 +90,9 @@ urlpatterns = [ {"tag": "*"}, name="hc-badge-all", ), + path( + "b/<int:states>/<uuid:badge_key>.<slug:fmt>", + views.check_badge, + name="hc-badge-check", + ), ] diff --git a/hc/api/views.py b/hc/api/views.py index 67fd9e83..8ccb230a 100644 --- a/hc/api/views.py +++ b/hc/api/views.py @@ -685,6 +685,20 @@ def badges(request: ApiRequest) -> JsonResponse: return JsonResponse({"badges": badges}) +SHIELDS_COLORS = {"up": "success", "late": "important", "down": "critical"} + + +def _shields_response(label: str, status: str): + return JsonResponse( + { + "schemaVersion": 1, + "label": label, + "message": status, + "color": SHIELDS_COLORS[status], + } + ) + + @never_cache @cors("GET") def badge( @@ -701,11 +715,11 @@ def badge( return HttpResponseNotFound() q = Check.objects.filter(project__badge_key=badge_key) - if tag != "*": + if tag == "*": + label = settings.MASTER_BADGE_LABEL + else: q = q.filter(tags__contains=tag) label = tag - else: - label = settings.MASTER_BADGE_LABEL status, total, grace, down = "up", 0, 0, 0 for check in q: @@ -728,15 +742,7 @@ def badge( status = "late" if fmt == "shields": - color = "success" - if status == "down": - color = "critical" - elif status == "late": - color = "important" - - return JsonResponse( - {"schemaVersion": 1, "label": label, "message": status, "color": color} - ) + return _shields_response(label, status) if fmt == "json": return JsonResponse( @@ -747,6 +753,39 @@ def badge( return HttpResponse(svg, content_type="image/svg+xml") +@never_cache +@cors("GET") +def check_badge( + request: HttpRequest, states: int, badge_key: UUID, fmt: str +) -> HttpResponse: + if fmt not in ("svg", "json", "shields"): + return HttpResponseNotFound() + + check = get_object_or_404(Check, badge_key=badge_key) + check_status = check.get_status() + status = "up" + if check_status == "down": + status = "down" + elif check_status == "grace" and states == 3: + status = "late" + + if fmt == "shields": + return _shields_response(check.name_then_code(), status) + + if fmt == "json": + return JsonResponse( + { + "status": status, + "total": 1, + "grace": 1 if check_status == "grace" else 0, + "down": 1 if check_status == "down" else 0, + } + ) + + svg = get_badge_svg(check.name_then_code(), status) + return HttpResponse(svg, content_type="image/svg+xml") + + @csrf_exempt @require_POST def notification_status(request: HttpRequest, code: UUID) -> HttpResponse: diff --git a/hc/front/forms.py b/hc/front/forms.py index 4edf1a2a..dfe542f7 100644 --- a/hc/front/forms.py +++ b/hc/front/forms.py @@ -406,7 +406,8 @@ class AddTelegramForm(forms.Form): class BadgeSettingsForm(forms.Form): - target = forms.ChoiceField(choices=_choices("all,tag")) + target = forms.ChoiceField(choices=_choices("all,tag,check")) tag = forms.CharField(max_length=100, required=False) + check = forms.UUIDField(required=False) fmt = forms.ChoiceField(choices=_choices("svg,json,shields")) states = forms.ChoiceField(choices=_choices("2,3")) diff --git a/hc/front/views.py b/hc/front/views.py index cbf013f7..73a078e0 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -1101,28 +1101,37 @@ def badges(request: AuthenticatedHttpRequest, code: UUID) -> HttpResponse: if not form.is_valid(): return HttpResponseBadRequest() + fmt = form.cleaned_data["fmt"] + states = form.cleaned_data["states"] + with_late = True if states == "3" else False if form.cleaned_data["target"] == "all": label = settings.MASTER_BADGE_LABEL - tag = "*" - else: + url = get_badge_url(project.badge_key, "*", fmt, with_late) + elif form.cleaned_data["target"] == "tag": label = form.cleaned_data["tag"] - tag = form.cleaned_data["tag"] - fmt = form.cleaned_data["fmt"] - with_late = True if form.cleaned_data["states"] == "3" else False - url = get_badge_url(project.badge_key, tag, fmt, with_late) + url = get_badge_url(project.badge_key, label, fmt, with_late) + elif form.cleaned_data["target"] == "check": + check = project.check_set.get(code=form.cleaned_data["check"]) + url = settings.SITE_ROOT + reverse( + "hc-badge-check", args=[states, check.prepare_badge_key(), fmt] + ) + label = check.name_then_code() + if fmt == "shields": url = "https://img.shields.io/endpoint?" + urlencode({"url": url}) ctx = {"fmt": fmt, "label": label, "url": url} return render(request, "front/badges_preview.html", ctx) + checks = list(Check.objects.filter(project=project).order_by("name")) tags = set() - for check in Check.objects.filter(project=project): + for check in checks: tags.update(check.tags_list()) sorted_tags = sorted(tags, key=lambda s: s.lower()) ctx = { + "checks": checks, "tags": sorted_tags, "fmt": "svg", "label": settings.MASTER_BADGE_LABEL, diff --git a/static/css/badges.css b/static/css/badges.css index 0f20d43b..b9fb121c 100644 --- a/static/css/badges.css +++ b/static/css/badges.css @@ -1,4 +1,5 @@ -#status-badges #tag { +#status-badges #tag, +#status-badges #check { margin-left: 49px; width: 200px } diff --git a/templates/front/badges.html b/templates/front/badges.html index ba0f1fb1..5ed8dd05 100644 --- a/templates/front/badges.html +++ b/templates/front/badges.html @@ -54,6 +54,23 @@ {% endfor %} </select> {% endif %} + {% if checks %} + <label class="radio-container"> + <input type="radio" name="target" value="check" autocomplete="off"> + <span class="radiomark"></span> + A specific check: + </label> + + <select + id="check" + name="check" + class="form-control" + autocomplete="off"> + {% for check in checks %} + <option value="{{ check.code }}">{{ check.name_then_code }}</option> + {% endfor %} + </select> + {% endif %} </div> <div class="form-group"> <p>Badge format</p>