990 lines
29 KiB
JavaScript
990 lines
29 KiB
JavaScript
/* Pi-hole: A black hole for Internet advertisements
|
||
* (c) 2017 Pi-hole, LLC (https://pi-hole.net)
|
||
* Network-wide ad blocking via your own hardware.
|
||
*
|
||
* This file is copyright under the latest version of the EUPL.
|
||
* Please see LICENSE file for your rights under this license. */
|
||
|
||
/* global utils:false, Chart:false, apiFailure:false, THEME_COLORS:false, customTooltips:false, htmlLegendPlugin:false,doughnutTooltip:false, ChartDeferred:false, REFRESH_INTERVAL: false, updateQueryFrequency: false, i18n */
|
||
|
||
"use strict";
|
||
|
||
// Define global variables
|
||
let timeLineChart;
|
||
let clientsChart;
|
||
let queryTypePieChart;
|
||
let forwardDestinationPieChart;
|
||
let privacyLevel = 0;
|
||
|
||
// Register the ChartDeferred plugin to all charts:
|
||
Chart.register(ChartDeferred);
|
||
Chart.defaults.set("plugins.deferred", {
|
||
yOffset: "20%",
|
||
delay: 300,
|
||
});
|
||
|
||
// Set the privacy level
|
||
function initPrivacyLevel() {
|
||
return $.ajax({
|
||
url: document.body.dataset.apiurl + "/info/ftl",
|
||
})
|
||
.done(data => {
|
||
privacyLevel = data.ftl.privacy_level;
|
||
})
|
||
.fail(data => {
|
||
apiFailure(data);
|
||
// Set privacy level to 0 by default if the request fails
|
||
privacyLevel = 0;
|
||
});
|
||
}
|
||
|
||
// Functions to update data in page
|
||
|
||
let failures = 0;
|
||
function updateQueriesOverTime() {
|
||
$.getJSON(document.body.dataset.apiurl + "/history", data => {
|
||
// Remove graph if there are no results (e.g. new
|
||
// installation or privacy mode enabled)
|
||
if (jQuery.isEmptyObject(data.history)) {
|
||
$("#queries-over-time").remove();
|
||
return;
|
||
}
|
||
|
||
// Remove possibly already existing data
|
||
timeLineChart.data.labels = [];
|
||
timeLineChart.data.datasets = [];
|
||
|
||
const labels = [
|
||
i18n.t("index.other_dns_queries"),
|
||
i18n.t("index.blocked_dns_queries"),
|
||
i18n.t("index.cached_dns_queries"),
|
||
i18n.t("index.forwarded_dns_queries"),
|
||
];
|
||
const cachedColor = utils.getCSSval("queries-cached", "background-color");
|
||
const blockedColor = utils.getCSSval("queries-blocked", "background-color");
|
||
const permittedColor = utils.getCSSval("queries-permitted", "background-color");
|
||
const otherColor = utils.getCSSval("queries-other", "background-color");
|
||
const colors = [otherColor, blockedColor, cachedColor, permittedColor];
|
||
|
||
// Collect values and colors, and labels
|
||
for (const [i, label] of labels.entries()) {
|
||
timeLineChart.data.datasets.push({
|
||
data: [],
|
||
// If we ran out of colors, make a random one
|
||
backgroundColor: colors[i],
|
||
pointRadius: 0,
|
||
pointHitRadius: 5,
|
||
pointHoverRadius: 5,
|
||
label,
|
||
cubicInterpolationMode: "monotone",
|
||
});
|
||
}
|
||
|
||
// Add data for each dataset that is available
|
||
for (const item of data.history) {
|
||
const timestamp = new Date(1000 * Number.parseInt(item.timestamp, 10));
|
||
|
||
timeLineChart.data.labels.push(timestamp);
|
||
const other = item.total - (item.blocked + item.cached + item.forwarded);
|
||
timeLineChart.data.datasets[0].data.push(other);
|
||
timeLineChart.data.datasets[1].data.push(item.blocked);
|
||
timeLineChart.data.datasets[2].data.push(item.cached);
|
||
timeLineChart.data.datasets[3].data.push(item.forwarded);
|
||
}
|
||
|
||
$("#queries-over-time .overlay").hide();
|
||
timeLineChart.update();
|
||
})
|
||
.done(() => {
|
||
failures = 0;
|
||
utils.setTimer(updateQueriesOverTime, REFRESH_INTERVAL.history);
|
||
})
|
||
.fail(() => {
|
||
failures++;
|
||
if (failures < 5) {
|
||
// Try again ´only if this has not failed more than five times in a row
|
||
utils.setTimer(updateQueriesOverTime, 0.1 * REFRESH_INTERVAL.history);
|
||
}
|
||
})
|
||
.fail(data => {
|
||
apiFailure(data);
|
||
});
|
||
}
|
||
|
||
function updateQueryTypesPie() {
|
||
$.getJSON(document.body.dataset.apiurl + "/stats/query_types", data => {
|
||
const v = [];
|
||
const c = [];
|
||
const k = [];
|
||
let i = 0;
|
||
let sum = 0;
|
||
|
||
// Compute total number of queries
|
||
for (const value of Object.values(data.types)) {
|
||
sum += value;
|
||
}
|
||
|
||
// Fill chart with data (only include query types which appeared recently)
|
||
for (const [item, value] of Object.entries(data.types)) {
|
||
if (value > 0) {
|
||
v.push((100 * value) / sum);
|
||
c.push(THEME_COLORS[i % THEME_COLORS.length]);
|
||
k.push(item);
|
||
}
|
||
|
||
i++;
|
||
}
|
||
|
||
// Build a single dataset with the data to be pushed
|
||
const dd = { data: v, backgroundColor: c };
|
||
// and push it at once
|
||
queryTypePieChart.data.datasets[0] = dd;
|
||
queryTypePieChart.data.labels = k;
|
||
$("#query-types-pie .overlay").hide();
|
||
// Passing 'none' will prevent rotation animation for further updates
|
||
//https://www.chartjs.org/docs/latest/developers/updates.html#preventing-animations
|
||
queryTypePieChart.update("none");
|
||
})
|
||
.done(() => {
|
||
utils.setTimer(updateQueryTypesPie, REFRESH_INTERVAL.query_types);
|
||
})
|
||
.fail(data => {
|
||
apiFailure(data);
|
||
});
|
||
}
|
||
|
||
function updateClientsOverTime() {
|
||
$.getJSON(document.body.dataset.apiurl + "/history/clients", data => {
|
||
// Remove graph if there are no results (e.g. new
|
||
// installation or privacy mode enabled)
|
||
if (jQuery.isEmptyObject(data.history)) {
|
||
$("#clients").remove();
|
||
return;
|
||
}
|
||
|
||
let numClients = 0;
|
||
const labels = [];
|
||
const clients = {};
|
||
for (const [ip, clientData] of Object.entries(data.clients)) {
|
||
clients[ip] = numClients++;
|
||
labels.push(clientData.name !== null ? clientData.name : ip);
|
||
}
|
||
|
||
// Remove possibly already existing data
|
||
clientsChart.data.labels = [];
|
||
clientsChart.data.datasets = [];
|
||
|
||
for (let i = 0; i < numClients; i++) {
|
||
clientsChart.data.datasets.push({
|
||
data: [],
|
||
// If we ran out of colors, make a random one
|
||
backgroundColor:
|
||
i < THEME_COLORS.length
|
||
? THEME_COLORS[i]
|
||
: "#" + (0x1_00_00_00 + Math.random() * 0xff_ff_ff).toString(16).substr(1, 6),
|
||
pointRadius: 0,
|
||
pointHitRadius: 5,
|
||
pointHoverRadius: 5,
|
||
label: labels[i],
|
||
cubicInterpolationMode: "monotone",
|
||
});
|
||
}
|
||
|
||
// Add data for each dataset that is available
|
||
// We need to iterate over all time slots and fill in the data for each client
|
||
for (const item of Object.values(data.history)) {
|
||
for (const [client, index] of Object.entries(clients)) {
|
||
const clientData = item.data[client];
|
||
// If there is no data for this client in this timeslot, we push 0, otherwise the data
|
||
clientsChart.data.datasets[index].data.push(clientData === undefined ? 0 : clientData);
|
||
}
|
||
}
|
||
|
||
// Extract data timestamps
|
||
for (const item of data.history) {
|
||
const d = new Date(1000 * Number.parseInt(item.timestamp, 10));
|
||
clientsChart.data.labels.push(d);
|
||
}
|
||
|
||
$("#clients .overlay").hide();
|
||
clientsChart.update();
|
||
})
|
||
.done(() => {
|
||
// Reload graph after 10 minutes
|
||
failures = 0;
|
||
utils.setTimer(updateClientsOverTime, REFRESH_INTERVAL.clients);
|
||
})
|
||
.fail(() => {
|
||
failures++;
|
||
if (failures < 5) {
|
||
// Try again only if this has not failed more than five times in a row
|
||
utils.setTimer(updateClientsOverTime, 0.1 * REFRESH_INTERVAL.clients);
|
||
}
|
||
})
|
||
.fail(data => {
|
||
apiFailure(data);
|
||
});
|
||
}
|
||
|
||
const upstreams = {};
|
||
function updateForwardDestinationsPie() {
|
||
$.getJSON(document.body.dataset.apiurl + "/stats/upstreams", data => {
|
||
const v = [];
|
||
const c = [];
|
||
const k = [];
|
||
let i = 0;
|
||
let sum = 0;
|
||
const values = [];
|
||
|
||
// Compute total number of queries
|
||
for (const item of data.upstreams) {
|
||
sum += item.count;
|
||
}
|
||
|
||
// Collect values and colors
|
||
for (const item of data.upstreams) {
|
||
let label = item.name !== null && item.name.length > 0 ? item.name : item.ip;
|
||
if (item.port > 0) {
|
||
label += "#" + item.port;
|
||
}
|
||
|
||
// Store upstreams for generating links to the Query Log
|
||
upstreams[label] = item.ip;
|
||
if (item.port > 0) {
|
||
upstreams[label] += "#" + item.port;
|
||
}
|
||
|
||
const percent = (100 * item.count) / sum;
|
||
values.push([label, percent, THEME_COLORS[i++ % THEME_COLORS.length]]);
|
||
}
|
||
|
||
// Split data into individual arrays for the graphs
|
||
for (const value of values) {
|
||
k.push(value[0]);
|
||
v.push(value[1]);
|
||
c.push(value[2]);
|
||
}
|
||
|
||
// Build a single dataset with the data to be pushed
|
||
const dd = { data: v, backgroundColor: c };
|
||
// and push it at once
|
||
forwardDestinationPieChart.data.labels = k;
|
||
forwardDestinationPieChart.data.datasets[0] = dd;
|
||
// and push it at once
|
||
$("#forward-destinations-pie .overlay").hide();
|
||
|
||
// Passing 'none' will prevent rotation animation for further updates
|
||
//https://www.chartjs.org/docs/latest/developers/updates.html#preventing-animations
|
||
queryTypePieChart.update("none");
|
||
forwardDestinationPieChart.update("none");
|
||
})
|
||
.done(() => {
|
||
utils.setTimer(updateForwardDestinationsPie, REFRESH_INTERVAL.upstreams);
|
||
})
|
||
.fail(data => {
|
||
apiFailure(data);
|
||
});
|
||
}
|
||
|
||
function updateTopClientsTable(blocked) {
|
||
let api;
|
||
let style;
|
||
let table;
|
||
let tablecontent;
|
||
let overlay;
|
||
let clienttable;
|
||
if (blocked) {
|
||
api = document.body.dataset.apiurl + "/stats/top_clients?blocked=true";
|
||
style = "queries-blocked";
|
||
table = $("#client-frequency-blocked");
|
||
tablecontent = $("#client-frequency-blocked td").parent();
|
||
overlay = $("#client-frequency-blocked .overlay");
|
||
clienttable = $("#client-frequency-blocked").find("tbody:last");
|
||
} else {
|
||
api = document.body.dataset.apiurl + "/stats/top_clients";
|
||
style = "queries-permitted";
|
||
table = $("#client-frequency");
|
||
tablecontent = $("#client-frequency td").parent();
|
||
overlay = $("#client-frequency .overlay");
|
||
clienttable = $("#client-frequency").find("tbody:last");
|
||
}
|
||
|
||
$.getJSON(api, data => {
|
||
// Clear tables before filling them with data
|
||
tablecontent.remove();
|
||
let url;
|
||
let percentage;
|
||
const sum = blocked ? data.blocked_queries : data.total_queries;
|
||
|
||
// When there is no data...
|
||
// a) remove table if there are no results (privacy mode enabled) or
|
||
// b) add note if there are no results (e.g. new installation)
|
||
if (jQuery.isEmptyObject(data.clients)) {
|
||
if (privacyLevel > 1) {
|
||
table.remove();
|
||
} else {
|
||
clienttable.append(
|
||
`<tr><td colspan="3" class="text-center">${i18n.t("index.no_data")}</td></tr>`
|
||
);
|
||
overlay.hide();
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
// Populate table with content
|
||
for (const client of data.clients) {
|
||
// Sanitize client
|
||
let clientname = client.name;
|
||
if (clientname.length === 0) clientname = client.ip;
|
||
url =
|
||
'<a href="queries?client_ip=' +
|
||
encodeURIComponent(client.ip) +
|
||
(blocked ? "&upstream=blocklist" : "") +
|
||
"&lang=" +
|
||
i18n.lang +
|
||
'">' +
|
||
utils.escapeHtml(clientname) +
|
||
"</a>";
|
||
percentage = (client.count / sum) * 100;
|
||
|
||
// Add row to table
|
||
clienttable.append(
|
||
"<tr> " +
|
||
utils.addTD(url) +
|
||
utils.addTD(client.count) +
|
||
utils.addTD(utils.colorBar(percentage, sum, style)) +
|
||
"</tr> "
|
||
);
|
||
}
|
||
|
||
// Hide overlay
|
||
overlay.hide();
|
||
}).fail(data => {
|
||
apiFailure(data);
|
||
});
|
||
}
|
||
|
||
function updateTopDomainsTable(blocked) {
|
||
let api;
|
||
let style;
|
||
let table;
|
||
let tablecontent;
|
||
let overlay;
|
||
let domaintable;
|
||
if (blocked) {
|
||
api = document.body.dataset.apiurl + "/stats/top_domains?blocked=true";
|
||
style = "queries-blocked";
|
||
table = $("#ad-frequency");
|
||
tablecontent = $("#ad-frequency td").parent();
|
||
overlay = $("#ad-frequency .overlay");
|
||
domaintable = $("#ad-frequency").find("tbody:last");
|
||
} else {
|
||
api = document.body.dataset.apiurl + "/stats/top_domains";
|
||
style = "queries-permitted";
|
||
table = $("#domain-frequency");
|
||
tablecontent = $("#domain-frequency td").parent();
|
||
overlay = $("#domain-frequency .overlay");
|
||
domaintable = $("#domain-frequency").find("tbody:last");
|
||
}
|
||
|
||
$.getJSON(api, data => {
|
||
// Clear tables before filling them with data
|
||
tablecontent.remove();
|
||
let url;
|
||
let domain;
|
||
let percentage;
|
||
let urlText;
|
||
const sum = blocked ? data.blocked_queries : data.total_queries;
|
||
|
||
// When there is no data...
|
||
// a) remove table if there are no results (privacy mode enabled) or
|
||
// b) add note if there are no results (e.g. new installation)
|
||
if (jQuery.isEmptyObject(data.domains)) {
|
||
if (privacyLevel > 0) {
|
||
table.remove();
|
||
} else {
|
||
domaintable.append(
|
||
`<tr><td colspan="3" class="text-center">${i18n.t("index.no_data")}</td></tr>`
|
||
);
|
||
overlay.hide();
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
// Populate table with content
|
||
for (const item of data.domains) {
|
||
// Sanitize domain
|
||
domain = encodeURIComponent(item.domain);
|
||
// Substitute "." for empty domain lookups
|
||
urlText = domain === "" ? "." : domain;
|
||
url =
|
||
'<a href="queries?domain=' +
|
||
domain +
|
||
(blocked ? "&upstream=blocklist" : "&upstream=permitted") +
|
||
"&lang=" +
|
||
i18n.lang +
|
||
'">' +
|
||
urlText +
|
||
"</a>";
|
||
percentage = (item.count / sum) * 100;
|
||
domaintable.append(
|
||
"<tr> " +
|
||
utils.addTD(url) +
|
||
utils.addTD(item.count) +
|
||
utils.addTD(utils.colorBar(percentage, sum, style)) +
|
||
"</tr> "
|
||
);
|
||
}
|
||
|
||
overlay.hide();
|
||
}).fail(data => {
|
||
apiFailure(data);
|
||
});
|
||
}
|
||
|
||
function updateTopLists() {
|
||
// Update blocked domains
|
||
updateTopDomainsTable(true);
|
||
|
||
// Update permitted domains
|
||
updateTopDomainsTable(false);
|
||
|
||
// Update blocked clients
|
||
updateTopClientsTable(true);
|
||
|
||
// Update permitted clients
|
||
updateTopClientsTable(false);
|
||
|
||
// Update top lists data every 10 seconds
|
||
utils.setTimer(updateTopLists, REFRESH_INTERVAL.top_lists);
|
||
}
|
||
|
||
let previousCount = 0;
|
||
let firstSummaryUpdate = true;
|
||
function updateSummaryData(runOnce = false) {
|
||
$.getJSON(document.body.dataset.apiurl + "/stats/summary", data => {
|
||
const intl = new Intl.NumberFormat();
|
||
const newCount = Number.parseInt(data.queries.total, 10);
|
||
|
||
$("span#dns_queries").text(intl.format(newCount));
|
||
$("span#active_clients").text(intl.format(Number.parseInt(data.clients.active, 10)));
|
||
$("a#total_clients").attr(
|
||
"title",
|
||
i18n.tf("index.num_total_clients", intl.format(Number.parseInt(data.clients.total, 10)))
|
||
);
|
||
$("span#blocked_queries").text(intl.format(Number.parseFloat(data.queries.blocked)));
|
||
const formattedPercentage = utils.toPercent(data.queries.percent_blocked, 1);
|
||
$("span#percent_blocked").text(formattedPercentage);
|
||
updateQueryFrequency(intl, data.queries.frequency);
|
||
|
||
const lastupdate = Number.parseInt(data.gravity.last_update, 10);
|
||
let updatetxt = i18n.t("index.list_never_updated");
|
||
if (lastupdate > 0) {
|
||
updatetxt = i18n.tf(
|
||
"index.lists_updated",
|
||
utils.datetimeRelative(lastupdate),
|
||
utils.datetime(lastupdate, false, false)
|
||
);
|
||
}
|
||
|
||
const gravityCount = Number.parseInt(data.gravity.domains_being_blocked, 10);
|
||
if (gravityCount < 0) {
|
||
// Error. Change the title text and show the error code in parentheses
|
||
updatetxt = i18n.t("index.error_updating_gravity");
|
||
$("span#gravity_size").text(i18n.tf("index.error_num", gravityCount));
|
||
} else {
|
||
$("span#gravity_size").text(intl.format(gravityCount));
|
||
}
|
||
|
||
$(".small-box:has(#gravity_size)").attr("title", updatetxt);
|
||
|
||
if (2 * previousCount < newCount && newCount > 100 && !firstSummaryUpdate) {
|
||
// Update the charts if the number of queries has increased significantly
|
||
// Do not run this on the first update as reloading the same data after
|
||
// creating the charts happens asynchronously and can cause a race
|
||
// condition
|
||
updateQueriesOverTime();
|
||
updateClientsOverTime();
|
||
updateQueryTypesPie();
|
||
updateForwardDestinationsPie();
|
||
updateTopLists();
|
||
}
|
||
|
||
previousCount = newCount;
|
||
firstSummaryUpdate = false;
|
||
})
|
||
.done(() => {
|
||
if (!runOnce) utils.setTimer(updateSummaryData, REFRESH_INTERVAL.summary);
|
||
})
|
||
.fail(data => {
|
||
utils.setTimer(updateSummaryData, 3 * REFRESH_INTERVAL.summary);
|
||
apiFailure(data);
|
||
});
|
||
}
|
||
|
||
function labelWithPercentage(tooltipLabel, skipZero = false) {
|
||
// Sum all queries for the current time by iterating over all keys in the
|
||
// current dataset
|
||
let sum = 0;
|
||
for (const value of Object.values(tooltipLabel.parsed._stacks.y)) {
|
||
if (value === undefined) continue;
|
||
const num = Number.parseInt(value, 10);
|
||
if (num) sum += num;
|
||
}
|
||
|
||
let percentage = 0;
|
||
const data = Number.parseInt(tooltipLabel.parsed._stacks.y[tooltipLabel.datasetIndex], 10);
|
||
if (sum > 0) {
|
||
percentage = (100 * data) / sum;
|
||
}
|
||
|
||
if (skipZero && data === 0) return undefined;
|
||
return (
|
||
tooltipLabel.dataset.label +
|
||
": " +
|
||
tooltipLabel.parsed.y +
|
||
" (" +
|
||
utils.toPercent(percentage, 1) +
|
||
")"
|
||
);
|
||
}
|
||
|
||
i18n.waitForTranslations(() => {
|
||
// Pull in data via AJAX
|
||
updateSummaryData();
|
||
|
||
// On click of the "Reset zoom" buttons, the closest chart to the button is reset
|
||
$(".zoom-reset").on("click", function () {
|
||
if ($(this).data("sel") === "reset-clients") clientsChart.resetZoom();
|
||
else timeLineChart.resetZoom();
|
||
|
||
// Show the closest info icon to the current chart
|
||
$(this).parent().find(".zoom-info").show();
|
||
// Hide the reset zoom button
|
||
$(this).hide();
|
||
});
|
||
|
||
const zoomPlugin = {
|
||
/* Allow zooming only on the y axis */
|
||
zoom: {
|
||
wheel: {
|
||
enabled: true,
|
||
modifierKey: "ctrl" /* Modifier key required for zooming via mouse wheel */,
|
||
},
|
||
pinch: {
|
||
enabled: true,
|
||
},
|
||
mode: "y",
|
||
onZoom({ chart, trigger }) {
|
||
if (trigger === "api") {
|
||
// Ignore onZoom triggered by the chart.zoomScale api call below
|
||
return;
|
||
}
|
||
|
||
// The first time the chart is zoomed, save the maximum initial scale bound
|
||
chart.absMax ||= chart.getInitialScaleBounds().y.max;
|
||
// Calculate the maximum value to be shown for the current zoom level
|
||
const zoomMax = chart.absMax / chart.getZoomLevel();
|
||
// Update the y axis scale
|
||
chart.zoomScale("y", { min: 0, max: zoomMax }, "default");
|
||
// Update the y axis ticks and round values to natural numbers
|
||
chart.options.scales.y.ticks.callback = function (value) {
|
||
return value.toFixed(0);
|
||
};
|
||
|
||
// Update the top right info icon and reset zoom button depending on the
|
||
// current zoom level
|
||
if (chart.getZoomLevel() === 1) {
|
||
// Show the closest info icon to the current chart
|
||
$(chart.canvas).parent().parent().parent().find(".zoom-info").show();
|
||
// Hide the reset zoom button
|
||
$(chart.canvas).parent().parent().parent().find(".zoom-reset").hide();
|
||
} else {
|
||
// Hide the closest info icon to the current chart
|
||
$(chart.canvas).parent().parent().parent().find(".zoom-info").hide();
|
||
// Show the reset zoom button
|
||
$(chart.canvas).parent().parent().parent().find(".zoom-reset").show();
|
||
}
|
||
},
|
||
},
|
||
/* Allow panning only on the y axis */
|
||
pan: {
|
||
enabled: true,
|
||
mode: "y",
|
||
},
|
||
limits: {
|
||
y: {
|
||
/* Users are not allowed to zoom out further than the initial range */
|
||
min: "original",
|
||
max: "original",
|
||
/* Users are not allowed to zoom in further than a range of 10 queries */
|
||
minRange: 10,
|
||
},
|
||
},
|
||
};
|
||
|
||
const gridColor = utils.getCSSval("graphs-grid", "background-color");
|
||
const ticksColor = utils.getCSSval("graphs-ticks", "color");
|
||
let ctx = document.getElementById("queryOverTimeChart").getContext("2d");
|
||
timeLineChart = new Chart(ctx, {
|
||
type: "bar",
|
||
data: {
|
||
labels: [],
|
||
datasets: [{ data: [], parsing: false }],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: {
|
||
mode: "nearest",
|
||
axis: "x",
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: false,
|
||
},
|
||
tooltip: {
|
||
enabled: true,
|
||
intersect: false,
|
||
yAlign: "bottom",
|
||
itemSort(a, b) {
|
||
return b.datasetIndex - a.datasetIndex;
|
||
},
|
||
callbacks: {
|
||
title(tooltipTitle) {
|
||
const label = tooltipTitle[0].label;
|
||
const time = label.match(/(\d?\d):?(\d?\d?)/);
|
||
const h = Number.parseInt(time[1], 10);
|
||
const m = Number.parseInt(time[2], 10) || 0;
|
||
const from = utils.padNumber(h) + ":" + utils.padNumber(m - 5) + ":00";
|
||
const to = utils.padNumber(h) + ":" + utils.padNumber(m + 4) + ":59";
|
||
return i18n.tf("index.queries_from_to", from, to);
|
||
},
|
||
label(tooltipLabel) {
|
||
return labelWithPercentage(tooltipLabel);
|
||
},
|
||
},
|
||
},
|
||
zoom: zoomPlugin,
|
||
},
|
||
scales: {
|
||
x: {
|
||
type: "time",
|
||
stacked: true,
|
||
offset: false,
|
||
time: {
|
||
unit: "hour",
|
||
displayFormats: {
|
||
hour: "HH:mm",
|
||
},
|
||
tooltipFormat: "HH:mm",
|
||
},
|
||
grid: {
|
||
color: gridColor,
|
||
offset: false,
|
||
},
|
||
ticks: {
|
||
color: ticksColor,
|
||
},
|
||
border: {
|
||
display: false,
|
||
},
|
||
},
|
||
y: {
|
||
stacked: true,
|
||
beginAtZero: true,
|
||
ticks: {
|
||
color: ticksColor,
|
||
precision: 0,
|
||
},
|
||
grid: {
|
||
color: gridColor,
|
||
},
|
||
border: {
|
||
display: false,
|
||
},
|
||
min: 0,
|
||
},
|
||
},
|
||
elements: {
|
||
line: {
|
||
borderWidth: 0,
|
||
spanGaps: false,
|
||
fill: true,
|
||
},
|
||
point: {
|
||
radius: 0,
|
||
hoverRadius: 5,
|
||
hitRadius: 5,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
// Pull in data via AJAX
|
||
updateQueriesOverTime();
|
||
|
||
// Create / load "Top Clients over Time" only if authorized
|
||
const clientsChartEl = document.getElementById("clientsChart");
|
||
if (clientsChartEl) {
|
||
ctx = clientsChartEl.getContext("2d");
|
||
clientsChart = new Chart(ctx, {
|
||
type: "bar",
|
||
data: {
|
||
labels: [],
|
||
datasets: [{ data: [], parsing: false }],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: {
|
||
mode: "nearest",
|
||
axis: "x",
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: false,
|
||
},
|
||
tooltip: {
|
||
// Disable the on-canvas tooltip
|
||
enabled: false,
|
||
intersect: false,
|
||
external: customTooltips,
|
||
yAlign: "top",
|
||
itemSort(a, b) {
|
||
return b.raw - a.raw;
|
||
},
|
||
callbacks: {
|
||
title(tooltipTitle) {
|
||
const label = tooltipTitle[0].label;
|
||
const time = label.match(/(\d?\d):?(\d?\d?)/);
|
||
const h = Number.parseInt(time[1], 10);
|
||
const m = Number.parseInt(time[2], 10) || 0;
|
||
const from = utils.padNumber(h) + ":" + utils.padNumber(m - 5) + ":00";
|
||
const to = utils.padNumber(h) + ":" + utils.padNumber(m + 4) + ":59";
|
||
return i18n.tf("index.activity_from_to", from, to);
|
||
},
|
||
label(tooltipLabel) {
|
||
return labelWithPercentage(tooltipLabel, true);
|
||
},
|
||
},
|
||
},
|
||
zoom: zoomPlugin,
|
||
},
|
||
scales: {
|
||
x: {
|
||
type: "time",
|
||
stacked: true,
|
||
offset: false,
|
||
time: {
|
||
unit: "hour",
|
||
displayFormats: {
|
||
hour: "HH:mm",
|
||
},
|
||
tooltipFormat: "HH:mm",
|
||
},
|
||
grid: {
|
||
color: gridColor,
|
||
offset: false,
|
||
},
|
||
border: {
|
||
display: false,
|
||
},
|
||
ticks: {
|
||
color: ticksColor,
|
||
},
|
||
},
|
||
y: {
|
||
beginAtZero: true,
|
||
ticks: {
|
||
color: ticksColor,
|
||
precision: 0,
|
||
},
|
||
stacked: true,
|
||
grid: {
|
||
color: gridColor,
|
||
},
|
||
border: {
|
||
display: false,
|
||
},
|
||
min: 0,
|
||
},
|
||
},
|
||
elements: {
|
||
line: {
|
||
borderWidth: 0,
|
||
spanGaps: false,
|
||
fill: true,
|
||
point: {
|
||
radius: 0,
|
||
hoverRadius: 5,
|
||
hitRadius: 5,
|
||
},
|
||
},
|
||
},
|
||
hover: {
|
||
animationDuration: 0,
|
||
},
|
||
},
|
||
});
|
||
|
||
// Pull in data via AJAX
|
||
updateClientsOverTime();
|
||
}
|
||
|
||
// Initialize privacy level before loading any data that depends on it
|
||
initPrivacyLevel().then(() => {
|
||
// After privacy level is initialized, load the top lists
|
||
updateTopLists();
|
||
});
|
||
|
||
$("#queryOverTimeChart").on("click", evt => {
|
||
const activePoints = timeLineChart.getElementsAtEventForMode(
|
||
evt,
|
||
"nearest",
|
||
{ intersect: true },
|
||
false
|
||
);
|
||
if (activePoints.length > 0) {
|
||
//get the internal index
|
||
const clickedElementindex = activePoints[0].index;
|
||
//get specific label by index
|
||
const label = timeLineChart.data.labels[clickedElementindex];
|
||
|
||
//get value by index
|
||
const from = label / 1000 - 300;
|
||
const until = label / 1000 + 300;
|
||
globalThis.location.href = "queries?from=" + from + "&until=" + until + "&lang=" + i18n.lang;
|
||
}
|
||
|
||
return false;
|
||
});
|
||
|
||
$("#clientsChart").on("click", evt => {
|
||
const activePoints = clientsChart.getElementsAtEventForMode(
|
||
evt,
|
||
"nearest",
|
||
{ intersect: true },
|
||
false
|
||
);
|
||
if (activePoints.length > 0) {
|
||
//get the internal index
|
||
const clickedElementindex = activePoints[0].index;
|
||
|
||
//get specific label by index
|
||
const label = clientsChart.data.labels[clickedElementindex];
|
||
|
||
//get value by index
|
||
const from = label / 1000 - 300;
|
||
const until = label / 1000 + 300;
|
||
globalThis.location.href = "queries?from=" + from + "&until=" + until + "&lang=" + i18n.lang;
|
||
}
|
||
|
||
return false;
|
||
});
|
||
|
||
if (document.getElementById("queryTypePieChart")) {
|
||
ctx = document.getElementById("queryTypePieChart").getContext("2d");
|
||
queryTypePieChart = new Chart(ctx, {
|
||
type: "doughnut",
|
||
data: {
|
||
labels: [],
|
||
datasets: [{ data: [], parsing: false }],
|
||
},
|
||
plugins: [htmlLegendPlugin],
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: true,
|
||
elements: {
|
||
arc: {
|
||
borderColor: $(".box").css("background-color"),
|
||
},
|
||
},
|
||
plugins: {
|
||
htmlLegend: {
|
||
containerID: "query-types-legend",
|
||
},
|
||
legend: {
|
||
display: false,
|
||
},
|
||
tooltip: {
|
||
// Disable the on-canvas tooltip
|
||
enabled: false,
|
||
external: customTooltips,
|
||
callbacks: {
|
||
title() {
|
||
return i18n.t("index.query_type");
|
||
},
|
||
label: doughnutTooltip,
|
||
},
|
||
},
|
||
},
|
||
animation: {
|
||
duration: 750,
|
||
},
|
||
},
|
||
});
|
||
|
||
// Pull in data via AJAX
|
||
updateQueryTypesPie();
|
||
}
|
||
|
||
if (document.getElementById("forwardDestinationPieChart")) {
|
||
ctx = document.getElementById("forwardDestinationPieChart").getContext("2d");
|
||
forwardDestinationPieChart = new Chart(ctx, {
|
||
type: "doughnut",
|
||
data: {
|
||
labels: [],
|
||
datasets: [{ data: [], parsing: false }],
|
||
},
|
||
plugins: [htmlLegendPlugin],
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: true,
|
||
elements: {
|
||
arc: {
|
||
borderColor: $(".box").css("background-color"),
|
||
},
|
||
},
|
||
plugins: {
|
||
htmlLegend: {
|
||
containerID: "forward-destinations-legend",
|
||
},
|
||
legend: {
|
||
display: false,
|
||
},
|
||
tooltip: {
|
||
// Disable the on-canvas tooltip
|
||
enabled: false,
|
||
external: customTooltips,
|
||
callbacks: {
|
||
title() {
|
||
return i18n.t("index.upstream_server");
|
||
},
|
||
label: doughnutTooltip,
|
||
},
|
||
},
|
||
},
|
||
animation: {
|
||
duration: 750,
|
||
},
|
||
},
|
||
});
|
||
|
||
// Pull in data via AJAX
|
||
updateForwardDestinationsPie();
|
||
}
|
||
});
|
||
|
||
//destroy all chartjs customTooltips on window resize
|
||
window.addEventListener("resize", () => {
|
||
$(".chartjs-tooltip").remove();
|
||
});
|
||
|
||
// Tooltips
|
||
i18n.waitForTranslations(() => {
|
||
$('[data-toggle="tooltip"]').tooltip({ html: true, container: "body" });
|
||
});
|