mirror of
https://github.com/pi-hole/web.git
synced 2025-12-24 12:48:29 +00:00
Move all files from /scripts/pi-hole/ to /scripts/
Signed-off-by: yubiuser <github@yubiuser.dev>
This commit is contained in:
361
scripts/js/charts.js
Normal file
361
scripts/js/charts.js
Normal 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>• " +
|
||||
itemPercentage +
|
||||
"% of all data<br>• " +
|
||||
((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
73
scripts/js/debug.js
Normal 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
758
scripts/js/footer.js
Normal 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> Active");
|
||||
ena.hide();
|
||||
dis.show();
|
||||
dis.removeClass("active");
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "disabled": {
|
||||
status.html("<i class='fa fa-circle fa-fw text-red'></i> Blocking disabled");
|
||||
ena.show();
|
||||
dis.hide();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "failure": {
|
||||
status.html(
|
||||
"<i class='fa-solid fa-triangle-exclamation fa-fw text-red'></i> <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> 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> ' +
|
||||
freqFormatted +
|
||||
" " +
|
||||
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> Memory usage: ' +
|
||||
percentRAM.toFixed(1) +
|
||||
" %"
|
||||
);
|
||||
$("#memory").prop(
|
||||
"title",
|
||||
"Total memory: " + totalRAMGB.toFixed(1) + " GB, Swap usage: " + swap
|
||||
);
|
||||
$("#sysinfo-memory-ram").text(
|
||||
percentRAM.toFixed(1) + "% of " + totalRAMGB.toFixed(1) + " GB is used"
|
||||
);
|
||||
if (system.memory.swap.total > 0) {
|
||||
$("#sysinfo-memory-swap").text(
|
||||
percentSwap.toFixed(1) + "% of " + totalSwapGB.toFixed(1) + " GB is used"
|
||||
);
|
||||
} else {
|
||||
$("#sysinfo-memory-swap").text("No swap space available");
|
||||
}
|
||||
|
||||
color = system.cpu.load.percent[0] > 100 ? "text-red" : "text-green-light";
|
||||
$("#cpu").html(
|
||||
'<i class="fa fa-fw fa-microchip ' +
|
||||
color +
|
||||
'"></i> CPU: ' +
|
||||
system.cpu.load.percent[0].toFixed(1) +
|
||||
" %"
|
||||
);
|
||||
$("#cpu").prop(
|
||||
"title",
|
||||
"Load: " +
|
||||
system.cpu.load.raw[0].toFixed(2) +
|
||||
" " +
|
||||
system.cpu.load.raw[1].toFixed(2) +
|
||||
" " +
|
||||
system.cpu.load.raw[2].toFixed(2) +
|
||||
" on " +
|
||||
system.cpu.nprocs +
|
||||
" cores running " +
|
||||
system.procs +
|
||||
" processes"
|
||||
);
|
||||
$("#sysinfo-cpu").text(
|
||||
system.cpu.load.percent[0].toFixed(1) +
|
||||
"% (load: " +
|
||||
system.cpu.load.raw[0].toFixed(2) +
|
||||
" " +
|
||||
system.cpu.load.raw[1].toFixed(2) +
|
||||
" " +
|
||||
system.cpu.load.raw[2].toFixed(2) +
|
||||
") on " +
|
||||
system.cpu.nprocs +
|
||||
" cores running " +
|
||||
system.procs +
|
||||
" processes"
|
||||
);
|
||||
|
||||
var startdate = moment()
|
||||
.subtract(system.uptime, "seconds")
|
||||
.format("dddd, MMMM Do YYYY, HH:mm:ss");
|
||||
$("#status").prop(
|
||||
"title",
|
||||
"System uptime: " +
|
||||
moment.duration(1000 * system.uptime).humanize() +
|
||||
" (running since " +
|
||||
startdate +
|
||||
")"
|
||||
);
|
||||
$("#sysinfo-uptime").text(
|
||||
moment.duration(1000 * system.uptime).humanize() + " (running since " + startdate + ")"
|
||||
);
|
||||
$("#sysinfo-system-overlay").hide();
|
||||
|
||||
clearTimeout(systemTimer);
|
||||
systemTimer = utils.setTimer(updateSystemInfo, REFRESH_INTERVAL.system);
|
||||
})
|
||||
.fail(function (data) {
|
||||
apiFailure(data);
|
||||
});
|
||||
}
|
||||
|
||||
function apiFailure(data) {
|
||||
if (data.status === 401) {
|
||||
// Unauthorized, reload page
|
||||
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 +
|
||||
' · <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> <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
106
scripts/js/gravity.js
Normal 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[K", "\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]);
|
||||
}
|
||||
}
|
||||
486
scripts/js/groups-clients.js
Normal file
486
scripts/js/groups-clients.js
Normal 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
|
||||
},
|
||||
});
|
||||
}
|
||||
95
scripts/js/groups-common.js
Normal file
95
scripts/js/groups-common.js
Normal 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
|
||||
});
|
||||
}
|
||||
621
scripts/js/groups-domains.js
Normal file
621
scripts/js/groups-domains.js
Normal 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
617
scripts/js/groups-lists.js
Normal 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) +
|
||||
" (" +
|
||||
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 </td>
|
||||
<td>${utils.datetimeRelative(data.date_added)} (${dateAddedISO})</td>
|
||||
</tr>
|
||||
<tr class="dataTables-child">
|
||||
<td>Database entry was last modified </td>
|
||||
<td>${utils.datetimeRelative(data.date_modified)} (${dateModifiedISO})</td>
|
||||
</tr>
|
||||
<tr class="dataTables-child">
|
||||
<td>The list contents were last updated </td><td>${dateUpdated}</td>
|
||||
</tr>
|
||||
<tr class="dataTables-child">
|
||||
<td>Number of entries on this list: </td><td>${numberOfEntries}</td>
|
||||
</tr>
|
||||
<tr class="dataTables-child">
|
||||
<td>Number of non-domains on this list: </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
355
scripts/js/groups.js
Normal 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
918
scripts/js/index.js
Normal 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
403
scripts/js/interfaces.js
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
137
scripts/js/ip-address-sorting.js
Normal file
137
scripts/js/ip-address-sorting.js
Normal 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
200
scripts/js/login.js
Normal 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
15
scripts/js/logout.js
Normal 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
204
scripts/js/messages.js
Normal 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
278
scripts/js/network.js
Normal 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
723
scripts/js/queries.js
Normal 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: " +
|
||||
'<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 " + data.cname + "</div>";
|
||||
}
|
||||
|
||||
// Show TTL if applicable
|
||||
var ttlInfo = "";
|
||||
if (data.ttl > 0) {
|
||||
ttlInfo =
|
||||
divStart +
|
||||
"Time-to-live (TTL): " +
|
||||
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: <strong>" + ipInfo + "</strong></div>";
|
||||
|
||||
// Show DNSSEC status if applicable
|
||||
var dnssecInfo = "";
|
||||
if (dnssecClass !== false) {
|
||||
dnssecInfo =
|
||||
divStart +
|
||||
'DNSSEC status:  <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: " + data.id + "</div>";
|
||||
}
|
||||
|
||||
// Always show reply info, add reply delay if applicable
|
||||
var replyInfo = "";
|
||||
replyInfo =
|
||||
data.reply.type !== "UNKNOWN"
|
||||
? divStart + "Reply:  " + data.reply.type + "</div>"
|
||||
: divStart + "Reply: No reply received</div>";
|
||||
|
||||
// Compile extra info for displaying
|
||||
return (
|
||||
'<div class="row">' +
|
||||
divStart +
|
||||
"Query received on: " +
|
||||
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
209
scripts/js/search.js
Normal 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();
|
||||
});
|
||||
407
scripts/js/settings-advanced.js
Normal file
407
scripts/js/settings-advanced.js
Normal 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
486
scripts/js/settings-api.js
Normal 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
230
scripts/js/settings-dhcp.js
Normal 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();
|
||||
});
|
||||
260
scripts/js/settings-dns-records.js
Normal file
260
scripts/js/settings-dns-records.js
Normal 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
126
scripts/js/settings-dns.js
Normal 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();
|
||||
});
|
||||
24
scripts/js/settings-privacy.js
Normal file
24
scripts/js/settings-privacy.js
Normal 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();
|
||||
});
|
||||
384
scripts/js/settings-system.js
Normal file
384
scripts/js/settings-system.js
Normal 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);
|
||||
});
|
||||
});
|
||||
107
scripts/js/settings-teleporter.js
Normal file
107
scripts/js/settings-teleporter.js
Normal 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
192
scripts/js/settings.js
Normal 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"> <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"> <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"> <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
225
scripts/js/taillog.js
Normal 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
759
scripts/js/utils.js
Normal 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 = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
// 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 = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
""": '"',
|
||||
"'": "'",
|
||||
"Ü": "Ü",
|
||||
"ü": "ü",
|
||||
"Ä": "Ä",
|
||||
"ä": "ä",
|
||||
"Ö": "Ö",
|
||||
"ö": "ö",
|
||||
"ß": "ß",
|
||||
};
|
||||
|
||||
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: " <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 = " <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 = " <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,
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user