Browse Source

Merge branch 'master' into docker

pull/230/head
Timothée Oliger 5 years ago
parent
commit
31e5395d3a
No known key found for this signature in database GPG Key ID: 19E0C7042397EC61
104 changed files with 2141 additions and 1235 deletions
  1. +1
    -1
      .travis.yml
  2. +34
    -3
      CHANGELOG.md
  3. +1
    -1
      LICENSE
  4. +87
    -18
      README.md
  5. +7
    -1
      hc/accounts/admin.py
  6. +2
    -0
      hc/accounts/models.py
  7. +9
    -4
      hc/accounts/tests/test_close_account.py
  8. +5
    -0
      hc/accounts/tests/test_login.py
  9. +8
    -2
      hc/accounts/views.py
  10. +3
    -0
      hc/api/decorators.py
  11. +16
    -0
      hc/api/management/commands/pruneflips.py
  12. +2
    -2
      hc/api/management/commands/prunepings.py
  13. +2
    -2
      hc/api/management/commands/prunepingsslow.py
  14. +37
    -0
      hc/api/migrations/0062_auto_20190720_1350.py
  15. +23
    -0
      hc/api/migrations/0063_auto_20190903_0901.py
  16. +119
    -14
      hc/api/models.py
  17. +64
    -1
      hc/api/tests/test_check_model.py
  18. +0
    -7
      hc/api/tests/test_list_channels.py
  19. +3
    -0
      hc/api/tests/test_list_checks.py
  20. +103
    -7
      hc/api/tests/test_notify.py
  21. +10
    -0
      hc/api/tests/test_ping.py
  22. +24
    -0
      hc/api/tests/test_prunepings.py
  23. +24
    -0
      hc/api/tests/test_prunepingsslow.py
  24. +57
    -2
      hc/api/transports.py
  25. +2
    -2
      hc/api/views.py
  26. +7
    -0
      hc/front/forms.py
  27. +1
    -0
      hc/front/management/commands/pygmentize.py
  28. +6
    -1
      hc/front/templatetags/hc_extras.py
  29. +29
    -0
      hc/front/tests/test_add_apprise.py
  30. +8
    -2
      hc/front/tests/test_add_check.py
  31. +23
    -0
      hc/front/tests/test_add_mattermost.py
  32. +70
    -0
      hc/front/tests/test_add_whatsapp.py
  33. +5
    -0
      hc/front/tests/test_details.py
  34. +1
    -1
      hc/front/tests/test_log.py
  35. +4
    -1
      hc/front/urls.py
  36. +95
    -9
      hc/front/views.py
  37. +36
    -0
      hc/lib/date.py
  38. +0
    -98
      hc/payments/invoices.py
  39. +14
    -43
      hc/payments/models.py
  40. +4
    -3
      hc/payments/tests/test_billing_history.py
  41. +0
    -74
      hc/payments/tests/test_charge_webhook.py
  42. +5
    -1
      hc/payments/tests/test_get_client_token.py
  43. +2
    -65
      hc/payments/tests/test_payment_method.py
  44. +0
    -65
      hc/payments/tests/test_pdf_invoice.py
  45. +9
    -0
      hc/payments/tests/test_pricing.py
  46. +60
    -15
      hc/payments/tests/test_update_subscription.py
  47. +2
    -8
      hc/payments/urls.py
  48. +35
    -77
      hc/payments/views.py
  49. +8
    -3
      hc/settings.py
  50. +3
    -3
      requirements.txt
  51. +29
    -0
      static/css/base.css
  52. +9
    -1
      static/css/billing.css
  53. +23
    -0
      static/css/details.css
  54. +13
    -8
      static/css/icomoon.css
  55. +5
    -1
      static/css/my_checks_desktop.css
  56. BIN
      static/fonts/icomoon.eot
  57. +2
    -1
      static/fonts/icomoon.svg
  58. BIN
      static/fonts/icomoon.ttf
  59. BIN
      static/fonts/icomoon.woff
  60. BIN
      static/img/integrations/apprise.png
  61. BIN
      static/img/integrations/mattermost.png
  62. BIN
      static/img/integrations/setup_mattermost_1.png
  63. BIN
      static/img/integrations/setup_mattermost_2.png
  64. BIN
      static/img/integrations/setup_mattermost_3.png
  65. BIN
      static/img/integrations/whatsapp.png
  66. +2
    -1
      static/js/add_trello.js
  67. +79
    -30
      static/js/billing.js
  68. +7
    -6
      static/js/checks.js
  69. +0
    -204
      static/js/collapse-native.js
  70. +12
    -4
      static/js/details.js
  71. +4
    -4
      static/js/log.js
  72. +1
    -0
      static/js/moment-timezone-with-data-10-year-range.min.js
  73. +1
    -7
      static/js/moment.min.js
  74. +4
    -1
      static/js/signup.js
  75. +0
    -117
      static/js/tab-native.js
  76. +6
    -29
      static/js/update-timeout-modal.js
  77. +130
    -127
      templates/accounts/billing.html
  78. +5
    -0
      templates/accounts/login.html
  79. +12
    -2
      templates/base.html
  80. +13
    -11
      templates/emails/report-body-html.html
  81. +82
    -0
      templates/emails/summary-downtimes-html.html
  82. +121
    -58
      templates/front/channels.html
  83. +41
    -15
      templates/front/details.html
  84. +16
    -0
      templates/front/details_downtimes.html
  85. +4
    -3
      templates/front/details_events.html
  86. +24
    -3
      templates/front/docs_api.html
  87. +1
    -0
      templates/front/docs_resources.html
  88. +6
    -1
      templates/front/last_ping_cell.html
  89. +11
    -3
      templates/front/log.html
  90. +7
    -3
      templates/front/my_checks_desktop.html
  91. +29
    -0
      templates/front/snippets/list_checks_response_readonly.html
  92. +28
    -0
      templates/front/snippets/list_checks_response_readonly.txt
  93. +62
    -37
      templates/front/welcome.html
  94. +49
    -0
      templates/integrations/add_apprise.html
  95. +97
    -0
      templates/integrations/add_mattermost.html
  96. +1
    -1
      templates/integrations/add_pushover.html
  97. +4
    -0
      templates/integrations/add_webhook.html
  98. +109
    -0
      templates/integrations/add_whatsapp.html
  99. +5
    -0
      templates/integrations/apprise_description.html
  100. +1
    -0
      templates/integrations/apprise_title.html

+ 1
- 1
.travis.yml View File

@ -6,7 +6,7 @@ python:
- "3.7"
install:
- pip install -r requirements.txt
- pip install braintree coveralls mock mysqlclient reportlab
- pip install braintree coveralls mock mysqlclient reportlab apprise
env:
- DB=sqlite
- DB=mysql


+ 34
- 3
CHANGELOG.md View File

