/* 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 */ "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 = [ "Other DNS Queries", "Blocked DNS Queries", "Cached DNS Queries", "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 upstreamIPs = []; 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 IPs for generating links to the Query Log upstreamIPs.push(item.port > 0 ? item.ip + "#" + item.port : item.ip); 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('- No data -'); 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 = '' + utils.escapeHtml(clientname) + ""; percentage = (client.count / sum) * 100; // Add row to table clienttable.append( " " + utils.addTD(url) + utils.addTD(client.count) + utils.addTD(utils.colorBar(percentage, sum, style)) + " " ); } // 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('- No data -'); 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 = '' + urlText + ""; percentage = (item.count / sum) * 100; domaintable.append( " " + utils.addTD(url) + utils.addTD(item.count) + utils.addTD(utils.colorBar(percentage, sum, style)) + " " ); } 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", intl.format(Number.parseInt(data.clients.total, 10)) + " total clients" ); $("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 = "Lists were never updated"; if (lastupdate > 0) { updatetxt = "Lists updated " + utils.datetimeRelative(lastupdate) + "\n(" + 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 = "Error! Update gravity to reset this value."; $("span#gravity_size").text("Error (" + 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 [key, value] of Object.entries(tooltipLabel.parsed._stacks.y)) { if (key.startsWith("_") || 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) + ")" ); } $(() => { // 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: { // Disable the on-canvas tooltip enabled: false, intersect: false, external: customTooltips, yAlign: "top", 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 "Queries from " + from + " to " + 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, }, 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 "Client activity from " + from + " to " + 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; } 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; } 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: $(".card").css("background-color"), hoverBorderColor: $(".card").css("background-color"), hoverOffset: 10, }, }, plugins: { htmlLegend: { containerID: "query-types-legend", }, legend: { display: false, }, tooltip: { // Disable the on-canvas tooltip enabled: false, external: customTooltips, callbacks: { title() { return "Query type"; }, label: doughnutTooltip, }, }, }, animation: { duration: 750, }, layout: { padding: 10, }, }, }); // 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: $(".card").css("background-color"), hoverBorderColor: $(".card").css("background-color"), hoverOffset: 10, }, }, plugins: { htmlLegend: { containerID: "forward-destinations-legend", }, legend: { display: false, }, tooltip: { // Disable the on-canvas tooltip enabled: false, external: customTooltips, callbacks: { title() { return "Upstream server"; }, label: doughnutTooltip, }, }, }, animation: { duration: 750, }, layout: { padding: 10, }, }, }); // Pull in data via AJAX updateForwardDestinationsPie(); } }); //destroy all chartjs customTooltips on window resize window.addEventListener("resize", () => { $(".chartjs-tooltip").remove(); }); // Tooltips $(() => { $('[data-toggle="tooltip"]').tooltip({ html: true, container: "body" }); });