0
0
Fork 0
mirror of https://github.com/healthchecks/healthchecks.git synced 2025-04-07 14:15:34 +00:00

Show status changes (flips) in check's log page

Fixes: 
This commit is contained in:
Pēteris Caune 2024-04-09 12:39:42 +03:00
parent c99c357709
commit d7948d9939
No known key found for this signature in database
GPG key ID: E28D7679E9A9EDE2
8 changed files with 60 additions and 21 deletions

View file

@ -1,6 +1,11 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## v3.4-dev - Unreleased
### Improvements
- Show status changes (flips) in check's log page (#447)
## v3.3 - 2024-04-03 ## v3.3 - 2024-04-03
### Improvements ### Improvements

View file

@ -391,6 +391,7 @@ class LogFiltersForm(forms.Form):
log = forms.BooleanField(required=False) log = forms.BooleanField(required=False)
ign = forms.BooleanField(required=False) ign = forms.BooleanField(required=False)
notification = forms.BooleanField(required=False) notification = forms.BooleanField(required=False)
flip = forms.BooleanField(required=False)
def clean_u(self) -> datetime | None: def clean_u(self) -> datetime | None:
if self.cleaned_data["u"]: if self.cleaned_data["u"]:
@ -403,7 +404,7 @@ class LogFiltersForm(forms.Form):
return None return None
def kinds(self) -> tuple[str, ...]: def kinds(self) -> tuple[str, ...]:
kind_keys = ("success", "fail", "start", "log", "ign", "notification") kind_keys = ("success", "fail", "start", "log", "ign", "notification", "flip")
return tuple(key for key in kind_keys if self.cleaned_data[key]) return tuple(key for key in kind_keys if self.cleaned_data[key])

View file

@ -6,7 +6,7 @@ from urllib.parse import urlencode
from django.utils.timezone import now from django.utils.timezone import now
from hc.api.models import Channel, Check, Notification, Ping from hc.api.models import Channel, Check, Flip, Notification, Ping
from hc.test import BaseTestCase from hc.test import BaseTestCase
@ -21,6 +21,12 @@ class LogTestCase(BaseTestCase):
ch.value = json.dumps({"value": "alice@example.org", "up": True, "down": True}) ch.value = json.dumps({"value": "alice@example.org", "up": True, "down": True})
ch.save() ch.save()
f = Flip(owner=self.check)
f.created = now() - td(hours=1)
f.old_status = "new"
f.new_status = "down"
f.save()
n = Notification(owner=self.check) n = Notification(owner=self.check)
n.created = now() - td(hours=1) n.created = now() - td(hours=1)
n.channel = ch n.channel = ch
@ -36,7 +42,7 @@ class LogTestCase(BaseTestCase):
def url(self, **kwargs): def url(self, **kwargs):
params = {} params = {}
for key in ("success", "fail", "start", "log", "ign", "notification"): for key in ("success", "fail", "start", "log", "ign", "notification", "flip"):
if kwargs.get(key, True): if kwargs.get(key, True):
params[key] = "on" params[key] = "on"
for key in ("u", "end"): for key in ("u", "end"):
@ -48,8 +54,9 @@ class LogTestCase(BaseTestCase):
def test_it_works(self) -> None: def test_it_works(self) -> None:
self.client.login(username="alice@example.org", password="password") self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url()) r = self.client.get(self.url())
self.assertContains(r, "hello world") self.assertContains(r, "hello world", status_code=200)
self.assertContains(r, "Sent email to alice@example.org", status_code=200) self.assertContains(r, "Sent email to alice@example.org")
self.assertContains(r, "new ➔ down")
def test_team_access_works(self) -> None: def test_team_access_works(self) -> None:
# Logging in as bob, not alice. Bob has team access so this # Logging in as bob, not alice. Bob has team access so this
@ -128,3 +135,9 @@ class LogTestCase(BaseTestCase):
r = self.client.get(self.url(notification=False)) r = self.client.get(self.url(notification=False))
self.assertContains(r, "hello world") self.assertContains(r, "hello world")
self.assertNotContains(r, "Sent email to alice@example.org", status_code=200) self.assertNotContains(r, "Sent email to alice@example.org", status_code=200)
def test_it_filters_flip(self) -> None:
self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url(flip=False))
self.assertContains(r, "hello world")
self.assertNotContains(r, "new ➔ down")

View file

