990 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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" });
});