Move all files from /scripts/pi-hole/ to /scripts/

Signed-off-by: yubiuser <github@yubiuser.dev>
This commit is contained in:
yubiuser
2024-10-28 20:22:09 +01:00
parent 732932a162
commit 1e922a8b29
58 changed files with 101 additions and 101 deletions

361
scripts/js/charts.js Normal file
View File

@@ -0,0 +1,361 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2023 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 upstreams */
// eslint-disable-next-line no-unused-vars
var THEME_COLORS = [
"#f56954",
"#3c8dbc",
"#00a65a",
"#00c0ef",
"#f39c12",
"#0073b7",
"#001f3f",
"#39cccc",
"#3d9970",
"#01ff70",
"#ff851b",
"#f012be",
"#8e24aa",
"#d81b60",
"#222222",
"#d2d6de",
];
// eslint-disable-next-line no-unused-vars
const htmlLegendPlugin = {
id: "htmlLegend",
afterUpdate(chart, args, options) {
const ul = getOrCreateLegendList(chart, options.containerID);
// Use the built-in legendItems generator
const items = chart.options.plugins.legend.labels.generateLabels(chart);
// Exit early if the legend has the same items as last time
if (
options.lastLegendItems &&
items.length === options.lastLegendItems.length &&
items.every((item, i) => item.text === options.lastLegendItems[i].text) &&
items.every((item, i) => item.hidden === options.lastLegendItems[i].hidden)
) {
return;
}
// else: Update the HTML legend if it is different from last time or if it
// did not exist
// Save the legend items so we can check against them next time to see if we
// need to update the legend
options.lastLegendItems = items;
// Remove old legend items
while (ul.firstChild) {
ul.firstChild.remove();
}
items.forEach(item => {
const li = document.createElement("li");
li.style.alignItems = "center";
li.style.cursor = "pointer";
li.style.display = "flex";
li.style.flexDirection = "row";
// Color checkbox (toggle visibility)
const boxSpan = document.createElement("span");
boxSpan.title = "Toggle visibility";
boxSpan.style.color = item.fillStyle;
boxSpan.style.display = "inline-block";
boxSpan.style.margin = "0 10px";
boxSpan.innerHTML = item.hidden
? '<i class="colorBoxWrapper fa fa-square"></i>'
: '<i class="colorBoxWrapper fa fa-check-square"></i>';
boxSpan.addEventListener("click", () => {
const { type } = chart.config;
if (type === "pie" || type === "doughnut") {
// Pie and doughnut charts only have a single dataset and visibility is per item
chart.toggleDataVisibility(item.index);
} else {
chart.setDatasetVisibility(item.datasetIndex, !chart.isDatasetVisible(item.datasetIndex));
}
chart.update();
});
const textLink = document.createElement("p");
if (
chart.canvas.id === "queryTypePieChart" ||
chart.canvas.id === "forwardDestinationPieChart"
) {
// Text (link to query log page)
textLink.title = "List " + item.text + " queries";
textLink.addEventListener("click", () => {
if (chart.canvas.id === "queryTypePieChart") {
window.location.href = "queries.lp?type=" + item.text;
} else if (chart.canvas.id === "forwardDestinationPieChart") {
// Encode the forward destination as it may contain an "#" character
const upstream = encodeURIComponent(upstreams[item.text]);
window.location.href = "queries.lp?upstream=" + upstream;
}
});
}
textLink.style.margin = 0;
textLink.style.padding = 0;
textLink.style.textDecoration = item.hidden ? "line-through" : "";
textLink.className = "legend-label-text";
textLink.append(item.text);
li.append(boxSpan, textLink);
ul.append(li);
});
},
};
// eslint-disable-next-line no-unused-vars
var customTooltips = function (context) {
var tooltip = context.tooltip;
var tooltipEl = document.getElementById(this.chart.canvas.id + "-customTooltip");
if (!tooltipEl) {
// Create Tooltip Element once per chart
tooltipEl = document.createElement("div");
tooltipEl.id = this.chart.canvas.id + "-customTooltip";
tooltipEl.classList.add("chartjs-tooltip");
tooltipEl.innerHTML = "<div class='arrow'></div> <table></table>";
// avoid browser's font-zoom since we know that <body>'s
// font-size was set to 14px by bootstrap's css
var fontZoom = parseFloat($("body").css("font-size")) / 14;
// set styles and font
tooltipEl.style.padding = tooltip.options.padding + "px " + tooltip.options.padding + "px";
tooltipEl.style.borderRadius = tooltip.options.cornerRadius + "px";
tooltipEl.style.font = tooltip.options.bodyFont.string;
tooltipEl.style.fontFamily = tooltip.options.bodyFont.family;
tooltipEl.style.fontSize = tooltip.options.bodyFont.size / fontZoom + "px";
tooltipEl.style.fontStyle = tooltip.options.bodyFont.style;
// append Tooltip next to canvas-containing box
tooltipEl.ancestor = this.chart.canvas.closest(".box[id]").parentNode;
tooltipEl.ancestor.append(tooltipEl);
}
// Hide if no tooltip
if (tooltip.opacity === 0) {
tooltipEl.style.opacity = 0;
return;
}
// Set caret position
tooltipEl.classList.remove("left", "right", "center", "top", "bottom");
tooltipEl.classList.add(tooltip.xAlign, tooltip.yAlign);
// Set Text
if (tooltip.body) {
var titleLines = tooltip.title || [];
var bodyLines = tooltip.body.map(function (bodyItem) {
return bodyItem.lines;
});
var innerHtml = "<thead>";
titleLines.forEach(function (title) {
innerHtml += "<tr><th>" + title + "</th></tr>";
});
innerHtml += "</thead><tbody>";
var printed = 0;
var devicePixel = (1 / window.devicePixelRatio).toFixed(1);
bodyLines.forEach(function (body, i) {
var labelColors = tooltip.labelColors[i];
var style = "background-color: " + labelColors.backgroundColor;
style += "; outline: 1px solid " + labelColors.backgroundColor;
style += "; border: " + devicePixel + "px solid #fff";
var span = "<span class='chartjs-tooltip-key' style='" + style + "'></span>";
var num = body[0].split(": ");
// do not display entries with value of 0 (in bar chart),
// but pass through entries with "0.0% (in pie charts)
if (num[1] !== "0") {
innerHtml += "<tr><td>" + span + body + "</td></tr>";
printed++;
}
});
if (printed < 1) {
innerHtml += "<tr><td>No activity recorded</td></tr>";
}
innerHtml += "</tbody>";
var tableRoot = tooltipEl.querySelector("table");
tableRoot.innerHTML = innerHtml;
}
var canvasPos = this.chart.canvas.getBoundingClientRect();
var boxPos = tooltipEl.ancestor.getBoundingClientRect();
var offsetX = canvasPos.left - boxPos.left;
var offsetY = canvasPos.top - boxPos.top;
var tooltipWidth = tooltipEl.offsetWidth;
var tooltipHeight = tooltipEl.offsetHeight;
var caretX = tooltip.caretX;
var caretY = tooltip.caretY;
var caretPadding = tooltip.options.caretPadding;
var tooltipX, tooltipY, arrowX;
var arrowMinIndent = 2 * tooltip.options.cornerRadius;
var arrowSize = 5;
// Compute X position
if ($(document).width() > 2 * tooltip.width || tooltip.xAlign !== "center") {
// If the viewport is wide enough, let the tooltip follow the caret position
tooltipX = offsetX + caretX;
if (tooltip.yAlign === "top" || tooltip.yAlign === "bottom") {
switch (tooltip.xAlign) {
case "center":
// set a minimal X position to 5px to prevent
// the tooltip to stick out left of the viewport
var minX = 5;
if (2 * tooltipX < tooltipWidth + minX) {
arrowX = tooltipX - minX;
tooltipX = minX;
} else {
tooltipX -= tooltipWidth / 2;
}
break;
case "left":
tooltipX -= arrowMinIndent;
arrowX = arrowMinIndent;
break;
case "right":
tooltipX -= tooltipWidth - arrowMinIndent;
arrowX = tooltipWidth - arrowMinIndent;
break;
default:
break;
}
} else if (tooltip.yAlign === "center") {
switch (tooltip.xAlign) {
case "left":
tooltipX += caretPadding;
break;
case "right":
tooltipX -= tooltipWidth - caretPadding;
break;
case "center":
tooltipX -= tooltipWidth / 2;
break;
default:
break;
}
}
} else {
// compute the tooltip's center inside ancestor element
tooltipX = (tooltipEl.ancestor.offsetWidth - tooltipWidth) / 2;
// move the tooltip if the arrow would stick out to the left
if (offsetX + caretX - arrowMinIndent < tooltipX) {
tooltipX = offsetX + caretX - arrowMinIndent;
}
// move the tooltip if the arrow would stick out to the right
if (offsetX + caretX - tooltipWidth + arrowMinIndent > tooltipX) {
tooltipX = offsetX + caretX - tooltipWidth + arrowMinIndent;
}
arrowX = offsetX + caretX - tooltipX;
}
// Compute Y position
switch (tooltip.yAlign) {
case "top":
tooltipY = offsetY + caretY + arrowSize + caretPadding;
break;
case "center":
tooltipY = offsetY + caretY - tooltipHeight / 2;
if (tooltip.xAlign === "left") {
tooltipX += arrowSize;
} else if (tooltip.xAlign === "right") {
tooltipX -= arrowSize;
}
break;
case "bottom":
tooltipY = offsetY + caretY - tooltipHeight - arrowSize - caretPadding;
break;
default:
break;
}
// Position tooltip and display
tooltipEl.style.top = tooltipY.toFixed(1) + "px";
tooltipEl.style.left = tooltipX.toFixed(1) + "px";
if (arrowX === undefined) {
tooltipEl.querySelector(".arrow").style.left = "";
} else {
// Calculate percentage X value depending on the tooltip's
// width to avoid hanging arrow out on tooltip width changes
var arrowXpercent = ((100 / tooltipWidth) * arrowX).toFixed(1);
tooltipEl.querySelector(".arrow").style.left = arrowXpercent + "%";
}
tooltipEl.style.opacity = 1;
};
// eslint-disable-next-line no-unused-vars
function doughnutTooltip(tooltipLabel) {
var percentageTotalShown = tooltipLabel.chart._metasets[0].total.toFixed(1);
// tooltipLabel.chart._metasets[0].total returns the total percentage of the shown slices
// to compensate rounding errors we round to one decimal
var label = " " + tooltipLabel.label;
var itemPercentage;
// if we only show < 1% percent of all, show each item with two decimals
if (percentageTotalShown < 1) {
itemPercentage = tooltipLabel.parsed.toFixed(2);
} else {
// show with one decimal, but in case the item share is really small it could be rounded to 0.0
// we compensate for this
itemPercentage =
tooltipLabel.parsed.toFixed(1) === "0.0" ? "< 0.1" : tooltipLabel.parsed.toFixed(1);
}
// even if no doughnut slice is hidden, sometimes percentageTotalShown is slightly less then 100
// we therefore use 99.9 to decide if slices are hidden (we only show with 0.1 precision)
if (percentageTotalShown > 99.9) {
// All items shown
return label + ": " + itemPercentage + "%";
} else {
// set percentageTotalShown again without rounding to account
// for cases where the total shown percentage would be <0.1% of all
percentageTotalShown = tooltipLabel.chart._metasets[0].total;
return (
label +
":<br>&bull; " +
itemPercentage +
"% of all data<br>&bull; " +
((tooltipLabel.parsed * 100) / percentageTotalShown).toFixed(1) +
"% of shown items"
);
}
}
// chartjs plugin used by the custom doughnut legend
const getOrCreateLegendList = (chart, id) => {
const legendContainer = document.getElementById(id);
let listContainer = legendContainer.querySelector("ul");
if (!listContainer) {
listContainer = document.createElement("ul");
listContainer.style.display = "flex";
listContainer.style.flexDirection = "column";
listContainer.style.flexWrap = "wrap";
listContainer.style.margin = 0;
listContainer.style.padding = 0;
legendContainer.append(listContainer);
}
return listContainer;
};

73
scripts/js/debug.js Normal file
View File

@@ -0,0 +1,73 @@
/* 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. */
function eventsource() {
var ta = $("#output");
var upload = $("#upload");
var dbcheck = $("#dbcheck");
var checked = "";
var token = encodeURIComponent($("#token").text());
if (upload.prop("checked")) {
// add upload option
checked += "&upload";
}
if (dbcheck.prop("checked")) {
// add db integrity check option
checked += "&dbcheck";
}
// IE does not support EventSource - load whole content at once
if (typeof EventSource !== "function") {
$.ajax({
method: "GET",
url: "scripts/php/debug.php?IE&token=" + token + checked,
async: false,
}).done(function (data) {
ta.show();
ta.empty();
ta.append(data);
});
return;
}
var source = new EventSource("scripts/php/debug.php?&token=" + token + checked);
// Reset and show field
ta.empty();
ta.show();
source.addEventListener(
"message",
function (e) {
ta.append(e.data);
// scroll to the bottom of #output (most recent data)
var taBottom = ta.offset().top + ta.outerHeight(true);
$("html, body").scrollTop(taBottom - $(window).height());
},
false
);
// Will be called when script has finished
source.addEventListener(
"error",
function () {
source.close();
$("#output").removeClass("loading");
},
false
);
}
$("#debugBtn").on("click", function () {
$("#debugBtn").prop("disabled", true);
$("#upload").prop("disabled", true);
$("#dbcheck").prop("disabled", true);
$("#output").addClass("loading");
eventsource();
});

758
scripts/js/footer.js Normal file
View File

@@ -0,0 +1,758 @@
/* 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 */
//The following functions allow us to display time until pi-hole is enabled after disabling.
//Works between all pages
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("<i class='fa fa-circle fa-fw text-green-light'></i>&nbsp;&nbsp;Active");
ena.hide();
dis.show();
dis.removeClass("active");
break;
}
case "disabled": {
status.html("<i class='fa fa-circle fa-fw text-red'></i>&nbsp;&nbsp;Blocking disabled");
ena.show();
dis.hide();
break;
}
case "failure": {
status.html(
"<i class='fa-solid fa-triangle-exclamation fa-fw text-red'></i>&nbsp;&nbsp;<span class='text-red'>DNS server failure</span>"
);
ena.hide();
dis.hide();
break;
}
default: {
status.html("<i class='fa fa-circle fa-fw text-red'></i>&nbsp;&nbsp;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: "/api/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("<i class='fa fa-spinner fa-spin'> </i>");
$.ajax({
url: "/api/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 = $('<link href="' + getCheckboxURL(chkboxStyle) + '" rel="stylesheet" />');
// 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(
'<i class="fa fa-fw fa-gauge-high text-green-light"></i>&nbsp;&nbsp;' +
freqFormatted +
"&thinsp;" +
unit
)
.attr("title", title);
}
var ftlinfoTimer = null;
function updateFtlInfo() {
$.ajax({
url: "/api/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, " +
intl.format(database.regex.allowed) +
" regex filters"
);
$("#num_denied")
.text(intl.format(database.domains.denied + database.regex.denied))
.attr(
"title",
"Denied: " +
intl.format(database.domains.denied) +
" exact domains, " +
intl.format(database.regex.denied) +
" regex filters"
);
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: "/api/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(
'<i class="fa fa-fw fa-memory ' +
color +
'"></i>&nbsp;&nbsp;Memory usage:&nbsp;' +
percentRAM.toFixed(1) +
"&thinsp;%"
);
$("#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(
'<i class="fa fa-fw fa-microchip ' +
color +
'"></i>&nbsp;&nbsp;CPU:&nbsp;' +
system.cpu.load.percent[0].toFixed(1) +
"&thinsp;%"
);
$("#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
window.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: "/api/info/version",
}).done(function (data) {
var version = data.version;
var updateAvailable = false;
var dockerUpdate = 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",
},
];
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 =
'<a href="' +
v.url +
"/" +
localVersion +
'" rel="noopener" target="_blank">' +
localVersion +
"</a>";
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" && versionCompare(v.local, v.remote) === -1) {
updateComponentAvailable = true;
dockerUpdate = true;
}
if (updateComponentAvailable) {
$("#versions").append(
"<li><strong>" +
v.name +
"</strong> " +
localVersion +
'&nbsp&middot; <a class="lookatme" lookatme-text="Update available!" href="' +
v.url +
'" rel="noopener" target="_blank">Update available!</a></li>'
);
// if at least one component can be updated, display the update-hint footer
updateAvailable = true;
} else {
$("#versions").append("<li><strong>" + v.name + "</strong> " + localVersion + "</li>");
}
}
});
if (dockerUpdate)
$("#update-hint").html(
'To install updates, <a href="https://github.com/pi-hole/docker-pi-hole#upgrading-persistence-and-customizations" rel="noopener" target="_blank">replace this old container with a fresh upgraded image</a>.'
);
else if (updateAvailable)
$("#update-hint").html(
'To install updates, run <code><a href="https://docs.pi-hole.net/main/update/" rel="noopener" target="_blank">pihole -up</a></code>.'
);
clearTimeout(versionTimer);
versionTimer = utils.setTimer(updateVersionInfo, REFRESH_INTERVAL.version);
});
}
$(function () {
if (window.location.pathname !== "/admin/login") 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 (window.location.pathname !== "/admin/login") {
// 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) {
window.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 = window.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: <i class="fa-solid fa-fw fa-lock' +
(isTLS ? "" : "-open") +
'" title="Your connection is ' +
(isTLS ? "" : "NOT ") +
'end-to-end encrypted (TLS/SSL)"></i>&nbsp;<span id="client-id"></span><br>'
);
// 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);
};
}

106
scripts/js/gravity.js Normal file
View File

