Skip to content

Commit

Permalink
Redesign the "Status Badges" page
Browse files Browse the repository at this point in the history
  • Loading branch information
cuu508 committed Feb 26, 2024
1 parent 6686147 commit 4959856
Show file tree
Hide file tree
Showing 9 changed files with 210 additions and 201 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 14 additions & 8 deletions hc/front/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()]

Expand Down Expand Up @@ -85,9 +89,7 @@ def clean_tags(self) -> str:


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()])
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"))
73 changes: 54 additions & 19 deletions hc/front/tests/test_badges.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -8,38 +10,71 @@ class BadgesTestCase(BaseTestCase):
def setUp(self) -> None:
super().setUp()

self.url = f"/projects/{self.project.code}/badges/"
self.project.badge_key = "alices-badge-key"
self.project.save()

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.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.svg")
self.assertContains(r, "a-B_1.svg")
self.assertContains(r, "foo")
self.assertContains(r, "a-B_1")
self.assertContains(r, self.project.badge_key)

# Expect badge URLs only for tags that match \w+
self.assertNotContains(r, "baz@.svg")
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)

# Expect only Alice's tags
self.assertNotContains(r, "bobs-tag.svg")
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)

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")
@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.project.badge_key = "alices-badge-key"
self.project.save()
self.assertContains(r, "![Overall Status]")

def test_it_previews_svg(self) -> None:
self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url)
self.assertContains(r, "badge/alices-badge-key/")
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, "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]")
46 changes: 24 additions & 22 deletions hc/front/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
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
Expand Down Expand Up @@ -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)


Expand Down
12 changes: 6 additions & 6 deletions hc/lib/badges.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 7 additions & 13 deletions static/css/badges.css
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 19 additions & 33 deletions static/js/badges.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading

0 comments on commit 4959856

Please sign in to comment.