/* SPDX-License-Identifier: GPL-3.0-or-later */
var colours = ["#081d58", "#253494", "#225ea8", "#1d91c0", "#41b6c4", "#7fcdbb", "#c7e9b4", "#edf8b1", "#edf8b1"];
var latency = ["slow", "1500ms", "1000ms", "500ms", "250ms", "100ms", "50ms", "10ms", "1ms"];
var Socket = "MozWebSocket" in window ? MozWebSocket : WebSocket;
let isGraphPaused = false;
$(function() {
/* Helper functions */
function colorBracket(rtt) {
for (var i = latency.length - 1; i >= 0; i--) {
if (rtt <= parseInt(latency[i])) {
return 'q' + i;
}
}
return 'q8';
}
function toGeokey(lon, lat) {
return lon.toFixed(0)+'#'+lat.toFixed(0);
}
function updateVisibility(graph, metrics, id, toggle) {
/* Some labels are aggregates */
if (metrics[id] == null) {
for (var key in metrics) {
const m = metrics[key];
if (m.length > 3 && m[3] == id) {
graph.setVisibility(m[0], toggle);
}
}
} else {
graph.setVisibility(metrics[id][0], toggle);
}
}
function formatNumber(n) {
with (Math) {
var base = floor(log(abs(n))/log(1000));
var suffix = 'KMB'[base-1];
return suffix ? String(n/pow(1000,base)).substring(0,3)+suffix : ''+n;
}
}
/* Initialize snippets. */
$('section').each(function () {
const heading = $(this).find('h2');
$('#modules-dropdown').append('
'+heading.text()+'');
});
/* Render other interesting metrics as lines (hidden by default) */
var data = [];
var last_metric = 17;
var metrics = {
'answer.noerror': [0, 'NOERROR', null, 'By RCODE'],
'answer.nodata': [1, 'NODATA', null, 'By RCODE'],
'answer.nxdomain': [2, 'NXDOMAIN', null, 'By RCODE'],
'answer.servfail': [3, 'SERVFAIL', null, 'By RCODE'],
'answer.dnssec': [4, 'DNSSEC', null, 'By RCODE'],
'cache.hit': [5, 'Cache hit'],
'cache.miss': [6, 'Cache miss'],
'cache.insert': [7, 'Cache insert'],
'cache.delete': [8, 'Cache delete'],
'worker.udp': [9, 'UDP queries'],
'worker.tcp': [10, 'TCP queries'],
'worker.ipv4': [11, 'IPv4 queries'],
'worker.ipv6': [12, 'IPv6 queries'],
'worker.concurrent': [13, 'Concurrent requests'],
'worker.queries': [14, 'Queries received/s'],
'worker.dropped': [15, 'Queries dropped'],
'worker.usertime': [16, 'CPU (user)', null, 'Workers'],
'worker.systime': [17, 'CPU (sys)', null, 'Workers'],
};
/* Render latency metrics as sort of a heatmap */
var series = {};
for (var i in latency) {
const name = 'RTT '+latency[i];
const colour = colours[colours.length - i - 1];
last_metric = last_metric + 1;
metrics['answer.'+latency[i]] = [last_metric, name, colour, 'latency'];
series[name] = {fillGraph: true, color: colour, fillAlpha: 1.0};
}
var labels = ['x'];
var visibility = [];
for (var key in metrics) {
labels.push(metrics[key][1]);
visibility.push(false);
}
/* Define how graph looks like. */
const graphContainer = $('#stats');
const graph = new Dygraph(
document.getElementById("chart"),
data, {
labels: labels,
labelsUTC: true,
labelsShowZeroValues: false,
visibility: visibility,
axes: { y: {
axisLabelFormatter: function(d) {
return formatNumber(d) + 'pps';
},
}},
series: series,
strokeWidth: 1,
highlightSeriesOpts: {
strokeWidth: 3,
strokeBorderWidth: 1,
highlightCircleSize: 5,
},
});
/* Define metric selector */
const chartSelector = $('#chart-selector').selectize({
maxItems: null,
create: false,
onItemAdd: function (x) { updateVisibility(graph, metrics, x, true); },
onItemRemove: function (x) { updateVisibility(graph, metrics, x, false); }
})[0].selectize;
for (var key in metrics) {
const m = metrics[key];
const groupid = m.length > 3 ? m[3] : key.split('.')[0];
const group = m.length > 3 ? m[3] : m[1].split(' ')[0];
/* Latency has a special aggregated item */
if (group != 'latency') {
chartSelector.addOptionGroup(groupid, { label: group } );
chartSelector.addOption({ text: m[1], value: key, optgroup: groupid });
}
}
/* Add latency as default */
chartSelector.addOption({ text: 'Latency', value: 'latency', optgroup: 'Queries' });
chartSelector.addItem('latency');
/* Add stacked graph control */
$('#chart-stacked').on('change', function(e) {
graph.updateOptions({stackedGraph: this.checked});
}).click();
/* Data map */
var fills = { defaultFill: '#F5F5F5' };
for (var i in colours) {
fills['q' + i] = colours[i];
}
const map = new Datamap({
element: document.getElementById('map'),
fills: fills,
data: {},
height: 400,
geographyConfig: {
highlightOnHover: false,
borderColor: '#ccc',
borderWidth: 0.5,
popupTemplate: function(geo, data) {
return ['',
'', geo.properties.name, '',
'
Queries: ', data ? data.queries : '0', '',
'
'].join('');
}
},
bubblesConfig: {
popupTemplate: function(geo, data) {
return ['',
'', data.name, '',
'
Queries: ', data ? data.queries : '0', '',
'
Average RTT: ', data ? parseInt(data.rtt) : '0', ' ms',
'
'].join('');
}
}
});
/* Realtime updates over WebSockets */
function pushMetrics(resp, now, buffer) {
var line = new Array(labels.length);
line[0] = new Date(now * 1000);
for (var lb in resp) {
/* Push new datapoints */
const metric = metrics[lb];
if (metric) {
line[metric[0] + 1] = resp[lb];
}
}
/* Buffer graph changes. */
data.push(line);
if (data.length > 1000) {
data.shift();
}
if ( !buffer ) {
if ( !isGraphPaused ) {
graph.updateOptions( { 'file': data } );
}
}
}
var age = 0;
var bubbles = [];
var bubblemap = {};
function pushUpstreams(resp) {
if (resp == null) {
$('#map-container').hide();
return;
} else {
$('#map-container').show();
}
/* Get current maximum number of queries for bubble diameter adjustment */
var maxQueries = 1;
for (var key in resp) {
var val = resp[key];
if ('data' in val) {
maxQueries = Math.max(maxQueries, resp[key].data.length)
}
}
/* Update bubbles and prune the oldest */
for (var key in resp) {
var val = resp[key];
if (!val.data || !val.location || val.location.longitude == null) {
continue;
}
var sum = val.data.reduce(function(a, b) { return a + b; });
var avg = sum / val.data.length;
var geokey = toGeokey(val.location.longitude, val.location.latitude)
var found = bubblemap[geokey];
if (!found) {
found = {
name: [key],
longitude: val.location.longitude,
latitude: val.location.latitude,
queries: 0,
rtt: avg,
}
bubbles.push(found);
bubblemap[geokey] = found;
}
/* Update bubble parameters */
if (!(key in found.name)) {
found.name.push(key);
}
found.rtt = (found.rtt + avg) / 2.0;
found.fillKey = colorBracket(found.rtt);
found.queries = found.queries + val.data.length;
found.radius = Math.max(5, 15*(val.data.length/maxQueries));
found.age = age;
}
/* Prune bubbles not updated in a while. */
for (var i in bubbles) {
var b = bubbles[i];
if (b.age <= age - 5) {
bubbles.splice(i, 1)
bubblemap[i] = null;
}
}
map.bubbles(bubbles);
age = age + 1;
}
/* Per-worker information */
function updateRate(x, y, dt) {
return (100.0 * ((x - y) / dt)).toFixed(1);
}
function updateWorker(row, next, data, timestamp, buffer) {
const dt = timestamp - data.timestamp;
const cell = row.find('td');
/* Update spark lines and CPU times first */
if (dt > 0.0) {
const utimeRate = updateRate(next.usertime, data.last.usertime, dt);
const stimeRate = updateRate(next.systime, data.last.systime, dt);
cell.eq(1).find('span').text(utimeRate + '% / ' + stimeRate + '%');
/* Update sparkline graph */
data.data.push([new Date(timestamp * 1000), Number(utimeRate), Number(stimeRate)]);
if (data.data.length > 60) {
data.data.shift();
}
if (!buffer) {
data.graph.updateOptions( { 'file': data.data } );
}
}
/* Update other fields */
if (!buffer) {
cell.eq(2).text(formatNumber(next.rss) + 'B');
cell.eq(3).text(next.pagefaults);
cell.eq(4).text('Healthy').addClass('text-success');
}
}
var workerData = {};
function pushWorkers(resp, timestamp, buffer) {
if (resp == null) {
return;
}
const workerTable = $('#workers');
for (var pid in resp) {
var row = workerTable.find('tr[data-pid='+pid+']');
if (row.length == 0) {
row = workerTable.append(
''+pid+' | '+
' | | | | '+
'
');
/* Create sparkline visualisation */
const spark = row.find('#spark-'+pid);
spark.css({'margin-right': '1em', width: '80px', height: '1.4em'});
workerData[pid] = {timestamp: timestamp, data: [[new Date(timestamp * 1000),0,0]], last: resp[pid]};
const workerGraph = new Dygraph(spark[0],
workerData[pid].data, {
valueRange: [0, 100],
legend: 'never',
axes : {
x : {
drawGrid: false,
drawAxis : false,
},
y : {
drawGrid: false,
drawAxis : false,
}
},
labels: ['x', '%user', '%sys'],
labelsDiv: '',
stackedGraph: true,
}
);
workerData[pid].graph = workerGraph;
}
updateWorker(row, resp[pid], workerData[pid], timestamp, buffer);
/* Track last datapoint */
workerData[pid].last = resp[pid];
workerData[pid].timestamp = timestamp;
}
/* Prune unhealthy PIDs */
if (!buffer) {
workerTable.find('tr').each(function () {
const e = $(this);
if (!(e.data('pid') in resp)) {
const healthCell = e.find('td').last();
healthCell.removeClass('text-success')
healthCell.text('Dead').addClass('text-danger');
}
});
}
}
/* WebSocket endpoints */
var wsStats = ('https:' == document.location.protocol ? 'wss://' : 'ws://') + location.host + '/stats';
var ws = new Socket(wsStats);
ws.onmessage = function(evt) {
var data = JSON.parse(evt.data);
if (data[0]) {
if (data.length > 0) {
pushUpstreams(data[data.length - 1].upstreams);
}
/* Buffer datapoints and redraw last */
for (var i in data) {
const is_last = (i == data.length - 1);
pushWorkers(data[i].workers, data[i].time, !is_last);
pushMetrics(data[i].stats, data[i].time, !is_last);
}
} else {
pushUpstreams(data.upstreams);
pushWorkers(data.workers, data.time);
pushMetrics(data.stats, data.time);
}
};
chartElement.addEventListener( 'mouseover', ( event ) =>
{
isGraphPaused = true;
}, false );
chartElement.addEventListener( 'mouseout', ( event ) =>
{
isGraphPaused = false;
}, false );
});