Merge development into new/simple-dhcp-static-leases and address review feedback

Resolve merge conflict in style/pi-hole.css (keep both StaticDHCPTable
styles and DNSSEC query log styles).

Address outstanding reviewer feedback:
- Change save button icon from floppy-disk to checkmark to clarify it
  confirms the row edit, not a final save
- Update hint text to mention "Save & Apply" is still needed
- Add hostname validation on the hostname cell (rejects spaces, commas,
  and other characters invalid in DNS names)

Signed-off-by: Dominik <dl6er@dl6er.de>
This commit is contained in:
Dominik
2026-03-25 07:23:10 +01:00
28 changed files with 336 additions and 236 deletions

View File

@@ -5,7 +5,7 @@
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
/* global upstreams:false */
/* global upstreamIPs:false */
"use strict";
@@ -63,6 +63,23 @@ globalThis.htmlLegendPlugin = {
for (const item of items) {
const li = document.createElement("li");
// Select the corresponding "slice" of the chart when the mouse is over a legend item
li.addEventListener("mouseover", () => {
chart.setActiveElements([
{
datasetIndex: 0,
index: item.index,
},
]);
chart.update();
});
// Deselect all "slices"
li.addEventListener("mouseout", () => {
chart.setActiveElements([]);
chart.update();
});
// Color checkbox (toggle visibility)
const boxSpan = document.createElement("span");
boxSpan.title = "Toggle visibility";
@@ -96,9 +113,19 @@ globalThis.htmlLegendPlugin = {
if (isQueryTypeChart) {
link.href = `queries?type=${item.text}`;
} else if (isForwardDestinationChart) {
} else {
// Encode the forward destination as it may contain an "#" character
link.href = `queries?upstream=${encodeURIComponent(upstreams[item.text])}`;
link.href = `queries?upstream=${encodeURIComponent(upstreamIPs[item.index])}`;
// If server name and IP are different:
if (item.text !== upstreamIPs[item.index]) {
// replace the title tooltip to include the upstream IP to the text ...
link.title = `List ${item.text} (${upstreamIPs[item.index]}) queries`;
// ... and include the server name (without port) to the querystring, to match
// the text used on the SELECT element (sent by suggestions API endpoint)
link.href += ` (${item.text.split("#")[0]})`;
}
}
} else {
// no clickable links in other charts
@@ -233,6 +260,10 @@ function positionTooltip(tooltipEl, tooltip, context) {
const arrowMinIndent = 2 * tooltip.options.cornerRadius;
const arrowSize = 5;
// Check if this is a queryOverTimeChart or clientsChart - these should stick to x-axis
const canvasId = context.chart.canvas.id;
const isTimelineChart = canvasId === "queryOverTimeChart" || canvasId === "clientsChart";
let tooltipX = offsetX + caretX;
let arrowX;
@@ -289,27 +320,37 @@ function positionTooltip(tooltipEl, tooltip, context) {
arrowX = offsetX + caretX - tooltipX;
}
let tooltipY = offsetY + caretY;
let tooltipY;
// Compute Y position
switch (tooltip.yAlign) {
case "top": {
tooltipY += arrowSize + caretPadding;
break;
}
if (isTimelineChart) {
// For timeline charts, always position tooltip below the chart with caret pointing to x-axis
const chartArea = context.chart.chartArea;
const canvasBottom = chartArea.bottom;
tooltipY = offsetY + canvasBottom + arrowSize + caretPadding;
case "center": {
tooltipY -= tooltipHeight / 2;
if (tooltip.xAlign === "left") tooltipX += arrowSize;
if (tooltip.xAlign === "right") tooltipX -= arrowSize;
break;
}
// Ensure the arrow points to the correct X position
arrowX = tooltip.caretX - (tooltipX - offsetX);
} else {
tooltipY = offsetY + caretY;
switch (tooltip.yAlign) {
case "top": {
tooltipY += arrowSize + caretPadding;
break;
}
case "bottom": {
tooltipY -= tooltipHeight + arrowSize + caretPadding;
break;
case "center": {
tooltipY -= tooltipHeight / 2;
if (tooltip.xAlign === "left") tooltipX += arrowSize;
if (tooltip.xAlign === "right") tooltipX -= arrowSize;
break;
}
case "bottom": {
tooltipY -= tooltipHeight + arrowSize + caretPadding;
break;
}
// No default
}
// No default
}
// Position tooltip and display

View File

@@ -241,28 +241,26 @@ function updateFtlInfo() {
$("#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))
.text(intl.format(database.domains.allowed.enabled + database.regex.allowed.enabled))
.attr(
"title",
"Allowed: " +
intl.format(database.domains.allowed) +
intl.format(database.domains.allowed.enabled) +
" exact domains and " +
intl.format(database.regex.allowed) +
intl.format(database.regex.allowed.enabled) +
" regex filters are enabled"
);
$("#num_denied")
.text(intl.format(database.domains.denied + database.regex.denied))
.text(intl.format(database.domains.denied.enabled + database.regex.denied.enabled))
.attr(
"title",
"Denied: " +
intl.format(database.domains.denied) +
intl.format(database.domains.denied.enabled) +
" exact domains and " +
intl.format(database.regex.denied) +
intl.format(database.regex.denied.enabled) +
" regex filters are enabled"
);
updateQueryFrequency(intl, ftl.query_frequency);
$("#sysinfo-cpu-ftl").text("(" + ftl["%cpu"].toFixed(1) + "% used by FTL)");
$("#sysinfo-ram-ftl").text("(" + ftl["%mem"].toFixed(1) + "% used by FTL)");
$("#sysinfo-pid-ftl").text(ftl.pid);
const startdate = moment()
.subtract(ftl.uptime, "milliseconds")
@@ -346,15 +344,17 @@ function updateSystemInfo() {
);
$("#cpu").prop(
"title",
"Load averages for the past 1, 5, and 15 minutes\non a system with " +
"CPU usage: " +
system.cpu["%cpu"].toFixed(1) +
"%\nLoad averages for the past 1, 5, and 15 minutes\non a system with " +
system.cpu.nprocs +
" core" +
(system.cpu.nprocs > 1 ? "s" : "") +
" running " +
system.procs +
" processes " +
" processes" +
(system.cpu.load.raw[0] > system.cpu.nprocs
? " (load is higher than the number of cores)"
? "\n(load is higher than the number of cores)"
: "")
);
$("#sysinfo-cpu").text(
@@ -368,6 +368,9 @@ function updateSystemInfo() {
" processes"
);
$("#sysinfo-cpu-ftl").text("(" + system.ftl["%cpu"].toFixed(1) + "% used by FTL)");
$("#sysinfo-ram-ftl").text("(" + system.ftl["%mem"].toFixed(1) + "% used by FTL)");
const startdate = moment()
.subtract(system.uptime, "seconds")
.format("dddd, MMMM Do YYYY, HH:mm:ss");

View File

@@ -5,7 +5,7 @@
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
/* global apiFailure:false */
/* global apiFailure:false, utils:false */
"use strict";
@@ -89,9 +89,11 @@ function parseLines(outputElement, text) {
const lines = text.split(/(?=\r)/g);
for (let line of lines) {
// Escape HTML to prevent XSS attacks (both in adlist URL and non-domain entries)
line = utils.escapeHtml(line);
if (line[0] === "\r") {
// This line starts with the "OVER" sequence. Replace them with "\n" before print
line = line.replaceAll("\r", "\n").replaceAll("\r", "\n");
line = line.replaceAll("\r\u001B[K", "\n").replaceAll("\r", "\n");
// Last line from the textarea will be overwritten, so we remove it
const lastLineIndex = outputElement.innerHTML.lastIndexOf("\n");
@@ -138,7 +140,7 @@ function parseLines(outputElement, text) {
});
// Append the new text to the end of the output
outputElement.append(line);
outputElement.innerHTML += line;
}
}

View File

@@ -234,23 +234,20 @@ function initTable() {
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>"
);
const codeElem = document.createElement("code");
codeElem.id = "address_" + dataId;
codeElem.className = "breakall";
codeElem.textContent = data.address;
$("td:eq(3)", row).empty().append(codeElem);
} 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>"
);
const aElem = document.createElement("a");
aElem.id = "address_" + dataId;
aElem.className = "breakall";
aElem.href = data.address;
aElem.target = "_blank";
aElem.rel = "noopener noreferrer";
aElem.textContent = data.address;
$("td:eq(3)", row).empty().append(aElem);
}
$("td:eq(4)", row).html(
@@ -518,12 +515,12 @@ function addList(event) {
}
$.ajax({
url: document.body.dataset.apiurl + "/lists",
url: document.body.dataset.apiurl + "/lists?type=" + encodeURIComponent(type),
method: "post",
dataType: "json",
processData: false,
contentType: "application/json; charset=utf-8",
data: JSON.stringify({ address: addresses, comment, type, groups: group }),
data: JSON.stringify({ address: addresses, comment, groups: group }),
success(data) {
utils.enableAll();
utils.listsAlert(type + "list", addresses, data);

View File

@@ -226,7 +226,7 @@ function updateClientsOverTime() {
});
}
const upstreams = {};
const upstreamIPs = [];
function updateForwardDestinationsPie() {
$.getJSON(document.body.dataset.apiurl + "/stats/upstreams", data => {
const v = [];
@@ -248,11 +248,8 @@ function updateForwardDestinationsPie() {
label += "#" + item.port;
}
// Store upstreams for generating links to the Query Log
upstreams[label] = item.ip;
if (item.port > 0) {
upstreams[label] += "#" + item.port;
}
// Store upstreams IPs for generating links to the Query Log
upstreamIPs.push(item.port > 0 ? item.ip + "#" + item.port : item.ip);
const percent = (100 * item.count) / sum;
values.push([label, percent, THEME_COLORS[i++ % THEME_COLORS.length]]);
@@ -521,8 +518,8 @@ function labelWithPercentage(tooltipLabel, skipZero = false) {
// Sum all queries for the current time by iterating over all keys in the
// current dataset
let sum = 0;
for (const value of Object.values(tooltipLabel.parsed._stacks.y)) {
if (value === undefined) continue;
for (const [key, value] of Object.entries(tooltipLabel.parsed._stacks.y)) {
if (key.startsWith("_") || value === undefined) continue;
const num = Number.parseInt(value, 10);
if (num) sum += num;
}
@@ -639,9 +636,11 @@ $(() => {
display: false,
},
tooltip: {
enabled: true,
// Disable the on-canvas tooltip
enabled: false,
intersect: false,
yAlign: "bottom",
external: customTooltips,
yAlign: "top",
itemSort(a, b) {
return b.datasetIndex - a.datasetIndex;
},
@@ -656,7 +655,7 @@ $(() => {
return "Queries from " + from + " to " + to;
},
label(tooltipLabel) {
return labelWithPercentage(tooltipLabel);
return labelWithPercentage(tooltipLabel, true);
},
},
},
@@ -893,6 +892,8 @@ $(() => {
elements: {
arc: {
borderColor: $(".box").css("background-color"),
hoverBorderColor: $(".box").css("background-color"),
hoverOffset: 10,
},
},
plugins: {
@@ -917,6 +918,9 @@ $(() => {
animation: {
duration: 750,
},
layout: {
padding: 10,
},
},
});
@@ -939,6 +943,8 @@ $(() => {
elements: {
arc: {
borderColor: $(".box").css("background-color"),
hoverBorderColor: $(".box").css("background-color"),
hoverOffset: 10,
},
},
plugins: {
@@ -963,6 +969,9 @@ $(() => {
animation: {
duration: 750,
},
layout: {
padding: 10,
},
},
});

View File

@@ -28,6 +28,14 @@ const filters = [
"reply",
"dnssec",
];
let doDNSSEC = false;
// Check if pihole is validiting DNSSEC
function getDnssecConfig() {
$.getJSON(document.body.dataset.apiurl + "/config/dns/dnssec", data => {
doDNSSEC = data.config.dns.dnssec;
});
}
function initDateRangePicker() {
$("#querytime").daterangepicker(
@@ -480,6 +488,9 @@ function liveUpdate() {
}
$(() => {
// Do we want to show DNSSEC icons?
getDnssecConfig();
// Do we want to filter queries?
const GETDict = utils.parseQueryString();
@@ -561,11 +572,13 @@ $(() => {
utils.stateSaveCallback("query_log_table", data);
},
stateLoadCallback() {
return utils.stateLoadCallback("query_log_table");
const state = utils.stateLoadCallback("query_log_table");
// Default to 25 entries if "All" was previously selected
if (state) state.length = state.length === -1 ? 25 : state.length;
return state;
},
rowCallback(row, data) {
const querystatus = parseQueryStatus(data);
const dnssec = parseDNSSEC(data);
if (querystatus.icon !== false) {
$("td:eq(1)", row).html(
@@ -589,14 +602,17 @@ $(() => {
// Prefix colored DNSSEC icon to domain text
let dnssecIcon = "";
dnssecIcon =
'<i class="mr-2 fa fa-fw ' +
dnssec.icon +
" " +
dnssec.color +
'" title="DNSSEC: ' +
dnssec.text +
'"></i>';
if (doDNSSEC === true) {
const dnssec = parseDNSSEC(data);
dnssecIcon =
'<i class="mr-2 fa fa-fw ' +
dnssec.icon +
" " +
dnssec.color +
'" title="DNSSEC: ' +
dnssec.text +
'"></i>';
}
// Escape HTML in domain
domain = dnssecIcon + utils.escapeHtml(domain);

View File

@@ -361,7 +361,7 @@ $(document).on("focus input", "#StaticDHCPTable td[contenteditable]", function (
if (!row.next().hasClass("edit-hint-row")) {
row.next(".edit-hint-row").remove(); // Remove any existing hint
row.after(
'<tr class="edit-hint-row"><td colspan="4" class="text-info" style="font-style:italic;">Please save this line before editing another or leaving the page, otherwise your changes will be lost.</td></tr>'
'<tr class="edit-hint-row"><td colspan="4" class="text-info" style="font-style:italic;">Please confirm changes using the green button, then click "Save &amp; Apply" before leaving the page.</td></tr>'
);
}
});
@@ -398,10 +398,10 @@ function renderStaticDHCPTable() {
$("<td></td>")
.append(
$(
'<button type="button" class="btn btn-success btn-xs save-static-row"><i class="fa fa-fw fa-floppy-disk"></i></button>'
'<button type="button" class="btn btn-success btn-xs save-static-row"><i class="fa fa-fw fa-check"></i></button>'
)
.attr("data-row", idx)
.attr("title", "Save changes to this line")
.attr("title", "Confirm changes to this line")
.attr("data-toggle", "tooltip")
)
.append(" ")
@@ -518,3 +518,17 @@ $(document).on("input blur paste", "#StaticDHCPTable td.static-ipaddr", function
$(this).attr("title", "");
}
});
$(document).on("input blur paste", "#StaticDHCPTable td.static-hostname", function () {
const val = $(this).text().trim();
// Hostnames must not contain spaces, commas, or characters invalid in DNS names
const hostnameValidator = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/v;
if (val && !hostnameValidator.test(val)) {
$(this).addClass("table-danger");
$(this).removeClass("table-success");
$(this).attr("title", "Invalid hostname: only letters, digits, hyphens, and dots allowed");
} else {
$(this).removeClass("table-danger table-success");
$(this).attr("title", "");
}
});

View File

@@ -215,7 +215,7 @@ $(() => {
$("#btnAdd-host").on("click", () => {
utils.disableAll();
const elem = $("#Hip").val() + " " + $("#Hdomain").val();
const elem = $("#Hip").val().trim() + " " + $("#Hdomain").val().trim();
const url = document.body.dataset.apiurl + "/config/dns/hosts/" + encodeURIComponent(elem);
utils.showAlert("info", "", "Adding DNS record...", elem);
$.ajax({
@@ -239,7 +239,7 @@ $(() => {
$("#btnAdd-cname").on("click", () => {
utils.disableAll();
let elem = $("#Cdomain").val() + "," + $("#Ctarget").val();
let elem = $("#Cdomain").val().trim() + "," + $("#Ctarget").val().trim();
const ttlVal = Number.parseInt($("#Cttl").val(), 10);
// TODO Fix eslint
// eslint-disable-next-line unicorn/prefer-number-properties

View File

@@ -267,7 +267,7 @@ $(".confirm-flusharp").confirm({
title: "Confirmation required",
confirm() {
$.ajax({
url: document.body.dataset.apiurl + "/action/flush/arp",
url: document.body.dataset.apiurl + "/action/flush/network",
type: "POST",
}).fail(data => {
apiFailure(data);

View File

@@ -24,7 +24,7 @@ mg.include('header.lp','r')
<script src="<?=pihole.fileversion('vendor/waitMe-js/modernized-waitme-min.js')?>"></script>
<script src="<?=pihole.fileversion('scripts/js/logout.js')?>"></script>
</head>
<body class="<?=theme.name?> hold-transition sidebar-mini <? if pihole.boxedlayout() then ?>layout-boxed<? end ?> logged-in page-<?=pihole.format_path(mg.request_info.request_uri)?>" data-apiurl="<?=pihole.api_url()?>" data-webhome="<?=webhome?>">
<body class="<?=theme.name?> hold-transition sidebar-mini <? if pihole.boxedlayout() then ?>layout-boxed<? end ?> logged-in page-<?=pihole.format_path(scriptname)?>" data-apiurl="<?=pihole.api_url()?>" data-webhome="<?=webhome?>">
<noscript>
<!-- JS Warning -->
<div>