0
0
Fork 0
mirror of https://github.com/healthchecks/healthchecks.git synced 2025-04-03 04:15:29 +00:00

Redesign the "Status Badges" page

This commit is contained in:
Pēteris Caune 2024-02-26 12:34:26 +02:00
parent 6686147cb1
commit 4959856e58
No known key found for this signature in database
GPG key ID: E28D7679E9A9EDE2
9 changed files with 225 additions and 216 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>
&nbsp;
<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 %}

View file

@ -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="&lt;a href=&quot;{{ url }}&quot;&gt;{{ label }}&lt;/a&gt;" readonly>
<p>Markdown code</p>
<input
class="form-control"
type="text"
name=""
value="![{{ label }}]({{ url }})" readonly>
{% endif %}