forked from GithubBackups/healthchecks
Host a read-only dashboard (from github.com/healthchecks/dashboard/), link to it from "Project Settings" > "Show API keys"
This commit is contained in:
parent
c75a37570e
commit
b7e2404f98
@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Handle status callbacks from Twilio, show delivery failures in Integrations
|
- Handle status callbacks from Twilio, show delivery failures in Integrations
|
||||||
- Removing unused /api/v1/notifications/{uuid}/bounce endpoint
|
- Removing unused /api/v1/notifications/{uuid}/bounce endpoint
|
||||||
- Less verbose output in the `senddeletionnotices` command
|
- Less verbose output in the `senddeletionnotices` command
|
||||||
|
- Host a read-only dashboard (from github.com/healthchecks/dashboard/)
|
||||||
|
|
||||||
## Bug Fixes
|
## Bug Fixes
|
||||||
- Handle excessively long email addresses in the signup form.
|
- Handle excessively long email addresses in the signup form.
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from secrets import token_urlsafe
|
from secrets import token_urlsafe
|
||||||
|
from urllib.parse import quote, urlencode
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -359,6 +360,13 @@ class Project(models.Model):
|
|||||||
def transfer_request(self):
|
def transfer_request(self):
|
||||||
return self.member_set.filter(transfer_request_date__isnull=False).first()
|
return self.member_set.filter(transfer_request_date__isnull=False).first()
|
||||||
|
|
||||||
|
def dashboard_url(self):
|
||||||
|
if not self.api_key_readonly:
|
||||||
|
return None
|
||||||
|
|
||||||
|
frag = urlencode({self.api_key_readonly: str(self)}, quote_via=quote)
|
||||||
|
return reverse("hc-dashboard") + "#" + frag
|
||||||
|
|
||||||
|
|
||||||
class Member(models.Model):
|
class Member(models.Model):
|
||||||
user = models.ForeignKey(User, models.CASCADE, related_name="memberships")
|
user = models.ForeignKey(User, models.CASCADE, related_name="memberships")
|
||||||
|
@ -90,6 +90,7 @@ project_urls = [
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.index, name="hc-index"),
|
path("", views.index, name="hc-index"),
|
||||||
|
path("tv/", views.dashboard, name="hc-dashboard"),
|
||||||
path("checks/cron_preview/", views.cron_preview),
|
path("checks/cron_preview/", views.cron_preview),
|
||||||
path("checks/<uuid:code>/", include(check_urls)),
|
path("checks/<uuid:code>/", include(check_urls)),
|
||||||
path("integrations/", include(channel_urls)),
|
path("integrations/", include(channel_urls)),
|
||||||
|
@ -279,6 +279,10 @@ def index(request):
|
|||||||
return render(request, "front/welcome.html", ctx)
|
return render(request, "front/welcome.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard(request):
|
||||||
|
return render(request, "front/dashboard.html", {})
|
||||||
|
|
||||||
|
|
||||||
def serve_doc(request, doc="introduction"):
|
def serve_doc(request, doc="introduction"):
|
||||||
path = os.path.join(settings.BASE_DIR, "templates/docs", doc + ".html")
|
path = os.path.join(settings.BASE_DIR, "templates/docs", doc + ".html")
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
|
@ -80,17 +80,24 @@
|
|||||||
{% if show_api_keys %}
|
{% if show_api_keys %}
|
||||||
<p>
|
<p>
|
||||||
API key: <br />
|
API key: <br />
|
||||||
<code>{{ project.api_key }}</code>
|
<pre>{{ project.api_key }}</pre>
|
||||||
</p>
|
</p>
|
||||||
{% if project.api_key_readonly %}
|
{% if project.api_key_readonly %}
|
||||||
<p>
|
<p>
|
||||||
API key (read-only): <br />
|
API key (read-only): <br />
|
||||||
<code>{{ project.api_key_readonly }}</code>
|
<pre>{{ project.api_key_readonly }}</pre>
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Prometheus metrics endpoint:
|
|
||||||
<a href="{% url 'hc-metrics' project.code project.api_key_readonly %}">here</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
<p>Related links:</p>
|
||||||
|
<ul>
|
||||||
|
<li><a href="{% url 'hc-serve-doc' 'api' %}">API documentation</a></li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'hc-metrics' project.code project.api_key_readonly %}">Prometheus metrics endpoint</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{{ project.dashboard_url }}">Read-only dashboard</a>
|
||||||
|
(<a href="https://github.com/healthchecks/dashboard/">more info</a>)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button
|
<button
|
||||||
data-toggle="modal"
|
data-toggle="modal"
|
||||||
@ -106,7 +113,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
name="show_api_keys"
|
name="show_api_keys"
|
||||||
class="btn btn-default pull-right">Show API keys</button>
|
class="btn btn-default pull-right">Show API Keys</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -117,7 +124,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
name="create_api_keys"
|
name="create_api_keys"
|
||||||
class="btn btn-default pull-right">Create API keys</button>
|
class="btn btn-default pull-right">Create API Keys</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
266
templates/front/dashboard.html
Normal file
266
templates/front/dashboard.html
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{{ site_name }}</title>
|
||||||
|
<style>
|
||||||
|
/* Colors, dark theme */
|
||||||
|
|
||||||
|
.theme-dark {
|
||||||
|
background: #000;
|
||||||
|
color: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark #panel > div {
|
||||||
|
background: #AAA;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark #panel > div.status-up {
|
||||||
|
color: #FFF;
|
||||||
|
background: #263026;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark #panel > div.status-started {
|
||||||
|
color: #FFF;
|
||||||
|
background: #263026;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.theme-dark #panel > div.status-grace {
|
||||||
|
color: #000;
|
||||||
|
background: #FFB300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark #panel > div.status-down {
|
||||||
|
color: #000;
|
||||||
|
background: #ff3929;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .spinner:after {
|
||||||
|
border-color: #4c604c transparent #4c604c transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Colors, light theme */
|
||||||
|
|
||||||
|
.theme-light {
|
||||||
|
background: #FFF;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light #panel > div {
|
||||||
|
color: #000;
|
||||||
|
background: #FFF;
|
||||||
|
border: 2px solid #DDD;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light #panel > div.status-grace {
|
||||||
|
color: #000;
|
||||||
|
background: #FFAB00;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light #panel > div.status-down {
|
||||||
|
color: #FFF;
|
||||||
|
background: #D81818;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .spinner:after {
|
||||||
|
border-color: #DDD transparent #DDD transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Spinner from https://loading.io/css/ */
|
||||||
|
.spinner {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.status-started .spinner {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -18px;
|
||||||
|
display: inline-block;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.spinner:after {
|
||||||
|
content: " ";
|
||||||
|
display: block;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin: 1px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
animation: lds-dual-ring 1.25s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lds-dual-ring {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout and font */
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
grid-gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel > h1 {
|
||||||
|
grid-column-start: 1;
|
||||||
|
grid-column-end: -1;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel > div {
|
||||||
|
padding: 8px 8px 32px 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel > div .name {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel > div .lp {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#panel > div .lp {
|
||||||
|
bottom: 8px;
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-template {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="theme-light">
|
||||||
|
<div id="panel"></div>
|
||||||
|
|
||||||
|
<div class="check-template">
|
||||||
|
<div class="name"></div>
|
||||||
|
<div class="lp"></div>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function fetch(key, callback) {
|
||||||
|
var httpRequest = new XMLHttpRequest();
|
||||||
|
httpRequest.onreadystatechange = function() {
|
||||||
|
if (httpRequest.readyState === 4) {
|
||||||
|
if (httpRequest.status === 200) {
|
||||||
|
callback(JSON.parse(httpRequest.responseText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
httpRequest.open("GET", "/api/v1/checks/");
|
||||||
|
httpRequest.setRequestHeader("X-Api-Key", key);
|
||||||
|
httpRequest.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeSince(date) {
|
||||||
|
var v = Math.floor((new Date() - date) / 1000);
|
||||||
|
|
||||||
|
if (v < 60) { // v is seconds
|
||||||
|
return v + " second" + (v == 1 ? "" : "s");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
v = Math.floor(v / 60); // v is now minutes
|
||||||
|
if (v < 60) {
|
||||||
|
return v + " minute" + (v == 1 ? "" : "s");
|
||||||
|
}
|
||||||
|
|
||||||
|
v = Math.floor(v / 60); // v is now hours
|
||||||
|
if (v < 24) {
|
||||||
|
return v + " hour" + (v == 1 ? "" : "s");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
v = Math.floor(v / 24); // v is now days
|
||||||
|
return v + " day" + (v == 1 ? "" : "s");
|
||||||
|
};
|
||||||
|
|
||||||
|
var template = document.querySelector(".check-template");
|
||||||
|
function updatePanel(node) {
|
||||||
|
fetch(node.dataset.readonlyKey, function(doc) {
|
||||||
|
var tag = "TAG_" + node.dataset.readonlyKey.substr(0, 6);
|
||||||
|
|
||||||
|
// Sort returned checks by name:
|
||||||
|
var sorted = doc.checks.sort(function(a, b) {
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
});
|
||||||
|
|
||||||
|
var fragment = document.createDocumentFragment();
|
||||||
|
sorted.forEach(function(item) {
|
||||||
|
var div = template.cloneNode(true);
|
||||||
|
div.setAttribute("class", tag + " status-" + item.status);
|
||||||
|
div.querySelector(".name").textContent = item.name || "unnamed";
|
||||||
|
if (item.last_ping) {
|
||||||
|
var s = timeSince(Date.parse(item.last_ping)) + " ago";
|
||||||
|
div.querySelector(".lp").textContent = s;
|
||||||
|
}
|
||||||
|
fragment.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
document.querySelectorAll('.' + tag).forEach(function(element) {
|
||||||
|
element.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
node.parentNode.insertBefore(fragment, node.nextSibling);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.hash) {
|
||||||
|
var panel;
|
||||||
|
|
||||||
|
var pairs = window.location.hash.substr(1).split("&");
|
||||||
|
for (var i=0, pair; pair=pairs[i]; i++) {
|
||||||
|
if (pair.indexOf("theme=") != -1) {
|
||||||
|
document.body.setAttribute("class", pair.replace("=", "-"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = pair.split("=");
|
||||||
|
var h1 = document.createElement("H1");
|
||||||
|
h1.dataset.readonlyKey = parts[0];
|
||||||
|
if (parts[1]) {
|
||||||
|
h1.innerText = decodeURIComponent(parts[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!panel) {
|
||||||
|
panel = document.getElementById("panel");
|
||||||
|
panel.innerHTML = "";
|
||||||
|
}
|
||||||
|
panel.appendChild(h1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.querySelectorAll("h1").forEach(updatePanel);
|
||||||
|
setInterval(function() {
|
||||||
|
document.querySelectorAll("h1").forEach(updatePanel);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
x
Reference in New Issue
Block a user