@@ -0,0 +1,106 @@
/* 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. */
function eventsource() {
var alInfo = $("#alInfo");
var alSuccess = $("#alSuccess");
var ta = $("#output");
// https://caniuse.com/fetch - everything except IE
// This is fine, as we dropped support for IE a while ago
if (typeof fetch !== "function") {
ta.show();
ta.html("Updating lists of ad-serving domains is not supported with this browser!");
return;
}
ta.html("");
ta.show();
alInfo.show();
alSuccess.hide();
// eslint-disable-next-line compat/compat
fetch("/api/action/gravity", {
method: "POST",
headers: { "X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr("content") },
})
// Retrieve its body as ReadableStream
.then(response => {
const reader = response.body.getReader();
return new ReadableStream({
start(controller) {
return pump();
function pump() {
return reader.read().then(({ done, value }) => {
// When no more data needs to be consumed, close the stream
if (done) {
controller.close();
alInfo.hide();
$("#gravityBtn").prop("disabled", false);
return;
}
// Enqueue the next data chunk into our target stream
controller.enqueue(value);
var string = new TextDecoder().decode(value);
parseLines(ta, string);
if (string.indexOf("Done.") !== -1) {
alSuccess.show();
}
return pump();
});
}
},
});
})
.catch(error => console.error(error)); // eslint-disable-line no-console
}
$("#gravityBtn").on("click", function () {
$("#gravityBtn").prop("disabled", true);
eventsource();
});
// Handle hiding of alerts
$(function () {
$("[data-hide]").on("click", function () {
$(this)
.closest("." + $(this).attr("data-hide"))
.hide();
});
// Do we want to start updating immediately?
// gravity.lp?go
var searchString = window.location.search.substring(1);
if (searchString.indexOf("go") !== -1) {
$("#gravityBtn").prop("disabled", true);
eventsource();
}
});
function parseLines(ta, str) {
// str can contain multiple lines.
// We want to split the text before an "OVER" escape sequence to allow overwriting previous line when needed
// Splitting the text on "\r"
var lines = str.split(/(?=\r)/g);
for (let i = 0; i < lines.length; i++) {
if (lines[i][0] === "\r") {
// This line starts with the "OVER" sequence. Replace them with "\n" before print
lines[i] = lines[i].replaceAll("\r", "\n").replaceAll("\r", "\n");
// Last line from the textarea will be overwritten, so we remove it
ta.text(ta.text().substring(0, ta.text().lastIndexOf("\n")));
}
// Append the new text to the end of the output
ta.append(lines[i]);
}
}

View File

@@ -0,0 +1,486 @@
/* 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, groups:false,, apiFailure:false, updateFtlInfo:false, getGroups:false, processGroupResult:false, delGroupItems:false */
/* exported initTable */
var table;
function reloadClientSuggestions() {
$.ajax({
url: "/api/clients/_suggestions",
type: "GET",
dataType: "json",
success: function (data) {
var sel = $("#select");
sel.empty();
// In order for the placeholder value to appear, we have to have a blank
// <option> as the first option in our <select> control. This is because
// the browser tries to select the first option by default. If our first
// option were non-empty, the browser would display this instead of the
// placeholder.
sel.append($("<option />"));
// Add data obtained from API
for (var i = 0; i < data.clients.length; i++) {
const client = data.clients[i];
let mockDevice = false;
var text = client.hwaddr.toUpperCase();
var key = text;
if (key.startsWith("IP-")) {
// Mock MAC address for address-only devices
mockDevice = true;
key = key.substring(3);
text = key;
}
// Append additional infos if available
var extraInfo = "";
if (client.names !== null && client.names.length > 0) {
// Count number of "," in client.names to determine number of hostnames
var numHostnames = client.names.split(",").length;
const pluralHostnames = numHostnames > 1 ? "s" : "";
extraInfo =
numHostnames + " hostname" + pluralHostnames + ": " + utils.escapeHtml(client.names);
}
if (client.macVendor !== null && client.macVendor.length > 0) {
if (extraInfo.length > 0) extraInfo += "; ";
extraInfo += "vendor: " + utils.escapeHtml(client.macVendor);
}
// Do not add addresses for mock devices as their address is already
// the hwaddr
if (client.addresses !== null && client.addresses.length > 0 && !mockDevice) {
if (extraInfo.length > 0) extraInfo += "; ";
// Count number of "," in client.addresses to determine number of addresses
var numAddresses = client.addresses.split(",").length;
const pluralAddresses = numAddresses > 1 ? "es" : "";
extraInfo +=
numAddresses + " address" + pluralAddresses + ": " + utils.escapeHtml(client.addresses);
}
if (extraInfo.length > 0) text += " (" + extraInfo + ")";
sel.append($("<option />").val(key).text(text));
}
},
});
}
$(function () {
$("#btnAdd").on("click", addClient);
$("select").select2({
tags: true,
placeholder: "Select client...",
allowClear: true,
});
reloadClientSuggestions();
utils.setBsSelectDefaults();
getGroups();
$("#select").on("change", function () {
$("#ip-custom").val("");
$("#ip-custom").prop("disabled", $("#select option:selected").val() !== "custom");
});
});
// eslint-disable-next-line no-unused-vars
function initTable() {
table = $("#clientsTable").DataTable({
processing: true,
ajax: {
url: "/api/clients",
dataSrc: "clients",
type: "GET",
},
order: [[0, "asc"]],
columns: [
{ data: "id", visible: false },
{ data: null, visible: true, orderable: false, width: "15px" },
{ data: "client", type: "ip-address" },
{ data: "comment" },
{ data: "groups", searchable: false },
{ data: null, width: "22px", orderable: false },
],
columnDefs: [
{
targets: 1,
className: "select-checkbox",
render: function () {
return "";
},
},
{
targets: "_all",
render: $.fn.dataTable.render.text(),
},
],
drawCallback: function () {
// Hide buttons if all clients were deleted
var hasRows = this.api().rows({ filter: "applied" }).data().length > 0;
$(".datatable-bt").css("visibility", hasRows ? "visible" : "hidden");
$('button[id^="deleteClient_"]').on("click", deleteClient);
// Remove visible dropdown to prevent orphaning
$("body > .bootstrap-select.dropdown").remove();
},
rowCallback: function (row, data) {
var dataId = utils.hexEncode(data.client);
$(row).attr("data-id", dataId);
var tooltip =
"Added: " +
utils.datetime(data.date_added, false) +
"\nLast modified: " +
utils.datetime(data.date_modified, false) +
"\nDatabase ID: " +
data.id;
var ipName =
'<code id="ip_' +
dataId +
'" title="' +
tooltip +
'" class="breakall">' +
utils.escapeHtml(data.client) +
"</code>";
if (data.name !== null && data.name.length > 0)
ipName +=
'<br><code id="name_' +
dataId +
'" title="' +
tooltip +
'" class="breakall">' +
utils.escapeHtml(data.name) +
"</code>";
$("td:eq(1)", row).html(ipName);
$("td:eq(2)", row).html('<input id="comment_' + dataId + '" class="form-control">');
var commentEl = $("#comment_" + dataId, row);
commentEl.val(data.comment);
commentEl.on("change", editClient);
$("td:eq(3)", row).empty();
$("td:eq(3)", row).append(
'<select class="selectpicker" id="multiselect_' + dataId + '" multiple></select>'
);
var selectEl = $("#multiselect_" + dataId, row);
// Add all known groups
for (var i = 0; i < groups.length; i++) {
var dataSub = "";
if (!groups[i].enabled) {
dataSub = 'data-subtext="(disabled)"';
}
selectEl.append(
$("<option " + dataSub + "/>")
.val(groups[i].id)
.text(groups[i].name)
);
}
// Select assigned groups
selectEl.val(data.groups);
// Initialize bootstrap-select
selectEl
// fix dropdown if it would stick out right of the viewport
.on("show.bs.select", function () {
var winWidth = $(window).width();
var dropdownEl = $("body > .bootstrap-select.dropdown");
if (dropdownEl.length > 0) {
dropdownEl.removeClass("align-right");
var width = dropdownEl.width();
var left = dropdownEl.offset().left;
if (left + width > winWidth) {
dropdownEl.addClass("align-right");
}
}
})
.on("changed.bs.select", function () {
// enable Apply button
if ($(applyBtn).prop("disabled")) {
$(applyBtn)
.addClass("btn-success")
.prop("disabled", false)
.on("click", function () {
editClient.call(selectEl);
});
}
})
.on("hide.bs.select", function () {
// Restore values if drop-down menu is closed without clicking the Apply button
if (!$(applyBtn).prop("disabled")) {
$(this).val(data.groups).selectpicker("refresh");
$(applyBtn).removeClass("btn-success").prop("disabled", true).off("click");
}
})
.selectpicker()
.siblings(".dropdown-menu")
.find(".bs-actionsbox")
.prepend(
'<button type="button" id=btn_apply_' +
dataId +
' class="btn btn-block btn-sm" disabled>Apply</button>'
);
var applyBtn = "#btn_apply_" + dataId;
var button =
'<button type="button" class="btn btn-danger btn-xs" id="deleteClient_' +
dataId +
'" data-id="' +
dataId +
'">' +
'<span class="far fa-trash-alt"></span>' +
"</button>";
$("td:eq(4)", row).html(button);
},
select: {
style: "multi",
selector: "td:not(:last-child)",
info: false,
},
buttons: [
{
text: '<span class="far fa-square"></span>',
titleAttr: "Select All",
className: "btn-sm datatable-bt selectAll",
action: function () {
table.rows({ page: "current" }).select();
},
},
{
text: '<span class="far fa-plus-square"></span>',
titleAttr: "Select All",
className: "btn-sm datatable-bt selectMore",
action: function () {
table.rows({ page: "current" }).select();
},
},
{
extend: "selectNone",
text: '<span class="far fa-check-square"></span>',
titleAttr: "Deselect All",
className: "btn-sm datatable-bt removeAll",
},
{
text: '<span class="far fa-trash-alt"></span>',
titleAttr: "Delete Selected",
className: "btn-sm datatable-bt deleteSelected",
action: function () {
// For each ".selected" row ...
var ids = [];
$("tr.selected").each(function () {
// ... add the row identified by "data-id".
ids.push({ item: $(this).attr("data-id") });
});
// Delete all selected rows at once
delGroupItems("client", ids, table);
},
},
],
dom:
"<'row'<'col-sm-6'l><'col-sm-6'f>>" +
"<'row'<'col-sm-3'B><'col-sm-9'p>>" +
"<'row'<'col-sm-12'<'table-responsive'tr>>>" +
"<'row'<'col-sm-3'B><'col-sm-9'p>>" +
"<'row'<'col-sm-12'i>>",
lengthMenu: [
[10, 25, 50, 100, -1],
[10, 25, 50, 100, "All"],
],
stateSave: true,
stateDuration: 0,
stateSaveCallback: function (settings, data) {
utils.stateSaveCallback("groups-clients-table", data);
},
stateLoadCallback: function () {
var data = utils.stateLoadCallback("groups-clients-table");
// Return if not available
if (data === null) {
return null;
}
// Reset visibility of ID column
data.columns[0].visible = false;
// Apply loaded state to table
return data;
},
});
// Disable autocorrect in the search box
var input = document.querySelector("input[type=search]");
if (input !== null) {
input.setAttribute("autocomplete", "off");
input.setAttribute("autocorrect", "off");
input.setAttribute("autocapitalize", "off");
input.setAttribute("spellcheck", false);
}
table.on("init select deselect", function () {
utils.changeBulkDeleteStates(table);
});
table.on("order.dt", function () {
var order = table.order();
if (order[0][0] !== 0 || order[0][1] !== "asc") {
$("#resetButton").removeClass("hidden");
} else {
$("#resetButton").addClass("hidden");
}
});
$("#resetButton").on("click", function () {
table.order([[0, "asc"]]).draw();
$("#resetButton").addClass("hidden");
});
}
// Remove 'bnt-group' class from container, to avoid grouping
$.fn.dataTable.Buttons.defaults.dom.container.className = "dt-buttons";
function deleteClient() {
// Passes the button data-id attribute as ID
const ids = [{ item: $(this).attr("data-id") }];
delGroupItems("client", ids, table);
}
function addClient() {
const comment = $("#new_comment").val();
// Check if the user wants to add multiple IPs (space or newline separated)
// If so, split the input and store it in an array
var ips = $("#select")
.val()
.trim()
.split(/[\s,]+/);
// Remove empty elements
ips = ips.filter(function (el) {
return el !== "";
});
const ipStr = JSON.stringify(ips);
// Validate input, can be:
// - IPv4 address (with and without CIDR)
// - IPv6 address (with and without CIDR)
// - MAC address (in the form AA:BB:CC:DD:EE:FF)
// - host name (arbitrary form, we're only checking against some reserved characters)
for (var i = 0; i < ips.length; i++) {
if (
utils.validateIPv4CIDR(ips[i]) ||
utils.validateIPv6CIDR(ips[i]) ||
utils.validateMAC(ips[i])
) {
// Convert input to upper case (important for MAC addresses)
ips[i] = ips[i].toUpperCase();
} else if (!utils.validateHostname(ips[i])) {
utils.showAlert(
"warning",
"",
"Warning",
"Input is neither a valid IP or MAC address nor a valid host name!"
);
return;
}
}
utils.disableAll();
utils.showAlert("info", "", "Adding client(s)...", ipStr);
if (ips.length === 0) {
utils.enableAll();
utils.showAlert("warning", "", "Warning", "Please specify a client IP or MAC address");
return;
}
$.ajax({
url: "/api/clients",
method: "post",
dataType: "json",
processData: false,
contentType: "application/json; charset=utf-8",
data: JSON.stringify({ client: ips, comment: comment }),
success: function (data) {
utils.enableAll();
utils.listsAlert("client", ips, data);
reloadClientSuggestions();
$("#new_comment").val("");
table.ajax.reload(null, false);
table.rows().deselect();
// Update number of groups in the sidebar
updateFtlInfo();
},
error: function (data, exception) {
apiFailure(data);
utils.enableAll();
utils.showAlert("error", "", "Error while adding new client", data.responseText);
console.log(exception); // eslint-disable-line no-console
},
});
}
function editClient() {
const elem = $(this).attr("id");
const tr = $(this).closest("tr");
const client = tr.attr("data-id");
// Convert list of string integers to list of integers using map(Number)
const groups = tr
.find("#multiselect_" + client)
.val()
.map(Number);
const comment = tr.find("#comment_" + client).val();
var done = "edited";
var notDone = "editing";
switch (elem) {
case "multiselect_" + client:
done = "edited groups of";
notDone = "editing groups of";
break;
case "comment_" + client:
done = "edited comment of";
notDone = "editing comment of";
break;
default:
alert("bad element (" + elem + ") or invalid data-id!");
return;
}
utils.disableAll();
const clientDecoded = utils.hexDecode(client);
utils.showAlert("info", "", "Editing client...", clientDecoded);
$.ajax({
url: "/api/clients/" + encodeURIComponent(clientDecoded),
method: "put",
dataType: "json",
processData: false,
contentType: "application/json; charset=utf-8",
data: JSON.stringify({
groups: groups,
comment: comment,
}),
success: function (data) {
utils.enableAll();
processGroupResult(data, "client", done, notDone);
table.ajax.reload(null, false);
},
error: function (data, exception) {
apiFailure(data);
utils.enableAll();
utils.showAlert(
"error",
"",
"Error while " + notDone + " client " + clientDecoded,
data.responseText
);
console.log(exception); // eslint-disable-line no-console
},
});
}

View File

@@ -0,0 +1,95 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2023 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 apiFailure:false, utils:false, initTable:false, updateFtlInfo:false */
// eslint-disable-next-line no-unused-vars
var groups = [];
// eslint-disable-next-line no-unused-vars
function getGroups() {
$.ajax({
url: "/api/groups",
type: "GET",
dataType: "json",
success: function (data) {
groups = data.groups;
// Actually load table contents
initTable();
},
error: function (data) {
apiFailure(data);
},
});
}
// eslint-disable-next-line no-unused-vars
function processGroupResult(data, type, done, notDone) {
// Loop over data.processed.success and show toasts
data.processed.success.forEach(function (item) {
utils.showAlert("success", "fas fa-pencil-alt", `Successfully ${done} ${type}`, item);
});
// Loop over errors and display them
data.processed.errors.forEach(function (error) {
console.log(error); // eslint-disable-line no-console
utils.showAlert("error", "", `Error while ${notDone} ${type} ${error.item}`, error.error);
});
}
// eslint-disable-next-line no-unused-vars
function delGroupItems(type, ids, table, listType = undefined) {
// Check input validity
if (!Array.isArray(ids)) return;
const url = "/api/" + type + "s:batchDelete";
// use utils.hexDecode() to decode all clients
let idstring = "";
for (var i = 0; i < ids.length; i++) {
ids[i].item = utils.hexDecode(ids[i].item);
idstring += ids[i].item + ", ";
}
// Remove last comma and space from idstring
idstring = idstring.substring(0, idstring.length - 2);
// Append "s" to type if more than one item is deleted
type += ids.length > 1 ? "s" : "";
// Prepend listType to type if it is not undefined
if (listType !== undefined) {
type = listType + " " + type;
}
utils.disableAll();
utils.showAlert("info", "", "Deleting " + ids.length + " " + type + "...", idstring);
$.ajax({
url: url,
data: JSON.stringify(ids),
contentType: "application/json",
method: "POST",
})
.done(function () {
utils.enableAll();
utils.showAlert("success", "far fa-trash-alt", "Successfully deleted " + type, idstring);
table.ajax.reload(null, false);
// Clear selection after deletion
table.rows().deselect();
utils.changeBulkDeleteStates(table);
// Update number of <type> items in the sidebar
updateFtlInfo();
})
.fail(function (data, exception) {
apiFailure(data);
utils.enableAll();
utils.showAlert("error", "", "Error while deleting " + type, data.responseText);
console.log(exception); // eslint-disable-line no-console
});
}

View File

@@ -0,0 +1,621 @@
/* 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, groups:false,, getGroups:false, updateFtlInfo:false, apiFailure:false, processGroupResult:false, delGroupItems:false */
/* exported initTable */
var table;
var GETDict = {};
$(function () {
GETDict = utils.parseQueryString();
// Tabs: Domain/Regex handling
// sync description fields, reset inactive inputs on tab change
$('a[data-toggle="tab"]').on("shown.bs.tab", function () {
var tabHref = $(this).attr("href");
var val;
if (tabHref === "#tab_domain") {
val = $("#new_regex_comment").val();
$("#new_domain_comment").val(val);
$("#new_regex").val("");
} else if (tabHref === "#tab_regex") {
val = $("#new_domain_comment").val();
$("#new_regex_comment").val(val);
$("#new_domain").val("");
$("#wildcard_checkbox").prop("checked", false);
}
clearTimeout(suggestTimeout);
$("#suggest_domains").hide();
});
$("#add_deny, #add_allow").on("click", addDomain);
// Domain suggestion handling
var suggestTimeout;
$("#new_domain").on("input", function (e) {
hideSuggestDomains();
clearTimeout(suggestTimeout);
suggestTimeout = setTimeout(showSuggestDomains, 1000, e.target.value);
});
utils.setBsSelectDefaults();
getGroups();
});
// Show a list of suggested domains based on the user's input
function showSuggestDomains(value) {
function createButton(hostname) {
// Purposefully omit 'btn' class to save space on padding
return $('<button type="button" class="btn-link btn-block text-right">')
.append($("<i>").text(hostname))
.on("click", function () {
hideSuggestDomains();
newDomainEl.val(hostname);
});
}
var newDomainEl = $("#new_domain");
var suggestDomainEl = $("#suggest_domains");
try {
// URL is not supported in all browsers, but we are in a try-block so we can ignore it
// eslint-disable-next-line compat/compat
var parts = new URL(value).hostname.split(".");
var table = $("<table>");
for (var i = 0; i < parts.length - 1; ++i) {
var hostname = parts.slice(i).join(".");
table.append(
$("<tr>")
.append($('<td class="text-nowrap text-right">').text(i === 0 ? "Did you mean" : "or"))
.append($("<td>").append(createButton(hostname)))
);
}
suggestDomainEl.slideUp("fast", function () {
suggestDomainEl.html(table);
suggestDomainEl.slideDown("fast");
});
} catch {
hideSuggestDomains();
}
}
function hideSuggestDomains() {
$("#suggest_domains").slideUp("fast");
}
// eslint-disable-next-line no-unused-vars
function initTable() {
table = $("#domainsTable").DataTable({
processing: true,
ajax: {
url: "/api/domains",
dataSrc: "domains",
type: "GET",
},
order: [[0, "asc"]],
columns: [
{ data: "id", visible: false },
{ data: null, visible: true, orderable: false, width: "15px" },
{ data: "domain" },
{ data: "type", searchable: false },
{ data: "enabled", searchable: false },
{ data: "comment" },
{ data: "groups", searchable: false },
{ data: null, width: "22px", orderable: false },
],
columnDefs: [
{
targets: 1,
className: "select-checkbox",
render: function () {
return "";
},
},
{
targets: "_all",
render: $.fn.dataTable.render.text(),
},
],
drawCallback: function () {
// Hide buttons if all domains were deleted
var hasRows = this.api().rows({ filter: "applied" }).data().length > 0;
$(".datatable-bt").css("visibility", hasRows ? "visible" : "hidden");
$('button[id^="deleteDomain_"]').on("click", deleteDomain);
// Remove visible dropdown to prevent orphaning
$("body > .bootstrap-select.dropdown").remove();
},
rowCallback: function (row, data) {
var dataId = utils.hexEncode(data.domain) + "_" + data.type + "_" + data.kind;
$(row).attr("data-id", dataId);
// Tooltip for domain
var tooltip =
"Added: " +
utils.datetime(data.date_added, false) +
"\nLast modified: " +
utils.datetime(data.date_modified, false) +
"\nDatabase ID: " +
data.id;
$("td:eq(1)", row).html(
'<code id="domain_' +
dataId +
'" title="' +
tooltip +
'" class="breakall">' +
utils.escapeHtml(data.unicode) +
(data.domain !== data.unicode ? " (" + utils.escapeHtml(data.domain) + ")" : "") +
"</code>"
);
// Drop-down type selector
$("td:eq(2)", row).html(
'<select id="type_' +
dataId +
'" class="form-control">' +
'<option value="allow/exact"' +
(data.type === "allow" && data.kind === "exact" ? " selected" : "") +
">Exact allow</option>" +
'<option value="allow/regex"' +
(data.type === "allow" && data.kind === "regex" ? " selected" : "") +
">Regex allow</option>" +
'<option value="deny/exact"' +
(data.type === "deny" && data.kind === "exact" ? " selected " : "") +
">Exact deny</option>" +
'<option value="deny/regex"' +
(data.type === "deny" && data.kind === "regex" ? " selected" : "") +
">Regex deny</option>" +
"</select>" +
"<input type='hidden' id='old_type_" +
dataId +
"' value='" +
data.type +
"/" +
data.kind +
"'>"
);
var typeEl = $("#type_" + dataId, row);
typeEl.on("change", editDomain);
// Initialize bootstrap-toggle for status field (enabled/disabled)
$("td:eq(3)", row).html(
'<input type="checkbox" id="enabled_' +
dataId +
'"' +
(data.enabled ? " checked" : "") +
">"
);
var statusEl = $("#enabled_" + dataId, row);
statusEl.bootstrapToggle({
on: "Enabled",
off: "Disabled",
size: "small",
onstyle: "success",
width: "80px",
});
statusEl.on("change", editDomain);
// Comment field
$("td:eq(4)", row).html('<input id="comment_' + dataId + '" class="form-control">');
var commentEl = $("#comment_" + dataId, row);
commentEl.val(data.comment);
commentEl.on("change", editDomain);
// Group assignment field (multi-select)
$("td:eq(5)", row).empty();
$("td:eq(5)", row).append(
'<select class="selectpicker" id="multiselect_' + dataId + '" multiple></select>'
);
var selectEl = $("#multiselect_" + dataId, row);
// Add all known groups
for (var i = 0; i < groups.length; i++) {
var dataSub = "";
if (!groups[i].enabled) {
dataSub = 'data-subtext="(disabled)"';
}
selectEl.append(
$("<option " + dataSub + "/>")
.val(groups[i].id)
.text(groups[i].name)
);
}
// Select assigned groups
selectEl.val(data.groups);
// Initialize bootstrap-select
const applyBtn = "#btn_apply_" + dataId;
selectEl
// fix dropdown if it would stick out right of the viewport
.on("show.bs.select", function () {
var winWidth = $(window).width();
var dropdownEl = $("body > .bootstrap-select.dropdown");
if (dropdownEl.length > 0) {
dropdownEl.removeClass("align-right");
var width = dropdownEl.width();
var left = dropdownEl.offset().left;
if (left + width > winWidth) {
dropdownEl.addClass("align-right");
}
}
})
.on("changed.bs.select", function () {
// enable Apply button if changes were made to the drop-down menu
// and have it call editDomain() on click
if ($(applyBtn).prop("disabled")) {
$(applyBtn)
.addClass("btn-success")
.prop("disabled", false)
.on("click", function () {
editDomain.call(selectEl);
});
}
})
.on("hide.bs.select", function () {
// Restore values if drop-down menu is closed without clicking the
// Apply button (e.g. by clicking outside) and re-disable the Apply
// button
if (!$(applyBtn).prop("disabled")) {
$(this).val(data.groups).selectpicker("refresh");
$(applyBtn).removeClass("btn-success").prop("disabled", true).off("click");
}
})
.selectpicker()
.siblings(".dropdown-menu")
.find(".bs-actionsbox")
.prepend(
'<button type="button" id=btn_apply_' +
dataId +
' class="btn btn-block btn-sm" disabled>Apply</button>'
);
// Highlight row (if url parameter "domainid=" is used)
if ("domainid" in GETDict && data.id === parseInt(GETDict.domainid, 10)) {
$(row).find("td").addClass("highlight");
}
// Add delete domain button
var button =
'<button type="button" class="btn btn-danger btn-xs" id="deleteDomain_' +
dataId +
'" data-id="' +
dataId +
'">' +
'<span class="far fa-trash-alt"></span>' +
"</button>";
$("td:eq(6)", row).html(button);
},
select: {
style: "multi",
selector: "td:first-child",
info: false,
},
buttons: [
{
text: '<span class="far fa-square"></span>',
titleAttr: "Select All",
className: "btn-sm datatable-bt selectAll",
action: function () {
table.rows({ page: "current" }).select();
},
},
{
text: '<span class="far fa-plus-square"></span>',
titleAttr: "Select All",
className: "btn-sm datatable-bt selectMore",
action: function () {
table.rows({ page: "current" }).select();
},
},
{
extend: "selectNone",
text: '<span class="far fa-check-square"></span>',
titleAttr: "Deselect All",
className: "btn-sm datatable-bt removeAll",
},
{
text: '<span class="far fa-trash-alt"></span>',
titleAttr: "Delete Selected",
className: "btn-sm datatable-bt deleteSelected",
action: function () {
// For each ".selected" row ...
var ids = [];
$("tr.selected").each(function () {
// ... add the row identified by "data-id".
ids.push($(this).attr("data-id"));
});
// Delete all selected rows at once
deleteDomains(ids);
},
},
],
dom:
"<'row'<'col-sm-6'l><'col-sm-6'f>>" +
"<'row'<'col-sm-3'B><'col-sm-9'p>>" +
"<'row'<'col-sm-12'<'table-responsive'tr>>>" +
"<'row'<'col-sm-3'B><'col-sm-9'p>>" +
"<'row'<'col-sm-12'i>>",
lengthMenu: [
[10, 25, 50, 100, -1],
[10, 25, 50, 100, "All"],
],
stateSave: true,
stateDuration: 0,
stateSaveCallback: function (settings, data) {
utils.stateSaveCallback("groups-domains-table", data);
},
stateLoadCallback: function () {
var data = utils.stateLoadCallback("groups-domains-table");
// Return if not available
if (data === null) {
return null;
}
// Reset visibility of ID column
data.columns[0].visible = false;
// Apply loaded state to table
return data;
},
initComplete: function () {
if ("domainid" in GETDict) {
var pos = table
.column(0, { order: "current" })
.data()
.indexOf(parseInt(GETDict.domainid, 10));
if (pos >= 0) {
var page = Math.floor(pos / table.page.info().length);
table.page(page).draw(false);
}
}
},
});
// Disable autocorrect in the search box
var input = document.querySelector("input[type=search]");
if (input !== null) {
input.setAttribute("autocomplete", "off");
input.setAttribute("autocorrect", "off");
input.setAttribute("autocapitalize", "off");
input.setAttribute("spellcheck", false);
}
table.on("init select deselect", function () {
utils.changeBulkDeleteStates(table);
});
table.on("order.dt", function () {
var order = table.order();
if (order[0][0] !== 0 || order[0][1] !== "asc") {
$("#resetButton").removeClass("hidden");
} else {
$("#resetButton").addClass("hidden");
}
});
$("#resetButton").on("click", function () {
table.order([[0, "asc"]]).draw();
$("#resetButton").addClass("hidden");
});
}
// Enable "filter by type" functionality, using checkboxes
$.fn.dataTable.ext.search.push(function (settings, searchData, index, rowData) {
var types = $(".filter_types input:checkbox:checked")
.map(function () {
return this.value;
})
.get();
const typeStr = rowData.type + "/" + rowData.kind;
if (types.indexOf(typeStr) !== -1) {
return true;
}
return false;
});
$(".filter_types input:checkbox").on("change", function () {
table.draw();
});
// Remove 'bnt-group' class from container, to avoid grouping
$.fn.dataTable.Buttons.defaults.dom.container.className = "dt-buttons";
function deleteDomain() {
// Passes the button data-id attribute as ID
deleteDomains([$(this).attr("data-id")]);
}
function deleteDomains(encodedIds) {
const decodedIds = [];
for (let i = 0; i < encodedIds.length; i++) {
// Decode domain, type, and kind and add to array
const parts = encodedIds[i].split("_");
decodedIds[i] = {
item: parts[0],
type: parts[1],
kind: parts[2],
};
}
delGroupItems("domain", decodedIds, table);
}
function addDomain() {
const action = this.id;
const tabHref = $('a[data-toggle="tab"][aria-expanded="true"]').attr("href");
const wildcardEl = $("#wildcard_checkbox");
const wildcardChecked = wildcardEl.prop("checked");
// current tab's inputs
var kind, domainEl, commentEl;
if (tabHref === "#tab_domain") {
kind = "exact";
domainEl = $("#new_domain");
commentEl = $("#new_domain_comment");
} else if (tabHref === "#tab_regex") {
kind = "regex";
domainEl = $("#new_regex");
commentEl = $("#new_regex_comment");
}
const comment = commentEl.val();
// Check if the user wants to add multiple domains (space or newline separated)
// If so, split the input and store it in an array
var domains = domainEl.val().split(/\s+/);
// Remove empty elements
domains = domains.filter(function (el) {
return el !== "";
});
const domainStr = JSON.stringify(domains);
utils.disableAll();
utils.showAlert("info", "", "Adding domain(s)...", domainStr);
if (domains.length === 0) {
utils.enableAll();
utils.showAlert("warning", "", "Warning", "Please specify at least one domain");
return;
}
for (var i = 0; i < domains.length; i++) {
if (kind === "exact" && wildcardChecked) {
// Transform domain to wildcard if specified by user
domains[i] = "(\\.|^)" + domains[i].replaceAll(".", "\\.") + "$";
kind = "regex";
// strip leading "*." if specified by user in wildcard mode
if (domains[i].startsWith("*.")) domains[i] = domains[i].substr(2);
}
}
// determine list type
const type = action === "add_deny" ? "deny" : "allow";
$.ajax({
url: "/api/domains/" + type + "/" + kind,
method: "post",
dataType: "json",
processData: false,
contentType: "application/json; charset=utf-8",
data: JSON.stringify({
domain: domains,
comment: comment,
type: type,
kind: kind,
}),
success: function (data) {
utils.enableAll();
utils.listsAlert("domain", domains, data);
$("#new_domain").val("");
$("#new_domain_comment").val("");
$("#new_regex").val("");
$("#new_regex_comment").val("");
table.ajax.reload(null, false);
table.rows().deselect();
// Update number of groups in the sidebar
updateFtlInfo();
},
error: function (data, exception) {
apiFailure(data);
utils.enableAll();
utils.showAlert("error", "", "Error while adding new domain", data.responseText);
console.log(exception); // eslint-disable-line no-console
},
});
}
function editDomain() {
const elem = $(this).attr("id");
const tr = $(this).closest("tr");
const domain = tr.attr("data-id");
const newTypestr = tr.find("#type_" + domain).val();
const oldTypeStr = tr.find("#old_type_" + domain).val();
const enabled = tr.find("#enabled_" + domain).is(":checked");
const comment = tr.find("#comment_" + domain).val();
// Convert list of string integers to list of integers using map
const groups = tr
.find("#multiselect_" + domain)
.val()
.map(Number);
const oldType = oldTypeStr.split("/")[0];
const oldKind = oldTypeStr.split("/")[1];
var done = "edited";
var notDone = "editing";
switch (elem) {
case "enabled_" + domain:
if (!enabled) {
done = "disabled";
notDone = "disabling";
} else {
done = "enabled";
notDone = "enabling";
}
break;
case "name_" + domain:
done = "edited name of";
notDone = "editing name of";
break;
case "comment_" + domain:
done = "edited comment of";
notDone = "editing comment of";
break;
case "type_" + domain:
done = "edited type of";
notDone = "editing type of";
break;
case "multiselect_" + domain:
done = "edited groups of";
notDone = "editing groups of";
break;
default:
alert("bad element (" + elem + ") or invalid data-id!");
return;
}
utils.disableAll();
const domainDecoded = utils.hexDecode(domain.split("_")[0]);
utils.showAlert("info", "", "Editing domain...", domainDecoded);
$.ajax({
url: "/api/domains/" + newTypestr + "/" + encodeURIComponent(domainDecoded),
method: "put",
dataType: "json",
processData: false,
contentType: "application/json; charset=utf-8",
data: JSON.stringify({
groups: groups,
comment: comment,
enabled: enabled,
type: oldType,
kind: oldKind,
}),
success: function (data) {
utils.enableAll();
processGroupResult(data, "domain", done, notDone);
table.ajax.reload(null, false);
},
error: function (data, exception) {
apiFailure(data);
utils.enableAll();
utils.showAlert(
"error",
"",
"Error while " + notDone + " domain " + domainDecoded,
data.responseText
);
console.log(exception); // eslint-disable-line no-console
},
});
}

617
scripts/js/groups-lists.js Normal file
View File

@@ -0,0 +1,617 @@
/* 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, groups:false, apiFailure:false, updateFtlInfo:false, getGroups:false, processGroupResult:false, delGroupItems:false */
/* exported initTable */
var table;
var GETDict = {};
$(function () {
GETDict = utils.parseQueryString();
$("#btnAddAllow").on("click", { type: "allow" }, addList);
$("#btnAddBlock").on("click", { type: "block" }, addList);
utils.setBsSelectDefaults();
getGroups();
});
function format(data) {
// Generate human-friendly status string
var statusText = setStatusText(data, true);
var numbers = true;
if (data.status === 0 || data.status === 4) {
numbers = false;
}
// Compile extra info for displaying
var dateAddedISO = utils.datetime(data.date_added, false),
dateModifiedISO = utils.datetime(data.date_modified, false),
dateUpdated =
data.date_updated > 0
? utils.datetimeRelative(data.date_updated) +
"&nbsp;(" +
utils.datetime(data.date_updated, false) +
")"
: "N/A",
numberOfEntries =
(data.number !== null && numbers === true
? parseInt(data.number, 10).toLocaleString()
: "N/A") +
(data.abp_entries !== null && parseInt(data.abp_entries, 10) > 0 && numbers === true
? " (out of which " + parseInt(data.abp_entries, 10).toLocaleString() + " are in ABP-style)"
: ""),
nonDomains =
data.invalid_domains !== null && numbers === true
? parseInt(data.invalid_domains, 10).toLocaleString()
: "N/A";
return `<table>
<tr class="dataTables-child">
<td>Type of this list:</td><td>${setTypeIcon(data.type)}${data.type}list</td>
</tr>
<tr class="dataTables-child">
<td>Health status of this list:</td><td>${statusText}</td>
</tr>
<tr class="dataTables-child">
<td>This list was added to Pi-hole&nbsp;&nbsp;</td>
<td>${utils.datetimeRelative(data.date_added)}&nbsp;(${dateAddedISO})</td>
</tr>
<tr class="dataTables-child">
<td>Database entry was last modified&nbsp;&nbsp;</td>
<td>${utils.datetimeRelative(data.date_modified)}&nbsp;(${dateModifiedISO})</td>
</tr>
<tr class="dataTables-child">
<td>The list contents were last updated&nbsp;&nbsp;</td><td>${dateUpdated}</td>
</tr>
<tr class="dataTables-child">
<td>Number of entries on this list:&nbsp;&nbsp;</td><td>${numberOfEntries}</td>
</tr>
<tr class="dataTables-child">
<td>Number of non-domains on this list:&nbsp;&nbsp;</td><td>${nonDomains}</td>
</tr>
<tr class="dataTables-child">
<td>Database ID of this list:</td><td>${data.id}</td>
</tr>
</table>`;
}
// Define the status icon element
function setStatusIcon(data) {
var statusCode = parseInt(data.status, 10),
statusTitle = setStatusText(data) + "\nClick for details about this list",
statusIcon;
switch (statusCode) {
case 1:
statusIcon = "fa-check-circle";
break;
case 2:
statusIcon = "fa-history";
break;
case 3:
statusIcon = "fa-exclamation-circle";
break;
case 4:
statusIcon = "fa-times-circle";
break;
default:
statusIcon = "fa-question-circle";
break;
}
return "<i class='fa fa-fw " + statusIcon + "' title='" + statusTitle + "'></i>";
}
// Define human-friendly status string
function setStatusText(data, showdetails = false) {
var statusText = "Unknown",
statusDetails = "";
if (data.status !== null) {
switch (parseInt(data.status, 10)) {
case 0:
statusText =
data.enabled === 0
? "List is disabled and not checked"
: "List was not downloaded so far";
break;
case 1:
statusText = "List download was successful";
statusDetails = ' (<span class="list-status-1">OK</span>)';
break;
case 2:
statusText = "List unchanged upstream, Pi-hole used a local copy";
statusDetails = ' (<span class="list-status-2">OK</span>)';
break;
case 3:
statusText = "List unavailable, Pi-hole used a local copy";
statusDetails = ' (<span class="list-status-3">check list</span>)';
break;
case 4:
statusText =
"List unavailable, there is no local copy of this list available on your Pi-hole";
statusDetails = ' (<span class="list-status-4">replace list</span>)';
break;
default:
statusText = "Unknown";
statusDetails = ' (<span class="list-status-0">' + parseInt(data.status, 10) + "</span>)";
break;
}
}
return statusText + (showdetails === true ? statusDetails : "");
}
// Define the type icon element
function setTypeIcon(type) {
//Add red ban icon if data["type"] is "block"
//Add green check icon if data["type"] is "allow"
let iconClass = "fa-question text-orange",
title = "This list is of unknown type";
if (type === "block") {
iconClass = "fa-ban text-red";
title = "This is a blocklist";
} else if (type === "allow") {
iconClass = "fa-check text-green";
title = "This is an allowlist";
}
return `<i class='fa fa-fw ${iconClass}' title='${title}\nClick for details about this list'></i> `;
}
// eslint-disable-next-line no-unused-vars
function initTable() {
table = $("#listsTable").DataTable({
processing: true,
ajax: {
url: "/api/lists",
dataSrc: "lists",
type: "GET",
},
order: [[0, "asc"]],
columns: [
{ data: "id", visible: false },
{ data: null, visible: true, orderable: false, width: "15px" },
{ data: "status", searchable: false, class: "details-control" },
{ data: "type", searchable: false, class: "details-control" },
{ data: "address" },
{ data: "enabled", searchable: false },
{ data: "comment" },
{ data: "groups", searchable: false },
{ data: null, width: "22px", orderable: false },
],
columnDefs: [
{
targets: 1,
className: "select-checkbox",
render: function () {
return "";
},
},
{
targets: "_all",
render: $.fn.dataTable.render.text(),
},
],
drawCallback: function () {
// Hide buttons if all lists were deleted
var hasRows = this.api().rows({ filter: "applied" }).data().length > 0;
$(".datatable-bt").css("visibility", hasRows ? "visible" : "hidden");
$('button[id^="deleteList_"]').on("click", deleteList);
// Remove visible dropdown to prevent orphaning
$("body > .bootstrap-select.dropdown").remove();
},
rowCallback: function (row, data) {
var dataId = utils.hexEncode(data.address + "_" + data.type);
$(row).attr("data-id", dataId);
$(row).attr("data-address", utils.hexEncode(data.address));
$(row).attr("data-type", data.type);
var statusCode = 0;
// If there is no status or the list is disabled, we keep
// status 0 (== unknown)
if (data.status !== null && data.enabled) {
statusCode = parseInt(data.status, 10);
}
$("td:eq(1)", row).addClass("list-status-" + statusCode);
$("td:eq(1)", row).html(setStatusIcon(data));
$("td:eq(2)", row).addClass("list-type-" + statusCode);
$("td:eq(2)", row).html(setTypeIcon(data.type));
if (data.address.startsWith("file://")) {
// Local files cannot be downloaded from a distant client so don't show
// a link to such a list here
$("td:eq(3)", row).html(
'<code id="address_' +
dataId +
'" class="breakall">' +
utils.escapeHtml(data.address) +
"</code>"
);
} else {
$("td:eq(3)", row).html(
'<a id="address_' +
dataId +
'" class="breakall" href="' +
encodeURI(data.address) +
'" target="_blank" rel="noopener noreferrer">' +
utils.escapeHtml(data.address) +
"</a>"
);
}
$("td:eq(4)", row).html(
'<input type="checkbox" id="enabled_' +
dataId +
'"' +
(data.enabled ? " checked" : "") +
">"
);
var statusEl = $("#enabled_" + dataId, row);
statusEl.bootstrapToggle({
on: "Enabled",
off: "Disabled",
size: "small",
onstyle: "success",
width: "80px",
});
statusEl.on("change", editList);
$("td:eq(5)", row).html('<input id="comment_' + dataId + '" class="form-control">');
var commentEl = $("#comment_" + dataId, row);
commentEl.val(data.comment);
commentEl.on("change", editList);
$("td:eq(6)", row).empty();
$("td:eq(6)", row).append(
'<select class="selectpicker" id="multiselect_' + dataId + '" multiple></select>'
);
var selectEl = $("#multiselect_" + dataId, row);
// Add all known groups
for (var i = 0; i < groups.length; i++) {
var dataSub = "";
if (!groups[i].enabled) {
dataSub = 'data-subtext="(disabled)"';
}
selectEl.append(
$("<option " + dataSub + "/>")
.val(groups[i].id)
.text(groups[i].name)
);
}
var applyBtn = "#btn_apply_" + dataId;
// Select assigned groups
selectEl.val(data.groups);
// Initialize bootstrap-select
selectEl
// fix dropdown if it would stick out right of the viewport
.on("show.bs.select", function () {
var winWidth = $(window).width();
var dropdownEl = $("body > .bootstrap-select.dropdown");
if (dropdownEl.length > 0) {
dropdownEl.removeClass("align-right");
var width = dropdownEl.width();
var left = dropdownEl.offset().left;
if (left + width > winWidth) {
dropdownEl.addClass("align-right");
}
}
})
.on("changed.bs.select", function () {
// enable Apply button
if ($(applyBtn).prop("disabled")) {
$(applyBtn)
.addClass("btn-success")
.prop("disabled", false)
.on("click", function () {
editList.call(selectEl);
});
}
})
.on("hide.bs.select", function () {
// Restore values if drop-down menu is closed without clicking the Apply button
if (!$(applyBtn).prop("disabled")) {
$(this).val(data.groups).selectpicker("refresh");
$(applyBtn).removeClass("btn-success").prop("disabled", true).off("click");
}
})
.selectpicker()
.siblings(".dropdown-menu")
.find(".bs-actionsbox")
.prepend(
'<button type="button" id=btn_apply_' +
dataId +
' class="btn btn-block btn-sm" disabled>Apply</button>'
);
// Highlight row (if url parameter "listid=" is used)
if ("listid" in GETDict && data.id === parseInt(GETDict.listid, 10)) {
$(row).find("td").addClass("highlight");
}
var button =
'<button type="button" class="btn btn-danger btn-xs" id="deleteList_' +
dataId +
'" data-id="' +
dataId +
'">' +
'<span class="far fa-trash-alt"></span>' +
"</button>";
$("td:eq(7)", row).html(button);
},
dom:
"<'row'<'col-sm-6'l><'col-sm-6'f>>" +
"<'row'<'col-sm-3'B><'col-sm-9'p>>" +
"<'row'<'col-sm-12'<'table-responsive'tr>>>" +
"<'row'<'col-sm-3'B><'col-sm-9'p>>" +
"<'row'<'col-sm-12'i>>",
lengthMenu: [
[10, 25, 50, 100, -1],
[10, 25, 50, 100, "All"],
],
select: {
style: "multi",
selector: "td:not(:last-child)",
info: false,
},
buttons: [
{
text: '<span class="far fa-square"></span>',
titleAttr: "Select All",
className: "btn-sm datatable-bt selectAll",
action: function () {
table.rows({ page: "current" }).select();
},
},
{
text: '<span class="far fa-plus-square"></span>',
titleAttr: "Select All",
className: "btn-sm datatable-bt selectMore",
action: function () {
table.rows({ page: "current" }).select();
},
},
{
extend: "selectNone",
text: '<span class="far fa-check-square"></span>',
titleAttr: "Deselect All",
className: "btn-sm datatable-bt removeAll",
},
{
text: '<span class="far fa-trash-alt"></span>',
titleAttr: "Delete Selected",
className: "btn-sm datatable-bt deleteSelected",
action: function () {
// For each ".selected" row ...
var ids = [];
$("tr.selected").each(function () {
// ... add the row identified by "data-id".
ids.push({ item: $(this).attr("data-address"), type: $(this).attr("data-type") });
});
// Delete all selected rows at once
delGroupItems("list", ids, table, "multiple ");
},
},
],
stateSave: true,
stateDuration: 0,
stateSaveCallback: function (settings, data) {
utils.stateSaveCallback("groups-lists-table", data);
},
stateLoadCallback: function () {
var data = utils.stateLoadCallback("groups-lists-table");
// Return if not available
if (data === null) {
return null;
}
// Reset visibility of ID column
data.columns[0].visible = false;
// Apply loaded state to table
return data;
},
initComplete: function () {
if ("listid" in GETDict) {
var pos = table
.column(0, { order: "current" })
.data()
.indexOf(parseInt(GETDict.listid, 10));
if (pos >= 0) {
var page = Math.floor(pos / table.page.info().length);
table.page(page).draw(false);
}
}
},
});
table.on("init select deselect", function () {
utils.changeBulkDeleteStates(table);
});
table.on("order.dt", function () {
var order = table.order();
if (order[0][0] !== 0 || order[0][1] !== "asc") {
$("#resetButton").removeClass("hidden");
} else {
$("#resetButton").addClass("hidden");
}
});
$("#resetButton").on("click", function () {
table.order([[0, "asc"]]).draw();
$("#resetButton").addClass("hidden");
});
// Add event listener for opening and closing details
$("#listsTable tbody").on("click", "td.details-control", function () {
var tr = $(this).closest("tr");
var row = table.row(tr);
if (row.child.isShown()) {
// This row is already open - close it
row.child.hide();
tr.removeClass("shown");
} else {
// Open this row
row.child(format(row.data())).show();
tr.addClass("shown");
}
});
// Disable autocorrect in the search box
var input = document.querySelector("input[type=search]");
if (input !== null) {
input.setAttribute("autocomplete", "off");
input.setAttribute("autocorrect", "off");
input.setAttribute("autocapitalize", "off");
input.setAttribute("spellcheck", false);
}
}
// Remove 'bnt-group' class from container, to avoid grouping
$.fn.dataTable.Buttons.defaults.dom.container.className = "dt-buttons";
function deleteList() {
const tr = $(this).closest("tr");
const listType = tr.attr("data-type");
const ids = [{ item: tr.attr("data-address"), type: listType }];
delGroupItems("list", ids, table, listType);
}
function addList(event) {
const type = event.data.type;
const comment = $("#new_comment").val();
// Check if the user wants to add multiple domains (space or newline separated)
// If so, split the input and store it in an array
var addresses = $("#new_address")
.val()
.split(/[\s,]+/);
// Remove empty elements
addresses = addresses.filter(function (el) {
return el !== "";
});
const addressestr = JSON.stringify(addresses);
utils.disableAll();
utils.showAlert("info", "", "Adding subscribed " + type + "list(s)...", addressestr);
if (addresses.length === 0) {
// enable the ui elements again
utils.enableAll();
utils.showAlert("warning", "", "Warning", "Please specify " + type + "list address");
return;
}
$.ajax({
url: "/api/lists",
method: "post",
dataType: "json",
processData: false,
contentType: "application/json; charset=utf-8",
data: JSON.stringify({ address: addresses, comment: comment, type: type }),
success: function (data) {
utils.enableAll();
utils.listsAlert(type + "list", addresses, data);
$("#new_address").val("");
$("#new_comment").val("");
table.ajax.reload(null, false);
table.rows().deselect();
// Update number of groups in the sidebar
updateFtlInfo();
},
error: function (data, exception) {
apiFailure(data);
utils.enableAll();
utils.showAlert("error", "", "Error while adding new " + type + "list", data.responseText);
console.log(exception); // eslint-disable-line no-console
},
});
}
function editList() {
const elem = $(this).attr("id");
const tr = $(this).closest("tr");
const type = tr.attr("data-type");
const dataId = tr.attr("data-id");
const address = utils.hexDecode(tr.attr("data-address"));
const enabled = tr.find("#enabled_" + dataId).is(":checked");
const comment = utils.escapeHtml(tr.find("#comment_" + dataId).val());
// Convert list of string integers to list of integers using map(Number)
const groups = tr
.find("#multiselect_" + dataId)
.val()
.map(Number);
var done = "edited";
var notDone = "editing";
switch (elem) {
case "enabled_" + dataId:
if (!enabled) {
done = "disabled";
notDone = "disabling";
} else {
done = "enabled";
notDone = "enabling";
}
break;
case "comment_" + dataId:
done = "edited comment of";
notDone = "editing comment of";
break;
case "multiselect_" + dataId:
done = "edited groups of";
notDone = "editing groups of";
break;
default:
alert("bad element (" + elem + ") or invalid data-id!");
return;
}
utils.disableAll();
utils.showAlert("info", "", "Editing address...", address);
$.ajax({
url: "/api/lists/" + encodeURIComponent(address) + "?type=" + type,
method: "put",
dataType: "json",
processData: false,
contentType: "application/json; charset=utf-8",
data: JSON.stringify({
groups: groups,
comment: comment,
enabled: enabled,
type: type,
}),
success: function (data) {
utils.enableAll();
processGroupResult(data, type + "list", done, notDone);
table.ajax.reload(null, false);
},
error: function (data, exception) {
apiFailure(data);
utils.enableAll();
utils.showAlert(
"error",
"",
"Error while " + notDone + type + "list " + address,
data.responseText
);
console.log(exception); // eslint-disable-line no-console
},
});
}

355
scripts/js/groups.js Normal file
View File

@@ -0,0 +1,355 @@
/* 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, apiFailure:false, updateFtlInfo:false, processGroupResult:false, delGroupItems:false */
var table;
function handleAjaxError(xhr, textStatus) {
if (textStatus === "timeout") {
alert("The server took too long to send the data.");
} else {
alert("An unknown error occurred while loading the data.\n" + xhr.responseText);
}
table.clear();
table.draw();
}
$(function () {
$("#btnAdd").on("click", addGroup);
table = $("#groupsTable").DataTable({
processing: true,
ajax: {
url: "/api/groups",
error: handleAjaxError,
dataSrc: "groups",
type: "GET",
},
order: [[0, "asc"]],
columns: [
{ data: "id", visible: false },
{ data: null, visible: true, orderable: false, width: "15px" },
{ data: "name" },
{ data: "enabled", searchable: false },
{ data: "comment" },
{ data: null, width: "22px", orderable: false },
],
columnDefs: [
{
targets: 1,
className: "select-checkbox",
render: function () {
return "";
},
},
{
targets: "_all",
render: $.fn.dataTable.render.text(),
},
],
drawCallback: function () {
// Hide buttons if all groups were deleted
// if there is one row, it's the default group
var hasRows = this.api().rows({ filter: "applied" }).data().length > 1;
$(".datatable-bt").css("visibility", hasRows ? "visible" : "hidden");
$('button[id^="deleteGroup_"]').on("click", deleteGroup);
},
rowCallback: function (row, data) {
var dataId = utils.hexEncode(data.name);
$(row).attr("data-id", dataId);
var tooltip =
"Added: " +
utils.datetime(data.date_added, false) +
"\nLast modified: " +
utils.datetime(data.date_modified, false) +
"\nDatabase ID: " +
data.id;
$("td:eq(1)", row).html(
'<input id="name_' + dataId + '" title="' + tooltip + '" class="form-control">'
);
var nameEl = $("#name_" + dataId, row);
nameEl.val(data.name);
nameEl.on("change", editGroup);
$("td:eq(2)", row).html(
'<input type="checkbox" id="enabled_' +
dataId +
'"' +
(data.enabled ? " checked" : "") +
">"
);
var enabledEl = $("#enabled_" + dataId, row);
enabledEl.bootstrapToggle({
on: "Enabled",
off: "Disabled",
size: "small",
onstyle: "success",
width: "80px",
});
enabledEl.on("change", editGroup);
$("td:eq(3)", row).html('<input id="comment_' + dataId + '" class="form-control">');
var comment = data.comment !== null ? data.comment : "";
var commentEl = $("#comment_" + dataId, row);
commentEl.val(comment);
commentEl.on("change", editGroup);
$("td:eq(4)", row).empty();
// Show delete button for all but the default group
if (data.id !== 0) {
var button =
'<button type="button" class="btn btn-danger btn-xs" id="deleteGroup_' +
dataId +
'" data-id="' +
dataId +
'">' +
'<span class="far fa-trash-alt"></span>' +
"</button>";
$("td:eq(4)", row).html(button);
}
},
select: {
style: "multi",
selector: "td:not(:last-child)",
info: false,
},
buttons: [
{
text: '<span class="far fa-square"></span>',
titleAttr: "Select All",
className: "btn-sm datatable-bt selectAll",
action: function () {
table.rows({ page: "current" }).select();
},
},
{
text: '<span class="far fa-plus-square"></span>',
titleAttr: "Select All",
className: "btn-sm datatable-bt selectMore",
action: function () {
table.rows({ page: "current" }).select();
},
},
{
extend: "selectNone",
text: '<span class="far fa-check-square"></span>',
titleAttr: "Deselect All",
className: "btn-sm datatable-bt removeAll",
},
{
text: '<span class="far fa-trash-alt"></span>',
titleAttr: "Delete Selected",
className: "btn-sm datatable-bt deleteSelected",
action: function () {
// For each ".selected" row ...
var ids = [];
$("tr.selected").each(function () {
// ... add the row identified by "data-id".
ids.push({ item: $(this).attr("data-id") });
});
// Delete all selected rows at once
delGroupItems("group", ids, table);
},
},
],
dom:
"<'row'<'col-sm-6'l><'col-sm-6'f>>" +
"<'row'<'col-sm-3'B><'col-sm-9'p>>" +
"<'row'<'col-sm-12'<'table-responsive'tr>>>" +
"<'row'<'col-sm-3'B><'col-sm-9'p>>" +
"<'row'<'col-sm-12'i>>",
lengthMenu: [
[10, 25, 50, 100, -1],
[10, 25, 50, 100, "All"],
],
stateSave: true,
stateDuration: 0,
stateSaveCallback: function (settings, data) {
utils.stateSaveCallback("groups-table", data);
},
stateLoadCallback: function () {
var data = utils.stateLoadCallback("groups-table");
// Return if not available
if (data === null) {
return null;
}
// Reset visibility of ID column
data.columns[0].visible = false;
// Apply loaded state to table
return data;
},
});
// Disable autocorrect in the search box
var input = document.querySelector("input[type=search]");
if (input !== null) {
input.setAttribute("autocomplete", "off");
input.setAttribute("autocorrect", "off");
input.setAttribute("autocapitalize", "off");
input.setAttribute("spellcheck", false);
}
table.on("init select deselect", function () {
// if the Default group is selected, undo the selection of it
if (table.rows({ selected: true }).data().pluck("id").indexOf(0) !== -1) {
table.rows(0).deselect();
}
utils.changeBulkDeleteStates(table);
});
table.on("order.dt", function () {
var order = table.order();
if (order[0][0] !== 0 || order[0][1] !== "asc") {
$("#resetButton").removeClass("hidden");
} else {
$("#resetButton").addClass("hidden");
}
});
$("#resetButton").on("click", function () {
table.order([[0, "asc"]]).draw();
$("#resetButton").addClass("hidden");
});
});
// Remove 'bnt-group' class from container, to avoid grouping
$.fn.dataTable.Buttons.defaults.dom.container.className = "dt-buttons";
function deleteGroup() {
// Passes the button data-del-id attribute as ID
const ids = [{ item: $(this).attr("data-id") }];
delGroupItems("group", ids, table);
}
function addGroup() {
const comment = $("#new_comment").val();
// Check if the user wants to add multiple groups (space or newline separated)
// If so, split the input and store it in an array
var names = utils
.escapeHtml($("#new_name"))
.val()
.split(/[\s,]+/);
// Remove empty elements
names = names.filter(function (el) {
return el !== "";
});
const groupStr = JSON.stringify(names);
utils.disableAll();
utils.showAlert("info", "", "Adding group(s)...", groupStr);
if (names.length === 0) {
// enable the ui elements again
utils.enableAll();
utils.showAlert("warning", "", "Warning", "Please specify a group name");
return;
}
$.ajax({
url: "/api/groups",
method: "post",
dataType: "json",
processData: false,
contentType: "application/json; charset=utf-8",
data: JSON.stringify({
name: names,
comment: comment,
enabled: true,
}),
success: function (data) {
utils.enableAll();
utils.listsAlert("group", names, data);
$("#new_name").val("");
$("#new_comment").val("");
table.ajax.reload();
table.rows().deselect();
$("#new_name").focus();
// Update number of groups in the sidebar
updateFtlInfo();
},
error: function (data, exception) {
apiFailure(data);
utils.enableAll();
utils.showAlert("error", "", "Error while adding new group", data.responseText);
console.log(exception); // eslint-disable-line no-console
},
});
}
function editGroup() {
const elem = $(this).attr("id");
const tr = $(this).closest("tr");
const id = tr.attr("data-id");
const oldName = utils.hexDecode(id);
const name = tr.find("#name_" + id).val();
const enabled = tr.find("#enabled_" + id).is(":checked");
const comment = tr.find("#comment_" + id).val();
var done = "edited";
var notDone = "editing";
switch (elem) {
case "enabled_" + id:
if (!enabled) {
done = "disabled";
notDone = "disabling";
} else {
done = "enabled";
notDone = "enabling";
}
break;
case "name_" + id:
done = "edited name of";
notDone = "editing name of";
break;
case "comment_" + id:
done = "edited comment of";
notDone = "editing comment of";
break;
default:
alert("bad element ( " + elem + " ) or invalid data-id!");
return;
}
utils.disableAll();
utils.showAlert("info", "", "Editing group...", oldName);
$.ajax({
url: "/api/groups/" + oldName,
method: "put",
dataType: "json",
processData: false,
contentType: "application/json; charset=utf-8",
data: JSON.stringify({
name: name,
comment: comment,
enabled: enabled,
}),
success: function (data) {
utils.enableAll();
processGroupResult(data, "group", done, notDone);
table.ajax.reload(null, false);
},
error: function (data, exception) {
apiFailure(data);
utils.enableAll();
utils.showAlert(
"error",
"",
"Error while " + notDone + " group with name " + oldName,
data.responseText
);
console.log(exception); // eslint-disable-line no-console
},
});
}

918
scripts/js/index.js Normal file
View File

@@ -0,0 +1,918 @@
/* 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 */
// Define global variables
var timeLineChart, clientsChart;
var queryTypePieChart, forwardDestinationPieChart;
// Register the ChartDeferred plugin to all charts:
Chart.register(ChartDeferred);
Chart.defaults.set("plugins.deferred", {
yOffset: "20%",
delay: 300,
});
// Functions to update data in page
var failures = 0;
function updateQueriesOverTime() {
$.getJSON("/api/history", function (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 = [];
var labels = [
"Other DNS Queries",
"Blocked DNS Queries",
"Cached DNS Queries",
"Forwarded DNS Queries",
];
var cachedColor = utils.getCSSval("queries-cached", "background-color");
var blockedColor = utils.getCSSval("queries-blocked", "background-color");
var permittedColor = utils.getCSSval("queries-permitted", "background-color");
var otherColor = utils.getCSSval("queries-other", "background-color");
var colors = [otherColor, blockedColor, cachedColor, permittedColor];
// Collect values and colors, and labels
for (var i = 0; i < labels.length; i++) {
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: labels[i],
cubicInterpolationMode: "monotone",
});
}
// Add data for each dataset that is available
data.history.forEach(function (item) {
var timestamp = new Date(1000 * parseInt(item.timestamp, 10));
timeLineChart.data.labels.push(timestamp);
var 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(function () {
failures = 0;
utils.setTimer(updateQueriesOverTime, REFRESH_INTERVAL.history);
})
.fail(function () {
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(function (data) {
apiFailure(data);
});
}
function updateQueryTypesPie() {
$.getJSON("/api/stats/query_types", function (data) {
var v = [],
c = [],
k = [],
i = 0,
sum = 0;
// Compute total number of queries
Object.keys(data.types).forEach(function (item) {
sum += data.types[item];
});
// Fill chart with data (only include query types which appeared recently)
Object.keys(data.types).forEach(function (item) {
if (data.types[item] > 0) {
v.push((100 * data.types[item]) / sum);
c.push(THEME_COLORS[i % THEME_COLORS.length]);
k.push(item);
}
i++;
});
// Build a single dataset with the data to be pushed
var 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(function () {
utils.setTimer(updateQueryTypesPie, REFRESH_INTERVAL.query_types);
})
.fail(function (data) {
apiFailure(data);
});
}
function updateClientsOverTime() {
$.getJSON("/api/history/clients", function (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 = [],
clients = {};
Object.keys(data.clients).forEach(function (ip) {
clients[ip] = numClients++;
labels.push(data.clients[ip].name !== null ? data.clients[ip].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]
: "#" + (0x1000000 + Math.random() * 0xffffff).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
Object.keys(data.history).forEach(function (item) {
Object.keys(clients).forEach(function (client) {
if (data.history[item].data[client] === undefined) {
// If there is no data for this client in this timeslot, we push 0
clientsChart.data.datasets[clients[client]].data.push(0);
} else {
// Otherwise, we push the data
clientsChart.data.datasets[clients[client]].data.push(data.history[item].data[client]);
}
});
});
// Extract data timestamps
data.history.forEach(function (item) {
var d = new Date(1000 * parseInt(item.timestamp, 10));
clientsChart.data.labels.push(d);
});
$("#clients .overlay").hide();
clientsChart.update();
})
.done(function () {
// Reload graph after 10 minutes
failures = 0;
utils.setTimer(updateClientsOverTime, REFRESH_INTERVAL.clients);
})
.fail(function () {
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(function (data) {
apiFailure(data);
});
}
var upstreams = {};
function updateForwardDestinationsPie() {
$.getJSON("/api/stats/upstreams", function (data) {
var v = [],
c = [],
k = [],
i = 0,
sum = 0,
values = [];
// Compute total number of queries
data.upstreams.forEach(function (item) {
sum += item.count;
});
// Collect values and colors
data.upstreams.forEach(function (item) {
var 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;
}
var percent = (100 * item.count) / sum;
values.push([label, percent, THEME_COLORS[i++ % THEME_COLORS.length]]);
});
// Split data into individual arrays for the graphs
values.forEach(function (value) {
k.push(value[0]);
v.push(value[1]);
c.push(value[2]);
});
// Build a single dataset with the data to be pushed
var 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(function () {
utils.setTimer(updateForwardDestinationsPie, REFRESH_INTERVAL.upstreams);
})
.fail(function (data) {
apiFailure(data);
});
}
function updateTopClientsTable(blocked) {
let api, style, tablecontent, overlay, clienttable;
if (blocked) {
api = "/api/stats/top_clients?blocked=true";
style = "queries-blocked";
tablecontent = $("#client-frequency-blocked td").parent();
overlay = $("#client-frequency-blocked .overlay");
clienttable = $("#client-frequency-blocked").find("tbody:last");
} else {
api = "/api/stats/top_clients";
style = "queries-permitted";
tablecontent = $("#client-frequency td").parent();
overlay = $("#client-frequency .overlay");
clienttable = $("#client-frequency").find("tbody:last");
}
$.getJSON(api, function (data) {
// Clear tables before filling them with data
tablecontent.remove();
let url, percentage;
const sum = blocked ? data.blocked_queries : data.total_queries;
// Add note if there are no results (e.g. privacy mode enabled)
if (jQuery.isEmptyObject(data.clients)) {
clienttable.append('<tr><td colspan="3"><center>- No data -</center></td></tr>');
overlay.hide();
return;
}
// Populate table with content
data.clients.forEach(function (client) {
// Sanitize client
let clientname = client.name;
if (clientname.length === 0) clientname = client.ip;
url =
'<a href="queries.lp?client_ip=' +
encodeURIComponent(client.ip) +
'">' +
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(function (data) {
apiFailure(data);
});
}
function updateTopDomainsTable(blocked) {
let api, style, tablecontent, overlay, domaintable;
if (blocked) {
api = "/api/stats/top_domains?blocked=true";
style = "queries-blocked";
tablecontent = $("#ad-frequency td").parent();
overlay = $("#ad-frequency .overlay");
domaintable = $("#ad-frequency").find("tbody:last");
} else {
api = "/api/stats/top_domains";
style = "queries-permitted";
tablecontent = $("#domain-frequency td").parent();
overlay = $("#domain-frequency .overlay");
domaintable = $("#domain-frequency").find("tbody:last");
}
$.getJSON(api, function (data) {
// Clear tables before filling them with data
tablecontent.remove();
let url, domain, percentage, urlText;
const sum = blocked ? data.blocked_queries : data.total_queries;
// Add note if there are no results (e.g. privacy mode enabled)
if (jQuery.isEmptyObject(data.domains)) {
domaintable.append('<tr><td colspan="3"><center>- No data -</center></td></tr>');
overlay.hide();
return;
}
// Populate table with content
data.domains.forEach(function (item) {
// Sanitize domain
domain = encodeURIComponent(item.domain);
// Substitute "." for empty domain lookups
urlText = domain === "" ? "." : domain;
url = '<a href="queries.lp?domain=' + domain + '">' + 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(function (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);
}
var previousCount = 0;
var firstSummaryUpdate = true;
function updateSummaryData(runOnce = false) {
$.getJSON("/api/stats/summary", function (data) {
var intl = new Intl.NumberFormat();
const newCount = parseInt(data.queries.total, 10);
$("span#dns_queries").text(intl.format(newCount));
$("span#active_clients").text(intl.format(parseInt(data.clients.active, 10)));
$("a#total_clients").attr(
"title",
intl.format(parseInt(data.clients.total, 10)) + " total clients"
);
$("span#blocked_queries").text(intl.format(parseFloat(data.queries.blocked)));
var formattedPercentage = utils.toPercent(data.queries.percent_blocked, 1);
$("span#percent_blocked").text(formattedPercentage);
updateQueryFrequency(intl, data.queries.frequency);
const lastupdate = parseInt(data.gravity.last_update, 10);
var updatetxt = "Lists were never updated";
if (lastupdate > 0) {
updatetxt =
"Lists updated " +
utils.datetimeRelative(lastupdate) +
"\n(" +
utils.datetime(lastupdate, false, false) +
")";
}
const gravityCount = 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(function () {
if (!runOnce) utils.setTimer(updateSummaryData, REFRESH_INTERVAL.summary);
})
.fail(function (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;
const keys = Object.keys(tooltipLabel.parsed._stacks.y);
for (let i = 0; i < keys.length; i++) {
if (tooltipLabel.parsed._stacks.y[i] === undefined) continue;
sum += parseInt(tooltipLabel.parsed._stacks.y[i], 10);
}
let percentage = 0;
const data = 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) +
")"
);
}
$(function () {
// 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: function ({ chart }) {
// The first time the chart is zoomed, save the maximum initial scale bound
if (!chart.absMax) 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,
},
},
};
var gridColor = utils.getCSSval("graphs-grid", "background-color");
var ticksColor = utils.getCSSval("graphs-ticks", "color");
var 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: function (a, b) {
return b.datasetIndex - a.datasetIndex;
},
callbacks: {
title: function (tooltipTitle) {
var label = tooltipTitle[0].label;
var time = label.match(/(\d?\d):?(\d?\d?)/);
var h = parseInt(time[1], 10);
var m = parseInt(time[2], 10) || 0;
var from = utils.padNumber(h) + ":" + utils.padNumber(m - 5) + ":00";
var to = utils.padNumber(h) + ":" + utils.padNumber(m + 4) + ":59";
return "Queries from " + from + " to " + to;
},
label: function (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
var 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: function (a, b) {
return b.raw - a.raw;
},
callbacks: {
title: function (tooltipTitle) {
var label = tooltipTitle[0].label;
var time = label.match(/(\d?\d):?(\d?\d?)/);
var h = parseInt(time[1], 10);
var m = parseInt(time[2], 10) || 0;
var from = utils.padNumber(h) + ":" + utils.padNumber(m - 5) + ":00";
var to = utils.padNumber(h) + ":" + utils.padNumber(m + 4) + ":59";
return "Client activity from " + from + " to " + to;
},
label: function (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();
}
updateTopLists();
$("#queryOverTimeChart").on("click", function (evt) {
var activePoints = timeLineChart.getElementsAtEventForMode(
evt,
"nearest",
{ intersect: true },
false
);
if (activePoints.length > 0) {
//get the internal index
var clickedElementindex = activePoints[0].index;
//get specific label by index
var label = timeLineChart.data.labels[clickedElementindex];
//get value by index
var from = label / 1000 - 300;
var until = label / 1000 + 300;
window.location.href = "queries.lp?from=" + from + "&until=" + until;
}
return false;
});
$("#clientsChart").on("click", function (evt) {
var activePoints = clientsChart.getElementsAtEventForMode(
evt,
"nearest",
{ intersect: true },
false
);
if (activePoints.length > 0) {
//get the internal index
var clickedElementindex = activePoints[0].index;
//get specific label by index
var label = clientsChart.data.labels[clickedElementindex];
//get value by index
var from = label / 1000 - 300;
var until = label / 1000 + 300;
window.location.href = "queries.lp?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: $(".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: function () {
return "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: function () {
return "Upstream server";
},
label: doughnutTooltip,
},
},
},
animation: {
duration: 750,
},
},
});
// Pull in data via AJAX
updateForwardDestinationsPie();
}
});
//destroy all chartjs customTooltips on window resize
window.addEventListener("resize", function () {
$(".chartjs-tooltip").remove();
});
// Tooltips
$(function () {
$('[data-toggle="tooltip"]').tooltip({ html: true, container: "body" });
});

403
scripts/js/interfaces.js Normal file
View File

@@ -0,0 +1,403 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2024 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 */
$(function () {
$.ajax({
url: "/api/network/gateway",
data: { detailed: true },
}).done(function (data) {
var intl = new Intl.NumberFormat();
const gateway = data.gateway;
// Get all objects in gateway that has family == "inet"
const inet = gateway.find(obj => obj.family === "inet");
// Get first object in gateway that has family == "inet6"
const inet6 = gateway.find(obj => obj.family === "inet6");
// Create a set of the gateways when they are found
const gateways = new Set();
if (inet !== undefined) {
gateways.add(inet.gateway);
}
if (inet6 !== undefined) {
gateways.add(inet6.gateway);
}
var json = [];
// For each interface in data.interface, create a new object and push it to json
data.interfaces.forEach(function (interface) {
const status = interface.carrier
? '<span class="text-green">UP</span>'
: '<span class="text-red">DOWN</span>';
var obj = {
text: interface.name + " - " + status,
class: gateways.has(interface.name) ? "text-bold" : null,
icon: "fa fa-network-wired fa-fw",
nodes: [],
};
if (interface.master !== undefined) {
// Find interface.master in data.interfaces
const master = data.interfaces.find(obj => obj.index === interface.master);
if (master !== undefined) {
obj.nodes.push({
text: "Master interface: <code>" + utils.escapeHtml(master.name) + "</code>",
icon: "fa fa-network-wired fa-fw",
});
}
}
if (interface.speed) {
obj.nodes.push({
text: "Speed: " + intl.format(interface.speed) + " Mbit/s",
icon: "fa fa-tachometer-alt fa-fw",
});
}
if (interface.type !== undefined) {
obj.nodes.push({
text: "Type: " + utils.escapeHtml(interface.type),
icon: "fa fa-network-wired fa-fw",
});
}
if (interface.flags !== undefined && interface.flags.length > 0) {
obj.nodes.push({
text: "Flags: " + utils.escapeHtml(interface.flags.join(", ")),
icon: "fa fa-flag fa-fw",
});
}
if (interface.address !== undefined) {
let extra = "";
if (interface.perm_address !== undefined && interface.perm_address !== interface.address) {
extra = " (permanent: <code>" + utils.escapeHtml(interface.perm_address) + "</code>)";
}
obj.nodes.push({
text:
"Hardware address: <code>" + utils.escapeHtml(interface.address) + "</code>" + extra,
icon: "fa fa-map-marker-alt fa-fw",
});
}
if (interface.addresses !== undefined) {
const addrs = {
text:
interface.addresses.length +
(interface.addresses.length === 1 ? " address" : " addresses") +
" connected to interface",
icon: "fa fa-map-marker-alt fa-fw",
nodes: [],
};
for (const addr of interface.addresses) {
let extraaddr = "";
if (addr.prefixlen !== undefined) {
extraaddr += " / <code>" + addr.prefixlen + "</code>";
}
if (addr.address_type !== undefined) {
let familyextra = "";
if (addr.family !== undefined) {
if (addr.family === "inet") {
familyextra = "IPv4 ";
} else if (addr.family === "inet6") {
familyextra = "IPv6 ";
}
}
extraaddr += " (" + familyextra + utils.escapeHtml(addr.address_type) + ")";
}
let family = "";
if (addr.family !== undefined) {
family = addr.family + "</code> <code>";
}
const jaddr = {
text:
"Address: <code>" + family + utils.escapeHtml(addr.address) + "</code>" + extraaddr,
icon: "fa fa-map-marker-alt fa-fw",
nodes: [],
};
if (addr.local !== undefined) {
jaddr.nodes.push({
text: "Local: <code>" + utils.escapeHtml(addr.local) + "</code>",
icon: "fa fa-map-marker-alt fa-fw",
});
}
if (addr.broadcast !== undefined) {
jaddr.nodes.push({
text: "Broadcast: <code>" + utils.escapeHtml(addr.broadcast) + "</code>",
icon: "fa fa-map-marker-alt fa-fw",
});
}
if (addr.scope !== undefined) {
jaddr.nodes.push({
text: "Scope: " + utils.escapeHtml(addr.scope),
icon: "fa fa-map-marker-alt fa-fw",
});
}
if (addr.flags !== undefined && addr.flags.length > 0) {
jaddr.nodes.push({
text: "Flags: " + utils.escapeHtml(addr.flags.join(", ")),
icon: "fa fa-map-marker-alt fa-fw",
});
}
if (addr.prefered !== undefined) {
const pref =
addr.prefered === 4294967295 ? "forever" : intl.format(addr.prefered) + " s";
jaddr.nodes.push({
text: "Preferred lifetime: " + pref,
icon: "fa fa-clock fa-fw",
});
}
if (addr.valid !== undefined) {
const valid = addr.valid === 4294967295 ? "forever" : intl.format(addr.valid) + " s";
jaddr.nodes.push({
text: "Valid lifetime: " + valid,
icon: "fa fa-clock fa-fw",
});
}
if (addr.cstamp !== undefined) {
jaddr.nodes.push({
text: "Created: " + new Date(addr.cstamp * 1000).toLocaleString(),
icon: "fa fa-clock fa-fw",
});
}
if (addr.tstamp !== undefined) {
jaddr.nodes.push({
text: "Last updated: " + new Date(addr.tstamp * 1000).toLocaleString(),
icon: "fa fa-clock fa-fw",
});
}
addrs.nodes.push(jaddr);
}
obj.nodes.push(addrs);
}
if (interface.stats !== undefined) {
const stats = {
text: "Statistics",
icon: "fa fa-chart-line fa-fw",
expanded: false,
nodes: [],
};
if (interface.stats.rx_bytes !== undefined) {
stats.nodes.push({
text:
"RX bytes: " +
intl.format(interface.stats.rx_bytes.value) +
" " +
interface.stats.rx_bytes.unit,
icon: "fa fa-download fa-fw",
});
}
if (interface.stats.tx_bytes !== undefined) {
stats.nodes.push({
text:
"TX bytes: " +
intl.format(interface.stats.tx_bytes.value) +
" " +
interface.stats.tx_bytes.unit,
icon: "fa fa-upload fa-fw",
});
}
if (interface.stats.rx_packets !== undefined) {
stats.nodes.push({
text: "RX packets: " + intl.format(interface.stats.rx_packets),
icon: "fa fa-download fa-fw",
});
}
if (interface.stats.rx_errors !== undefined) {
stats.nodes.push({
text:
"RX errors: " +
intl.format(interface.stats.rx_errors) +
" (" +
((interface.stats.rx_errors / interface.stats.rx_packets) * 100).toFixed(1) +
"%)",
icon: "fa fa-download fa-fw",
});
}
if (interface.stats.rx_dropped !== undefined) {
stats.nodes.push({
text:
"RX dropped: " +
intl.format(interface.stats.rx_dropped) +
" (" +
((interface.stats.rx_dropped / interface.stats.rx_packets) * 100).toFixed(1) +
"%)",
icon: "fa fa-download fa-fw",
});
}
if (interface.stats.tx_packets !== undefined) {
stats.nodes.push({
text: "TX packets: " + intl.format(interface.stats.tx_packets),
icon: "fa fa-upload fa-fw",
});
}
if (interface.stats.tx_errors !== undefined) {
stats.nodes.push({
text:
"TX errors: " +
intl.format(interface.stats.tx_errors) +
" (" +
((interface.stats.tx_errors / interface.stats.tx_packets) * 100).toFixed(1) +
"%)",
icon: "fa fa-upload fa-fw",
});
}
if (interface.stats.tx_dropped !== undefined) {
stats.nodes.push({
text:
"TX dropped: " +
intl.format(interface.stats.tx_dropped) +
" (" +
((interface.stats.tx_dropped / interface.stats.tx_packets) * 100).toFixed(1) +
"%)",
icon: "fa fa-upload fa-fw",
});
}
if (interface.stats.multicast !== undefined) {
stats.nodes.push({
text: "Multicast: " + intl.format(interface.stats.multicast),
icon: "fa fa-broadcast-tower fa-fw",
});
}
if (interface.stats.collisions !== undefined) {
stats.nodes.push({
text: "Collisions: " + intl.format(interface.stats.collisions),
icon: "fa fa-exchange-alt fa-fw",
});
}
obj.nodes.push(stats);
}
const furtherDetails = {
text: "Further details",
icon: "fa fa-info-circle fa-fw",
expanded: false,
nodes: [],
};
if (interface.parent_dev_name !== undefined) {
let extra = "";
if (interface.parent_dev_bus_name !== undefined) {
extra = " @ " + utils.escapeHtml(interface.parent_dev_bus_name);
}
furtherDetails.nodes.push({
text:
"Parent device: <code>" +
utils.escapeHtml(interface.parent_dev_name) +
extra +
"</code>",
icon: "fa fa-network-wired fa-fw",
});
}
if (interface.carrier_changes !== undefined) {
furtherDetails.nodes.push({
text: "Carrier changes: " + intl.format(interface.carrier_changes),
icon: "fa fa-exchange-alt fa-fw",
});
}
if (interface.broadcast) {
furtherDetails.nodes.push({
text: "Broadcast: <code>" + utils.escapeHtml(interface.broadcast) + "</code>",
icon: "fa fa-broadcast-tower fa-fw",
});
}
if (interface.mtu) {
let extra = "";
if (interface.min_mtu !== undefined && interface.max_mtu !== undefined) {
extra +=
" (min: " +
intl.format(interface.min_mtu) +
" bytes, max: " +
intl.format(interface.max_mtu) +
" bytes)";
}
furtherDetails.nodes.push({
text: "MTU: " + intl.format(interface.mtu) + " bytes" + extra,
icon: "fa fa-arrows-alt-h fa-fw",
});
}
if (interface.txqlen) {
furtherDetails.nodes.push({
text: "TX queue length: " + intl.format(interface.txqlen),
icon: "fa fa-file-upload fa-fw",
});
}
if (interface.promiscuity !== undefined) {
furtherDetails.nodes.push({
text: "Promiscuity mode: " + (interface.promiscuity ? "Yes" : "No"),
icon: "fa fa-eye fa-fw",
});
}
if (interface.qdisc !== undefined) {
furtherDetails.nodes.push({
text: "Scheduler: " + utils.escapeHtml(interface.qdisc),
icon: "fa fa-network-wired fa-fw",
});
}
if (furtherDetails.nodes.length > 0) {
obj.nodes.push(furtherDetails);
}
json.push(obj);
});
$("#tree").bstreeview({
data: json,
expandIcon: "fa fa-angle-down fa-fw",
collapseIcon: "fa fa-angle-right fa-fw",
indent: 1.25,
});
$("#spinner").hide();
// Expand gateway interfaces by default
for (const gw of gateways) {
const div = $("#tree").find("div:contains('" + gw + "')");
div.removeClass("collapsed");
div.next("div").collapse("show");
// Change expand icon to collapse icon
div.find("i:first").removeClass("fa-angle-right").addClass("fa-angle-down");
}
});
});

View File

@@ -0,0 +1,137 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2019 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. */
// This code has been taken from
// https://datatables.net/plug-ins/sorting/ip-address
// and was modified by the Pi-hole team to support
// CIDR notation and be more robust against invalid
// input data (like empty IP addresses)
$.extend($.fn.dataTableExt.oSort, {
"ip-address-pre": function (a) {
// Skip empty fields (IP address might have expired or
// reassigned to a different device)
if (!a || a.length === 0) {
return Infinity;
}
var i, item;
// Use the first IP in case there is a list of IPs
// for a given device
if (Array.isArray(a)) {
a = a[0];
}
var m = a.split("."),
n = a.split(":"),
x = "",
xa = "",
cidr = [];
if (m.length === 4) {
// IPV4 (possibly with CIDR)
cidr = m[3].split("/");
if (cidr.length === 2) {
m.pop();
m = m.concat(cidr);
}
for (i = 0; i < m.length; i++) {
item = m[i];
if (item.length === 1) {
x += "00" + item;
} else if (item.length === 2) {
x += "0" + item;
} else {
x += item;
}
}
} else if (n.length > 0) {
// IPV6 (possibly with CIDR)
var count = 0;
for (i = 0; i < n.length; i++) {
item = n[i];
if (i > 0) {
xa += ":";
}
switch (item.length) {
case 0: {
count += 0;
break;
}
case 1: {
xa += "000" + item;
count += 4;
break;
}
case 2: {
xa += "00" + item;
count += 4;
break;
}
case 3: {
xa += "0" + item;
count += 4;
break;
}
default: {
xa += item;
count += 4;
}
}
}
// Padding the ::
n = xa.split(":");
var paddDone = 0;
for (i = 0; i < n.length; i++) {
item = n[i];
if (item.length === 0 && paddDone === 0) {
for (var padding = 0; padding < 32 - count; padding++) {
x += "0";
paddDone = 1;
}
} else {
x += item;
}
}
cidr = x.split("/");
x = cidr[0];
if (cidr.length === 2) {
item = cidr[1];
if (item.length === 1) {
x += "00" + item;
} else if (item.length === 2) {
x += "0" + item;
} else {
x += item;
}
}
}
return x;
},
"ip-address-asc": function (a, b) {
return a < b ? -1 : a > b ? 1 : 0;
},
"ip-address-desc": function (a, b) {
return a < b ? 1 : a > b ? -1 : 0;
},
});

200
scripts/js/login.js Normal file
View File

@@ -0,0 +1,200 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2023 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, NProgress:false */
function redirect() {
// Login succeeded or not needed (empty password)
// Default: Send back to dashboard
var target = ".";
// If DNS failure: send to Pi-hole diagnosis messages page
if ($("#dns-failure-label").is(":visible")) {
target = "messages.lp";
}
// Redirect to target
window.location.replace(target);
}
function wrongPassword(isError = false, isSuccess = false, data = null) {
if (isError) {
let isErrorResponse = false,
isInvalidTOTP = false;
// Reset hint and error message
$("#error-message").text("");
$("#error-hint").hide();
$("#error-hint").text("");
if (data !== null && "error" in data.responseJSON && "message" in data.responseJSON.error) {
// This is an error, highlight both the password and the TOTP field
isErrorResponse = true;
// Check if the error is caused by an invalid TOTP token
isInvalidTOTP = data.responseJSON.error.message === "Invalid 2FA token";
$("#error-message").text(data.responseJSON.error.message);
if ("hint" in data.responseJSON.error && data.responseJSON.error.hint !== null) {
$("#error-hint").text(data.responseJSON.error.hint);
$("#error-hint").show();
}
} else {
$("#error-message").text("Wrong password!");
}
$("#error-label").show();
// Always highlight the TOTP field on error
if (isErrorResponse) $("#totp_input").addClass("has-error");
// Only show the invalid 2FA box if the error is caused by an invalid TOTP
// token
if (isInvalidTOTP) $("#invalid2fa-box").removeClass("hidden");
// Only highlight the password field if the error is NOT caused by an
// invalid TOTP token
if (!isInvalidTOTP) $("#pw-field").addClass("has-error");
// Only show the forgot password box if the error is NOT caused by an
// invalid TOTP token and this is no error response (= password is wrong)
if (!isErrorResponse && !isInvalidTOTP) {
$("#forgot-pw-box")
.removeClass("box-info")
.removeClass("collapsed-box")
.addClass("box-danger");
$("#forgot-pw-box .box-body").show();
$("#forgot-pw-toggle-icon").removeClass("fa-plus").addClass("fa-minus");
}
return;
} else if (isSuccess) {
$("#pw-field").addClass("has-success");
$("#totp_input").addClass("has-success");
} else {
$("#pw-field").removeClass("has-error");
$("#totp_input").removeClass("has-error");
$("#error-label").hide();
}
$("#invalid2fa-box").addClass("hidden");
$("#forgot-pw-box").addClass("box-info").addClass("collapsed-box").removeClass("box-danger");
$("#forgot-pw-box .box-body").hide();
$("#forgot-pw-toggle-icon").removeClass("fa-minus").addClass("fa-plus");
}
function doLogin(password) {
wrongPassword(false, false, null);
NProgress.start();
utils.disableAll();
$.ajax({
url: "/api/auth",
method: "POST",
dataType: "json",
processData: false,
contentType: "application/json; charset=utf-8",
data: JSON.stringify({ password: password, totp: parseInt($("#totp").val(), 10) }),
})
.done(function (data) {
wrongPassword(false, true, data);
NProgress.done();
redirect();
})
.fail(function (data) {
wrongPassword(true, false, data);
NProgress.done();
utils.enableAll();
});
}
$("#loginform").submit(function (e) {
// Cancel the native submit event (prevent the form from being
// submitted) because we want to do a two-step challenge-response login
e.preventDefault();
// Check if cookie checkbox is enabled
/* if (!$("#logincookie").is(":checked")) {
alert("Please consent to using a login cookie to be able to log in. It is necessary to keep you logged in between page reloads. You can end the session by clicking on the logout button in the top right menu at any time.");
return;
}*/
doLogin($("#current-password").val());
});
// Submit form when TOTP code is entered and password is already filled
$("#totp").on("input", function () {
const code = $(this).val();
const password = $("#current-password").val();
if (code.length === 6 && password.length > 0) {
$("#loginform").submit();
}
});
// Toggle password visibility button
$("#toggle-password").on("click", function () {
// Toggle font-awesome classes to change the svg icon on the button
$("svg", this).toggleClass("fa-eye fa-eye-slash");
// Password field
var $pwd = $("#current-password");
if ($pwd.attr("type") === "password") {
$pwd.attr("type", "text");
$pwd.attr("title", "Hide password");
} else {
$pwd.attr("type", "password");
$pwd.attr(
"title",
"Show password as plain text. Warning: this will display your password on the screen"
);
}
// move the focus to password field after the click
$pwd.trigger("focus");
});
function showDNSfailure() {
$("#dns-failure-label").show();
$("#login-box").addClass("error-box");
}
$(function () {
// Check if we need to login at all
$.ajax({
url: "/api/auth",
})
.done(function (data) {
// If we are already logged in, redirect to dashboard
if (data.session.valid === true) redirect();
})
.fail(function (xhr) {
const session = xhr.responseJSON.session;
// If TOPT is enabled, show the input field and add the required attribute
if (session.totp === true) {
$("#totp_input").removeClass("hidden");
$("#totp").attr("required", "required");
$("#totp-forgotten-title").removeClass("hidden");
$("#totp-forgotten-body").removeClass("hidden");
}
});
// Get information about HTTPS port and DNS status
$.ajax({
url: "/api/info/login",
}).done(function (data) {
if (data.dns === false) showDNSfailure();
// Generate HTTPS redirection link (only used if not already HTTPS)
if (location.protocol !== "https:" && data.https_port !== 0) {
let url = "https://" + location.hostname;
if (data.https_port !== 443) url += ":" + data.https_port;
url += location.pathname + location.search + location.hash;
$("#https-link").attr("href", url);
$("#insecure-box").show();
}
});
// Clear TOTP field
$("#totp").val("");
});

15
scripts/js/logout.js Normal file
View File

@@ -0,0 +1,15 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2023 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 */
document.addEventListener("DOMContentLoaded", () => {
const logoutButton = document.getElementById("logout-button");
logoutButton.addEventListener("click", () => {
utils.doLogout();
});
});

204
scripts/js/messages.js Normal file
View File

@@ -0,0 +1,204 @@
/* 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 */
var table,
toasts = {};
$(function () {
var ignoreNonfatal = localStorage
? localStorage.getItem("hideNonfatalDnsmasqWarnings_chkbox") === "true"
: false;
var url = "/api/info/messages" + (ignoreNonfatal ? "?filter_dnsmasq_warnings=true" : "");
table = $("#messagesTable").DataTable({
ajax: {
url: url,
type: "GET",
dataSrc: "messages",
},
order: [[0, "asc"]],
columns: [
{ data: "id", visible: false },
{ data: null, visible: true, width: "15px" },
{ data: "timestamp", width: "8%", render: utils.renderTimestamp },
{ data: "type", width: "8%" },
{ data: "html", orderable: false, render: utils.htmlPass },
{ data: null, width: "22px", orderable: false },
],
columnDefs: [
{
targets: 1,
orderable: false,
className: "select-checkbox",
render: function () {
return "";
},
},
{
targets: "_all",
render: $.fn.dataTable.render.text(),
},
],
drawCallback: function () {
$('button[id^="deleteMessage_"]').on("click", deleteMessage);
// Hide buttons if all messages were deleted
var hasRows = this.api().rows({ filter: "applied" }).data().length > 0;
$(".datatable-bt").css("visibility", hasRows ? "visible" : "hidden");
// Remove visible dropdown to prevent orphaning
$("body > .bootstrap-select.dropdown").remove();
},
rowCallback: function (row, data) {
$(row).attr("data-id", data.id);
var button =
'<button type="button" class="btn btn-danger btn-xs" id="deleteMessage_' +
data.id +
'" data-del-id="' +
data.id +
'">' +
'<span class="far fa-trash-alt"></span>' +
"</button>";
$("td:eq(4)", row).html(button);
},
select: {
style: "multi",
selector: "td:not(:last-child)",
info: false,
},
buttons: [
{
text: '<span class="far fa-square"></span>',
titleAttr: "Select All",
className: "btn-sm datatable-bt selectAll",
action: function () {
table.rows({ page: "current" }).select();
},
},
{
text: '<span class="far fa-plus-square"></span>',
titleAttr: "Select All",
className: "btn-sm datatable-bt selectMore",
action: function () {
table.rows({ page: "current" }).select();
},
},
{
extend: "selectNone",
text: '<span class="far fa-check-square"></span>',
titleAttr: "Deselect All",
className: "btn-sm datatable-bt removeAll",
},
{
text: '<span class="far fa-trash-alt"></span>',
titleAttr: "Delete Selected",
className: "btn-sm datatable-bt deleteSelected",
action: function () {
// For each ".selected" row ...
$("tr.selected").each(function () {
// ... delete the row identified by "data-id".
delMsg($(this).attr("data-id"));
});
},
},
],
dom:
"<'row'<'col-sm-6'l><'col-sm-6'f>>" +
"<'row'<'col-sm-3'B><'col-sm-9'p>>" +
"<'row'<'col-sm-12'<'table-responsive'tr>>>" +
"<'row'<'col-sm-3'B><'col-sm-9'p>>" +
"<'row'<'col-sm-12'i>>",
lengthMenu: [
[10, 25, 50, 100, -1],
[10, 25, 50, 100, "All"],
],
language: {
emptyTable: "No issues found.",
},
stateSave: true,
stateDuration: 0,
stateSaveCallback: function (settings, data) {
utils.stateSaveCallback("messages-table", data);
},
stateLoadCallback: function () {
var data = utils.stateLoadCallback("messages-table");
// Return if not available
if (data === null) {
return null;
}
// Reset visibility of ID column
data.columns[0].visible = false;
// Apply loaded state to table
return data;
},
});
table.on("init select deselect", function () {
utils.changeTableButtonStates(table);
});
});
// Remove 'bnt-group' class from container, to avoid grouping
$.fn.dataTable.Buttons.defaults.dom.container.className = "dt-buttons";
function deleteMessage() {
// Passes the button data-del-id attribute as ID
delMsg($(this).attr("data-del-id"));
}
function delMsg(id) {
id = parseInt(id, 10);
utils.disableAll();
toasts[id] = utils.showAlert("info", "", "Deleting message...", "ID: " + id, null);
$.ajax({
url: "/api/info/messages/" + id,
method: "DELETE",
})
.done(function (response) {
utils.enableAll();
if (response === undefined) {
utils.showAlert(
"success",
"far fa-trash-alt",
"Successfully deleted message",
"ID: " + id,
toasts[id]
);
table.row(id).remove();
table.draw(false).ajax.reload(null, false);
} else {
utils.showAlert(
"error",
"",
"Error while deleting message: " + id,
response.message,
toasts[id]
);
}
// Clear selection after deletion
table.rows().deselect();
utils.changeTableButtonStates(table);
})
.done(
utils.checkMessages // Update icon warnings count
)
.fail(function (jqXHR, exception) {
utils.enableAll();
utils.showAlert(
"error",
"",
"Error while deleting message: " + id,
jqXHR.responseText,
toasts[id]
);
console.log(exception); // eslint-disable-line no-console
});
}

278
scripts/js/network.js Normal file
View File

@@ -0,0 +1,278 @@
/* 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, apiFailure:false */
var tableApi;
// How many IPs do we show at most per device?
const MAXIPDISPLAY = 3;
const DAY_IN_SECONDS = 24 * 60 * 60;
function handleAjaxError(xhr, textStatus) {
if (textStatus === "timeout") {
alert("The server took too long to send the data.");
} else if (xhr.responseText.indexOf("Connection refused") === -1) {
alert("An unknown error occurred while loading the data.\n" + xhr.responseText);
} else {
alert("An error occurred while loading the data: Connection refused. Is FTL running?");
}
$("#network-entries_processing").hide();
tableApi.clear();
tableApi.draw();
}
function getTimestamp() {
return Math.floor(Date.now() / 1000);
}
function valueToHex(c) {
var hex = Math.round(c).toString(16);
return hex.length === 1 ? "0" + hex : hex;
}
function rgbToHex(values) {
return "#" + valueToHex(values[0]) + valueToHex(values[1]) + valueToHex(values[2]);
}
function mixColors(ratio, rgb1, rgb2) {
return [
(1 - ratio) * rgb1[0] + ratio * rgb2[0],
(1 - ratio) * rgb1[1] + ratio * rgb2[1],
(1 - ratio) * rgb1[2] + ratio * rgb2[2],
];
}
function parseColor(input) {
var match = input.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
if (match) {
return [match[1], match[2], match[3]];
}
}
function deleteNetworkEntry() {
const tr = $(this).closest("tr");
const id = tr.attr("data-id");
const hwaddr = tr.attr("data-hwaddr");
utils.disableAll();
utils.showAlert("info", "", "Deleting network table entry...");
$.ajax({
url: "/api/network/devices/" + id,
method: "DELETE",
success: function () {
utils.enableAll();
utils.showAlert(
"success",
"far fa-trash-alt",
"Successfully deleted network table entry",
hwaddr
);
tableApi.row(tr).remove().draw(false).ajax.reload(null, false);
},
error: function (data, exception) {
apiFailure(data);
utils.enableAll();
utils.showAlert(
"error",
"",
"Error while deleting network table entry with ID " + id,
data.responseText
);
console.log(exception); // eslint-disable-line no-console
},
});
}
$(function () {
tableApi = $("#network-entries").DataTable({
rowCallback: function (row, data) {
var color;
var index;
var iconClasses;
var lastQuery = parseInt(data.lastQuery, 10);
var diff = getTimestamp() - lastQuery;
var networkRecent = $(".network-recent").css("background-color");
var networkOld = $(".network-old").css("background-color");
var networkOlder = $(".network-older").css("background-color");
var networkNever = $(".network-never").css("background-color");
if (lastQuery > 0) {
if (diff <= DAY_IN_SECONDS) {
// Last query came in within the last 24 hours
// Color: light-green to light-yellow
var ratio = Number(diff) / DAY_IN_SECONDS;
color = rgbToHex(mixColors(ratio, parseColor(networkRecent), parseColor(networkOld)));
iconClasses = "fas fa-check";
} else {
// Last query was longer than 24 hours ago
// Color: light-orange
color = networkOlder;
iconClasses = "fas fa-question";
}
} else {
// This client has never sent a query to Pi-hole, color light-red
color = networkNever;
iconClasses = "fas fa-times";
}
// Set determined background color
$(row).css("background-color", color);
$("td:eq(6)", row).html('<i class="' + iconClasses + '"></i>');
// Insert "Never" into Last Query field when we have
// never seen a query from this device
if (data.lastQuery === 0) {
$("td:eq(4)", row).html("Never");
}
// Set number of queries to localized string (add thousand separators)
$("td:eq(5)", row).html(data.numQueries.toLocaleString());
var ips = [],
iptitles = [];
for (index = 0; index < data.ips.length; index++) {
var ip = data.ips[index],
iptext = ip.ip;
if (ip.name !== null && ip.name.length > 0) {
iptext = iptext + " (" + ip.name + ")";
}
iptitles.push(iptext);
// Only add IPs to the table if we have not reached the maximum
if (index < MAXIPDISPLAY) {
ips.push('<a href="queries.lp?client_ip=' + ip.ip + '">' + iptext + "</a>");
}
}
if (data.ips.length > MAXIPDISPLAY) {
// We hit the maximum above, add "..." to symbolize we would
// have more to show here
ips.push("...");
// Show the IPs on the title when there are more than MAXIPDISPLAY items
$("td:eq(0)", row).attr("title", iptitles.join("\n"));
}
// Show the IPs in the first column
$("td:eq(0)", row).html(ips.join("<br>"));
// MAC + Vendor field if available
if (data.macVendor && data.macVendor.length > 0) {
$("td:eq(1)", row).html(
utils.escapeHtml(data.hwaddr) + "<br/>" + utils.escapeHtml(data.macVendor)
);
}
// Make mock MAC addresses italics and add title
if (data.hwaddr.startsWith("ip-")) {
$("td:eq(1)", row).css("font-style", "italic");
$("td:eq(1)", row).attr("title", "Mock MAC address");
}
// Add delete button
$(row).attr("data-id", data.id);
$(row).attr("data-hwaddr", data.hwaddr);
var button =
'<button type="button" class="btn btn-danger btn-xs" id="deleteNetworkEntry_' +
data.id +
'">' +
'<span class="far fa-trash-alt"></span>' +
"</button>";
$("td:eq(7)", row).html(button);
},
dom:
"<'row'<'col-sm-12'f>>" +
"<'row'<'col-sm-4'l><'col-sm-8'p>>" +
"<'row'<'col-sm-12'<'table-responsive'tr>>>" +
"<'row'<'col-sm-5'i><'col-sm-7'p>>",
ajax: {
url: "/api/network/devices",
type: "GET",
dataType: "json",
data: {
max_devices: 999,
max_addresses: 25,
},
error: handleAjaxError,
dataSrc: "devices",
},
autoWidth: false,
processing: true,
order: [[6, "desc"]],
columns: [
{ data: "id", visible: false },
{ data: "ips[].ip", type: "ip-address", width: "25%" },
{ data: "hwaddr", width: "10%" },
{ data: "interface", width: "4%" },
{
data: "firstSeen",
width: "8%",
render: function (data, type) {
if (type === "display") {
return utils.datetime(data);
}
return data;
},
},
{
data: "lastQuery",
width: "8%",
render: function (data, type) {
if (type === "display") {
return utils.datetime(data);
}
return data;
},
},
{ data: "numQueries", width: "9%", render: $.fn.dataTable.render.text() },
{ data: "", width: "6%", orderable: false },
{ data: "", width: "6%", orderable: false },
],
drawCallback: function () {
$('button[id^="deleteNetworkEntry_"]').on("click", deleteNetworkEntry);
// Remove visible dropdown to prevent orphaning
$("body > .bootstrap-select.dropdown").remove();
},
lengthMenu: [
[10, 25, 50, 100, -1],
[10, 25, 50, 100, "All"],
],
stateSave: true,
stateDuration: 0,
stateSaveCallback: function (settings, data) {
utils.stateSaveCallback("network_table", data);
},
stateLoadCallback: function () {
return utils.stateLoadCallback("network_table");
},
columnDefs: [
{
targets: [-1, -2],
data: null,
defaultContent: "",
},
{
targets: "_all",
render: $.fn.dataTable.render.text(),
},
],
});
// Disable autocorrect in the search box
var input = document.querySelector("input[type=search]");
input.setAttribute("autocomplete", "off");
input.setAttribute("autocorrect", "off");
input.setAttribute("autocapitalize", "off");
input.setAttribute("spellcheck", false);
});

723
scripts/js/queries.js Normal file
View File

@@ -0,0 +1,723 @@
/* 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 moment:false, utils:false, REFRESH_INTERVAL:false */
const beginningOfTime = 1262304000; // Jan 01 2010, 00:00 in seconds
const endOfTime = 2147483647; // Jan 19, 2038, 03:14 in seconds
var from = beginningOfTime;
var until = endOfTime;
var dateformat = "MMM Do YYYY, HH:mm";
var table = null;
var cursor = null;
var filters = [
"client_ip",
"client_name",
"domain",
"upstream",
"type",
"status",
"reply",
"dnssec",
];
function initDateRangePicker() {
$("#querytime").daterangepicker(
{
timePicker: true,
timePickerIncrement: 5,
timePicker24Hour: true,
locale: { format: dateformat },
startDate: moment(from * 1000), // convert to milliseconds since epoch
endDate: moment(until * 1000), // convert to milliseconds since epoch
ranges: {
"Last 10 Minutes": [moment().subtract(10, "minutes"), moment()],
"Last Hour": [moment().subtract(1, "hours"), moment()],
Today: [moment().startOf("day"), moment().endOf("day")],
Yesterday: [
moment().subtract(1, "days").startOf("day"),
moment().subtract(1, "days").endOf("day"),
],
"Last 7 Days": [moment().subtract(6, "days"), moment().endOf("day")],
"Last 30 Days": [moment().subtract(29, "days"), moment().endOf("day")],
"This Month": [moment().startOf("month"), moment().endOf("month")],
"Last Month": [
moment().subtract(1, "month").startOf("month"),
moment().subtract(1, "month").endOf("month"),
],
"This Year": [moment().startOf("year"), moment().endOf("year")],
"All Time": [moment(beginningOfTime * 1000), moment(endOfTime * 1000)], // convert to milliseconds since epoch
},
opens: "center",
showDropdowns: true,
autoUpdateInput: true,
},
function (startt, endt) {
// Update global variables
// Convert milliseconds (JS) to seconds (API)
from = moment(startt).utc().valueOf() / 1000;
until = moment(endt).utc().valueOf() / 1000;
}
);
}
function handleAjaxError(xhr, textStatus) {
if (textStatus === "timeout") {
alert("The server took too long to send the data.");
} else {
alert("An unknown error occurred while loading the data.\n" + xhr.responseText);
}
$("#all-queries_processing").hide();
table.clear();
table.draw();
}
function parseQueryStatus(data) {
// Parse query status
var fieldtext,
buttontext,
icon = null,
colorClass = false,
blocked = false,
isCNAME = false;
switch (data.status) {
case "GRAVITY":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = "Blocked (gravity)";
buttontext =
'<button type="button" class="btn btn-default btn-sm text-green btn-whitelist"><i class="fas fa-check"></i> Allow</button>';
blocked = true;
break;
case "FORWARDED":
colorClass = "text-green";
icon = "fa-solid fa-cloud-download-alt";
fieldtext = "Forwarded to " + data.upstream;
buttontext =
'<button type="button" class="btn btn-default btn-sm text-red btn-blacklist"><i class="fa fa-ban"></i> Deny</button>';
break;
case "CACHE":
colorClass = "text-green";
icon = "fa-solid fa-database";
fieldtext = "Served from cache";
buttontext =
'<button type="button" class="btn btn-default btn-sm text-red btn-blacklist"><i class="fa fa-ban"></i> Deny</button>';
break;
case "REGEX":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = "Blocked (regex)";
buttontext =
'<button type="button" class="btn btn-default btn-sm text-green btn-whitelist"><i class="fas fa-check"></i> Allow</button>';
blocked = true;
break;
case "DENYLIST":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = "Blocked (exact)";
buttontext =
'<button type="button" class="btn btn-default btn-sm text-green btn-whitelist"><i class="fas fa-check"></i> Allow</button>';
blocked = true;
break;
case "EXTERNAL_BLOCKED_IP":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = "Blocked (external, IP)";
buttontext = "";
blocked = true;
break;
case "EXTERNAL_BLOCKED_NULL":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = "Blocked (external, NULL)";
buttontext = "";
blocked = true;
break;
case "EXTERNAL_BLOCKED_NXRA":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = "Blocked (external, NXRA)";
buttontext = "";
blocked = true;
break;
case "GRAVITY_CNAME":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = "Blocked (gravity, CNAME)";
buttontext =
'<button type="button" class="btn btn-default btn-sm text-green btn-whitelist"><i class="fas fa-check"></i> Allow</button>';
isCNAME = true;
blocked = true;
break;
case "REGEX_CNAME":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = "Blocked (regex denied, CNAME)";
buttontext =
'<button type="button" class="btn btn-default btn-sm text-green btn-whitelist"><i class="fas fa-check"></i> Allow</button>';
isCNAME = true;
blocked = true;
break;
case "DENYLIST_CNAME":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = "Blocked (exact denied, CNAME)";
buttontext =
'<button type="button" class="btn btn-default btn-sm text-green btn-whitelist"><i class="fas fa-check"></i> Allow</button>';
isCNAME = true;
blocked = true;
break;
case "RETRIED":
colorClass = "text-green";
icon = "fa-solid fa-redo"; // fa-repeat
fieldtext = "Retried";
buttontext = "";
break;
case "RETRIED_DNSSEC":
colorClass = "text-green";
icon = "fa-solid fa-redo"; // fa-repeat
fieldtext = "Retried (ignored)";
buttontext = "";
break;
case "IN_PROGRESS":
colorClass = "text-green";
icon = "fa-solid fa-hourglass-half";
fieldtext = "Already forwarded, awaiting reply";
buttontext =
'<button type="button" class="btn btn-default btn-sm text-red btn-blacklist"><i class="fa fa-ban"></i> Deny</button>';
break;
case "CACHE_STALE":
colorClass = "text-green";
icon = "fa-solid fa-infinity";
fieldtext = "Served by cache optimizer";
buttontext =
'<button type="button" class="btn btn-default btn-sm text-red btn-blacklist"><i class="fa fa-ban"></i> Deny</button>';
break;
case "SPECIAL_DOMAIN":
colorClass = "text-red";
icon = "fa-solid fa-ban";
fieldtext = data.status;
buttontext = "";
blocked = true;
break;
default:
colorClass = "text-orange";
icon = "fa-solid fa-question";
fieldtext = data.status;
buttontext = "";
}
var matchText =
colorClass === "text-green" ? "allowed" : colorClass === "text-red" ? "blocked" : "matched";
return {
fieldtext: fieldtext,
buttontext: buttontext,
colorClass: colorClass,
icon: icon,
isCNAME: isCNAME,
matchText: matchText,
blocked: blocked,
};
}
function formatReplyTime(replyTime, type) {
if (type === "display") {
// Units:
// - seconds if replytime >= 1 second
// - milliseconds if reply time >= 100 µs
// - microseconds otherwise
return replyTime < 1e-4
? (1e6 * replyTime).toFixed(1) + " µs"
: replyTime < 1
? (1e3 * replyTime).toFixed(1) + " ms"
: replyTime.toFixed(1) + " s";
}
// else: return the number itself (for sorting and searching)
return replyTime;
}
function formatInfo(data) {
// DNSSEC status
var dnssecStatus = data.dnssec,
dnssecClass;
switch (data.dnssec) {
case "SECURE":
dnssecClass = "text-green";
break;
case "INSECURE":
dnssecClass = "text-orange";
break;
case "BOGUS":
dnssecClass = "text-red";
break;
case "ABANDONED":
dnssecClass = "text-red";
break;
default:
// No DNSSEC or UNKNOWN
dnssecStatus = "N/A";
dnssecClass = false;
}
// Parse Query Status
var queryStatus = parseQueryStatus(data);
var divStart = '<div class="col-xl-2 col-lg-4 col-md-6 col-12 overflow-wrap">';
var statusInfo = "";
if (queryStatus.colorClass !== false) {
statusInfo =
divStart +
"Query Status:&nbsp;&nbsp;" +
'<strong><span class="' +
queryStatus.colorClass +
'">' +
queryStatus.fieldtext +
"</span></strong></div>";
}
var listInfo = "",
cnameInfo = "";
if (data.list_id !== null && data.list_id !== -1) {
// Some list matched - add link to search page
var listLink =
'<a href="search?domain=' +
encodeURIComponent(data.domain) +
'" target="_blank">search lists</a>';
listInfo = divStart + "Query was " + queryStatus.matchText + ", " + listLink + "</div>";
}
if (queryStatus.isCNAME) {
cnameInfo =
divStart + "Query was blocked during CNAME inspection of&nbsp;&nbsp;" + data.cname + "</div>";
}
// Show TTL if applicable
var ttlInfo = "";
if (data.ttl > 0) {
ttlInfo =
divStart +
"Time-to-live (TTL):&nbsp;&nbsp;" +
moment.duration(data.ttl, "s").humanize() +
" (" +
data.ttl +
"s)</div>";
}
// Show client information, show hostname only if available
var ipInfo =
data.client.name !== null && data.client.name.length > 0
? utils.escapeHtml(data.client.name) + " (" + data.client.ip + ")"
: data.client.ip;
var clientInfo = divStart + "Client:&nbsp;&nbsp;<strong>" + ipInfo + "</strong></div>";
// Show DNSSEC status if applicable
var dnssecInfo = "";
if (dnssecClass !== false) {
dnssecInfo =
divStart +
'DNSSEC status:&nbsp&nbsp;<strong><span class="' +
dnssecClass +
'">' +
dnssecStatus +
"</span></strong></div>";
}
// Show long-term database information if applicable
var dbInfo = "";
if (data.dbid !== false) {
dbInfo = divStart + "Database ID:&nbsp;&nbsp;" + data.id + "</div>";
}
// Always show reply info, add reply delay if applicable
var replyInfo = "";
replyInfo =
data.reply.type !== "UNKNOWN"
? divStart + "Reply:&nbsp&nbsp;" + data.reply.type + "</div>"
: divStart + "Reply:&nbsp;&nbsp;No reply received</div>";
// Compile extra info for displaying
return (
'<div class="row">' +
divStart +
"Query received on:&nbsp;&nbsp;" +
moment.unix(data.time).format("Y-MM-DD HH:mm:ss.SSS z") +
"</div>" +
clientInfo +
dnssecInfo +
statusInfo +
cnameInfo +
listInfo +
ttlInfo +
replyInfo +
dbInfo +
"</div>"
);
}
function addSelectSuggestion(name, dict, data) {
var obj = $("#" + name + "_filter"),
value = "";
obj.empty();
// In order for the placeholder value to appear, we have to have a blank
// <option> as the first option in our <select> control. This is because
// the browser tries to select the first option by default. If our first
// option were non-empty, the browser would display this instead of the
// placeholder.
obj.append($("<option />"));
// Add GET parameter as first suggestion (if present and not already included)
if (name in dict) {
value = decodeURIComponent(dict[name]);
if (!data.includes(value)) data.unshift(value);
}
// Add data obtained from API
for (var key in data) {
if (!Object.prototype.hasOwnProperty.call(data, key)) {
continue;
}
var text = data[key];
obj.append($("<option />").val(text).text(text));
}
// Select GET parameter (if present)
if (name in dict) {
obj.val(value);
}
}
/*
function addChkboxSuggestion(name, data) {
var obj = $("#" + name + "_filter");
obj.empty();
// Add data obtained from API
for (var key in data) {
if (!Object.prototype.hasOwnProperty.call(data, key)) {
continue;
}
var text = data[key];
obj.append(
'<div class="checkbox"><label><input type="checkbox" checked id="' +
name +
"_" +
key +
'">' +
text +
"</label></div>"
);
}
}
*/
function getSuggestions(dict) {
$.get(
"/api/queries/suggestions",
function (data) {
for (var key in filters) {
if (Object.hasOwnProperty.call(filters, key)) {
var f = filters[key];
addSelectSuggestion(f, dict, data.suggestions[f]);
}
}
},
"json"
);
}
function parseFilters() {
var filter = {};
for (var key in filters) {
if (Object.hasOwnProperty.call(filters, key)) {
var f = filters[key];
filter[f] = $("#" + f + "_filter").val();
}
}
return filter;
}
function filterOn(param, dict) {
const typ = typeof dict[param];
return param in dict && (typ === "number" || (typ === "string" && dict[param].length > 0));
}
function getAPIURL(filters) {
var apiurl = "/api/queries?";
for (var key in filters) {
if (Object.hasOwnProperty.call(filters, key)) {
var filter = filters[key];
if (filterOn(key, filters)) {
if (!apiurl.endsWith("?")) apiurl += "&";
apiurl += key + "=" + encodeURIComponent(filter);
}
}
}
// Omit from/until filtering if we cannot reach these times. This will speed
// up the database lookups notably on slow devices. The API accepts timestamps
// in seconds since epoch
if (from > beginningOfTime) apiurl += "&from=" + from;
if (until > beginningOfTime && until < endOfTime) apiurl += "&until=" + until;
if ($("#disk").prop("checked")) apiurl += "&disk=true";
return encodeURI(apiurl);
}
var liveMode = false;
$("#live").prop("checked", liveMode);
$("#live").on("click", function () {
liveMode = $(this).prop("checked");
liveUpdate();
});
function liveUpdate() {
if (liveMode) {
refreshTable();
}
}
$(function () {
// Do we want to filter queries?
var GETDict = utils.parseQueryString();
for (var sel in filters) {
if (Object.hasOwnProperty.call(filters, sel)) {
var element = filters[sel];
$("#" + element + "_filter").select2({
width: "100%",
tags: sel < 3, // Only the first three are allowed to freely specify input
placeholder: "Select...",
allowClear: true,
});
}
}
getSuggestions(GETDict);
var apiurl = getAPIURL(GETDict);
if ("from" in GETDict) {
from = GETDict.from;
$("#from").val(moment.unix(from).format("Y-MM-DD HH:mm:ss"));
}
if ("until" in GETDict) {
until = GETDict.until;
$("#until").val(moment.unix(until).format("Y-MM-DD HH:mm:ss"));
}
initDateRangePicker();
table = $("#all-queries").DataTable({
ajax: {
url: apiurl,
error: handleAjaxError,
dataSrc: "queries",
data: function (d) {
if (cursor !== null) d.cursor = cursor;
},
dataFilter: function (d) {
var json = jQuery.parseJSON(d);
cursor = json.cursor; // Extract cursor from original data
if (liveMode) {
utils.setTimer(liveUpdate, REFRESH_INTERVAL.query_log);
}
return d;
},
},
serverSide: true,
dom:
"<'row'<'col-sm-4'l><'col-sm-8'p>>" +
"<'row'<'col-sm-12'<'table-responsive'tr>>>" +
"<'row'<'col-sm-5'i><'col-sm-7'p>>",
autoWidth: false,
processing: false,
order: [[0, "desc"]],
columns: [
{
data: "time",
width: "10%",
render: function (data, type) {
if (type === "display") {
return moment.unix(data).format("Y-MM-DD [<br class='hidden-lg'>]HH:mm:ss z");
}
return data;
},
searchable: false,
},
{ data: "status", width: "1%", searchable: false },
{ data: "type", width: "1%", searchable: false },
{ data: "domain", width: "45%" },
{ data: "client.ip", width: "29%", type: "ip-address" },
{ data: "reply.time", width: "4%", render: formatReplyTime, searchable: false },
{ data: null, width: "10%", sortable: false, searchable: false },
],
lengthMenu: [
[10, 25, 50, 100, -1],
[10, 25, 50, 100, "All"],
],
stateSave: true,
stateSaveCallback: function (settings, data) {
utils.stateSaveCallback("query_log_table", data);
},
stateLoadCallback: function () {
return utils.stateLoadCallback("query_log_table");
},
rowCallback: function (row, data) {
var querystatus = parseQueryStatus(data);
// Remove HTML from querystatus.fieldtext
var rawtext = $("<div/>").html(querystatus.fieldtext).text();
if (querystatus.icon !== false) {
$("td:eq(1)", row).html(
"<i class='fa fa-fw " +
querystatus.icon +
" " +
querystatus.colorClass +
"' title='" +
rawtext +
"'></i>"
);
} else if (querystatus.colorClass !== false) {
$(row).addClass(querystatus.colorClass);
}
// Define row background color
$(row).addClass(querystatus.blocked === true ? "blocked-row" : "allowed-row");
// Substitute domain by "." if empty
var domain = data.domain === 0 ? "." : data.domain;
if (querystatus.isCNAME) {
// Add domain in CNAME chain causing the query to have been blocked
$("td:eq(3)", row).text(domain + "\n(blocked " + data.cname + ")");
} else {
$("td:eq(3)", row).text(domain);
}
// Show hostname instead of IP if available
if (data.client.name !== null && data.client.name !== "") {
$("td:eq(4)", row).text(data.client.name);
} else {
$("td:eq(4)", row).text(data.client.ip);
}
// Show X-icon instead of reply time if no reply was received
if (data.reply.type === "UNKNOWN") {
$("td:eq(5)", row).html('<i class="fa fa-times"></i>');
}
if (querystatus.buttontext !== false) {
$("td:eq(6)", row).html(querystatus.buttontext);
}
},
initComplete: function () {
this.api()
.columns()
.every(function () {
// Skip columns that are not searchable
const colIdx = this.index();
const bSearchable = this.context[0].aoColumns[colIdx].bSearchable;
if (!bSearchable) {
return null;
}
// Replace footer text with input field for searchable columns
const input = document.createElement("input");
input.placeholder = this.footer().textContent;
this.footer().replaceChildren(input);
// Event listener for user input
input.addEventListener("keyup", () => {
if (this.search() !== this.value) {
this.search(input.value).draw();
}
});
return null;
});
},
});
// Add event listener for adding domains to the allow-/blocklist
$("#all-queries tbody").on("click", "button", function (event) {
var button = $(this);
var tr = button.parents("tr");
var allowButton = button[0].classList.contains("text-green");
var denyButton = button[0].classList.contains("text-red");
var data = table.row(tr).data();
if (denyButton) {
utils.addFromQueryLog(data.domain, "deny");
} else if (allowButton) {
utils.addFromQueryLog(data.domain, "allow");
}
// else: no (colorful) button, so nothing to do
// Prevent tr click even tto be triggered for row from opening/closing
event.stopPropagation();
});
// Add event listener for opening and closing details, except on rows with "details-row" class
$("#all-queries tbody").on("click", "tr:not(.details-row)", function () {
var tr = $(this);
var row = table.row(tr);
if (window.getSelection().toString().length > 0) {
// This event was triggered by a selection, so don't open the row
return;
}
if (row.child.isShown()) {
// This row is already open - close it
row.child.hide();
tr.removeClass("shown");
} else {
// Open this row. Add a class to the row
row.child(formatInfo(row.data()), "details-row").show();
tr.addClass("shown");
}
});
$("#refresh").on("click", refreshTable);
// Disable live mode when #disk is checked
$("#disk").on("click", function () {
if ($(this).prop("checked")) {
$("#live").prop("checked", false);
$("#live").prop("disabled", true);
liveMode = false;
} else {
$("#live").prop("disabled", false);
}
});
});
function refreshTable() {
// Set cursor to NULL so we pick up newer queries
cursor = null;
// Clear table
table.clear();
// Source data from API
var filters = parseFilters();
filters.from = from;
filters.until = until;
var apiurl = getAPIURL(filters);
table.ajax.url(apiurl).draw();
}

209
scripts/js/search.js Normal file
View File

@@ -0,0 +1,209 @@
/* 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, apiFailure:false */
var GETDict = {};
$(function () {
GETDict = utils.parseQueryString();
if (GETDict.domain !== undefined) {
$("input[id^='domain']").val(GETDict.domain);
}
if (GETDict.N !== undefined) {
$("#number").val(GETDict.number);
}
});
function doSearch() {
const ta = $("#output");
// process with the current visible domain input field
const q = $("input[id^='domain']:visible").val().trim().toLowerCase();
const N = $("#number").val();
// Partial matching?
const partial = $("#partialMatch").is(":checked");
if (q.length === 0) {
return;
}
var verb = partial ? "partially" : "exactly";
$.ajax({
method: "GET",
url: "/api/search/" + encodeURIComponent(q),
async: false,
data: {
partial: partial,
N: N,
},
})
.done(function (data) {
ta.empty();
ta.show();
const res = data.search;
var result = "";
const numDomains = res.domains.length;
result =
"Found " +
numDomains +
" domain" +
(numDomains !== 1 ? "s" : "") +
" <em>" +
verb +
"</em> matching '<strong class='text-blue'>" +
utils.escapeHtml(q) +
"</strong>'" +
(numDomains > 0 ? ":" : ".") +
"<br><br>";
for (const domain of res.domains) {
const color = domain.type === "deny" ? "red" : "green";
result +=
" - <a href='groups-domains.lp?domainid=" +
domain.id +
"' target='_blank'><strong>" +
utils.escapeHtml(domain.domain) +
"</strong></a><br> <strong class='text-" +
color +
"'>" +
domain.kind +
" " +
domain.type +
" domain</strong><br> added: " +
utils.renderTimestamp(domain.date_added, "display") +
"<br> last modified: " +
utils.renderTimestamp(domain.date_modified, "display") +
"<br> " +
(domain.enabled ? "enabled" : "disabled") +
", used in " +
domain.groups.length +
" group" +
(domain.groups.length === 1 ? "" : "s") +
(domain.comment !== null && domain.comment.length > 0
? '<br> comment: "' + utils.escapeHtml(domain.comment) + '"'
: "<br> no comment") +
"<br><br>";
}
// Group results in res.gravity by res.gravity[].address
var grouped = {};
for (const list of res.gravity) {
if (grouped[list.address + "_" + list.type] === undefined) {
grouped[list.address + "_" + list.type] = [];
}
grouped[list.address + "_" + list.type].push(list);
}
const numLists = Object.keys(grouped).length;
result +=
"Found " +
numLists +
" list" +
(numLists !== 1 ? "s" : "") +
" <em>" +
verb +
"</em> matching '<strong class='text-blue'>" +
utils.escapeHtml(q) +
"</strong>'" +
(numLists > 0 ? ":" : ".") +
"<br><br>";
for (const listId of Object.keys(grouped)) {
const list = grouped[listId][0];
const color = list.type === "block" ? "red" : "green";
result +=
" - <a href='groups-lists.lp?listid=" +
list.id +
"' target='_blank'>" +
utils.escapeHtml(list.address) +
"</a><br> <strong class='text-" +
color +
"'>" +
list.type +
" list</strong>" +
"<br> added: " +
utils.renderTimestamp(list.date_added, "display") +
"<br> last modified: " +
utils.renderTimestamp(list.date_modified, "display") +
"<br> last updated: " +
utils.renderTimestamp(list.date_updated, "display") +
" (" +
list.number.toLocaleString() +
" domains)" +
"<br> " +
(list.enabled ? "enabled" : "disabled") +
", used in " +
list.groups.length +
" group" +
(list.groups.length === 1 ? "" : "s") +
(list.comment !== null && list.comment.length > 0
? '<br> comment: "' + utils.escapeHtml(list.comment) + '"'
: "<br> no comment") +
"<br> matching entries:<br>";
for (const lists of grouped[listId]) {
result +=
" - <strong class='text-blue'>" + utils.escapeHtml(lists.domain) + "</strong><br>";
}
result += "<br>";
}
result += "Number of results per type:<br>";
result +=
" - <strong class='text-blue'>" +
data.search.results.domains.exact +
"</strong> exact domain matches<br>";
result +=
" - <strong class='text-blue'>" +
data.search.results.domains.regex +
"</strong> regex domain matches<br>";
result +=
" - <strong class='text-blue'>" +
data.search.results.gravity.allow +
"</strong> allowlist (antigravity) matches<br>";
result +=
" - <strong class='text-blue'>" +
data.search.results.gravity.block +
"</strong> blocklist (gravity) matches<br>";
if (
data.search.results.gravity.allow > data.search.parameters.N ||
data.search.results.gravity.block > data.search.parameters.N ||
data.search.results.domains.exact > data.search.parameters.N ||
data.search.results.domains.regex > data.search.parameters.N
) {
result +=
"<br><br><strong class='text-green'>Note:</strong> " +
"The number of results to return per type is limited to " +
data.search.parameters.N +
" entries.<br> There are " +
data.search.results.total +
" matching entries in total.<br> Consider " +
"using a more specific search term or increase N.<br>";
}
ta.append(result);
})
.fail(function (data) {
apiFailure(data);
});
}
// Handle enter key
$("#domain").on("keypress", function (e) {
if (e.which === 13) {
// Enter was pressed, and the input has focus
doSearch();
}
});
// Handle search buttons
$("button[id='btnSearch']").on("click", function () {
doSearch();
});

View File

@@ -0,0 +1,407 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2023 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, apiFailure: false, applyCheckboxRadioStyle: false, saveSettings:false */
/* exported createDynamicConfigTabs */
function addAllowedValues(allowed) {
if (typeof allowed === "object") {
return (
"<p>Available options:</p><ul><li>" +
allowed
.map(function (option) {
return "<code>" + option.item + "</code>: " + utils.escapeHtml(option.description);
})
.join("</li><li>") +
"</li></ul>"
);
} else if (typeof allowed === "string") {
return "<p><small>Allowed value: " + utils.escapeHtml(allowed) + "</small></p>";
}
}
function boxIcons(value) {
return (
'<span class="box-icons">' +
(value.modified
? '<i class="far fa-edit text-light-blue" title="Modified from default"></i>'
: "") +
(value.flags.restart_dnsmasq
? '<i class="fas fa-redo text-orange" title="Setting requires FTL restart on change"></i>'
: "") +
(value.flags.env_var
? '<i class="fas fa-lock text-orange" title="Settings overwritten by an environmental variable are read-only"></i>'
: "") +
"</span>"
);
}
function valueDetails(key, value) {
// Define default hint text
let defaultValueHint = "";
if (value.modified) {
defaultValueHint = "";
if (value.default !== null) {
let defVal = utils.escapeHtml(JSON.stringify(value.default));
switch (defVal) {
case "true": {
defVal = "enabled";
break;
}
case "false": {
defVal = "disabled";
break;
}
case '""':
case "[]": {
defVal = "empty";
break;
}
// No default
}
defaultValueHint = "<p>Default Value: " + defVal + "</p>";
}
}
// Define extraAttributes, if needed
let extraAttributes = "";
if (value.flags.env_var) {
extraAttributes = " disabled";
}
// Format the output depending on the value type
let content = "";
switch (value.type) {
case "IPv4 address":
case "IPv6 address":
case "string": {
content +=
'<label class="col-sm-2 control-label">Value <small>(string)</small></label>' +
'<div class="col-sm-10">' +
'<input type="text" class="form-control" value="' +
value.value +
'" data-key="' +
key +
'"' +
extraAttributes +
"> " +
defaultValueHint +
addAllowedValues(value.allowed) +
"</div>";
break;
}
case "boolean": {
content +=
'<div class="col-sm-12">' +
'<div><input type="checkbox" ' +
(value.value ? " checked" : "") +
' id="' +
key +
'-checkbox" data-key="' +
key +
'"' +
extraAttributes +
'><label for="' +
key +
'-checkbox">Enabled ' +
defaultValueHint +
"</label></div>" +
" </div>";
break;
}
case "double": {
content +=
'<label class="col-sm-2 control-label">Value</label>' +
'<div class="col-sm-10">' +
'<input type="number" class="form-control" value="' +
value.value +
'" data-key="' +
key +
'" data-type="float"' +
extraAttributes +
"> " +
defaultValueHint +
"</div>";
break;
}
case "integer": {
content +=
'<label class="col-sm-2 control-label">Value <small>(integer)</small></label>' +
'<div class="col-sm-10">' +
'<input type="number" step="1" class="form-control" value="' +
value.value +
'" data-key="' +
key +
'" data-type="integer"' +
extraAttributes +
"> " +
defaultValueHint +
"</div>";
break;
}
case "unsigned integer": {
content +=
'<label class="col-sm-4 control-label">Value <small>(unsigned integer)</small></label>' +
'<div class="col-sm-8">' +
'<input type="number" step="1" min="0" class="form-control" value="' +
value.value +
'" data-key="' +
key +
'" data-type="integer"' +
extraAttributes +
"> " +
defaultValueHint +
"</div>";
break;
}
case "unsigned integer (16 bit)": {
content +=
'<label class="col-sm-4 control-label">Value <small>(unsigned 16bit integer)</small></label>' +
'<div class="col-sm-8">' +
'<input type="number" step="1" min="0" max="65535" class="form-control" value="' +
value.value +
'" data-key="' +
key +
'" data-type="integer"' +
extraAttributes +
"> " +
defaultValueHint +
"</div>";
break;
}
case "string array": {
content +=
'<label class="col-sm-12 control-label">Values <small>(one item per line)</small></label>' +
'<div class="col-sm-12">' +
'<textarea class="form-control field-sizing-content" data-key="' +
key +
'"' +
extraAttributes +
">" +
value.value.join("\n") +
"</textarea> " +
defaultValueHint +
addAllowedValues(value.allowed) +
"</div>";
break;
}
case "enum (unsigned integer)": // fallthrough
case "enum (string)": {
content += '<div class="col-sm-12">';
value.allowed.forEach(function (option, i) {
content +=
"<div>" +
// Radio button
'<input type="radio" class="form-control" ' +
`value="${option.item}" name="${key}" id="${key}_${i}" data-key="${key}"${extraAttributes}` +
(option.item === value.value ? " checked" : "") +
">" +
// Label
`<label for="${key}_${i}"><strong>${utils.escapeHtml(option.item)}` +
(option.item === value.default ? " <em>(default)</em>" : "") +
"</strong></label>" +
// Paragraph with description
`<p class="help-block">${option.description}</p>` +
"</div>";
});
content += "</div>";
break;
}
case "password (write-only string)": {
content +=
'<label class="col-sm-2 control-label">Value <small>(string)</small></label>' +
'<div class="col-sm-10">' +
'<input type="password" class="form-control" value="' +
value.value +
'" data-key="' +
key +
'"' +
extraAttributes +
"> " +
defaultValueHint +
addAllowedValues(value.allowed) +
"</div>";
break;
}
default: {
content += "TYPE " + value.type + " NOT DEFINED";
}
}
return '<div class="row">' + content + "</div>";
}
function generateRow(topic, key, value) {
// If the value is an object, we need to recurse
if (!("description" in value)) {
Object.keys(value).forEach(function (subkey) {
var subvalue = value[subkey];
generateRow(topic, key + "." + subkey, subvalue);
});
return;
}
// else: we have a setting we can display
var box =
'<div class="box settings-box">' +
'<div class="box-header with-border">' +
'<h3 class="box-title" data-key="' +
key +
'" data-modified="' +
(value.modified ? "true" : "false") +
'">' +
key +
boxIcons(value) +
"</h3>" +
"</div>" +
'<div class="box-body">' +
utils.escapeHtml(value.description).replaceAll("\n", "<br>") +
"</div>" +
'<div class="box-footer">' +
valueDetails(key, value) +
"</div></div> ";
var topKey = key.split(".")[0];
var elem = $("#advanced-content-" + topKey + "-flex");
elem.append(box);
}
function createDynamicConfigTabs() {
$.ajax({
url: "/api/config?detailed=true",
})
.done(function (data) {
// Create the tabs for the advanced dynamic config topics
Object.keys(data.topics).forEach(function (n) {
var topic = data.topics[n];
$("#advanced-settings-tabs").append(`
<div id="advanced-content-${topic.name}" role="tabpanel" class="tab-pane fade">
<h3 class="page-header">${topic.description} (<code>${topic.name}</code>)</h3>
<div class="row" id="advanced-content-${topic.name}-body">
<div class="col-xs-12 settings-container" id="advanced-content-${topic.name}-flex"></div>
</div>
</div>
`);
// Dynamically create the settings menu
$("#advanced-settings-menu ul").append(`
<li role="presentation">
<a href="#advanced-content-${topic.name}" class="btn btn-default" aria-controls="advanced-content-${topic.name}" role="pill" data-toggle="pill">${topic.description.replace(" settings", "")}</a>
</li>
`);
});
// Dynamically fill the tabs with config topics
Object.keys(data.config).forEach(function (topic) {
var value = data.config[topic];
generateRow(topic, topic, value, data);
});
$("#advanced-overlay").hide();
// Select the first tab and show the content
$("#advanced-settings-menu ul li:first-child").addClass("active");
$("#advanced-settings-tabs > div:first-child").addClass("active in");
$("button[id='save']").on("click", function () {
saveSettings();
});
applyCheckboxRadioStyle();
applyOnlyChanged();
})
.fail(function (data) {
apiFailure(data);
});
}
function initOnlyChanged() {
const elem = $("#only-changed");
// Restore settings level from local storage (if available) or default to "false"
if (localStorage.getItem("only-changed") === null) {
localStorage.setItem("only-changed", "false");
}
elem.prop("checked", localStorage.getItem("only-changed") === "true");
elem.bootstrapToggle({
on: "Modified settings",
off: "All settings",
onstyle: "primary",
offstyle: "success",
size: "small",
width: "180px",
});
elem.on("change", function () {
localStorage.setItem("only-changed", $(this).prop("checked") ? "true" : "false");
applyOnlyChanged();
});
elem.bootstrapToggle(localStorage.getItem("only-changed") === "true" ? "on" : "off");
elem.trigger("change");
}
function applyOnlyChanged() {
if (localStorage.getItem("only-changed") === "true") {
// Show only modified settings (hide tabs menu and empty tabs).
// Hide the tabs menu
$("#advanced-settings-menu").hide();
// Show all tabs, except the ones containing "data-modified='true'" attribute
// to prevent empty tabs (using the same classes used by Boostrap3)
$("#advanced-settings-tabs > .tab-pane").addClass("in active");
$("#advanced-settings-tabs > .tab-pane:not(:has(h3[data-modified='true']))").removeClass(
"in active"
);
// Hide all boxes with data-key attribute, except the ones with "data-modified='true'" attribute
$(".box-title[data-key]").not("[data-modified='true']").closest(".box").hide();
} else {
// Show the tabs menu and activate only the first button (deactivate other buttons)
$("#advanced-settings-menu").show();
$("#advanced-settings-menu ul li").removeClass("active");
$("#advanced-settings-menu ul li:first-child").addClass("active");
// Hide all tabs, except the first one (removing the classes used by Boostrap3)
$("#advanced-settings-tabs > .tab-pane:not(:first-child)").removeClass("in active");
// Show all boxes with data-key attribute
$(".box-title[data-key]").closest(".box").show();
}
}
$(document).ready(function () {
createDynamicConfigTabs();
initOnlyChanged();
});

486
scripts/js/settings-api.js Normal file
View File

@@ -0,0 +1,486 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2023 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, setConfigValues: false, apiFailure: false, QRious: false */
var apiSessionsTable = null;
var ownSessionID = null;
var deleted = 0;
var TOTPdata = null;
var apppwSet = false;
function renderBool(data, type) {
// Display and search content
if (type === "display" || type === "filter") {
var icon = "fa-xmark text-danger";
if (data === true) {
icon = "fa-check text-success";
}
return '<i class="fa-solid ' + icon + '"></i>';
}
// Sorting content
return data;
}
$(function () {
apiSessionsTable = $("#APISessionsTable").DataTable({
ajax: {
url: "/api/auth/sessions",
type: "GET",
dataSrc: "sessions",
},
order: [[1, "asc"]],
columns: [
{ data: null, width: "22px" },
{ data: "id" },
{ data: "valid", render: renderBool },
{ data: null },
{ data: "app", render: renderBool },
{ data: "cli", render: renderBool },
{ data: "login_at", render: utils.renderTimestamp },
{ data: "valid_until", render: utils.renderTimestamp },
{ data: "remote_addr", type: "ip-address" },
{ data: "user_agent" },
{ data: null, width: "22px", orderable: false },
],
columnDefs: [
{
targets: 0,
orderable: false,
className: "select-checkbox",
render: function () {
return "";
},
},
{
targets: "_all",
render: $.fn.dataTable.render.text(),
},
],
drawCallback: function () {
$('button[id^="deleteSession_"]').on("click", deleteThisSession);
// Hide buttons if all messages were deleted
var hasRows = this.api().rows({ filter: "applied" }).data().length > 0;
$(".datatable-bt").css("visibility", hasRows ? "visible" : "hidden");
// Remove visible dropdown to prevent orphaning
$("body > .bootstrap-select.dropdown").remove();
},
rowCallback: function (row, data) {
$(row).attr("data-id", data.id);
var button =
'<button type="button" class="btn btn-danger btn-xs" id="deleteSession_' +
data.id +
'" data-del-id="' +
data.id +
'" title="Delete ' +
(data.current_session
? "your current session\nWARNING: This will require you to re-login"
: "this session") +
'">' +
'<span class="far fa-trash-alt"></span>' +
"</button>";
$("td:eq(10)", row).html(button);
if (data.current_session) {
ownSessionID = data.id;
$(row).addClass("text-bold allowed-row");
$(row).attr("title", "This is the session you are currently using for the web interface");
}
let icon = "";
let title = "";
if (data.tls.mixed) {
title = "Session is PARTIALLY end-to-end encrypted";
icon = "fa-triangle-exclamation text-warning";
} else if (data.tls.login) {
title = "Session is end-to-end encrypted (TLS/SSL)";
icon = "fa-check text-success";
} else {
title = "Session is NOT end-to-end encrypted (TLS/SSL)";
icon = "fa-xmark text-danger";
}
$("td:eq(3)", row).html('<i class="fa-solid ' + icon + '" title="' + title + '"></i>');
// If x_forwarded_for is != null, the session is using a proxy
// Show x-forwarded-for instead of the remote address in italics
// and show the remote address in the title attribute
if (data.x_forwarded_for !== null) {
$("td:eq(8)", row).html("<i>" + data.x_forwarded_for + "</i>");
$("td:eq(8)", row).attr("title", "Original remote address: " + data.remote_addr);
}
},
select: {
style: "multi",
selector: "td:not(:last-child)",
info: false,
},
buttons: [
{
text: '<span class="far fa-square"></span>',
titleAttr: "Select All",
className: "btn-sm datatable-bt selectAll",
action: function () {
apiSessionsTable.rows({ page: "current" }).select();
},
},
{
text: '<span class="far fa-plus-square"></span>',
titleAttr: "Select All",
className: "btn-sm datatable-bt selectMore",
action: function () {
apiSessionsTable.rows({ page: "current" }).select();
},
},
{
extend: "selectNone",
text: '<span class="far fa-check-square"></span>',
titleAttr: "Deselect All",
className: "btn-sm datatable-bt removeAll",
},
{
text: '<span class="far fa-trash-alt"></span>',
titleAttr: "Delete Selected",
className: "btn-sm datatable-bt deleteSelected",
action: function () {
// For each ".selected" row ...
var ids = [];
$("tr.selected").each(function () {
// ... add the row identified by "data-id".
ids.push(parseInt($(this).attr("data-id"), 10));
});
// Delete all selected rows at once
deleteMultipleSessions(ids);
},
},
],
dom:
"<'row'<'col-sm-6'l><'col-sm-6'f>>" +
"<'row'<'col-sm-3'B><'col-sm-9'p>>" +
"<'row'<'col-sm-12'<'table-responsive'tr>>>" +
"<'row'<'col-sm-3'B><'col-sm-9'p>>" +
"<'row'<'col-sm-12'i>>",
lengthMenu: [
[10, 25, 50, 100, -1],
[10, 25, 50, 100, "All"],
],
language: {
emptyTable: "No active sessions found.",
},
stateSave: true,
stateDuration: 0,
stateSaveCallback: function (settings, data) {
utils.stateSaveCallback("api-sessions-table", data);
},
stateLoadCallback: function () {
var data = utils.stateLoadCallback("api-sessions-table");
// Return if not available
if (data === null) {
return null;
}
// Apply loaded state to table
return data;
},
});
apiSessionsTable.on("init select deselect", function () {
utils.changeTableButtonStates(apiSessionsTable);
});
});
function deleteThisSession() {
// This function is called when a red trash button is clicked
// We get the ID of the current item from the data-del-id attribute
const thisID = parseInt($(this).attr("data-del-id"), 10);
deleted = 0;
deleteOneSession(thisID, 1, false);
}
function deleteMultipleSessions(ids) {
// This function is called when multiple sessions are selected and the gray
// trash button is clicked
// Check input validity
if (!Array.isArray(ids)) return;
// Exploit prevention: Return early for non-numeric IDs
for (const id of ids) {
if (Object.hasOwnProperty.call(ids, id) && isNaN(ids[id])) return;
}
// Convert all ids to integers
ids = ids.map(function (value) {
return parseInt(value, 10);
});
// Check if own session is selected and remove it when deleting multiple
// We need this only when multiple sessions are removed to ensure we do not
// accidentally remove our own session and thus log us out *before* we can
// remove the other sessions
let ownSessionDelete = false;
if (ids.includes(ownSessionID) && ids.length > 1) {
ownSessionDelete = true;
// Strip own session ID from array
ids = ids.filter(function (value) {
return value !== ownSessionID;
});
}
// Loop through IDs and delete them
deleted = 0;
for (const id of ids) {
deleteOneSession(id, ids.length, ownSessionDelete);
}
}
function deleteOneSession(id, len, ownSessionDelete) {
// This function is called to delete a single session
// If we are batch deleting, we ensure that we do not delete our own session
// before having successfully deleted all other sessions, the deletion of
// our own session is then triggered by the last successful deletion of
// another session (ownSessionDelete == true, len == global deleted)
$.ajax({
url: "/api/auth/session/" + id,
method: "DELETE",
})
.done(function () {
// Do not reload page when deleting multiple sessions
if (++deleted < len) return;
// All other sessions have been deleted, now delete own session
if (ownSessionDelete) deleteOneSession(ownSessionID, 1, false);
if (id !== ownSessionID) {
// Reload table to remove session
apiSessionsTable.ajax.reload();
} else {
// Reload page to clear session
location.reload();
}
})
.fail(function (data) {
apiFailure(data);
});
}
function processWebServerConfig() {
$.ajax({
url: "/api/config/webserver?detailed=true",
})
.done(function (data) {
setConfigValues("webserver", "webserver", data.config.webserver);
if (data.config.webserver.api.app_pwhash.value.length > 0) {
apppwSet = true;
$("#existing_apppw_warning").show();
$("#apppw_submit").text("Replace app password");
$("#apppw_submit").removeClass("btn-success");
$("#apppw_submit").addClass("btn-warning");
} else $("#apppw_clear").hide();
})
.fail(function (data) {
apiFailure(data);
});
}
$("#modal-totp").on("shown.bs.modal", function () {
$.ajax({
url: "/api/auth/totp",
})
.done(function (data) {
TOTPdata = data.totp;
$("#totp_secret").text(data.totp.secret);
var qrlink =
"otpauth://totp/" +
data.totp.issuer +
":" +
data.totp.account +
"?secret=" +
data.totp.secret +
"&issuer=" +
data.totp.issuer +
"&algorithm=" +
data.totp.algorithm +
"&digits=" +
data.totp.digits +
"&period=" +
data.totp.period;
/* eslint-disable-next-line no-new */
new QRious({
element: document.getElementById("qrcode"),
value: qrlink,
level: "Q",
size: 300,
});
$("#qrcode-spinner").hide();
$("#qrcode").show();
})
.fail(function (data) {
apiFailure(data);
});
});
var apppwhash = null;
$("#modal-apppw").on("shown.bs.modal", function () {
$.ajax({
url: "/api/auth/app",
})
.done(function (data) {
apppwhash = data.app.hash;
$("#password_code").text(data.app.password);
$("#password_display").removeClass("hidden");
$("#password-spinner").hide();
})
.fail(function (data) {
apiFailure(data);
});
});
$("#apppw_submit").on("click", function () {
// Enable app password
if (!apppwSet) {
return setAppPassword();
}
// Else: Show confirm dialog
$.confirm({
text: "Are you sure you want to replace your previous app password? You will need to re-login to continue using the web interface.",
title: "Confirmation required",
confirm: setAppPassword,
cancel: function () {
// nothing to do
},
confirmButton: "Yes, replace password",
cancelButton: "No, go back",
post: true,
confirmButtonClass: "btn-danger",
cancelButtonClass: "btn-success",
dialogClass: "modal-dialog",
});
});
$("#apppw_clear").on("click", function () {
// Disable app password
apppwhash = "";
setAppPassword();
});
function setAppPassword() {
$.ajax({
url: "/api/config",
type: "PATCH",
dataType: "json",
processData: false,
data: JSON.stringify({ config: { webserver: { api: { app_pwhash: apppwhash } } } }),
contentType: "application/json; charset=utf-8",
})
.done(function () {
$("#modal-apppw").modal("hide");
const verb = apppwhash.length > 0 ? "enabled" : "disabled";
const verb2 = apppwhash.length > 0 ? "will" : "may";
alert(
"App password has been " +
verb +
", you " +
verb2 +
" need to re-login to continue using the web interface."
);
location.reload();
})
.fail(function (data) {
apiFailure(data);
});
}
// Remove class "password_background" from the password input field with ID
// password_code when the user hovers over it or if it is focused
$("#password_code").on("mouseover focus", function () {
$(this).removeClass("password_background");
});
$("#password_code").on("mouseout blur", function () {
$(this).addClass("password_background");
});
// Trigger keyup event when pasting into the TOTP code input field
$("#totp_code").on("paste", function (e) {
$(e.target).keyup();
});
$("#totp_code").on("keyup", function () {
var code = parseInt($(this).val(), 10);
if (TOTPdata.codes.includes(code)) {
$("#totp_div").removeClass("has-error");
$("#totp_div").addClass("has-success");
$("#totp_code").prop("disabled", true);
$("#totp_submit").prop("disabled", false);
$("#totp_submit").removeClass("btn-default");
$("#totp_submit").addClass("btn-success");
}
});
function setTOTPSecret(secret) {
$.ajax({
url: "/api/config",
type: "PATCH",
dataType: "json",
processData: false,
data: JSON.stringify({ config: { webserver: { api: { totp_secret: secret } } } }),
contentType: "application/json; charset=utf-8",
})
.done(function () {
$("#button-enable-totp").addClass("hidden");
$("#button-disable-totp").removeClass("hidden");
$("#totp_code").val("");
$("#modal-totp").modal("hide");
var verb = secret.length > 0 ? "enabled" : "disabled";
alert(
"Two-factor authentication has been " +
verb +
", you will need to re-login to continue using the web interface."
);
location.reload();
})
.fail(function (data) {
apiFailure(data);
});
}
$("#totp_submit").on("click", function () {
// Enable TOTP
setTOTPSecret(TOTPdata.secret);
});
$("#button-disable-totp").confirm({
text: "Are you sure you want to disable 2FA authentication on your Pi-hole?",
title: "Confirmation required",
confirm: function () {
// Disable TOTP
setTOTPSecret("");
},
cancel: function () {
// nothing to do
},
confirmButton: "Yes, disable 2FA",
cancelButton: "No, keep 2FA enabled",
post: true,
confirmButtonClass: "btn-danger",
cancelButtonClass: "btn-success",
dialogClass: "modal-dialog",
});
$(document).ready(function () {
processWebServerConfig();
// Check if TOTP is enabled
$.ajax({
url: "/api/auth",
}).done(function (data) {
if (data.session.totp === false) $("#button-enable-totp").removeClass("hidden");
else $("#button-disable-totp").removeClass("hidden");
});
});

230
scripts/js/settings-dhcp.js Normal file
View File

@@ -0,0 +1,230 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2023 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, setConfigValues: false, apiFailure: false */
var dhcpLeaesTable = null,
toasts = {};
// DHCP leases tooltips
$(function () {
$('[data-toggle="tooltip"]').tooltip({ html: true, container: "body" });
});
function renderHostnameCLID(data, type) {
// Display and search content
if (type === "display" || type === "filter") {
if (data === "*") {
return "<i>---</i>";
}
return data;
}
// Sorting content
return data;
}
$(function () {
dhcpLeaesTable = $("#DHCPLeasesTable").DataTable({
ajax: {
url: "/api/dhcp/leases",
type: "GET",
dataSrc: "leases",
},
order: [[1, "asc"]],
columns: [
{ data: null, width: "22px" },
{ data: "ip", type: "ip-address" },
{ data: "name", render: renderHostnameCLID },
{ data: "hwaddr" },
{ data: "expires", render: utils.renderTimespan },
{ data: "clientid", render: renderHostnameCLID },
{ data: null, width: "22px", orderable: false },
],
columnDefs: [
{
targets: 0,
orderable: false,
className: "select-checkbox",
render: function () {
return "";
},
},
{
targets: "_all",
render: $.fn.dataTable.render.text(),
},
],
drawCallback: function () {
$('button[id^="deleteLease_"]').on("click", deleteLease);
// Hide buttons if all messages were deleted
var hasRows = this.api().rows({ filter: "applied" }).data().length > 0;
$(".datatable-bt").css("visibility", hasRows ? "visible" : "hidden");
// Remove visible dropdown to prevent orphaning
$("body > .bootstrap-select.dropdown").remove();
},
rowCallback: function (row, data) {
$(row).attr("data-id", data.ip);
var button =
'<button type="button" class="btn btn-danger btn-xs" id="deleteLease_' +
data.ip +
'" data-del-ip="' +
data.ip +
'">' +
'<span class="far fa-trash-alt"></span>' +
"</button>";
$("td:eq(6)", row).html(button);
},
select: {
style: "multi",
selector: "td:not(:last-child)",
info: false,
},
buttons: [
{
text: '<span class="far fa-square"></span>',
titleAttr: "Select All",
className: "btn-sm datatable-bt selectAll",
action: function () {
dhcpLeaesTable.rows({ page: "current" }).select();
},
},
{
text: '<span class="far fa-plus-square"></span>',
titleAttr: "Select All",
className: "btn-sm datatable-bt selectMore",
action: function () {
dhcpLeaesTable.rows({ page: "current" }).select();
},
},
{
extend: "selectNone",
text: '<span class="far fa-check-square"></span>',
titleAttr: "Deselect All",
className: "btn-sm datatable-bt removeAll",
},
{
text: '<span class="far fa-trash-alt"></span>',
titleAttr: "Delete Selected",
className: "btn-sm datatable-bt deleteSelected",
action: function () {
// For each ".selected" row ...
$("tr.selected").each(function () {
// ... delete the row identified by "data-id".
delLease($(this).attr("data-id"));
});
},
},
],
dom:
"<'row'<'col-sm-6'l><'col-sm-6'f>>" +
"<'row'<'col-sm-3'B><'col-sm-9'p>>" +
"<'row'<'col-sm-12'<'table-responsive'tr>>>" +
"<'row'<'col-sm-3'B><'col-sm-9'p>>" +
"<'row'<'col-sm-12'i>>",
lengthMenu: [
[10, 25, 50, 100, -1],
[10, 25, 50, 100, "All"],
],
language: {
emptyTable: "No DHCP leases found.",
},
stateSave: true,
stateDuration: 0,
stateSaveCallback: function (settings, data) {
utils.stateSaveCallback("dhcp-leases-table", data);
},
stateLoadCallback: function () {
var data = utils.stateLoadCallback("dhcp-leases-table");
// Return if not available
if (data === null) {
return null;
}
// Apply loaded state to table
return data;
},
});
dhcpLeaesTable.on("init select deselect", function () {
utils.changeTableButtonStates(dhcpLeaesTable);
});
});
function deleteLease() {
// Passes the button data-del-id attribute as IP
delLease($(this).attr("data-del-ip"));
}
function delLease(ip) {
utils.disableAll();
toasts[ip] = utils.showAlert("info", "", "Deleting lease...", ip, null);
$.ajax({
url: "/api/dhcp/leases/" + encodeURIComponent(ip),
method: "DELETE",
})
.done(function (response) {
utils.enableAll();
if (response === undefined) {
utils.showAlert(
"success",
"far fa-trash-alt",
"Successfully deleted lease",
ip,
toasts[ip]
);
dhcpLeaesTable.ajax.reload(null, false);
} else {
utils.showAlert(
"error",
"",
"Error while deleting lease: " + ip,
response.lease,
toasts[ip]
);
}
// Clear selection after deletion
dhcpLeaesTable.rows().deselect();
utils.changeTableButtonStates(dhcpLeaesTable);
})
.fail(function (jqXHR, exception) {
utils.enableAll();
utils.showAlert(
"error",
"",
"Error while deleting lease: " + ip,
jqXHR.responseText,
toasts[ip]
);
console.log(exception); // eslint-disable-line no-console
});
}
function fillDHCPhosts(data) {
$("#dhcp-hosts").val(data.value.join("\n"));
}
function processDHCPConfig() {
$.ajax({
url: "/api/config/dhcp?detailed=true",
})
.done(function (data) {
fillDHCPhosts(data.config.dhcp.hosts);
setConfigValues("dhcp", "dhcp", data.config.dhcp);
})
.fail(function (data) {
apiFailure(data);
});
}
$(document).ready(function () {
processDHCPConfig();
});

View File

@@ -0,0 +1,260 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2023 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.
* Precord see LICENSE file for your rights under this license. */
/* global utils: false, apiFailure:false, setConfigValues: false */
function hostsDomain(data) {
// Split record in format IP NAME1 [NAME2 [NAME3 [NAME...]]]
const name = data.substring(data.indexOf(" ") + 1);
return name;
}
function hostsIP(data) {
// Split record in format IP NAME1 [NAME2 [NAME3 [NAME...]]]
const ip = data.substring(0, data.indexOf(" "));
return ip;
}
function CNAMEdomain(data) {
// Split record in format <cname>,<target>[,<TTL>]
const CNAMEarr = data.split(",");
return CNAMEarr[0];
}
function CNAMEtarget(data) {
// Split record in format <cname>,<target>[,<TTL>]
const CNAMEarr = data.split(",");
return CNAMEarr[1];
}
function CNAMEttl(data) {
// Split record in format <cname>,<target>[,<TTL>]
const CNAMEarr = data.split(",");
return CNAMEarr.length > 2 ? CNAMEarr[2] : "-";
}
function populateDataTable(endpoint) {
var columns = "";
if (endpoint === "hosts") {
columns = [
{ data: null, render: hostsDomain },
{ data: null, type: "ip-address", render: hostsIP },
{ data: null, width: "22px", orderable: false },
];
} else {
columns = [
{ data: null, render: CNAMEdomain },
{ data: null, render: CNAMEtarget },
{ data: null, width: "40px", render: CNAMEttl },
{ data: null, width: "22px", orderable: false },
];
}
var setByEnv = false;
$.ajax({
url: `/api/config/dns/${endpoint}?detailed=true`,
}).done(function (data) {
// Set the title icons if needed
setConfigValues("dns", "dns", data.config.dns);
// disable input fields if set by env var
if (data.config.dns[endpoint].flags.env_var) {
$(`.${endpoint}`).prop("disabled", true);
}
});
$(`#${endpoint}-Table`).DataTable({
ajax: {
url: `/api/config/dns/${endpoint}`,
type: "GET",
dataSrc: `config.dns.${endpoint}`,
},
autoWidth: false,
columns: columns,
columnDefs: [
{
targets: "_all",
render: $.fn.dataTable.render.text(),
},
],
drawCallback: function () {
$(`button[id^="delete${endpoint}"]`).on("click", deleteRecord);
// Remove visible dropdown to prevent orphaning
$("body > .bootstrap-select.dropdown").remove();
},
rowCallback: function (row, data) {
$(row).attr("data-id", data);
var button = `<button type="button"
class="btn btn-danger btn-xs"
id="delete${endpoint}${utils.hexEncode(data)}"
data-tag="${data}"
data-type="${endpoint}"
${setByEnv ? "disabled" : ""}>
<span class="far fa-trash-alt"></span>
</button>`;
$(`td:eq(${endpoint === "hosts" ? 2 : 3})`, row).html(button);
},
dom:
"<'row'<'col-sm-5'l><'col-sm-7'f>>" +
"<'row'<'col-sm-12'p>>" +
"<'row'<'col-sm-12'<'table-responsive'tr>>>" +
"<'row'<'col-sm-12'p>>" +
"<'row'<'col-sm-12'i>>",
lengthMenu: [
[10, 25, 50, 100, -1],
[10, 25, 50, 100, "All"],
],
language: {
emptyTable: function () {
return endpoint === "hosts"
? "No local DNS records defined."
: "No local CNAME records defined.";
},
},
stateSave: true,
stateDuration: 0,
processing: true,
stateSaveCallback: function (settings, data) {
utils.stateSaveCallback(`${endpoint}-records-table`, data);
},
stateLoadCallback: function () {
var data = utils.stateLoadCallback(`${endpoint}-records-table`);
// Return if not available
if (data === null) {
return null;
}
// Apply loaded state to table
return data;
},
});
}
$(function () {
populateDataTable("hosts");
populateDataTable("cnameRecords");
});
function deleteRecord() {
if ($(this).attr("data-type") === "hosts") delHosts($(this).attr("data-tag"));
else delCNAME($(this).attr("data-tag"));
}
function delHosts(elem) {
utils.disableAll();
utils.showAlert("info", "", "Deleting DNS record...", elem);
const url = "/api/config/dns/hosts/" + encodeURIComponent(elem);
$.ajax({
url: url,
method: "DELETE",
})
.done(function () {
utils.enableAll();
utils.showAlert("success", "fas fa-trash-alt", "Successfully deleted DNS record", elem);
$("#hosts-Table").DataTable().ajax.reload(null, false);
})
.fail(function (data, exception) {
utils.enableAll();
apiFailure(data);
utils.showAlert(
"error",
"",
"Error while deleting DNS record: <code>" + elem + "</code>",
data.responseText
);
console.log(exception); // eslint-disable-line no-console
});
}
function delCNAME(elem) {
utils.disableAll();
utils.showAlert("info", "", "Deleting local CNAME record...", elem);
const url = "/api/config/dns/cnameRecords/" + encodeURIComponent(elem);
$.ajax({
url: url,
method: "DELETE",
})
.done(function () {
utils.enableAll();
utils.showAlert(
"success",
"fas fa-trash-alt",
"Successfully deleted local CNAME record",
elem
);
$("#cnameRecords-Table").DataTable().ajax.reload(null, false);
})
.fail(function (data, exception) {
utils.enableAll();
apiFailure(data);
utils.showAlert(
"error",
"",
"Error while deleting CNAME record: <code>" + elem + "</code>",
data.responseText
);
console.log(exception); // eslint-disable-line no-console
});
}
$(document).ready(function () {
$("#btnAdd-host").on("click", function () {
utils.disableAll();
const elem = $("#Hip").val() + " " + $("#Hdomain").val();
const url = "/api/config/dns/hosts/" + encodeURIComponent(elem);
utils.showAlert("info", "", "Adding DNS record...", elem);
$.ajax({
url: url,
method: "PUT",
})
.done(function () {
utils.enableAll();
utils.showAlert("success", "fas fa-plus", "Successfully added DNS record", elem);
$("#Hdomain").val("");
$("#Hip").val("");
$("#hosts-Table").DataTable().ajax.reload(null, false);
})
.fail(function (data, exception) {
utils.enableAll();
apiFailure(data);
utils.showAlert("error", "", "Error while deleting DNS record", data.responseText);
console.log(exception); // eslint-disable-line no-console
});
});
$("#btnAdd-cname").on("click", function () {
utils.disableAll();
var elem = $("#Cdomain").val() + "," + $("#Ctarget").val();
var ttlVal = parseInt($("#Cttl").val(), 10);
if (isFinite(ttlVal) && ttlVal >= 0) elem += "," + ttlVal;
const url = "/api/config/dns/cnameRecords/" + encodeURIComponent(elem);
utils.showAlert("info", "", "Adding DNS record...", elem);
$.ajax({
url: url,
method: "PUT",
})
.done(function () {
utils.enableAll();
utils.showAlert("success", "fas fa-plus", "Successfully added CNAME record", elem);
$("#Cdomain").val("");
$("#Ctarget").val("");
$("#cnameRecords-Table").DataTable().ajax.reload(null, false);
})
.fail(function (data, exception) {
utils.enableAll();
apiFailure(data);
utils.showAlert("error", "", "Error while deleting CNAME record", data.responseText);
console.log(exception); // eslint-disable-line no-console
});
});
// Add a small legend below the CNAME table
$("#cnameRecords-Table").after("<small>* <b>TTL</b> in seconds <i>(optional)</i></small>");
});

126
scripts/js/settings-dns.js Normal file
View File

@@ -0,0 +1,126 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2023 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 applyCheckboxRadioStyle:false, setConfigValues: false, apiFailure: false */
// Remove an element from an array (inline)
function removeFromArray(arr, what) {
var found = arr.indexOf(what);
while (found !== -1) {
arr.splice(found, 1);
found = arr.indexOf(what);
}
}
function fillDNSupstreams(value, servers) {
var disabledStr = "";
if (value.flags.env_var === true) {
$("#DNSupstreamsTextfield").prop("disabled", true);
disabledStr = 'disabled="Disabled"';
}
var i = 0;
var customServers = value.value.length;
servers.forEach(element => {
var row = "<tr>";
// Build checkboxes for IPv4 and IPv6
const addresses = [element.v4, element.v6];
// Loop over address types (IPv4, IPv6)
for (let v = 0; v < 2; v++) {
const address = addresses[v];
// Loop over available addresses (up to 2)
for (let index = 0; index < 2; index++) {
if (address.length > index) {
var checkedStr = "";
if (
value.value.includes(address[index]) ||
value.value.includes(address[index] + "#53")
) {
checkedStr = "checked";
customServers--;
}
row += `<td title="${address[index]}">
<div>
<input type="checkbox" id="DNSupstreams-${i}" ${disabledStr} ${checkedStr}>
<label for="DNSupstreams-${i++}"></label>
</div>
</td>`;
} else {
row += "<td></td>";
}
}
}
// Add server name
row += "<td>" + element.name + "</td>";
// Close table row
row += "</tr>";
// Add row to table
$("#DNSupstreamsTable").append(row);
});
// Add event listener to checkboxes
$("input[id^='DNSupstreams-']").on("change", function () {
var upstreams = $("#DNSupstreamsTextfield").val().split("\n");
var customServers = 0;
$("#DNSupstreamsTable input").each(function () {
var title = $(this).closest("td").attr("title");
if (this.checked && !upstreams.includes(title)) {
// Add server to array
upstreams.push(title);
} else if (!this.checked && upstreams.includes(title)) {
// Remove server from array
removeFromArray(upstreams, title);
}
if (upstreams.includes(title)) customServers--;
});
// The variable will contain a negative value, we need to add the length to
// get the correct number of custom servers
customServers += upstreams.length;
updateDNSserversTextfield(upstreams, customServers);
});
// Initialize textfield
updateDNSserversTextfield(value.value, customServers);
// Hide the loading animation
$("#dns-upstreams-overlay").hide();
// Apply styling to the new checkboxes
applyCheckboxRadioStyle();
}
// Update the textfield with all (incl. custom) upstream servers
function updateDNSserversTextfield(upstreams, customServers) {
$("#DNSupstreamsTextfield").val(upstreams.join("\n"));
$("#custom-servers-title").text(
"(" + customServers + " custom server" + (customServers === 1 ? "" : "s") + " enabled)"
);
}
function processDNSConfig() {
$.ajax({
url: "/api/config/dns?detailed=true", // We need the detailed output to get the DNS server list
})
.done(function (data) {
// Initialize the DNS upstreams
fillDNSupstreams(data.config.dns.upstreams, data.dns_servers);
setConfigValues("dns", "dns", data.config.dns);
})
.fail(function (data) {
apiFailure(data);
});
}
$(document).ready(function () {
processDNSConfig();
});

View File

@@ -0,0 +1,24 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2023 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 setConfigValues: false, apiFailure: false */
function getConfig() {
$.ajax({
url: "/api/config/?detailed=true",
})
.done(function (data) {
setConfigValues("", "", data.config);
})
.fail(function (data) {
apiFailure(data);
});
}
$(document).ready(function () {
getConfig();
});

View File

@@ -0,0 +1,384 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2023 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 apiFailure:false, Chart:false, THEME_COLORS:false, customTooltips:false, htmlLegendPlugin:false,doughnutTooltip:false, ChartDeferred:false, REFRESH_INTERVAL: false, utils: false */
var hostinfoTimer = null;
var cachePieChart = null;
var cacheSize = 0,
cacheEntries = 0;
// Register the ChartDeferred plugin to all charts:
Chart.register(ChartDeferred);
Chart.defaults.set("plugins.deferred", {
yOffset: "20%",
delay: 300,
});
function updateCachePie(data) {
var v = [],
c = [],
k = [],
i = 0;
// Compute total number of cache entries
cacheEntries = 0;
Object.keys(data).forEach(function (item) {
cacheEntries += data[item].valid;
cacheEntries += data[item].stale;
});
// Sort data by value, put OTHER always as last
var sorted = Object.keys(data).sort(function (a, b) {
if (a === "OTHER") {
return 1;
} else if (b === "OTHER") {
return -1;
} else {
return data[b].valid + data[b].stale - (data[a].valid + data[a].stale);
}
});
// Rebuild data object
var tmp = {};
sorted.forEach(function (item) {
tmp[item] = data[item];
});
data = tmp;
// Add empty space to chart
data.empty = {};
data.empty.valid = cacheSize - cacheEntries;
// Fill chart with data
Object.keys(data).forEach(function (item) {
if (data[item].valid > 0) {
v.push((100 * data[item].valid) / cacheSize);
c.push(item !== "empty" ? THEME_COLORS[i++ % THEME_COLORS.length] : "#80808040");
k.push(item);
}
if (data[item].stale > 0) {
// There are no stale empty entries
v.push((100 * data[item].stale) / cacheSize);
c.push(THEME_COLORS[i++ % THEME_COLORS.length]);
k.push(item + " (stale)");
}
});
// Build a single dataset with the data to be pushed
var dd = { data: v, backgroundColor: c };
// and push it at once
cachePieChart.data.datasets[0] = dd;
cachePieChart.data.labels = k;
$("#cache-pie-chart .overlay").hide();
// Passing 'none' will prevent rotation animation for further updates
//https://www.chartjs.org/docs/latest/developers/updates.html#preventing-animations
cachePieChart.update("none");
}
function updateHostInfo() {
$.ajax({
url: "/api/info/host",
})
.done(function (data) {
var host = data.host;
var uname = host.uname;
if (uname.domainname !== "(none)") {
$("#sysinfo-hostname").text(uname.nodename + "." + uname.domainname);
} else {
$("#sysinfo-hostname").text(uname.nodename);
}
$("#sysinfo-kernel").text(
uname.sysname +
" " +
uname.nodename +
" " +
uname.release +
" " +
uname.version +
" " +
uname.machine
);
clearTimeout(hostinfoTimer);
hostinfoTimer = utils.setTimer(updateHostInfo, REFRESH_INTERVAL.hosts);
})
.fail(function (data) {
apiFailure(data);
});
}
// Walk nested objects, create a dash-separated global key and assign the value
// to the corresponding element (add percentage for DNS replies)
function setMetrics(data, prefix) {
var cacheData = {};
for (const [key, val] of Object.entries(data)) {
if (prefix === "sysinfo-dns-cache-content-") {
// Create table row for each DNS cache entry
// (if table exists)
if ($("#dns-cache-table").length > 0) {
const name =
val.name !== "OTHER"
? "Valid " + (val.name !== null ? val.name : "TYPE " + val.type)
: "Other valid";
const tr = "<tr><th>" + name + " records in cache:</th><td>" + val.count + "</td></tr>";
// Append row to DNS cache table
$("#dns-cache-table").append(tr);
}
cacheData[val.name] = val.count;
} else if (typeof val === "object") {
setMetrics(val, prefix + key + "-");
} else if (prefix === "sysinfo-dns-replies-") {
// Compute and display percentage of DNS replies in addition to the absolute value
const lval = val.toLocaleString();
const percent = (100 * val) / data.sum;
$("#" + prefix + key).text(lval + " (" + percent.toFixed(1) + "%)");
} else {
const lval = val.toLocaleString();
$("#" + prefix + key).text(lval);
}
}
// Draw pie chart if data is available
if (Object.keys(cacheData).length > 0) {
updateCachePie(cacheData);
}
}
var metricsTimer = null;
function updateMetrics() {
$.ajax({
url: "/api/info/metrics",
})
.done(function (data) {
var metrics = data.metrics;
$("#dns-cache-table").empty();
// Set global cache size
cacheSize = metrics.dns.cache.size;
// Set metrics
setMetrics(metrics, "sysinfo-");
$("#cache-utilization").text(
cacheEntries.toLocaleString() + " (" + ((100 * cacheEntries) / cacheSize).toFixed(1) + "%)"
);
$("div[id^='sysinfo-metrics-overlay']").hide();
clearTimeout(metricsTimer);
metricsTimer = utils.setTimer(updateMetrics, REFRESH_INTERVAL.metrics);
})
.fail(function (data) {
apiFailure(data);
});
}
function showQueryLoggingButton(state) {
if (state) {
$("#loggingButton").addClass("btn-warning");
$("#loggingButton").removeClass("btn-success");
$("#loggingButton").text("Disable query logging");
$("#loggingButton").data("state", "enabled");
} else {
$("#loggingButton").addClass("btn-success");
$("#loggingButton").removeClass("btn-warning");
$("#loggingButton").text("Enable query logging");
$("#loggingButton").data("state", "disabled");
}
}
function getLoggingButton() {
$.ajax({
url: "/api/config/dns/queryLogging",
})
.done(function (data) {
showQueryLoggingButton(data.config.dns.queryLogging);
})
.fail(function (data) {
apiFailure(data);
});
}
$(".confirm-restartdns").confirm({
text:
"Are you sure you want to send a restart command to your DNS server?<br><br>" +
"This will clear the DNS cache and may temporarily interrupt your internet connection.<br>" +
"Furthermore, you will be logged out of the web interface as consequence of this action.",
title: "Confirmation required",
confirm: function () {
$.ajax({
url: "/api/action/restartdns",
type: "POST",
}).fail(function (data) {
apiFailure(data);
});
},
cancel: function () {
// nothing to do
},
confirmButton: "Yes, restart DNS server",
cancelButton: "No, go back",
post: true,
confirmButtonClass: "btn-danger",
cancelButtonClass: "btn-default",
dialogClass: "modal-dialog",
});
$(".confirm-flushlogs").confirm({
text:
"Are you sure you want to flush your logs?<br><br>" +
"<strong>This will clear all logs and cannot be undone.</strong>",
title: "Confirmation required",
confirm: function () {
$.ajax({
url: "/api/action/flush/logs",
type: "POST",
}).fail(function (data) {
apiFailure(data);
});
},
cancel: function () {
// nothing to do
},
confirmButton: "Yes, flush logs",
cancelButton: "No, go back",
post: true,
confirmButtonClass: "btn-danger",
cancelButtonClass: "btn-default",
dialogClass: "modal-dialog",
});
$(".confirm-flusharp").confirm({
text:
"Are you sure you want to flush your network table?<br><br>" +
"<strong>This will clear all entries and cannot be undone.</strong>",
title: "Confirmation required",
confirm: function () {
$.ajax({
url: "/api/action/flush/arp",
type: "POST",
}).fail(function (data) {
apiFailure(data);
});
},
cancel: function () {
// nothing to do
},
confirmButton: "Yes, flush my network table",
cancelButton: "No, go back",
post: true,
confirmButtonClass: "btn-danger",
cancelButtonClass: "btn-default",
dialogClass: "modal-dialog",
});
$("#loggingButton").confirm({
text:
"Are you sure you want to switch query logging mode?<br><br>" +
"<strong>This will restart the DNS server.</strong><br>" +
"As consequence of this action, your DNS cache will be cleared and you may temporarily loose your internet connection.<br>" +
"Furthermore, you will be logged out of the web interface.",
title: "Confirmation required",
confirm: function () {
const data = {};
data.config = {};
data.config.dns = {};
data.config.dns.queryLogging = $("#loggingButton").data("state") !== "enabled";
$.ajax({
url: "/api/config/dns/queryLogging",
type: "PATCH",
dataType: "json",
processData: false,
contentType: "application/json; charset=utf-8",
data: JSON.stringify(data),
})
.done(function (data) {
showQueryLoggingButton(data.config.dns.queryLogging);
})
.fail(function (data) {
apiFailure(data);
});
},
cancel: function () {
// nothing to do
},
confirmButton: "Yes, change query logging",
cancelButton: "No, go back",
post: true,
confirmButtonClass: "btn-danger",
cancelButtonClass: "btn-default",
dialogClass: "modal-dialog",
});
$(function () {
updateHostInfo();
updateMetrics();
getLoggingButton();
var ctx = document.getElementById("cachePieChart").getContext("2d");
cachePieChart = 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: "cache-legend",
},
legend: {
display: false,
},
tooltip: {
// Disable the on-canvas tooltip
enabled: false,
external: customTooltips,
callbacks: {
title: function () {
return "Cache content";
},
label: doughnutTooltip,
},
},
},
animation: {
duration: 750,
},
},
});
$.ajax({
url: "/api/network/gateway",
})
.done(function (data) {
const gateway = data.gateway;
// Get first object in gateway that has family == "inet"
const inet = gateway.find(obj => obj.family === "inet");
// Get first object in gateway that has family == "inet6"
const inet6 = gateway.find(obj => obj.family === "inet6");
$("#sysinfo-gw-v4-addr").text(inet ? inet.local.join("\n") : "N/A");
$("#sysinfo-gw-v4-iface").text(inet ? inet.interface : "N/A");
$("#sysinfo-gw-v6-addr").text(inet6 ? inet6.local.join("\n") : "N/A");
$("#sysinfo-gw-v6-iface").text(inet6 ? inet6.interface : "N/A");
})
.fail(function (data) {
apiFailure(data);
});
});

View File

@@ -0,0 +1,107 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2023 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 */
// Add event listener to import button
document.getElementById("submit-import").addEventListener("click", function () {
importZIP();
});
// Upload file to Pi-hole
function importZIP() {
var file = document.getElementById("file").files[0];
if (file === undefined) {
alert("Please select a file to import.");
return;
}
// https://caniuse.com/fetch - everything except IE
// This is fine, as we dropped support for IE a while ago
if (typeof fetch !== "function") {
alert("Importing Tricorder files is not supported with this browser!");
return;
}
// Get the selected import options
const imports = {},
gravity = {};
imports.config = document.getElementById("import.config").checked;
imports.dhcp_leases = document.getElementById("import.dhcp_leases").checked;
gravity.group = document.getElementById("import.gravity.group").checked;
gravity.adlist = document.getElementById("import.gravity.adlist").checked;
gravity.adlist_by_group = document.getElementById("import.gravity.adlist").checked;
gravity.domainlist = document.getElementById("import.gravity.domainlist").checked;
gravity.domainlist_by_group = document.getElementById("import.gravity.domainlist").checked;
gravity.client = document.getElementById("import.gravity.client").checked;
gravity.client_by_group = document.getElementById("import.gravity.client").checked;
imports.gravity = gravity;
const formData = new FormData();
formData.append("import", JSON.stringify(imports));
formData.append("file", file);
// eslint-disable-next-line compat/compat
fetch("/api/teleporter", {
method: "POST",
body: formData,
headers: { "X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr("content") },
})
.then(response => response.json())
.then(data => {
$("#import-spinner").hide();
$("#modal-import-success").hide();
$("#modal-import-error").hide();
$("#modal-import-info").hide();
if ("error" in data) {
$("#modal-import-error").show();
$("#modal-import-error-title").text("Error: " + data.error.message);
if (data.error.hint !== null) $("#modal-import-error-message").text(data.error.hint);
} else if ("files" in data) {
$("#modal-import-success").show();
$("#modal-import-success-title").text("Import successful");
var text = "<p>Processed files:</p><ul>";
for (var i = 0; i < data.files.length; i++) {
text += "<li>" + utils.escapeHtml(data.files[i]) + "</li>";
}
text += "</ul>";
$("#modal-import-success-message").html(text);
$("#modal-import-gravity").show();
}
$("#modal-import").modal("show");
})
.catch(error => {
alert("An unexpected error occurred.");
console.error(error); // eslint-disable-line no-console
});
}
// Inspired by https://stackoverflow.com/a/59576416/2087442
$("#GETTeleporter").on("click", function () {
$.ajax({
url: "/api/teleporter",
headers: { "X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr("content") },
method: "GET",
xhrFields: {
responseType: "blob",
},
success: function (data, status, xhr) {
var a = document.createElement("a");
// eslint-disable-next-line compat/compat
var url = window.URL.createObjectURL(data);
a.href = url;
a.download = xhr.getResponseHeader("Content-Disposition").match(/filename="([^"]*)"/)[1];
document.body.append(a);
a.click();
a.remove();
// eslint-disable-next-line compat/compat
window.URL.revokeObjectURL(url);
},
});
});

192
scripts/js/settings.js Normal file
View File

@@ -0,0 +1,192 @@
/* 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, apiFailure:false*/
$(function () {
// Handle hiding of alerts
$("[data-hide]").on("click", function () {
$(this)
.closest("." + $(this).attr("data-hide"))
.hide();
});
// Handle saving of settings
$(".save-button").on("click", function () {
saveSettings();
});
});
// Globally available function to set config values
// eslint-disable-next-line no-unused-vars
function setConfigValues(topic, key, value) {
// If the value is an object, we need to recurse
if (!("description" in value)) {
Object.keys(value).forEach(function (subkey) {
var subvalue = value[subkey];
// If the key is empty, we are at the top level
var newKey = key === "" ? subkey : key + "." + subkey;
setConfigValues(topic, newKey, subvalue);
});
return;
}
// else: we have a setting we can set
var escapedKey = key.replaceAll(".", "\\.");
var envTitle = $(`[data-configkeys~='${key}']`);
if (
envTitle.parents().parents().hasClass("settings-level-expert") &&
envTitle.find(".expert-warning").length === 0
) {
envTitle.append(
`<span class="expert-warning">&nbsp;&nbsp;<i class="fas fa-wrench" title="Expert level setting"></i></span>`
);
}
if (value.flags.restart_dnsmasq && envTitle.find(".restart-warning").length === 0) {
envTitle.append(
`<span class="restart-warning">&nbsp;&nbsp;<i class="fas fa-redo text-orange" title="Setting requires FTL restart on change"></i></span>`
);
}
if (value.flags.env_var) {
// If this setting has been set by environment variable, display a padlock in the section title
if (envTitle.find(".env-warning").length === 0) {
envTitle.append(
`<span class="env-warning">&nbsp;&nbsp;<i class="fas fa-lock text-orange env-warning" title="Settings overwritten by an environmental variable are read-only"></i></span>`
);
}
$(`#${escapedKey}`).prop("disabled", "disabled");
}
switch (value.type) {
case "enum (unsigned integer)": // fallthrough
case "enum (string)": {
// Remove all options from select
$("#" + escapedKey + " option").remove();
// Add allowed select items (if available)
value.allowed.forEach(function (allowedValue) {
$("#" + escapedKey + "-" + allowedValue.item).prop("disabled", value.flags.env_var);
var newopt = $("<option></option>")
.attr("value", allowedValue.item)
.text(allowedValue.description);
$("#" + escapedKey).append(newopt);
});
// Select the current value
$("#" + escapedKey)
.val(value.value)
.trigger("click");
// Also select matching radio button (if any)
$("#" + escapedKey + "-" + value.value).prop("checked", true);
break;
}
case "boolean": {
// Select checkboxes (if available)
$("#" + escapedKey).prop("checked", value.value);
break;
}
case "string array": {
// Set input field values from array (if available)
$("#" + escapedKey).val(value.value.join("\n"));
break;
}
default: {
// Set input field values (if available)
// Set text if this is a <span> or <code> element
if ($("#" + escapedKey).is("span") || $("#" + escapedKey).is("code")) {
$("#" + escapedKey).text(value.value);
} else {
// Set value if this is an <input> element
$("#" + escapedKey).val(value.value);
}
}
}
}
function saveSettings() {
var settings = {};
utils.disableAll();
$("[data-key]").each(function () {
var key = $(this).data("key");
var value = $(this).val();
// If this is a checkbox, use the checked state
if ($(this).is(":checkbox")) {
value = $(this).is(":checked");
}
// If this is a radio button, skip all but the checked one
if ($(this).is(":radio") && !$(this).is(":checked")) return;
// If this is a string array, split the value into an array
if ($(this).is("textarea")) {
value = $(this).val();
value = value === "" ? [] : value.split("\n");
}
// If this is an integer number, parse it accordingly
if ($(this).data("type") === "integer") {
value = parseInt(value, 10);
}
// If this is a floating point value, parse it accordingly
if ($(this).data("type") === "float") {
value = parseFloat(value);
}
// Build deep object
// Transform "foo.bar.baz" into {foo: {bar: {baz: value}}}
var parts = key.split(".");
var obj = {};
var tmp = obj;
for (var i = 0; i < parts.length - 1; i++) {
tmp[parts[i]] = {};
tmp = tmp[parts[i]];
}
tmp[parts.at(-1)] = value;
// Merge deep object into settings
$.extend(true, settings, obj);
});
// Apply changes
$.ajax({
url: "/api/config",
method: "PATCH",
dataType: "json",
processData: false,
data: JSON.stringify({ config: settings }),
contentType: "application/json; charset=utf-8",
})
.done(function () {
utils.enableAll();
// Success
utils.showAlert(
"success",
"fa-solid fa-fw fa-floppy-disk",
"Successfully saved and applied settings",
""
);
// Show loading overlay
utils.loadingOverlay(true);
})
.fail(function (data, exception) {
utils.enableAll();
utils.showAlert("error", "", "Error while applying settings", data.responseText);
console.log(exception); // eslint-disable-line no-console
apiFailure(data);
});
}

225
scripts/js/taillog.js Normal file
View File

@@ -0,0 +1,225 @@
/* 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 moment: false, apiFailure: false, utils: false, REFRESH_INTERVAL: false */
var nextID = 0;
var lastPID = -1;
// Maximum number of lines to display
const maxlines = 5000;
// Fade in new lines
const fadeIn = true;
// Mark new lines with a red line above them
const markUpdates = true;
// Format a line of the dnsmasq log
function formatDnsmasq(line) {
// Remove dnsmasq + PID
let txt = line.replaceAll(/ dnsmasq\[\d*]/g, "");
if (line.includes("denied") || line.includes("gravity blocked")) {
// Red bold text for blocked domains
txt = `<b class="log-red">${txt}</b>`;
} else if (line.includes("query[A") || line.includes("query[DHCP")) {
// Bold text for initial query lines
txt = `<b>${txt}</b>`;
} else {
// Grey text for all other lines
txt = `<span class="text-muted">${txt}</span>`;
}
return txt;
}
function formatFTL(line, prio) {
// Colorize priority
let prioClass = "";
switch (prio) {
case "INFO": {
prioClass = "text-success";
break;
}
case "WARNING": {
prioClass = "text-warning";
break;
}
case "ERR":
case "ERROR":
case "EMERG":
case "ALERT":
case "CRIT": {
prioClass = "text-danger";
break;
}
default:
prioClass = prio.startsWith("DEBUG") ? "text-info" : "text-muted";
}
// Return formatted line
return `<span class="${prioClass}">${utils.escapeHtml(prio)}</span> ${line}`;
}
// Function that asks the API for new data
function getData() {
// Only update when spinner is spinning
if (!$("#feed-icon").hasClass("fa-play")) {
utils.setTimer(getData, REFRESH_INTERVAL.logs);
return;
}
var GETDict = utils.parseQueryString();
if (!("file" in GETDict)) {
window.location.href += "?file=dnsmasq";
return;
}
$.ajax({
url: "/api/logs/" + GETDict.file + "?nextID=" + nextID,
timeout: 5000,
method: "GET",
})
.done(function (data) {
// Check if we have a new PID -> FTL was restarted
if (lastPID !== data.pid) {
if (lastPID !== -1) {
$("#output").append("<div><i class='text-danger'>*** FTL restarted ***</i></div>");
}
// Remember PID
lastPID = data.pid;
// Reset nextID
nextID = 0;
getData();
return;
}
// Set placeholder text if log file is empty and we have no new lines
if (data.log.length === 0) {
if (nextID === 0) {
$("#output").html("<div><i>*** Log file is empty ***</i></div>");
}
utils.setTimer(getData, REFRESH_INTERVAL.logs);
return;
}
// We have new lines
if (markUpdates && nextID > 0) {
// Add red fading out background to new lines
$("#output").append('<hr class="hr-small">').children(":last").fadeOut(2000);
}
data.log.forEach(function (line) {
// Escape HTML
line.message = utils.escapeHtml(line.message);
// Format line if applicable
if (GETDict.file === "dnsmasq") line.message = formatDnsmasq(line.message);
else if (GETDict.file === "ftl") line.message = formatFTL(line.message, line.prio);
// Add new line to output
$("#output").append(
'<div class="log-entry"><span class="text-muted">' +
moment(1000 * line.timestamp).format("YYYY-MM-DD HH:mm:ss.SSS") +
"</span> " +
line.message +
"</div>"
);
if (fadeIn) {
//$(".left-line:last").fadeOut(2000);
$("#output").children(":last").hide().fadeIn("fast");
}
});
// Limit output to <maxlines> lines
var lines = $("#output").val().split("\n");
if (lines.length > maxlines) {
lines.splice(0, lines.length - maxlines);
$("#output").val(lines.join("\n"));
}
// Scroll to bottom of output if we are already at the bottom
if (gAutoScrolling) {
// Auto-scrolling is enabled
$("#output").scrollTop($("#output")[0].scrollHeight);
}
// Update nextID
nextID = data.nextID;
// Set filename
$("#filename").text(data.file);
utils.setTimer(getData, REFRESH_INTERVAL.logs);
})
.fail(function (data) {
apiFailure(data);
utils.setTimer(getData, 5 * REFRESH_INTERVAL.logs);
});
}
var gAutoScrolling = true;
$("#output").on("scroll", function () {
// Check if we are at the bottom of the output
//
// - $("#output")[0].scrollHeight: This gets the entire height of the content
// of the "output" element, including the part that is not visible due to
// scrolling.
// - $("#output").innerHeight(): This gets the inner height of the "output"
// element, which is the visible part of the content.
// - $("#output").scrollTop(): This gets the number of pixels that the content
// of the "output" element is scrolled vertically from the top.
//
// By subtracting the inner height and the scroll top from the scroll height,
// you get the distance from the bottom of the scrollable area.
const bottom =
$("#output")[0].scrollHeight - $("#output").innerHeight() - $("#output").scrollTop();
// Add a tolerance of four line heights
const tolerance = 4 * parseFloat($("#output").css("line-height"));
if (bottom <= tolerance) {
// Auto-scrolling is enabled
gAutoScrolling = true;
$("#autoscrolling").addClass("fa-check");
$("#autoscrolling").removeClass("fa-xmark");
} else {
// Auto-scrolling is disabled
gAutoScrolling = false;
$("#autoscrolling").addClass("fa-xmark");
$("#autoscrolling").removeClass("fa-check");
}
});
$(function () {
getData();
// Clicking on the element with class "fa-spinner" will toggle the play/pause state
$("#live-feed").on("click", function () {
if ($("#feed-icon").hasClass("fa-play")) {
// Toggle button color
$("#feed-icon").addClass("fa-pause");
$("#feed-icon").removeClass("fa-fade");
$("#feed-icon").removeClass("fa-play");
$(this).addClass("btn-danger");
$(this).removeClass("btn-success");
$("#title").text("Paused");
} else {
// Toggle button color
$("#feed-icon").addClass("fa-play");
$("#feed-icon").addClass("fa-fade");
$("#feed-icon").removeClass("fa-pause");
$(this).addClass("btn-success");
$(this).removeClass("btn-danger");
$("#title").text("Live");
}
});
});

759
scripts/js/utils.js Normal file
View File

@@ -0,0 +1,759 @@
/* Pi-hole: A black hole for Internet advertisements
* (c) 2020 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 moment:false, apiFailure: false, updateFtlInfo: false, NProgress:false */
$(function () {
// CSRF protection for AJAX requests, this has to be configured globally
// because we are using the jQuery $.ajax() function directly in some cases
// Furthermore, has this to be done before any AJAX request is made so that
// the CSRF token is sent along with each request to the API
$.ajaxSetup({
headers: { "X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr("content") },
});
});
// Credit: https://stackoverflow.com/a/4835406
function escapeHtml(text) {
var map = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
};
// Return early when text is not a string
if (typeof text !== "string") return text;
return text.replaceAll(/[&<>"']/g, function (m) {
return map[m];
});
}
function unescapeHtml(text) {
var map = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#039;": "'",
"&Uuml;": "Ü",
"&uuml;": "ü",
"&Auml;": "Ä",
"&auml;": "ä",
"&Ouml;": "Ö",
"&ouml;": "ö",
"&szlig;": "ß",
};
if (text === null) return null;
return text.replaceAll(
/&(?:amp|lt|gt|quot|#039|Uuml|uuml|Auml|auml|Ouml|ouml|szlig);/g,
function (m) {
return map[m];
}
);
}
// Helper function for converting Objects to Arrays after sorting the keys
function objectToArray(obj) {
var arr = [];
var idx = [];
var keys = Object.keys(obj);
keys.sort(function (a, b) {
return a - b;
});
for (var i = 0; i < keys.length; i++) {
arr.push(obj[keys[i]]);
idx.push(keys[i]);
}
return [idx, arr];
}
function padNumber(num) {
return ("00" + num).substr(-2, 2);
}
var showAlertBox = null;
function showAlert(type, icon, title, message, toast) {
const options = {
title: "&nbsp;<strong>" + escapeHtml(title) + "</strong><br>",
message: escapeHtml(message),
icon: icon,
},
settings = {
type: type,
delay: 5000, // default value
mouse_over: "pause",
animate: {
enter: "animate__animated animate__fadeInDown",
exit: "animate__animated animate__fadeOutUp",
},
};
switch (type) {
case "info":
options.icon = icon !== null && icon.len > 0 ? icon : "fas fa-clock";
break;
case "success":
break;
case "warning":
options.icon = "fas fa-exclamation-triangle";
settings.delay *= 2;
break;
case "error":
options.icon = "fas fa-times";
if (title.length === 0)
options.title = "&nbsp;<strong>Error, something went wrong!</strong><br>";
settings.delay *= 2;
// If the message is an API object, nicely format the error message
// Try to parse message as JSON
try {
var data = JSON.parse(message);
console.log(data); // eslint-disable-line no-console
if (data.error !== undefined) {
options.title = "&nbsp;<strong>" + escapeHtml(data.error.message) + "</strong><br>";
if (data.error.hint !== null) options.message = escapeHtml(data.error.hint);
}
} catch {
// Do nothing
}
break;
default:
// Case not handled, do nothing
console.log("Unknown alert type: " + type); // eslint-disable-line no-console
return;
}
if (toast === undefined) {
if (type === "info") {
// Create a new notification for info boxes
showAlertBox = $.notify(options, settings);
return showAlertBox;
} else if (showAlertBox !== null) {
// Update existing notification for other boxes (if available)
showAlertBox.update(options);
showAlertBox.update(settings);
return showAlertBox;
} else {
// Create a new notification for other boxes if no previous info box exists
return $.notify(options, settings);
}
} else if (toast === null) {
// Always create a new toast
return $.notify(options, settings);
} else {
// Update existing toast
toast.update(options);
toast.update(settings);
return toast;
}
}
function datetime(date, html, humanReadable) {
if (date === 0 && humanReadable) {
return "Never";
}
var format = html === false ? "Y-MM-DD HH:mm:ss z" : "Y-MM-DD [<br class='hidden-lg'>]HH:mm:ss z";
var timestr = moment.unix(Math.floor(date)).format(format).trim();
return humanReadable
? '<span title="' + timestr + '">' + moment.unix(Math.floor(date)).fromNow() + "</span>"
: timestr;
}
function datetimeRelative(date) {
return moment.unix(Math.floor(date)).fromNow();
}
function disableAll() {
$("input").prop("disabled", true);
$("select").prop("disabled", true);
$("button").prop("disabled", true);
$("textarea").prop("disabled", true);
}
function enableAll() {
$("input").prop("disabled", false);
$("select").prop("disabled", false);
$("button").prop("disabled", false);
$("textarea").prop("disabled", false);
// Enable custom input field only if applicable
var ip = $("#select") ? $("#select").val() : null;
if (ip !== null && ip !== "custom") {
$("#ip-custom").prop("disabled", true);
}
}
// Pi-hole IPv4/CIDR validator by DL6ER, see regexr.com/50csh
function validateIPv4CIDR(ip) {
// One IPv4 element is 8bit: 0 - 256
var ipv4elem = "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]?|0)";
// CIDR for IPv4 is 1 - 32 bit
var v4cidr = "(\\/([1-9]|[1-2][0-9]|3[0-2])){0,1}";
var ipv4validator = new RegExp(
"^" + ipv4elem + "\\." + ipv4elem + "\\." + ipv4elem + "\\." + ipv4elem + v4cidr + "$"
);
return ipv4validator.test(ip);
}
// Pi-hole IPv6/CIDR validator by DL6ER, see regexr.com/50csn
function validateIPv6CIDR(ip) {
// One IPv6 element is 16bit: 0000 - FFFF
var ipv6elem = "[0-9A-Fa-f]{1,4}";
// CIDR for IPv6 is 1- 128 bit
var v6cidr = "(\\/([1-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])){0,1}";
var ipv6validator = new RegExp(
"^(((?:" +
ipv6elem +
"))*((?::" +
ipv6elem +
"))*::((?:" +
ipv6elem +
"))*((?::" +
ipv6elem +
"))*|((?:" +
ipv6elem +
"))((?::" +
ipv6elem +
")){7})" +
v6cidr +
"$"
);
return ipv6validator.test(ip);
}
function validateMAC(mac) {
var macvalidator = /^([\da-fA-F]{2}:){5}([\da-fA-F]{2})$/;
return macvalidator.test(mac);
}
function validateHostname(name) {
var namevalidator = /[^<>;"]/;
return namevalidator.test(name);
}
// set bootstrap-select defaults
function setBsSelectDefaults() {
var bsSelectDefaults = $.fn.selectpicker.Constructor.DEFAULTS;
bsSelectDefaults.noneSelectedText = "none selected";
bsSelectDefaults.selectedTextFormat = "count > 1";
bsSelectDefaults.actionsBox = true;
bsSelectDefaults.width = "fit";
bsSelectDefaults.container = "body";
bsSelectDefaults.dropdownAlignRight = "auto";
bsSelectDefaults.selectAllText = "All";
bsSelectDefaults.deselectAllText = "None";
bsSelectDefaults.countSelectedText = function (num, total) {
if (num === total) {
return "All selected (" + num + ")";
}
return num + " selected";
};
}
var backupStorage = {};
function stateSaveCallback(itemName, data) {
if (localStorage === null) {
backupStorage[itemName] = JSON.stringify(data);
} else {
localStorage.setItem(itemName, JSON.stringify(data));
}
}
function stateLoadCallback(itemName) {
var data;
// Receive previous state from client's local storage area
if (localStorage === null) {
var item = backupStorage[itemName];
data = item === "undefined" ? null : item;
} else {
data = localStorage.getItem(itemName);
}
// Return if not available
if (data === null) {
return null;
}
// Parse JSON string
data = JSON.parse(data);
// Clear possible filtering settings
data.columns.forEach(function (value, index) {
data.columns[index].search.search = "";
});
// Always start on the first page to show most recent queries
data.start = 0;
// Always start with empty search field
data.search.search = "";
// Apply loaded state to table
return data;
}
function addFromQueryLog(domain, list) {
var alertModal = $("#alertModal");
var alProcessing = alertModal.find(".alProcessing");
var alSuccess = alertModal.find(".alSuccess");
var alFailure = alertModal.find(".alFailure");
var alNetworkErr = alertModal.find(".alFailure #alNetErr");
var alCustomErr = alertModal.find(".alFailure #alCustomErr");
var alList = "#alList";
var alDomain = "#alDomain";
// Exit the function here if the Modal is already shown (multiple running interlock)
if (alertModal.css("display") !== "none") {
return;
}
var listtype = list === "allow" ? "Allowlist" : "Denylist";
alProcessing.children(alDomain).text(domain);
alProcessing.children(alList).text(listtype);
alertModal.modal("show");
// add Domain to List after Modal has faded in
alertModal.one("shown.bs.modal", function () {
$.ajax({
url: "/api/domains/" + list + "/exact",
method: "post",
dataType: "json",
processData: false,
contentType: "application/json; charset=utf-8",
data: JSON.stringify({
domain: domain,
comment: "Added from Query Log",
type: list,
kind: "exact",
}),
success: function (response) {
alProcessing.hide();
if ("domains" in response && response.domains.length > 0) {
// Success
alSuccess.children(alDomain).text(domain);
alSuccess.children(alList).text(listtype);
alSuccess.fadeIn(1000);
// Update domains counter in the menu
updateFtlInfo();
setTimeout(function () {
alertModal.modal("hide");
}, 2000);
} else {
// Failure
alNetworkErr.hide();
alCustomErr.html(response.message);
alFailure.fadeIn(1000);
setTimeout(function () {
alertModal.modal("hide");
}, 10000);
}
},
error: function () {
// Network Error
alProcessing.hide();
alNetworkErr.show();
alFailure.fadeIn(1000);
setTimeout(function () {
alertModal.modal("hide");
}, 8000);
},
});
});
// Reset Modal after it has faded out
alertModal.one("hidden.bs.modal", function () {
alProcessing.show();
alSuccess.add(alFailure).hide();
alProcessing.add(alSuccess).children(alDomain).html("").end().children(alList).html("");
alCustomErr.html("");
});
}
// Helper functions to format the progress bars used on the Dashboard and Long-term Lists
function addTD(content) {
return "<td>" + content + "</td> ";
}
function toPercent(number, fractionDigits = 0) {
const userLocale = navigator.language || "en-US";
return new Intl.NumberFormat(userLocale, {
style: "percent",
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}).format(number / 100);
}
function colorBar(percentage, total, cssClass) {
const formattedPercentage = toPercent(percentage, 1);
const title = `${formattedPercentage} of ${total}`;
const bar = `<div class="progress-bar ${cssClass}" style="width: ${percentage}%"></div>`;
return `<div class="progress progress-sm" title="${title}"> ${bar} </div>`;
}
function checkMessages() {
var ignoreNonfatal = localStorage
? localStorage.getItem("hideNonfatalDnsmasqWarnings_chkbox") === "true"
: false;
$.ajax({
url: "/api/info/messages/count" + (ignoreNonfatal ? "?filter_dnsmasq_warnings=true" : ""),
method: "GET",
dataType: "json",
})
.done(function (data) {
if (data.count > 0) {
var more = '\nAccess "Tools/Pi-hole diagnosis" for further details.';
var title =
data.count > 1
? "There are " + data.count + " warnings." + more
: "There is one warning." + more;
$(".warning-count").prop("title", title);
$(".warning-count").text(data.count);
$(".warning-count").removeClass("hidden");
} else {
$(".warning-count").addClass("hidden");
}
})
.fail(function (data) {
$(".warning-count").addClass("hidden");
apiFailure(data);
});
}
// Show only the appropriate delete buttons in datatables
function changeBulkDeleteStates(table) {
var allRows = table.rows({ filter: "applied" }).data().length;
var pageLength = table.page.len();
var selectedRows = table.rows(".selected").data().length;
if (selectedRows === 0) {
// Nothing selected
$(".selectAll").removeClass("hidden");
$(".selectMore").addClass("hidden");
$(".removeAll").addClass("hidden");
$(".deleteSelected").addClass("hidden");
} else if (selectedRows >= pageLength || selectedRows === allRows) {
// Whole page is selected (or all available messages were selected)
$(".selectAll").addClass("hidden");
$(".selectMore").addClass("hidden");
$(".removeAll").removeClass("hidden");
$(".deleteSelected").removeClass("hidden");
} else {
// Some rows are selected, but not all
$(".selectAll").addClass("hidden");
$(".selectMore").removeClass("hidden");
$(".removeAll").addClass("hidden");
$(".deleteSelected").removeClass("hidden");
}
}
function doLogout() {
$.ajax({
url: "/api/auth",
method: "DELETE",
}).always(function () {
location.reload();
});
}
function renderTimestamp(data, type) {
// Display and search content
if (type === "display" || type === "filter") {
return datetime(data, false, false);
}
// Sorting content
return data;
}
function renderTimespan(data, type) {
// Display and search content
if (type === "display" || type === "filter") {
return datetime(data, false, true);
}
// Sorting content
return data;
}
function htmlPass(data, _type) {
return data;
}
// Show only the appropriate buttons
function changeTableButtonStates(table) {
var allRows = table.rows({ filter: "applied" }).data().length;
var pageLength = table.page.len();
var selectedRows = table.rows(".selected").data().length;
if (selectedRows === 0) {
// Nothing selected
$(".selectAll").removeClass("hidden");
$(".selectMore").addClass("hidden");
$(".removeAll").addClass("hidden");
$(".deleteSelected").addClass("hidden");
} else if (selectedRows >= pageLength || selectedRows === allRows) {
// Whole page is selected (or all available messages were selected)
$(".selectAll").addClass("hidden");
$(".selectMore").addClass("hidden");
$(".removeAll").removeClass("hidden");
$(".deleteSelected").removeClass("hidden");
} else {
// Some rows are selected, but not all
$(".selectAll").addClass("hidden");
$(".selectMore").removeClass("hidden");
$(".removeAll").addClass("hidden");
$(".deleteSelected").removeClass("hidden");
}
}
function getCSSval(cssclass, cssproperty) {
var elem = $("<div class='" + cssclass + "'></div>"),
val = elem.appendTo("body").css(cssproperty);
elem.remove();
return val;
}
function parseQueryString(queryString = window.location.search) {
const GETDict = {};
queryString
.substr(1)
.split("&")
.forEach(function (item) {
GETDict[item.split("=")[0]] = decodeURIComponent(item.split("=")[1]);
});
return GETDict;
}
// https://stackoverflow.com/q/21647928
function hexEncode(string) {
var hex, i;
var result = "";
for (i = 0; i < string.length; i++) {
hex = string.codePointAt(i).toString(16);
result += ("000" + hex).slice(-4);
}
return result;
}
// https://stackoverflow.com/q/21647928
function hexDecode(string) {
var j;
var hexes = string.match(/.{1,4}/g) || [];
var back = "";
for (j = 0; j < hexes.length; j++) {
back += String.fromCodePoint(parseInt(hexes[j], 16));
}
return back;
}
function listAlert(type, items, data) {
// Show simple success message if there is no "processed" object in "data" or
// if all items were processed successfully
if (data.processed === undefined || data.processed.success.length === items.length) {
showAlert(
"success",
"fas fa-plus",
"Successfully added " + type + (items.length !== 1 ? "s" : ""),
items.join(", ")
);
return;
}
// Show a more detailed message if there is a "processed" object in "data" and
// not all items were processed successfully
let message = "";
// Show a list of successful items if there are any
if (data.processed.success.length > 0) {
message +=
"Successfully added " +
data.processed.success.length +
" " +
type +
(data.processed.success.length !== 1 ? "s" : "") +
":";
// Loop over data.processed.success and print "item"
for (const item in data.processed.success) {
if (Object.prototype.hasOwnProperty.call(data.processed.success, item)) {
message += "\n- " + data.processed.success[item].item;
}
}
}
// Add a line break if there are both successful and failed items
if (data.processed.success.length > 0 && data.processed.errors.length > 0) {
message += "\n\n";
}
// Show a list of failed items if there are any
if (data.processed.errors.length > 0) {
message +=
"Failed to add " +
data.processed.errors.length +
" " +
type +
(data.processed.errors.length !== 1 ? "s" : "") +
":\n";
// Loop over data.processed.errors and print "item: error"
for (const item in data.processed.errors) {
if (Object.prototype.hasOwnProperty.call(data.processed.errors, item)) {
let error = data.processed.errors[item].error;
// Replace some error messages with a more user-friendly text
if (error.indexOf("UNIQUE constraint failed") > -1) {
error = "Already present";
}
message += "\n- " + data.processed.errors[item].item + ": " + error;
}
}
}
// Show the warning message
const total = data.processed.success.length + data.processed.errors.length;
const processed = "(" + total + " " + type + (total !== 1 ? "s" : "") + " processed)";
showAlert(
"warning",
"fas fa-exclamation-triangle",
"Some " + type + (items.length !== 1 ? "s" : "") + " could not be added " + processed,
message
);
}
// Callback function for the loading overlay timeout
function loadingOverlayTimeoutCallback(reloadAfterTimeout) {
// Try to ping FTL to see if it finished restarting
$.ajax({
url: "/api/info/login",
method: "GET",
cache: false,
dataType: "json",
})
.done(function () {
// FTL is running again, hide loading overlay
NProgress.done();
if (reloadAfterTimeout) {
location.reload();
} else {
$(".wrapper").waitMe("hide");
}
})
.fail(function () {
// FTL is not running yet, try again in 500ms
setTimeout(loadingOverlayTimeoutCallback, 500, reloadAfterTimeout);
});
}
function loadingOverlay(reloadAfterTimeout = false) {
NProgress.start();
$(".wrapper").waitMe({
effect: "bounce",
text: "Pi-hole is currently applying your changes...",
bg: "rgba(0,0,0,0.7)",
color: "#fff",
maxSize: "",
textPos: "vertical",
});
// Start checking for FTL status after 2 seconds
setTimeout(loadingOverlayTimeoutCallback, 2000, reloadAfterTimeout);
return true;
}
// Function that calls a function only if the page is currently visible. This is
// useful to prevent unnecessary API calls when the page is not visible (e.g.
// when the user is on another tab).
function callIfVisible(func) {
if (document.hidden) {
// Page is not visible, try again in 1 second
window.setTimeout(callIfVisible, 1000, func);
return;
}
// Page is visible, call function instead
func();
}
// Timer that calls a function after <interval> milliseconds but only if the
// page is currently visible. We cancel possibly running timers for the same
// function before starting a new one to prevent multiple timers running at
// the same time causing unnecessary identical API calls when the page is
// visible again.
function setTimer(func, interval) {
// Cancel possibly running timer
window.clearTimeout(func.timer);
// Start new timer
func.timer = window.setTimeout(callIfVisible, interval, func);
}
// Same as setTimer() but calls the function every <interval> milliseconds
function setInter(func, interval) {
// Cancel possibly running timer
window.clearTimeout(func.timer);
// Start new timer
func.timer = window.setTimeout(callIfVisible, interval, func);
// Restart timer
window.setTimeout(setInter, interval, func, interval);
}
window.utils = (function () {
return {
escapeHtml: escapeHtml,
unescapeHtml: unescapeHtml,
objectToArray: objectToArray,
padNumber: padNumber,
showAlert: showAlert,
datetime: datetime,
datetimeRelative: datetimeRelative,
disableAll: disableAll,
enableAll: enableAll,
validateIPv4CIDR: validateIPv4CIDR,
validateIPv6CIDR: validateIPv6CIDR,
setBsSelectDefaults: setBsSelectDefaults,
stateSaveCallback: stateSaveCallback,
stateLoadCallback: stateLoadCallback,
validateMAC: validateMAC,
validateHostname: validateHostname,
addFromQueryLog: addFromQueryLog,
addTD: addTD,
toPercent: toPercent,
colorBar: colorBar,
checkMessages: checkMessages,
changeBulkDeleteStates: changeBulkDeleteStates,
doLogout: doLogout,
renderTimestamp: renderTimestamp,
renderTimespan: renderTimespan,
htmlPass: htmlPass,
changeTableButtonStates: changeTableButtonStates,
getCSSval: getCSSval,
parseQueryString: parseQueryString,
hexEncode: hexEncode,
hexDecode: hexDecode,
listsAlert: listAlert,
loadingOverlay: loadingOverlay,
setTimer: setTimer,
setInter: setInter,
};
})();