From d7948d9939633b806ef1585a7295a9abc579844b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C4=93teris=20Caune?= <cuu508@gmail.com>
Date: Tue, 9 Apr 2024 12:39:42 +0300
Subject: [PATCH] Show status changes (flips) in check's log page

Fixes: #447
---
 CHANGELOG.md                      |  5 +++++
 hc/front/forms.py                 |  3 ++-
 hc/front/tests/test_log_events.py | 21 +++++++++++++++++----
 hc/front/views.py                 | 13 ++++++++++---
 static/css/log.css                | 11 ++++++++---
 static/css/variables.css          |  6 ++----
 templates/front/log.html          | 10 +++++++---
 templates/front/log_row.html      | 12 +++++++++---
 8 files changed, 60 insertions(+), 21 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f22069d..9cac572d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,11 @@
 # Changelog
 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
 
 ### Improvements
diff --git a/hc/front/forms.py b/hc/front/forms.py
index 827d5ffc..48435b18 100644
--- a/hc/front/forms.py
+++ b/hc/front/forms.py
@@ -391,6 +391,7 @@ class LogFiltersForm(forms.Form):
     log = forms.BooleanField(required=False)
     ign = forms.BooleanField(required=False)
     notification = forms.BooleanField(required=False)
+    flip = forms.BooleanField(required=False)
 
     def clean_u(self) -> datetime | None:
         if self.cleaned_data["u"]:
@@ -403,7 +404,7 @@ class LogFiltersForm(forms.Form):
         return None
 
     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])
 
 
diff --git a/hc/front/tests/test_log_events.py b/hc/front/tests/test_log_events.py
index 23ecd8b4..354a25f4 100644
--- a/hc/front/tests/test_log_events.py
+++ b/hc/front/tests/test_log_events.py
@@ -6,7 +6,7 @@ from urllib.parse import urlencode
 
 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
 
 
@@ -21,6 +21,12 @@ class LogTestCase(BaseTestCase):
         ch.value = json.dumps({"value": "alice@example.org", "up": True, "down": True})
         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.created = now() - td(hours=1)
         n.channel = ch
@@ -36,7 +42,7 @@ class LogTestCase(BaseTestCase):
 
     def url(self, **kwargs):
         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):
                 params[key] = "on"
         for key in ("u", "end"):
@@ -48,8 +54,9 @@ class LogTestCase(BaseTestCase):
     def test_it_works(self) -> None:
         self.client.login(username="alice@example.org", password="password")
         r = self.client.get(self.url())
-        self.assertContains(r, "hello world")
-        self.assertContains(r, "Sent email to alice@example.org", status_code=200)
+        self.assertContains(r, "hello world", status_code=200)
+        self.assertContains(r, "Sent email to alice@example.org")
+        self.assertContains(r, "new ➔ down")
 
     def test_team_access_works(self) -> None:
         # 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))
         self.assertContains(r, "hello world")
         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")
diff --git a/hc/front/views.py b/hc/front/views.py
index dc830a3e..0483300a 100644
--- a/hc/front/views.py
+++ b/hc/front/views.py
@@ -51,6 +51,7 @@ from hc.api.models import (
     MAX_DURATION,
     Channel,
     Check,
+    Flip,
     Notification,
     Ping,
     TokenBucket,
@@ -847,7 +848,7 @@ def _get_events(
     start: datetime,
     end: datetime,
     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
     # 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
@@ -892,13 +893,19 @@ def _get_events(
             ping.duration = None
 
     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 = aq.filter(created__gte=start, created__lte=end, check_status="down")
         aq = aq.select_related("channel")
         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)
     return events[:page_limit]
 
diff --git a/static/css/log.css b/static/css/log.css
index 0be108d8..3ed5fe7f 100644
--- a/static/css/log.css
+++ b/static/css/log.css
@@ -82,9 +82,11 @@
 }
 
 #log tr.missing td {
-    color: #d9534f;
-    background: var(--log-missing-bg);
-    border-bottom: 1px solid var(--log-missing-border);
+    color: #a94442;
+}
+
+#log tr.flip {
+    background: var(--log-flip-bg);
 }
 
 #log tr.missing td:nth-child(4) {
@@ -105,3 +107,6 @@
     line-height: 1;
 }
 
+#filters hr {
+    margin: 10px;
+}
\ No newline at end of file
diff --git a/static/css/variables.css b/static/css/variables.css
index 95c2c409..1218a06f 100644
--- a/static/css/variables.css
+++ b/static/css/variables.css
@@ -40,8 +40,7 @@
     --label-start-color: #117a3f;
     --link-color: #0091EA;
     --link-hover-color: #00629e;
-    --log-missing-bg: #fff3f2;
-    --log-missing-border: #fbe2e0;
+    --log-flip-bg: #eee;
     --modal-content-bg: #fff;
     --nav-link-hover-bg: #eee;
     --panel-bg: #fff;
@@ -123,8 +122,7 @@ body.dark {
     --label-start-color: #e0e0e2;
     --link-color: hsl(202.8, 100%, 55%);
     --link-hover-color: hsl(202.8, 100%, 65%);
-    --log-missing-bg: #2d1e21;
-    --log-missing-border: #403033;
+    --log-flip-bg: #383840;
     --modal-content-bg: #1f1f22;
     --nav-link-hover-bg: #383840;
     --panel-bg: hsl(240, 6%, 16%);
diff --git a/templates/front/log.html b/templates/front/log.html
index 41523704..47cc5bee 100644
--- a/templates/front/log.html
+++ b/templates/front/log.html
@@ -84,7 +84,7 @@
         <br>
         <label>Event types</label>
         <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 class="checkbox">
             <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>
         </div>
         <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 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>
         </form>
     </div>
diff --git a/templates/front/log_row.html b/templates/front/log_row.html
index 8f737eb3..cfc2a572 100644
--- a/templates/front/log_row.html
+++ b/templates/front/log_row.html
@@ -24,8 +24,14 @@
 {% elif event.check_status %}
 <tr class="missing" data-dt="{{ event.created|timestamp }}">
     <td><span class="ic-missing"></span></td>
-    <td></td>
-    <td></td>
+    <td></td><td></td>
     <td colspan="2">{% include "front/event_summary.html" %}</td>
 </tr>
-{% endif %}
\ No newline at end of file
+{% 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 %}