@ -51,6 +51,7 @@ from hc.api.models import (
MAX_DURATION, MAX_DURATION,
Channel, Channel,
Check, Check,
Flip,
Notification, Notification,
Ping, Ping,
TokenBucket, TokenBucket,
@ -847,7 +848,7 @@ def _get_events(
start: datetime, start: datetime,
end: datetime, end: datetime,
kinds: tuple[str, ...] | None = None, kinds: tuple[str, ...] | None = None,
) -> list[Notification | Ping]: ) -> list[Notification | Ping | Flip]:
# Sorting by "n" instead of "id" is important here. Both give the same # Sorting by "n" instead of "id" is important here. Both give the same
# query results, but sorting by "id" can cause postgres to pick # 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 # api_ping.id index (slow if the api_ping table is big). Sorting by
@ -892,13 +893,19 @@ def _get_events(
ping.duration = None ping.duration = None
alerts: list[Notification] = [] alerts: list[Notification] = []
if kinds is None or "notification" in kinds: if kinds and "notification" in kinds:
aq = check.notification_set.order_by("-created") aq = check.notification_set.order_by("-created")
aq = aq.filter(created__gte=start, created__lte=end, check_status="down") aq = aq.filter(created__gte=start, created__lte=end, check_status="down")
aq = aq.select_related("channel") aq = aq.select_related("channel")
alerts = list(aq[:page_limit]) alerts = list(aq[:page_limit])
events = pings + alerts flips: list[Flip] = []
if kinds is None or "flip" in kinds:
fq = check.flip_set.order_by("-created")
fq = fq.filter(created__gte=start, created__lte=end)
flips = list(fq[:page_limit])
events = pings + alerts + flips
events.sort(key=lambda el: el.created, reverse=True) events.sort(key=lambda el: el.created, reverse=True)
return events[:page_limit] return events[:page_limit]

View file

@ -82,9 +82,11 @@
} }
#log tr.missing td { #log tr.missing td {
color: #d9534f; color: #a94442;
background: var(--log-missing-bg); }
border-bottom: 1px solid var(--log-missing-border);
#log tr.flip {
background: var(--log-flip-bg);
} }
#log tr.missing td:nth-child(4) { #log tr.missing td:nth-child(4) {
@ -105,3 +107,6 @@
line-height: 1; line-height: 1;
} }
#filters hr {
margin: 10px;
}

View file

@ -40,8 +40,7 @@
--label-start-color: #117a3f; --label-start-color: #117a3f;
--link-color: #0091EA; --link-color: #0091EA;
--link-hover-color: #00629e; --link-hover-color: #00629e;
--log-missing-bg: #fff3f2; --log-flip-bg: #eee;
--log-missing-border: #fbe2e0;
--modal-content-bg: #fff; --modal-content-bg: #fff;
--nav-link-hover-bg: #eee; --nav-link-hover-bg: #eee;
--panel-bg: #fff; --panel-bg: #fff;
@ -123,8 +122,7 @@ body.dark {
--label-start-color: #e0e0e2; --label-start-color: #e0e0e2;
--link-color: hsl(202.8, 100%, 55%); --link-color: hsl(202.8, 100%, 55%);
--link-hover-color: hsl(202.8, 100%, 65%); --link-hover-color: hsl(202.8, 100%, 65%);
--log-missing-bg: #2d1e21; --log-flip-bg: #383840;
--log-missing-border: #403033;
--modal-content-bg: #1f1f22; --modal-content-bg: #1f1f22;
--nav-link-hover-bg: #383840; --nav-link-hover-bg: #383840;
--panel-bg: hsl(240, 6%, 16%); --panel-bg: hsl(240, 6%, 16%);

View file

@ -84,7 +84,7 @@
<br> <br>
<label>Event types</label> <label>Event types</label>
<div class="checkbox"> <div class="checkbox">
<label><input type="checkbox" name="success" autocomplete="off" checked> OK</label> <label><input type="checkbox" name="success" autocomplete="off" checked> Success</label>
</div> </div>
<div class="checkbox"> <div class="checkbox">
<label><input type="checkbox" name="fail" autocomplete="off" checked> Failure</label> <label><input type="checkbox" name="fail" autocomplete="off" checked> Failure</label>
@ -96,10 +96,14 @@
<label><input type="checkbox" name="log" autocomplete="off" checked> Log</label> <label><input type="checkbox" name="log" autocomplete="off" checked> Log</label>
</div> </div>
<div class="checkbox"> <div class="checkbox">
<label><input type="checkbox" name="ign" autocomplete="off" checked> Ignored</label> <label><input type="checkbox" name="ign" autocomplete="off" checked> Ignored ping</label>
</div>
<hr>
<div class="checkbox">
<label><input type="checkbox" name="flip" autocomplete="off" checked> Status change</label>
</div> </div>
<div class="checkbox"> <div class="checkbox">
<label><input type="checkbox" name="notification" autocomplete="off" checked> Notification</label> <label><input type="checkbox" name="notification" autocomplete="off"> Downtime alert</label>
</div> </div>
</form> </form>
</div> </div>

View file

@ -24,8 +24,14 @@
{% elif event.check_status %} {% elif event.check_status %}
<tr class="missing" data-dt="{{ event.created|timestamp }}"> <tr class="missing" data-dt="{{ event.created|timestamp }}">
<td><span class="ic-missing"></span></td> <td><span class="ic-missing"></span></td>
<td></td> <td></td><td></td>
<td></td>
<td colspan="2">{% include "front/event_summary.html" %}</td> <td colspan="2">{% include "front/event_summary.html" %}</td>
</tr> </tr>
{% endif %} {% elif event.new_status %}
<tr class="flip" data-dt="{{ event.created|timestamp }}">
<td></td><td></td><td></td>
<td colspan="2">
Status: <strong>{{ event.old_status }} ➔ {{ event.new_status }}</strong>.
</td>
</tr>
{% endif %}