diff --git a/CHANGELOG.md b/CHANGELOG.md index e558c91f..f31725ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. - Add support for $NAME_JSON and $BODY_JSON placeholders in webhook payloads - Update the WhatsApp integration to use Twilio Content Templates - Add auto-refresh functionality to the Log page (#957, @mickBoat00) +- Redesign the "Status Badges" page ### Bug Fixes - Fix Gotify integration to handle Gotify server URLs with paths (#964) diff --git a/hc/front/forms.py b/hc/front/forms.py index f5ff47c4..4edf1a2a 100644 --- a/hc/front/forms.py +++ b/hc/front/forms.py @@ -27,6 +27,10 @@ def _is_latin1(s: str) -> bool: return False +def _choices(csv: str) -> list[tuple[str, str]]: + return [(v, v) for v in csv.split(",")] + + class LaxURLField(forms.URLField): default_validators = [WebhookValidator()] @@ -85,9 +89,7 @@ class NameTagsForm(forms.Form): class AddCheckForm(NameTagsForm): - kind = forms.ChoiceField( - choices=(("simple", "simple"), ("cron", "cron"), ("oncalendar", "oncalendar")) - ) + kind = forms.ChoiceField(choices=_choices("simple,cron,oncalendar")) timeout = forms.IntegerField(min_value=60, max_value=31536000) schedule = forms.CharField(required=False, max_length=100) tz = forms.CharField(max_length=36, validators=[TimezoneValidator()]) @@ -198,19 +200,16 @@ class AddUrlForm(forms.Form): value = LaxURLField(max_length=1000) -METHODS = ("GET", "POST", "PUT") - - class WebhookForm(forms.Form): error_css_class = "has-error" name = forms.CharField(max_length=100, required=False) - method_down = forms.ChoiceField(initial="GET", choices=zip(METHODS, METHODS)) + method_down = forms.ChoiceField(initial="GET", choices=_choices("GET,POST,PUT")) body_down = forms.CharField(max_length=1000, required=False) headers_down = HeadersField(required=False) url_down = LaxURLField(max_length=1000, required=False) - method_up = forms.ChoiceField(initial="GET", choices=zip(METHODS, METHODS)) + method_up = forms.ChoiceField(initial="GET", choices=_choices("GET,POST,PUT")) body_up = forms.CharField(max_length=1000, required=False) headers_up = HeadersField(required=False) url_up = LaxURLField(max_length=1000, required=False) @@ -404,3 +403,10 @@ class TransferForm(forms.Form): class AddTelegramForm(forms.Form): project = forms.UUIDField() + + +class BadgeSettingsForm(forms.Form): + target = forms.ChoiceField(choices=_choices("all,tag")) + tag = forms.CharField(max_length=100, required=False) + fmt = forms.ChoiceField(choices=_choices("svg,json,shields")) + states = forms.ChoiceField(choices=_choices("2,3")) diff --git a/hc/front/tests/test_badges.py b/hc/front/tests/test_badges.py index d73124b1..cd15e02a 100644 --- a/hc/front/tests/test_badges.py +++ b/hc/front/tests/test_badges.py @@ -1,5 +1,7 @@ from __future__ import annotations +from django.test.utils import override_settings + from hc.api.models import Check from hc.test import BaseTestCase @@ -8,38 +10,71 @@ class BadgesTestCase(BaseTestCase): def setUp(self) -> None: super().setUp() - self.url = f"/projects/{self.project.code}/badges/" - - def test_it_shows_badges(self) -> None: - Check.objects.create(project=self.project, tags="foo a-B_1 baz@") - Check.objects.create(project=self.bobs_project, tags="bobs-tag") - - self.client.login(username="alice@example.org", password="password") - r = self.client.get(self.url) - self.assertContains(r, "foo.svg") - self.assertContains(r, "a-B_1.svg") - - # Expect badge URLs only for tags that match \w+ - self.assertNotContains(r, "baz@.svg") - - # Expect only Alice's tags - self.assertNotContains(r, "bobs-tag.svg") - - def test_it_uses_badge_key(self) -> None: - Check.objects.create(project=self.project, tags="foo bar") - Check.objects.create(project=self.bobs_project, tags="bobs-tag") - self.project.badge_key = "alices-badge-key" self.project.save() + Check.objects.create(project=self.project, tags="foo a-B_1 baz@") + + self.url = f"/projects/{self.project.code}/badges/" + + def test_it_shows_form(self) -> None: self.client.login(username="alice@example.org", password="password") r = self.client.get(self.url) + self.assertContains(r, "foo") + self.assertContains(r, "a-B_1") + self.assertContains(r, self.project.badge_key) + + def test_it_checks_ownership(self) -> None: + self.client.login(username="charlie@example.org", password="password") + r = self.client.get(self.url) + self.assertEqual(r.status_code, 404) + + def test_team_access_works(self) -> None: + # Logging in as bob, not alice. Bob has team access so this + # should work. + self.client.login(username="bob@example.org", password="password") + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + @override_settings(MASTER_BADGE_LABEL="Overall Status") + def test_it_previews_master_svg(self) -> None: + self.client.login(username="alice@example.org", password="password") + payload = {"target": "all", "fmt": "svg", "states": "2"} + r = self.client.post(self.url, payload) + + self.assertContains(r, "![Overall Status]") + + def test_it_previews_svg(self) -> None: + self.client.login(username="alice@example.org", password="password") + payload = {"target": "tag", "tag": "foo", "fmt": "svg", "states": "2"} + r = self.client.post(self.url, payload) + self.assertContains(r, "badge/alices-badge-key/") - self.assertContains(r, "badge/alices-badge-key/") + self.assertContains(r, "foo.svg") + self.assertContains(r, "![foo]") def test_it_handles_special_characters_in_tags(self) -> None: - Check.objects.create(project=self.project, tags="db@dc1") - self.client.login(username="alice@example.org", password="password") - r = self.client.get(self.url) + payload = {"target": "tag", "tag": "db@dc1", "fmt": "svg", "states": "2"} + r = self.client.post(self.url, payload) self.assertContains(r, "db%2540dc1.svg") + self.assertContains(r, "![db@dc1]") + + def test_it_previews_json(self) -> None: + self.client.login(username="alice@example.org", password="password") + payload = {"target": "tag", "tag": "foo", "fmt": "json", "states": "2"} + r = self.client.post(self.url, payload) + + self.assertContains(r, "fetch-json") + self.assertContains(r, "foo.json") + self.assertNotContains(r, "![foo]") + + def test_it_previews_shields(self) -> None: + self.client.login(username="alice@example.org", password="password") + payload = {"target": "tag", "tag": "foo", "fmt": "shields", "states": "2"} + r = self.client.post(self.url, payload) + + self.assertContains(r, "https://img.shields.io/endpoint") + self.assertContains(r, "%3A%2F%2F") # url-encoded "://" + self.assertContains(r, "foo.shields") + self.assertContains(r, "![foo]") diff --git a/hc/front/views.py b/hc/front/views.py index 11389ccb..cbf013f7 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -11,7 +11,6 @@ from collections import Counter, defaultdict from collections.abc import Iterable from datetime import datetime from datetime import timedelta as td -from datetime import timezone from email.message import EmailMessage from secrets import token_urlsafe from typing import Literal, TypedDict, cast @@ -1097,35 +1096,38 @@ def status_single(request: AuthenticatedHttpRequest, code: UUID) -> HttpResponse def badges(request: AuthenticatedHttpRequest, code: UUID) -> HttpResponse: project, rw = _get_project_for_user(request, code) + if request.method == "POST": + form = forms.BadgeSettingsForm(request.POST) + if not form.is_valid(): + return HttpResponseBadRequest() + + if form.cleaned_data["target"] == "all": + label = settings.MASTER_BADGE_LABEL + tag = "*" + else: + 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) + 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) + tags = set() for check in Check.objects.filter(project=project): tags.update(check.tags_list()) sorted_tags = sorted(tags, key=lambda s: s.lower()) - sorted_tags.append("*") # For the "overall status" badge - - key = project.badge_key - urls = [] - for tag in sorted_tags: - urls.append( - { - "tag": tag, - "svg": get_badge_url(key, tag), - "svg3": get_badge_url(key, tag, with_late=True), - "json": get_badge_url(key, tag, fmt="json"), - "json3": get_badge_url(key, tag, fmt="json", with_late=True), - "shields": get_badge_url(key, tag, fmt="shields"), - "shields3": get_badge_url(key, tag, fmt="shields", with_late=True), - } - ) ctx = { - "have_tags": len(urls) > 1, - "page": "badges", - "project": project, - "badges": urls, + "tags": sorted_tags, + "fmt": "svg", + "label": settings.MASTER_BADGE_LABEL, + "url": get_badge_url(project.badge_key, "*"), } - return render(request, "front/badges.html", ctx) diff --git a/hc/lib/badges.py b/hc/lib/badges.py index bbc31366..42a3e125 100644 --- a/hc/lib/badges.py +++ b/hc/lib/badges.py @@ -99,21 +99,21 @@ def get_badge_svg(tag: str, status: str) -> str: return render_to_string("badge.svg", ctx) -def check_signature(username: str, tag: str, sig: str) -> bool: - ours = base64_hmac(str(username), tag, settings.SECRET_KEY) +def check_signature(badge_key: str, tag: str, sig: str) -> bool: + ours = base64_hmac(str(badge_key), tag, settings.SECRET_KEY) return ours[:8] == sig[:8] def get_badge_url( - username: str, tag: str, fmt: str = "svg", with_late: bool = False + badge_key: str, tag: str, fmt: str = "svg", with_late: bool = False ) -> str: - sig = base64_hmac(str(username), tag, settings.SECRET_KEY)[:8] + sig = base64_hmac(str(badge_key), tag, settings.SECRET_KEY)[:8] if not with_late: sig += "-2" if tag == "*": - url = reverse("hc-badge-all", args=[username, sig, fmt]) + url = reverse("hc-badge-all", args=[badge_key, sig, fmt]) else: - url = reverse("hc-badge", args=[username, sig, tag, fmt]) + url = reverse("hc-badge", args=[badge_key, sig, tag, fmt]) return settings.SITE_ROOT + url diff --git a/static/css/badges.css b/static/css/badges.css index 8c06f93b..0f20d43b 100644 --- a/static/css/badges.css +++ b/static/css/badges.css @@ -1,16 +1,10 @@ -.table.badge-preview th { - border-top: 0; - color: var(--text-muted); - font-weight: normal; - font-size: 12px; - padding-top: 32px; +#status-badges #tag { + margin-left: 49px; + width: 200px } -#badges-json .fetch-json { - background: var(--pre-bg); - padding: 3px; -} - -#badges-json, #badges-shields, .badge-preview .with-late { - display: none; +#status-badges #preview img, +#status-badges #preview pre, +#status-badges #preview input { + margin-bottom: 20px; } \ No newline at end of file diff --git a/static/js/badges.js b/static/js/badges.js index 4681f92a..cb1777b5 100644 --- a/static/js/badges.js +++ b/static/js/badges.js @@ -1,37 +1,23 @@ $(function() { - - $(".fetch-json").each(function(idx, el) { - $.getJSON(el.dataset.url, function(data) { - el.innerText = JSON.stringify(data); + function updatePreview() { + var params = $("#badge-settings-form").serialize(); + var token = $('input[name=csrfmiddlewaretoken]').val(); + $.ajax({ + url: window.location.href, + type: "post", + headers: {"X-CSRFToken": token}, + data: params, + success: function(data) { + document.getElementById("preview").innerHTML = data; + $(".fetch-json").each(function(idx, el) { + $.getJSON(el.dataset.url, function(data) { + el.innerText = JSON.stringify(data); + }); + }); + } }); - }); - - $("#show-svg").click(function() { - $("#badges-svg").show(); - $("#badges-json").hide(); - $("#badges-shields").hide(); - }) - - $("#show-json").click(function() { - $("#badges-svg").hide(); - $("#badges-json").show(); - $("#badges-shields").hide(); - }) - - $("#show-shields").click(function() { - $("#badges-svg").hide(); - $("#badges-json").hide(); - $("#badges-shields").show(); - }) - - $("#show-with-late").click(function() { - $(".no-late").hide(); - $(".with-late").show(); - }) - - $("#show-no-late").click(function() { - $(".with-late").hide(); - $(".no-late").show(); - }) + } + $("input[type=radio]").change(updatePreview); + $("select").change(updatePreview); }); diff --git a/templates/front/badges.html b/templates/front/badges.html index 2b47d208..ba0f1fb1 100644 --- a/templates/front/badges.html +++ b/templates/front/badges.html @@ -4,132 +4,86 @@ {% block title %}Status Badges - {{ site_name }}{% endblock %} {% block content %} -<div class="row"> - <div class="col-sm-10"> +<div id="status-badges" class="row"> + <div class="col-sm-12"> <h1>Status Badges</h1> - - <p id="badges-description"> + <p> {{ site_name }} provides status badges for each of the tags you have used. The badges have public, but hard-to-guess URLs. You can use them in your READMEs, dashboards or status pages. </p> - <p>Each badge can be in one of the following states:</p> <ul> <li><strong>up</strong> – all matching checks are up.</li> <li><strong>down</strong> – at least one check is currently down.</li> </ul> - <p> As an option, the badges can report a third state: <strong>late</strong> (when at least one check is running late but has not exceeded its grace time yet). </p> - - <br /> - - <div class="btn-group" data-toggle="buttons"> - <label id="show-svg" class="btn btn-default active"> - <input type="radio" checked> SVG - </label> - <label id="show-json" class="btn btn-default"> - <input type="radio"> JSON - </label> - <label id="show-shields" class="btn btn-default"> - <input type="radio"> Shields.io - </label> - </div> - - - <div class="btn-group" data-toggle="buttons"> - <label id="show-no-late" class="btn btn-default active"> - <input type="radio" checked> Badge states: <b>up</b> or <b>down</b> - </label> - <label id="show-with-late" class="btn btn-default"> - <input type="radio"> Badge states: <b>up</b>, <b>late</b> or <b>down</b> - </label> - </div> - - <table id="badges-svg" class="table badge-preview"> - {% if have_tags %} - <tr><th colspan="2">Tags</th></tr> - {% endif %} - - {% for urldict in badges %} - {% if urldict.tag == "*" %} - <tr> - <th colspan="2">Overall Status</th> - </tr> - {% endif %} - - <tr> - <td> - <img class="no-late" src="{{ urldict.svg }}" alt="" /> - <img class="with-late" src="{{ urldict.svg3 }}" alt="" /> - </td> - <td> - <code class="no-late">{{ urldict.svg }}</code> - <code class="with-late">{{ urldict.svg3 }}</code> - </td> - </tr> - {% endfor %} - </table> - <table id="badges-json" class="table badge-preview"> - {% if have_tags %} - <tr> - <th colspan="2">Tags</th> - </tr> - {% endif %} - - {% for urldict in badges %} - {% if urldict.tag == "*" %} - <tr> - <th colspan="2">Overall Status</th> - </tr> - {% endif %} - - <tr> - <td> - <code class="fetch-json no-late" data-url="{{ urldict.json }}"></code> - <code class="fetch-json with-late" data-url="{{ urldict.json3 }}"></code> - </td> - <td> - <code class="no-late">{{ urldict.json }}</code> - <code class="with-late">{{ urldict.json3 }}</code> - </td> - </tr> - {% endfor %} - </table> - - <table id="badges-shields" class="table badge-preview"> - {% if have_tags %} - <tr> - <th>Shields.io badge</th> - <th>JSON endpoint for Shields.io <a href="https://shields.io/endpoint">(how to use)</a></th> - </tr> - {% endif %} - - {% for urldict in badges %} - {% if urldict.tag == "*" %} - <tr> - <th colspan="2">Overall Status</th> - </tr> - {% endif %} - - <tr> - <td> - <img class="no-late" src="https://img.shields.io/endpoint?url={{ urldict.shields|urlencode:"" }}" alt="" /> - <img class="with-late" src="https://img.shields.io/endpoint?url={{ urldict.shields3|urlencode:"" }}" alt="" /> - </td> - <td> - <code class="no-late">{{ urldict.shields }}</code> - <code class="with-late">{{ urldict.shields3 }}</code> - </td> - </tr> - {% endfor %} - </table> </div> + + <div class="col-md-4"> + <h2>Badge Generator</h2> + {% csrf_token %} + <form id="badge-settings-form"> + <div class="form-group"> + <p>Generate a badge for</p> + + <label class="radio-container"> + <input type="radio" name="target" value="all" autocomplete="off" checked> + <span class="radiomark"></span> + All checks in the project + </label> + {% if tags %} + <label class="radio-container"> + <input type="radio" name="target" value="tag" autocomplete="off"> + <span class="radiomark"></span> + The checks tagged with this tag: + </label> + + <select + id="tag" + name="tag" + class="form-control" + autocomplete="off"> + {% for tag in tags %} + <option>{{ tag }}</option> + {% endfor %} + </select> + {% endif %} + </div> + <div class="form-group"> + <p>Badge format</p> + <label class="radio-container"> + <input type="radio" name="fmt" value="svg" autocomplete="off" checked> + <span class="radiomark"></span> SVG + </label> + <label class="radio-container"> + <input type="radio" name="fmt" value="json" autocomplete="off"> + <span class="radiomark"></span> JSON + </label> + <label class="radio-container"> + <input type="radio" name="fmt" value="shields" autocomplete="off"> + <span class="radiomark"></span> Shields.io + </label> + </div> + <div class="form-group"> + <p>Badge states</p> + <label class="radio-container"> + <input type="radio" name="states" value="2" autocomplete="off" checked> + <span class="radiomark"></span> Two states (<strong>up</strong>, <strong>down</strong>) + </label> + <label class="radio-container"> + <input type="radio" name="states" value="3" autocomplete="off"> + <span class="radiomark"></span> Three states (<strong>up</strong>, <strong>late</strong> or <strong>down</strong>) + </label> + </div> + </form> + </div> + <div id="preview" class="col-md-8">{% include "front/badges_preview.html" %}</div> </div> {% endblock %} diff --git a/templates/front/badges_preview.html b/templates/front/badges_preview.html new file mode 100644 index 00000000..b6d01da0 --- /dev/null +++ b/templates/front/badges_preview.html @@ -0,0 +1,31 @@ +<h2>Preview</h2> + +{% if fmt == "json" %} +<pre class="fetch-json" data-url="{{ url }}"></pre> + +<p>Badge URL</p> +<input + class="form-control" + type="text" name="" + value="{{ url }}" readonly> + +{% else %} +<img src="{{ url }}"> +<p>Badge URL</p> +<input + class="form-control" + type="text" name="" + value="{{ url }}" readonly> +<p>HTML code</p> +<input + class="form-control" + type="text" + name="" + value="<a href="{{ url }}">{{ label }}</a>" readonly> +<p>Markdown code</p> +<input + class="form-control" + type="text" + name="" + value="" readonly> +{% endif %} \ No newline at end of file