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

Add monthly uptime percentage display in Check Details page

Fixes: 
This commit is contained in:
Pēteris Caune 2023-09-27 13:44:35 +03:00
parent db5d8adeb5
commit f7af738c76
No known key found for this signature in database
GPG key ID: E28D7679E9A9EDE2
10 changed files with 105 additions and 16 deletions

View file

@ -15,6 +15,7 @@ version is 3.10.
- Add support for ntfy access tokens (#879)
- Improve ntfy notifications (include tags, period, last ping type etc.)
- Add an "Account closed." confirmation message after closing an account
- Add monthly uptime percentage display in Check Details page (#773)
### Bug Fixes
- Fix "senddeletionnotices" to recognize "Supporter" subscriptions

View file

@ -240,7 +240,7 @@ class Profile(models.Model):
boundaries = month_boundaries(3, self.tz)
for check in checks:
downtimes = check.downtimes_by_boundary(boundaries)
downtimes = check.downtimes_by_boundary(boundaries, self.tz)
# downtimes_by_boundary returns records in descending order.
# Switch to ascending order:
downtimes.reverse()

View file

@ -28,7 +28,7 @@ from pydantic import BaseModel, Field
from hc.accounts.models import Project
from hc.api import transports
from hc.lib import emails
from hc.lib.date import month_boundaries
from hc.lib.date import month_boundaries, seconds_in_month
from hc.lib.s3 import get_object, put_object, remove_objects
STATUSES = (("up", "Up"), ("down", "Down"), ("new", "New"), ("paused", "Paused"))
@ -134,15 +134,25 @@ class CheckDict(TypedDict, total=False):
@dataclass
class DowntimeRecord:
boundary: datetime
tz: str
duration: td
count: int | None
def monthly_uptime(self) -> float:
# NB: this method assumes monthly boundaries.
# It will yield incorrect results for weekly boundaries
max_seconds = seconds_in_month(self.boundary.date(), self.tz)
up_seconds = max_seconds - self.duration.total_seconds()
return up_seconds / max_seconds
class DowntimeSummary(object):
def __init__(self, boundaries: list[datetime]) -> None:
def __init__(self, boundaries: list[datetime], tz: str) -> None:
self.boundaries = list(sorted(boundaries, reverse=True))
self.durations = [td() for _ in boundaries]
self.counts = [0 for _ in boundaries]
self.tz = tz
def add(self, when: datetime, duration: td) -> None:
for i in range(0, len(self.boundaries)):
@ -154,7 +164,7 @@ class DowntimeSummary(object):
def as_records(self) -> list[DowntimeRecord]:
result = []
for b, d, c in zip(self.boundaries, self.durations, self.counts):
result.append(DowntimeRecord(b, d, c))
result.append(DowntimeRecord(b, self.tz, d, c))
return result
@ -497,18 +507,19 @@ class Check(models.Model):
threshold = self.n_pings - self.project.owner_profile.ping_log_limit
return self.ping_set.filter(n__gt=threshold)
def downtimes_by_boundary(self, boundaries: list[datetime]) -> list[DowntimeRecord]:
def downtimes_by_boundary(
self, boundaries: list[datetime], tz: str
) -> list[DowntimeRecord]:
"""Calculate downtime counts and durations for the given time intervals.
Returns a list of namedtuples (DowntimeRecord instances)
in descending datetime order.
Returns a list of DowntimeRecord instances in descending datetime order.
`boundaries` are the datetimes of the first days of time intervals
(months or weeks) we're interested in, in ascending order.
"""
summary = DowntimeSummary(boundaries)
summary = DowntimeSummary(boundaries, tz)
# A list of flips and time interval boundaries
events = [(b, "---") for b in boundaries]
@ -521,7 +532,10 @@ class Check(models.Model):
dt, status = now(), self.status
for prev_dt, prev_status in sorted(events, reverse=True):
if status == "down":
summary.add(prev_dt, dt - prev_dt)
# Before subtracting datetimes convert them to UTC.
# Otherwise we will get incorrect results around DST transitions:
delta = dt.astimezone(timezone.utc) - prev_dt.astimezone(timezone.utc)
summary.add(prev_dt, delta)
dt = prev_dt
if prev_status != "---":
@ -540,7 +554,7 @@ class Check(models.Model):
def downtimes(self, months: int, tz: str) -> list[DowntimeRecord]:
boundaries = month_boundaries(months, tz)
return self.downtimes_by_boundary(boundaries)
return self.downtimes_by_boundary(boundaries, tz)
def create_flip(self, new_status: str, mark_as_processed: bool = False) -> None:
"""Create a Flip object for this check.

View file

@ -210,8 +210,27 @@ class CheckModelTestCase(BaseTestCase):
records = check.downtimes(10, "UTC")
self.assertEqual(len(records), 10)
for r in records:
self.assertEqual(records[0].count, 1)
self.assertEqual(records[0].monthly_uptime(), (31 - 14) / 31)
for r in records[1:]:
self.assertEqual(r.count, 1)
self.assertEqual(r.monthly_uptime(), 0.0)
@patch("hc.api.models.now", MOCK_NOW)
@patch("hc.lib.date.now", MOCK_NOW)
def test_monthly_uptime_pct_handles_dst(self) -> None:
check = Check(project=self.project, status="down")
check.created = datetime(2019, 1, 1, tzinfo=timezone.utc)
check.save()
records = check.downtimes(10, "Europe/Riga")
self.assertEqual(len(records), 10)
for r in records[1:]:
self.assertEqual(r.count, 1)
self.assertEqual(r.monthly_uptime(), 0.0)
@patch("hc.api.models.now", MOCK_NOW)
@patch("hc.lib.date.now", MOCK_NOW)
@ -254,14 +273,17 @@ class CheckModelTestCase(BaseTestCase):
self.assertEqual(jan.boundary.isoformat(), "2020-01-01T00:00:00+00:00")
self.assertEqual(jan.duration, td(days=14))
self.assertEqual(jan.monthly_uptime(), (31 - 14) / 31)
self.assertEqual(jan.count, 1)
self.assertEqual(dec.boundary.isoformat(), "2019-12-01T00:00:00+00:00")
self.assertEqual(dec.duration, td(days=31))
self.assertEqual(dec.monthly_uptime(), 0.0)
self.assertEqual(dec.count, 1)
self.assertEqual(nov.boundary.isoformat(), "2019-11-01T00:00:00+00:00")
self.assertEqual(nov.duration, td(days=16))
self.assertEqual(nov.monthly_uptime(), 14 / 30)
self.assertEqual(nov.count, 1)
@patch("hc.api.models.now", MOCK_NOW)
@ -283,6 +305,9 @@ class CheckModelTestCase(BaseTestCase):
self.assertEqual(jan.boundary.isoformat(), "2020-01-01T00:00:00+02:00")
self.assertEqual(jan.duration, td(days=14, hours=1))
total_hours = 31 * 24
up_hours = total_hours - 14 * 24 - 1
self.assertEqual(jan.monthly_uptime(), up_hours / total_hours)
self.assertEqual(jan.count, 1)
self.assertEqual(dec.boundary.isoformat(), "2019-12-01T00:00:00+02:00")

View file

@ -274,3 +274,8 @@ def fix_asterisks(s: str) -> str:
"""Prepend asterisks with "Combining Grapheme Joiner" characters."""
return s.replace("*", "\u034f*")
@register.filter
def pct(v: float) -> str:
return str(int(v * 1000) / 10)

View file

@ -145,7 +145,8 @@ class DetailsTestCase(BaseTestCase):
self.assertContains(r, "Dec. 2019")
# The summary for Jan. 2020 should be "1 downtime, 1 hour total"
self.assertContains(r, "1 downtime, 1 hour total", html=True)
self.assertContains(r, "1 downtime, 1 hour total")
self.assertContains(r, "99.8% uptime")
@patch("hc.lib.date.now")
def test_it_downtime_summary_handles_plural(self, mock_now: Mock) -> None:
@ -171,7 +172,8 @@ class DetailsTestCase(BaseTestCase):
self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url)
self.assertContains(r, "1 downtime, 2 hours total", html=True)
self.assertContains(r, "1 downtime, 2 hours total")
self.assertContains(r, "99.7% uptime")
@patch("hc.lib.date.now")
def test_downtime_summary_handles_positive_utc_offset(self, mock_now: Mock) -> None:

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime, timedelta
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from django.utils.timezone import now
@ -102,3 +102,19 @@ def week_boundaries(weeks: int, tzstr: str) -> list[datetime]:
needle -= timedelta(days=7)
return result
def seconds_in_month(d: date, tzstr: str) -> float:
tz = ZoneInfo(tzstr)
start = datetime(d.year, d.month, 1, tzinfo=tz)
start_utc = start.astimezone(timezone.utc)
y, m = d.year, d.month
m += 1
if m > 12:
y += 1
m = 1
end = datetime(y, m, 1, tzinfo=tz)
end_utc = end.astimezone(timezone.utc)
return (end_utc - start_utc).total_seconds()

View file

@ -1,12 +1,12 @@
from __future__ import annotations
from datetime import datetime
from datetime import date, datetime
from datetime import timedelta as td
from datetime import timezone
from unittest import TestCase
from unittest.mock import Mock, patch
from hc.lib.date import format_hms, month_boundaries, week_boundaries
from hc.lib.date import format_hms, month_boundaries, seconds_in_month, week_boundaries
CURRENT_TIME = datetime(2020, 1, 15, tzinfo=timezone.utc)
MOCK_NOW = Mock(return_value=CURRENT_TIME)
@ -66,3 +66,17 @@ class WeekBoundaryTestCase(TestCase):
self.assertEqual(result[0].isoformat(), "2019-12-30T00:00:00+02:00")
self.assertEqual(result[1].isoformat(), "2020-01-06T00:00:00+02:00")
self.assertEqual(result[2].isoformat(), "2020-01-13T00:00:00+02:00")
class SecondsInMonthTestCase(TestCase):
def test_utc_works(self) -> None:
result = seconds_in_month(date(2023, 10, 1), "UTC")
self.assertEqual(result, 31 * 24 * 60 * 60)
def test_it_handles_dst_extra_hour(self) -> None:
result = seconds_in_month(date(2023, 10, 1), "Europe/Riga")
self.assertEqual(result, 31 * 24 * 60 * 60 + 60 * 60)
def test_it_handles_dst_skipped_hour(self) -> None:
result = seconds_in_month(date(2024, 3, 1), "Europe/Riga")
self.assertEqual(result, 31 * 24 * 60 * 60 - 60 * 60)

View file

@ -93,6 +93,17 @@
color: #888;
}
#downtimes .uptime {
display: block;
color: #888;
}
@media (min-width: 1199px) {
#downtimes .uptime {
float: right;
}
}
.alert.no-events, .alert.no-channels {
border: var(--alert-no-data-border);
background: var(--alert-no-data-bg);

View file

@ -7,6 +7,7 @@
<td>
{% if d.count %}
{{ d.count }} downtime{{ d.count|pluralize }}, {{ d.duration|hc_approx_duration }} total
<span class="uptime">{{ d.monthly_uptime|pct }}% uptime</span>
{% elif d.count is None %}
{% else %}