mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-08 14:40:05 +00:00
parent
df6895ed1f
commit
855fac0973
9 changed files with 179 additions and 121 deletions
|
@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file.
|
|||
- Add support for per-check status badges (#853)
|
||||
- Add "Last ping subject" field in email notifications
|
||||
- Change the signup flow to accept registered users (and sign them in instead)
|
||||
- Implement event type filtering in the Log page (#873)
|
||||
|
||||
### Bug Fixes
|
||||
- Fix Gotify integration to handle Gotify server URLs with paths (#964)
|
||||
|
|
|
@ -383,13 +383,30 @@ class SearchForm(forms.Form):
|
|||
|
||||
class LogFiltersForm(forms.Form):
|
||||
# min_value is 2010-01-01, max_value is 2030-01-01
|
||||
end = forms.IntegerField(min_value=1262296800, max_value=1893448800, required=False)
|
||||
u = forms.FloatField(min_value=1262296800, max_value=1893448800, required=False)
|
||||
end = forms.FloatField(min_value=1262296800, max_value=1893448800, required=False)
|
||||
success = forms.BooleanField(required=False)
|
||||
fail = forms.BooleanField(required=False, label="Fail")
|
||||
start = forms.BooleanField(required=False)
|
||||
log = forms.BooleanField(required=False)
|
||||
ign = forms.BooleanField(required=False)
|
||||
notification = forms.BooleanField(required=False)
|
||||
|
||||
def clean_u(self) -> datetime | None:
|
||||
if self.cleaned_data["u"]:
|
||||
return datetime.fromtimestamp(self.cleaned_data["u"], tz=timezone.utc)
|
||||
return None
|
||||
|
||||
def clean_end(self) -> datetime | None:
|
||||
if self.cleaned_data["end"]:
|
||||
return datetime.fromtimestamp(self.cleaned_data["end"], tz=timezone.utc)
|
||||
return None
|
||||
|
||||
def kinds(self) -> tuple[str, ...]:
|
||||
# FIXME: this is slightly naughty as it is also returning "u" and "end"
|
||||
# which are not ping kinds
|
||||
return tuple(key for key in self.cleaned_data if self.cleaned_data[key])
|
||||
|
||||
|
||||
class TransferForm(forms.Form):
|
||||
project = forms.UUIDField()
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import timedelta as td
|
||||
from datetime import timezone
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.utils.timezone import now
|
||||
|
@ -15,7 +13,9 @@ from hc.test import BaseTestCase
|
|||
class LogTestCase(BaseTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.check = Check.objects.create(project=self.project)
|
||||
self.check = Check(project=self.project)
|
||||
self.check.created = "2000-01-01T00:00:00+00:00"
|
||||
self.check.save()
|
||||
|
||||
self.ping = Ping.objects.create(owner=self.check, n=1)
|
||||
self.ping.body_raw = b"hello world"
|
||||
|
@ -31,7 +31,6 @@ class LogTestCase(BaseTestCase):
|
|||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get(self.url)
|
||||
self.assertContains(r, "Browser's time zone", status_code=200)
|
||||
self.assertContains(r, "Found 1 ping event.")
|
||||
self.assertContains(r, "hello world")
|
||||
|
||||
def test_it_displays_body(self) -> None:
|
||||
|
@ -186,11 +185,3 @@ class LogTestCase(BaseTestCase):
|
|||
# The notification should not show up in the log as it is
|
||||
# older than the oldest visible ping:
|
||||
self.assertNotContains(r, "Sent email to alice@example.org", status_code=200)
|
||||
|
||||
def test_it_accepts_end_query_parameter(self) -> None:
|
||||
dt = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
||||
ts = str(int(dt.timestamp()))
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get(self.url + "?end=" + ts)
|
||||
self.assertContains(r, f'value="{ts}"', status_code=200)
|
||||
|
|
|
@ -25,7 +25,7 @@ class LogTestCase(BaseTestCase):
|
|||
|
||||
def test_it_works(self) -> None:
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get(self.url)
|
||||
r = self.client.get(self.url + "?success=on")
|
||||
self.assertContains(r, "hello world")
|
||||
|
||||
def test_team_access_works(self) -> None:
|
||||
|
@ -58,12 +58,12 @@ class LogTestCase(BaseTestCase):
|
|||
def test_it_accepts_start_parameter(self) -> None:
|
||||
ts = str(now().timestamp())
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get(self.url + "?start=" + ts)
|
||||
r = self.client.get(self.url + "?success=on&u=" + ts)
|
||||
self.assertNotContains(r, "hello world")
|
||||
|
||||
def test_it_rejects_bad_start_parameter(self) -> None:
|
||||
def test_it_rejects_bad_u_parameter(self) -> None:
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
for sample in ["surprise", "100000000000000000"]:
|
||||
r = self.client.get(self.url + "?start=" + sample)
|
||||
r = self.client.get(self.url + "?u=" + sample)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
|
|
@ -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
|
||||
|
@ -26,7 +25,7 @@ from django.contrib.auth.decorators import login_required
|
|||
from django.contrib.auth.models import User
|
||||
from django.core import signing
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Case, Count, F, QuerySet, When
|
||||
from django.db.models import Case, Count, F, Q, QuerySet, When
|
||||
from django.http import (
|
||||
Http404,
|
||||
HttpRequest,
|
||||
|
@ -76,7 +75,7 @@ STATUS_TEXT_TMPL = get_template("front/log_status_text.html")
|
|||
LAST_PING_TMPL = get_template("front/last_ping_cell.html")
|
||||
EVENTS_TMPL = get_template("front/details_events.html")
|
||||
DOWNTIMES_TMPL = get_template("front/details_downtimes.html")
|
||||
LOGS_TMPL = get_template("front/log_row.html")
|
||||
LOG_ROWS_TMPL = get_template("front/log_rows.html")
|
||||
|
||||
|
||||
def _tags_counts(checks: Iterable[Check]) -> tuple[list[tuple[str, str, str]], int]:
|
||||
|
@ -848,12 +847,18 @@ def _get_events(
|
|||
page_limit: int,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
kinds: tuple[str, ...] | None = None,
|
||||
) -> list[Notification | Ping]:
|
||||
# Sorting by "n" instead of "id" is important here. Both give the same
|
||||
# query results, but sorting by "id" can cause postgres to pick
|
||||
# api_ping.id index (slow if the api_ping table is big). Sorting by
|
||||
# "n" works around the problem--postgres picks the api_ping.owner_id index.
|
||||
pq = check.visible_pings.order_by("-n")
|
||||
if kinds is not None:
|
||||
q = Q(kind__in=kinds)
|
||||
if "success" in kinds:
|
||||
q = q | Q(kind__isnull=True) | Q(kind="")
|
||||
pq = pq.filter(q)
|
||||
if start and end:
|
||||
pq = pq.filter(created__gte=start, created__lte=end)
|
||||
|
||||
|
@ -888,61 +893,41 @@ def _get_events(
|
|||
for ping in pings:
|
||||
ping.duration = None
|
||||
|
||||
alerts: list[Notification]
|
||||
q = Notification.objects.select_related("channel")
|
||||
q = q.filter(owner=check, check_status="down")
|
||||
if start and end:
|
||||
q = q.filter(created__gte=start, created__lte=end)
|
||||
alerts = list(q)
|
||||
elif len(pings):
|
||||
cutoff = pings[-1].created
|
||||
q = q.filter(created__gt=cutoff)
|
||||
alerts = list(q)
|
||||
else:
|
||||
alerts = []
|
||||
alerts: list[Notification] = []
|
||||
if kinds is None or "notification" in kinds:
|
||||
nq = check.notification_set.order_by("-created")
|
||||
nq = nq.filter(check_status="down")
|
||||
nq = nq.select_related("channel")
|
||||
if start and end:
|
||||
nq = nq.filter(created__gte=start, created__lte=end)
|
||||
elif len(pings):
|
||||
cutoff = pings[-1].created
|
||||
nq = nq.filter(created__gt=cutoff)
|
||||
alerts = list(nq[:page_limit])
|
||||
|
||||
events = pings + alerts
|
||||
events.sort(key=lambda el: el.created, reverse=True)
|
||||
return events
|
||||
return events[:page_limit]
|
||||
|
||||
|
||||
@login_required
|
||||
def log(request: AuthenticatedHttpRequest, code: UUID) -> HttpResponse:
|
||||
check, rw = _get_check_for_user(request, code, preload_owner_profile=True)
|
||||
|
||||
smin = check.created
|
||||
smax = now()
|
||||
smin = smax - td(hours=24)
|
||||
|
||||
oldest_ping = check.visible_pings.order_by("n").first()
|
||||
if oldest_ping:
|
||||
smin = min(smin, oldest_ping.created)
|
||||
smin = max(smin, oldest_ping.created)
|
||||
|
||||
# Align slider steps to full hours
|
||||
smin = smin.replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
form = forms.LogFiltersForm(request.GET)
|
||||
start, end = smin, smax
|
||||
if form.is_valid():
|
||||
if form.cleaned_data["end"]:
|
||||
end = form.cleaned_data["end"]
|
||||
|
||||
# Clamp the _get_events start argument to the date of the oldest visible ping
|
||||
get_events_start = start
|
||||
if oldest_ping and oldest_ping.created > get_events_start:
|
||||
get_events_start = oldest_ping.created
|
||||
|
||||
total = check.visible_pings.filter(created__gte=start, created__lte=end).count()
|
||||
events = _get_events(check, 1000, start=get_events_start, end=end)
|
||||
ctx = {
|
||||
"page": "log",
|
||||
"project": check.project,
|
||||
"check": check,
|
||||
"min": smin,
|
||||
"max": smax,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"events": events,
|
||||
"num_total": total,
|
||||
"events": _get_events(check, 1000, start=smin, end=smax),
|
||||
"oldest_ping": oldest_ping,
|
||||
}
|
||||
|
||||
return render(request, "front/log.html", ctx)
|
||||
|
@ -2748,23 +2733,27 @@ def verify_signal_number(request: AuthenticatedHttpRequest) -> HttpResponse:
|
|||
def log_events(request: AuthenticatedHttpRequest, code: UUID) -> HttpResponse:
|
||||
check, rw = _get_check_for_user(request, code, preload_owner_profile=True)
|
||||
|
||||
if "start" in request.GET:
|
||||
try:
|
||||
v = float(request.GET["start"])
|
||||
except ValueError:
|
||||
return HttpResponseBadRequest()
|
||||
# min_value is 2010-01-01, max_value is 2030-01-01
|
||||
if v < 1262296800 or v > 1893448800:
|
||||
return HttpResponseBadRequest()
|
||||
start = datetime.fromtimestamp(v, tz=timezone.utc) + td(microseconds=1)
|
||||
form = forms.LogFiltersForm(request.GET)
|
||||
if not form.is_valid():
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
start = form.cleaned_data["u"]
|
||||
if start:
|
||||
# We are live-loading more events
|
||||
start += td(microseconds=1)
|
||||
end = now()
|
||||
else:
|
||||
# We're applying new filters
|
||||
start = check.created
|
||||
end = form.cleaned_data["end"] or now()
|
||||
|
||||
html = ""
|
||||
for event in _get_events(check, 1000, start=start, end=now()):
|
||||
html += LOGS_TMPL.render({"event": event, "describe_body": True})
|
||||
ctx = {
|
||||
"events": _get_events(check, 1000, start=start, end=end, kinds=form.kinds()),
|
||||
"describe_body": True,
|
||||
}
|
||||
|
||||
return JsonResponse({"max": now().timestamp(), "events": html})
|
||||
payload = {"max": now().timestamp(), "events": LOG_ROWS_TMPL.render(ctx)}
|
||||
return JsonResponse(payload)
|
||||
|
||||
|
||||
# Forks: add custom views after this line
|
||||
|
|
|
@ -104,6 +104,3 @@
|
|||
line-height: 1;
|
||||
}
|
||||
|
||||
#filters-ping-types {
|
||||
margin-left: 20px;
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
$(function () {
|
||||
var activeRequest = null;
|
||||
var slider = document.getElementById("end");
|
||||
|
||||
// Look up the active tz switch to determine the initial display timezone:
|
||||
|
@ -10,19 +11,55 @@ $(function () {
|
|||
}
|
||||
|
||||
function updateSliderPreview() {
|
||||
var toFormatted = "now";
|
||||
var toFormatted = "now, live updates";
|
||||
if (slider.value != slider.max) {
|
||||
toFormatted = fromUnix(slider.value).format("MMM D, HH:mm");
|
||||
}
|
||||
$("#end-formatted").text(toFormatted);
|
||||
$("#end-formatted").html(toFormatted);
|
||||
}
|
||||
|
||||
function formatDateSpans() {
|
||||
$("span[data-dt]").each(function(i, el) {
|
||||
el.innerText = fromUnix(el.dataset.dt).format(el.dataset.fmt);
|
||||
});
|
||||
}
|
||||
|
||||
function updateNumHits() {
|
||||
$("#num-hits").text($("#log tr").length);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
var url = document.getElementById("log").dataset.refreshUrl;
|
||||
$("#end").attr("disabled", slider.value == slider.max);
|
||||
var qs = $("#filters").serialize();
|
||||
$("#end").attr("disabled", false);
|
||||
|
||||
if (activeRequest) {
|
||||
// Abort the previous in-flight request so we don't display stale
|
||||
// data later
|
||||
activeRequest.abort();
|
||||
}
|
||||
activeRequest = $.ajax({
|
||||
url: url + "?" + qs,
|
||||
dataType: "json",
|
||||
timeout: 2000,
|
||||
success: function(data) {
|
||||
activeRequest = null;
|
||||
if (!data.events)
|
||||
return;
|
||||
|
||||
var tbody = document.createElement("tbody");
|
||||
tbody.innerHTML = data.events;
|
||||
switchDateFormat(dateFormat, tbody.querySelectorAll("tr"));
|
||||
$("#log").empty().append(tbody);
|
||||
updateNumHits();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$("#end").on("input", updateSliderPreview);
|
||||
$("#end").on("change", function() {
|
||||
// Don't send the end parameter if slider is set to "now"
|
||||
$("#end").attr("disabled", slider.value == slider.max);
|
||||
$("#filters").submit();
|
||||
});
|
||||
$("#end").on("change", applyFilters);
|
||||
$("#filters input:checkbox").on("change", applyFilters);
|
||||
|
||||
$("#log").on("click", "tr.ok", function() {
|
||||
var n = $("td", this).first().text();
|
||||
|
@ -45,40 +82,48 @@ $(function () {
|
|||
$("#format-switcher").click(function(ev) {
|
||||
var format = ev.target.dataset.format;
|
||||
switchDateFormat(format, document.querySelectorAll("#log tr"));
|
||||
formatDateSpans();
|
||||
});
|
||||
|
||||
switchDateFormat(dateFormat, document.querySelectorAll("#log tr"));
|
||||
formatDateSpans();
|
||||
// The table is initially hidden to avoid flickering as we convert dates.
|
||||
// Once it's ready, set it to visible:
|
||||
$("#log").css("visibility", "visible");
|
||||
|
||||
function fetchNewEvents() {
|
||||
var url = document.getElementById("log").dataset.refreshUrl;
|
||||
var firstRow = $("#log tr").get(0);
|
||||
if (firstRow) {
|
||||
url += "?start=" + firstRow.dataset.dt;
|
||||
// Do not fetch updates if the slider is not set to "now"
|
||||
// or there's an AJAX request in flight
|
||||
if (slider.value != slider.max || activeRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
var url = document.getElementById("log").dataset.refreshUrl;
|
||||
var qs = $("#filters").serialize();
|
||||
|
||||
var firstRow = $("#log tr").get(0);
|
||||
if (firstRow) {
|
||||
qs += "&u=" + firstRow.dataset.dt;
|
||||
}
|
||||
|
||||
activeRequest = $.ajax({
|
||||
url: url + "?" + qs,
|
||||
dataType: "json",
|
||||
timeout: 2000,
|
||||
success: function(data) {
|
||||
if (data.events) {
|
||||
var tbody = document.createElement("tbody");
|
||||
tbody.setAttribute("class", "new");
|
||||
tbody.innerHTML = data.events;
|
||||
switchDateFormat(dateFormat, tbody.querySelectorAll("tr"));
|
||||
document.getElementById("log").prepend(tbody);
|
||||
$("#events-count").remove();
|
||||
}
|
||||
activeRequest = null;
|
||||
if (!data.events)
|
||||
return;
|
||||
|
||||
var tbody = document.createElement("tbody");
|
||||
tbody.setAttribute("class", "new");
|
||||
tbody.innerHTML = data.events;
|
||||
switchDateFormat(dateFormat, tbody.querySelectorAll("tr"));
|
||||
document.getElementById("log").prepend(tbody);
|
||||
updateNumHits();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (slider.value == slider.max) {
|
||||
adaptiveSetInterval(fetchNewEvents, false);
|
||||
}
|
||||
|
||||
|
||||
adaptiveSetInterval(fetchNewEvents, false);
|
||||
});
|
||||
|
|
|
@ -40,22 +40,6 @@
|
|||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="alert alert-info">
|
||||
<span id="events-count">
|
||||
{% if num_total > 1000 %}
|
||||
Found {{ num_total }} ping events, displaying the most recent 1000.
|
||||
{% elif num_total > 0 %}
|
||||
Found {{ num_total }} ping event{{ num_total|pluralize }}.
|
||||
{% elif not events and check.n_pings > 0 %}
|
||||
The filter parameters matched no events.
|
||||
{% elif check.n_pings == 0 %}
|
||||
Log is empty. This check has not received any pings yet.
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if end == max %}
|
||||
Updating live, new events will load automatically.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-9">
|
||||
<div class="table-responsive">
|
||||
|
@ -64,13 +48,18 @@
|
|||
id="log"
|
||||
data-url="{% url 'hc-ping-details' check.code '0' %}"
|
||||
data-refresh-url="{% url 'hc-log-events' check.code %}">
|
||||
<tbody>
|
||||
{% if events %}
|
||||
{% for event in events %}{% include "front/log_row.html" with describe_body=True %}{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
<tbody>{% include "front/log_rows.html" %}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if oldest_ping %}
|
||||
<div class="alert alert-info">
|
||||
Showing <span id="num-hits">{{ events|length }}</span> matching events.
|
||||
<br >
|
||||
The oldest stored ping is ping #{{ oldest_ping.n }} from
|
||||
<span data-dt="{{ oldest_ping.created|timestamp }}" data-fmt="MMM D, YYYY"></span>.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<form id="filters" method="get">
|
||||
|
@ -84,9 +73,34 @@
|
|||
name="end"
|
||||
min="{{ min|timestamp|floatformat:"0" }}"
|
||||
max="{{ max|timestamp|floatformat:"0" }}"
|
||||
value="{{ end|timestamp|floatformat:"0" }}">
|
||||
value="{{ max|timestamp|floatformat:"0" }}"
|
||||
autocomplete="off">
|
||||
|
||||
<p id="end-help" class="text-muted">⬆<br>{{ min|date:"M d, Y"}}</p>
|
||||
<p id="end-help" class="text-muted">
|
||||
⬆<br>
|
||||
<span data-dt="{{ min|timestamp }}" data-fmt="MMM D, YYYY"></span>.
|
||||
</p>
|
||||
|
||||
<br>
|
||||
<label>Event types</label>
|
||||
<div class="checkbox">
|
||||
<label><input type="checkbox" name="success" autocomplete="off" checked> OK</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label><input type="checkbox" name="fail" autocomplete="off" checked> Failure</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label><input type="checkbox" name="start" autocomplete="off" checked> Started</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label><input type="checkbox" name="log" autocomplete="off" checked> Log</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label><input type="checkbox" name="ign" autocomplete="off" checked> Ignored</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label><input type="checkbox" name="notification" autocomplete="off" checked> Notification</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
4
templates/front/log_rows.html
Normal file
4
templates/front/log_rows.html
Normal file
|
@ -0,0 +1,4 @@
|
|||
{% if events %}
|
||||
{% for event in events %}{% include "front/log_row.html" with describe_body=True %}{% endfor %}
|
||||
{% endif %}
|
||||
|
Loading…
Add table
Reference in a new issue