0
0
Fork 0
mirror of https://github.com/healthchecks/healthchecks.git synced 2025-04-14 00:49:05 +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) - Add support for ntfy access tokens (#879)
- Improve ntfy notifications (include tags, period, last ping type etc.) - Improve ntfy notifications (include tags, period, last ping type etc.)
- Add an "Account closed." confirmation message after closing an account - Add an "Account closed." confirmation message after closing an account
- Add monthly uptime percentage display in Check Details page (#773)
### Bug Fixes ### Bug Fixes
- Fix "senddeletionnotices" to recognize "Supporter" subscriptions - Fix "senddeletionnotices" to recognize "Supporter" subscriptions

View file

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

View file

@ -28,7 +28,7 @@ from pydantic import BaseModel, Field
from hc.accounts.models import Project from hc.accounts.models import Project
from hc.api import transports from hc.api import transports
from hc.lib import emails 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 from hc.lib.s3 import get_object, put_object, remove_objects
STATUSES = (("up", "Up"), ("down", "Down"), ("new", "New"), ("paused", "Paused")) STATUSES = (("up", "Up"), ("down", "Down"), ("new", "New"), ("paused", "Paused"))
@ -134,15 +134,25 @@ class CheckDict(TypedDict, total=False):
@dataclass @dataclass
class DowntimeRecord: class DowntimeRecord:
boundary: datetime boundary: datetime
tz: str
duration: td duration: td
count: int | None 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): 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.boundaries = list(sorted(boundaries, reverse=True))
self.durations = [td() for _ in boundaries] self.durations = [td() for _ in boundaries]
self.counts = [0 for _ in boundaries] self.counts = [0 for _ in boundaries]
self.tz = tz
def add(self, when: datetime, duration: td) -> None: def add(self, when: datetime, duration: td) -> None:
for i in range(0, len(self.boundaries)): for i in range(0, len(self.boundaries)):
@ -154,7 +164,7 @@ class DowntimeSummary(object):
def as_records(self) -> list[DowntimeRecord]: def as_records(self) -> list[DowntimeRecord]:
result = [] result = []
for b, d, c in zip(self.boundaries, self.durations, self.counts): 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 return result
@ -497,18 +507,19 @@ class Check(models.Model):
threshold = self.n_pings - self.project.owner_profile.ping_log_limit threshold = self.n_pings - self.project.owner_profile.ping_log_limit
return self.ping_set.filter(n__gt=threshold) 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. """Calculate downtime counts and durations for the given time intervals.
Returns a list of namedtuples (DowntimeRecord instances) Returns a list of DowntimeRecord instances in descending datetime order.
in descending datetime order.
`boundaries` are the datetimes of the first days of time intervals `boundaries` are the datetimes of the first days of time intervals
(months or weeks) we're interested in, in ascending order. (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 # A list of flips and time interval boundaries
events = [(b, "---") for b in boundaries] events = [(b, "---") for b in boundaries]
@ -521,7 +532,10 @@ class Check(models.Model):
dt, status = now(), self.status dt, status = now(), self.status
for prev_dt, prev_status in sorted(events, reverse=True): for prev_dt, prev_status in sorted(events, reverse=True):
if status == "down": 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 dt = prev_dt
if prev_status != "---": if prev_status != "---":
@ -540,7 +554,7 @@ class Check(models.Model):
def downtimes(self, months: int, tz: str) -> list[DowntimeRecord]: def downtimes(self, months: int, tz: str) -> list[DowntimeRecord]:
boundaries = month_boundaries(months, tz) 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: def create_flip(self, new_status: str, mark_as_processed: bool = False) -> None:
"""Create a Flip object for this check. """Create a Flip object for this check.

View file

@ -210,8 +210,27 @@ class CheckModelTestCase(BaseTestCase):
records = check.downtimes(10, "UTC") records = check.downtimes(10, "UTC")
self.assertEqual(len(records), 10) 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.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.api.models.now", MOCK_NOW)
@patch("hc.lib.date.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.boundary.isoformat(), "2020-01-01T00:00:00+00:00")
self.assertEqual(jan.duration, td(days=14)) self.assertEqual(jan.duration, td(days=14))
self.assertEqual(jan.monthly_uptime(), (31 - 14) / 31)
self.assertEqual(jan.count, 1) self.assertEqual(jan.count, 1)
self.assertEqual(dec.boundary.isoformat(), "2019-12-01T00:00:00+00:00") self.assertEqual(dec.boundary.isoformat(), "2019-12-01T00:00:00+00:00")
self.assertEqual(dec.duration, td(days=31)) self.assertEqual(dec.duration, td(days=31))
self.assertEqual(dec.monthly_uptime(), 0.0)
self.assertEqual(dec.count, 1) self.assertEqual(dec.count, 1)
self.assertEqual(nov.boundary.isoformat(), "2019-11-01T00:00:00+00:00") self.assertEqual(nov.boundary.isoformat(), "2019-11-01T00:00:00+00:00")
self.assertEqual(nov.duration, td(days=16)) self.assertEqual(nov.duration, td(days=16))
self.assertEqual(nov.monthly_uptime(), 14 / 30)
self.assertEqual(nov.count, 1) self.assertEqual(nov.count, 1)
@patch("hc.api.models.now", MOCK_NOW) @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.boundary.isoformat(), "2020-01-01T00:00:00+02:00")
self.assertEqual(jan.duration, td(days=14, hours=1)) 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(jan.count, 1)
self.assertEqual(dec.boundary.isoformat(), "2019-12-01T00:00:00+02:00") 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.""" """Prepend asterisks with "Combining Grapheme Joiner" characters."""
return s.replace("*", "\u034f*") 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") self.assertContains(r, "Dec. 2019")
# The summary for Jan. 2020 should be "1 downtime, 1 hour total" # 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") @patch("hc.lib.date.now")
def test_it_downtime_summary_handles_plural(self, mock_now: Mock) -> None: 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") self.client.login(username="alice@example.org", password="password")
r = self.client.get(self.url) 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") @patch("hc.lib.date.now")
def test_downtime_summary_handles_positive_utc_offset(self, mock_now: Mock) -> None: def test_downtime_summary_handles_positive_utc_offset(self, mock_now: Mock) -> None:

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from django.utils.timezone import now from django.utils.timezone import now
@ -102,3 +102,19 @@ def week_boundaries(weeks: int, tzstr: str) -> list[datetime]:
needle -= timedelta(days=7) needle -= timedelta(days=7)
return result 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 __future__ import annotations
from datetime import datetime from datetime import date, datetime
from datetime import timedelta as td from datetime import timedelta as td
from datetime import timezone from datetime import timezone
from unittest import TestCase from unittest import TestCase
from unittest.mock import Mock, patch 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) CURRENT_TIME = datetime(2020, 1, 15, tzinfo=timezone.utc)
MOCK_NOW = Mock(return_value=CURRENT_TIME) 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[0].isoformat(), "2019-12-30T00:00:00+02:00")
self.assertEqual(result[1].isoformat(), "2020-01-06T00: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") 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; color: #888;
} }
#downtimes .uptime {
display: block;
color: #888;
}
@media (min-width: 1199px) {
#downtimes .uptime {
float: right;
}
}
.alert.no-events, .alert.no-channels { .alert.no-events, .alert.no-channels {
border: var(--alert-no-data-border); border: var(--alert-no-data-border);
background: var(--alert-no-data-bg); background: var(--alert-no-data-bg);

View file

@ -7,6 +7,7 @@
<td> <td>
{% if d.count %} {% if d.count %}
{{ d.count }} downtime{{ d.count|pluralize }}, {{ d.duration|hc_approx_duration }} total {{ 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 %} {% elif d.count is None %}
{% else %} {% else %}