mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-08 14:40:05 +00:00
Rearrange the log page to make room for more filters
* Switch from nouislider to simpler <input type="range"> * Move it to a sidebar Also, fix a bug in _get_events where the "start" local variable got clobbered, and made the date range for the Notification query wrong.
This commit is contained in:
parent
22325565b5
commit
47894f6add
8 changed files with 67 additions and 176 deletions
|
@ -381,16 +381,10 @@ class SearchForm(forms.Form):
|
|||
q = forms.RegexField(regex=r"^[0-9a-zA-Z\s]{3,100}$")
|
||||
|
||||
|
||||
class SeekForm(forms.Form):
|
||||
class LogFiltersForm(forms.Form):
|
||||
# min_value is 2010-01-01, max_value is 2030-01-01
|
||||
start = forms.FloatField(min_value=1262296800, max_value=1893448800, required=False)
|
||||
end = forms.FloatField(min_value=1262296800, max_value=1893448800, required=False)
|
||||
|
||||
def clean_start(self) -> datetime | None:
|
||||
if self.cleaned_data["start"]:
|
||||
return datetime.fromtimestamp(self.cleaned_data["start"], 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)
|
||||
|
|
|
@ -187,27 +187,10 @@ class LogTestCase(BaseTestCase):
|
|||
# older than the oldest visible ping:
|
||||
self.assertNotContains(r, "Sent email to alice@example.org", status_code=200)
|
||||
|
||||
def test_it_accepts_start_query_parameter(self) -> None:
|
||||
dt = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
||||
ts = str(dt.timestamp())
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get(self.url + "?start=" + ts)
|
||||
self.assertContains(r, f'data-start="{ts}"', status_code=200)
|
||||
|
||||
def test_it_accepts_end_query_parameter(self) -> None:
|
||||
dt = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
||||
ts = str(dt.timestamp())
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get(self.url + "?end=" + ts)
|
||||
self.assertContains(r, f'data-end="{ts}"', status_code=200)
|
||||
|
||||
def test_it_ignores_bad_time_filter(self) -> None:
|
||||
self.ping.refresh_from_db()
|
||||
smin = str(self.ping.created.timestamp())
|
||||
|
||||
for sample in ["surprise", "0"]:
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
r = self.client.get(self.url + "?start=" + sample)
|
||||
self.assertContains(r, f'data-start="{smin}"', status_code=200)
|
||||
self.assertContains(r, f'value="{ts}"', status_code=200)
|
||||
|
|
|
@ -11,6 +11,7 @@ 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
|
||||
|
@ -874,10 +875,10 @@ def _get_events(
|
|||
num_misses += 1
|
||||
else:
|
||||
ping.duration = None
|
||||
start = starts[ping.rid]
|
||||
if start is not None:
|
||||
if ping.created - start < MAX_DURATION:
|
||||
ping.duration = ping.created - start
|
||||
matching_start = starts[ping.rid]
|
||||
if matching_start is not None:
|
||||
if ping.created - matching_start < MAX_DURATION:
|
||||
ping.duration = ping.created - matching_start
|
||||
|
||||
starts[ping.rid] = None
|
||||
|
||||
|
@ -917,13 +918,11 @@ def log(request: AuthenticatedHttpRequest, code: UUID) -> HttpResponse:
|
|||
smin = min(smin, oldest_ping.created)
|
||||
|
||||
# Align slider steps to full hours
|
||||
smin = smin.replace(minute=0, second=0)
|
||||
smin = smin.replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
form = forms.SeekForm(request.GET)
|
||||
form = forms.LogFiltersForm(request.GET)
|
||||
start, end = smin, smax
|
||||
if form.is_valid():
|
||||
if form.cleaned_data["start"]:
|
||||
start = form.cleaned_data["start"]
|
||||
if form.cleaned_data["end"]:
|
||||
end = form.cleaned_data["end"]
|
||||
|
||||
|
@ -2749,12 +2748,15 @@ 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)
|
||||
|
||||
form = forms.SeekForm(request.GET)
|
||||
if not form.is_valid():
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
if form.cleaned_data["start"]:
|
||||
start = form.cleaned_data["start"] + td(microseconds=1)
|
||||
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)
|
||||
else:
|
||||
start = check.created
|
||||
|
||||
|
|
|
@ -146,11 +146,18 @@ body {
|
|||
}
|
||||
|
||||
|
||||
.page-checks .container-fluid, .page-details .container-fluid {
|
||||
.page-checks .container-fluid,
|
||||
.page-details .container-fluid {
|
||||
/* Fluid below 1320px, but max width capped to 1320px ... */
|
||||
max-width: 1320px;
|
||||
}
|
||||
|
||||
.page-log .container-fluid {
|
||||
/* Fluid below 1520px, but max width capped to 1520px ... */
|
||||
max-width: 1520px;
|
||||
}
|
||||
|
||||
|
||||
.status {
|
||||
font-size: 24px;
|
||||
display: inline-block;
|
||||
|
|
|
@ -95,15 +95,15 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#log-slider {
|
||||
margin-bottom: 60px;
|
||||
#end-formatted {
|
||||
float: right;
|
||||
}
|
||||
|
||||
#log-slider .noUi-connect {
|
||||
background-color: var(--link-color);
|
||||
#end-help {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#slider-from-formatted,
|
||||
#slider-to-formatted {
|
||||
font-weight: bold;
|
||||
}
|
||||
#filters-ping-types {
|
||||
margin-left: 20px;
|
||||
}
|
118
static/js/log.js
118
static/js/log.js
|
@ -1,10 +1,5 @@
|
|||
$(function () {
|
||||
var BIG_LABEL = 1;
|
||||
var SMALL_LABEL = 2;
|
||||
var PIP = 0;
|
||||
var NO_PIP = -1;
|
||||
|
||||
var slider = document.getElementById("log-slider");
|
||||
var slider = document.getElementById("end");
|
||||
|
||||
// Look up the active tz switch to determine the initial display timezone:
|
||||
var dateFormat = $(".active", "#format-switcher").data("format");
|
||||
|
@ -15,97 +10,18 @@ $(function () {
|
|||
}
|
||||
|
||||
function updateSliderPreview() {
|
||||
var values = slider.noUiSlider.get();
|
||||
$("#slider-from-formatted").text(fromUnix(values[0]).format("MMMM D, HH:mm"));
|
||||
|
||||
var toFormatted = "now";
|
||||
if (values[1] < parseInt(slider.dataset.max)) {
|
||||
toFormatted = fromUnix(values[1]).format("MMMM D, HH:mm");
|
||||
if (slider.value < parseInt(slider.max)) {
|
||||
toFormatted = fromUnix(slider.value).format("MMM D, HH:mm");
|
||||
}
|
||||
|
||||
$("#slider-to-formatted").text(toFormatted);
|
||||
$("#end-formatted").text(toFormatted);
|
||||
}
|
||||
|
||||
function setupSlider() {
|
||||
var smin = parseInt(slider.dataset.min);
|
||||
var smax = parseInt(slider.dataset.max);
|
||||
var pixelsPerSecond = slider.clientWidth / (smax - smin);
|
||||
var pixelsPerHour = pixelsPerSecond * 3600;
|
||||
var pixelsPerDay = pixelsPerHour * 24;
|
||||
var dayGap = Math.round(0.5 + 80 / pixelsPerDay);
|
||||
|
||||
function filterPips(value, type) {
|
||||
var m = fromUnix(value);
|
||||
if (m.minute() != 0)
|
||||
return NO_PIP;
|
||||
|
||||
// Date labels on every day
|
||||
if (pixelsPerDay > 60 && m.hour() == 0)
|
||||
return BIG_LABEL;
|
||||
|
||||
// Date labels every "dayGap" days
|
||||
if (m.hour() == 0 && m.dayOfYear() % dayGap == 0)
|
||||
return BIG_LABEL;
|
||||
|
||||
// Hour labels on every hour:
|
||||
if (pixelsPerHour > 40)
|
||||
return SMALL_LABEL;
|
||||
|
||||
// Hour labels every 3 hours:
|
||||
if (pixelsPerHour > 15 && m.hour() % 3 == 0)
|
||||
return SMALL_LABEL;
|
||||
|
||||
// Hour labels every 6 hours:
|
||||
if (pixelsPerHour > 5 && m.hour() % 6 == 0)
|
||||
return SMALL_LABEL;
|
||||
|
||||
// Pip on every hour
|
||||
if (pixelsPerHour > 5)
|
||||
return PIP;
|
||||
|
||||
// Pip on every day
|
||||
if (pixelsPerDay > 10 && m.hour() == 0)
|
||||
return PIP;
|
||||
|
||||
return NO_PIP;
|
||||
}
|
||||
|
||||
function fmt(ts) {
|
||||
var pipType = filterPips(ts);
|
||||
return fromUnix(ts).format(pipType == 2 ? "HH:mm" : "MMM D");
|
||||
}
|
||||
|
||||
if (slider.noUiSlider) {
|
||||
slider.noUiSlider.destroy();
|
||||
}
|
||||
noUiSlider.create(slider, {
|
||||
start: [parseInt(slider.dataset.start), parseInt(slider.dataset.end)],
|
||||
range: {'min': smin, 'max': smax},
|
||||
connect: true,
|
||||
step: 3600,
|
||||
pips: {
|
||||
mode: "steps",
|
||||
density: 3,
|
||||
filter: filterPips,
|
||||
format: {
|
||||
to: fmt,
|
||||
from: function() {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
slider.noUiSlider.on("slide", updateSliderPreview);
|
||||
slider.noUiSlider.on("change", function(a, b, value) {
|
||||
var start = Math.round(value[0]);
|
||||
$("#seek-start").val(start).attr("disabled", start == smin);
|
||||
var end = Math.round(value[1]);
|
||||
$("#seek-end").val(end).attr("disabled", end == smax);
|
||||
$("#seek-form").submit();
|
||||
});
|
||||
}
|
||||
|
||||
setupSlider();
|
||||
updateSliderPreview();
|
||||
$("#end").on("input", updateSliderPreview);
|
||||
$("#end").on("change", function() {
|
||||
$("#end").attr("disabled", slider.value == parseInt(slider.max));
|
||||
$("#filters").submit();
|
||||
});
|
||||
|
||||
$("#log").on("click", "tr.ok", function() {
|
||||
var n = $("td", this).first().text();
|
||||
|
@ -116,7 +32,6 @@ $(function () {
|
|||
|
||||
function switchDateFormat(format, rows) {
|
||||
dateFormat = format;
|
||||
slider.noUiSlider.updateOptions({}, true);
|
||||
updateSliderPreview();
|
||||
|
||||
rows.forEach(function(row) {
|
||||
|
@ -148,18 +63,6 @@ $(function () {
|
|||
dataType: "json",
|
||||
timeout: 2000,
|
||||
success: function(data) {
|
||||
// Has a minute has passed since last slider refresh?
|
||||
if (data.max - slider.dataset.max > 60) {
|
||||
// If the right handle was all the way to the right,
|
||||
// make sure it is still all the way to the right after
|
||||
// the update:
|
||||
if (slider.dataset.end == slider.dataset.max) {
|
||||
slider.dataset.end = data.max;
|
||||
}
|
||||
slider.dataset.max = data.max;
|
||||
setupSlider();
|
||||
}
|
||||
|
||||
if (data.events) {
|
||||
var tbody = document.createElement("tbody");
|
||||
tbody.setAttribute("class", "new");
|
||||
|
@ -172,8 +75,9 @@ $(function () {
|
|||
});
|
||||
}
|
||||
|
||||
if (slider.dataset.end == slider.dataset.max) {
|
||||
if (slider.value == slider.max) {
|
||||
adaptiveSetInterval(fetchNewEvents, false);
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
|
|
@ -72,7 +72,7 @@
|
|||
{% endif %}
|
||||
|
||||
<nav class="navbar navbar-default">
|
||||
<div class="container{% if page == "checks" or page == "details" %}-fluid{% endif %}">
|
||||
<div class="container{% if page == "checks" or page == "details" or page == "log" %}-fluid{% endif %}">
|
||||
<div class="navbar-header">
|
||||
<button
|
||||
type="button"
|
||||
|
@ -206,13 +206,13 @@
|
|||
</nav>
|
||||
|
||||
{% block containers %}
|
||||
<div class="container{% if page == "checks" or page == "details" %}-fluid{% endif %}">
|
||||
<div class="container{% if page == "checks" or page == "details" or page == "log" %}-fluid{% endif %}">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container{% if page == "checks" or page == "details" %}-fluid{% endif %}">
|
||||
<div class="container{% if page == "checks" or page == "details" or page == "log" %}-fluid{% endif %}">
|
||||
<ul>
|
||||
<li>
|
||||
Healthchecks {% site_version %}
|
||||
|
|
|
@ -40,21 +40,6 @@
|
|||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p>Filter events from <strong id="slider-from-formatted"></strong> to <strong id="slider-to-formatted"></strong>.</p>
|
||||
<div
|
||||
id="log-slider"
|
||||
data-min="{{ min|timestamp }}"
|
||||
data-max="{{ max|timestamp }}"
|
||||
data-start="{{ start|timestamp }}"
|
||||
data-end="{{ end|timestamp }}">
|
||||
</div>
|
||||
|
||||
<form id="seek-form" method="get">
|
||||
<input type="hidden" name="start" id="seek-start">
|
||||
<input type="hidden" name="end" id="seek-end">
|
||||
</form>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<span id="events-count">
|
||||
{% if num_total > 1000 %}
|
||||
|
@ -71,7 +56,8 @@
|
|||
Updating live, new events will load automatically.
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-sm-9">
|
||||
<div class="table-responsive">
|
||||
<table
|
||||
class="table"
|
||||
|
@ -85,7 +71,23 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<form id="filters" method="get">
|
||||
<div>
|
||||
<label>Show events older than:</label>
|
||||
<span id="end-formatted">now</span>
|
||||
</div>
|
||||
<input
|
||||
id="end"
|
||||
type="range"
|
||||
name="end"
|
||||
min="{{ min|timestamp }}"
|
||||
max="{{ max|timestamp }}"
|
||||
value="{{ end|timestamp }}">
|
||||
|
||||
<p id="end-help" class="text-muted">⬆<br>{{ min|date:"M d, Y"}}</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -104,7 +106,6 @@
|
|||
|
||||
{% block scripts %}
|
||||
{% compress js %}
|
||||
<script src="{% static 'js/nouislider.min.js' %}"></script>
|
||||
<script src="{% static 'js/moment.min.js' %}"></script>
|
||||
<script src="{% static 'js/moment-timezone-with-data-10-year-range.min.js' %}"></script>
|
||||
<script src="{% static 'js/purify.min.js' %}"></script>
|
||||
|
|
Loading…
Add table
Reference in a new issue