0
0
Fork 0
mirror of https://github.com/healthchecks/healthchecks.git synced 2025-04-08 14:40:05 +00:00

Implement filtering by event type in the Log page

Fixes: 
This commit is contained in:
Pēteris Caune 2024-03-20 15:12:33 +02:00
parent df6895ed1f
commit 855fac0973
No known key found for this signature in database
GPG key ID: E28D7679E9A9EDE2
9 changed files with 179 additions and 121 deletions

View file

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

View file

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

View file

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

View file

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

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

View file

@ -104,6 +104,3 @@
line-height: 1;
}
#filters-ping-types {
margin-left: 20px;
}

View file

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

View file

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

View file

@ -0,0 +1,4 @@
{% if events %}
{% for event in events %}{% include "front/log_row.html" with describe_body=True %}{% endfor %}
{% endif %}