/* 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, moment:false */ var _isLoginPage = false; const apiUrl = document.body.dataset.apiurl; const REFRESH_INTERVAL = { logs: 500, // 0.5 sec (logs page) summary: 1000, // 1 sec (dashboard) query_log: 2000, // 2 sec (Query Log) blocking: 10000, // 10 sec (all pages, sidebar) metrics: 10000, // 10 sec (settings page) system: 20000, // 20 sec (all pages, sidebar) query_types: 60000, // 1 min (dashboard) upstreams: 60000, // 1 min (dashboard) top_lists: 60000, // 1 min (dashboard) messages: 60000, // 1 min (all pages) version: 120000, // 2 min (all pages, footer) ftl: 120000, // 2 min (all pages, sidebar) hosts: 120000, // 2 min (settings page) history: 600000, // 10 min (dashboard) clients: 600000, // 10 min (dashboard) }; function secondsTimeSpanToHMS(s) { var h = Math.floor(s / 3600); //Get whole hours s -= h * 3600; var m = Math.floor(s / 60); //Get remaining minutes s -= m * 60; return h + ":" + (m < 10 ? "0" + m : m) + ":" + (s < 10 ? "0" + s : s); //zero padding on minutes and seconds } function piholeChanged(blocking, timer = null) { const status = $("#status"); const ena = $("#pihole-enable"); const dis = $("#pihole-disable"); const enaT = $("#enableTimer"); if (timer !== null && parseFloat(timer) > 0) { enaT.html(Date.now() + parseFloat(timer) * 1000); setTimeout(countDown, 100); } switch (blocking) { case "enabled": { status.html("  Active"); ena.hide(); dis.show(); dis.removeClass("active"); break; } case "disabled": { status.html("  Blocking disabled"); ena.show(); dis.hide(); break; } case "failure": { status.html( "  DNS server failure" ); ena.hide(); dis.hide(); break; } default: { status.html("  Status unknown"); ena.hide(); dis.hide(); } } } function countDown() { var ena = $("#enableLabel"); var enaT = $("#enableTimer"); var target = new Date(parseInt(enaT.text(), 10)); var seconds = Math.round((target.getTime() - Date.now()) / 1000); //Stop and remove timer when user enabled early if ($("#pihole-enable").is(":hidden")) { ena.text("Enable Blocking"); return; } if (seconds > 0) { setTimeout(countDown, 1000); ena.text("Enable Blocking (" + secondsTimeSpanToHMS(seconds) + ")"); } else { ena.text("Enable Blocking"); piholeChanged("enabled", null); if (localStorage) { localStorage.removeItem("countDownTarget"); } } } function checkBlocking() { // Skip if page is hidden if (document.hidden) { utils.setTimer(checkBlocking, REFRESH_INTERVAL.blocking); return; } $.ajax({ url: apiUrl + "/dns/blocking", method: "GET", }) .done(function (data) { piholeChanged(data.blocking, data.timer); utils.setTimer(checkBlocking, REFRESH_INTERVAL.blocking); }) .fail(function (data) { apiFailure(data); utils.setTimer(checkBlocking, 3 * REFRESH_INTERVAL.blocking); }); } function piholeChange(action, duration) { let btnStatus = null; switch (action) { case "enable": btnStatus = $("#flip-status-enable"); break; case "disable": btnStatus = $("#flip-status-disable"); break; default: // Do nothing break; } btnStatus.html(" "); $.ajax({ url: apiUrl + "/dns/blocking", method: "POST", dataType: "json", processData: false, contentType: "application/json; charset=utf-8", data: JSON.stringify({ blocking: action === "enable", timer: parseInt(duration, 10) > 0 ? parseInt(duration, 10) : null, }), }) .done(function (data) { if (data.blocking === action + "d") { btnStatus.html(""); piholeChanged(data.blocking, data.timer); } }) .fail(function (data) { apiFailure(data); }); } function testCookies() { if (navigator.cookieEnabled) { return true; } // set and read cookie document.cookie = "cookietest=1"; var ret = document.cookie.indexOf("cookietest=") !== -1; // delete cookie document.cookie = "cookietest=1; expires=Thu, 01-Jan-1970 00:00:01 GMT"; return ret; } var iCheckStyle = "primary"; function applyCheckboxRadioStyle() { // Get all radio/checkboxes for theming, with the exception of the two radio buttons on the custom disable timer, // as well as every element with an id that starts with "status_" var sel = $("input[type='radio'],input[type='checkbox']") .not("#selSec") .not("#selMin") .not("#expert-settings") .not("#only-changed") .not("[id^=status_]"); sel.parent().removeClass(); sel.parent().addClass("icheck-" + iCheckStyle); } function initCheckboxRadioStyle() { function getCheckboxURL(style) { var extra = style.startsWith("material-") ? "material" : "bootstrap"; return "/admin/vendor/icheck/icheck-" + extra + ".min.css"; } // Read from local storage, initialize if needed var chkboxStyle = localStorage ? localStorage.getItem("theme_icheck") : null; if (chkboxStyle === null) { chkboxStyle = "primary"; } var boxsheet = $(''); // Only add the stylesheet if it's not already present if ($("link[href='" + boxsheet.attr("href") + "']").length === 0) boxsheet.appendTo("head"); iCheckStyle = chkboxStyle; applyCheckboxRadioStyle(); // Add handler when on settings page var iCheckStyle = $("#iCheckStyle"); if (iCheckStyle !== null) { iCheckStyle.val(chkboxStyle); iCheckStyle.on("change", function () { var themename = $(this).val(); localStorage.setItem("theme_icheck", themename); applyCheckboxRadioStyle(themename); }); } } var systemTimer, versionTimer; function updateInfo() { updateSystemInfo(); updateVersionInfo(); updateFtlInfo(); checkBlocking(); } function updateQueryFrequency(intl, frequency) { let freq = parseFloat(frequency) * 60; let unit = "q/min"; let title = "Queries per minute"; if (freq > 100) { freq /= 60; unit = "q/s"; title = "Queries per second"; } // Determine number of fraction digits based on the frequency // - 0 fraction digits for frequencies > 10 // - 1 fraction digit for frequencies between 1 and 10 // - 2 fraction digits for frequencies < 1 const fractionDigits = freq > 10 ? 0 : freq < 1 ? 2 : 1; const userLocale = navigator.language || "en-US"; const freqFormatted = new Intl.NumberFormat(userLocale, { minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits, }).format(freq); $("#query_frequency") .html( '  ' + freqFormatted + " " + unit ) .attr("title", title); } var ftlinfoTimer = null; function updateFtlInfo() { $.ajax({ url: apiUrl + "/info/ftl", }) .done(function (data) { var ftl = data.ftl; var database = ftl.database; var intl = new Intl.NumberFormat(); $("#num_groups").text(intl.format(database.groups)); $("#num_clients").text(intl.format(database.clients)); $("#num_lists").text(intl.format(database.lists)); $("#num_gravity").text(intl.format(database.gravity)); $("#num_allowed") .text(intl.format(database.domains.allowed + database.regex.allowed)) .attr( "title", "Allowed: " + intl.format(database.domains.allowed) + " exact domains and " + intl.format(database.regex.allowed) + " regex filters are enabled" ); $("#num_denied") .text(intl.format(database.domains.denied + database.regex.denied)) .attr( "title", "Denied: " + intl.format(database.domains.denied) + " exact domains and " + intl.format(database.regex.denied) + " regex filters are enabled" ); updateQueryFrequency(intl, ftl.query_frequency); $("#sysinfo-cpu-ftl").text("(" + ftl["%cpu"].toFixed(1) + "% used by FTL)"); $("#sysinfo-ram-ftl").text("(" + ftl["%mem"].toFixed(1) + "% used by FTL)"); $("#sysinfo-pid-ftl").text(ftl.pid); var startdate = moment() .subtract(ftl.uptime, "milliseconds") .format("dddd, MMMM Do YYYY, HH:mm:ss"); $("#sysinfo-uptime-ftl").text(startdate); $("#sysinfo-privacy_level").text(ftl.privacy_level); $("#sysinfo-ftl-overlay").hide(); $(".destructive_action").prop("disabled", !ftl.allow_destructive); $(".destructive_action").prop( "title", ftl.allow_destructive ? "" : "Destructive actions are disabled by a config setting" ); clearTimeout(ftlinfoTimer); ftlinfoTimer = utils.setTimer(updateFtlInfo, REFRESH_INTERVAL.ftl); }) .fail(function (data) { apiFailure(data); }); } function updateSystemInfo() { $.ajax({ url: apiUrl + "/info/system", }) .done(function (data) { var system = data.system; var percentRAM = system.memory.ram["%used"]; var percentSwap = system.memory.swap["%used"]; var totalRAMGB = system.memory.ram.total / 1024 / 1024; var totalSwapGB = system.memory.swap.total / 1024 / 1024; var swap = system.memory.swap.total > 0 ? ((1e2 * system.memory.swap.used) / system.memory.swap.total).toFixed(1) + " %" : "N/A"; var color; color = percentRAM > 75 ? "text-red" : "text-green-light"; $("#memory").html( '  Memory usage: ' + percentRAM.toFixed(1) + " %" ); $("#memory").prop( "title", "Total memory: " + totalRAMGB.toFixed(1) + " GB, Swap usage: " + swap ); $("#sysinfo-memory-ram").text( percentRAM.toFixed(1) + "% of " + totalRAMGB.toFixed(1) + " GB is used" ); if (system.memory.swap.total > 0) { $("#sysinfo-memory-swap").text( percentSwap.toFixed(1) + "% of " + totalSwapGB.toFixed(1) + " GB is used" ); } else { $("#sysinfo-memory-swap").text("No swap space available"); } color = system.cpu.load.percent[0] > 100 ? "text-red" : "text-green-light"; $("#cpu").html( '  CPU: ' + system.cpu.load.percent[0].toFixed(1) + " %" ); $("#cpu").prop( "title", "Load: " + system.cpu.load.raw[0].toFixed(2) + " " + system.cpu.load.raw[1].toFixed(2) + " " + system.cpu.load.raw[2].toFixed(2) + " on " + system.cpu.nprocs + " cores running " + system.procs + " processes" ); $("#sysinfo-cpu").text( system.cpu.load.percent[0].toFixed(1) + "% (load: " + system.cpu.load.raw[0].toFixed(2) + " " + system.cpu.load.raw[1].toFixed(2) + " " + system.cpu.load.raw[2].toFixed(2) + ") on " + system.cpu.nprocs + " cores running " + system.procs + " processes" ); var startdate = moment() .subtract(system.uptime, "seconds") .format("dddd, MMMM Do YYYY, HH:mm:ss"); $("#status").prop( "title", "System uptime: " + moment.duration(1000 * system.uptime).humanize() + " (running since " + startdate + ")" ); $("#sysinfo-uptime").text( moment.duration(1000 * system.uptime).humanize() + " (running since " + startdate + ")" ); $("#sysinfo-system-overlay").hide(); clearTimeout(systemTimer); systemTimer = utils.setTimer(updateSystemInfo, REFRESH_INTERVAL.system); }) .fail(function (data) { apiFailure(data); }); } function apiFailure(data) { if (data.status === 401) { // Unauthorized, reload page globalThis.location.reload(); } } // Method to compare two versions. Returns 1 if v2 is smaller, -1 if v1 is // smaller, 0 if equal // Credits: https://www.geeksforgeeks.org/compare-two-version-numbers/ function versionCompare(v1, v2) { // vnum stores each numeric part of version var vnum1 = 0, vnum2 = 0; // Remove possible leading "v" in v1 and v2 if (v1[0] === "v") { v1 = v1.substring(1); } if (v2[0] === "v") { v2 = v2.substring(1); } // loop until both string are processed for (var i = 0, j = 0; i < v1.length || j < v2.length; ) { // storing numeric part of version 1 in vnum1 while (i < v1.length && v1[i] !== ".") { vnum1 = vnum1 * 10 + (v1[i] - "0"); i++; } // storing numeric part of version 2 in vnum2 while (j < v2.length && v2[j] !== ".") { vnum2 = vnum2 * 10 + (v2[j] - "0"); j++; } if (vnum1 > vnum2) return 1; if (vnum2 > vnum1) return -1; // if equal, reset variables and go for next numeric part vnum1 = 0; vnum2 = 0; i++; j++; } return 0; } function updateVersionInfo() { $.ajax({ url: apiUrl + "/info/version", }).done(function (data) { var version = data.version; var updateAvailable = false; var dockerUpdate = false; var isDocker = false; $("#versions").empty(); var versions = [ { name: "Docker Tag", local: version.docker.local, remote: version.docker.remote, branch: null, hash: null, url: "https://github.com/pi-hole/docker-pi-hole/releases", }, { name: "Core", local: version.core.local.version, remote: version.core.remote.version, branch: version.core.local.branch, hash: version.core.local.hash, hash_remote: version.core.remote.hash, url: "https://github.com/pi-hole/pi-hole/releases", }, { name: "FTL", local: version.ftl.local.version, remote: version.ftl.remote.version, branch: version.ftl.local.branch, hash: version.ftl.local.hash, hash_remote: version.ftl.remote.hash, url: "https://github.com/pi-hole/FTL/releases", }, { name: "Web interface", local: version.web.local.version, remote: version.web.remote.version, branch: version.web.local.branch, hash: version.web.local.hash, hash_remote: version.web.remote.hash, url: "https://github.com/pi-hole/web/releases", }, ]; // Check if we are running in a Docker container if (version.docker.local !== null) { isDocker = true; } versions.forEach(function (v) { if (v.local !== null) { // reset update status for each component var updateComponentAvailable = false; var localVersion = v.local; if (v.branch !== null && v.hash !== null) { if (v.branch === "master") { localVersion = v.local.split("-")[0]; localVersion = '' + localVersion + ""; if (versionCompare(v.local, v.remote) === -1) { // Update available updateComponentAvailable = true; } } else { // non-master branch localVersion = "vDev (" + v.branch + ", " + v.hash + ")"; if (v.hash_remote && v.hash !== v.hash_remote) { // hash differ > Update available updateComponentAvailable = true; // link to the commit history instead of release page v.url = v.url.replace("releases", "commits/" + v.branch); } } } if (v.name === "Docker Tag") { if (versionCompare(v.local, v.remote) === -1) { // Display update information for the docker tag updateComponentAvailable = true; dockerUpdate = true; } else { // Display the link for the current tag localVersion = '' + localVersion + ""; } } // Display update information of individual components only if we are not running in a Docker container if ((!isDocker || (isDocker && v.name === "Docker Tag")) && updateComponentAvailable) { $("#versions").append( "
  • " + v.name + " " + localVersion + ' · Update available!
  • ' ); // if at least one component can be updated, display the update-hint footer updateAvailable = true; } else { $("#versions").append("
  • " + v.name + " " + localVersion + "
  • "); } } }); if (dockerUpdate) $("#update-hint").html( 'To install updates, replace this old container with a fresh upgraded image.' ); else if (updateAvailable) $("#update-hint").html( 'To install updates, run pihole -up.' ); clearTimeout(versionTimer); versionTimer = utils.setTimer(updateVersionInfo, REFRESH_INTERVAL.version); }); } $(function () { if (!_isLoginPage) updateInfo(); var enaT = $("#enableTimer"); var target = new Date(parseInt(enaT.html(), 10)); var seconds = Math.round((target.getTime() - Date.now()) / 1000); if (seconds > 0) { setTimeout(countDown, 100); } if (!testCookies() && $("#cookieInfo").length > 0) { $("#cookieInfo").show(); } // Apply per-browser styling settings initCheckboxRadioStyle(); if (!_isLoginPage) { // Run check immediately after page loading ... utils.checkMessages(); // ... and then periodically utils.setInter(utils.checkMessages, REFRESH_INTERVAL.messages); } }); // Handle Enable/Disable $("#pihole-enable").on("click", function (e) { e.preventDefault(); localStorage.removeItem("countDownTarget"); piholeChange("enable", ""); }); $("#pihole-disable-indefinitely").on("click", function (e) { e.preventDefault(); piholeChange("disable", "0"); }); $("#pihole-disable-10s").on("click", function (e) { e.preventDefault(); piholeChange("disable", "10"); }); $("#pihole-disable-30s").on("click", function (e) { e.preventDefault(); piholeChange("disable", "30"); }); $("#pihole-disable-5m").on("click", function (e) { e.preventDefault(); piholeChange("disable", "300"); }); $("#pihole-disable-custom").on("click", function (e) { e.preventDefault(); var custVal = $("#customTimeout").val(); custVal = $("#btnMins").hasClass("active") ? custVal * 60 : custVal; piholeChange("disable", custVal); }); function initSettingsLevel() { const elem = $("#expert-settings"); // Skip init if element is not present (e.g. on login page) if (elem.length === 0) { applyExpertSettings(); return; } // Restore settings level from local storage (if available) or default to "false" if (localStorage.getItem("expert_settings") === null) { localStorage.setItem("expert_settings", "false"); } elem.prop("checked", localStorage.getItem("expert_settings") === "true"); // Init the settings level toggle elem.bootstrapToggle({ on: "Expert", off: "Basic", size: "small", offstyle: "success", onstyle: "danger", width: "80px", }); // Add handler for settings level toggle elem.on("change", function () { localStorage.setItem("expert_settings", $(this).prop("checked") ? "true" : "false"); applyExpertSettings(); addAdvancedInfo(); }); // Apply settings level applyExpertSettings(); } function applyExpertSettings() { // Apply settings level, this will hide/show elements with the class // "settings-level-basic" or "settings-level-expert" depending on the // current settings level // If "expert_settings" is not set, we default to !"true" (basic settings) if (localStorage.getItem("expert_settings") === "true") { $(".settings-level-basic").show(); $(".settings-level-expert").show(); } else { $(".settings-level-basic").show(); $(".settings-level-expert").hide(); // If we left with an empty page (no visible boxes) after switching from // Expert to Basic settings, redirect to admin/settings/system instead // - class settings-selector is present (this class is present in every // settings pages, but not in other pages - it is there on the "all" // settings page as well, even when the button has "only modified" // functionality there), and // - there are no visible boxes (the page is empty) if ($(".settings-selector").length > 0 && $(".box:visible").length === 0) { globalThis.location.href = "/admin/settings/system"; } } } function addAdvancedInfo() { const advancedInfoSource = $("#advanced-info-data"); const advancedInfoTarget = $("#advanced-info"); const isTLS = advancedInfoSource.data("tls"); const clientIP = advancedInfoSource.data("client-ip"); const XForwardedFor = globalThis.atob(advancedInfoSource.data("xff") ?? ""); const starttime = parseFloat(advancedInfoSource.data("starttime")); const endtime = parseFloat(advancedInfoSource.data("endtime")); const totaltime = 1e3 * (endtime - starttime); // Show advanced info advancedInfoTarget.empty(); // Add TLS and client IP info advancedInfoTarget.append( 'Client:  
    ' ); // Add client IP info $("#client-id").text(XForwardedFor ? XForwardedFor : clientIP); if (XForwardedFor) { // If X-Forwarded-For is set, show the X-Forwarded-For in italics and add // the real client IP as tooltip $("#client-id").css("font-style", "italic"); $("#client-id").prop("title", "Original remote address: " + clientIP); } // Add render time info advancedInfoTarget.append( "Render time: " + (totaltime > 0.5 ? totaltime.toFixed(1) : totaltime.toFixed(3)) + " ms" ); // Show advanced info advancedInfoTarget.show(); } $(function () { initSettingsLevel(); addAdvancedInfo(); }); // Install custom AJAX error handler for DataTables // if $.fn.dataTable is available if ($.fn.dataTable) { $.fn.dataTable.ext.errMode = function (settings, helpPage, message) { // eslint-disable-next-line no-console console.log("DataTables warning: " + message); }; }