diff --git a/CHANGELOG.md b/CHANGELOG.md
index b01d25f0..e93c2b2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,12 @@
# Changelog
All notable changes to this project will be documented in this file.
+## Unreleased
+
+### Improvements
+- Show the number of outages and total downtime in monthly reports. (#104)
+
+
## 1.8.0 - 2019-07-08
### Improvements
diff --git a/hc/accounts/models.py b/hc/accounts/models.py
index db35b2b5..37ed5cd7 100644
--- a/hc/accounts/models.py
+++ b/hc/accounts/models.py
@@ -12,6 +12,7 @@ from django.db.models import Count, Q
from django.urls import reverse
from django.utils import timezone
from hc.lib import emails
+from hc.lib.date import month_boundaries
NO_NAG = timedelta()
@@ -176,6 +177,7 @@ class Profile(models.Model):
"nag": nag,
"nag_period": self.nag_period.total_seconds(),
"num_down": num_down,
+ "month_boundaries": month_boundaries(),
}
emails.report(self.user.email, ctx, headers)
diff --git a/hc/api/models.py b/hc/api/models.py
index ca92fdd8..52824ca0 100644
--- a/hc/api/models.py
+++ b/hc/api/models.py
@@ -13,6 +13,7 @@ from django.utils import timezone
from hc.accounts.models import Project
from hc.api import transports
from hc.lib import emails
+from hc.lib.date import month_boundaries
import pytz
STATUSES = (("up", "Up"), ("down", "Down"), ("new", "New"), ("paused", "Paused"))
@@ -245,6 +246,39 @@ class Check(models.Model):
ping.body = body[:10000]
ping.save()
+ def outages_by_month(self, months=2):
+ now = timezone.now()
+
+ totals = {}
+ events = []
+ for boundary in month_boundaries(months=months):
+ totals[(boundary.year, boundary.month)] = [boundary, 0, 0]
+ events.append((boundary, "---"))
+
+ flips = self.flip_set.filter(created__gt=now - td(days=32 * months))
+ for flip in flips:
+ events.append((flip.created, flip.old_status))
+
+ events.sort(reverse=True)
+
+ needle, status = now, self.status
+ for dt, old_status in events:
+ if status == "down":
+ if (dt.year, dt.month) not in totals:
+ break
+
+ delta = needle - dt
+ totals[(dt.year, dt.month)][1] += int(delta.total_seconds())
+ totals[(dt.year, dt.month)][2] += 1
+
+ needle = dt
+ if old_status != "---":
+ status = old_status
+
+ flattened = list(totals.values())
+ flattened.sort(reverse=True)
+ return flattened
+
class Ping(models.Model):
id = models.BigAutoField(primary_key=True)
diff --git a/hc/front/templatetags/hc_extras.py b/hc/front/templatetags/hc_extras.py
index bff76b5d..aaadc79f 100644
--- a/hc/front/templatetags/hc_extras.py
+++ b/hc/front/templatetags/hc_extras.py
@@ -5,7 +5,7 @@ from django.conf import settings
from django.utils.html import escape
from django.utils.safestring import mark_safe
-from hc.lib.date import format_duration, format_hms
+from hc.lib.date import format_duration, format_approx_duration, format_hms
register = template.Library()
@@ -15,6 +15,11 @@ def hc_duration(td):
return format_duration(td)
+@register.filter
+def hc_approx_duration(td):
+ return format_approx_duration(td)
+
+
@register.filter
def hms(td):
return format_hms(td)
diff --git a/hc/lib/date.py b/hc/lib/date.py
index a358a167..6c9ef704 100644
--- a/hc/lib/date.py
+++ b/hc/lib/date.py
@@ -1,3 +1,7 @@
+from datetime import datetime as dt
+from django.utils import timezone
+
+
class Unit(object):
def __init__(self, name, nsecs):
self.name = name
@@ -5,6 +9,7 @@ class Unit(object):
self.nsecs = nsecs
+SECOND = Unit("second", 1)
MINUTE = Unit("minute", 60)
HOUR = Unit("hour", MINUTE.nsecs * 60)
DAY = Unit("day", HOUR.nsecs * 24)
@@ -13,6 +18,7 @@ WEEK = Unit("week", DAY.nsecs * 7)
def format_duration(td):
remaining_seconds = int(td.total_seconds())
+
result = []
for unit in (WEEK, DAY, HOUR, MINUTE):
@@ -30,7 +36,11 @@ def format_duration(td):
def format_hms(td):
- total_seconds = int(td.total_seconds())
+ if isinstance(td, int):
+ total_seconds = td
+ else:
+ total_seconds = int(td.total_seconds())
+
result = []
mins, secs = divmod(total_seconds, 60)
@@ -45,3 +55,31 @@ def format_hms(td):
result.append("%s sec" % secs)
return " ".join(result)
+
+
+def format_approx_duration(v):
+ for unit in (DAY, HOUR, MINUTE, SECOND):
+ if v >= unit.nsecs:
+ vv = v // unit.nsecs
+ if vv == 1:
+ return "1 %s" % unit.name
+ else:
+ return "%d %s" % (vv, unit.plural)
+
+ return ""
+
+
+def month_boundaries(months=2):
+ result = []
+
+ now = timezone.now()
+ y, m = now.year, now.month
+ for x in range(0, months):
+ result.append(dt(y, m, 1, tzinfo=timezone.utc))
+
+ m -= 1
+ if m == 0:
+ m = 12
+ y = y - 1
+
+ return result
diff --git a/templates/emails/report-body-html.html b/templates/emails/report-body-html.html
index da214c7a..d61bbf75 100644
--- a/templates/emails/report-body-html.html
+++ b/templates/emails/report-body-html.html
@@ -20,7 +20,12 @@ Hello,
{% endif %}
-{% include "emails/summary-html.html" %}
+
+{% if nag %}
+ {% include "emails/summary-html.html" %}
+{% else %}
+ {% include "emails/summary-downtimes-html.html" %}
+{% endif %}
{% if nag %}
Too many notifications?
diff --git a/templates/emails/summary-downtimes-html.html b/templates/emails/summary-downtimes-html.html
new file mode 100644
index 00000000..160fcf33
--- /dev/null
+++ b/templates/emails/summary-downtimes-html.html
@@ -0,0 +1,84 @@
+{% load humanize hc_extras %}
+{% regroup checks by project as groups %}
+
+ {{ group.grouper|mangle_link }} + | + {% for dt in month_boundaries %} ++ {{ dt|date:"N Y"}} + | + {% endfor %} +||||||||||
+
|
+
+ {% if check.name %}
+ {% if check.name|length > 20 %}
+ {{ check.name|mangle_link }}
+ {% else %}
+ {{ check.name|mangle_link }}
+ {% endif %}
+ {% else %}
+ unnamed
+ {% endif %}
+ {% if check.tags %}
+ +
|
+ {% for boundary, seconds, count in check.outages_by_month %}
+ {% if count %}
+
+ {{ count }} outage{{ count|pluralize }}
+
+ + ({{ seconds|hc_approx_duration }} total) + + |
+ {% else %}
+ + All good! + | + {% endif %} + {% endfor %} +