mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-03 04:15:29 +00:00
parent
5eb21a6919
commit
1322bb1123
10 changed files with 207 additions and 21 deletions
|
@ -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)
|
||||
|
|
18
hc/api/migrations/0103_check_badge_key.py
Normal file
18
hc/api/migrations/0103_check_badge_key.py
Normal file
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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 = {
|
||||
|
|
88
hc/api/tests/test_check_badge.py
Normal file
88
hc/api/tests/test_check_badge.py
Normal file
|
@ -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",
|
||||
},
|
||||
)
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
#status-badges #tag {
|
||||
#status-badges #tag,
|
||||
#status-badges #check {
|
||||
margin-left: 49px;
|
||||
width: 200px
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Reference in a new issue