@ -3,17 +3,48 @@ All notable changes to this project will be documented in this file.
## Unreleased
### Improvements
- Add the "Last Duration" field in the "My Checks" page (#257)
- Add "last_duration" attribute to the Check API resource (#257)
- Upgrade to psycopg2 2.8.3
### Bug Fixes
- Usernames now are uuid3(const, email). Prevents multiple accts with same email (#290)
- Prevent double-clicking the submit button in signup form
- Upgrade to Django 2.2.6 – fixes sqlite migrations (#284)
## 1.9.0 - 2019-09-03
### Improvements
- Show the number of downtimes and total downtime minutes in monthly reports (#104)
- Show the number of downtimes and total downtime minutes in "Check Details" page
- Add the `pruneflips` management command
- Add Mattermost integration (#276)
- Three choices in timezone switcher (UTC / check's timezone / browser's timezone) (#278)
- After adding a new check redirect to the "Check Details" page
### Bug Fixes
- Fix javascript code to construct correct URLs when running from a subdirectory (#273)
- Don't show the "Sign Up" link in the login page if registration is closed (#280)
## 1.8.0 - 2019-07-08
### Improvements
- Add the `prunetokenbucket` management command
- Show check counts in JSON "badges" (#251)
- Webhooks support HTTP PUT (#249)
- Webhooks can use different req. bodies and headers for "up" and "down" events. (#249)
- Show check's code instead of full URL on 992px - 1200px wide screens. (#253)
- Webhooks can use different req. bodies and headers for "up" and "down" events (#249)
- Show check's code instead of full URL on 992px - 1200px wide screens (#253)
- Add WhatsApp integration (uses Twilio same as the SMS integration)
- Webhooks support the $TAGS placeholder
- Don't include ping URLs in API responses when the read-only key is used
### Bug Fixes
- Fix badges for tags containing special characters (#240, #237)
- Fix the "Integrations" page for when the user has no active project
- Prevent email clients from opening the one-time login links (#255)
- Fix `prunepings` and `prunepingsslow`, they got broken when adding Projects (#264)
## 1.7.0 - 2019-05-02
@ -49,7 +80,7 @@ All notable changes to this project will be documented in this file.
### Improvements
- Database schema: add uniqueness constraint to Check.code
- Database schema: add Ping.kind field. Remove "start" and "fail" fields.
- Database schema: add Ping.kind field. Remove "start" and "fail" fields
- Add "Email Settings..." dialog and "Subject Must Contain" setting
- Database schema: add the Project model
- Move project-specific settings to a new "Project Settings" page


+ 1
- 1
LICENSE View File

@ -1,4 +1,4 @@
Copyright (c) 2015, Pēteris Caune
Copyright (c) 2015, Pēteris Caune and other contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:


+ 87
- 18
README.md View File

@ -1,4 +1,4 @@
# healthchecks
# Healthchecks
[![Build Status](https://travis-ci.org/healthchecks/healthchecks.svg?branch=master)](https://travis-ci.org/healthchecks/healthchecks)
[![Coverage Status](https://coveralls.io/repos/healthchecks/healthchecks/badge.svg?branch=master&service=github)](https://coveralls.io/github/healthchecks/healthchecks?branch=master)
@ -86,19 +86,19 @@ Configurations settings loaded from environment variables:
| Environment variable | Default value | Notes
| -------------------- | ------------- | ----- |
| [SECRET_KEY](https://docs.djangoproject.com/en/2.1/ref/settings/#secret-key) | `"---"`
| [DEBUG](https://docs.djangoproject.com/en/2.1/ref/settings/#debug) | `True` | Set to `False` for production
| [ALLOWED_HOSTS](https://docs.djangoproject.com/en/2.1/ref/settings/#allowed-hosts) | `*` | Separate multiple hosts with commas
| [DEFAULT_FROM_EMAIL](https://docs.djangoproject.com/en/2.1/ref/settings/#default-from-email) | `"[email protected]"`
| [SECRET_KEY](https://docs.djangoproject.com/en/2.2/ref/settings/#secret-key) | `"---"`
| [DEBUG](https://docs.djangoproject.com/en/2.2/ref/settings/#debug) | `True` | Set to `False` for production
| [ALLOWED_HOSTS](https://docs.djangoproject.com/en/2.2/ref/settings/#allowed-hosts) | `*` | Separate multiple hosts with commas
| [DEFAULT_FROM_EMAIL](https://docs.djangoproject.com/en/2.2/ref/settings/#default-from-email) | `"[email protected]"`
| USE_PAYMENTS | `False`
| REGISTRATION_OPEN | `True`
| DB | `"sqlite"` | Set to `"postgres"` or `"mysql"`
| [DB_HOST](https://docs.djangoproject.com/en/2.1/ref/settings/#host) | `""` *(empty string)*
| [DB_PORT](https://docs.djangoproject.com/en/2.1/ref/settings/#port) | `""` *(empty string)*
| [DB_NAME](https://docs.djangoproject.com/en/2.1/ref/settings/#name) | `"hc"` (PostgreSQL, MySQL) or `"/path/to/project/hc.sqlite"` (SQLite) | For SQLite, specify the full path to the database file.
| [DB_USER](https://docs.djangoproject.com/en/2.1/ref/settings/#user) | `"postgres"` or `"root"`
| [DB_PASSWORD](https://docs.djangoproject.com/en/2.1/ref/settings/#password) | `""` *(empty string)*
| [DB_CONN_MAX_AGE](https://docs.djangoproject.com/en/2.1/ref/settings/#conn-max-age) | `0`
| [DB_HOST](https://docs.djangoproject.com/en/2.2/ref/settings/#host) | `""` *(empty string)*
| [DB_PORT](https://docs.djangoproject.com/en/2.2/ref/settings/#port) | `""` *(empty string)*
| [DB_NAME](https://docs.djangoproject.com/en/2.2/ref/settings/#name) | `"hc"` (PostgreSQL, MySQL) or `"/path/to/project/hc.sqlite"` (SQLite) | For SQLite, specify the full path to the database file.
| [DB_USER](https://docs.djangoproject.com/en/2.2/ref/settings/#user) | `"postgres"` or `"root"`
| [DB_PASSWORD](https://docs.djangoproject.com/en/2.2/ref/settings/#password) | `""` *(empty string)*
| [DB_CONN_MAX_AGE](https://docs.djangoproject.com/en/2.2/ref/settings/#conn-max-age) | `0`
| DB_SSLMODE | `"prefer"` | PostgreSQL-specific, [details](https://blog.github.com/2018-10-21-october21-incident-report/)
| DB_TARGET_SESSION_ATTRS | `"read-write"` | PostgreSQL-specific, [details](https://www.postgresql.org/docs/10/static/libpq-connect.html#LIBPQ-CONNECT-TARGET-SESSION-ATTRS)
| EMAIL_HOST | `""` *(empty string)*
@ -127,11 +127,13 @@ Configurations settings loaded from environment variables:
| TWILIO_ACCOUNT | `None`
| TWILIO_AUTH | `None`
| TWILIO_FROM | `None`
| TWILIO_USE_WHATSAPP | `"False"`
| PD_VENDOR_KEY | `None`
| TRELLO_APP_KEY | `None`
| MATRIX_HOMESERVER | `None`
| MATRIX_USER_ID | `None`
| MATRIX_ACCESS_TOKEN | `None`
| APPRISE_ENABLED | `"False"`
Some useful settings keys to override are:
@ -268,7 +270,7 @@ There are separate Django management commands for each task:
$ ./manage.py pruneusers
```
* Remove old records fromt he `api_tokenbucket` table. The TokenBucket
* Remove old records from the `api_tokenbucket` table. The TokenBucket
model is used for rate-limiting login attempts and similar operations.
Any records older than one day can be safely removed.
@ -276,6 +278,15 @@ There are separate Django management commands for each task:
$ ./manage.py prunetokenbucket
```
* Remove old records from the `api_flip` table. The Flip
objects are used to track status changes of checks, and to calculate
downtime statistics month by month. Flip objects from more than 3 months
ago are not used and can be safely removed.
```
$ ./manage.py pruneflips
```
When you first try these commands on your data, it is a good idea to
test them on a copy of your database, not on the live database right away.
In a production setup, you should also have regular, automated database
@ -326,14 +337,27 @@ To enable Discord integration, you will need to:
### Pushover
To enable Pushover integration, you will need to:
Pushover integration works by creating an application on Pushover.net which
is then subscribed to by Healthchecks users. The registration workflow is as follows:
* On Healthchecks, the user adds a "Pushover" integration to a project
* Healthchecks redirects user's browser to a Pushover.net subscription page
* User approves adding the Healthchecks subscription to their Pushover account
* Pushover.net HTTP redirects back to Healthchecks with a subscription token
* Healthchecks saves the subscription token and uses it for sending Pushover
notifications
* register a new application on https://pushover.net/apps/build
* enable subscriptions in your application and make sure to enable the URL
subscription type
* put the application token and the subscription URL in
To enable the Pushover integration, you will need to:
* Register a new application on Pushover via https://pushover.net/apps/build.
* Within the Pushover 'application' configuration, enable subscriptions.
Make sure the subscription type is set to "URL". Also make sure the redirect
URL is configured to point back to the root of the Healthchecks instance
(e.g., `http://healthchecks.example.com/`).
* Put the Pushover application API Token and the Pushover subscription URL in
`PUSHOVER_API_TOKEN` and `PUSHOVER_SUBSCRIPTION_URL` environment
variables
variables. The Pushover subscription URL should look similar to
`https://pushover.net/subscribe/yourAppName-randomAlphaNumericData`.
### Telegram
@ -353,3 +377,48 @@ where to forward channel messages by invoking Telegram's
For this to work, your `SITE_ROOT` needs to be correct and use "https://"
scheme.
### Apprise
To enable Apprise integration, you will need to:
* ensure you have apprise installed in your local environment:
```bash
pip install apprise
```
* enable the apprise functionality by setting the `APPRISE_ENABLED` environment variable.
## Running in Production
Here is a non-exhaustive list of pointers and things to check before launching a Healthchecks instance
in production.
* Environment variables, settings.py and local_settings.py.
* [DEBUG](https://docs.djangoproject.com/en/2.2/ref/settings/#debug). Make sure it is set to `False`.
* [ALLOWED_HOSTS](https://docs.djangoproject.com/en/2.2/ref/settings/#allowed-hosts). Make sure it
contains the correct domain name you want to use.
* Server Errors. When DEBUG=False, Django will not show detailed error pages, and will not print exception
tracebacks to standard output. To receive exception tracebacks in email,
review and edit the [ADMINS](https://docs.djangoproject.com/en/2.2/ref/settings/#admins) and
[SERVER_EMAIL](https://docs.djangoproject.com/en/2.2/ref/settings/#server-email) settings.
Another good option for receiving exception tracebacks is to use [Sentry](https://sentry.io/for/django/).
* Management commands that need to be run during each deployment.
* This project uses [Django Compressor](https://django-compressor.readthedocs.io/en/stable/)
to combine the CSS and JS files. It is configured for offline compression – run the
`manage.py compress` command whenever files in the `/static/` directory change.
* This project uses Django's [staticfiles app](https://docs.djangoproject.com/en/2.2/ref/contrib/staticfiles/).
Run the `manage.py collectstatic` command whenever files in the `/static/`
directory change. This command collects all the static files inside the `static-collected` directory.
Configure your web server to serve files from this directory under the `/static/` prefix.
* Processes that need to be running constantly.
* `manage.py runserver` is intended for development only. Do not use it in production,
instead consider using [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) or
[gunicorn](https://gunicorn.org/).
* Make sure the `manage.py sendalerts` command is running and can survive server restarts.
On modern linux systems, a good option is to
[define a systemd service](https://github.com/healthchecks/healthchecks/issues/273#issuecomment-520560304) for it.
* General
* Make sure the database is secured well and is getting backed up regularly
* Make sure the TLS certificates are secured well and are getting refreshed regularly
* Have monitoring in place to be sure the Healthchecks instance itself is operational
(is accepting pings, is sending out alerts, is not running out of resources).

+ 7
- 1
hc/accounts/admin.py View File

@ -189,7 +189,7 @@ class ProjectAdmin(admin.ModelAdmin):
class HcUserAdmin(UserAdmin):
actions = ["send_report"]
actions = ["send_report", "send_nag"]
list_display = (
"id",
"email",
@ -237,6 +237,12 @@ class HcUserAdmin(UserAdmin):
self.message_user(request, "%d email(s) sent" % qs.count())
def send_nag(self, request, qs):
for user in qs:
user.profile.send_report(nag=True)
self.message_user(request, "%d email(s) sent" % qs.count())
admin.site.unregister(User)
admin.site.register(User, HcUserAdmin)

+ 2
- 0
hc/accounts/models.py View File

@ -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)


+ 9
- 4
hc/accounts/tests/test_close_account.py View File

@ -6,10 +6,12 @@ from mock import patch
class CloseAccountTestCase(BaseTestCase):
@patch("hc.payments.models.Subscription.cancel")
def test_it_works(self, mock_cancel):
@patch("hc.payments.models.braintree")
def test_it_works(self, mock_braintree):
Check.objects.create(project=self.project, tags="foo a-B_1 baz@")
Subscription.objects.create(user=self.alice, subscription_id="123")
Subscription.objects.create(
user=self.alice, subscription_id="123", customer_id="fake-customer-id"
)
self.client.login(username="[email protected]", password="password")
r = self.client.post("/accounts/close/")
@ -27,7 +29,10 @@ class CloseAccountTestCase(BaseTestCase):
self.assertFalse(Check.objects.exists())
# Subscription should have been canceled
self.assertTrue(mock_cancel.called)
self.assertTrue(mock_braintree.Subscription.cancel.called)
# Braintree customer should have been deleted
self.assertTrue(mock_braintree.Customer.delete.called)
# Subscription should be gone
self.assertFalse(Subscription.objects.exists())


+ 5
- 0
hc/accounts/tests/test_login.py View File

@ -105,3 +105,8 @@ class LoginTestCase(BaseTestCase):
r = self.client.post("/accounts/login/", form)
self.assertContains(r, "Incorrect email or password")
@override_settings(REGISTRATION_OPEN=False)
def test_it_obeys_registration_open(self):
r = self.client.get("/accounts/login/")
self.assertNotContains(r, "Create Your Account")

+ 8
- 2
hc/accounts/views.py View File

@ -43,6 +43,8 @@ NEXT_WHITELIST = (
"hc-add-pushover",
)
NAMESPACE_HC = uuid.UUID("2b25afdf-ce1a-4fa3-adf2-592e35f27fa9")
def _is_whitelisted(path):
try:
@ -54,7 +56,10 @@ def _is_whitelisted(path):
def _make_user(email, with_project=True):
username = str(uuid.uuid4())[:30]
# Generate username from email in a deterministic way.
# Since the database has an uniqueness constraint on username,
# this makes sure that emails also are unique.
username = str(uuid.uuid3(NAMESPACE_HC, email))
user = User(username=username, email=email)
user.set_unusable_password()
user.save()
@ -132,6 +137,7 @@ def login(request):
"form": form,
"magic_form": magic_form,
"bad_link": bad_link,
"registration_open": settings.REGISTRATION_OPEN,
}
return render(request, "accounts/login.html", ctx)
@ -457,7 +463,7 @@ def close(request):
# Subscription needs to be canceled before it is deleted:
sub = Subscription.objects.filter(user=user).first()
if sub:
sub.cancel()
sub.cancel(delete_customer=True)
user.delete()


+ 3
- 0
hc/api/decorators.py View File

@ -27,6 +27,7 @@ def authorize(f):
except Project.DoesNotExist:
return error("wrong api key", 401)
request.readonly = False
return f(request, *args, **kwds)
return wrapper
@ -50,6 +51,7 @@ def authorize_read(f):
except Project.DoesNotExist:
return error("wrong api key", 401)
request.readonly = api_key == request.project.api_key_readonly
return f(request, *args, **kwds)
return wrapper
@ -107,6 +109,7 @@ def cors(*methods):
response["Access-Control-Allow-Origin"] = "*"
response["Access-Control-Allow-Headers"] = "X-Api-Key"
response["Access-Control-Allow-Methods"] = methods_str
response["Access-Control-Max-Age"] = "600"
return response
return wrapper


+ 16
- 0
hc/api/management/commands/pruneflips.py View File

@ -0,0 +1,16 @@
from django.core.management.base import BaseCommand
from hc.api.models import Flip
from hc.lib.date import month_boundaries
class Command(BaseCommand):
help = "Prune old Flip objects."
def handle(self, *args, **options):
threshold = min(month_boundaries(months=3))
q = Flip.objects.filter(created__lt=threshold)
n_pruned, _ = q.delete()
return "Done! Pruned %d flips." % n_pruned

+ 2
- 2
hc/api/management/commands/prunepings.py View File

@ -14,8 +14,8 @@ class Command(BaseCommand):
Profile.objects.get_or_create(user_id=user.id)
q = Ping.objects
q = q.annotate(limit=F("owner__user__profile__ping_log_limit"))
q = q.filter(n__lt=F("owner__n_pings") - F("limit"))
q = q.annotate(limit=F("owner__project__owner__profile__ping_log_limit"))
q = q.filter(n__lte=F("owner__n_pings") - F("limit"))
q = q.filter(n__gt=0)
n_pruned, _ = q.delete()


+ 2
- 2
hc/api/management/commands/prunepingsslow.py View File

@ -21,11 +21,11 @@ class Command(BaseCommand):
Profile.objects.get_or_create(user_id=user.id)
checks = Check.objects
checks = checks.annotate(limit=F("user__profile__ping_log_limit"))
checks = checks.annotate(limit=F("project__owner__profile__ping_log_limit"))
for check in checks:
q = Ping.objects.filter(owner_id=check.id)
q = q.filter(n__lt=check.n_pings - check.limit)
q = q.filter(n__lte=check.n_pings - check.limit)
q = q.filter(n__gt=0)
n_pruned, _ = q.delete()


+ 37
- 0
hc/api/migrations/0062_auto_20190720_1350.py View File

@ -0,0 +1,37 @@
# Generated by Django 2.2.3 on 2019-07-20 13:50
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('api', '0061_webhook_values'),
]
operations = [
migrations.AlterField(
model_name='channel',
name='kind',
field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('pagertree', 'PagerTree'), ('pagerteam', 'Pager Team'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps'), ('discord', 'Discord'), ('telegram', 'Telegram'), ('sms', 'SMS'), ('zendesk', 'Zendesk'), ('trello', 'Trello'), ('matrix', 'Matrix'), ('whatsapp', 'WhatsApp')], max_length=20),
),
migrations.AlterField(
model_name='flip',
name='processed',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='tokenbucket',
name='updated',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddIndex(
model_name='check',
index=models.Index(condition=models.Q(_negated=True, status='down'), fields=['alert_after'], name='api_check_aa_not_down'),
),
migrations.AddIndex(
model_name='flip',
index=models.Index(condition=models.Q(processed=None), fields=['processed'], name='api_flip_not_processed'),
),
]

+ 23
- 0
hc/api/migrations/0063_auto_20190903_0901.py View File

@ -0,0 +1,23 @@
# Generated by Django 2.2.5 on 2019-09-03 09:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0062_auto_20190720_1350'),
]
operations = [
migrations.AddField(
model_name='check',
name='last_duration',
field=models.DurationField(blank=True, null=True),
),
migrations.AlterField(
model_name='channel',
name='kind',
field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('pagertree', 'PagerTree'), ('pagerteam', 'Pager Team'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps'), ('discord', 'Discord'), ('telegram', 'Telegram'), ('sms', 'SMS'), ('zendesk', 'Zendesk'), ('trello', 'Trello'), ('matrix', 'Matrix'), ('whatsapp', 'WhatsApp'), ('apprise', 'Apprise'), ('mattermost', 'Mattermost')], max_length=20),
),
]

+ 119
- 14
hc/api/models.py View File

@ -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"))
@ -20,6 +21,8 @@ DEFAULT_TIMEOUT = td(days=1)
DEFAULT_GRACE = td(hours=1)
NEVER = datetime(3000, 1, 1, tzinfo=pytz.UTC)
CHECK_KINDS = (("simple", "Simple"), ("cron", "Cron"))
# max time between start and ping where we will consider both events related:
MAX_DELTA = td(hours=24)
CHANNEL_KINDS = (
("email", "Email"),
@ -39,6 +42,9 @@ CHANNEL_KINDS = (
("zendesk", "Zendesk"),
("trello", "Trello"),
("matrix", "Matrix"),
("whatsapp", "WhatsApp"),
("apprise", "Apprise"),
("mattermost", "Mattermost"),
)
PO_PRIORITIES = {-2: "lowest", -1: "low", 0: "normal", 1: "high", 2: "emergency"}
@ -67,11 +73,23 @@ class Check(models.Model):
n_pings = models.IntegerField(default=0)
last_ping = models.DateTimeField(null=True, blank=True)
last_start = models.DateTimeField(null=True, blank=True)
last_duration = models.DurationField(null=True, blank=True)
last_ping_was_fail = models.NullBooleanField(default=False)
has_confirmation_link = models.BooleanField(default=False)
alert_after = models.DateTimeField(null=True, blank=True, editable=False)
status = models.CharField(max_length=6, choices=STATUSES, default="new")
class Meta:
indexes = [
# Index for the alert_after field. Excludes rows with status=down.
# Used in the sendalerts management command.
models.Index(
fields=["alert_after"],
name="api_check_aa_not_down",
condition=~models.Q(status="down"),
)
]
def __str__(self):
return "%s (%d)" % (self.name or self.code, self.id)
@ -90,6 +108,10 @@ class Check(models.Model):
def email(self):
return "%s@%s" % (self.code, settings.PING_EMAIL_DOMAIN)
def clamped_last_duration(self):
if self.last_duration and self.last_duration < MAX_DELTA:
return self.last_duration
def get_grace_start(self):
""" Return the datetime when the grace period starts.
@ -166,26 +188,40 @@ class Check(models.Model):
def matches_tag_set(self, tag_set):
return tag_set.issubset(self.tags_list())
def to_dict(self):
update_rel_url = reverse("hc-api-update", args=[self.code])
pause_rel_url = reverse("hc-api-pause", args=[self.code])
channel_codes = [str(ch.code) for ch in self.channel_set.all()]
def channels_str(self):
""" Return a comma-separated string of assigned channel codes. """
codes = self.channel_set.order_by("code").values_list("code", flat=True)
return ",".join(map(str, codes))
def to_dict(self, readonly=False):
result = {
"name": self.name,
"ping_url": self.url(),
"update_url": settings.SITE_ROOT + update_rel_url,
"pause_url": settings.SITE_ROOT + pause_rel_url,
"tags": self.tags,
"desc": self.desc,
"grace": int(self.grace.total_seconds()),
"n_pings": self.n_pings,
"status": self.get_status(),
"channels": ",".join(sorted(channel_codes)),
"last_ping": isostring(self.last_ping),
"next_ping": isostring(self.get_grace_start()),
"desc": self.desc,
}
if self.last_duration:
result["last_duration"] = int(self.last_duration.total_seconds())
if readonly:
code_half = self.code.hex[:16]
result["unique_key"] = hashlib.sha1(code_half.encode()).hexdigest()
else:
update_rel_url = reverse("hc-api-update", args=[self.code])
pause_rel_url = reverse("hc-api-pause", args=[self.code])
result["ping_url"] = self.url()
result["update_url"] = settings.SITE_ROOT + update_rel_url
result["pause_url"] = settings.SITE_ROOT + pause_rel_url
result["channels"] = self.channels_str()
if self.kind == "simple":
result["timeout"] = int(self.timeout.total_seconds())
elif self.kind == "cron":
@ -201,8 +237,12 @@ class Check(models.Model):
elif action == "ign":
pass
else:
self.last_start = None
self.last_ping = timezone.now()
if self.last_start:
self.last_duration = self.last_ping - self.last_start
self.last_start = None
else:
self.last_duration = None
new_status = "down" if action == "fail" else "up"
if self.status != new_status:
@ -233,6 +273,44 @@ class Check(models.Model):
ping.body = body[:10000]
ping.save()
def downtimes(self, months=2):
""" Calculate the number of downtimes and downtime minutes per month.
Returns a list of (datetime, downtime_in_secs, number_of_outages) tuples.
"""
def monthkey(dt):
return dt.year, dt.month
# Datetimes of the first days of months we're interested in. Ascending order.
boundaries = month_boundaries(months=months)
# Will accumulate totals here.
# (year, month) -> [datetime, total_downtime, number_of_outages]
totals = {monthkey(b): [b, td(), 0] for b in boundaries}
# A list of flips and month boundaries
events = [(b, "---") for b in boundaries]
q = self.flip_set.filter(created__gt=min(boundaries))
for pair in q.values_list("created", "old_status"):
events.append(pair)
# Iterate through flips and month boundaries in reverse order,
# and for each "down" event increase the counters in `totals`.
dt, status = timezone.now(), self.status
for prev_dt, prev_status in sorted(events, reverse=True):
if status == "down":
delta = dt - prev_dt
totals[monthkey(prev_dt)][1] += delta
totals[monthkey(prev_dt)][2] += 1
dt = prev_dt
if prev_status != "---":
status = prev_status
return sorted(totals.values())
class Ping(models.Model):
id = models.BigAutoField(primary_key=True)
@ -300,7 +378,7 @@ class Channel(models.Model):
return transports.Email(self)
elif self.kind == "webhook":
return transports.Webhook(self)
elif self.kind == "slack":
elif self.kind in ("slack", "mattermost"):
return transports.Slack(self)
elif self.kind == "hipchat":
return transports.HipChat(self)
@ -328,6 +406,10 @@ class Channel(models.Model):
return transports.Trello(self)
elif self.kind == "matrix":
return transports.Matrix(self)
elif self.kind == "whatsapp":
return transports.WhatsApp(self)
elif self.kind == "apprise":
return transports.Apprise(self)
else:
raise NotImplementedError("Unknown channel kind: %s" % self.kind)
@ -437,7 +519,7 @@ class Channel(models.Model):
@property
def slack_webhook_url(self):
assert self.kind == "slack"
assert self.kind in ("slack", "mattermost")
if not self.value.startswith("{"):
return self.value
@ -495,7 +577,7 @@ class Channel(models.Model):
@property
def sms_number(self):
assert self.kind == "sms"
assert self.kind in ("sms", "whatsapp")
if self.value.startswith("{"):
doc = json.loads(self.value)
return doc["value"]
@ -556,6 +638,18 @@ class Channel(models.Model):
doc = json.loads(self.value)
return doc.get("down")
@property
def whatsapp_notify_up(self):
assert self.kind == "whatsapp"
doc = json.loads(self.value)
return doc["up"]
@property
def whatsapp_notify_down(self):
assert self.kind == "whatsapp"
doc = json.loads(self.value)
return doc["down"]
class Notification(models.Model):
class Meta:
@ -575,10 +669,21 @@ class Notification(models.Model):
class Flip(models.Model):
owner = models.ForeignKey(Check, models.CASCADE)
created = models.DateTimeField()
processed = models.DateTimeField(null=True, blank=True, db_index=True)
processed = models.DateTimeField(null=True, blank=True)
old_status = models.CharField(max_length=8, choices=STATUSES)
new_status = models.CharField(max_length=8, choices=STATUSES)
class Meta:
indexes = [
# For quickly looking up unprocessed flips.
# Used in the sendalerts management command.
models.Index(
fields=["processed"],
name="api_flip_not_processed",
condition=models.Q(processed=None),
)
]
def send_alerts(self):
if self.new_status == "up" and self.old_status in ("new", "paused"):
# Don't send alerts on new->up and paused->up transitions


+ 64
- 1
hc/api/tests/test_check_model.py View File

@ -1,8 +1,9 @@
from datetime import datetime, timedelta
from django.utils import timezone
from hc.api.models import Check
from hc.api.models import Check, Flip
from hc.test import BaseTestCase
from mock import patch
class CheckModelTestCase(BaseTestCase):
@ -164,3 +165,65 @@ class CheckModelTestCase(BaseTestCase):
d = check.to_dict()
self.assertEqual(d["next_ping"], "2000-01-01T01:00:00+00:00")
def test_downtimes_handles_no_flips(self):
check = Check.objects.create(project=self.project)
r = check.downtimes(10)
self.assertEqual(len(r), 10)
for dt, downtime, outages in r:
self.assertEqual(downtime.total_seconds(), 0)
self.assertEqual(outages, 0)
def test_downtimes_handles_currently_down_check(self):
check = Check.objects.create(project=self.project, status="down")
r = check.downtimes(10)
self.assertEqual(len(r), 10)
for dt, downtime, outages in r:
self.assertEqual(outages, 1)
@patch("hc.api.models.timezone.now")
def test_downtimes_handles_flip_one_day_ago(self, mock_now):
mock_now.return_value = datetime(2019, 7, 19, tzinfo=timezone.utc)
check = Check.objects.create(project=self.project, status="down")
flip = Flip(owner=check)
flip.created = datetime(2019, 7, 18, tzinfo=timezone.utc)
flip.old_status = "up"
flip.new_status = "down"
flip.save()
r = check.downtimes(10)
self.assertEqual(len(r), 10)
for dt, downtime, outages in r:
if dt.month == 7:
self.assertEqual(downtime.total_seconds(), 86400)
self.assertEqual(outages, 1)
else:
self.assertEqual(downtime.total_seconds(), 0)
self.assertEqual(outages, 0)
@patch("hc.api.models.timezone.now")
def test_downtimes_handles_flip_two_months_ago(self, mock_now):
mock_now.return_value = datetime(2019, 7, 19, tzinfo=timezone.utc)
check = Check.objects.create(project=self.project, status="down")
flip = Flip(owner=check)
flip.created = datetime(2019, 5, 19, tzinfo=timezone.utc)
flip.old_status = "up"
flip.new_status = "down"
flip.save()
r = check.downtimes(10)
self.assertEqual(len(r), 10)
for dt, downtime, outages in r:
if dt.month == 7:
self.assertEqual(outages, 1)
elif dt.month == 6:
self.assertEqual(downtime.total_seconds(), 30 * 86400)
self.assertEqual(outages, 1)
elif dt.month == 5:
self.assertEqual(outages, 1)
else:
self.assertEqual(downtime.total_seconds(), 0)
self.assertEqual(outages, 0)

+ 0
- 7
hc/api/tests/test_list_channels.py View File

@ -51,10 +51,3 @@ class ListChannelsTestCase(BaseTestCase):
self.assertEqual(r.status_code, 200)
self.assertContains(r, "Email to Alice")
def test_readonly_key_works(self):
self.project.api_key_readonly = "R" * 32
self.project.save()
r = self.client.get("/api/v1/channels/", HTTP_X_API_KEY="R" * 32)
self.assertEqual(r.status_code, 200)

+ 3
- 0
hc/api/tests/test_list_checks.py View File

@ -150,3 +150,6 @@ class ListChecksTestCase(BaseTestCase):
r = self.client.get("/api/v1/checks/", HTTP_X_API_KEY="R" * 32)
self.assertEqual(r.status_code, 200)
# When using readonly keys, the ping URLs should not be exposed:
self.assertNotContains(r, self.a1.url())

+ 103
- 7
hc/api/tests/test_notify.py View File

@ -7,8 +7,9 @@ from django.core import mail
from django.utils.timezone import now
from hc.api.models import Channel, Check, Notification
from hc.test import BaseTestCase
from mock import patch
from mock import patch, Mock
from requests.exceptions import ConnectionError, Timeout
from django.test.utils import override_settings
class NotifyTestCase(BaseTestCase):
@ -33,7 +34,7 @@ class NotifyTestCase(BaseTestCase):
self.channel.notify(self.check)
mock_get.assert_called_with(
"get",
u"http://example",
"http://example",
headers={"User-Agent": "healthchecks.io"},
timeout=5,
)
@ -72,6 +73,19 @@ class NotifyTestCase(BaseTestCase):
n = Notification.objects.get()
self.assertEqual(n.error, "Received status code 500")
@patch("hc.api.transports.requests.request")
def test_webhooks_support_tags(self, mock_get):
template = "http://host/$TAGS"
self._setup_data("webhook", template)
self.check.tags = "foo bar"
self.check.save()
self.channel.notify(self.check)
args, kwargs = mock_get.call_args
self.assertEqual(args[0], "get")
self.assertEqual(args[1], "http://host/foo%20bar")
@patch("hc.api.transports.requests.request")
def test_webhooks_support_variables(self, mock_get):
template = "http://host/$CODE/$STATUS/$TAG1/$TAG2/?name=$NAME"
@ -82,7 +96,7 @@ class NotifyTestCase(BaseTestCase):
self.channel.notify(self.check)
url = u"http://host/%s/down/foo/bar/?name=Hello%%20World" % self.check.code
url = "http://host/%s/down/foo/bar/?name=Hello%%20World" % self.check.code
args, kwargs = mock_get.call_args
self.assertEqual(args[0], "get")
@ -118,7 +132,7 @@ class NotifyTestCase(BaseTestCase):
self.channel.notify(self.check)
url = u"http://host/%24TAG1"
url = "http://host/%24TAG1"
mock_get.assert_called_with(
"get", url, headers={"User-Agent": "healthchecks.io"}, timeout=5
)
@ -135,7 +149,7 @@ class NotifyTestCase(BaseTestCase):
@patch("hc.api.transports.requests.request")
def test_webhooks_handle_unicode_post_body(self, mock_request):
template = u"http://example.com\n\n(╯°□°)╯︵ ┻━┻"
template = "http://example.com\n\n(╯°□°)╯︵ ┻━┻"
self._setup_data("webhook", template)
self.check.save()
@ -522,12 +536,12 @@ class NotifyTestCase(BaseTestCase):
mock_post.return_value.status_code = 200
self.channel.notify(self.check)
assert Notification.objects.count() == 1
self.assertEqual(Notification.objects.count(), 1)
args, kwargs = mock_post.call_args
payload = kwargs["data"]
self.assertEqual(payload["To"], "+1234567890")
self.assertFalse(u"\xa0" in payload["Body"])
self.assertFalse("\xa0" in payload["Body"])
# sent SMS counter should go up
self.profile.refresh_from_db()
@ -575,3 +589,85 @@ class NotifyTestCase(BaseTestCase):
self.channel.notify(self.check)
self.assertTrue(mock_post.called)
@patch("hc.api.transports.requests.request")
def test_whatsapp(self, mock_post):
definition = {"value": "+1234567890", "up": True, "down": True}
self._setup_data("whatsapp", json.dumps(definition))
self.check.last_ping = now() - td(hours=2)
mock_post.return_value.status_code = 200
self.channel.notify(self.check)
self.assertEqual(Notification.objects.count(), 1)
args, kwargs = mock_post.call_args
payload = kwargs["data"]
self.assertEqual(payload["To"], "whatsapp:+1234567890")
# sent SMS counter should go up
self.profile.refresh_from_db()
self.assertEqual(self.profile.sms_sent, 1)
@patch("hc.api.transports.requests.request")
def test_whatsapp_obeys_up_down_flags(self, mock_post):
definition = {"value": "+1234567890", "up": True, "down": False}
self._setup_data("whatsapp", json.dumps(definition))
self.check.last_ping = now() - td(hours=2)
self.channel.notify(self.check)
self.assertEqual(Notification.objects.count(), 0)
self.assertFalse(mock_post.called)
@patch("hc.api.transports.requests.request")
def test_whatsapp_limit(self, mock_post):
# At limit already:
self.profile.last_sms_date = now()
self.profile.sms_sent = 50
self.profile.save()
definition = {"value": "+1234567890", "up": True, "down": True}
self._setup_data("whatsapp", json.dumps(definition))
self.channel.notify(self.check)
self.assertFalse(mock_post.called)
n = Notification.objects.get()
self.assertTrue("Monthly message limit exceeded" in n.error)
@patch("apprise.Apprise")
@override_settings(APPRISE_ENABLED=True)
def test_apprise_enabled(self, mock_apprise):
self._setup_data("apprise", "123")
mock_aobj = Mock()
mock_aobj.add.return_value = True
mock_aobj.notify.return_value = True
mock_apprise.return_value = mock_aobj
self.channel.notify(self.check)
self.assertEqual(Notification.objects.count(), 1)
self.check.status = "up"
self.assertEqual(Notification.objects.count(), 1)
@patch("apprise.Apprise")
@override_settings(APPRISE_ENABLED=False)
def test_apprise_disabled(self, mock_apprise):
self._setup_data("apprise", "123")
mock_aobj = Mock()
mock_aobj.add.return_value = True
mock_aobj.notify.return_value = True
mock_apprise.return_value = mock_aobj
self.channel.notify(self.check)
self.assertEqual(Notification.objects.count(), 1)
def test_not_implimented(self):
self._setup_data("webhook", "http://example")
self.channel.kind = "invalid"
with self.assertRaises(NotImplementedError):
self.channel.notify(self.check)

+ 10
- 0
hc/api/tests/test_ping.py View File

@ -176,3 +176,13 @@ class PingTestCase(BaseTestCase):
self.check.refresh_from_db()
self.assertTrue(self.check.last_start)
self.assertEqual(self.check.status, "paused")
def test_it_sets_last_duration(self):
self.check.last_start = now() - td(seconds=10)
self.check.save()
r = self.client.get("/ping/%s/" % self.check.code)
self.assertEqual(r.status_code, 200)
self.check.refresh_from_db()
self.assertTrue(self.check.last_duration.total_seconds() >= 10)

+ 24
- 0
hc/api/tests/test_prunepings.py View File

@ -0,0 +1,24 @@
from datetime import timedelta
from django.utils import timezone
from hc.api.management.commands.prunepings import Command
from hc.api.models import Check, Ping
from hc.test import BaseTestCase
class PrunePingsTestCase(BaseTestCase):
year_ago = timezone.now() - timedelta(days=365)
def test_it_removes_old_pings(self):
self.profile.ping_log_limit = 1
self.profile.save()
c = Check(project=self.project, n_pings=2)
c.save()
Ping.objects.create(owner=c, n=1)
Ping.objects.create(owner=c, n=2)
Command().handle()
self.assertEqual(Ping.objects.count(), 1)

+ 24
- 0
hc/api/tests/test_prunepingsslow.py View File

@ -0,0 +1,24 @@
from datetime import timedelta
from django.utils import timezone
from hc.api.management.commands.prunepingsslow import Command
from hc.api.models import Check, Ping
from hc.test import BaseTestCase
class PrunePingsSlowTestCase(BaseTestCase):
year_ago = timezone.now() - timedelta(days=365)
def test_it_removes_old_pings(self):
self.profile.ping_log_limit = 1
self.profile.save()
c = Check(project=self.project, n_pings=2)
c.save()
Ping.objects.create(owner=c, n=1)
Ping.objects.create(owner=c, n=2)
Command().handle()
self.assertEqual(Ping.objects.count(), 1)

+ 57
- 2
hc/api/transports.py View File

@ -8,12 +8,18 @@ from urllib.parse import quote, urlencode
from hc.accounts.models import Profile
from hc.lib import emails
try:
import apprise
except ImportError:
# Enforce
settings.APPRISE_ENABLED = False
def tmpl(template_name, **ctx):
template_path = "integrations/%s" % template_name
# \xa0 is non-breaking space. It causes SMS messages to use UCS2 encoding
# and cost twice the money.
return render_to_string(template_path, ctx).strip().replace(u"\xa0", " ")
return render_to_string(template_path, ctx).strip().replace("\xa0", " ")
class Transport(object):
@ -161,6 +167,9 @@ class Webhook(HttpTransport):
if "$NAME" in result:
result = result.replace("$NAME", safe(check.name))
if "$TAGS" in result:
result = result.replace("$TAGS", safe(check.tags))
if "$TAG" in result:
for i, tag in enumerate(check.tags_list()):
placeholder = "$TAG%d" % (i + 1)
@ -270,7 +279,7 @@ class PagerTree(HttpTransport):
class PagerTeam(HttpTransport):
def notify(self, check):
url = self.channel.value
headers = {"Conent-Type": "application/json"}
headers = {"Content-Type": "application/json"}
payload = {
"incident_key": str(check.code),
"event_type": "trigger" if check.status == "down" else "resolve",
@ -415,6 +424,33 @@ class Sms(HttpTransport):
return self.post(url, data=data, auth=auth)
class WhatsApp(HttpTransport):
URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json"
def is_noop(self, check):
if check.status == "down":
return not self.channel.whatsapp_notify_down
else:
return not self.channel.whatsapp_notify_up
def notify(self, check):
profile = Profile.objects.for_user(self.channel.project.owner)
if not profile.authorize_sms():
return "Monthly message limit exceeded"
url = self.URL % settings.TWILIO_ACCOUNT
auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH)
text = tmpl("whatsapp_message.html", check=check, site_name=settings.SITE_NAME)
data = {
"From": "whatsapp:%s" % settings.TWILIO_FROM,
"To": "whatsapp:%s" % self.channel.sms_number,
"Body": text,
}
return self.post(url, data=data, auth=auth)
class Trello(HttpTransport):
URL = "https://api.trello.com/1/cards"
@ -431,3 +467,22 @@ class Trello(HttpTransport):
}
return self.post(self.URL, params=params)
class Apprise(HttpTransport):
def notify(self, check):
if not settings.APPRISE_ENABLED:
# Not supported and/or enabled
return "Apprise is disabled and/or not installed."
a = apprise.Apprise()
title = tmpl("apprise_title.html", check=check)
body = tmpl("apprise_description.html", check=check)
a.add(self.channel.value)
notify_type = apprise.NotifyType.SUCCESS \
if check.status == "up" else apprise.NotifyType.FAILURE
return "Failed" if not \
a.notify(body=body, title=title, notify_type=notify_type) else None

+ 2
- 2
hc/api/views.py View File

@ -121,7 +121,7 @@ def get_checks(request):
for check in q:
# precise, final filtering
if not tags or check.matches_tag_set(tags):
checks.append(check.to_dict())
checks.append(check.to_dict(readonly=request.readonly))
return JsonResponse({"checks": checks})
@ -153,7 +153,7 @@ def checks(request):
@cors("GET")
@validate_json()
@authorize_read
@authorize
def channels(request):
q = Channel.objects.filter(project=request.project)
channels = [ch.to_dict() for ch in q]


+ 7
- 0
hc/front/forms.py View File

@ -133,6 +133,8 @@ class AddSmsForm(forms.Form):
error_css_class = "has-error"
label = forms.CharField(max_length=100, required=False)
value = forms.CharField(max_length=16, validators=[phone_validator])
down = forms.BooleanField(required=False, initial=True)
up = forms.BooleanField(required=False, initial=True)
class ChannelNameForm(forms.Form):
@ -157,3 +159,8 @@ class AddMatrixForm(forms.Form):
self.cleaned_data["room_id"] = doc["room_id"]
return v
class AddAppriseForm(forms.Form):
error_css_class = "has-error"
url = forms.CharField(max_length=512)

+ 1
- 0
hc/front/management/commands/pygmentize.py View File

@ -47,6 +47,7 @@ class Command(BaseCommand):
# API examples
_process("list_checks_request", lexers.BashLexer())
_process("list_checks_response", lexers.JsonLexer())
_process("list_checks_response_readonly", lexers.JsonLexer())
_process("list_channels_request", lexers.BashLexer())
_process("list_channels_response", lexers.JsonLexer())
_process("create_check_request_a", lexers.BashLexer())


+ 6
- 1
hc/front/templatetags/hc_extras.py View File

@ -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)


+ 29
- 0
hc/front/tests/test_add_apprise.py View File

@ -0,0 +1,29 @@
from hc.api.models import Channel
from hc.test import BaseTestCase
from django.test.utils import override_settings
@override_settings(APPRISE_ENABLED=True)
class AddAppriseTestCase(BaseTestCase):
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/add_apprise/")
self.assertContains(r, "Integration Settings", status_code=200)
def test_it_works(self):
form = {"url": "json://example.org"}
self.client.login(username="[email protected]", password="password")
r = self.client.post("/integrations/add_apprise/", form)
self.assertRedirects(r, "/integrations/")
c = Channel.objects.get()
self.assertEqual(c.kind, "apprise")
self.assertEqual(c.value, "json://example.org")
self.assertEqual(c.project, self.project)
@override_settings(APPRISE_ENABLED=False)
def test_it_requires_client_id(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/add_apprise/")
self.assertEqual(r.status_code, 404)

+ 8
- 2
hc/front/tests/test_add_check.py View File

@ -12,20 +12,26 @@ class AddCheckTestCase(BaseTestCase):
def test_it_works(self):
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url)
self.assertRedirects(r, self.redirect_url)
check = Check.objects.get()
self.assertEqual(check.project, self.project)
redirect_url = "/checks/%s/details/?new" % check.code
self.assertRedirects(r, redirect_url)
def test_it_handles_unset_current_project(self):
self.profile.current_project = None
self.profile.save()
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url)
self.assertRedirects(r, self.redirect_url)
check = Check.objects.get()
self.assertEqual(check.project, self.project)
redirect_url = "/checks/%s/details/?new" % check.code
self.assertRedirects(r, redirect_url)
def test_team_access_works(self):
self.client.login(username="[email protected]", password="password")
self.client.post(self.url)


+ 23
- 0
hc/front/tests/test_add_mattermost.py View File

@ -0,0 +1,23 @@
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
class AddMattermostTestCase(BaseTestCase):
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/integrations/add_mattermost/")
self.assertContains(r, "Integration Settings", status_code=200)
def test_it_works(self):
form = {"value": "http://example.org"}
self.client.login(username="[email protected]", password="password")
r = self.client.post("/integrations/add_mattermost/", form)
self.assertRedirects(r, "/integrations/")
c = Channel.objects.get()
self.assertEqual(c.kind, "mattermost")
self.assertEqual(c.value, "http://example.org")
self.assertEqual(c.project, self.project)

+ 70
- 0
hc/front/tests/test_add_whatsapp.py View File

@ -0,0 +1,70 @@
from django.test.utils import override_settings
from hc.api.models import Channel
from hc.test import BaseTestCase
TEST_CREDENTIALS = {
"TWILIO_ACCOUNT": "foo",
"TWILIO_AUTH": "foo",
"TWILIO_FROM": "123",
"TWILIO_USE_WHATSAPP": True,
}
@override_settings(**TEST_CREDENTIALS)
class AddWhatsAppTestCase(BaseTestCase):
url = "/integrations/add_whatsapp/"
def test_instructions_work(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertContains(r, "Get a WhatsApp message")
@override_settings(USE_PAYMENTS=True)
def test_it_warns_about_limits(self):
self.profile.sms_limit = 0
self.profile.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertContains(r, "upgrade to a")
def test_it_creates_channel(self):
form = {
"label": "My Phone",
"value": "+1234567890",
"down": "true",
"up": "true",
}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
c = Channel.objects.get()
self.assertEqual(c.kind, "whatsapp")
self.assertEqual(c.sms_number, "+1234567890")
self.assertEqual(c.name, "My Phone")
self.assertTrue(c.whatsapp_notify_down)
self.assertTrue(c.whatsapp_notify_up)
self.assertEqual(c.project, self.project)
def test_it_obeys_up_down_flags(self):
form = {"label": "My Phone", "value": "+1234567890"}
self.client.login(username="[email protected]", password="password")
r = self.client.post(self.url, form)
self.assertRedirects(r, "/integrations/")
c = Channel.objects.get()
self.assertEqual(c.kind, "whatsapp")
self.assertEqual(c.sms_number, "+1234567890")
self.assertEqual(c.name, "My Phone")
self.assertFalse(c.whatsapp_notify_down)
self.assertFalse(c.whatsapp_notify_up)
self.assertEqual(c.project, self.project)
@override_settings(TWILIO_USE_WHATSAPP=False)
def test_it_obeys_use_whatsapp_flag(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)

+ 5
- 0
hc/front/tests/test_details.py View File

@ -43,3 +43,8 @@ class DetailsTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
def test_it_shows_new_check_notice(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url + "?new")
self.assertContains(r, "Your new check is ready!", status_code=200)

+ 1
- 1
hc/front/tests/test_log.py View File

@ -22,7 +22,7 @@ class LogTestCase(BaseTestCase):
self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url)
self.assertContains(r, "Local Time", status_code=200)
self.assertContains(r, "Browser's time zone", status_code=200)
def test_team_access_works(self):


+ 4
- 1
hc/front/urls.py View File

@ -10,7 +10,7 @@ check_urls = [
path("pause/", views.pause, name="hc-pause"),
path("remove/", views.remove_check, name="hc-remove-check"),
path("log/", views.log, name="hc-log"),
path("status/", views.status_single),
path("status/", views.status_single, name="hc-status-single"),
path("last_ping/", views.ping_details, name="hc-last-ping"),
path("transfer/", views.transfer, name="hc-transfer"),
path(
@ -30,6 +30,7 @@ channel_urls = [
path("add_pagertree/", views.add_pagertree, name="hc-add-pagertree"),
path("add_pagerteam/", views.add_pagerteam, name="hc-add-pagerteam"),
path("add_slack/", views.add_slack, name="hc-add-slack"),
path("add_mattermost/", views.add_mattermost, name="hc-add-mattermost"),
path("add_slack_btn/", views.add_slack_btn, name="hc-add-slack-btn"),
path("add_pushbullet/", views.add_pushbullet, name="hc-add-pushbullet"),
path("add_discord/", views.add_discord, name="hc-add-discord"),
@ -39,9 +40,11 @@ channel_urls = [
path("telegram/bot/", views.telegram_bot, name="hc-telegram-webhook"),
path("add_telegram/", views.add_telegram, name="hc-add-telegram"),
path("add_sms/", views.add_sms, name="hc-add-sms"),
path("add_whatsapp/", views.add_whatsapp, name="hc-add-whatsapp"),
path("add_trello/", views.add_trello, name="hc-add-trello"),
path("add_trello/settings/", views.trello_settings, name="hc-trello-settings"),
path("add_matrix/", views.add_matrix, name="hc-add-matrix"),
path("add_apprise/", views.add_apprise, name="hc-add-apprise"),
path("<uuid:code>/checks/", views.channel_checks, name="hc-channel-checks"),
path("<uuid:code>/name/", views.update_channel_name, name="hc-channel-name"),
path("<uuid:code>/test/", views.send_test_notification, name="hc-channel-test"),


+ 95
- 9
hc/front/views.py View File

@ -26,6 +26,7 @@ from hc.accounts.models import Project
from hc.api.models import (
DEFAULT_GRACE,
DEFAULT_TIMEOUT,
MAX_DELTA,
Channel,
Check,
Ping,
@ -44,6 +45,7 @@ from hc.front.forms import (
ChannelNameForm,
EmailSettingsForm,
AddMatrixForm,
AddAppriseForm,
)
from hc.front.schemas import telegram_callback
from hc.front.templatetags.hc_extras import num_down_title, down_title, sortchecks
@ -58,8 +60,7 @@ VALID_SORT_VALUES = ("name", "-name", "last_ping", "-last_ping", "created")
STATUS_TEXT_TMPL = get_template("front/log_status_text.html")
LAST_PING_TMPL = get_template("front/last_ping_cell.html")
EVENTS_TMPL = get_template("front/details_events.html")
ONE_HOUR = td(hours=1)
TWELVE_HOURS = td(hours=12)
DOWNTIMES_TMPL = get_template("front/details_downtimes.html")
def _tags_statuses(checks):
@ -153,6 +154,13 @@ def my_checks(request, code):
if search not in search_key:
hidden_checks.add(check)
# Do we need to show the "Last Duration" header?
show_last_duration = False
for check in checks:
if check.clamped_last_duration():
show_last_duration = True
break
ctx = {
"page": "checks",
"checks": checks,
@ -166,9 +174,9 @@ def my_checks(request, code):
"num_available": project.num_checks_available(),
"sort": request.profile.sort,
"selected_tags": selected_tags,
"show_search": True,
"search": search,
"hidden_checks": hidden_checks,
"show_last_duration": show_last_duration,
}
return render(request, "front/my_checks.html", ctx)
@ -232,9 +240,11 @@ def index(request):
"enable_discord": settings.DISCORD_CLIENT_ID is not None,
"enable_telegram": settings.TELEGRAM_TOKEN is not None,
"enable_sms": settings.TWILIO_AUTH is not None,
"enable_whatsapp": settings.TWILIO_USE_WHATSAPP,
"enable_pd": settings.PD_VENDOR_KEY is not None,
"enable_trello": settings.TRELLO_APP_KEY is not None,
"enable_matrix": settings.MATRIX_ACCESS_TOKEN is not None,
"enable_apprise": settings.APPRISE_ENABLED is True,
"registration_open": settings.REGISTRATION_OPEN,
}
@ -289,7 +299,8 @@ def add_check(request, code):
check.assign_all_channels()
return redirect("hc-checks", code)
url = reverse("hc-details", args=[check.code])
return redirect(url + "?new")
@require_POST
@ -417,10 +428,6 @@ def remove_check(request, code):
def _get_events(check, limit):
# max time between start and ping where we will consider
# the both events related.
max_delta = min(ONE_HOUR + check.grace, TWELVE_HOURS)
pings = Ping.objects.filter(owner=check).order_by("-id")[:limit]
pings = list(pings)
@ -428,7 +435,7 @@ def _get_events(check, limit):
for ping in pings:
if ping.kind == "start" and prev and prev.kind != "start":
delta = prev.created - ping.created
if delta < max_delta:
if delta < MAX_DELTA:
setattr(prev, "delta", delta)
prev = ping
@ -474,6 +481,8 @@ def details(request, code):
"check": check,
"channels": channels,
"timezones": pytz.all_timezones,
"downtimes": check.downtimes(months=3),
"is_new": "new" in request.GET,
}
return render(request, "front/details.html", ctx)
@ -523,6 +532,7 @@ def status_single(request, code):
if updated != request.GET.get("u"):
doc["events"] = EVENTS_TMPL.render({"check": check, "events": events})
doc["downtimes"] = DOWNTIMES_TMPL.render({"downtimes": check.downtimes(3)})
return JsonResponse(doc)
@ -603,9 +613,11 @@ def channels(request):
"enable_discord": settings.DISCORD_CLIENT_ID is not None,
"enable_telegram": settings.TELEGRAM_TOKEN is not None,
"enable_sms": settings.TWILIO_AUTH is not None,
"enable_whatsapp": settings.TWILIO_USE_WHATSAPP,
"enable_pd": settings.PD_VENDOR_KEY is not None,
"enable_trello": settings.TRELLO_APP_KEY is not None,
"enable_matrix": settings.MATRIX_ACCESS_TOKEN is not None,
"enable_apprise": settings.APPRISE_ENABLED is True,
"use_payments": settings.USE_PAYMENTS,
}
@ -895,6 +907,25 @@ def add_slack(request):
return render(request, "integrations/add_slack.html", ctx)
@login_required
def add_mattermost(request):
if request.method == "POST":
form = AddUrlForm(request.POST)
if form.is_valid():
channel = Channel(project=request.project, kind="mattermost")
channel.value = form.cleaned_data["value"]
channel.save()
channel.assign_all_checks()
return redirect("hc-channels")
else:
form = AddUrlForm()
ctx = {"page": "channels", "form": form, "project": request.project}
return render(request, "integrations/add_mattermost.html", ctx)
@login_required
def add_slack_btn(request):
code = _get_validated_code(request, "slack")
@ -1222,6 +1253,39 @@ def add_sms(request):
return render(request, "integrations/add_sms.html", ctx)
@login_required
def add_whatsapp(request):
if not settings.TWILIO_USE_WHATSAPP:
raise Http404("whatsapp integration is not available")
if request.method == "POST":
form = AddSmsForm(request.POST)
if form.is_valid():
channel = Channel(project=request.project, kind="whatsapp")
channel.name = form.cleaned_data["label"]
channel.value = json.dumps(
{
"value": form.cleaned_data["value"],
"up": form.cleaned_data["up"],
"down": form.cleaned_data["down"],
}
)
channel.save()
channel.assign_all_checks()
return redirect("hc-channels")
else:
form = AddSmsForm()
ctx = {
"page": "channels",
"project": request.project,
"form": form,
"profile": request.project.owner_profile,
}
return render(request, "integrations/add_whatsapp.html", ctx)
@login_required
def add_trello(request):
if settings.TRELLO_APP_KEY is None:
@ -1288,6 +1352,28 @@ def add_matrix(request):
return render(request, "integrations/add_matrix.html", ctx)
@login_required
def add_apprise(request):
if not settings.APPRISE_ENABLED:
raise Http404("apprise integration is not available")
if request.method == "POST":
form = AddAppriseForm(request.POST)
if form.is_valid():
channel = Channel(project=request.project, kind="apprise")
channel.value = form.cleaned_data["url"]
channel.save()
channel.assign_all_checks()
messages.success(request, "The Apprise integration has been added!")
return redirect("hc-channels")
else:
form = AddAppriseForm()
ctx = {"page": "channels", "project": request.project, "form": form}
return render(request, "integrations/add_apprise.html", ctx)
@login_required
@require_POST
def trello_settings(request):


+ 36
- 0
hc/lib/date.py View File

@ -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):
@ -31,6 +37,7 @@ def format_duration(td):
def format_hms(td):
total_seconds = int(td.total_seconds())
result = []
mins, secs = divmod(total_seconds, 60)
@ -45,3 +52,32 @@ def format_hms(td):
result.append("%s sec" % secs)
return " ".join(result)
def format_approx_duration(td):
v = td.total_seconds()
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.insert(0, dt(y, m, 1, tzinfo=timezone.utc))
m -= 1
if m == 0:
m = 12
y = y - 1
return result

+ 0
- 98
hc/payments/invoices.py View File

@ -1,98 +0,0 @@
# coding: utf-8
try:
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import inch
from reportlab.pdfgen.canvas import Canvas
W, H = A4
except ImportError:
# Don't crash if reportlab is not installed.
Canvas = object
def f(dt):
return dt.strftime("%b. %-d, %Y")
class PdfInvoice(Canvas):
def __init__(self, fileobj):
Canvas.__init__(self, fileobj, pagesize=A4, pageCompression=0)
self.head_y = H - inch * 0.5
def linefeed(self):
self.head_y -= inch / 8
def text(self, s, align="left", size=10, bold=False):
self.head_y -= inch / 24
self.linefeed()
self.setFont("Helvetica-Bold" if bold else "Helvetica", size)
if align == "left":
self.drawString(inch * 0.5, self.head_y, s)
elif align == "right":
self.drawRightString(W - inch * 0.5, self.head_y, s)
elif align == "center":
self.drawCentredString(W / 2, self.head_y, s)
self.head_y -= inch / 24
def hr(self):
self.setLineWidth(inch / 72 / 8)
self.line(inch * 0.5, self.head_y, W - inch * 0.5, self.head_y)
def row(self, items, align="left", bold=False, size=10):
self.head_y -= inch / 8
self.linefeed()
self.setFont("Helvetica-Bold" if bold else "Helvetica", size)
self.drawString(inch * 0.5, self.head_y, items[0])
self.drawString(inch * 3.5, self.head_y, items[1])
self.drawString(inch * 5.5, self.head_y, items[2])
self.drawRightString(W - inch * 0.5, self.head_y, items[3])
self.head_y -= inch / 8
def render(self, tx, bill_to):
invoice_id = "MS-HC-%s" % tx.id.upper()
self.setTitle(invoice_id)
self.text("SIA Monkey See Monkey Do", size=16)
self.linefeed()
self.text("Gaujas iela 4-2")
self.text("Valmiera, LV-4201, Latvia")
self.text("VAT: LV44103100701")
self.linefeed()
created = f(tx.created_at)
self.text("Date Issued: %s" % created, align="right")
self.text("Invoice Id: %s" % invoice_id, align="right")
self.linefeed()
self.hr()
self.row(["Description", "Start", "End", tx.currency_iso_code], bold=True)
self.hr()
start = f(tx.subscription_details.billing_period_start_date)
end = f(tx.subscription_details.billing_period_end_date)
if tx.currency_iso_code == "USD":
amount = "$%s" % tx.amount
elif tx.currency_iso_code == "EUR":
amount = "%s" % tx.amount
else:
amount = "%s %s" % (tx.currency_iso_code, tx.amount)
self.row(["healthchecks.io paid plan", start, end, amount])
self.hr()
self.row(["", "", "", "Total: %s" % amount], bold=True)
self.linefeed()
self.text("Bill to:", bold=True)
for s in bill_to.split("\n"):
self.text(s.strip())
self.linefeed()
self.showPage()
self.save()

+ 14
- 43
hc/payments/models.py View File

@ -66,11 +66,9 @@ class Subscription(models.Model):
@property
def payment_method(self):
if not self.payment_method_token:
return None
if not hasattr(self, "_pm"):
self._pm = braintree.PaymentMethod.find(self.payment_method_token)
o = self._get_braintree_subscription()
self._pm = braintree.PaymentMethod.find(o.payment_method_token)
return self._pm
def _get_braintree_subscription(self):
@ -79,43 +77,19 @@ class Subscription(models.Model):
return self._sub
def get_client_token(self):
assert self.customer_id
return braintree.ClientToken.generate({"customer_id": self.customer_id})
def update_payment_method(self, nonce):
# Create customer record if it does not exist:
if not self.customer_id:
result = braintree.Customer.create({"email": self.user.email})
if not result.is_success:
return result
assert self.subscription_id
self.customer_id = result.customer.id
self.save()
# Create payment method
result = braintree.PaymentMethod.create(
{
"customer_id": self.customer_id,
"payment_method_nonce": nonce,
"options": {"make_default": True},
}
result = braintree.Subscription.update(
self.subscription_id, {"payment_method_nonce": nonce}
)
if not result.is_success:
return result
self.payment_method_token = result.payment_method.token
self.save()
# Update an existing subscription to use this payment method
if self.subscription_id:
result = braintree.Subscription.update(
self.subscription_id,
{"payment_method_token": self.payment_method_token},
)
if not result.is_success:
return result
def update_address(self, post_data):
# Create customer record if it does not exist:
if not self.customer_id:
@ -141,9 +115,9 @@ class Subscription(models.Model):
if not result.is_success:
return result
def setup(self, plan_id):
def setup(self, plan_id, nonce):
result = braintree.Subscription.create(
{"payment_method_token": self.payment_method_token, "plan_id": plan_id}
{"payment_method_nonce": nonce, "plan_id": plan_id}
)
if result.is_success:
@ -162,11 +136,15 @@ class Subscription(models.Model):
return result
def cancel(self):
def cancel(self, delete_customer=False):
if self.subscription_id:
braintree.Subscription.cancel(self.subscription_id)
self.subscription_id = ""
if self.customer_id and delete_customer:
braintree.Customer.delete(self.customer_id)
self.customer_id = ""
self.subscription_id = ""
self.plan_id = ""
self.plan_name = ""
self.save()
@ -195,13 +173,6 @@ class Subscription(models.Model):
return self._address
def flattened_address(self):
if self.address_id:
ctx = {"a": self.address, "email": self.user.email}
return render_to_string("payments/address_plain.html", ctx)
else:
return self.user.email
@property
def transactions(self):
if not hasattr(self, "_tx"):


+ 4
- 3
hc/payments/tests/test_billing_history.py View File

@ -1,5 +1,6 @@
from mock import Mock, patch
from django.utils.timezone import now
from hc.payments.models import Subscription
from hc.test import BaseTestCase
@ -15,11 +16,11 @@ class BillingHistoryTestCase(BaseTestCase):
@patch("hc.payments.models.braintree")
def test_it_works(self, mock_braintree):
m1 = Mock(id="abc123", amount=123)
m2 = Mock(id="def456", amount=456)
m1 = Mock(id="abc123", amount=123, created_at=now())
m2 = Mock(id="def456", amount=456, created_at=now())
mock_braintree.Transaction.search.return_value = [m1, m2]
self.client.login(username="[email protected]", password="password")
r = self.client.get("/accounts/profile/billing/history/")
self.assertContains(r, "123")
self.assertContains(r, "def456")
self.assertContains(r, "456")

+ 0
- 74
hc/payments/tests/test_charge_webhook.py View File

@ -1,74 +0,0 @@
from mock import Mock, patch
from unittest import skipIf
from django.core import mail
from django.utils.timezone import now
from hc.payments.models import Subscription
from hc.test import BaseTestCase
try:
import reportlab
except ImportError:
reportlab = None
class ChargeWebhookTestCase(BaseTestCase):
def setUp(self):
super(ChargeWebhookTestCase, self).setUp()
self.sub = Subscription(user=self.alice)
self.sub.subscription_id = "test-id"
self.sub.customer_id = "test-customer-id"
self.sub.send_invoices = True
self.sub.save()
self.tx = Mock()
self.tx.id = "abc123"
self.tx.customer_details.id = "test-customer-id"
self.tx.created_at = now()
self.tx.currency_iso_code = "USD"
self.tx.amount = 5
self.tx.subscription_details.billing_period_start_date = now()
self.tx.subscription_details.billing_period_end_date = now()
@skipIf(reportlab is None, "reportlab not installed")
@patch("hc.payments.views.Subscription.objects.by_braintree_webhook")
def test_it_works(self, mock_getter):
mock_getter.return_value = self.sub, self.tx
r = self.client.post("/pricing/charge/")
self.assertEqual(r.status_code, 200)
# See if email was sent
self.assertEqual(len(mail.outbox), 1)
msg = mail.outbox[0]
self.assertEqual(msg.subject, "Invoice from Mychecks")
self.assertEqual(msg.to, ["[email protected]"])
self.assertEqual(msg.attachments[0][0], "MS-HC-ABC123.pdf")
@patch("hc.payments.views.Subscription.objects.by_braintree_webhook")
def test_it_obeys_send_invoices_flag(self, mock_getter):
mock_getter.return_value = self.sub, self.tx
self.sub.send_invoices = False
self.sub.save()
r = self.client.post("/pricing/charge/")
self.assertEqual(r.status_code, 200)
# It should not send the email
self.assertEqual(len(mail.outbox), 0)
@skipIf(reportlab is None, "reportlab not installed")
@patch("hc.payments.views.Subscription.objects.by_braintree_webhook")
def test_it_uses_invoice_email(self, mock_getter):
mock_getter.return_value = self.sub, self.tx
self.sub.invoice_email = "[email protected]"
self.sub.save()
r = self.client.post("/pricing/charge/")
self.assertEqual(r.status_code, 200)
# See if the email was sent to Alice's accountant:
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["[email protected]"])

+ 5
- 1
hc/payments/tests/test_get_client_token.py View File

@ -7,10 +7,14 @@ from hc.test import BaseTestCase
class GetClientTokenTestCase(BaseTestCase):
@patch("hc.payments.models.braintree")
def test_it_works(self, mock_braintree):
sub = Subscription(user=self.alice)
sub.customer_id = "fake-customer-id"
sub.save()
mock_braintree.ClientToken.generate.return_value = "test-token"
self.client.login(username="[email protected]", password="password")
r = self.client.get("/pricing/get_client_token/")
r = self.client.get("/pricing/token/")
self.assertContains(r, "test-token", status_code=200)
# A subscription object should have been created


+ 2
- 65
hc/payments/tests/test_payment_method.py View File

@ -5,23 +5,13 @@ from hc.test import BaseTestCase
class UpdatePaymentMethodTestCase(BaseTestCase):
def _setup_mock(self, mock):
""" Set up Braintree calls that the controller will use. """
mock.PaymentMethod.create.return_value.is_success = True
mock.PaymentMethod.create.return_value.payment_method.token = "fake"
@patch("hc.payments.models.braintree")
def test_it_retrieves_paypal(self, mock):
self._setup_mock(mock)
mock.paypal_account.PayPalAccount = dict
mock.credit_card.CreditCard = list
mock.PaymentMethod.find.return_value = {"email": "[email protected]"}
self.sub = Subscription(user=self.alice)
self.sub.payment_method_token = "fake-token"
self.sub.save()
Subscription.objects.create(user=self.alice)
self.client.login(username="[email protected]", password="password")
r = self.client.get("/accounts/profile/billing/payment_method/")
@ -29,65 +19,12 @@ class UpdatePaymentMethodTestCase(BaseTestCase):
@patch("hc.payments.models.braintree")
def test_it_retrieves_cc(self, mock):
self._setup_mock(mock)
mock.paypal_account.PayPalAccount = list
mock.credit_card.CreditCard = dict
mock.PaymentMethod.find.return_value = {"masked_number": "1***2"}
self.sub = Subscription(user=self.alice)
self.sub.payment_method_token = "fake-token"
self.sub.save()
Subscription.objects.create(user=self.alice)
self.client.login(username="[email protected]", password="password")
r = self.client.get("/accounts/profile/billing/payment_method/")
self.assertContains(r, "1***2")
@patch("hc.payments.models.braintree")
def test_it_creates_payment_method(self, mock):
self._setup_mock(mock)
self.sub = Subscription(user=self.alice)
self.sub.customer_id = "test-customer"
self.sub.save()
self.client.login(username="[email protected]", password="password")
form = {"payment_method_nonce": "test-nonce"}
r = self.client.post("/accounts/profile/billing/payment_method/", form)
self.assertRedirects(r, "/accounts/profile/billing/")
@patch("hc.payments.models.braintree")
def test_it_creates_customer(self, mock):
self._setup_mock(mock)
mock.Customer.create.return_value.is_success = True
mock.Customer.create.return_value.customer.id = "test-customer-id"
self.sub = Subscription(user=self.alice)
self.sub.save()
self.client.login(username="[email protected]", password="password")
form = {"payment_method_nonce": "test-nonce"}
self.client.post("/accounts/profile/billing/payment_method/", form)
self.sub.refresh_from_db()
self.assertEqual(self.sub.customer_id, "test-customer-id")
@patch("hc.payments.models.braintree")
def test_it_updates_subscription(self, mock):
self._setup_mock(mock)
self.sub = Subscription(user=self.alice)
self.sub.customer_id = "test-customer"
self.sub.subscription_id = "fake-id"
self.sub.save()
mock.Customer.create.return_value.is_success = True
mock.Customer.create.return_value.customer.id = "test-customer-id"
self.client.login(username="[email protected]", password="password")
form = {"payment_method_nonce": "test-nonce"}
self.client.post("/accounts/profile/billing/payment_method/", form)
self.assertTrue(mock.Subscription.update.called)

+ 0
- 65
hc/payments/tests/test_pdf_invoice.py View File

@ -1,65 +0,0 @@
from mock import Mock, patch
from unittest import skipIf
from django.utils.timezone import now
from hc.payments.models import Subscription
from hc.test import BaseTestCase
try:
import reportlab
except ImportError:
reportlab = None
class PdfInvoiceTestCase(BaseTestCase):
def setUp(self):
super(PdfInvoiceTestCase, self).setUp()
self.sub = Subscription(user=self.alice)
self.sub.subscription_id = "test-id"
self.sub.customer_id = "test-customer-id"
self.sub.save()
self.tx = Mock()
self.tx.id = "abc123"
self.tx.customer_details.id = "test-customer-id"
self.tx.created_at = now()
self.tx.currency_iso_code = "USD"
self.tx.amount = 5
self.tx.subscription_details.billing_period_start_date = now()
self.tx.subscription_details.billing_period_end_date = now()
@skipIf(reportlab is None, "reportlab not installed")
@patch("hc.payments.models.braintree")
def test_it_works(self, mock_braintree):
mock_braintree.Transaction.find.return_value = self.tx
self.client.login(username="[email protected]", password="password")
r = self.client.get("/invoice/pdf/abc123/")
self.assertTrue(b"ABC123" in r.content)
self.assertTrue(b"[email protected]" in r.content)
@patch("hc.payments.models.braintree")
def test_it_checks_customer_id(self, mock_braintree):
tx = Mock()
tx.id = "abc123"
tx.customer_details.id = "test-another-customer-id"
tx.created_at = None
mock_braintree.Transaction.find.return_value = tx
self.client.login(username="[email protected]", password="password")
r = self.client.get("/invoice/pdf/abc123/")
self.assertEqual(r.status_code, 403)
@skipIf(reportlab is None, "reportlab not installed")
@patch("hc.payments.models.braintree")
def test_it_shows_company_data(self, mock):
self.sub.address_id = "aa"
self.sub.save()
mock.Transaction.find.return_value = self.tx
mock.Address.find.return_value = {"company": "Alice and Partners"}
self.client.login(username="[email protected]", password="password")
r = self.client.get("/invoice/pdf/abc123/")
self.assertTrue(b"Alice and Partners" in r.content)

+ 9
- 0
hc/payments/tests/test_pricing.py View File

@ -35,6 +35,15 @@ class PricingTestCase(BaseTestCase):
r = self.client.get("/pricing/")
self.assertContains(r, "To manage billing for this project")
def test_it_handles_null_project(self):
self.profile.current_project = None
self.profile.save()
self.client.login(username="[email protected]", password="password")
r = self.client.get("/pricing/")
self.assertContains(r, "Unlimited Team Members")
def test_it_shows_active_plan(self):
self.sub = Subscription(user=self.alice)
self.sub.subscription_id = "test-id"


hc/payments/tests/test_set_plan.py → hc/payments/tests/test_update_subscription.py View File


+ 2
- 8
hc/payments/urls.py View File

@ -16,12 +16,6 @@ urlpatterns = [
views.payment_method,
name="hc-payment-method",
),
path(
"invoice/pdf/<slug:transaction_id>/", views.pdf_invoice, name="hc-invoice-pdf"
),
path("pricing/set_plan/", views.set_plan, name="hc-set-plan"),
path(
"pricing/get_client_token/", views.get_client_token, name="hc-get-client-token"
),
path("pricing/charge/", views.charge_webhook),
path("pricing/update/", views.update, name="hc-update-subscription"),
path("pricing/token/", views.token, name="hc-get-client-token"),
]

+ 35
- 77
hc/payments/views.py View File

@ -1,39 +1,35 @@
from io import BytesIO
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import (
HttpResponseBadRequest,
HttpResponseForbidden,
JsonResponse,
HttpResponse,
)
from django.http import HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from hc.api.models import Check
from hc.lib import emails
from hc.payments.forms import InvoiceEmailingForm
from hc.payments.invoices import PdfInvoice
from hc.payments.models import Subscription
@login_required
def get_client_token(request):
def token(request):
sub = Subscription.objects.for_user(request.user)
return JsonResponse({"client_token": sub.get_client_token()})
def pricing(request):
if request.user.is_authenticated and request.user != request.project.owner:
ctx = {"page": "pricing"}
return render(request, "payments/pricing_not_owner.html", ctx)
if request.user.is_authenticated:
if request.project and request.project.owner != request.user:
ctx = {"page": "pricing"}
return render(request, "payments/pricing_not_owner.html", ctx)
# Don't use Subscription.objects.for_user method here, so a
# subscription object is not created just by viewing a page.
sub = Subscription.objects.filter(user_id=request.user.id).first()
ctx = {"page": "pricing", "sub": sub}
ctx = {
"page": "pricing",
"sub": sub,
"enable_whatsapp": settings.TWILIO_USE_WHATSAPP,
}
return render(request, "payments/pricing.html", ctx)
@ -91,27 +87,38 @@ def log_and_bail(request, result):
@login_required
@require_POST
def set_plan(request):
def update(request):
plan_id = request.POST["plan_id"]
if plan_id not in ("", "P20", "P80", "Y192", "Y768"):
return HttpResponseBadRequest()
nonce = request.POST["nonce"]
sub = Subscription.objects.for_user(request.user)
if sub.plan_id == plan_id:
# If plan_id has not changed then just update the payment method:
if plan_id == sub.plan_id:
error = sub.update_payment_method(nonce)
if error:
return log_and_bail(request, error)
request.session["payment_method_status"] = "success"
return redirect("hc-billing")
# Cancel the previous plan
if plan_id not in ("", "P20", "P80", "Y192", "Y768"):
return HttpResponseBadRequest()
# Cancel the previous plan and reset limits:
sub.cancel()
profile = request.user.profile
profile.ping_log_limit = 100
profile.check_limit = 20
profile.team_limit = 2
profile.sms_limit = 0
profile.save()
if plan_id == "":
profile = request.user.profile
profile.ping_log_limit = 100
profile.check_limit = 20
profile.team_limit = 2
profile.sms_limit = 0
profile.save()
request.session["set_plan_status"] = "success"
return redirect("hc-billing")
result = sub.setup(plan_id)
result = sub.setup(plan_id, nonce)
if not result.is_success:
return log_and_bail(request, result)
@ -154,19 +161,6 @@ def address(request):
@login_required
def payment_method(request):
sub = get_object_or_404(Subscription, user=request.user)
if request.method == "POST":
if "payment_method_nonce" not in request.POST:
return HttpResponseBadRequest()
nonce = request.POST["payment_method_nonce"]
error = sub.update_payment_method(nonce)
if error:
return log_and_bail(request, error)
request.session["payment_method_status"] = "success"
return redirect("hc-billing")
ctx = {"sub": sub, "pm": sub.payment_method}
return render(request, "payments/payment_method.html", ctx)
@ -181,39 +175,3 @@ def billing_history(request):
ctx = {"transactions": transactions}
return render(request, "payments/billing_history.html", ctx)
@login_required
def pdf_invoice(request, transaction_id):
sub, tx = Subscription.objects.by_transaction(transaction_id)
# Does this transaction belong to a customer we know about?
if sub is None or tx is None:
return HttpResponseForbidden()
# Does the transaction's customer match the currently logged in user?
if sub.user != request.user and not request.user.is_superuser:
return HttpResponseForbidden()
response = HttpResponse(content_type="application/pdf")
filename = "MS-HC-%s.pdf" % tx.id.upper()
response["Content-Disposition"] = 'attachment; filename="%s"' % filename
PdfInvoice(response).render(tx, sub.flattened_address())
return response
@csrf_exempt
@require_POST
def charge_webhook(request):
sub, tx = Subscription.objects.by_braintree_webhook(request)
if sub.send_invoices:
filename = "MS-HC-%s.pdf" % tx.id.upper()
sink = BytesIO()
PdfInvoice(sink).render(tx, sub.flattened_address())
ctx = {"tx": tx}
recipient = sub.invoice_email or sub.user.email
emails.invoice(recipient, ctx, filename, sink.getvalue())
return HttpResponse()

+ 8
- 3
hc/settings.py View File

@ -135,9 +135,9 @@ if os.getenv("DB") == "mysql":
TIME_ZONE = "UTC"
USE_I18N = True
USE_I18N = False
USE_L10N = True
USE_L10N = False
USE_TZ = True
@ -187,10 +187,11 @@ PUSHBULLET_CLIENT_SECRET = os.getenv("PUSHBULLET_CLIENT_SECRET")
TELEGRAM_BOT_NAME = os.getenv("TELEGRAM_BOT_NAME", "ExampleBot")
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN")
# SMS (Twilio) integration
# SMS and WhatsApp (Twilio) integration
TWILIO_ACCOUNT = os.getenv("TWILIO_ACCOUNT")
TWILIO_AUTH = os.getenv("TWILIO_AUTH")
TWILIO_FROM = os.getenv("TWILIO_FROM")
TWILIO_USE_WHATSAPP = envbool("TWILIO_USE_WHATSAPP", "False")
# PagerDuty
PD_VENDOR_KEY = os.getenv("PD_VENDOR_KEY")
@ -203,6 +204,10 @@ MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER")
MATRIX_USER_ID = os.getenv("MATRIX_USER_ID")
MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN")
# Apprise
APPRISE_ENABLED = envbool("APPRISE_ENABLED", "False")
if os.path.exists(os.path.join(BASE_DIR, "hc/local_settings.py")):
from .local_settings import *
else:


+ 3
- 3
requirements.txt View File

@ -1,6 +1,6 @@
croniter==0.3.30
Django==2.2.1
django_compressor==2.2
psycopg2==2.7.5
Django==2.2.6
django_compressor==2.3
psycopg2==2.8.3
pytz==2019.1
requests==2.22.0

+ 29
- 0
static/css/base.css View File

@ -61,6 +61,35 @@ body {
font-size: small;
}
#nav-sign-up {
padding: 21px 0 21px 15px;
}
#nav-sign-up:hover {
border: 0;
}
#nav-sign-up:focus, #nav-sign-up:active {
outline: none;
}
#nav-sign-up span {
display: inline-block;
color: #1ea65a;
font-weight: bold;
border: 1px solid #1ea65a;
border-radius: 5px;
padding: 8px 16px;
transition: all 0.2s ease;
}
#nav-sign-up:hover span {
color: #fff;
background: #22bc66;
border-color: #22bc66;
}
.page-checks .container-fluid, .page-details .container-fluid {
/* Fluid below 1320px, but max width capped to 1320px ... */
max-width: 1320px;


+ 9
- 1
static/css/billing.css View File

@ -12,7 +12,7 @@
}
@media (min-width: 992px) {
#change-billing-plan-modal .modal-dialog {
#change-billing-plan-modal .modal-dialog, #payment-method-modal .modal-dialog, #please-wait-modal .modal-dialog {
width: 850px;
}
}
@ -82,4 +82,12 @@
margin-top: 20px;
}
.text-muted code {
color: #777;
}
#please-wait-modal .modal-body {
text-align: center;
padding: 100px;
font-size: 18px;
}

+ 23
- 0
static/css/details.css View File

@ -80,3 +80,26 @@
color: #d43f3a;
background: #FFF;
}
#downtimes table {
width: 350px;
}
#downtimes tr:first-child td, #downtimes tr:first-child th {
border-top: 0;
}
#downtimes th {
width: 100px;
text-align: right;
font-weight: normal;
color: #888;
}
.alert.no-events {
border: #ddd;
background: #F5F5F5;
color: #444;
text-align: center;
padding: 32px;
}

+ 13
- 8
static/css/icomoon.css View File

@ -1,10 +1,10 @@
@font-face {
font-family: 'icomoon';
src: url('../fonts/icomoon.eot?m3lvqd');
src: url('../fonts/icomoon.eot?m3lvqd#iefix') format('embedded-opentype'),
url('../fonts/icomoon.ttf?m3lvqd') format('truetype'),
url('../fonts/icomoon.woff?m3lvqd') format('woff'),
url('../fonts/icomoon.svg?m3lvqd#icomoon') format('svg');
src: url('../fonts/icomoon.eot?smade2');
src: url('../fonts/icomoon.eot?smade2#iefix') format('embedded-opentype'),
url('../fonts/icomoon.ttf?smade2') format('truetype'),
url('../fonts/icomoon.woff?smade2') format('woff'),
url('../fonts/icomoon.svg?smade2#icomoon') format('svg');
font-weight: normal;
font-style: normal;
}
@ -24,10 +24,18 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-mattermost:before {
content: "\e907";
color: #045acc;
}
.icon-pagerteam:before {
content: "\e914";
color: #cd2a00;
}
.icon-apprise:before {
content: "\e915";
color: #236b6b;
}
.icon-matrix:before {
content: "\e900";
}
@ -94,9 +102,6 @@
content: "\e902";
color: #25d366;
}
.icon-zendesk:before {
content: "\e907";
}
.icon-clippy:before {
content: "\e903";
}


+ 5
- 1
static/css/my_checks_desktop.css View File

@ -67,7 +67,6 @@
}
.timeout-grace .cron-expression {
display: inline-block;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
@ -128,3 +127,8 @@ tr:hover .copy-link {
line-height: 36px;
color: #333;
}
.checks-subline-duration {
color: #888;
white-space: nowrap;
}

BIN
static/fonts/icomoon.eot View File


+ 2
- 1
static/fonts/icomoon.svg View File

@ -25,7 +25,7 @@
<glyph unicode="&#xe904;" glyph-name="sms" d="M142.769 871.774c-73.5 0-133.644-60.149-133.644-133.65v-398.151c0-73.5 60.143-133.644 133.644-133.644h56.045v-215.2l195.357 215.2 489.426 0.265c68.005 8.811 129.663 59.708 129.663 133.379v398.151c0 73.501-60.144 133.644-133.644 133.644l-736.847 0.006zM263.737 675.43c16.811-0.371 31.771-4.079 44.873-11.124 13.103-6.922 23.111-16.626 30.033-29.11 6.922-12.361 10.137-26.517 9.643-42.463l-45.608 0.188c0.865 14.091-2.291 25.033-9.46 32.82-7.046 7.787-17.735 11.865-32.073 12.236-14.462 0.247-26.7-3.155-36.712-10.201-9.889-7.046-15.697-16.561-17.427-28.551-2.472-15.698 8.035-28.059 31.521-37.083l13.905-5.004c28.306-10.136 48.083-22.069 59.331-35.789 11.248-13.597 16.194-29.724 14.834-48.389-1.236-15.204-6.246-28.492-15.022-39.864-8.776-11.248-20.95-19.966-36.524-26.147-15.451-6.18-32.511-9.143-51.176-8.896-17.676 0.124-33.745 3.831-48.207 11.124-14.339 7.293-25.339 17.366-33.002 30.221-7.664 12.979-11.184 27.687-10.566 44.127l45.796-0.182c-0.989-15.327 2.778-27.131 11.307-35.413s20.767-12.547 36.712-12.794c14.833-0.247 27.194 2.903 37.083 9.454 10.012 6.551 16.008 15.885 17.986 27.999 2.719 17.8-6.984 31.023-29.11 39.676l-15.017 5.562c-27.936 9.765-47.777 21.322-59.519 34.672s-17.058 28.867-15.946 46.543c0.989 15.327 6.181 28.797 15.575 40.417 9.518 11.743 22.002 20.766 37.453 27.070s31.89 9.273 49.318 8.902zM811.818 675.43c16.811-0.371 31.771-4.079 44.873-11.124 13.103-6.922 23.111-16.626 30.033-29.11 6.922-12.361 10.137-26.517 9.643-42.463l-45.608 0.188c0.865 14.091-2.291 25.033-9.46 32.82-7.046 7.787-17.735 11.865-32.073 12.236-14.462 0.247-26.7-3.155-36.712-10.201-9.889-7.046-15.697-16.561-17.427-28.551-2.472-15.698 8.029-28.059 31.515-37.083l13.911-5.004c28.306-10.136 48.083-22.069 59.331-35.789 11.248-13.597 16.194-29.724 14.834-48.389-1.236-15.204-6.246-28.492-15.022-39.864-8.776-11.248-20.95-19.966-36.524-26.147-15.451-6.18-32.511-9.143-51.176-8.896-17.676 0.124-33.745 3.831-48.207 11.124-14.339 7.293-25.339 17.366-33.002 30.221-7.664 12.979-11.184 27.687-10.566 44.127l45.796-0.182c-0.989-15.327 2.778-27.131 11.307-35.413s20.767-12.547 36.712-12.794c14.833-0.247 27.194 2.903 37.083 9.454 10.012 6.551 16.008 15.885 17.986 27.999 2.719 17.8-6.984 31.023-29.11 39.676l-15.017 5.562c-27.936 9.765-47.777 21.322-59.519 34.672s-17.058 28.867-15.946 46.543c0.989 15.327 6.181 28.797 15.575 40.417 9.518 11.743 22.002 20.766 37.453 27.070s31.889 9.273 49.318 8.902zM402.614 671.72h58.22l39.493-206.551 111.060 206.551h60.072l-46.72-269.963h-45.614l15.763 91.411 23.912 112.548-111.243-203.958h-33.008l-41.346 210.073-16.875-123.484-15.017-86.589h-45.608l46.908 269.963z" />
<glyph unicode="&#xe905;" glyph-name="slack" d="M421.504 469.547l44.16-131.627 136.747 45.824-44.16 131.157-136.747-46.080v0.725zM803.157 338.987l-66.347-22.229 23.040-68.693c9.301-27.733-5.76-57.813-33.536-67.157-6.4-1.92-12.16-2.859-18.56-2.688-21.803 0.64-41.643 14.421-49.28 36.224l-23.040 68.565-136.96-45.781 22.997-68.608c9.003-27.819-5.76-57.941-33.877-67.2-6.4-2.048-12.16-2.859-18.56-2.731-21.76 0.64-41.643 14.507-49.323 36.267l-22.997 69.077-66.603-22.357c-6.4-1.92-12.16-2.603-18.56-2.603-21.803 0.683-41.643 14.72-49.28 36.48-9.6 28.16 5.76 58.197 33.28 67.2l66.56 22.4-42.112 131.755-66.176-22.4c-6.016-2.048-12.16-2.816-18.261-2.731-21.12 0.683-41.6 14.421-48.683 36.181-8.917 27.733 5.76 57.771 33.963 67.157l66.56 22.187-23.040 68.48c-8.96 27.904 5.803 57.984 33.963 67.2 28.117 9.387 58.155-5.803 67.157-33.408l22.997-68.608 136.32 45.824-23.040 68.48c-8.917 27.52 5.76 57.6 33.664 67.157 27.819 9.003 57.984-5.76 67.2-33.749l23.040-69.163 66.347 21.76c27.776 9.344 57.856-5.76 67.2-33.237 9.301-27.904-5.76-57.984-33.451-67.2l-66.432-22.357 44.16-131.669 66.176 22.016c27.819 9.003 57.941-5.76 67.2-33.92 9.387-28.16-5.76-58.24-33.237-67.157l-0.469 1.237zM981.12 567.51c-105.6 351.701-258.091 433.92-609.963 328.277-351.701-105.557-433.92-257.963-328.277-609.963 105.6-351.787 257.963-433.92 609.963-328.277 351.787 105.6 433.92 257.963 328.277 609.963z" />
<glyph unicode="&#xe906;" glyph-name="telegram" d="M385.195 49.622c-30.464 0-25.301 11.563-35.797 40.491l-89.728 295.253 690.219 409.515zM385.195 49.622c23.552 0 33.92 10.752 47.147 23.595l125.483 121.899-156.629 94.464zM401.195 289.579l379.307-280.235c43.307-23.893 74.581-11.563 85.333 40.192l154.453 727.595c15.872 63.445-24.064 92.117-65.451 73.387l-906.837-349.739c-61.867-24.832-61.568-59.392-11.264-74.795l232.747-72.533 538.624 339.755c25.387 15.36 48.768 7.125 29.611-9.899z" />
<glyph unicode="&#xe907;" glyph-name="zendesk" d="M472.96 38.614h-472.96l472.96 571.008v-571.008zM1024 38.614h-472.96c0 130.688 105.771 236.501 236.501 236.501 130.688 0 236.459-105.899 236.459-236.501zM551.040 243.584v571.136h472.96l-472.96-571.136zM472.96 814.72c0-130.603-105.856-236.544-236.501-236.544-130.56 0-236.459 105.856-236.459 236.459l472.96 0.085z" />
<glyph unicode="&#xe907;" glyph-name="mattermost" d="M812.886 862.286l5.388-108.675c88.107-97.331 122.88-235.174 79.024-364.727-65.466-193.39-281.341-295.051-482.17-227.064-200.829 67.985-310.561 279.871-245.094 473.263 44.003 129.983 155.955 218.524 285.614 241.97l70.053 82.77c-218.559 5.917-424.758-129.745-498.535-347.68-90.647-267.771 52.94-558.326 320.711-648.975 267.771-90.646 558.329 52.94 648.975 320.711 73.658 217.588-7.346 450.217-183.967 578.408zM687.37 541.618l-3.711 151.926-2.976 87.421-2.013 75.737c0 0 0.422 36.521-0.854 45.102-0.268 1.806-0.838 3.275-1.511 4.559l-0.258 0.537-0.291 0.469c-1.401 2.411-3.603 4.368-6.451 5.333-2.914 0.987-5.927 0.743-8.546-0.387l-0.162-0.061c-0.311-0.141-0.608-0.299-0.907-0.463-1.241-0.604-2.503-1.387-3.738-2.585-6.226-6.042-28.077-35.308-28.077-35.308l-47.603-58.941-55.467-67.637-95.232-118.43c0 0-43.701-54.541-34.045-121.676s59.567-99.84 98.288-112.949c38.72-13.107 98.236-17.445 146.687 30.020 48.451 47.463 46.867 117.334 46.867 117.334z" />
<glyph unicode="&#xe908;" glyph-name="webhook" horiz-adv-x="1097" d="M512.168 508.059c-45.458-76.418-89.010-150.42-133.47-223.865-11.418-18.856-17.069-34.216-7.948-58.183 25.184-66.213-10.343-130.647-77.112-148.136-62.969-16.5-124.319 24.884-136.812 92.301-11.071 59.67 35.236 118.166 101.028 127.494 5.51 0.788 11.14 0.878 20.403 1.572l100.077 167.815c-62.943 62.588-100.407 135.746-92.117 226.405 5.861 64.083 31.063 119.464 77.121 164.854 88.218 86.924 222.802 100.998 326.675 34.28 99.76-64.087 145.45-188.92 106.504-295.763-29.366 7.961-58.937 15.99-91.444 24.807 12.228 59.404 3.183 112.746-36.881 158.445-26.47 30.171-60.437 45.985-99.057 51.812-77.429 11.697-153.449-38.046-176.007-114.045-25.604-86.247 13.149-156.705 119.040-209.799zM641.992 598.432c32.027-56.5 64.546-113.852 96.774-170.677 162.896 50.398 285.716-39.777 329.777-136.32 53.222-116.62 16.838-254.743-87.682-326.692-107.284-73.856-242.961-61.238-338.012 33.638 24.225 20.278 48.569 40.647 74.58 62.408 93.882-60.805 175.994-57.943 236.954 14.062 51.984 61.427 50.857 153.016-2.635 213.153-61.731 69.396-144.417 71.512-244.366 4.897-41.461 73.556-83.642 146.525-123.792 220.613-13.539 24.97-28.483 39.46-58.997 44.743-50.955 8.835-83.852 52.592-85.827 101.615-1.941 48.483 26.624 92.309 71.268 109.383 44.225 16.919 96.123 3.265 125.87-34.336 24.31-30.72 32.035-65.296 19.242-103.183-3.556-10.566-8.162-20.788-13.153-33.308zM718.539 136.564h-196.101c-18.796-77.309-59.396-139.726-129.349-179.417-54.383-30.848-112.995-41.307-175.377-31.234-114.859 18.522-208.775 121.919-217.044 238.346-9.357 131.894 81.298 249.13 202.134 275.463 8.342-30.296 16.774-60.883 25.116-91.105-110.865-56.564-149.237-127.832-118.209-216.949 27.314-78.423 104.901-121.41 189.147-104.786 86.033 16.975 129.409 88.475 124.113 203.222 81.56 0 163.188 0.844 244.756-0.416 31.851-0.501 56.44 2.802 80.433 30.883 39.503 46.204 112.215 42.035 154.76-1.602 43.479-44.597 41.397-116.354-4.614-159.070-44.392-41.217-114.524-39.015-156.058 5.398-8.535 9.152-15.261 20.021-23.706 31.268z" />
<glyph unicode="&#xe909;" glyph-name="pushbullet" d="M512 938.667c-282.768 0-512-229.232-512-512s229.232-512 512-512c282.768 0 512 229.232 512 512s-229.232 512-512 512zM308 681.599h13.336c66.932 0 66.932 0 66.932-66.932v-367.468c0-66.932 0-66.932-66.932-66.932h-13.336c-66.932 0-66.932 0-66.932 66.932v367.468c0 66.932 0 66.932 66.932 66.932zM484.164 681.599h150.148c128.712 0 210.512-129.092 210.512-252.1s-82.512-249.232-210.512-249.232h-150.148c-22.796 0-34.032 11.236-34.032 34.032v433.272c0 22.792 11.236 34.028 34.032 34.028z" />
<glyph unicode="&#xe90a;" glyph-name="email" d="M512.021 938.667c-0.002 0-0.005 0-0.008 0-282.777 0-512.013-229.236-512.013-512.013 0-0.003 0-0.006 0-0.008v0c0.020-282.762 229.248-511.979 512.013-511.979 0.003 0 0.006 0 0.008 0v0c0.002 0 0.005 0 0.008 0 282.765 0 511.993 229.216 512.013 511.977v0.002c0 0.002 0 0.005 0 0.008 0 282.777-229.236 512.013-512.013 512.013-0.003 0-0.006 0-0.008 0v0zM515.875 776.257c172.986 0 324.563-149.864 344.259-323.278 1.285-11.133 1.712-21.837 1.712-31.685 0-89.062-50.526-177.268-145.154-177.268-41.962 0-75.788 17.555-99.767 48.385-29.116-25.691-68.509-45.387-115.609-45.387-103.62 0-182.406 82.639-182.406 186.26 0 92.916 81.783 178.98 173.842 178.98 38.108 0 72.363-9.848 101.051-28.26 5.994 17.127 21.837 29.117 40.249 29.117 23.55 0 42.818-19.268 42.818-42.818v-209.81c0-14.986 18.84-29.545 39.821-29.545 39.821 0 58.661 44.959 58.661 89.919 0 146.438-126.742 266.33-259.479 266.33-155.431 0-268.471-124.601-268.471-258.622 0-142.157 105.333-264.189 255.197-264.189 30.401 0 61.23 5.995 93.772 17.127 10.276 3.425 19.268 5.994 27.832 5.994 22.694 0 34.255-17.127 34.255-39.821 0-22.266-11.989-39.393-32.542-47.1-41.534-15.843-82.639-23.55-123.317-23.55-197.821 0-340.405 164.85-340.405 350.254 0 174.271 146.010 348.969 353.679 348.969zM497.891 525.77c-50.954 0-93.344-44.103-93.344-95.913 0-51.382 37.68-96.769 93.344-96.769 53.951 0 93.772 41.962 94.628 95.485-0.427 53.951-41.962 97.197-94.628 97.197z" />
@ -39,4 +39,5 @@
<glyph unicode="&#xe912;" glyph-name="settings" d="M512 276.667c82 0 150 68 150 150s-68 150-150 150-150-68-150-150 68-150 150-150zM830 384.667l90-70c8-6 10-18 4-28l-86-148c-6-10-16-12-26-8l-106 42c-22-16-46-32-72-42l-16-112c-2-10-10-18-20-18h-172c-10 0-18 8-20 18l-16 112c-26 10-50 24-72 42l-106-42c-10-4-20-2-26 8l-86 148c-6 10-4 22 4 28l90 70c-2 14-2 28-2 42s0 28 2 42l-90 70c-8 6-10 18-4 28l86 148c6 10 16 12 26 8l106-42c22 16 46 32 72 42l16 112c2 10 10 18 20 18h172c10 0 18-8 20-18l16-112c26-10 50-24 72-42l106 42c10 4 20 2 26-8l86-148c6-10 4-22-4-28l-90-70c2-14 2-28 2-42s0-28-2-42z" />
<glyph unicode="&#xe913;" glyph-name="delete" d="M810 768.667v-86h-596v86h148l44 42h212l44-42h148zM256 128.667v512h512v-512c0-46-40-86-86-86h-340c-46 0-86 40-86 86z" />
<glyph unicode="&#xe914;" glyph-name="pagerteam" horiz-adv-x="981" d="M484.289 621.868c-67.542-1.322-134.652-26.387-187.221-70.263-56.96-47.541-94.861-114.277-106.117-186.879-3.371-21.755-3.183-14.333-3.367-130.672-0.144-87.121-0.309-106.544-0.894-107.030-31.25-1.325-94.191-0.875-94.191-0.875s-0.436-123.022-0.285-189.922l799.764-0.228-0.323 189.561-94.818 0.856-0.228 104.062c-0.235 112.101-0.168 109.401-3.138 130.159-1.569 10.965-4.997 27.461-7.798 37.528-26.226 94.196-95.127 169.766-186.194 204.207-32.295 12.214-64.792 18.42-101.666 19.439-4.502 0.124-9.021 0.145-13.524 0.057zM504.47 546.85c51.456 0 106.155-17.857 149.103-56.434s72.159-98.866 70.871-174.553c-1.391-48.523-73.925-47.248-73.61 1.293 0.97 57.004-18.643 93.453-46.506 118.48s-65.885 37.604-99.859 37.604c-49.962-0.897-49.962 74.507 0 73.61zM38.407 694.94c-0.476 0.022-1.035 0.035-1.596 0.035-20.33 0-36.811-16.481-36.811-36.811 0-13.706 7.49-25.662 18.6-31.998l0.181-0.095 156.359-91.3c5.323-3.162 11.735-5.030 18.583-5.030 20.347 0 36.841 16.494 36.841 36.841 0 13.498-7.259 25.301-18.087 31.717l-0.171 0.094-156.431 91.3c-4.992 3.055-10.98 4.971-17.393 5.245l-0.076 0.003zM232.796 876.532c-19.71-0.785-35.391-16.953-35.391-36.784 0-6.961 1.932-13.471 5.29-19.023l-0.092 0.165 87.202-150.968c6.296-11.786 18.515-19.67 32.577-19.67 20.33 0 36.811 16.481 36.811 36.811 0 7.295-2.122 14.094-5.782 19.814l0.088-0.148-87.13 150.968c-6.42 11.337-18.401 18.863-32.139 18.863-0.504 0-1.006-0.010-1.505-0.030l0.072 0.002zM492.029 959.996c-20.081-0.324-36.236-16.679-36.236-36.807 0-0.177 0.001-0.354 0.004-0.531v0.027-187.2c-0.002-0.155-0.004-0.338-0.004-0.521 0-20.33 16.481-36.811 36.811-36.811s36.811 16.481 36.811 36.811c0 0.183-0.001 0.366-0.004 0.548v-0.028 187.2c0.002 0.15 0.003 0.327 0.003 0.504 0 20.33-16.481 36.811-36.811 36.811-0.202 0-0.404-0.002-0.605-0.005h0.030zM945.507 690.842c-0.29 0.008-0.632 0.013-0.974 0.013-7.054 0-13.644-1.984-19.243-5.424l0.16 0.091-156.431-91.3c-11.571-6.357-19.282-18.463-19.282-32.37 0-20.33 16.481-36.811 36.811-36.811 7.253 0 14.016 2.098 19.716 5.719l-0.15-0.089 156.431 91.3c11.271 6.437 18.746 18.382 18.746 32.073 0 19.969-15.9 36.224-35.731 36.795l-0.053 0.001zM747.38 872.362c-0.089 0.001-0.193 0.001-0.298 0.001-13.728 0-25.7-7.514-32.029-18.654l-0.095-0.182-87.13-150.895c-3.572-5.572-5.694-12.371-5.694-19.666 0-20.33 16.481-36.811 36.811-36.811 14.061 0 26.281 7.884 32.48 19.472l0.096 0.198 87.202 150.895c3.256 5.381 5.182 11.882 5.182 18.832 0 20.23-16.319 36.648-36.51 36.81h-0.015z" />
<glyph unicode="&#xe915;" glyph-name="apprise" horiz-adv-x="1103" d="M419.207-63.539c-36.821 10.251-62.633 68.381-78.184 96.84s-44.871 79.948-44.871 79.948l144.118 77.477c0 0 60.549-101.414 89.638-152.536 19.503-48.548-37.228-71.026-70.145-90.61-12.11-6.879-26.274-13.39-40.556-11.119zM139.125 137.497c-83.563 6.246-150.932 89.762-137.383 173.161 0.044 28.578 33.377 106.495 61.177 57.277 41.786-74.223 86.086-147.054 127.101-221.634-9.907-13.558-36.039-7.416-50.895-8.805zM256.767 178.268c-51.040 82.94-97.903 168.519-147.818 252.248 31.046 22.803 61.092 39.433 87.762 60.464 113.646 71.464 237.133 203.369 288.762 347.602 13.484 45.244 66.37 79.001 93.522 38.262 100.485-174.847 203.317-348.42 302.511-523.936 17.51-66.627-63.993-53.787-103.86-44.62-133.333 17.402-276.261 7.503-394.63-61.032-41.186-22.873-80.753-48.963-122.811-70.028l-3.438 1.038zM1008.674 488.667c-59.824 20.665 2.515 73.201 14.237 107.157 44.133 94.328 5.38 215.539-83.422 269.141-47.146 29.856-104.57 37.992-159.139 29.894-49.006 8.783-26.794 61.723 19.937 63.521 135.186 15.694 273.035-84.419 296.526-219.010 18.169-86.287-5.187-184.47-69.789-246.399-5.822-2.236-11.938-5.013-18.349-4.303zM874.499 536.119c-56.018 26.015 12.996 72.844 8.156 111.868 9.085 66.073-58.288 124.609-122.441 110.005-37.378 8.906-34.985 58.261 13.385 63.11 100.043 8.227 190.553-92.3 170.885-191.055-6.546-34.584-27.598-94.615-69.985-93.926z" />
</font></defs></svg>

BIN
static/fonts/icomoon.ttf View File


BIN
static/fonts/icomoon.woff View File


BIN
static/img/integrations/apprise.png View File

Before After
Width: 133  |  Height: 133  |  Size: 28 KiB

BIN
static/img/integrations/mattermost.png View File

Before After
Width: 96  |  Height: 96  |  Size: 3.1 KiB

BIN
static/img/integrations/setup_mattermost_1.png View File

Before After
Width: 523  |  Height: 401  |  Size: 37 KiB

BIN
static/img/integrations/setup_mattermost_2.png View File

Before After
Width: 959  |  Height: 747  |  Size: 46 KiB

BIN
static/img/integrations/setup_mattermost_3.png View File

Before After
Width: 965  |  Height: 443  |  Size: 32 KiB

BIN
static/img/integrations/whatsapp.png View File

Before After
Width: 107  |  Height: 107  |  Size: 20 KiB

+ 2
- 1
static/js/add_trello.js View File

@ -15,9 +15,10 @@ $(function() {
$("integration-settings").text("Loading...");
token = tokenMatch[1];
var base = document.getElementById("base-url").getAttribute("href").slice(0, -1);
var csrf = $('input[name=csrfmiddlewaretoken]').val();
$.ajax({
url: "/integrations/add_trello/settings/",
url: base + "/integrations/add_trello/settings/",
type: "post",
headers: {"X-CSRFToken": csrf},
data: {token: token},


+ 79
- 30
static/js/billing.js View File

@ -1,45 +1,92 @@
$(function () {
var clientTokenRequested = false;
function requestClientToken() {
if (!clientTokenRequested) {
clientTokenRequested = true;
$.getJSON("/pricing/get_client_token/", setupDropin);
var preloadedToken = null;
function getToken(callback) {
if (preloadedToken) {
callback(preloadedToken);
} else {
$.getJSON("/pricing/token/", function(response) {
preloadedToken = response.client_token;
callback(response.client_token);
});
}
}
function setupDropin(data) {
braintree.dropin.create({
authorization: data.client_token,
container: "#dropin",
paypal: { flow: 'vault' }
}, function(createErr, instance) {
$("#payment-form-submit").click(function() {
instance.requestPaymentMethod(function (requestPaymentMethodErr, payload) {
$("#pmm-nonce").val(payload.nonce);
$("#payment-form").submit();
// Preload client token:
if ($("#billing-address").length) {
getToken(function(token){});
}
function getAmount(planId) {
return planId.substr(1);
}
function showPaymentMethodForm(planId) {
$("#plan-id").val(planId);
$("#nonce").val("");
if (planId == "") {
// Don't need a payment method when switching to the free plan
// -- can submit the form right away:
$("#update-subscription-form").submit();
return;
}
$("#payment-form-submit").prop("disabled", true);
$("#payment-method-modal").modal("show");
getToken(function(token) {
braintree.dropin.create({
authorization: token,
container: "#dropin",
threeDSecure: {
amount: getAmount(planId),
},
paypal: { flow: 'vault' },
preselectVaultedPaymentMethod: false
}, function(createErr, instance) {
$("#payment-form-submit").off().click(function() {
instance.requestPaymentMethod(function (err, payload) {
$("#payment-method-modal").modal("hide");
$("#please-wait-modal").modal("show");
$("#nonce").val(payload.nonce);
$("#update-subscription-form").submit();
});
});
$("#payment-method-modal").off("hidden.bs.modal").on("hidden.bs.modal", function() {
instance.teardown();
});
}).prop("disabled", false);
instance.on("paymentMethodRequestable", function() {
$("#payment-form-submit").prop("disabled", false);
});
instance.on("noPaymentMethodRequestable", function() {
$("#payment-form-submit").prop("disabled", true);
});
});
});
}
$("#update-payment-method").hover(requestClientToken);
$("#change-plan-btn").click(function() {
$("#change-billing-plan-modal").modal("hide");
showPaymentMethodForm(this.dataset.planId);
});
$("#update-payment-method").click(function() {
requestClientToken();
$("#payment-form").attr("action", this.dataset.action);
$("#payment-form-submit").text("Update Payment Method");
$("#payment-method-modal").modal("show");
showPaymentMethodForm($("#old-plan-id").val());
});
$("#billing-history").load( "/accounts/profile/billing/history/" );
$("#billing-address").load( "/accounts/profile/billing/address/", function() {
$("#billing-history").load("/accounts/profile/billing/history/");
$("#billing-address").load("/accounts/profile/billing/address/", function() {
$("#billing-address input").each(function(idx, obj) {
$("#" + obj.name).val(obj.value);
});
});
$("#payment-method").load( "/accounts/profile/billing/payment_method/", function() {
$("#payment-method").load("/accounts/profile/billing/payment_method/", function() {
$("#next-billing-date").text($("#nbd").val());
});
@ -94,9 +141,7 @@ $(function () {
if ($("#plan-business-plus").hasClass("selected")) {
planId = period == "monthly" ? "P80" : "Y768";
}
$("#plan-id").val(planId);
if (planId == $("#old-plan-id").val()) {
$("#change-plan-btn")
.attr("disabled", "disabled")
@ -105,10 +150,14 @@ $(function () {
} else {
var caption = "Change Billing Plan";
if (planId) {
caption += " And Pay $" + planId.substr(1) + " Now";
var amount = planId.substr(1);
caption += " And Pay $" + amount + " Now";
}
$("#change-plan-btn").removeAttr("disabled").text(caption);
$("#change-plan-btn")
.removeAttr("disabled")
.text(caption)
.attr("data-plan-id", planId);
}
}
updateChangePlanForm();


+ 7
- 6
static/js/checks.js View File

@ -1,8 +1,9 @@
$(function () {
var base = document.getElementById("base-url").getAttribute("href").slice(0, -1);
$(".my-checks-name").click(function() {
var code = $(this).closest("tr.checks-row").attr("id");
var url = "/checks/" + code + "/name/";
var url = base + "/checks/" + code + "/name/";
$("#update-name-form").attr("action", url);
$("#update-name-input").val(this.dataset.name);
@ -31,7 +32,7 @@ $(function () {
var checkCode = $(this).closest("tr.checks-row").attr("id");
var channelCode = $("#ch-" + idx).data("code");
var url = "/checks/" + checkCode + "/channels/" + channelCode + "/enabled";
var url = base + "/checks/" + checkCode + "/channels/" + channelCode + "/enabled";
$.ajax({
url: url,
@ -52,12 +53,12 @@ $(function () {
$('#ping-details-modal').modal("show");
var code = $(this).closest("tr.checks-row").attr("id");
var lastPingUrl = "/checks/" + code + "/last_ping/";
var lastPingUrl = base + "/checks/" + code + "/last_ping/";
$.get(lastPingUrl, function(data) {
$("#ping-details-body" ).html(data);
});
var logUrl = "/checks/" + code + "/log/";
var logUrl = base + "/checks/" + code + "/log/";
$("#ping-details-log").attr("href", logUrl);
return false;
@ -79,7 +80,7 @@ $(function () {
// Update hash
if (window.history && window.history.replaceState) {
var url = $("#checks-table").data("list-url");;
var url = $("#checks-table").data("list-url");
if (qs.length) {
url += "?" + $.param(qs);
}
@ -132,7 +133,7 @@ $(function () {
$(".show-log").click(function(e) {
var code = $(this).closest("tr.checks-row").attr("id");
var url = "/checks/" + code + "/details/";
var url = base + "/checks/" + code + "/details/";
window.location = url;
return false;
});


+ 0
- 204
static/js/collapse-native.js View File

@ -1,204 +0,0 @@
// Native Javascript for Bootstrap 3 | Collapse
// by dnp_theme
(function(factory){
// CommonJS/RequireJS and "native" compatibility
if(typeof module !== "undefined" && typeof exports == "object") {
// A commonJS/RequireJS environment
if(typeof window != "undefined") {
// Window and document exist, so return the factory's return value.
module.exports = factory();
} else {
// Let the user give the factory a Window and Document.
module.exports = factory;
}
} else {
// Assume a traditional browser.
window.Collapse = factory();
}
})(function(){
// COLLAPSE DEFINITION
// ===================
var Collapse = function( element, options ) {
options = options || {};
this.btn = typeof element === 'object' ? element : document.querySelector(element);
this.accordion = null;
this.collapse = null;
this.duration = 300; // default collapse transition duration
this.options = {};
this.options.duration = /ie/.test(document.documentElement.className) ? 0 : (options.duration || this.duration);
this.init();
}
// COLLAPSE METHODS
// ================
Collapse.prototype = {
init : function() {
this.actions();
this.btn.addEventListener('click', this.toggle, false);
// allows the collapse to expand
// ** when window gets resized
// ** or via internal clicks handers such as dropwowns or any other
document.addEventListener('click', this.update, false);
window.addEventListener('resize', this.update, false)
},
actions : function() {
var self = this;
this.toggle = function(e) {
self.btn = self.getTarget(e).btn;
self.collapse = self.getTarget(e).collapse;
if (!/in/.test(self.collapse.className)) {
self.open(e)
} else {
self.close(e)
}
},
this.close = function(e) {
e.preventDefault();
self.btn = self.getTarget(e).btn;
self.collapse = self.getTarget(e).collapse;
self._close(self.collapse);
self.btn.className = self.btn.className.replace(' collapsed','');
},
this.open = function(e) {
e.preventDefault();
self.btn = self.getTarget(e).btn;
self.collapse = self.getTarget(e).collapse;
self.accordion = self.btn.getAttribute('data-parent') && self.getClosest(self.btn, self.btn.getAttribute('data-parent'));
self._open(self.collapse);
self.btn.className += ' collapsed';
if ( self.accordion !== null ) {
var active = self.accordion.querySelectorAll('.collapse.in'), al = active.length, i = 0;
for (i;i<al;i++) {
if ( active[i] !== self.collapse) self._close(active[i]);
}
}
},
this._open = function(c) {
c.className += ' in';
c.style.height = 0;
c.style.overflow = 'hidden';
c.setAttribute('area-expanded','true');
// the collapse MUST have a childElement div to wrap them all inside, just like accordion/well
var oh = this.getMaxHeight(c).oh, br = this.getMaxHeight(c).br;
c.style.height = oh + br + 'px';
setTimeout(function() {
c.style.overflow = '';
}, self.options.duration)
},
this._close = function(c) {
c.style.overflow = 'hidden';
c.style.height = 0;
setTimeout(function() {
c.className = c.className.replace(' in','');
c.style.overflow = '';
c.setAttribute('area-expanded','false');
}, self.options.duration)
},
this.update = function(e) {
var evt = e.type, tg = e.target, closest = self.getClosest(tg,'.collapse'),
itms = document.querySelectorAll('.collapse.in'), i = 0, il = itms.length;
for (i;i<il;i++) {
var itm = itms[i], oh = self.getMaxHeight(itm).oh, br = self.getMaxHeight(itm).br;
if ( evt === 'resize' && !/ie/.test(document.documentElement.className) ){
setTimeout(function() {
itm.style.height = oh + br + 'px';
}, self.options.duration)
} else if ( evt === 'click' && closest === itm ) {
itm.style.height = oh + br + 'px';
}
}
},
this.getMaxHeight = function(l) { // get collapse trueHeight and border
var t = l.children[0];
var cs = l.currentStyle || window.getComputedStyle(l);
return {
oh : getOuterHeight(t),
br : parseInt(cs.borderTop||0) + parseInt(cs.borderBottom||0)
}
},
this.getTarget = function(e) {
var t = e.currentTarget || e.srcElement,
h = t.href && t.getAttribute('href').replace('#',''),
d = t.getAttribute('data-target') && ( t.getAttribute('data-target') ),
id = h || ( d && /#/.test(d)) && d.replace('#',''),
cl = (d && d.charAt(0) === '.') && d, //the navbar collapse trigger targets a class
c = id && document.getElementById(id) || cl && document.querySelector(cl);
return {
btn : t,
collapse : c
}
},
this.getClosest = function (el, s) { //el is the element and s the selector of the closest item to find
// source http://gomakethings.com/climbing-up-and-down-the-dom-tree-with-vanilla-javascript/
var f = s.charAt(0);
for ( ; el && el !== document; el = el.parentNode ) {// Get closest match
if ( f === '.' ) {// If selector is a class
if ( document.querySelector(s) !== undefined ) { return el; }
}
if ( f === '#' ) { // If selector is an ID
if ( el.id === s.substr(1) ) { return el; }
}
}
return false;
}
}
}
var getOuterHeight = function (el) {
var s = el && el.currentStyle || window.getComputedStyle(el),
mtp = /px/.test(s.marginTop) ? Math.round(s.marginTop.replace('px','')) : 0,
mbp = /px/.test(s.marginBottom) ? Math.round(s.marginBottom.replace('px','')) : 0,
mte = /em/.test(s.marginTop) ? Math.round(s.marginTop.replace('em','') * parseInt(s.fontSize)) : 0,
mbe = /em/.test(s.marginBottom) ? Math.round(s.marginBottom.replace('em','') * parseInt(s.fontSize)) : 0;
return el.offsetHeight + parseInt( mtp ) + parseInt( mbp ) + parseInt( mte ) + parseInt( mbe ) //we need an accurate margin value
}
// COLLAPSE DATA API
// =================
var Collapses = document.querySelectorAll('[data-toggle="collapse"]'), i = 0, cll = Collapses.length;
for (i;i<cll;i++) {
var item = Collapses[i], options = {};
options.duration = item.getAttribute('data-duration');
new Collapse(item,options);
}
//we must add the height to the pre-opened collapses
window.addEventListener('load', function() {
var openedCollapses = document.querySelectorAll('.collapse'), i = 0, ocl = openedCollapses.length;
for (i;i<ocl;i++) {
var oc = openedCollapses[i];
if (/in/.test(oc.className)) {
var s = oc.currentStyle || window.getComputedStyle(oc);
var oh = getOuterHeight(oc.children[0]);
var br = parseInt(s.borderTop||0) + parseInt(s.borderBottom||0);
oc.style.height = oh + br + 'px';
}
}
});
return Collapse;
});

+ 12
- 4
static/js/details.js View File

@ -6,6 +6,11 @@ $(function () {
return false;
});
$("#new-check-alert a").click(function() {
$("#" + this.dataset.target).click();
return false;
});
$("#edit-desc").click(function() {
$('#update-name-modal').modal("show");
$("#update-desc-input").focus();
@ -44,8 +49,7 @@ $(function () {
});
})
var code = document.getElementById("edit-timeout").dataset.code;
var statusUrl = "/checks/" + code + "/status/";
var statusUrl = document.getElementById("edit-timeout").dataset.statusUrl;
var lastStatusText = "";
var lastUpdated = "";
adaptiveSetInterval(function() {
@ -66,6 +70,10 @@ $(function () {
switchDateFormat(lastFormat);
}
if (data.downtimes) {
$("#downtimes").html(data.downtimes);
}
if (document.title != data.title) {
document.title = data.title;
}
@ -108,10 +116,10 @@ $(function () {
lastFormat = format;
$("#log tr").each(function(index, row) {
var dt = moment(row.getAttribute("data-dt"));
format == "local" ? dt.local() : dt.utc();
format == "local" ? dt.local() : dt.tz(format);
$(".date", row).text(dt.format("MMM D"));
$(".time", row).text(dt.format("HH:mm"));
$(".time", row).text(dt.format("HH:mm"));
})
// The table is initially hidden to avoid flickering as we convert dates.


+ 4
- 4
static/js/log.js View File

@ -4,7 +4,7 @@ $(function () {
$('#ping-details-modal').modal("show");
$.get(this.dataset.url, function(data) {
$("#ping-details-body" ).html(data);
$("#ping-details-body").html(data);
});
return false;
@ -13,11 +13,11 @@ $(function () {
function switchDateFormat(format) {
$("#log tr").each(function(index, row) {
var dt = moment(row.getAttribute("data-dt"));
format == "local" ? dt.local() : dt.utc();
format == "local" ? dt.local() : dt.tz(format);
$(".date", row).text(dt.format("MMM D"));
$(".time", row).text(dt.format("HH:mm"));
})
$(".time", row).text(dt.format("HH:mm"));
})
}
$("#format-switcher").click(function(ev) {


+ 1
- 0
static/js/moment-timezone-with-data-10-year-range.min.js
File diff suppressed because it is too large
View File


+ 1
- 7
static/js/moment.min.js
File diff suppressed because it is too large
View File


+ 4
- 1
static/js/signup.js View File

@ -1,16 +1,19 @@
$(function () {
$("#signup-go").on("click", function() {
var base = document.getElementById("base-url").getAttribute("href").slice(0, -1);
var email = $("#signup-email").val();
var token = $('input[name=csrfmiddlewaretoken]').val();
$("#signup-go").prop("disabled", true);
$.ajax({
url: "/accounts/signup/",
url: base + "/accounts/signup/",
type: "post",
headers: {"X-CSRFToken": token},
data: {"identity": email},
success: function(data) {
$("#signup-result").html(data).show();
$("#signup-go").prop("disabled", false);
}
});


+ 0
- 117
static/js/tab-native.js View File

@ -1,117 +0,0 @@
// Native Javascript for Bootstrap 3 | Tab
// by dnp_theme
(function(factory){
// CommonJS/RequireJS and "native" compatibility
if(typeof module !== "undefined" && typeof exports == "object") {
// A commonJS/RequireJS environment
if(typeof window != "undefined") {
// Window and document exist, so return the factory's return value.
module.exports = factory();
} else {
// Let the user give the factory a Window and Document.
module.exports = factory;
}
} else {
// Assume a traditional browser.
window.Tab = factory();
}
})(function(){
// TAB DEFINITION
// ===================
var Tab = function( element,options ) {
options = options || {};
this.tab = typeof element === 'object' ? element : document.querySelector(element);
this.tabs = this.tab.parentNode.parentNode;
this.dropdown = this.tabs.querySelector('.dropdown');
if ( this.tabs.classList.contains('dropdown-menu') ) {
this.dropdown = this.tabs.parentNode;
this.tabs = this.tabs.parentNode.parentNode;
}
this.options = {};
// default tab transition duration
this.duration = 150;
this.options.duration = document.documentElement.classList.contains('ie') ? 0 : (options.duration || this.duration);
this.init();
}
// TAB METHODS
// ================
Tab.prototype = {
init : function() {
var self = this;
self.actions();
self.tab.addEventListener('click', self.action, false);
},
actions : function() {
var self = this;
this.action = function(e) {
e = e || window.e;
var next = e.target; //the tab we clicked is now the next tab
var nextContent = document.getElementById(next.getAttribute('href').replace('#','')); //this is the actual object, the next tab content to activate
var activeTab = self.getActiveTab();
var activeContent = self.getActiveContent();
//toggle "active" class name
activeTab.classList.remove('active');
next.parentNode.classList.add('active');
//handle dropdown menu "active" class name
if ( !self.tab.parentNode.parentNode.classList.contains('dropdown-menu')){
self.dropdown && self.dropdown.classList.remove('active');
} else {
self.dropdown && self.dropdown.classList.add('active');
}
//1. hide current active content first
activeContent.classList.remove('in');
setTimeout(function() {
//2. toggle current active content from view
activeContent.classList.remove('active');
nextContent.classList.add('active');
}, self.options.duration);
setTimeout(function() {
//3. show next active content
nextContent.classList.add('in');
}, self.options.duration*2);
e.preventDefault();
},
this.getActiveTab = function() {
var activeTabs = self.tabs.querySelectorAll('.active');
if ( activeTabs.length === 1 && !activeTabs[0].classList.contains('dropdown') ) {
return activeTabs[0]
} else if ( activeTabs.length > 1 ) {
return activeTabs[activeTabs.length-1]
}
},
this.getActiveContent = function() {
var a = self.getActiveTab().getElementsByTagName('A')[0].getAttribute('href').replace('#','');
return a && document.getElementById(a)
}
}
}
// TAB DATA API
// =================
var Tabs = document.querySelectorAll("[data-toggle='tab'], [data-toggle='pill']"), tbl = Tabs.length, i=0;
for ( i;i<tbl;i++ ) {
var tab = Tabs[i], options = {};
options.duration = tab.getAttribute('data-duration') && tab.getAttribute('data-duration') || false;
new Tab(tab,options);
}
return Tab;
});

+ 6
- 29
static/js/update-timeout-modal.js View File

@ -1,11 +1,13 @@
$(function () {
var base = document.getElementById("base-url").getAttribute("href").slice(0, -1);
$(".timeout-grace").click(function() {
var code = $(this).closest("tr.checks-row").attr("id");
if (!code) {
code = this.dataset.code;
}
var url = "/checks/" + code + "/timeout/";
var url = base + "/checks/" + code + "/timeout/";
$("#update-timeout-form").attr("action", url);
$("#update-cron-form").attr("action", url);
@ -28,32 +30,6 @@ $(function () {
return false;
});
function init(code, kind, timeout, grace, schedule, tz) {
var url = "/checks/" + code + "/timeout/";
$("#update-timeout-form").attr("action", url);
$("#update-cron-form").attr("action", url);
// Simple
periodSlider.noUiSlider.set(timeout);
graceSlider.noUiSlider.set(grace);
// Cron
currentPreviewHash = "";
$("#cron-preview").html("<p>Updating...</p>");
$("#schedule").val(schedule);
$("#tz").selectpicker("val", tz);
var minutes = parseInt(grace / 60);
$("#update-timeout-grace-cron").val(minutes);
updateCronPreview();
kind == "simple" ? showSimple() : showCron();
$('#update-timeout-modal').modal({"show":true, "backdrop":"static"});
return false;
}
var MINUTE = {name: "minute", nsecs: 60};
var HOUR = {name: "hour", nsecs: MINUTE.nsecs * 60};
var DAY = {name: "day", nsecs: HOUR.nsecs * 24};
@ -166,7 +142,7 @@ $(function () {
var token = $('input[name=csrfmiddlewaretoken]').val();
$.ajax({
url: "/checks/cron_preview/",
url: base + "/checks/cron_preview/",
type: "post",
headers: {"X-CSRFToken": token},
data: {schedule: schedule, tz: tz},
@ -187,5 +163,6 @@ $(function () {
$(".kind-cron").click(showCron);
$("#schedule").on("keyup", updateCronPreview);
$("#tz").on("change", updateCronPreview);
});

+ 130
- 127
templates/accounts/billing.html View File

@ -76,16 +76,13 @@
{% endif %}
</div>
{% if sub.subscription_id %}
<div class="panel panel-{{ payment_method_status }}">
<div class="panel-body settings-block">
<h2>Payment Method</h2>
{% if sub.payment_method_token %}
<p id="payment-method">
<span class="loading">loading…</span>
</p>
{% else %}
<p id="payment-method-missing" class="billing-empty">Not set</p>
{% endif %}
<button
id="update-payment-method"
class="btn btn-default pull-right">
@ -97,6 +94,7 @@
</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="col-sm-6">
<div class="panel panel-{{ address_status }}">
@ -170,110 +168,104 @@
<div id="change-billing-plan-modal" class="modal">
<div class="modal-dialog">
{% if sub.payment_method_token and sub.address_id %}
<form method="post" class="form-horizontal" autocomplete="off" action="{% url 'hc-set-plan' %}">
{% csrf_token %}
<input type="hidden" id="old-plan-id" value="{{ sub.plan_id }}">
<input type="hidden" id="plan-id" name="plan_id" value="{{ sub.plan_id }}">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button>
<h4>Change Billing Plan</h4>
</div>
<div class="modal-body">
<div class="row">
<div class="col-sm-4">
<div id="plan-hobbyist" class="panel plan {% if sub.plan_id == "" %}selected{% endif %}">
<div class="marker">Selected Plan</div>
<h2>Hobbyist</h2>
<ul>
<li>Checks: 20</li>
<li>Team members: 3</li>
<li>Log entries: 100</li>
</ul>
<h3>Free</h3>
</div>
</div>
<div class="col-sm-4">
<div id="plan-business" class="panel plan {% if sub.plan_id == "P20" or sub.plan_id == "Y192" %}selected{% endif %}">
<div class="marker">Selected Plan</div>
<h2>Business</h2>
<ul>
<li>Checks: 100</li>
<li>Team members: 10</li>
<li>Log entries: 1000</li>
</ul>
<h3>
<span id="business-price"></span>
<small>/ month</small>
</h3>
</div>
{% if sub.address_id %}
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button>
<h4>Change Billing Plan</h4>
</div>
<div class="modal-body">
<div class="row">
<div class="col-sm-4">
<div id="plan-hobbyist" class="panel plan {% if sub.plan_id == "" %}selected{% endif %}">
<div class="marker">Selected Plan</div>
<h2>Hobbyist</h2>
<ul>
<li>Checks: 20</li>
<li>Team members: 3</li>
<li>Log entries: 100</li>
</ul>
<h3>Free</h3>
</div>
<div class="col-sm-4">
<div id="plan-business-plus" class="panel plan {% if sub.plan_id == "P80" or sub.plan_id == "Y768" %}selected{% endif %}">
<div class="marker">Selected Plan</div>
<h2>Business Plus</h2>
<ul>
<li>Checks: 1000</li>
<li>Team members: Unlimited</li>
<li>Log entries: 1000</li>
</ul>
<h3>
<span id="business-plus-price"></span>
<small>/ month</small>
</h3>
</div>
</div>
<div class="col-sm-4">
<div id="plan-business" class="panel plan {% if sub.plan_id == "P20" or sub.plan_id == "Y192" %}selected{% endif %}">
<div class="marker">Selected Plan</div>
<h2>Business</h2>
<ul>
<li>Checks: 100</li>
<li>Team members: 10</li>
<li>Log entries: 1000</li>
</ul>
<h3>
<span id="business-price"></span>
<small>/ month</small>
</h3>
</div>
</div>
<div class="row">
<div id="billing-periods" class="col-sm-6">
<p>Billing Period</p>
<label class="radio-container">
<input
id="billing-monthly"
type="radio"
name="billing_period"
value="monthly"
{% if sub.plan_id == "Y192" or sub.plan_id == "Y768" %}{% else %}checked{% endif %}>
<span class="radiomark"></span>
Monthly
</label>
<label class="radio-container">
<input
id="billing-annual"
type="radio"
name="billing_period"
value="annual"
{% if sub.plan_id == "Y192" or sub.plan_id == "Y768" %} checked {% endif %}>
<span class="radiomark"></span>
Annual, 20% off
</label>
<div class="col-sm-4">
<div id="plan-business-plus" class="panel plan {% if sub.plan_id == "P80" or sub.plan_id == "Y768" %}selected{% endif %}">
<div class="marker">Selected Plan</div>
<h2>Business Plus</h2>
<ul>
<li>Checks: 1000</li>
<li>Team members: Unlimited</li>
<li>Log entries: 1000</li>
</ul>
<h3>
<span id="business-plus-price"></span>
<small>/ month</small>
</h3>
</div>
</div>
</div>
<div class="text-warning">
<strong>No proration.</strong> We currently do not
support proration when changing billing plans.
Changing the plan starts a new billing cycle
and charges your payment method.
<div class="row">
<div id="billing-periods" class="col-sm-6">
<p>Billing Period</p>
<label class="radio-container">
<input
id="billing-monthly"
type="radio"
name="billing_period"
value="monthly"
{% if sub.plan_id == "Y192" or sub.plan_id == "Y768" %}{% else %}checked{% endif %}>
<span class="radiomark"></span>
Monthly
</label>
<label class="radio-container">
<input
id="billing-annual"
type="radio"
name="billing_period"
value="annual"
{% if sub.plan_id == "Y192" or sub.plan_id == "Y768" %} checked {% endif %}>
<span class="radiomark"></span>
Annual, 20% off
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button id="change-plan-btn" type="submit" class="btn btn-primary" disabled="disabled">
Change Billing Plan
</button>
<div class="text-warning">
<strong>No proration.</strong> We currently do not
support proration when changing billing plans.
Changing the plan starts a new billing cycle
and charges your payment method.
</div>
</div>
</form>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button id="change-plan-btn" type="button" class="btn btn-primary" disabled="disabled">
Change Billing Plan
</button>
</div>
</div>
{% else %}
<div class="modal-content">
<div class="modal-header">
@ -281,14 +273,6 @@
<h4>Some details are missing…</h4>
</div>
<div class="modal-body">
{% if not sub.payment_method_token %}
<div id="no-payment-method">
<h4>No payment method.</h4>
<p>Please add a payment method before changing the billing
plan.
</p>
</div>
{% endif %}
{% if not sub.address_id %}
<div id="no-billing-address">
<h4>Country not specified.</h4>
@ -315,28 +299,23 @@
<div id="payment-method-modal" class="modal pm-modal">
<div class="modal-dialog">
<form id="payment-form" method="post" action="{% url 'hc-payment-method' %}">
{% csrf_token %}
<input id="pmm-nonce" type="hidden" name="payment_method_nonce" />
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button>
<h4>Payment Method</h4>
</div>
<div class="modal-body">
<div id="dropin"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Cancel
</button>
<button id="payment-form-submit" type="button" class="btn btn-primary" disabled>
Confirm Payment Method
</button>
</div>
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button>
<h4>Payment Method</h4>
</div>
</form>
<div class="modal-body">
<div id="dropin"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Cancel
</button>
<button id="payment-form-submit" type="button" class="btn btn-primary" disabled>
Confirm Payment Method
</button>
</div>
</div>
</div>
</div>
@ -510,11 +489,35 @@
</div>
</div>
<div id="please-wait-modal" class="modal pm-modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4>Payment Method</h4>
</div>
<div class="modal-body">
Processing, please wait&hellip;
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" disabled>
Confirm Payment Method
</button>
</div>
</div>
</div>
</div>
<form id="update-subscription-form" method="post" action="{% url 'hc-update-subscription' %}">
{% csrf_token %}
<input id="nonce" type="hidden" name="nonce" />
<input type="hidden" id="old-plan-id" value="{{ sub.plan_id }}">
<input id="plan-id" type="hidden" name="plan_id" />
</form>
{% endblock %}
{% block scripts %}
<script src="https://js.braintreegateway.com/web/dropin/1.17.1/js/dropin.min.js"></script>
<script src="https://js.braintreegateway.com/web/dropin/1.20.0/js/dropin.min.js"></script>
{% compress js %}
<script src="{% static 'js/jquery-2.1.4.min.js' %}"></script>
<script src="{% static 'js/bootstrap.min.js' %}"></script>


+ 5
- 0
templates/accounts/login.html View File

@ -89,17 +89,22 @@
</div>
</div>
{% if registration_open %}
<div class="row">
<div id="login-signup-cta" class="col-sm-12 text-center">
Don't have an account?
<a href="#" data-toggle="modal" data-target="#signup-modal">Sign Up</a>
</div>
</div>
{% endif %}
</div>
</div>
{% if registration_open %}
{% include "front/signup_modal.html" %}
{% endif %}
{% endblock %}
{% block scripts %}


+ 12
- 2
templates/base.html View File

@ -1,4 +1,4 @@
<!DOCTYPE html>{% load compress staticfiles hc_extras %}
<!DOCTYPE html>{% load compress static hc_extras %}
<html lang="en">
<head>
<meta charset="utf-8">
@ -67,7 +67,7 @@
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/">
<a id="base-url" class="navbar-brand" href="{% url 'hc-index' %}">
{% if request.user.is_authenticated and project %}
{{ project }}
<span class="caret"></span>
@ -151,6 +151,16 @@
{% elif page != "login" %}
<li><a href="{% url 'hc-login' %}">Sign In</a></li>
{% endif %}
{% if registration_open %}
{% if page == "welcome" or page == "login" %}
<li>
<a id="nav-sign-up" href="#" data-toggle="modal" data-target="#signup-modal">
<span>Sign Up</span>
</a>
</li>
{% endif %}
{% endif %}
</ul>
</div>


+ 13
- 11
templates/emails/report-body-html.html View File

@ -15,30 +15,32 @@ Hello,<br />
{% else %}
{{ num_down }} checks are currently <strong>DOWN</strong>.
{% endif %}
{% else %}
This is a monthly report sent by <a href="{% site_root %}">{% site_name %}</a>.
{% endif %}
<br />
{% include "emails/summary-html.html" %}
<br />
{% include "emails/summary-html.html" %}
{% if nag %}
<strong>Too many notifications?</strong>
Visit the <a href="{{ notifications_url }}">Email Reports</a>
page on {% site_name %} to set your notification preferences.
{% else %}
<strong>Just one more thing to check:</strong>
Do you have more cron jobs,
not yet on this list, that would benefit from monitoring?
Get the ball rolling by adding one more!
This is a monthly report sent by <a href="{% site_root %}">{% site_name %}</a>.
<br />
{% include "emails/summary-downtimes-html.html" %}
<strong>Just one more thing to check:</strong>
Do you have more cron jobs,
not yet on this list, that would benefit from monitoring?
Get the ball rolling by adding one more!
{% endif %}
<br /><br />
Cheers,<br>
The {% site_name %} Team
{% endblock %}
{% block unsub %}
<br>
<a href="{{ unsub_link }}?ask=1" target="_blank" style="color: #666666; text-decoration: underline;">


+ 82
- 0
templates/emails/summary-downtimes-html.html View File

@ -0,0 +1,82 @@
{% load humanize hc_extras %}
{% regroup checks by project as groups %}
<table style="margin: 0; width: 100%; font-size: 16px;" cellpadding="0" cellspacing="0">
{% for group in groups %}
<tr>
<td colspan="2" style="font-weight: bold; padding: 32px 8px 8px 8px; color: #333;">
{{ group.grouper|mangle_link }}
</td>
{% for dt in month_boundaries %}
<td style="padding: 32px 8px 8px 8px; margin: 0; font-size: 12px; color: #9BA2AB; font-family: Helvetica, Arial, sans-serif;">
{{ dt|date:"N Y"}}
</td>
{% endfor %}
</tr>
{% for check in group.list|sortchecks:sort %}
<tr>
<td style="border-top: 1px solid #EDEFF2; padding: 16px 8px;">
<table cellpadding="0" cellspacing="0">
<tr>
{% if check.get_status == "new" %}
<td style="background: #AAA; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; margin: 0; border-radius: 3px;">NEW</td>
{% elif check.get_status == "paused" %}
<td style="background: #AAA; font-family: Helvetica, Arial, sans-serif; font-weight: bold; font-size: 10px; line-height: 10px; color: white; padding: 6px; border-radius: 3px;">PAUSED</td>
{% elif check.get_status == "grace" %}
<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" %}
<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" %}
<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 %}
</tr>
</table>
</td>
<td style="border-top: 1px solid #EDEFF2; padding: 16px 8px; font-family: Helvetica, Arial, sans-serif;">
{% if check.name %}
{% if check.name|length > 20 %}
<small>{{ check.name|mangle_link }}</small>
{% else %}
{{ check.name|mangle_link }}
{% endif %}
{% else %}
<span style="color: #74787E; font-style: italic;">unnamed</span>
{% endif %}
{% if check.tags %}
<br />
<table cellpadding="0" cellspacing="0">
<tr>
{% for tag in check.tags_list %}
<td style="padding-right: 4px">
<table cellpadding="0" cellspacing="0">
<tr>
<td style="background: #eee; font-family: Helvetica, Arial, sans-serif; font-size: 10px; line-height: 10px; color: #555; padding: 4px; margin: 0; border-radius: 2px;">
{{ tag|mangle_link }}
</td>
</tr>
</table>
</td>
{% endfor %}
</tr>
</table>
{% endif %}
</td>
{% for boundary, seconds, count in check.downtimes %}
{% if count %}
<td style="border-top: 1px solid #EDEFF2; padding: 16px 8px; font-family: Helvetica, Arial, sans-serif;">
{{ count }} downtime{{ count|pluralize }},
<br />
{{ seconds|hc_approx_duration }} total
</td>
{% else %}
<td style="border-top: 1px solid #EDEFF2; padding: 16px 8px; font-family: Helvetica, Arial, sans-serif; color: #9BA2AB;">
All good!
</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
{% endfor %}
</table>
<br />

+ 121
- 58
templates/front/channels.html View File

@ -15,8 +15,8 @@
{% endif %}
<div class="col-sm-12">
{% if channels %}
<table class="table channels-table">
{% if channels %}
<tr>
<th></th>
<th class="th-name">Name, Details</th>
@ -69,6 +69,8 @@
{% endif %}
{% elif ch.kind == "webhook" %}
Webhook
{% elif ch.kind == "apprise" %}
Apprise
{% elif ch.kind == "pushbullet" %}
Pushbullet
{% elif ch.kind == "discord" %}
@ -90,6 +92,16 @@
list <span>{{ ch.trello_board_list|last }}</span>
{% elif ch.kind == "matrix" %}
Matrix <span>{{ ch.value }}</span>
{% elif ch.kind == "whatsapp" %}
WhatsApp to <span>{{ ch.sms_number }}</span>
{% if ch.whatsapp_notify_down and not ch.whatsapp_notify_up %}
(down only)
{% endif %}
{% if ch.whatsapp_notify_up and not ch.whatsapp_notify_down %}
(up only)
{% endif %}
{% elif ch.kind == "mattermost" %}
Mattermost
{% else %}
{{ ch.kind }}
{% endif %}
@ -127,7 +139,7 @@
{% else %}
Never
{% endif %}
{% if ch.kind == "sms" %}
{% if ch.kind == "sms" or ch.kind == "whatsapp" %}
<p>Used {{ profile.sms_sent_this_month }} of {{ profile.sms_limit }} sends this month.</p>
{% endif %}
</td>
@ -156,8 +168,12 @@
</tr>
{% endwith %}
{% endfor %}
{% endif %}
</table>
{% else %}
<div class="alert alert-info">
The project <strong>{{ project }}</strong> has no integrations set up yet.
</div>
{% endif %}
<h1 class="ai-title">Add More</h1>
@ -179,17 +195,7 @@
<a href="{% url 'hc-add-email' %}" class="btn btn-primary">Add Integration</a>
</li>
{% if enable_sms %}
<li>
<img src="{% static 'img/integrations/sms.png' %}"
class="icon" alt="SMS icon" />
<h2>SMS {% if use_payments %}<small>(paid plans)</small>{% endif %}</h2>
<p>Get a text message to your phone when a check goes down.</p>
<a href="{% url 'hc-add-sms' %}" class="btn btn-primary">Add Integration</a>
</li>
{% endif %}
<li>
<img src="{% static 'img/integrations/webhook.png' %}"
class="icon" alt="Webhook icon" />
@ -199,39 +205,62 @@
<a href="{% url 'hc-add-webhook' %}" class="btn btn-primary">Add Integration</a>
</li>
{% if enable_pushover %}
{% if enable_apprise %}
<li>
<img src="{% static 'img/integrations/po.png' %}"
class="icon" alt="Pushover icon" />
<img src="{% static 'img/integrations/apprise.png' %}"
class="icon" alt="Apprise icon" />
<h2>Pushover</h2>
<p>Receive instant push notifications on your phone or tablet.</p>
<h2>Apprise</h2>
<p>Receive instant push notifications using Apprise; see <a href="https://github.com/caronc/apprise#popular-notification-services" >all of the supported services here</a>.</p>
<a href="{% url 'hc-add-pushover' %}" class="btn btn-primary">Add Integration</a>
<a href="{% url 'hc-add-apprise' %}" class="btn btn-primary">Add Integration</a>
</li>
{% endif %}
{% if enable_pushbullet %}
{% if enable_discord %}
<li>
<img src="{% static 'img/integrations/pushbullet.png' %}"
class="icon" alt="Pushbullet icon" />
<img src="{% static 'img/integrations/discord.png' %}"
class="icon" alt="Discord icon" />
<h2>Pushbullet</h2>
<p>Pushbullet connects your devices, making them feel like one.</p>
<h2>Discord</h2>
<p>Cross-platform voice and text chat app designed for gamers.</p>
<a href="{% url 'hc-add-pushbullet' %}" class="btn btn-primary">Add Integration</a>
<a href="{% url 'hc-add-discord' %}" class="btn btn-primary">Add Integration</a>
</li>
{% endif %}
{% if enable_telegram %}
{% if enable_matrix %}
<li>
<img src="{% static 'img/integrations/telegram.png' %}"
class="icon" alt="Telegram icon" />
<img src="{% static 'img/integrations/matrix.png' %}"
class="icon" alt="Matrix icon" />
<h2>Telegram</h2>
<p>A messaging app with a focus on speed and security.</p>
<h2>Matrix</h2>
<p>Post notifications to a Matrix room.</p>
<a href="{% url 'hc-add-telegram' %}" class="btn btn-primary">Add Integration</a>
<a href="{% url 'hc-add-matrix' %}" class="btn btn-primary">Add Integration</a>
</li>
{% endif %}
<li>
<img src="{% static 'img/integrations/mattermost.png' %}"
class="icon" alt="Mattermost icon" />
<h2>Mattermost</h2>
<p>High Trust Messaging for the Enterprise.</p>
<a href="{% url 'hc-add-mattermost' %}" class="btn btn-primary">Add Integration</a>
</li>
<li>
<img src="{% static 'img/integrations/opsgenie.png' %}"
class="icon" alt="OpsGenie icon" />
<h2>OpsGenie</h2>
<p> Alerting &amp; Incident Management Solution for Dev &amp; Ops.</p>
<a href="{% url 'hc-add-opsgenie' %}" class="btn btn-primary">Add Integration</a>
</li>
{% if enable_pd %}
<li>
<img src="{% static 'img/integrations/pd.png' %}"
@ -243,6 +272,17 @@
<a href="{% url 'hc-add-pd' %}" class="btn btn-primary">Add Integration</a>
</li>
{% endif %}
<li>
<img src="{% static 'img/integrations/pagerteam.png' %}"
class="icon" alt="PagerTeam icon" />
<h2>Pager Team</h2>
<p>On-call rotations without limits.</p>
<a href="{% url 'hc-add-pagerteam' %}" class="btn btn-primary">Add Integration</a>
</li>
<li>
<img src="{% static 'img/integrations/pagertree.png' %}"
class="icon" alt="PagerTree icon" />
@ -252,44 +292,55 @@
<a href="{% url 'hc-add-pagertree' %}" class="btn btn-primary">Add Integration</a>
</li>
{% if enable_pushbullet %}
<li>
<img src="{% static 'img/integrations/victorops.png' %}"
class="icon" alt="VictorOps icon" />
<img src="{% static 'img/integrations/pushbullet.png' %}"
class="icon" alt="Pushbullet icon" />
<h2>VictorOps</h2>
<p>On-call scheduling, alerting, and incident tracking.</p>
<h2>Pushbullet</h2>
<p>Pushbullet connects your devices, making them feel like one.</p>
<a href="{% url 'hc-add-victorops' %}" class="btn btn-primary">Add Integration</a>
<a href="{% url 'hc-add-pushbullet' %}" class="btn btn-primary">Add Integration</a>
</li>
{% endif %}
{% if enable_pushover %}
<li>
<img src="{% static 'img/integrations/pagerteam.png' %}"
class="icon" alt="PagerTeam icon" />
<img src="{% static 'img/integrations/po.png' %}"
class="icon" alt="Pushover icon" />
<h2>Pager Team</h2>
<p>On-call rotations without limits.</p>
<h2>Pushover</h2>
<p>Receive instant push notifications on your phone or tablet.</p>
<a href="{% url 'hc-add-pagerteam' %}" class="btn btn-primary">Add Integration</a>
<a href="{% url 'hc-add-pushover' %}" class="btn btn-primary">Add Integration</a>
</li>
{% if enable_discord %}
{% endif %}
{% if enable_sms %}
<li>
<img src="{% static 'img/integrations/discord.png' %}"
class="icon" alt="Discord icon" />
<img src="{% static 'img/integrations/sms.png' %}"
class="icon" alt="SMS icon" />
<h2>Discord</h2>
<p>Cross-platform voice and text chat app designed for gamers.</p>
<h2>SMS {% if use_payments %}<small>(paid plans)</small>{% endif %}</h2>
<p>Get a text message to your phone when a check goes down.</p>
<a href="{% url 'hc-add-discord' %}" class="btn btn-primary">Add Integration</a>
<a href="{% url 'hc-add-sms' %}" class="btn btn-primary">Add Integration</a>
</li>
{% endif %}
{% if enable_telegram %}
<li>
<img src="{% static 'img/integrations/opsgenie.png' %}"
class="icon" alt="OpsGenie icon" />
<img src="{% static 'img/integrations/telegram.png' %}"
class="icon" alt="Telegram icon" />
<h2>OpsGenie</h2>
<p> Alerting &amp; Incident Management Solution for Dev &amp; Ops.</p>
<h2>Telegram</h2>
<p>A messaging app with a focus on speed and security.</p>
<a href="{% url 'hc-add-opsgenie' %}" class="btn btn-primary">Add Integration</a>
<a href="{% url 'hc-add-telegram' %}" class="btn btn-primary">Add Integration</a>
</li>
{% endif %}
{% if enable_trello %}
<li>
<img src="{% static 'img/integrations/trello.png' %}"
@ -301,17 +352,29 @@
<a href="{% url 'hc-add-trello' %}" class="btn btn-primary">Add Integration</a>
</li>
{% endif %}
{% if enable_matrix %}
<li>
<img src="{% static 'img/integrations/matrix.png' %}"
class="icon" alt="Matrix icon" />
<img src="{% static 'img/integrations/victorops.png' %}"
class="icon" alt="VictorOps icon" />
<h2>Matrix</h2>
<p>Post notifications to a Matrix room.</p>
<h2>VictorOps</h2>
<p>On-call scheduling, alerting, and incident tracking.</p>
<a href="{% url 'hc-add-matrix' %}" class="btn btn-primary">Add Integration</a>
<a href="{% url 'hc-add-victorops' %}" class="btn btn-primary">Add Integration</a>
</li>
{% if enable_whatsapp %}
<li>
<img src="{% static 'img/integrations/whatsapp.png' %}"
class="icon" alt="WhatsApp icon" />
<h2>WhatsApp {% if use_payments %}<small>(paid plans)</small>{% endif %}</h2>
<p>Get a WhatsApp message when a check goes up or down.</p>
<a href="{% url 'hc-add-whatsapp' %}" class="btn btn-primary">Add Integration</a>
</li>
{% endif %}
<li class="link-to-github">
<img src="{% static 'img/integrations/missing.png' %}"
class="icon" alt="Suggest New Integration" />


+ 41
- 15
templates/front/details.html View File

@ -7,6 +7,26 @@
{% block content %}
<div class="row">
{% if is_new %}
<div class="col-sm-12">
<p id="new-check-alert" class="alert alert-success">
<strong>Your new check is ready!</strong>
You can now
<a data-target="edit-name" href="#" >give it a name</a>
or
<a data-target="edit-timeout" href="#" >set its schedule</a>.
</p>
</div>
{% endif %}
{% if messages %}
<div class="col-sm-12">
{% for message in messages %}
<p class="alert alert-{{ message.tags }}">{{ message }}</p>
{% endfor %}
</div>
{% endif %}
<div id="details-head" class="col-sm-12">
<h1>
{{ check.name_then_code }}
@ -18,14 +38,6 @@
{% endfor %}
</div>
{% if messages %}
<div class="col-sm-12">
{% for message in messages %}
<p class="alert alert-{{ message.tags }}">{{ message }}</p>
{% endfor %}
</div>
{% endif %}
<div class="col-sm-5">
<div class="details-block">
<h2>Description</h2>
@ -87,8 +99,14 @@
<td>
<span id="log-status-icon" class="status icon-{{ check.get_status }}"></span>
</td>
<td id="log-status-text">
{% include "front/log_status_text.html" %}
<td >
<p id="log-status-text">{% include "front/log_status_text.html" %}</p>
</td>
</tr>
<tr>
<td></td>
<td id="downtimes">
{% include "front/details_downtimes.html" %}
</td>
</tr>
</table>
@ -147,6 +165,7 @@
id="edit-timeout"
class="btn btn-sm btn-default timeout-grace"
data-code="{{ check.code }}"
data-status-url="{% url 'hc-status-single' check.code %}"
data-kind="{{ check.kind }}"
data-timeout="{{ check.timeout.total_seconds }}"
data-grace="{{ check.grace.total_seconds }}"
@ -196,20 +215,26 @@
</div>
<div id="events" class="col-sm-7">
<h2>
Log
<small>Click on individual items for details</small>
<div id="format-switcher" class="btn-group pull-right" data-toggle="buttons">
<label class="btn btn-default btn-xs" data-format="utc">
<input type="radio" name="date-format" checked>
<label class="btn btn-default btn-xs" data-format="UTC">
<input type="radio" name="date-format">
UTC
</label>
</label>
{% if check.kind == "cron" and check.tz != "UTC" %}
<label class="btn btn-default btn-xs" data-format="{{ check.tz }}">
<input type="radio" name="date-format">
{{ check.tz }}
</label>
{% endif %}
<label class="btn btn-default btn-xs active" data-format="local">
<input type="radio" name="date-format">
Local Time
Browser's time zone
</label>
</div>
</h2>
@ -250,6 +275,7 @@
<script src="{% static 'js/nouislider.min.js' %}"></script>
<script src="{% static 'js/snippet-copy.js' %}"></script>
<script src="{% static 'js/moment.min.js' %}"></script>
<script src="{% static 'js/moment-timezone-with-data-10-year-range.min.js' %}"></script>
<script src="{% static 'js/update-timeout-modal.js' %}"></script>
<script src="{% static 'js/adaptive-setinterval.js' %}"></script>
<script src="{% static 'js/details.js' %}"></script>


+ 16
- 0
templates/front/details_downtimes.html View File

@ -0,0 +1,16 @@
{% load hc_extras %}
<table class="table">
{% for boundary, seconds, count in downtimes reversed %}
<tr>
<th>{{ boundary|date:"N Y"}}</th>
<td>
{% if count %}
{{ count }} downtime{{ count|pluralize }},
{{ seconds|hc_approx_duration }} total
{% else %}
All good!
{% endif %}
</td>
</tr>
{% endfor %}
</table>

+ 4
- 3
templates/front/details_events.html View File

@ -87,11 +87,12 @@
{% endfor %}
</table>
{% if check.n_pings > 20 %}
<p class="text-center">
<a href="{% url 'hc-log' check.code %}">Show More&hellip;</a>
</p>
{% endif %}
{% else %}
<div class="alert alert-info">This check has not received any pings yet.</div>
<div class="alert no-events">
You will see a live-updating log of received pings here. <br />
This check has not received any pings yet.
</div>
{% endif %}

+ 24
- 3
templates/front/docs_api.html View File

@ -56,11 +56,25 @@
<h2>Authentication</h2>
<p>Your requests to {% site_name %} REST API must authenticate using an
API key. By default, an user account on {% site_name %} doesn't have
an API key. You can create read-write and read-only API keys
in the <b>Project Settings</b> page.
API key. Each project in your {% site_name %} account has separate API keys.
There are no account-wide API keys. By default, a project on {% site_name %} doesn't have
an API key. You can create read-write and read-only API keys in the
<b>Project Settings</b> page.
</p>
<table class="table table-bordered">
<tr>
<td>Regular API keys</td>
<td>Have full access to all documented API endpoints.</td>
</tr>
<tr>
<td>Read-only API keys</td>
<td>Only work with the
<a href="#list-checks">Get a list of existing checks</a>
endpoint. Some fields are omitted from the API responses.</td>
</tr>
</table>
<p>The client can authenticate itself by sending an appropriate HTTP
request header. The header's name should be <code>X-Api-Key</code> and
its value should be your API key.
@ -126,6 +140,13 @@ one or more tags.</p>
<h3 class="api-section">Example Response</h3>
{% include "front/snippets/list_checks_response.html" %}
<p>When using the read-only API key, the following fields are omitted:
<code>ping_url</code>, <code>update_url</code>, <code>pause_url</code>,
<code>channels</code>. An extra <code>unique_key</code> field is added.
This identifier is stable across API calls. Example:
</p>
{% include "front/snippets/list_checks_response_readonly.html" %}
<!-- ********************************************************************** /-->
<a class="section" name="create-check">


+ 1
- 0
templates/front/docs_resources.html View File

@ -50,5 +50,6 @@
<ul>
{% include "front/single_resource.html" with url="https://github.com/taylus/HealthTray" name="HealthTray" desc="Watch your healthchecks in Windows system tray" %}
{% include "front/single_resource.html" with url="https://github.com/healthchecks/dashboard" name="healthchecks/dashboard" desc="A standalone HTML page showing the status of the checks in your account. " %}
</ul>
{% endblock %}

+ 6
- 1
templates/front/last_ping_cell.html View File

@ -1,9 +1,14 @@
{% load humanize %}
{% load humanize hc_extras %}
{% if check.last_ping %}
{{ check.last_ping|naturaltime }}
{% if check.has_confirmation_link %}
<br /><span class="label label-confirmation">confirmation link</span>
{% elif check.clamped_last_duration %}
<br />
<span class="checks-subline-duration">
<span class="icon-timer"></span> {{ check.clamped_last_duration|hms }}
</span>
{% endif %}
{% else %}
Never

+ 11
- 3
templates/front/log.html View File

@ -1,5 +1,5 @@
{% extends "base.html" %}
{% load compress humanize staticfiles hc_extras %}
{% load compress humanize static hc_extras %}
{% block title %}My Checks - {% site_name %}{% endblock %}
@ -19,14 +19,21 @@
<li id="format-switcher-container" class="pull-right">
<div id="format-switcher" class="btn-group" data-toggle="buttons">
<label class="btn btn-default btn-xs" data-format="utc">
<label class="btn btn-default btn-xs" data-format="UTC">
<input type="radio" name="date-format" checked>
UTC
</label>
{% if check.kind == "cron" and check.tz != "UTC" %}
<label class="btn btn-default btn-xs" data-format="{{ check.tz }}">
<input type="radio" name="date-format">
{{ check.tz }}
</label>
{% endif %}
<label class="btn btn-default btn-xs active" data-format="local">
<input type="radio" name="date-format">
Local Time
Browser's time zone
</label>
</div>
</li>
@ -165,6 +172,7 @@
<script src="{% static 'js/jquery-2.1.4.min.js' %}"></script>
<script src="{% static 'js/bootstrap.min.js' %}"></script>
<script src="{% static 'js/moment.min.js' %}"></script>
<script src="{% static 'js/moment-timezone-with-data-10-year-range.min.js' %}"></script>
<script src="{% static 'js/log.js' %}"></script>
{% endcompress %}
{% endblock %}

+ 7
- 3
templates/front/my_checks_desktop.html View File

@ -44,6 +44,10 @@
Last Ping</span>
</a>
{% endif %}
{% if show_last_duration %}
<br />
<span class="checks-subline">Last Duration</span>
{% endif %}
</th>
<th class="hidden-xs"></th>
</tr>
@ -102,17 +106,17 @@
class="timeout-grace">
{% if check.kind == "simple" %}
{{ check.timeout|hc_duration }}
<br />
{% elif check.kind == "cron" %}
<span class="cron-expression">{{ check.schedule }}</span>
<div class="cron-expression">{{ check.schedule }}</div>
{% endif %}
<br />
<span class="checks-subline">
{{ check.grace|hc_duration }}
</span>
</div>
</td>
<td>
<div id="lpd-{{ check.code}}" class="last-ping">
<div id="lpd-{{ check.code }}" class="last-ping">
{% include "front/last_ping_cell.html" with check=check %}
</div>
</td>


+ 29
- 0
templates/front/snippets/list_checks_response_readonly.html View File

@ -0,0 +1,29 @@
<div class="highlight"><pre><span></span><span class="p">{</span>
<span class="nt">&quot;checks&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span>
<span class="nt">&quot;desc&quot;</span><span class="p">:</span> <span class="s2">&quot;Longer free-form description goes here&quot;</span><span class="p">,</span>
<span class="nt">&quot;grace&quot;</span><span class="p">:</span> <span class="mi">900</span><span class="p">,</span>
<span class="nt">&quot;last_ping&quot;</span><span class="p">:</span> <span class="s2">&quot;2017-01-04T13:24:39.903464+00:00&quot;</span><span class="p">,</span>
<span class="nt">&quot;n_pings&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;name&quot;</span><span class="p">:</span> <span class="s2">&quot;Api test 1&quot;</span><span class="p">,</span>
<span class="nt">&quot;status&quot;</span><span class="p">:</span> <span class="s2">&quot;up&quot;</span><span class="p">,</span>
<span class="nt">&quot;tags&quot;</span><span class="p">:</span> <span class="s2">&quot;foo&quot;</span><span class="p">,</span>
<span class="nt">&quot;timeout&quot;</span><span class="p">:</span> <span class="mi">3600</span><span class="p">,</span>
<span class="nt">&quot;unique_key&quot;</span><span class="p">:</span> <span class="s2">&quot;2872190d95224bad120f41d3c06aab94b8175bb6&quot;</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;desc&quot;</span><span class="p">:</span> <span class="s2">&quot;&quot;</span><span class="p">,</span>
<span class="nt">&quot;grace&quot;</span><span class="p">:</span> <span class="mi">3600</span><span class="p">,</span>
<span class="nt">&quot;last_ping&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;n_pings&quot;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
<span class="nt">&quot;name&quot;</span><span class="p">:</span> <span class="s2">&quot;Api test 2&quot;</span><span class="p">,</span>
<span class="nt">&quot;next_ping&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;schedule&quot;</span><span class="p">:</span> <span class="s2">&quot;0/10 * * * *&quot;</span><span class="p">,</span>
<span class="nt">&quot;status&quot;</span><span class="p">:</span> <span class="s2">&quot;new&quot;</span><span class="p">,</span>
<span class="nt">&quot;tags&quot;</span><span class="p">:</span> <span class="s2">&quot;bar baz&quot;</span><span class="p">,</span>
<span class="nt">&quot;tz&quot;</span><span class="p">:</span> <span class="s2">&quot;UTC&quot;</span><span class="p">,</span>
<span class="nt">&quot;unique_key&quot;</span><span class="p">:</span> <span class="s2">&quot;9b5fc29129560ff2c5c1803803a7415e4f80cf7e&quot;</span>
<span class="p">}</span>
<span class="p">]</span>
<span class="p">}</span>
</pre></div>

+ 28
- 0
templates/front/snippets/list_checks_response_readonly.txt View File

@ -0,0 +1,28 @@
{
"checks": [
{
"desc": "Longer free-form description goes here",
"grace": 900,
"last_ping": "2017-01-04T13:24:39.903464+00:00",
"n_pings": 1,
"name": "Api test 1",
"status": "up",
"tags": "foo",
"timeout": 3600,
"unique_key": "2872190d95224bad120f41d3c06aab94b8175bb6"
},
{
"desc": "",
"grace": 3600,
"last_ping": null,
"n_pings": 0,
"name": "Api test 2",
"next_ping": null,
"schedule": "0/10 * * * *",
"status": "new",
"tags": "bar baz",
"tz": "UTC",
"unique_key": "9b5fc29129560ff2c5c1803803a7415e4f80cf7e"
}
]
}

+ 62
- 37
templates/front/welcome.html View File

@ -129,7 +129,7 @@
<div class="row">
<div id="get-started" class="col-sm-8 col-sm-offset-2 text-center">
<h1>{% site_name %} monitors the heartbeat messages sent by your cron jobs, services and APIs.
Get immediate alerts you when they don't arrive on schedule. </h1>
Get immediate alerts when they don't arrive on schedule. </h1>
<a href="#" data-toggle="modal" data-target="#signup-modal" class="btn btn-lg btn-primary">
Sign Up – It's Free
</a>
@ -310,15 +310,6 @@
</div>
</div>
{% if enable_sms %}
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/sms.png' %}" class="icon" alt="SMS icon" />
<h3>SMS<br><small>&nbsp;</small></h3>
</div>
</div>
{% endif %}
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/webhook.png' %}" class="icon" alt="Webhook icon" />
@ -333,32 +324,46 @@
</div>
</div>
{% if enable_telegram %}
{% if enable_apprise %}
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/telegram.png' %}" class="icon" alt="Telegram icon" />
<h3>Telegram<br><small>Chat</small></h3>
<img src="{% static 'img/integrations/apprise.png' %}" class="icon" alt="Apprise icon" />
<h3>Apprise<br><small>Push Notifications</small></h3>
</div>
</div>
{% endif %}
{% endif %}
{% if enable_pushover %}
{% if enable_discord %}
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/po.png' %}" class="icon" alt="Pushover icon" />
<h3>Pushover<br><small>Push Notifications</small></h3>
<img src="{% static 'img/integrations/discord.png' %}" class="icon" alt="Discord icon" />
<h3>Discord<br><small>Chat</small></h3>
</div>
</div>
{% endif %}
{% if enable_matrix %}
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/matrix.png' %}" class="icon" alt="Matrix icon" />
<h3>Matrix<br><small>Chat</small></h3>
</div>
</div>
{% endif %}
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/mattermost.png' %}" class="icon" alt="Mattermost icon" />
<h3>Mattermost<br><small>Chat</small></h3>
</div>
</div>
{% if enable_pushbullet %}
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/pushbullet.png' %}" class="icon" alt="Pushbullet icon" />
<h3>Pushbullet<br><small>Push Notifications</small></h3>
<img src="{% static 'img/integrations/opsgenie.png' %}" class="icon" alt="OpsGenie icon" />
<h3>OpsGenie<br><small>Incident Management</small></h3>
</div>
</div>
{% endif %}
{% if enable_pd %}
<div class="col-md-2 col-sm-4 col-xs-6">
@ -371,40 +376,53 @@
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/victorops.png' %}" class="icon" alt="VictorOps icon" />
<h3>VictorOps<br><small>Incident Management</small></h3>
<img src="{% static 'img/integrations/pagerteam.png' %}" class="icon" alt="Pager Team icon" />
<h3>Pager Team <br><small>Incident Management</small></h3>
</div>
</div>
</div>
{% if enable_discord %}
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/discord.png' %}" class="icon" alt="Discord icon" />
<h3>Discord<br><small>Chat</small></h3>
<img src="{% static 'img/integrations/pagertree.png' %}" class="icon" alt="PagerTree icon" />
<h3>PagerTree<br><small>Incident Management</small></h3>
</div>
</div>
{% if enable_pushbullet %}
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/pushbullet.png' %}" class="icon" alt="Pushbullet icon" />
<h3>Pushbullet<br><small>Push Notifications</small></h3>
</div>
</div>
{% endif %}
{% if enable_pushover %}
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/opsgenie.png' %}" class="icon" alt="OpsGenie icon" />
<h3>OpsGenie<br><small>Incident Management</small></h3>
<img src="{% static 'img/integrations/po.png' %}" class="icon" alt="Pushover icon" />
<h3>Pushover<br><small>Push Notifications</small></h3>
</div>
</div>
{% endif %}
{% if enable_sms %}
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/pagertree.png' %}" class="icon" alt="PagerTree icon" />
<h3>PagerTree<br><small>Incident Management</small></h3>
<img src="{% static 'img/integrations/sms.png' %}" class="icon" alt="SMS icon" />
<h3>SMS<br><small>&nbsp;</small></h3>
</div>
</div>
{% endif %}
{% if enable_telegram %}
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/pagerteam.png' %}" class="icon" alt="Pager Team icon" />
<h3>Pager Team <br><small>Incident Management</small></h3>
<img src="{% static 'img/integrations/telegram.png' %}" class="icon" alt="Telegram icon" />
<h3>Telegram<br><small>Chat</small></h3>
</div>
</div>
{% endif %}
{% if enable_trello %}
<div class="col-md-2 col-sm-4 col-xs-6">
@ -415,11 +433,18 @@
</div>
{% endif %}
{% if enable_matrix %}
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/matrix.png' %}" class="icon" alt="Matrix icon" />
<h3>Matrix<br><small>Chat</small></h3>
<img src="{% static 'img/integrations/victorops.png' %}" class="icon" alt="VictorOps icon" />
<h3>VictorOps<br><small>Incident Management</small></h3>
</div>
</div>
{% if enable_whatsapp %}
<div class="col-md-2 col-sm-4 col-xs-6">
<div class="integration">
<img src="{% static 'img/integrations/whatsapp.png' %}" class="icon" alt="WhatsApp icon" />
<h3>WhatsApp<br><small>Chat</small></h3>
</div>
</div>
{% endif %}


+ 49
- 0
templates/integrations/add_apprise.html View File

@ -0,0 +1,49 @@
{% extends "base.html" %}
{% load humanize static hc_extras %}
{% block title %}Add Apprise - {% site_name %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-12">
<h1>Apprise</h1>
<p>
Identify as many Apprise URLs as you wish. You can use a comma (,) to identify
more than on URL if you wish to.
For a detailed list of all supported Apprise Notification URLs simply
<a href="https://github.com/caronc/apprise#popular-notification-services" >click here</a>.
</p>
<h2>Integration Settings</h2>
<form method="post" class="form-horizontal">
{% csrf_token %}
<div class="form-group {{ form.room_id.css_classes }}">
<label for="url" class="col-sm-2 control-label">Apprise URL</label>
<div class="col-sm-6">
<input
id="url"
type="text"
class="form-control"
name="url"
value="{{ form.url.value|default:"" }}">
{% if form.url.errors %}
<div class="help-block">
{{ form.url.errors|join:"" }}
</div>
{% endif %}
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary">Save Integration</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

+ 97
- 0
templates/integrations/add_mattermost.html View File

@ -0,0 +1,97 @@
{% extends "base.html" %}
{% load humanize static hc_extras %}
{% block title %}Add Mattermost - {% site_name %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-12">
<h1>Mattermost</h1>
<p>If your team uses <a href="https://mattermost.com/">Mattermost</a>, you can set
up {% site_name %} to post status updates directly to an appropriate
Mattermost channel.</p>
<h2>Setup Guide</h2>
<div class="row ai-step">
<div class="col-sm-6">
<span class="step-no">1</span>
Log into your Mattermost account and
select <strong>Integrations</strong> in the
hamburger menu.
</div>
<div class="col-sm-6">
<img
class="ai-guide-screenshot"
alt="Screenshot"
src="{% static 'img/integrations/setup_mattermost_1.png' %}">
</div>
</div>
<div class="row ai-step">
<div class="col-sm-6">
<span class="step-no">2</span>
<p>
In the "Integrations" screen, select <strong>Incoming Webhook</strong>
and then <strong>Add Incoming Webhook</strong>.
</p>
<p>Fill in the form and hit "Save".</p>
</div>
<div class="col-sm-6">
<img
class="ai-guide-screenshot"
alt="Screenshot"
src="{% static 'img/integrations/setup_mattermost_2.png' %}">
</div>
</div>
<div class="row ai-step">
<div class="col-sm-6">
<span class="step-no">3</span>
<p>Copy the displayed <strong>URL</strong> and paste it down below.</p>
<p>Save the integration, and it's done!</p>
</div>
<div class="col-sm-6">
<img
class="ai-guide-screenshot"
alt="Screenshot"
src="{% static 'img/integrations/setup_mattermost_3.png' %}">
</div>
</div>
<h2>Integration Settings</h2>
<form method="post" class="form-horizontal" action="{% url 'hc-add-mattermost' %}">
{% csrf_token %}
<div class="form-group {{ form.value.css_classes }}">
<label for="callback-url" class="col-sm-2 control-label">
Webhook URL
</label>
<div class="col-sm-10">
<input
id="callback-url"
type="text"
class="form-control"
name="value"
placeholder="https://"
value="{{ form.value.value|default:"" }}">
{% if form.value.errors %}
<div class="help-block">
{{ form.value.errors|join:"" }}
</div>
{% endif %}
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary">Save Integration</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

+ 1
- 1
templates/integrations/add_pushover.html View File

@ -7,7 +7,7 @@
{% block content %}
<div class="row">
<div class="col-sm-12">
<h1>Pushbover</h1>
<h1>Pushover</h1>
<div class="jumbotron">
{% if request.user.is_authenticated %}
<p>


+ 4
- 0
templates/integrations/add_webhook.html View File

@ -181,6 +181,10 @@
<th><code>$STATUS</code></th>
<td>Check's current status ("up" or "down")</td>
</tr>
<tr>
<th><code>$TAGS</code></th>
<td>Check's tags, separated by spaces</td>
</tr>
<tr>
<th><code>$TAG1, $TAG2, …</code></th>
<td>Value of the first tag, the second tag, …</td>


+ 109
- 0
templates/integrations/add_whatsapp.html View File

@ -0,0 +1,109 @@
{% extends "base.html" %}
{% load humanize static hc_extras %}
{% block title %}Notification Channels - {% site_name %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-12">
<h1>WhatsApp</h1>
<p>
Get a WhatsApp message when a check goes up or down.
</p>
{% if show_pricing and profile.sms_limit == 0 %}
<p class="alert alert-info">
<strong>Paid plan required.</strong>
WhatsApp messaging is not available on the free plan–sending the messages
cost too much! Please upgrade to a
<a href="{% url 'hc-billing' %}">paid plan</a> to enable WhatsApp messaging.
</p>
{% endif %}
<h2>Integration Settings</h2>
<form method="post" class="form-horizontal" action="{% url 'hc-add-whatsapp' %}">
{% csrf_token %}
<div class="form-group {{ form.label.css_classes }}">
<label for="id_label" class="col-sm-2 control-label">Label</label>
<div class="col-sm-6">
<input
id="id_label"
type="text"
class="form-control"
name="label"
placeholder="Alice's Phone"
value="{{ form.label.value|default:"" }}">
{% if form.label.errors %}
<div class="help-block">
{{ form.label.errors|join:"" }}
</div>
{% else %}
<span class="help-block">
Optional. If you add multiple phone numbers,
the labels will help you tell them apart.
</span>
{% endif %}
</div>
</div>
<div class="form-group {{ form.value.css_classes }}">
<label for="id_number" class="col-sm-2 control-label">Phone Number</label>
<div class="col-sm-3">
<input
id="id_number"
type="tel"
class="form-control"
name="value"
placeholder="+1234567890"
value="{{ form.value.value|default:"" }}">
{% if form.value.errors %}
<div class="help-block">
{{ form.value.errors|join:"" }}
</div>
{% else %}
<span class="help-block">
Make sure the phone number starts with "+" and has the
country code.
</span>
{% endif %}
</div>
</div>
<div id="add-email-notify-group" class="form-group">
<label class="col-sm-2 control-label">Notify When</label>
<div class="col-sm-10">
<label class="checkbox-container">
<input
type="checkbox"
name="down"
value="true"
{% if form.down.value %} checked {% endif %}>
<span class="checkmark"></span>
A check goes <strong>down</strong>
</label>
<label class="checkbox-container">
<input
type="checkbox"
name="up"
value="true"
{% if form.up.value %} checked {% endif %}>
<span class="checkmark"></span>
A check goes <strong>up</strong>
</label>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary">Save Integration</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

+ 5
- 0
templates/integrations/apprise_description.html View File

@ -0,0 +1,5 @@
{% load humanize %}
{{ check.name_then_code }} is {{ check.status|upper }}.
{% if check.status == "down" %}
Last ping was {{ check.last_ping|naturaltime }}.
{% endif %}

+ 1
- 0
templates/integrations/apprise_title.html View File

@ -0,0 +1 @@
{{ check.name_then_code }} is {{ check.status|upper }}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save