Initial commit
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
data
|
338
css/style.css
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
* {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Source Sans Pro', sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 100px rgba(0, 0, 0, .15);
|
||||||
|
transition: background-color 2s ease;
|
||||||
|
border-bottom: 1px solid #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chart-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: flex-start;
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-50 { width: 50%; }
|
||||||
|
.chart-100 { width: 100%; }
|
||||||
|
|
||||||
|
#overall-status-text {
|
||||||
|
#padding: 50px 10px 10px 10px;
|
||||||
|
color: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
#overall-status.green { background-color: #40D24C; }
|
||||||
|
#overall-status.yellow { background-color: #D2C640; }
|
||||||
|
#overall-status.red { background-color: #D24040; }
|
||||||
|
#overall-status.gray { background-color: #B8B8B8; }
|
||||||
|
|
||||||
|
#overall-status-text {
|
||||||
|
font: bold 42px 'Source Sans Pro', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobar {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(255, 255, 255, .8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobar .item {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timeline {
|
||||||
|
width: 25%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timeline > div {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
margin: 2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timeline > div:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timeline .message {
|
||||||
|
font-size: 14px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: solid;
|
||||||
|
background: #FFF;
|
||||||
|
margin-left: 2.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timeline .message-head,
|
||||||
|
#timeline .message-body {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timeline .message-head {
|
||||||
|
color: #FFF;
|
||||||
|
padding-top: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timeline .message.green { border-color: #40D24C; }
|
||||||
|
#timeline .message.green .message-head { background-color: #40D24C; }
|
||||||
|
#timeline .message.yellow { border-color: #FFAC3B; }
|
||||||
|
#timeline .message.yellow .message-head { background-color: #FFAC3B; }
|
||||||
|
#timeline .message.red { border-color: #D24040; }
|
||||||
|
#timeline .message.red .message-head { background-color: #D24040; }
|
||||||
|
|
||||||
|
#timeline .event {
|
||||||
|
line-height: 2em;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: auto 2em;
|
||||||
|
padding-left: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timeline .event.green { background-image: url('../images/status-green.png'); }
|
||||||
|
#timeline .event.yellow { background-image: url('../images/status-yellow.png'); }
|
||||||
|
#timeline .event.red { background-image: url('../images/status-red.png'); }
|
||||||
|
#timeline .event.gray { background-image: url('../images/status-gray.png'); }
|
||||||
|
|
||||||
|
#timeline .event .time {
|
||||||
|
margin-right: .25em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timeline #bg-line {
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
border-left: 3px solid #EEE;
|
||||||
|
left: 16px;
|
||||||
|
top: 0;
|
||||||
|
margin: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#big-gap {
|
||||||
|
display: none;
|
||||||
|
color: #CC0000;
|
||||||
|
padding-left: 3em;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title a {
|
||||||
|
color: #2D6F96;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font: 10px sans-serif;
|
||||||
|
max-width: 100%; /* thanks for nothin, Safari */
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container .chart svg {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis path,
|
||||||
|
.axis line {
|
||||||
|
fill: none;
|
||||||
|
stroke: #CCC;
|
||||||
|
stroke-width: 1px;
|
||||||
|
shape-rendering: crispEdges;
|
||||||
|
stroke-linecap: square;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
fill: none;
|
||||||
|
stroke: #CCC;
|
||||||
|
stroke-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container .chart .line {
|
||||||
|
stroke-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min.line,
|
||||||
|
.max.line {
|
||||||
|
stroke-dasharray: 1, 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container .chart .min.line,
|
||||||
|
.chart-container .chart .max.line {
|
||||||
|
stroke-width: 2px;
|
||||||
|
stroke-dasharray: 5, 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container .chart .tolerance.line {
|
||||||
|
stroke-width: 1px;
|
||||||
|
stroke-dasharray: 5, 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.min {
|
||||||
|
stroke: #B1DC9C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.main {
|
||||||
|
stroke: #333;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.max {
|
||||||
|
stroke: #FFA447;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.tolerance {
|
||||||
|
stroke: #FF7070;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
fill: none;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus circle {
|
||||||
|
fill: #333;
|
||||||
|
stroke: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus text {
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
/* this nonsense makes it look halfway decent across browsers */
|
||||||
|
text-shadow:
|
||||||
|
1px 1px 0 #FFF,
|
||||||
|
-1px -1px 0 #FFF,
|
||||||
|
1px -1px 0 #FFF,
|
||||||
|
-1px 1px 0 #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 50px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #AAA;
|
||||||
|
font-family: sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#checkup-logo {
|
||||||
|
height: 1.75em;
|
||||||
|
vertical-align: top;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.focus text {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chart-grid { width: 70%; }
|
||||||
|
#timeline { width: 30%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1050px) {
|
||||||
|
#chart-grid { width: 70%; }
|
||||||
|
#timeline { width: 30%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
#chart-grid { width: 60%; }
|
||||||
|
#timeline { width: 40%; }
|
||||||
|
.chart-50 { width: 100%; }
|
||||||
|
|
||||||
|
#overall-status-text {
|
||||||
|
font-size: 34px;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timeline .message {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infobar .item {
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-head,
|
||||||
|
.message-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-head {
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis path,
|
||||||
|
.axis line {
|
||||||
|
stroke: #DDD;
|
||||||
|
stroke-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
stroke-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min.line,
|
||||||
|
.max.line {
|
||||||
|
stroke-dasharray: 4, 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus text {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
#chart-grid { width: 100%; }
|
||||||
|
#timeline { width: 100%; }
|
||||||
|
}
|
BIN
images/checkup.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
images/degraded.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
images/favicon.png
Executable file
After Width: | Height: | Size: 5.0 KiB |
BIN
images/incident.png
Normal file
After Width: | Height: | Size: 980 B |
BIN
images/ok.png
Normal file
After Width: | Height: | Size: 569 B |
BIN
images/status-gray.png
Executable file
After Width: | Height: | Size: 1.6 KiB |
BIN
images/status-green.png
Executable file
After Width: | Height: | Size: 1.8 KiB |
BIN
images/status-red.png
Executable file
After Width: | Height: | Size: 1.8 KiB |
BIN
images/status-yellow.png
Executable file
After Width: | Height: | Size: 1.8 KiB |
48
index.html
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Status Page</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<script src="js/d3.v3.min.js" charset="utf-8"></script>
|
||||||
|
<script src="js/fs.js"></script>
|
||||||
|
<script src="js/checkup.js?v=2"></script>
|
||||||
|
<script src="js/config.js?v=2"></script>
|
||||||
|
<script src="js/statuspage.js?v=1"></script>
|
||||||
|
<link rel="icon" href="images/favicon.png" id="favicon">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,300,700">
|
||||||
|
<link rel="stylesheet" href="css/style.css?v=3">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header id="overall-status" class="gray">
|
||||||
|
<div id="overall-status-text">
|
||||||
|
Loading
|
||||||
|
</div>
|
||||||
|
<div class="infobar">
|
||||||
|
<div class="item">
|
||||||
|
<b><span id="info-checkcount">—</span> checks</b> in the last <b><span id="info-timeframe">—</span></b>
|
||||||
|
</div>
|
||||||
|
<div class="item">
|
||||||
|
<b>Last check:</b> <span id="info-lastcheck">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div id="chart-grid">
|
||||||
|
<!-- Populated by JavaScript -->
|
||||||
|
<span id="chart-placeholder"> </span>
|
||||||
|
</div>
|
||||||
|
<div id="timeline">
|
||||||
|
<div id="big-gap">
|
||||||
|
There is a big gap of time where no checkups were performed, so some graphs may look distorted.
|
||||||
|
</div>
|
||||||
|
<div id="bg-line"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Powered by <img src="images/checkup.png" id="checkup-logo">
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
231
js/checkup.js
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
// checkup is the global namespace for all checkup variables (except time).
|
||||||
|
var checkup = checkup || {};
|
||||||
|
|
||||||
|
// time provides simple nanosecond-based unit measurements.
|
||||||
|
var time = (function() {
|
||||||
|
// now gets the current time with millisecond accuracy,
|
||||||
|
// but as a unit of nanoseconds.
|
||||||
|
var now = function() {
|
||||||
|
return new Date().getTime() * 1e6;
|
||||||
|
};
|
||||||
|
var ns = 1,
|
||||||
|
us = 1000 * ns,
|
||||||
|
ms = 1000 * us,
|
||||||
|
second = 1000 * ms,
|
||||||
|
minute = 60 * second,
|
||||||
|
hour = 60 * minute,
|
||||||
|
day = 24 * hour,
|
||||||
|
week = 7 * day;
|
||||||
|
|
||||||
|
return {
|
||||||
|
Now: now,
|
||||||
|
Nanosecond: ns,
|
||||||
|
Microsecond: us,
|
||||||
|
Millisecond: ms,
|
||||||
|
Second: second,
|
||||||
|
Minute: minute,
|
||||||
|
Hour: hour,
|
||||||
|
Day: day,
|
||||||
|
Week: week
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// formatDuration formats d (in nanoseconds) with
|
||||||
|
// a proper unit suffix based on its value.
|
||||||
|
checkup.formatDuration = function(d) {
|
||||||
|
if (d == 0)
|
||||||
|
return d+"ms";
|
||||||
|
else if (d < time.Millisecond)
|
||||||
|
return Math.round(d*1e-3)+"µs";
|
||||||
|
else if (d < 10 * time.Second)
|
||||||
|
return Math.round(d*1e-6)+"ms";
|
||||||
|
else if (d < 90 * time.Second)
|
||||||
|
return Math.round(d*1e-9)+"s";
|
||||||
|
else if (d < 90 * time.Minute)
|
||||||
|
return Math.round(d*1e-9/60)+" minutes";
|
||||||
|
else if (d < 48 * time.Hour)
|
||||||
|
return Math.round(d*1e-9/60/60)+" hours";
|
||||||
|
else
|
||||||
|
return Math.round(d*1e-9 / 60/60/24)+" days";
|
||||||
|
};
|
||||||
|
|
||||||
|
// I'm not even joking
|
||||||
|
checkup.leftpad = function(str, len, ch) {
|
||||||
|
str = String(str);
|
||||||
|
var i = -1;
|
||||||
|
if (!ch && ch !== 0) ch = ' ';
|
||||||
|
len = len - str.length;
|
||||||
|
while (++i < len) str = ch + str;
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeSince renders the duration ms (in milliseconds) in human-friendly form.
|
||||||
|
checkup.timeSince = function(ms) {
|
||||||
|
var seconds = Math.floor((new Date() - ms) / 1000);
|
||||||
|
var interval = Math.floor(seconds / 31536000);
|
||||||
|
if (interval > 1) return interval + " years";
|
||||||
|
interval = Math.floor(seconds / 2592000);
|
||||||
|
if (interval > 1) return interval + " months";
|
||||||
|
interval = Math.floor(seconds / 86400);
|
||||||
|
if (interval > 1) return interval + " days";
|
||||||
|
interval = Math.floor(seconds / 3600);
|
||||||
|
if (interval > 1) return interval + " hours";
|
||||||
|
interval = Math.floor(seconds / 60);
|
||||||
|
if (interval > 1) return interval + " minutes";
|
||||||
|
return Math.floor(seconds) + " seconds";
|
||||||
|
};
|
||||||
|
|
||||||
|
// makeTimeTag returns a <time> tag (as a string) that
|
||||||
|
// has the time since the timestamp, ms (in milliseconds).
|
||||||
|
checkup.makeTimeTag = function(ms) {
|
||||||
|
// dateTimeString converts ms (in milliseconds) into
|
||||||
|
// a value usable in a <time> tag's datetime attribute.
|
||||||
|
function dateTimeString(ms) {
|
||||||
|
return (new Date(ms)).toUTCString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<time class="dynamic" datetime="'+dateTimeString(ms)+'">'
|
||||||
|
+ checkup.timeSince(ms)
|
||||||
|
+ '</time>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// All check files must have this suffix.
|
||||||
|
checkup.checkFileSuffix = "-check.json";
|
||||||
|
|
||||||
|
// Width and height of chart viewport scale
|
||||||
|
checkup.CHART_WIDTH = 600;
|
||||||
|
checkup.CHART_HEIGHT = 200;
|
||||||
|
|
||||||
|
// A couple bits of state to coordinate rendering the page
|
||||||
|
checkup.domReady = false; // whether DOM is loaded
|
||||||
|
checkup.graphsMade = false; // whether graphs have been rendered at least once
|
||||||
|
checkup.placeholdersRemoved = false; // whether chart placeholders have been removed
|
||||||
|
|
||||||
|
checkup.unixNanoToD3Timestamp = function(unixNanoTimestamp) {
|
||||||
|
return new Date(unixNanoTimestamp * 1e-6);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maps status names to their associated color class.
|
||||||
|
checkup.color = {healthy: "green", degraded: "yellow", down: "red"};
|
||||||
|
|
||||||
|
// Stores the checks that are downloaded (1:1 ratio with check files)
|
||||||
|
checkup.checks = [];
|
||||||
|
|
||||||
|
// Stores all the results, keyed by endpoint
|
||||||
|
checkup.results = {};
|
||||||
|
|
||||||
|
// Stores all the results, keyed by timestamp indicated in the JSON
|
||||||
|
// of the check file (may be multiple results with same timestamp)
|
||||||
|
checkup.groupedResults = {};
|
||||||
|
|
||||||
|
// Stores the results in ascending timestamp order; order may not be
|
||||||
|
// guaranteed until all results are loaded
|
||||||
|
checkup.orderedResults = [];
|
||||||
|
|
||||||
|
// Stores the charts (keyed by endpoint) and all their data/info/elements
|
||||||
|
checkup.charts = {};
|
||||||
|
|
||||||
|
// ID counter for the charts, always incremented
|
||||||
|
checkup.chartCounter = 0;
|
||||||
|
|
||||||
|
// ID counter for events generated by checks, always incremented
|
||||||
|
checkup.eventCounter = 0;
|
||||||
|
|
||||||
|
// Events that get rendered to the timeline
|
||||||
|
checkup.events = [];
|
||||||
|
|
||||||
|
// Duration of chart animations in ms
|
||||||
|
checkup.animDuration = 0;
|
||||||
|
|
||||||
|
// Quick, reusable access to DOM elements; populated after DOM loads
|
||||||
|
checkup.dom = {};
|
||||||
|
|
||||||
|
// Timestamp of the last result, (taken from the 'timestamp' field
|
||||||
|
// of JSON) as a Date() object.
|
||||||
|
checkup.lastResultTs = null;
|
||||||
|
|
||||||
|
// Timestamp of the last check (taken from the first part of the
|
||||||
|
// check file name).
|
||||||
|
checkup.lastCheckTs = null;
|
||||||
|
|
||||||
|
checkup.makeChart = function(title) {
|
||||||
|
var chart = {
|
||||||
|
id: "chart"+(checkup.chartCounter++),
|
||||||
|
title: title,
|
||||||
|
results: [],
|
||||||
|
series: {
|
||||||
|
min: [],
|
||||||
|
med: [],
|
||||||
|
max: [],
|
||||||
|
threshold: [],
|
||||||
|
events: [],
|
||||||
|
},
|
||||||
|
data: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// add series here to add more lines to a single chart; layered
|
||||||
|
// in order that they appear here (last series appears on top)
|
||||||
|
chart.data = [chart.series.threshold, chart.series.med];
|
||||||
|
|
||||||
|
return chart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// getJSON downloads the file at url and executes callback
|
||||||
|
// with the parsed JSON and the url as arguments.
|
||||||
|
checkup.getJSON = function(url, callback) {
|
||||||
|
var request = new XMLHttpRequest();
|
||||||
|
request.open('GET', url, true);
|
||||||
|
request.onload = function() {
|
||||||
|
if (request.status >= 200 && request.status < 400) {
|
||||||
|
var json = JSON.parse(request.responseText);
|
||||||
|
callback(json, url);
|
||||||
|
} else {
|
||||||
|
console.error("GET "+url+":", request);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = function() {
|
||||||
|
console.error("Network error (GET "+url+"):", request.error);
|
||||||
|
};
|
||||||
|
request.send();
|
||||||
|
};
|
||||||
|
|
||||||
|
checkup.loadScript = function(url, callback) {
|
||||||
|
var head = document.getElementsByTagName("head")[0];
|
||||||
|
var script = document.createElement("script");
|
||||||
|
script.type = "text/javascript";
|
||||||
|
script.src = url;
|
||||||
|
|
||||||
|
script.onreadystatechange = callback;
|
||||||
|
script.onload = callback;
|
||||||
|
|
||||||
|
head.appendChild(script);
|
||||||
|
};
|
||||||
|
|
||||||
|
// computeStats computes basic stats about a result.
|
||||||
|
checkup.computeStats = function(result) {
|
||||||
|
function median(values) {
|
||||||
|
values.sort(function(a, b) { return a.rtt - b.rtt; });
|
||||||
|
var half = Math.floor(values.length / 2);
|
||||||
|
if (values.length % 2 == 0)
|
||||||
|
return Math.round((values[half-1].rtt + values[half].rtt) / 2);
|
||||||
|
else
|
||||||
|
return values[half].rtt;
|
||||||
|
}
|
||||||
|
var sum = 0, min, max;
|
||||||
|
for (var i = 0; i < result.times.length; i++) {
|
||||||
|
var attempt = result.times[i];
|
||||||
|
if (!attempt.rtt) continue;
|
||||||
|
sum += attempt.rtt;
|
||||||
|
if (attempt.rtt < min || (typeof min === 'undefined'))
|
||||||
|
min = attempt.rtt;
|
||||||
|
if (attempt.rtt > max || (typeof max === 'undefined'))
|
||||||
|
max = attempt.rtt;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total: sum,
|
||||||
|
average: sum / result.times.length,
|
||||||
|
median: median(result.times),
|
||||||
|
min: min,
|
||||||
|
max: max
|
||||||
|
};
|
||||||
|
};
|
30
js/config.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
checkup.config = {
|
||||||
|
// How much history to show on the status page. Long durations and
|
||||||
|
// frequent checks make for slow loading, so be conservative.
|
||||||
|
// This value is in NANOSECONDS to mirror Go's time package.
|
||||||
|
"timeframe": 12 * time.Hour,
|
||||||
|
|
||||||
|
// How often, in seconds, to pull new checks and update the page.
|
||||||
|
"refresh_interval": 30,
|
||||||
|
|
||||||
|
// Configure read-only access to stored checks. This configuration
|
||||||
|
// depends on your storage provider. Any credentials and other values
|
||||||
|
// here will be visible to everyone, so use keys with ONLY read access!
|
||||||
|
"storage": {
|
||||||
|
// Amazon S3 - if using, ensure these are public, READ-ONLY credentials!
|
||||||
|
"AccessKeyID": "<key id here>",
|
||||||
|
"SecretAccessKey": "<not-so-secret key here>",
|
||||||
|
"Region": "<bucket region name here if you specified one>",
|
||||||
|
"BucketName": "<bucket name here>",
|
||||||
|
|
||||||
|
// Local file system (Caddy recommended: https://caddyserver.com)
|
||||||
|
"url": "https://status.simon987.net/data/"
|
||||||
|
},
|
||||||
|
|
||||||
|
// The text to display along the top bar depending on overall status.
|
||||||
|
"status_text": {
|
||||||
|
"healthy": "All services online",
|
||||||
|
"degraded": "Degraded Service",
|
||||||
|
"down": "Service Disruption"
|
||||||
|
}
|
||||||
|
};
|
5
js/d3.v3.min.js
vendored
Normal file
68
js/fs.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
|
||||||
|
FS Storage Adapter for Checkup.js
|
||||||
|
|
||||||
|
**/
|
||||||
|
|
||||||
|
var checkup = checkup || {};
|
||||||
|
|
||||||
|
checkup.storage = (function() {
|
||||||
|
var url;
|
||||||
|
|
||||||
|
// getCheckFileList gets the list of check files within
|
||||||
|
// the given timeframe (as a unit of nanoseconds) to
|
||||||
|
// download.
|
||||||
|
function getCheckFileList(timeframe, callback) {
|
||||||
|
var after = time.Now() - timeframe;
|
||||||
|
checkup.getJSON(url+'/index.json', function(index) {
|
||||||
|
var names = [];
|
||||||
|
for (var name in index) {
|
||||||
|
if (index[name] >= after) {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback(names);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// setup prepares this storage unit to operate.
|
||||||
|
this.setup = function(cfg) {
|
||||||
|
url = cfg.url;
|
||||||
|
};
|
||||||
|
|
||||||
|
// getChecksWithin gets all the checks within timeframe as a unit
|
||||||
|
// of nanoseconds, and executes callback for each check file.
|
||||||
|
this.getChecksWithin = function(timeframe, fileCallback, doneCallback) {
|
||||||
|
var checksLoaded = 0, resultsLoaded = 0;
|
||||||
|
getCheckFileList(timeframe, function(list) {
|
||||||
|
if (list.length == 0 && (typeof doneCallback === 'function')) {
|
||||||
|
doneCallback(checksLoaded);
|
||||||
|
} else {
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
checkup.getJSON(url+'/'+list[i], function(filename) {
|
||||||
|
return function(json, url) {
|
||||||
|
checksLoaded++;
|
||||||
|
resultsLoaded += json.length;
|
||||||
|
if (typeof fileCallback === 'function')
|
||||||
|
fileCallback(json, filename);
|
||||||
|
if (checksLoaded >= list.length && (typeof doneCallback === 'function'))
|
||||||
|
doneCallback(checksLoaded, resultsLoaded);
|
||||||
|
};
|
||||||
|
}(list[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// getNewChecks gets any checks since the timestamp on the file name
|
||||||
|
// of the youngest check file that has been downloaded. If no check
|
||||||
|
// files have been downloaded, no new check files will be loaded.
|
||||||
|
this.getNewChecks = function(fileCallback, doneCallback) {
|
||||||
|
if (!checkup.lastCheckTs == null)
|
||||||
|
return;
|
||||||
|
var timeframe = time.Now() - checkup.lastCheckTs;
|
||||||
|
return this.getChecksWithin(timeframe, fileCallback, doneCallback);
|
||||||
|
};
|
||||||
|
|
||||||
|
return this;
|
||||||
|
})();
|
513
js/statuspage.js
Normal file
@ -0,0 +1,513 @@
|
|||||||
|
// config.js must be included BEFORE this file!
|
||||||
|
|
||||||
|
// IE 11 Polyfill for .remove()
|
||||||
|
if (!('remove' in Element.prototype)) {
|
||||||
|
Element.prototype.remove = function() {
|
||||||
|
if (this.parentNode) {
|
||||||
|
this.parentNode.removeChild(this);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Configure access to storage
|
||||||
|
checkup.storage.setup(checkup.config.storage);
|
||||||
|
|
||||||
|
// Once the DOM is loaded, go ahead and render the graphs
|
||||||
|
// (if it hasn't been done already).
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
checkup.domReady = true;
|
||||||
|
|
||||||
|
checkup.dom.favicon = document.getElementById("favicon");
|
||||||
|
checkup.dom.status = document.getElementById("overall-status");
|
||||||
|
checkup.dom.statustext = document.getElementById("overall-status-text");
|
||||||
|
checkup.dom.timeframe = document.getElementById("info-timeframe");
|
||||||
|
checkup.dom.checkcount = document.getElementById("info-checkcount");
|
||||||
|
checkup.dom.lastcheck = document.getElementById("info-lastcheck");
|
||||||
|
checkup.dom.timeline = document.getElementById("timeline");
|
||||||
|
// Immediately begin downloading check files, and keep page updated
|
||||||
|
checkup.storage.getChecksWithin(checkup.config.timeframe, processNewCheckFile, allCheckFilesLoaded);
|
||||||
|
|
||||||
|
if (!checkup.graphsMade) makeGraphs();
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
setInterval(function() {
|
||||||
|
checkup.storage.getNewChecks(processNewCheckFile, allCheckFilesLoaded);
|
||||||
|
}, checkup.config.refresh_interval * 1000);
|
||||||
|
|
||||||
|
// Update "time ago" tags every so often
|
||||||
|
setInterval(function() {
|
||||||
|
var times = document.querySelectorAll("time.dynamic");
|
||||||
|
for (var i = 0; i < times.length; i++) {
|
||||||
|
var timeEl = times[i];
|
||||||
|
var ms = Date.parse(timeEl.getAttribute("datetime"));
|
||||||
|
timeEl.innerHTML = checkup.timeSince(ms);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
|
||||||
|
function processNewCheckFile(json, filename) {
|
||||||
|
checkup.checks.push(json);
|
||||||
|
|
||||||
|
// update the timestamp of the last check file's timestamp
|
||||||
|
var dashLoc = filename.indexOf("-");
|
||||||
|
if (dashLoc > 0) {
|
||||||
|
var checkTs = Number(filename.substr(0, dashLoc));
|
||||||
|
if (checkTs > checkup.lastCheckTs) {
|
||||||
|
checkup.lastCheckTs = checkTs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var process = function(result) {
|
||||||
|
checkup.orderedResults.push(result); // will sort later, more efficient that way
|
||||||
|
|
||||||
|
if (!checkup.groupedResults[result.timestamp])
|
||||||
|
checkup.groupedResults[result.timestamp] = [result];
|
||||||
|
else
|
||||||
|
checkup.groupedResults[result.timestamp].push(result);
|
||||||
|
|
||||||
|
if (!checkup.results[result.endpoint])
|
||||||
|
checkup.results[result.endpoint] = [result];
|
||||||
|
else
|
||||||
|
checkup.results[result.endpoint].push(result);
|
||||||
|
|
||||||
|
var chart = checkup.charts[result.endpoint] || checkup.makeChart(result.title);
|
||||||
|
chart.results.push(result);
|
||||||
|
|
||||||
|
var ts = checkup.unixNanoToD3Timestamp(result.timestamp);
|
||||||
|
chart.series.min.push({ timestamp: ts, rtt: Math.round(result.stats.min) });
|
||||||
|
chart.series.med.push({ timestamp: ts, rtt: Math.round(result.stats.median) });
|
||||||
|
chart.series.max.push({ timestamp: ts, rtt: Math.round(result.stats.max) });
|
||||||
|
if (result.threshold)
|
||||||
|
chart.series.threshold.push({ timestamp: ts, rtt: result.threshold });
|
||||||
|
|
||||||
|
if (!checkup.lastResultTs || ts > checkup.lastResultTs) {
|
||||||
|
checkup.lastResultTs = ts;
|
||||||
|
checkup.dom.lastcheck.innerHTML = checkup.makeTimeTag(checkup.lastResultTs)+" ago";
|
||||||
|
}
|
||||||
|
return chart;
|
||||||
|
};
|
||||||
|
|
||||||
|
// iterate each result and store/process it
|
||||||
|
json.forEach(function(result) {
|
||||||
|
// Save stats with the result so we don't have to recompute them later
|
||||||
|
result.stats = checkup.computeStats(result);
|
||||||
|
|
||||||
|
var chart = process(result);
|
||||||
|
checkup.charts[result.endpoint] = chart;
|
||||||
|
checkup.charts[result.endpoint].endpoint = result.endpoint;
|
||||||
|
});
|
||||||
|
|
||||||
|
var byTimestamp = function(a, b) {
|
||||||
|
return a.timestamp - b.timestamp;
|
||||||
|
};
|
||||||
|
var values = function(obj) {
|
||||||
|
return Object.keys(obj)
|
||||||
|
.map(function(key) { return obj[key]; });
|
||||||
|
};
|
||||||
|
|
||||||
|
values(checkup.charts)
|
||||||
|
.forEach(function(chart) {
|
||||||
|
values(chart.series)
|
||||||
|
.forEach(function(series) { series.sort(byTimestamp); })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (checkup.domReady)
|
||||||
|
makeGraphs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function allCheckFilesLoaded(numChecksLoaded, numResultsLoaded) {
|
||||||
|
// Sort the result lists
|
||||||
|
checkup.orderedResults.sort(function(a, b) { return a.timestamp - b.timestamp; });
|
||||||
|
for (var endpoint in checkup.results)
|
||||||
|
checkup.results[endpoint].sort(function(a, b) { return a.timestamp - b.timestamp; });
|
||||||
|
|
||||||
|
// Create events for the timeline
|
||||||
|
|
||||||
|
var newEvents = [];
|
||||||
|
var statuses = {}; // keyed by endpoint
|
||||||
|
|
||||||
|
// First load the last known status of each endpoint
|
||||||
|
for (var i = checkup.events.length-1; i >= 0; i--) {
|
||||||
|
var result = checkup.events[i].result;
|
||||||
|
if (!statuses[result.endpoint])
|
||||||
|
statuses[result.endpoint] = checkup.events[i].status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then go through the new results and look for new events
|
||||||
|
for (var i = checkup.orderedResults.length-numResultsLoaded; i < checkup.orderedResults.length; i++) {
|
||||||
|
var result = checkup.orderedResults[i];
|
||||||
|
|
||||||
|
var status = "healthy";
|
||||||
|
if (result.degraded) status = "degraded";
|
||||||
|
else if (result.down) status = "down";
|
||||||
|
|
||||||
|
if (status != statuses[result.endpoint]) {
|
||||||
|
// New event because status changed
|
||||||
|
newEvents.push({
|
||||||
|
id: checkup.eventCounter++,
|
||||||
|
result: result,
|
||||||
|
status: status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result.message) {
|
||||||
|
// New event because message posted
|
||||||
|
newEvents.push({
|
||||||
|
id: checkup.eventCounter++,
|
||||||
|
result: result,
|
||||||
|
status: status,
|
||||||
|
message: result.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses[result.endpoint] = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkup.events = checkup.events.concat(newEvents);
|
||||||
|
|
||||||
|
function renderTime(ns) {
|
||||||
|
var d = new Date(ns * 1e-6);
|
||||||
|
var hours = d.getHours();
|
||||||
|
var ampm = "AM";
|
||||||
|
if (hours > 12) {
|
||||||
|
hours -= 12;
|
||||||
|
ampm = "PM";
|
||||||
|
}
|
||||||
|
return hours+":"+checkup.leftpad(d.getMinutes(), 2, "0")+" "+ampm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render events
|
||||||
|
for (var i = 0; i < newEvents.length; i++) {
|
||||||
|
var e = newEvents[i];
|
||||||
|
|
||||||
|
// Save this event to the chart's event series so it will render on the graph
|
||||||
|
var imgFile = "ok.png", imgWidth = 15, imgHeight = 15; // the different icons look smaller/larger because of their shape
|
||||||
|
if (e.status == "down") { imgFile = "incident.png"; imgWidth = 20; imgHeight = 20; }
|
||||||
|
else if (e.status == "degraded") { imgFile = "degraded.png"; imgWidth = 25; imgHeight = 25; }
|
||||||
|
var chart = checkup.charts[e.result.endpoint];
|
||||||
|
chart.series.events.push({
|
||||||
|
timestamp: checkup.unixNanoToD3Timestamp(e.result.timestamp),
|
||||||
|
rtt: e.result.stats.median,
|
||||||
|
eventid: e.id,
|
||||||
|
imgFile: imgFile,
|
||||||
|
imgWidth: imgWidth,
|
||||||
|
imgHeight: imgHeight
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render event to timeline
|
||||||
|
var evtElem = document.createElement("div");
|
||||||
|
evtElem.setAttribute("data-eventid", e.id);
|
||||||
|
evtElem.classList.add("event-item");
|
||||||
|
evtElem.classList.add("event-id-"+e.id);
|
||||||
|
evtElem.classList.add(checkup.color[e.status]);
|
||||||
|
if (e.message) {
|
||||||
|
evtElem.classList.add("message");
|
||||||
|
evtElem.innerHTML = '<div class="message-head">'+checkup.makeTimeTag(e.result.timestamp*1e-6)+' ago</div>';
|
||||||
|
evtElem.innerHTML += '<div class="message-body">'+e.message+'</div>';
|
||||||
|
} else {
|
||||||
|
evtElem.classList.add("event");
|
||||||
|
evtElem.innerHTML = '<span class="time">'+renderTime(e.result.timestamp)+'</span> '+e.result.title+" "+e.status;
|
||||||
|
}
|
||||||
|
checkup.dom.timeline.insertBefore(evtElem, checkup.dom.timeline.childNodes[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update DOM now that we have the whole picture
|
||||||
|
|
||||||
|
// Update overall status
|
||||||
|
var overall = "healthy";
|
||||||
|
for (var endpoint in checkup.results) {
|
||||||
|
if (overall == "down") break;
|
||||||
|
var lastResult = checkup.results[endpoint][checkup.results[endpoint].length-1];
|
||||||
|
if (lastResult) {
|
||||||
|
if (lastResult.down)
|
||||||
|
overall = "down";
|
||||||
|
else if (lastResult.degraded)
|
||||||
|
overall = "degraded";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overall == "healthy") {
|
||||||
|
checkup.dom.favicon.href = "images/status-green.png";
|
||||||
|
checkup.dom.status.className = "green";
|
||||||
|
checkup.dom.statustext.innerHTML = checkup.config.status_text.healthy || "System Nominal";
|
||||||
|
} else if (overall == "degraded") {
|
||||||
|
checkup.dom.favicon.href = "images/status-yellow.png";
|
||||||
|
checkup.dom.status.className = "yellow";
|
||||||
|
checkup.dom.statustext.innerHTML = checkup.config.status_text.degraded || "Sub-Optimal";
|
||||||
|
} else if (overall == "down") {
|
||||||
|
checkup.dom.favicon.href = "images/status-red.png";
|
||||||
|
checkup.dom.status.className = "red";
|
||||||
|
checkup.dom.statustext.innerHTML = checkup.config.status_text.down || "Outage";
|
||||||
|
} else {
|
||||||
|
checkup.dom.favicon.href = "images/status-gray.png";
|
||||||
|
checkup.dom.status.className = "gray";
|
||||||
|
checkup.dom.statustext.innerHTML = checkup.config.status_text.unknown || "Status Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Detect big gaps in any of the charts, and if there is one, show an explanation.
|
||||||
|
var bigGap = false;
|
||||||
|
var lastTimeDiff;
|
||||||
|
for (var key in checkup.charts) {
|
||||||
|
// We expect results to be chronologically ordered, but since they are downloaded
|
||||||
|
// in an arbitrary order due to network conditions, we have to sort to be sure.
|
||||||
|
checkup.charts[key].results.sort(function(a, b) {
|
||||||
|
return a.timestamp - b.timestamp;
|
||||||
|
});
|
||||||
|
for (var k = 1; k < checkup.charts[key].results.length; k++) {
|
||||||
|
var timeDiff = Math.abs(checkup.charts[key].results[k].timestamp - checkup.charts[key].results[k-1].timestamp);
|
||||||
|
bigGap = lastTimeDiff && timeDiff > lastTimeDiff * 10;
|
||||||
|
lastTimeDiff = timeDiff;
|
||||||
|
if (bigGap) {
|
||||||
|
document.getElementById("big-gap").style.display = 'block';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bigGap) break;
|
||||||
|
}
|
||||||
|
if (!bigGap) {
|
||||||
|
document.getElementById("big-gap").style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
makeGraphs(); // must render graphs again after we've filled in the event series
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGraphs() {
|
||||||
|
checkup.dom.timeframe.innerHTML = checkup.formatDuration(checkup.config.timeframe);
|
||||||
|
checkup.dom.checkcount.innerHTML = checkup.checks.length;
|
||||||
|
|
||||||
|
if (!checkup.placeholdersRemoved && checkup.checks.length > 0) {
|
||||||
|
// Remove placeholder to make way for the charts;
|
||||||
|
// placeholder necessary to give space in absense of charts.
|
||||||
|
if (phElem = document.getElementById("chart-placeholder"))
|
||||||
|
phElem.remove();
|
||||||
|
checkup.placeholdersRemoved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var endpoint in checkup.charts) {
|
||||||
|
makeGraph(checkup.charts[endpoint], endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkup.graphsMade = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGraph(chart, endpoint) {
|
||||||
|
// Render chart to page if first time seeing this endpoint
|
||||||
|
if (!chart.elem) {
|
||||||
|
renderChart(chart);
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.xScale.domain([
|
||||||
|
d3.min(chart.data, function(c) { return d3.min(c, function(d) { return d.timestamp; }); }),
|
||||||
|
d3.max(chart.data, function(c) { return d3.max(c, function(d) { return d.timestamp; }); })
|
||||||
|
]);
|
||||||
|
chart.yScale.domain([
|
||||||
|
0,
|
||||||
|
d3.max(chart.data, function(c) { return d3.max(c, function(d) { return d.rtt; }); })
|
||||||
|
]);
|
||||||
|
|
||||||
|
chart.xAxis = d3.svg.axis()
|
||||||
|
.scale(chart.xScale)
|
||||||
|
.ticks(5)
|
||||||
|
.outerTickSize(0)
|
||||||
|
.orient("bottom");
|
||||||
|
|
||||||
|
chart.yAxis = d3.svg.axis()
|
||||||
|
.scale(chart.yScale)
|
||||||
|
.tickFormat(checkup.formatDuration)
|
||||||
|
.outerTickSize(0)
|
||||||
|
.ticks(2)
|
||||||
|
.orient("left");
|
||||||
|
|
||||||
|
if (chart.svg.selectAll(".x.axis")[0].length == 0) {
|
||||||
|
chart.svg.insert("g", ":first-child")
|
||||||
|
.attr("class", "x axis")
|
||||||
|
.attr("transform", "translate(0," + chart.height + ")")
|
||||||
|
.call(chart.xAxis);
|
||||||
|
} else {
|
||||||
|
chart.svg.selectAll(".x.axis")
|
||||||
|
//.transition()
|
||||||
|
//.duration(checkup.animDuration)
|
||||||
|
.call(chart.xAxis);
|
||||||
|
}
|
||||||
|
if (chart.svg.selectAll(".y.axis")[0].length == 0) {
|
||||||
|
chart.svg.insert("g", ":first-child")
|
||||||
|
.attr("class", "y axis")
|
||||||
|
.call(chart.yAxis);
|
||||||
|
} else {
|
||||||
|
chart.svg.selectAll(".y.axis")
|
||||||
|
//.transition()
|
||||||
|
//.duration(checkup.animDuration)
|
||||||
|
.call(chart.yAxis);
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.lines = chart.lineGroup.selectAll(".line")
|
||||||
|
.data(chart.data);
|
||||||
|
chart.events = chart.eventGroup.selectAll("image")
|
||||||
|
.data(chart.series.events);
|
||||||
|
|
||||||
|
// transition from old paths to new paths
|
||||||
|
chart.lines
|
||||||
|
//.transition()
|
||||||
|
//.duration(checkup.animDuration)
|
||||||
|
.attr("d", chart.line);
|
||||||
|
chart.events
|
||||||
|
//.transition()
|
||||||
|
//.duration(checkup.animDuration);
|
||||||
|
|
||||||
|
// enter any new data (lines)
|
||||||
|
chart.lines.enter()
|
||||||
|
.append("path")
|
||||||
|
.attr("class", function(d) {
|
||||||
|
if (d == chart.series.min) return "min line";
|
||||||
|
else if (d == chart.series.med) return "main line";
|
||||||
|
else if (d == chart.series.max) return "max line";
|
||||||
|
else if (d == chart.series.threshold) return "tolerance line";
|
||||||
|
else return "line";
|
||||||
|
})
|
||||||
|
.attr("d", chart.line);
|
||||||
|
|
||||||
|
// enter any new data (events)
|
||||||
|
chart.events.enter().append("svg:image")
|
||||||
|
.attr("width", function(d, i) { return d.imgWidth || 0; })
|
||||||
|
.attr("height", function(d, i) { return d.imgHeight || 0; })
|
||||||
|
.attr("xlink:href", function(d, i) { return "images/"+d.imgFile; })
|
||||||
|
.attr("x", function(d, i) { return chart.xScale(d.timestamp) - (d.imgWidth/2); })
|
||||||
|
.attr("y", function(d, i) { return chart.yScale(d.rtt) - (d.imgHeight/2); })
|
||||||
|
.attr("data-eventid", function(d, i) { return d.eventid; })
|
||||||
|
.attr("class", function(d, i) { return "event-item event-id-"+d.eventid; })
|
||||||
|
.on("mouseover", highlightSameEvent)
|
||||||
|
.on("mouseout", unhighlightSameEvent);
|
||||||
|
|
||||||
|
// exit any old data
|
||||||
|
chart.lines
|
||||||
|
.exit()
|
||||||
|
.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderChart(chart) {
|
||||||
|
// Outer div is a wrapper that we use for layout
|
||||||
|
var el = document.createElement('div');
|
||||||
|
var containerSize = "chart-50";
|
||||||
|
if (document.getElementsByClassName('chart-container').length == 0) {
|
||||||
|
containerSize = "chart-100";
|
||||||
|
} else {
|
||||||
|
// It's possible that a chart was created that, at the time,
|
||||||
|
// was the only one, but now it is too wide, since there are
|
||||||
|
// at least two charts. Resize the wide one to be smaller.
|
||||||
|
var tooWide = document.querySelector('.chart-container.chart-100');
|
||||||
|
if (tooWide)
|
||||||
|
tooWide.className = "chart-container chart-50";
|
||||||
|
}
|
||||||
|
el.className = "chart-container "+containerSize;
|
||||||
|
|
||||||
|
// Div to contain the endpoint / title
|
||||||
|
var el2 = document.createElement('div');
|
||||||
|
el2.className = "chart-title";
|
||||||
|
var el2b = document.createElement('a'); el2b.setAttribute("href", chart.endpoint);
|
||||||
|
el2b.appendChild(document.createTextNode(chart.title));
|
||||||
|
el2.appendChild(el2b);
|
||||||
|
el.appendChild(el2);
|
||||||
|
|
||||||
|
// Inner div is used to contain the actual svg tag
|
||||||
|
var el3 = document.createElement('div');
|
||||||
|
el3.className = "chart";
|
||||||
|
el.appendChild(el3);
|
||||||
|
|
||||||
|
// Inject elements into DOM
|
||||||
|
document.getElementById('chart-grid').appendChild(el);
|
||||||
|
|
||||||
|
// Save it with the chart and use D3 to set up its svg element.
|
||||||
|
chart.elem = el3;
|
||||||
|
chart.svgTag = d3.select(chart.elem)
|
||||||
|
.append("svg")
|
||||||
|
.attr("id", chart.id)
|
||||||
|
.attr("preserveAspectRatio", "xMinYMin meet")
|
||||||
|
.attr("viewBox", "0 0 "+checkup.CHART_WIDTH+" "+checkup.CHART_HEIGHT);
|
||||||
|
|
||||||
|
chart.margin = {top: 20, right: 20, bottom: 40, left: 75};
|
||||||
|
chart.width = checkup.CHART_WIDTH - chart.margin.left - chart.margin.right;
|
||||||
|
chart.height = checkup.CHART_HEIGHT - chart.margin.top - chart.margin.bottom;
|
||||||
|
|
||||||
|
// chart.svgTag is the svg tag, but chart.svg
|
||||||
|
// is the group where we actually put the lines.
|
||||||
|
chart.svg = chart.svgTag
|
||||||
|
.append("g")
|
||||||
|
.attr("class", "chart-data")
|
||||||
|
.attr("transform", "translate(" + chart.margin.left + "," + chart.margin.top + ")");
|
||||||
|
|
||||||
|
chart.xScale = d3.time.scale()
|
||||||
|
.range([0, chart.width]);
|
||||||
|
|
||||||
|
chart.yScale = d3.scale.linear()
|
||||||
|
.range([chart.height, 0]);
|
||||||
|
|
||||||
|
chart.line = d3.svg.line()
|
||||||
|
.x(function(d) { return chart.xScale(d.timestamp); })
|
||||||
|
.y(function(d) { return chart.yScale(d.rtt); })
|
||||||
|
.interpolate("step-after"); // linear, monotone, or basis
|
||||||
|
|
||||||
|
|
||||||
|
chart.lineGroup = chart.svg
|
||||||
|
.append("g")
|
||||||
|
.attr("class", "lines");
|
||||||
|
|
||||||
|
var focus = chart.svg
|
||||||
|
.append("g")
|
||||||
|
.attr("class", "focus")
|
||||||
|
.style("display", "none");
|
||||||
|
|
||||||
|
focus.append("circle")
|
||||||
|
.attr("r", 6);
|
||||||
|
|
||||||
|
var text = focus.append("text")
|
||||||
|
.attr("x", 9)
|
||||||
|
.attr("dy", ".35em")
|
||||||
|
.attr("class", "focus-text");
|
||||||
|
|
||||||
|
|
||||||
|
// Next we build an overlay to cover the data area,
|
||||||
|
// so when the mouse hovers it we can show the point.
|
||||||
|
var bisectDate = d3.bisector(function(d) { return d.timestamp; }).left;
|
||||||
|
var overlay;
|
||||||
|
var mousemove = function() {
|
||||||
|
var x0 = chart.xScale.invert(d3.mouse(this)[0]),
|
||||||
|
i = bisectDate(chart.series.med, x0, 1),
|
||||||
|
d0 = chart.series.med[i - 1],
|
||||||
|
d1 = chart.series.med[i],
|
||||||
|
d = (d0 && d1) ? (x0 - d0.timestamp > d1.timestamp - x0 ? d1 : d0) : (d0 || d1);
|
||||||
|
var xloc = chart.xScale(d.timestamp);
|
||||||
|
focus.attr("transform", "translate(" + xloc + "," + chart.yScale(d.rtt) + ")");
|
||||||
|
if (xloc > overlay.width.animVal.value - 50)
|
||||||
|
text.attr("transform", "translate(-60, 10)");
|
||||||
|
else
|
||||||
|
text.attr("transform", "translate(0, 10)");
|
||||||
|
focus.select("text").text(checkup.formatDuration(d.rtt));
|
||||||
|
};
|
||||||
|
chart.svg.append("rect")
|
||||||
|
.attr("class", "overlay")
|
||||||
|
.attr("width", chart.width)
|
||||||
|
.attr("height", chart.height)
|
||||||
|
.on("mouseover", function() { focus.style("display", null); })
|
||||||
|
.on("mouseout", function() { focus.style("display", "none"); })
|
||||||
|
.on("mousemove", mousemove);
|
||||||
|
overlay = document.querySelector("#"+chart.id+" .overlay");
|
||||||
|
|
||||||
|
chart.eventGroup = chart.svg
|
||||||
|
.append("g")
|
||||||
|
.attr("class", "events");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function highlightSameEvent() {
|
||||||
|
var elems = document.querySelectorAll(".event-item:not(.event-id-"+this.getAttribute("data-eventid")+")");
|
||||||
|
for (var i = 0; i < elems.length; i++) {
|
||||||
|
elems[i].style.opacity = ".25";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unhighlightSameEvent() {
|
||||||
|
var elems = document.querySelectorAll(".event-item:not(.event-id-"+this.getAttribute("data-eventid")+")");
|
||||||
|
for (var i = 0; i < elems.length; i++) {
|
||||||
|
elems[i].style.opacity = "";
|
||||||
|
}
|
||||||
|
}
|