forked from GithubBackups/healthchecks
Support for "Add to Slack" button
This commit is contained in:
parent
af997446f3
commit
760b5b4fdb
@ -137,7 +137,7 @@ def profile(request):
|
|||||||
elif "create_api_key" in request.POST:
|
elif "create_api_key" in request.POST:
|
||||||
profile.set_api_key()
|
profile.set_api_key()
|
||||||
show_api_key = True
|
show_api_key = True
|
||||||
messages.info(request, "The API key has been created!")
|
messages.success(request, "The API key has been created!")
|
||||||
elif "revoke_api_key" in request.POST:
|
elif "revoke_api_key" in request.POST:
|
||||||
profile.api_key = ""
|
profile.api_key = ""
|
||||||
profile.save()
|
profile.save()
|
||||||
@ -149,7 +149,7 @@ def profile(request):
|
|||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
profile.reports_allowed = form.cleaned_data["reports_allowed"]
|
profile.reports_allowed = form.cleaned_data["reports_allowed"]
|
||||||
profile.save()
|
profile.save()
|
||||||
messages.info(request, "Your settings have been updated!")
|
messages.success(request, "Your settings have been updated!")
|
||||||
elif "invite_team_member" in request.POST:
|
elif "invite_team_member" in request.POST:
|
||||||
if not profile.team_access_allowed:
|
if not profile.team_access_allowed:
|
||||||
return HttpResponseForbidden()
|
return HttpResponseForbidden()
|
||||||
@ -164,7 +164,7 @@ def profile(request):
|
|||||||
user = _make_user(email)
|
user = _make_user(email)
|
||||||
|
|
||||||
profile.invite(user)
|
profile.invite(user)
|
||||||
messages.info(request, "Invitation to %s sent!" % email)
|
messages.success(request, "Invitation to %s sent!" % email)
|
||||||
elif "remove_team_member" in request.POST:
|
elif "remove_team_member" in request.POST:
|
||||||
form = RemoveTeamMemberForm(request.POST)
|
form = RemoveTeamMemberForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
@ -186,7 +186,7 @@ def profile(request):
|
|||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
profile.team_name = form.cleaned_data["team_name"]
|
profile.team_name = form.cleaned_data["team_name"]
|
||||||
profile.save()
|
profile.save()
|
||||||
messages.info(request, "Team Name updated!")
|
messages.success(request, "Team Name updated!")
|
||||||
|
|
||||||
tags = set()
|
tags = set()
|
||||||
for check in Check.objects.filter(user=request.team.user):
|
for check in Check.objects.filter(user=request.team.user):
|
||||||
@ -230,7 +230,7 @@ def set_password(request, token):
|
|||||||
u = authenticate(username=request.user.email, password=password)
|
u = authenticate(username=request.user.email, password=password)
|
||||||
auth_login(request, u)
|
auth_login(request, u)
|
||||||
|
|
||||||
messages.info(request, "Your password has been set!")
|
messages.success(request, "Your password has been set!")
|
||||||
return redirect("hc-profile")
|
return redirect("hc-profile")
|
||||||
|
|
||||||
return render(request, "accounts/set_password.html", {})
|
return render(request, "accounts/set_password.html", {})
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
# coding: utf-8
|
# coding: utf-8
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import timedelta as td
|
from datetime import timedelta as td
|
||||||
|
|
||||||
@ -197,6 +198,33 @@ class Channel(models.Model):
|
|||||||
parts = self.value.split("\n")
|
parts = self.value.split("\n")
|
||||||
return parts[1] if len(parts) == 2 else ""
|
return parts[1] if len(parts) == 2 else ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slack_team(self):
|
||||||
|
assert self.kind == "slack"
|
||||||
|
if not self.value.startswith("{"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
doc = json.loads(self.value)
|
||||||
|
return doc["team_name"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slack_channel(self):
|
||||||
|
assert self.kind == "slack"
|
||||||
|
if not self.value.startswith("{"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
doc = json.loads(self.value)
|
||||||
|
return doc["incoming_webhook"]["channel"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slack_webhook_url(self):
|
||||||
|
assert self.kind == "slack"
|
||||||
|
if not self.value.startswith("{"):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
doc = json.loads(self.value)
|
||||||
|
return doc["incoming_webhook"]["url"]
|
||||||
|
|
||||||
def latest_notification(self):
|
def latest_notification(self):
|
||||||
return Notification.objects.filter(channel=self).latest()
|
return Notification.objects.filter(channel=self).latest()
|
||||||
|
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
from django.core import mail
|
import json
|
||||||
from mock import patch
|
|
||||||
from requests.exceptions import ConnectionError, Timeout
|
|
||||||
|
|
||||||
|
from django.core import mail
|
||||||
from hc.api.models import Channel, Check, Notification
|
from hc.api.models import Channel, Check, Notification
|
||||||
from hc.test import BaseTestCase
|
from hc.test import BaseTestCase
|
||||||
|
from mock import patch
|
||||||
|
from requests.exceptions import ConnectionError, Timeout
|
||||||
|
|
||||||
|
|
||||||
class NotifyTestCase(BaseTestCase):
|
class NotifyTestCase(BaseTestCase):
|
||||||
@ -27,7 +28,7 @@ class NotifyTestCase(BaseTestCase):
|
|||||||
|
|
||||||
self.channel.notify(self.check)
|
self.channel.notify(self.check)
|
||||||
mock_get.assert_called_with(
|
mock_get.assert_called_with(
|
||||||
"get", u"http://example",
|
"get", u"http://example",
|
||||||
headers={"User-Agent": "healthchecks.io"}, timeout=5)
|
headers={"User-Agent": "healthchecks.io"}, timeout=5)
|
||||||
|
|
||||||
@patch("hc.api.transports.requests.request", side_effect=Timeout)
|
@patch("hc.api.transports.requests.request", side_effect=Timeout)
|
||||||
@ -152,6 +153,18 @@ class NotifyTestCase(BaseTestCase):
|
|||||||
fields = {f["title"]: f["value"] for f in attachment["fields"]}
|
fields = {f["title"]: f["value"] for f in attachment["fields"]}
|
||||||
self.assertEqual(fields["Last Ping"], "Never")
|
self.assertEqual(fields["Last Ping"], "Never")
|
||||||
|
|
||||||
|
@patch("hc.api.transports.requests.request")
|
||||||
|
def test_slack_with_complex_value(self, mock_post):
|
||||||
|
v = json.dumps({"incoming_webhook": {"url": "123"}})
|
||||||
|
self._setup_data("slack", v)
|
||||||
|
mock_post.return_value.status_code = 200
|
||||||
|
|
||||||
|
self.channel.notify(self.check)
|
||||||
|
assert Notification.objects.count() == 1
|
||||||
|
|
||||||
|
args, kwargs = mock_post.call_args
|
||||||
|
self.assertEqual(args[1], "123")
|
||||||
|
|
||||||
@patch("hc.api.transports.requests.request")
|
@patch("hc.api.transports.requests.request")
|
||||||
def test_slack_handles_500(self, mock_post):
|
def test_slack_handles_500(self, mock_post):
|
||||||
self._setup_data("slack", "123")
|
self._setup_data("slack", "123")
|
||||||
|
@ -118,7 +118,7 @@ class Slack(HttpTransport):
|
|||||||
def notify(self, check):
|
def notify(self, check):
|
||||||
text = tmpl("slack_message.json", check=check)
|
text = tmpl("slack_message.json", check=check)
|
||||||
payload = json.loads(text)
|
payload = json.loads(text)
|
||||||
return self.post(self.channel.value, payload)
|
return self.post(self.channel.slack_webhook_url, payload)
|
||||||
|
|
||||||
|
|
||||||
class HipChat(HttpTransport):
|
class HipChat(HttpTransport):
|
||||||
|
24
hc/front/tests/test_channels.py
Normal file
24
hc/front/tests/test_channels.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from hc.api.models import Channel
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelsTestCase(BaseTestCase):
|
||||||
|
|
||||||
|
def test_it_formats_complex_slack_value(self):
|
||||||
|
ch = Channel(kind="slack", user=self.alice)
|
||||||
|
ch.value = json.dumps({
|
||||||
|
"ok": True,
|
||||||
|
"team_name": "foo-team",
|
||||||
|
"incoming_webhook": {
|
||||||
|
"url": "http://example.org",
|
||||||
|
"channel": "#bar"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ch.save()
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.get("/integrations/")
|
||||||
|
self.assertContains(r, "foo-team", status_code=200)
|
||||||
|
self.assertContains(r, "#bar")
|
53
hc/front/tests/test_slack_callback.py
Normal file
53
hc/front/tests/test_slack_callback.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
from hc.api.models import Channel
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
from mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(PUSHOVER_API_TOKEN="token", PUSHOVER_SUBSCRIPTION_URL="url")
|
||||||
|
class SlackCallbackTestCase(BaseTestCase):
|
||||||
|
|
||||||
|
@patch("hc.front.views.requests.post")
|
||||||
|
def test_it_works(self, mock_post):
|
||||||
|
oauth_response = {
|
||||||
|
"ok": True,
|
||||||
|
"team_name": "foo",
|
||||||
|
"incoming_webhook": {
|
||||||
|
"url": "http://example.org",
|
||||||
|
"channel": "bar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_post.return_value.text = json.dumps(oauth_response)
|
||||||
|
mock_post.return_value.json.return_value = oauth_response
|
||||||
|
|
||||||
|
url = "/integrations/add_slack_btn/?code=12345678"
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.get(url, follow=True)
|
||||||
|
self.assertRedirects(r, "/integrations/")
|
||||||
|
self.assertContains(r, "The Slack integration has been added!")
|
||||||
|
|
||||||
|
ch = Channel.objects.get()
|
||||||
|
self.assertEqual(ch.slack_team, "foo")
|
||||||
|
self.assertEqual(ch.slack_channel, "bar")
|
||||||
|
self.assertEqual(ch.slack_webhook_url, "http://example.org")
|
||||||
|
|
||||||
|
@patch("hc.front.views.requests.post")
|
||||||
|
def test_it_handles_error(self, mock_post):
|
||||||
|
oauth_response = {
|
||||||
|
"ok": False,
|
||||||
|
"error": "something went wrong"
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_post.return_value.text = json.dumps(oauth_response)
|
||||||
|
mock_post.return_value.json.return_value = oauth_response
|
||||||
|
|
||||||
|
url = "/integrations/add_slack_btn/?code=12345678"
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.get(url, follow=True)
|
||||||
|
self.assertRedirects(r, "/integrations/")
|
||||||
|
self.assertContains(r, "something went wrong")
|
@ -21,6 +21,7 @@ urlpatterns = [
|
|||||||
url(r'^integrations/add_webhook/$', views.add_webhook, name="hc-add-webhook"),
|
url(r'^integrations/add_webhook/$', views.add_webhook, name="hc-add-webhook"),
|
||||||
url(r'^integrations/add_pd/$', views.add_pd, name="hc-add-pd"),
|
url(r'^integrations/add_pd/$', views.add_pd, name="hc-add-pd"),
|
||||||
url(r'^integrations/add_slack/$', views.add_slack, name="hc-add-slack"),
|
url(r'^integrations/add_slack/$', views.add_slack, name="hc-add-slack"),
|
||||||
|
url(r'^integrations/add_slack_btn/$', views.add_slack_btn, name="hc-add-slack-btn"),
|
||||||
url(r'^integrations/add_hipchat/$', views.add_hipchat, name="hc-add-hipchat"),
|
url(r'^integrations/add_hipchat/$', views.add_hipchat, name="hc-add-hipchat"),
|
||||||
url(r'^integrations/add_pushover/$', views.add_pushover, name="hc-add-pushover"),
|
url(r'^integrations/add_pushover/$', views.add_pushover, name="hc-add-pushover"),
|
||||||
url(r'^integrations/add_victorops/$', views.add_victorops, name="hc-add-victorops"),
|
url(r'^integrations/add_victorops/$', views.add_victorops, name="hc-add-victorops"),
|
||||||
|
@ -2,7 +2,9 @@ from collections import Counter
|
|||||||
from datetime import timedelta as td
|
from datetime import timedelta as td
|
||||||
from itertools import tee
|
from itertools import tee
|
||||||
|
|
||||||
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
@ -12,7 +14,7 @@ from django.utils import timezone
|
|||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.six.moves.urllib.parse import urlencode
|
from django.utils.six.moves.urllib.parse import urlencode
|
||||||
from hc.api.decorators import uuid_or_400
|
from hc.api.decorators import uuid_or_400
|
||||||
from hc.api.models import Channel, Check, Ping, DEFAULT_TIMEOUT, DEFAULT_GRACE
|
from hc.api.models import DEFAULT_GRACE, DEFAULT_TIMEOUT, Channel, Check, Ping
|
||||||
from hc.front.forms import (AddChannelForm, AddWebhookForm, NameTagsForm,
|
from hc.front.forms import (AddChannelForm, AddWebhookForm, NameTagsForm,
|
||||||
TimeoutForm)
|
TimeoutForm)
|
||||||
|
|
||||||
@ -269,6 +271,7 @@ def channels(request):
|
|||||||
"channels": channels,
|
"channels": channels,
|
||||||
"num_checks": num_checks,
|
"num_checks": num_checks,
|
||||||
"enable_pushover": settings.PUSHOVER_API_TOKEN is not None,
|
"enable_pushover": settings.PUSHOVER_API_TOKEN is not None,
|
||||||
|
"slack_client_id": settings.SLACK_CLIENT_ID
|
||||||
}
|
}
|
||||||
return render(request, "front/channels.html", ctx)
|
return render(request, "front/channels.html", ctx)
|
||||||
|
|
||||||
@ -377,6 +380,34 @@ def add_slack(request):
|
|||||||
return render(request, "integrations/add_slack.html", ctx)
|
return render(request, "integrations/add_slack.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def add_slack_btn(request):
|
||||||
|
code = request.GET.get("code", "")
|
||||||
|
if len(code) < 8:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
result = requests.post("https://slack.com/api/oauth.access", {
|
||||||
|
"client_id": settings.SLACK_CLIENT_ID,
|
||||||
|
"client_secret": settings.SLACK_CLIENT_SECRET,
|
||||||
|
"code": code
|
||||||
|
})
|
||||||
|
|
||||||
|
doc = result.json()
|
||||||
|
if doc.get("ok"):
|
||||||
|
channel = Channel()
|
||||||
|
channel.user = request.team.user
|
||||||
|
channel.kind = "slack"
|
||||||
|
channel.value = result.text
|
||||||
|
channel.save()
|
||||||
|
channel.assign_all_checks()
|
||||||
|
messages.info(request, "The Slack integration has been added!")
|
||||||
|
else:
|
||||||
|
s = doc.get("error")
|
||||||
|
messages.warning(request, "Error message from slack: %s" % s)
|
||||||
|
|
||||||
|
return redirect("hc-channels")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def add_hipchat(request):
|
def add_hipchat(request):
|
||||||
ctx = {"page": "channels"}
|
ctx = {"page": "channels"}
|
||||||
|
@ -136,6 +136,10 @@ COMPRESS_OFFLINE = True
|
|||||||
|
|
||||||
EMAIL_BACKEND = "djmail.backends.default.EmailBackend"
|
EMAIL_BACKEND = "djmail.backends.default.EmailBackend"
|
||||||
|
|
||||||
|
# Slack integration -- override these in local_settings
|
||||||
|
SLACK_CLIENT_ID = None
|
||||||
|
SLACK_CLIENT_SECRET = None
|
||||||
|
|
||||||
# Pushover integration -- override these in local_settings
|
# Pushover integration -- override these in local_settings
|
||||||
PUSHOVER_API_TOKEN = None
|
PUSHOVER_API_TOKEN = None
|
||||||
PUSHOVER_SUBSCRIPTION_URL = None
|
PUSHOVER_SUBSCRIPTION_URL = None
|
||||||
|
@ -43,7 +43,7 @@ table.channels-table > tbody > tr > th {
|
|||||||
font-weight: bold
|
font-weight: bold
|
||||||
}
|
}
|
||||||
|
|
||||||
.preposition {
|
.preposition, .description {
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,7 +102,7 @@ table.channels-table > tbody > tr > th {
|
|||||||
background: #eee;
|
background: #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-integration img {
|
.add-integration .icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 16px;
|
left: 16px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@ -125,6 +125,10 @@ table.channels-table > tbody > tr > th {
|
|||||||
right: 16px;
|
right: 16px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
margin-top: -17px;
|
margin-top: -17px;
|
||||||
|
width: 139px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
<p class="alert alert-success">{{ message }}</p>
|
<p class="alert alert-{{ message.tags }}">{{ message }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -6,6 +6,14 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
{% 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-12">
|
<div class="col-sm-12">
|
||||||
<table class="table channels-table">
|
<table class="table channels-table">
|
||||||
{% if channels %}
|
{% if channels %}
|
||||||
@ -44,6 +52,15 @@
|
|||||||
<span class="preposition">user key</span>
|
<span class="preposition">user key</span>
|
||||||
{{ ch.po_value|first }}
|
{{ ch.po_value|first }}
|
||||||
({{ ch.po_value|last }} priority)
|
({{ ch.po_value|last }} priority)
|
||||||
|
{% elif ch.kind == "slack" %}
|
||||||
|
{% if ch.slack_team %}
|
||||||
|
<span class="preposition">team</span>
|
||||||
|
{{ ch.slack_team }},
|
||||||
|
<span class="preposition">channel</span>
|
||||||
|
{{ ch.slack_channel }}
|
||||||
|
{% else %}
|
||||||
|
{{ ch.value }}
|
||||||
|
{% endif %}
|
||||||
{% elif ch.kind == "webhook" %}
|
{% elif ch.kind == "webhook" %}
|
||||||
<table>
|
<table>
|
||||||
{% if ch.value_down %}
|
{% if ch.value_down %}
|
||||||
@ -107,16 +124,22 @@
|
|||||||
<ul class="add-integration">
|
<ul class="add-integration">
|
||||||
<li>
|
<li>
|
||||||
<img src="{% static 'img/integrations/slack.png' %}"
|
<img src="{% static 'img/integrations/slack.png' %}"
|
||||||
alt="Slack icon" />
|
class="icon" alt="Slack icon" />
|
||||||
|
|
||||||
<h2>Slack</h2>
|
<h2>Slack</h2>
|
||||||
<p>A messaging app for teams.</p>
|
<p>A messaging app for teams.</p>
|
||||||
|
|
||||||
|
{% if slack_client_id %}
|
||||||
|
<a href="https://slack.com/oauth/authorize?scope=incoming-webhook&client_id={{ slack_client_id }}">
|
||||||
|
<img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" />
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
<a href="{% url 'hc-add-slack' %}" class="btn btn-primary">Add Integration</a>
|
<a href="{% url 'hc-add-slack' %}" class="btn btn-primary">Add Integration</a>
|
||||||
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<img src="{% static 'img/integrations/email.png' %}"
|
<img src="{% static 'img/integrations/email.png' %}"
|
||||||
alt="Email icon" />
|
class="icon" alt="Email icon" />
|
||||||
|
|
||||||
<h2>Email</h2>
|
<h2>Email</h2>
|
||||||
<p>Get an email message when check goes up or down.</p>
|
<p>Get an email message when check goes up or down.</p>
|
||||||
@ -125,7 +148,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<img src="{% static 'img/integrations/webhook.png' %}"
|
<img src="{% static 'img/integrations/webhook.png' %}"
|
||||||
alt="Webhook icon" />
|
class="icon" alt="Webhook icon" />
|
||||||
|
|
||||||
<h2>Webhook</h2>
|
<h2>Webhook</h2>
|
||||||
<p>Receive a HTTP callback when a check goes down.</p>
|
<p>Receive a HTTP callback when a check goes down.</p>
|
||||||
@ -134,7 +157,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<img src="{% static 'img/integrations/pd.png' %}"
|
<img src="{% static 'img/integrations/pd.png' %}"
|
||||||
alt="PagerDuty icon" />
|
class="icon" alt="PagerDuty icon" />
|
||||||
|
|
||||||
<h2>PagerDuty</h2>
|
<h2>PagerDuty</h2>
|
||||||
<p>On-call scheduling, alerting, and incident tracking.</p>
|
<p>On-call scheduling, alerting, and incident tracking.</p>
|
||||||
@ -143,7 +166,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<img src="{% static 'img/integrations/hipchat.png' %}"
|
<img src="{% static 'img/integrations/hipchat.png' %}"
|
||||||
alt="HipChat icon" />
|
class="icon" alt="HipChat icon" />
|
||||||
|
|
||||||
<h2>HipChat</h2>
|
<h2>HipChat</h2>
|
||||||
<p>Group and private chat, file sharing, and integrations.</p>
|
<p>Group and private chat, file sharing, and integrations.</p>
|
||||||
@ -152,7 +175,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<img src="{% static 'img/integrations/victorops.png' %}"
|
<img src="{% static 'img/integrations/victorops.png' %}"
|
||||||
alt="VictorOps icon" />
|
class="icon" alt="VictorOps icon" />
|
||||||
|
|
||||||
<h2>VictorOps</h2>
|
<h2>VictorOps</h2>
|
||||||
<p>On-call scheduling, alerting, and incident tracking.</p>
|
<p>On-call scheduling, alerting, and incident tracking.</p>
|
||||||
@ -162,7 +185,7 @@
|
|||||||
{% if enable_pushover %}
|
{% if enable_pushover %}
|
||||||
<li>
|
<li>
|
||||||
<img src="{% static 'img/integrations/pushover.png' %}"
|
<img src="{% static 'img/integrations/pushover.png' %}"
|
||||||
alt="Pushover icon" />
|
class="icon" alt="Pushover icon" />
|
||||||
|
|
||||||
<h2>Pushover</h2>
|
<h2>Pushover</h2>
|
||||||
<p>Receive instant push notifications on your phone or tablet.</p>
|
<p>Receive instant push notifications on your phone or tablet.</p>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user