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: #773
This commit is contained in:
parent
db5d8adeb5
commit
f7af738c76
10 changed files with 105 additions and 16 deletions
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 %}
|
||||
|
|
Loading…
Add table
Reference in a new issue