forked from GithubBackups/healthchecks
Add "/ping/<code>/start" API endpoint
This commit is contained in:
parent
25e48f1b9f
commit
481848a749
@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Allow simultaneous access to checks from different teams
|
- Allow simultaneous access to checks from different teams
|
||||||
- Add CORS support to API endpoints
|
- Add CORS support to API endpoints
|
||||||
- Flip model, for tracking status changes of the Check objects
|
- Flip model, for tracking status changes of the Check objects
|
||||||
|
- Add "/ping/<code>/start" API endpoint
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
- Fix after-login redirects (the "?next=" query parameter)
|
- Fix after-login redirects (the "?next=" query parameter)
|
||||||
|
@ -85,16 +85,16 @@ class Command(BaseCommand):
|
|||||||
# In PostgreSQL, add this index to run the below query efficiently:
|
# In PostgreSQL, add this index to run the below query efficiently:
|
||||||
# CREATE INDEX api_check_up ON api_check (alert_after) WHERE status = 'up'
|
# CREATE INDEX api_check_up ON api_check (alert_after) WHERE status = 'up'
|
||||||
|
|
||||||
q = Check.objects.filter(alert_after__lt=now, status="up")
|
q = Check.objects.filter(alert_after__lt=now).exclude(status="down")
|
||||||
# Sort by alert_after, to avoid unnecessary sorting by id:
|
# Sort by alert_after, to avoid unnecessary sorting by id:
|
||||||
check = q.order_by("alert_after").first()
|
check = q.order_by("alert_after").first()
|
||||||
if check is None:
|
if check is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
q = Check.objects.filter(id=check.id, status="up")
|
old_status = check.status
|
||||||
|
q = Check.objects.filter(id=check.id, status=old_status)
|
||||||
|
|
||||||
current_status = check.get_status()
|
if not check.is_down():
|
||||||
if current_status != "down":
|
|
||||||
# It is not down yet. Update alert_after
|
# It is not down yet. Update alert_after
|
||||||
q.update(alert_after=check.get_alert_after())
|
q.update(alert_after=check.get_alert_after())
|
||||||
return True
|
return True
|
||||||
@ -107,7 +107,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
flip = Flip(owner=check)
|
flip = Flip(owner=check)
|
||||||
flip.created = check.get_alert_after()
|
flip.created = check.get_alert_after()
|
||||||
flip.old_status = "up"
|
flip.old_status = old_status
|
||||||
flip.new_status = "down"
|
flip.new_status = "down"
|
||||||
flip.save()
|
flip.save()
|
||||||
|
|
||||||
|
23
hc/api/migrations/0046_auto_20181218_1245.py
Normal file
23
hc/api/migrations/0046_auto_20181218_1245.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 2.1.4 on 2018-12-18 12:45
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('api', '0045_flip'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='check',
|
||||||
|
name='last_start',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ping',
|
||||||
|
name='start',
|
||||||
|
field=models.NullBooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
100
hc/api/models.py
100
hc/api/models.py
@ -25,6 +25,7 @@ STATUSES = (
|
|||||||
)
|
)
|
||||||
DEFAULT_TIMEOUT = td(days=1)
|
DEFAULT_TIMEOUT = td(days=1)
|
||||||
DEFAULT_GRACE = td(hours=1)
|
DEFAULT_GRACE = td(hours=1)
|
||||||
|
NEVER = datetime(3000, 1, 1, tzinfo=pytz.UTC)
|
||||||
CHECK_KINDS = (("simple", "Simple"),
|
CHECK_KINDS = (("simple", "Simple"),
|
||||||
("cron", "Cron"))
|
("cron", "Cron"))
|
||||||
|
|
||||||
@ -55,6 +56,8 @@ PO_PRIORITIES = {
|
|||||||
|
|
||||||
def isostring(dt):
|
def isostring(dt):
|
||||||
"""Convert the datetime to ISO 8601 format with no microseconds. """
|
"""Convert the datetime to ISO 8601 format with no microseconds. """
|
||||||
|
|
||||||
|
if dt:
|
||||||
return dt.replace(microsecond=0).isoformat()
|
return dt.replace(microsecond=0).isoformat()
|
||||||
|
|
||||||
|
|
||||||
@ -73,6 +76,7 @@ class Check(models.Model):
|
|||||||
tz = models.CharField(max_length=36, default="UTC")
|
tz = models.CharField(max_length=36, default="UTC")
|
||||||
n_pings = models.IntegerField(default=0)
|
n_pings = models.IntegerField(default=0)
|
||||||
last_ping = models.DateTimeField(null=True, blank=True)
|
last_ping = models.DateTimeField(null=True, blank=True)
|
||||||
|
last_start = models.DateTimeField(null=True, blank=True)
|
||||||
last_ping_was_fail = models.NullBooleanField(default=False)
|
last_ping_was_fail = models.NullBooleanField(default=False)
|
||||||
has_confirmation_link = models.BooleanField(default=False)
|
has_confirmation_link = models.BooleanField(default=False)
|
||||||
alert_after = models.DateTimeField(null=True, blank=True, editable=False)
|
alert_after = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
@ -110,34 +114,58 @@ class Check(models.Model):
|
|||||||
return errors
|
return errors
|
||||||
|
|
||||||
def get_grace_start(self):
|
def get_grace_start(self):
|
||||||
""" Return the datetime when grace period starts. """
|
""" Return the datetime when the grace period starts.
|
||||||
|
|
||||||
# The common case, grace starts after timeout
|
If the check is currently new, paused or down, return None.
|
||||||
if self.kind == "simple":
|
|
||||||
return self.last_ping + self.timeout
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# NEVER is a constant sentinel value (year 3000).
|
||||||
|
# Using None instead would make the logic clunky.
|
||||||
|
result = NEVER
|
||||||
|
|
||||||
|
if self.kind == "simple" and self.status == "up":
|
||||||
|
result = self.last_ping + self.timeout
|
||||||
|
elif self.kind == "cron" and self.status == "up":
|
||||||
# The complex case, next ping is expected based on cron schedule.
|
# The complex case, next ping is expected based on cron schedule.
|
||||||
# Don't convert to naive datetimes (and so avoid ambiguities around
|
# Don't convert to naive datetimes (and so avoid ambiguities around
|
||||||
# DST transitions).
|
# DST transitions). Croniter will handle the timezone-aware datetimes.
|
||||||
# croniter does handle timezone-aware datetimes.
|
|
||||||
|
|
||||||
zone = pytz.timezone(self.tz)
|
zone = pytz.timezone(self.tz)
|
||||||
last_local = timezone.localtime(self.last_ping, zone)
|
last_local = timezone.localtime(self.last_ping, zone)
|
||||||
it = croniter(self.schedule, last_local)
|
it = croniter(self.schedule, last_local)
|
||||||
return it.next(datetime)
|
result = it.next(datetime)
|
||||||
|
|
||||||
|
if self.last_start:
|
||||||
|
result = min(result, self.last_start)
|
||||||
|
|
||||||
|
if result != NEVER:
|
||||||
|
return result
|
||||||
|
|
||||||
|
def is_down(self):
|
||||||
|
""" Return True if the check is currently in alert state. """
|
||||||
|
|
||||||
|
alert_after = self.get_alert_after()
|
||||||
|
if alert_after is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return timezone.now() >= self.get_alert_after()
|
||||||
|
|
||||||
def get_status(self, now=None):
|
def get_status(self, now=None):
|
||||||
""" Return "up" if the check is up or in grace, otherwise "down". """
|
""" Return current status for display. """
|
||||||
|
|
||||||
if self.status in ("new", "paused"):
|
|
||||||
return self.status
|
|
||||||
|
|
||||||
if self.last_ping_was_fail:
|
|
||||||
return "down"
|
|
||||||
|
|
||||||
if now is None:
|
if now is None:
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
|
||||||
|
if self.last_start:
|
||||||
|
if now >= self.last_start + self.grace:
|
||||||
|
return "down"
|
||||||
|
else:
|
||||||
|
return "started"
|
||||||
|
|
||||||
|
if self.status in ("new", "paused", "down"):
|
||||||
|
return self.status
|
||||||
|
|
||||||
grace_start = self.get_grace_start()
|
grace_start = self.get_grace_start()
|
||||||
grace_end = grace_start + self.grace
|
grace_end = grace_start + self.grace
|
||||||
if now >= grace_end:
|
if now >= grace_end:
|
||||||
@ -151,12 +179,9 @@ class Check(models.Model):
|
|||||||
def get_alert_after(self):
|
def get_alert_after(self):
|
||||||
""" Return the datetime when check potentially goes down. """
|
""" Return the datetime when check potentially goes down. """
|
||||||
|
|
||||||
# For "fail" pings, sendalerts should the check right
|
grace_start = self.get_grace_start()
|
||||||
# after receiving the ping, without waiting for the grace time:
|
if grace_start is not None:
|
||||||
if self.last_ping_was_fail:
|
return grace_start + self.grace
|
||||||
return self.last_ping
|
|
||||||
|
|
||||||
return self.get_grace_start() + self.grace
|
|
||||||
|
|
||||||
def assign_all_channels(self):
|
def assign_all_channels(self):
|
||||||
if self.user:
|
if self.user:
|
||||||
@ -183,7 +208,9 @@ class Check(models.Model):
|
|||||||
"grace": int(self.grace.total_seconds()),
|
"grace": int(self.grace.total_seconds()),
|
||||||
"n_pings": self.n_pings,
|
"n_pings": self.n_pings,
|
||||||
"status": self.get_status(),
|
"status": self.get_status(),
|
||||||
"channels": ",".join(sorted(channel_codes))
|
"channels": ",".join(sorted(channel_codes)),
|
||||||
|
"last_ping": isostring(self.last_ping),
|
||||||
|
"next_ping": isostring(self.get_grace_start())
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.kind == "simple":
|
if self.kind == "simple":
|
||||||
@ -192,23 +219,21 @@ class Check(models.Model):
|
|||||||
result["schedule"] = self.schedule
|
result["schedule"] = self.schedule
|
||||||
result["tz"] = self.tz
|
result["tz"] = self.tz
|
||||||
|
|
||||||
if self.last_ping:
|
|
||||||
result["last_ping"] = isostring(self.last_ping)
|
|
||||||
result["next_ping"] = isostring(self.get_grace_start())
|
|
||||||
else:
|
|
||||||
result["last_ping"] = None
|
|
||||||
result["next_ping"] = None
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def ping(self, remote_addr, scheme, method, ua, body, is_fail=False):
|
def ping(self, remote_addr, scheme, method, ua, body, action):
|
||||||
self.n_pings = models.F("n_pings") + 1
|
if action == "start":
|
||||||
|
# If we receive multiple start events in a row,
|
||||||
|
# we remember the first one, not the last one
|
||||||
|
if self.last_start is None:
|
||||||
|
self.last_start = timezone.now()
|
||||||
|
# DOn't update "last_ping" field.
|
||||||
|
else:
|
||||||
|
self.last_start = None
|
||||||
self.last_ping = timezone.now()
|
self.last_ping = timezone.now()
|
||||||
self.last_ping_was_fail = is_fail
|
self.last_ping_was_fail = action == "fail"
|
||||||
self.has_confirmation_link = "confirm" in str(body).lower()
|
|
||||||
self.alert_after = self.get_alert_after()
|
|
||||||
|
|
||||||
new_status = "down" if is_fail else "up"
|
new_status = "down" if action == "fail" else "up"
|
||||||
if self.status != new_status:
|
if self.status != new_status:
|
||||||
flip = Flip(owner=self)
|
flip = Flip(owner=self)
|
||||||
flip.created = self.last_ping
|
flip.created = self.last_ping
|
||||||
@ -218,12 +243,16 @@ class Check(models.Model):
|
|||||||
|
|
||||||
self.status = new_status
|
self.status = new_status
|
||||||
|
|
||||||
|
self.alert_after = self.get_alert_after()
|
||||||
|
self.n_pings = models.F("n_pings") + 1
|
||||||
|
self.has_confirmation_link = "confirm" in str(body).lower()
|
||||||
self.save()
|
self.save()
|
||||||
self.refresh_from_db()
|
self.refresh_from_db()
|
||||||
|
|
||||||
ping = Ping(owner=self)
|
ping = Ping(owner=self)
|
||||||
ping.n = self.n_pings
|
ping.n = self.n_pings
|
||||||
ping.fail = is_fail
|
ping.start = action == "start"
|
||||||
|
ping.fail = action == "fail"
|
||||||
ping.remote_addr = remote_addr
|
ping.remote_addr = remote_addr
|
||||||
ping.scheme = scheme
|
ping.scheme = scheme
|
||||||
ping.method = method
|
ping.method = method
|
||||||
@ -238,6 +267,7 @@ class Ping(models.Model):
|
|||||||
n = models.IntegerField(null=True)
|
n = models.IntegerField(null=True)
|
||||||
owner = models.ForeignKey(Check, models.CASCADE)
|
owner = models.ForeignKey(Check, models.CASCADE)
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
start = models.NullBooleanField(default=False)
|
||||||
fail = models.NullBooleanField(default=False)
|
fail = models.NullBooleanField(default=False)
|
||||||
scheme = models.CharField(max_length=10, default="http")
|
scheme = models.CharField(max_length=10, default="http")
|
||||||
remote_addr = models.GenericIPAddressField(blank=True, null=True)
|
remote_addr = models.GenericIPAddressField(blank=True, null=True)
|
||||||
|
30
hc/api/tests/test_check_alert_after.py
Normal file
30
hc/api/tests/test_check_alert_after.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from datetime import timedelta as td
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
from hc.api.models import Check
|
||||||
|
|
||||||
|
|
||||||
|
class CheckModelTestCase(TestCase):
|
||||||
|
|
||||||
|
def test_it_handles_new_check(self):
|
||||||
|
check = Check()
|
||||||
|
self.assertEqual(check.get_alert_after(), None)
|
||||||
|
|
||||||
|
def test_it_handles_paused_check(self):
|
||||||
|
check = Check()
|
||||||
|
check.last_ping = timezone.now() - td(days=2)
|
||||||
|
self.assertEqual(check.get_alert_after(), None)
|
||||||
|
|
||||||
|
def test_it_handles_up(self):
|
||||||
|
check = Check(status="up")
|
||||||
|
check.last_ping = timezone.now() - td(hours=1)
|
||||||
|
expected_aa = check.last_ping + td(days=1, hours=1)
|
||||||
|
self.assertEqual(check.get_alert_after(), expected_aa)
|
||||||
|
|
||||||
|
def test_it_handles_paused_then_started_check(self):
|
||||||
|
check = Check(status="paused")
|
||||||
|
check.last_start = timezone.now() - td(days=2)
|
||||||
|
|
||||||
|
expected_aa = check.last_start + td(hours=1)
|
||||||
|
self.assertEqual(check.get_alert_after(), expected_aa)
|
@ -27,7 +27,7 @@ class CheckModelTestCase(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(check.get_status(), "grace")
|
self.assertEqual(check.get_status(), "grace")
|
||||||
|
|
||||||
def test_get_stauts_handles_paused_check(self):
|
def test_get_status_handles_paused_check(self):
|
||||||
check = Check()
|
check = Check()
|
||||||
|
|
||||||
check.status = "up"
|
check.status = "up"
|
||||||
@ -82,6 +82,33 @@ class CheckModelTestCase(TestCase):
|
|||||||
now = dt + timedelta(days=1, minutes=60)
|
now = dt + timedelta(days=1, minutes=60)
|
||||||
self.assertEqual(check.get_status(now), "down")
|
self.assertEqual(check.get_status(now), "down")
|
||||||
|
|
||||||
|
def test_get_status_handles_past_grace(self):
|
||||||
|
check = Check()
|
||||||
|
check.status = "up"
|
||||||
|
check.last_ping = timezone.now() - timedelta(days=2)
|
||||||
|
|
||||||
|
self.assertEqual(check.get_status(), "down")
|
||||||
|
|
||||||
|
def test_get_status_obeys_down_status(self):
|
||||||
|
check = Check()
|
||||||
|
check.status = "down"
|
||||||
|
check.last_ping = timezone.now() - timedelta(minutes=1)
|
||||||
|
|
||||||
|
self.assertEqual(check.get_status(), "down")
|
||||||
|
|
||||||
|
def test_get_status_handles_started(self):
|
||||||
|
check = Check()
|
||||||
|
check.last_ping = timezone.now() - timedelta(hours=2)
|
||||||
|
check.last_start = timezone.now() - timedelta(minutes=5)
|
||||||
|
for status in ("new", "paused", "up", "down"):
|
||||||
|
check.status = status
|
||||||
|
self.assertEqual(check.get_status(), "started")
|
||||||
|
|
||||||
|
def test_get_status_handles_started_and_mia(self):
|
||||||
|
check = Check()
|
||||||
|
check.last_start = timezone.now() - timedelta(hours=2)
|
||||||
|
self.assertEqual(check.get_status(), "down")
|
||||||
|
|
||||||
def test_next_ping_with_cron_syntax(self):
|
def test_next_ping_with_cron_syntax(self):
|
||||||
dt = timezone.make_aware(datetime(2000, 1, 1), timezone=timezone.utc)
|
dt = timezone.make_aware(datetime(2000, 1, 1), timezone=timezone.utc)
|
||||||
|
|
||||||
@ -95,13 +122,3 @@ class CheckModelTestCase(TestCase):
|
|||||||
|
|
||||||
d = check.to_dict()
|
d = check.to_dict()
|
||||||
self.assertEqual(d["next_ping"], "2000-01-01T01:00:00+00:00")
|
self.assertEqual(d["next_ping"], "2000-01-01T01:00:00+00:00")
|
||||||
|
|
||||||
def test_status_checks_the_fail_flag(self):
|
|
||||||
check = Check()
|
|
||||||
check.status = "up"
|
|
||||||
check.last_ping = timezone.now() - timedelta(minutes=5)
|
|
||||||
check.last_ping_was_fail = True
|
|
||||||
|
|
||||||
# The computed status should be "down" because last_ping_was_fail
|
|
||||||
# is set.
|
|
||||||
self.assertEqual(check.get_status(), "down")
|
|
||||||
|
@ -17,8 +17,7 @@ class ListChecksTestCase(BaseTestCase):
|
|||||||
self.a1 = Check(user=self.alice, name="Alice 1")
|
self.a1 = Check(user=self.alice, name="Alice 1")
|
||||||
self.a1.timeout = td(seconds=3600)
|
self.a1.timeout = td(seconds=3600)
|
||||||
self.a1.grace = td(seconds=900)
|
self.a1.grace = td(seconds=900)
|
||||||
self.a1.last_ping = self.now
|
self.a1.n_pings = 0
|
||||||
self.a1.n_pings = 1
|
|
||||||
self.a1.status = "new"
|
self.a1.status = "new"
|
||||||
self.a1.tags = "a1-tag a1-additional-tag"
|
self.a1.tags = "a1-tag a1-additional-tag"
|
||||||
self.a1.save()
|
self.a1.save()
|
||||||
@ -45,19 +44,16 @@ class ListChecksTestCase(BaseTestCase):
|
|||||||
doc = r.json()
|
doc = r.json()
|
||||||
self.assertEqual(len(doc["checks"]), 2)
|
self.assertEqual(len(doc["checks"]), 2)
|
||||||
|
|
||||||
a1 = None
|
by_name = {}
|
||||||
a2 = None
|
|
||||||
for check in doc["checks"]:
|
for check in doc["checks"]:
|
||||||
if check["name"] == "Alice 1":
|
by_name[check["name"]] = check
|
||||||
a1 = check
|
|
||||||
if check["name"] == "Alice 2":
|
|
||||||
a2 = check
|
|
||||||
|
|
||||||
|
a1 = by_name["Alice 1"]
|
||||||
self.assertEqual(a1["timeout"], 3600)
|
self.assertEqual(a1["timeout"], 3600)
|
||||||
self.assertEqual(a1["grace"], 900)
|
self.assertEqual(a1["grace"], 900)
|
||||||
self.assertEqual(a1["ping_url"], self.a1.url())
|
self.assertEqual(a1["ping_url"], self.a1.url())
|
||||||
self.assertEqual(a1["last_ping"], self.now.isoformat())
|
self.assertEqual(a1["last_ping"], None)
|
||||||
self.assertEqual(a1["n_pings"], 1)
|
self.assertEqual(a1["n_pings"], 0)
|
||||||
self.assertEqual(a1["status"], "new")
|
self.assertEqual(a1["status"], "new")
|
||||||
self.assertEqual(a1["channels"], str(self.c1.code))
|
self.assertEqual(a1["channels"], str(self.c1.code))
|
||||||
|
|
||||||
@ -66,13 +62,16 @@ class ListChecksTestCase(BaseTestCase):
|
|||||||
self.assertEqual(a1["update_url"], update_url)
|
self.assertEqual(a1["update_url"], update_url)
|
||||||
self.assertEqual(a1["pause_url"], pause_url)
|
self.assertEqual(a1["pause_url"], pause_url)
|
||||||
|
|
||||||
next_ping = self.now + td(seconds=3600)
|
self.assertEqual(a1["next_ping"], None)
|
||||||
self.assertEqual(a1["next_ping"], next_ping.isoformat())
|
|
||||||
|
|
||||||
|
a2 = by_name["Alice 2"]
|
||||||
self.assertEqual(a2["timeout"], 86400)
|
self.assertEqual(a2["timeout"], 86400)
|
||||||
self.assertEqual(a2["grace"], 3600)
|
self.assertEqual(a2["grace"], 3600)
|
||||||
self.assertEqual(a2["ping_url"], self.a2.url())
|
self.assertEqual(a2["ping_url"], self.a2.url())
|
||||||
self.assertEqual(a2["status"], "up")
|
self.assertEqual(a2["status"], "up")
|
||||||
|
next_ping = self.now + td(seconds=86400)
|
||||||
|
self.assertEqual(a2["last_ping"], self.now.isoformat())
|
||||||
|
self.assertEqual(a2["next_ping"], next_ping.isoformat())
|
||||||
|
|
||||||
def test_it_handles_options(self):
|
def test_it_handles_options(self):
|
||||||
r = self.client.options("/api/v1/checks/")
|
r = self.client.options("/api/v1/checks/")
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
from django.utils.timezone import now
|
||||||
from hc.api.models import Check
|
from hc.api.models import Check
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
@ -55,3 +56,17 @@ class PauseTestCase(BaseTestCase):
|
|||||||
HTTP_X_API_KEY="X" * 32)
|
HTTP_X_API_KEY="X" * 32)
|
||||||
|
|
||||||
self.assertEqual(r.status_code, 404)
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
def test_it_clears_last_start(self):
|
||||||
|
check = Check(user=self.alice, status="up", last_start=now())
|
||||||
|
check.save()
|
||||||
|
|
||||||
|
url = "/api/v1/checks/%s/pause" % check.code
|
||||||
|
r = self.client.post(url, "", content_type="application/json",
|
||||||
|
HTTP_X_API_KEY="X" * 32)
|
||||||
|
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r["Access-Control-Allow-Origin"], "*")
|
||||||
|
|
||||||
|
check.refresh_from_db()
|
||||||
|
self.assertEqual(check.last_start, None)
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
|
from datetime import timedelta as td
|
||||||
|
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
from hc.api.models import Check, Flip, Ping
|
||||||
from hc.api.models import Check, Ping
|
|
||||||
|
|
||||||
|
|
||||||
class PingTestCase(TestCase):
|
class PingTestCase(TestCase):
|
||||||
@ -31,6 +32,16 @@ class PingTestCase(TestCase):
|
|||||||
self.check.refresh_from_db()
|
self.check.refresh_from_db()
|
||||||
self.assertEqual(self.check.status, "up")
|
self.assertEqual(self.check.status, "up")
|
||||||
|
|
||||||
|
def test_it_clears_last_start(self):
|
||||||
|
self.check.last_start = now()
|
||||||
|
self.check.save()
|
||||||
|
|
||||||
|
r = self.client.get("/ping/%s/" % self.check.code)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.check.refresh_from_db()
|
||||||
|
self.assertEqual(self.check.last_start, None)
|
||||||
|
|
||||||
def test_post_works(self):
|
def test_post_works(self):
|
||||||
csrf_client = Client(enforce_csrf_checks=True)
|
csrf_client = Client(enforce_csrf_checks=True)
|
||||||
r = csrf_client.post("/ping/%s/" % self.check.code, "hello world",
|
r = csrf_client.post("/ping/%s/" % self.check.code, "hello world",
|
||||||
@ -132,7 +143,51 @@ class PingTestCase(TestCase):
|
|||||||
|
|
||||||
self.check.refresh_from_db()
|
self.check.refresh_from_db()
|
||||||
self.assertTrue(self.check.last_ping_was_fail)
|
self.assertTrue(self.check.last_ping_was_fail)
|
||||||
self.assertTrue(self.check.alert_after <= now())
|
self.assertEqual(self.check.status, "down")
|
||||||
|
self.assertEqual(self.check.alert_after, None)
|
||||||
|
|
||||||
ping = Ping.objects.latest("id")
|
ping = Ping.objects.get()
|
||||||
self.assertTrue(ping.fail)
|
self.assertTrue(ping.fail)
|
||||||
|
|
||||||
|
flip = Flip.objects.get()
|
||||||
|
self.assertEqual(flip.owner, self.check)
|
||||||
|
self.assertEqual(flip.new_status, "down")
|
||||||
|
|
||||||
|
def test_start_endpoint_works(self):
|
||||||
|
last_ping = now() - td(hours=2)
|
||||||
|
self.check.last_ping = last_ping
|
||||||
|
self.check.save()
|
||||||
|
|
||||||
|
r = self.client.get("/ping/%s/start" % self.check.code)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.check.refresh_from_db()
|
||||||
|
self.assertTrue(self.check.last_start)
|
||||||
|
self.assertEqual(self.check.last_ping, last_ping)
|
||||||
|
|
||||||
|
ping = Ping.objects.get()
|
||||||
|
self.assertTrue(ping.start)
|
||||||
|
|
||||||
|
def test_start_does_not_change_status_of_paused_check(self):
|
||||||
|
self.check.status = "paused"
|
||||||
|
self.check.save()
|
||||||
|
|
||||||
|
r = self.client.get("/ping/%s/start" % self.check.code)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.check.refresh_from_db()
|
||||||
|
self.assertTrue(self.check.last_start)
|
||||||
|
self.assertEqual(self.check.status, "paused")
|
||||||
|
|
||||||
|
def test_start_does_not_overwrite_last_start(self):
|
||||||
|
first_start = now() - td(hours=2)
|
||||||
|
|
||||||
|
self.check.last_start = first_start
|
||||||
|
self.check.save()
|
||||||
|
|
||||||
|
r = self.client.get("/ping/%s/start" % self.check.code)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
self.check.refresh_from_db()
|
||||||
|
# Should still be the original value
|
||||||
|
self.assertEqual(self.check.last_start, first_start)
|
||||||
|
@ -5,8 +5,8 @@ from hc.api import views
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('ping/<uuid:code>/', views.ping, name="hc-ping-slash"),
|
path('ping/<uuid:code>/', views.ping, name="hc-ping-slash"),
|
||||||
path('ping/<uuid:code>', views.ping, name="hc-ping"),
|
path('ping/<uuid:code>', views.ping, name="hc-ping"),
|
||||||
path('ping/<uuid:code>/fail', views.ping, {"is_fail": True},
|
path('ping/<uuid:code>/fail', views.ping, {"action": "fail"}, name="hc-fail"),
|
||||||
name="hc-fail"),
|
path('ping/<uuid:code>/start', views.ping, {"action": "start"}, name="hc-start"),
|
||||||
|
|
||||||
path('api/v1/checks/', views.checks),
|
path('api/v1/checks/', views.checks),
|
||||||
path('api/v1/checks/<uuid:code>', views.update, name="hc-api-update"),
|
path('api/v1/checks/<uuid:code>', views.update, name="hc-api-update"),
|
||||||
|
@ -19,7 +19,7 @@ from hc.lib.badges import check_signature, get_badge_svg
|
|||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@never_cache
|
@never_cache
|
||||||
def ping(request, code, is_fail=False):
|
def ping(request, code, action="success"):
|
||||||
check = get_object_or_404(Check, code=code)
|
check = get_object_or_404(Check, code=code)
|
||||||
|
|
||||||
headers = request.META
|
headers = request.META
|
||||||
@ -30,7 +30,7 @@ def ping(request, code, is_fail=False):
|
|||||||
ua = headers.get("HTTP_USER_AGENT", "")
|
ua = headers.get("HTTP_USER_AGENT", "")
|
||||||
body = request.body.decode()
|
body = request.body.decode()
|
||||||
|
|
||||||
check.ping(remote_addr, scheme, method, ua, body, is_fail)
|
check.ping(remote_addr, scheme, method, ua, body, action)
|
||||||
|
|
||||||
response = HttpResponse("OK")
|
response = HttpResponse("OK")
|
||||||
response["Access-Control-Allow-Origin"] = "*"
|
response["Access-Control-Allow-Origin"] = "*"
|
||||||
@ -188,6 +188,7 @@ def pause(request, code):
|
|||||||
return HttpResponseForbidden()
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
check.status = "paused"
|
check.status = "paused"
|
||||||
|
check.last_start = None
|
||||||
check.save()
|
check.save()
|
||||||
return JsonResponse(check.to_dict())
|
return JsonResponse(check.to_dict())
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
from django.utils.timezone import now
|
||||||
from hc.api.models import Check
|
from hc.api.models import Check
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
@ -9,28 +10,35 @@ class PauseTestCase(BaseTestCase):
|
|||||||
self.check = Check(user=self.alice, status="up")
|
self.check = Check(user=self.alice, status="up")
|
||||||
self.check.save()
|
self.check.save()
|
||||||
|
|
||||||
def test_it_pauses(self):
|
self.url = "/checks/%s/pause/" % self.check.code
|
||||||
url = "/checks/%s/pause/" % self.check.code
|
|
||||||
|
|
||||||
|
def test_it_pauses(self):
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
r = self.client.post(url)
|
r = self.client.post(self.url)
|
||||||
self.assertRedirects(r, "/checks/")
|
self.assertRedirects(r, "/checks/")
|
||||||
|
|
||||||
self.check.refresh_from_db()
|
self.check.refresh_from_db()
|
||||||
self.assertEqual(self.check.status, "paused")
|
self.assertEqual(self.check.status, "paused")
|
||||||
|
|
||||||
def test_it_rejects_get(self):
|
def test_it_rejects_get(self):
|
||||||
url = "/checks/%s/pause/" % self.check.code
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
r = self.client.get(url)
|
r = self.client.get(self.url)
|
||||||
self.assertEqual(r.status_code, 405)
|
self.assertEqual(r.status_code, 405)
|
||||||
|
|
||||||
def test_it_allows_cross_team_access(self):
|
def test_it_allows_cross_team_access(self):
|
||||||
self.bobs_profile.current_team = None
|
self.bobs_profile.current_team = None
|
||||||
self.bobs_profile.save()
|
self.bobs_profile.save()
|
||||||
|
|
||||||
url = "/checks/%s/pause/" % self.check.code
|
|
||||||
|
|
||||||
self.client.login(username="bob@example.org", password="password")
|
self.client.login(username="bob@example.org", password="password")
|
||||||
r = self.client.post(url)
|
r = self.client.post(self.url)
|
||||||
self.assertRedirects(r, "/checks/")
|
self.assertRedirects(r, "/checks/")
|
||||||
|
|
||||||
|
def test_it_clears_last_start(self):
|
||||||
|
self.check.last_start = now()
|
||||||
|
self.check.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
self.client.post(self.url)
|
||||||
|
|
||||||
|
self.check.refresh_from_db()
|
||||||
|
self.assertEqual(self.check.last_start, None)
|
||||||
|
@ -14,6 +14,26 @@ class LastPingTestCase(BaseTestCase):
|
|||||||
r = self.client.get("/checks/%s/last_ping/" % check.code)
|
r = self.client.get("/checks/%s/last_ping/" % check.code)
|
||||||
self.assertContains(r, "this is body", status_code=200)
|
self.assertContains(r, "this is body", status_code=200)
|
||||||
|
|
||||||
|
def test_it_shows_fail(self):
|
||||||
|
check = Check(user=self.alice)
|
||||||
|
check.save()
|
||||||
|
|
||||||
|
Ping.objects.create(owner=check, fail=True)
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.get("/checks/%s/last_ping/" % check.code)
|
||||||
|
self.assertContains(r, "/fail", status_code=200)
|
||||||
|
|
||||||
|
def test_it_shows_start(self):
|
||||||
|
check = Check(user=self.alice)
|
||||||
|
check.save()
|
||||||
|
|
||||||
|
Ping.objects.create(owner=check, start=True)
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.get("/checks/%s/last_ping/" % check.code)
|
||||||
|
self.assertContains(r, "/start", status_code=200)
|
||||||
|
|
||||||
def test_it_requires_user(self):
|
def test_it_requires_user(self):
|
||||||
check = Check.objects.create()
|
check = Check.objects.create()
|
||||||
r = self.client.get("/checks/%s/last_ping/" % check.code)
|
r = self.client.get("/checks/%s/last_ping/" % check.code)
|
||||||
@ -24,8 +44,8 @@ class LastPingTestCase(BaseTestCase):
|
|||||||
check.save()
|
check.save()
|
||||||
|
|
||||||
# remote_addr, scheme, method, ua, body:
|
# remote_addr, scheme, method, ua, body:
|
||||||
check.ping("1.2.3.4", "http", "post", "tester", "foo-123")
|
check.ping("1.2.3.4", "http", "post", "tester", "foo-123", "success")
|
||||||
check.ping("1.2.3.4", "http", "post", "tester", "bar-456")
|
check.ping("1.2.3.4", "http", "post", "tester", "bar-456", "success")
|
||||||
|
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
@ -301,11 +301,10 @@ def update_timeout(request, code):
|
|||||||
check.alert_after = check.get_alert_after()
|
check.alert_after = check.get_alert_after()
|
||||||
|
|
||||||
# Changing timeout can change check's status:
|
# Changing timeout can change check's status:
|
||||||
is_up = check.get_status() in ("up", "grace")
|
if not check.is_down() and check.status == "down":
|
||||||
if is_up and check.status != "up":
|
|
||||||
flip = Flip(owner=check)
|
flip = Flip(owner=check)
|
||||||
flip.created = timezone.now()
|
flip.created = timezone.now()
|
||||||
flip.old_status = check.status
|
flip.old_status = "down"
|
||||||
flip.new_status = "up"
|
flip.new_status = "up"
|
||||||
flip.save()
|
flip.save()
|
||||||
|
|
||||||
@ -365,6 +364,7 @@ def pause(request, code):
|
|||||||
check = _get_check_for_user(request, code)
|
check = _get_check_for_user(request, code)
|
||||||
|
|
||||||
check.status = "paused"
|
check.status = "paused"
|
||||||
|
check.last_start = None
|
||||||
check.save()
|
check.save()
|
||||||
|
|
||||||
if "/details/" in request.META.get("HTTP_REFERER", ""):
|
if "/details/" in request.META.get("HTTP_REFERER", ""):
|
||||||
|
@ -63,11 +63,17 @@ body {
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.icon-up { color: #5cb85c; }
|
.status.icon-up, .status.icon-started { color: #5cb85c; }
|
||||||
.status.icon-new, .status.icon-paused { color: #CCC; }
|
.status.icon-new, .status.icon-paused { color: #CCC; }
|
||||||
.status.icon-grace { color: #f0ad4e; }
|
.status.icon-grace { color: #f0ad4e; }
|
||||||
.status.icon-down { color: #d9534f; }
|
.status.icon-down { color: #d9534f; }
|
||||||
|
|
||||||
|
.label-start {
|
||||||
|
background-color: #FFF;
|
||||||
|
color: #117a3f;;
|
||||||
|
border: 1px solid #117a3f;
|
||||||
|
}
|
||||||
|
|
||||||
.hc-dialog {
|
.hc-dialog {
|
||||||
background: #FFF;
|
background: #FFF;
|
||||||
padding: 2em;
|
padding: 2em;
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'icomoon';
|
font-family: 'icomoon';
|
||||||
src: url('../fonts/icomoon.eot?b4dy0b');
|
src: url('../fonts/icomoon.eot?swifyd');
|
||||||
src: url('../fonts/icomoon.eot?b4dy0b#iefix') format('embedded-opentype'),
|
src: url('../fonts/icomoon.eot?swifyd#iefix') format('embedded-opentype'),
|
||||||
url('../fonts/icomoon.ttf?b4dy0b') format('truetype'),
|
url('../fonts/icomoon.ttf?swifyd') format('truetype'),
|
||||||
url('../fonts/icomoon.woff?b4dy0b') format('woff'),
|
url('../fonts/icomoon.woff?swifyd') format('woff'),
|
||||||
url('../fonts/icomoon.svg?b4dy0b#icomoon') format('svg');
|
url('../fonts/icomoon.svg?swifyd#icomoon') format('svg');
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
@ -24,6 +24,9 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-started:before {
|
||||||
|
content: "\e038";
|
||||||
|
}
|
||||||
.icon-pagertree:before {
|
.icon-pagertree:before {
|
||||||
content: "\e90d";
|
content: "\e90d";
|
||||||
color: #33ade2;
|
color: #33ade2;
|
||||||
@ -80,6 +83,10 @@
|
|||||||
content: "\e911";
|
content: "\e911";
|
||||||
color: #0079bf;
|
color: #0079bf;
|
||||||
}
|
}
|
||||||
|
.icon-whatsapp:before {
|
||||||
|
content: "\e902";
|
||||||
|
color: #25d366;
|
||||||
|
}
|
||||||
.icon-zendesk:before {
|
.icon-zendesk:before {
|
||||||
content: "\e907";
|
content: "\e907";
|
||||||
}
|
}
|
||||||
@ -92,9 +99,6 @@
|
|||||||
.icon-up:before, .icon-new:before, .icon-ok:before {
|
.icon-up:before, .icon-new:before, .icon-ok:before {
|
||||||
content: "\e86c";
|
content: "\e86c";
|
||||||
}
|
}
|
||||||
.icon-close:before {
|
|
||||||
content: "\e5cd";
|
|
||||||
}
|
|
||||||
.icon-grace:before {
|
.icon-grace:before {
|
||||||
content: "\e000";
|
content: "\e000";
|
||||||
}
|
}
|
||||||
@ -107,9 +111,6 @@
|
|||||||
.icon-asc:before {
|
.icon-asc:before {
|
||||||
content: "\e316";
|
content: "\e316";
|
||||||
}
|
}
|
||||||
.icon-mail:before {
|
|
||||||
content: "\e0e1";
|
|
||||||
}
|
|
||||||
.icon-dots:before {
|
.icon-dots:before {
|
||||||
content: "\e5d3";
|
content: "\e5d3";
|
||||||
}
|
}
|
||||||
@ -120,8 +121,8 @@
|
|||||||
content: "\e7f6";
|
content: "\e7f6";
|
||||||
}
|
}
|
||||||
.icon-settings:before {
|
.icon-settings:before {
|
||||||
content: "\e902";
|
content: "\e912";
|
||||||
}
|
}
|
||||||
.icon-delete:before {
|
.icon-delete:before {
|
||||||
content: "\e900";
|
content: "\e913";
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
Binary file not shown.
Binary file not shown.
@ -21,6 +21,8 @@
|
|||||||
<td style="background: #f0ad4e; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; border-radius: 3px;">LATE</td>
|
<td style="background: #f0ad4e; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; border-radius: 3px;">LATE</td>
|
||||||
{% elif check.get_status == "up" %}
|
{% elif check.get_status == "up" %}
|
||||||
<td style="background: #5cb85c; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; border-radius: 3px;">UP</td>
|
<td style="background: #5cb85c; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; border-radius: 3px;">UP</td>
|
||||||
|
{% elif check.get_status == "started" %}
|
||||||
|
<td style="background: #5cb85c; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; border-radius: 3px;">STARTED</td>
|
||||||
{% elif check.get_status == "down" %}
|
{% elif check.get_status == "down" %}
|
||||||
<td style="background: #d9534f; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; border-radius: 3px;">DOWN</td>
|
<td style="background: #d9534f; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; border-radius: 3px;">DOWN</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -12,6 +12,8 @@
|
|||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
{% if event.fail %}
|
{% if event.fail %}
|
||||||
<span class="label label-danger">Failure</span>
|
<span class="label label-danger">Failure</span>
|
||||||
|
{% elif event.start %}
|
||||||
|
<span class="label label-start">Started</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="label label-success">OK</span>
|
<span class="label label-success">OK</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -46,6 +46,8 @@
|
|||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
{% if event.fail %}
|
{% if event.fail %}
|
||||||
<span class="label label-danger">Failure</span>
|
<span class="label label-danger">Failure</span>
|
||||||
|
{% elif event.start %}
|
||||||
|
<span class="label label-start">Started</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="label label-success">OK</span>
|
<span class="label label-success">OK</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -10,5 +10,7 @@
|
|||||||
This check is paused.
|
This check is paused.
|
||||||
{% elif status == "new" %}
|
{% elif status == "new" %}
|
||||||
This check has never received a ping.
|
This check has never received a ping.
|
||||||
|
{% elif status == "started" %}
|
||||||
|
This check is currently running. Started {{ check.last_start|naturaltime }}.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
@ -2,6 +2,8 @@
|
|||||||
<h3>Ping #{{ ping.n }}
|
<h3>Ping #{{ ping.n }}
|
||||||
{% if ping.fail %}
|
{% if ping.fail %}
|
||||||
<span class="text-danger">(received via the <code>/fail</code> endpoint)</span>
|
<span class="text-danger">(received via the <code>/fail</code> endpoint)</span>
|
||||||
|
{% elif ping.start %}
|
||||||
|
<span class="text-success">(received via the <code>/start</code> endpoint)</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user