- You don't have permission to access =mg.request_info.request_uri?> on this server.
+ You don't have permission to access this URL.
Did you mean to go to your Pi-hole's dashboard instead?
You can select an existing client or add a custom one by typing into the field above and confirming your entry with ⏎.
- Multiple clients can be added by separating each client with a space or comma.
-
Clients may be described either by their IP addresses (IPv4 and IPv6 are supported),
- IP subnets (CIDR notation, like 192.168.2.0/24),
- their MAC addresses (like 12:34:56:78:9A:BC),
- by their hostnames (like localhost), or by the interface they are connected to (prefaced with a colon, like :eth0).
-
-
Note that client recognition by IP addresses (incl. subnet ranges) are preferred over MAC address, host name or interface recognition as
- the two latter will only be available after some time.
- Furthermore, MAC address recognition only works for devices at most one networking hop away from your Pi-hole.
-
+ Multiple clients can be added by separating each client with a space or comma. Clients may be described either by their
+
+
IP addresses (IPv4 and IPv6 are supported),
+
IP subnets (CIDR notation, like 192.168.2.0/24), their
+
MAC addresses (like 12:34:56:78:9A:BC), by their
+
hostnames (like localhost), or by the
+
interface they are connected to (prefaced with a colon, like :eth0).
+
+
The first match (from top to down) wins. Note that client recognition by host name or interface recognition as
+ the two latter may only be available after some time. Furthermore, MAC address recognition only works for devices at most one networking hop away from your Pi-hole.
";
+ }
+
+ tooltipHtml += "";
+
+ const tableRoot = tooltipEl.querySelector("table");
+ tableRoot.innerHTML = tooltipHtml;
+}
+
+function positionTooltip(tooltipEl, tooltip, context) {
+ if (tooltip.opacity === 0 || tooltipEl.style.opacity === 0) return;
+
+ const canvasPos = context.chart.canvas.getBoundingClientRect();
const boxPos = tooltipEl.ancestor.getBoundingClientRect();
const offsetX = canvasPos.left - boxPos.left;
const offsetY = canvasPos.top - boxPos.top;
const tooltipWidth = tooltipEl.offsetWidth;
const tooltipHeight = tooltipEl.offsetHeight;
- const caretX = tooltip.caretX;
- const caretY = tooltip.caretY;
- const caretPadding = tooltip.options.caretPadding;
- let tooltipX;
- let tooltipY;
- let arrowX;
+ const { caretX, caretY } = tooltip;
+ const { caretPadding } = tooltip.options;
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;
+
// 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
- const 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;
+ 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
+ const minX = 5;
+ tooltipX = Math.max(minX, tooltipX - tooltipWidth / 2);
+ arrowX = tooltip.caretX - (tooltipX - offsetX);
+ 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;
+
+ case "left": {
+ tooltipX -= arrowMinIndent;
+ arrowX = arrowMinIndent;
+ break;
}
+
+ case "right": {
+ tooltipX -= tooltipWidth - arrowMinIndent;
+ arrowX = tooltipWidth - arrowMinIndent;
+ break;
+ }
+ // No default
}
- } else {
- // compute the tooltip's center inside ancestor element
+ } 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;
+ }
+ // No default
+ }
+ }
+
+ // Adjust X position if tooltip is centered inside ancestor
+ if (document.documentElement.clientWidth <= 2 * tooltip.width && tooltip.xAlign === "center") {
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;
- }
-
+ tooltipX = Math.max(tooltipX, offsetX + caretX - arrowMinIndent); // Prevent left overflow
+ tooltipX = Math.min(tooltipX, offsetX + caretX - tooltipWidth + arrowMinIndent); // Prevent right overflow
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;
+ let tooltipY;
+
+ 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;
+
+ // 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;
}
- break;
- case "bottom":
- tooltipY = offsetY + caretY - tooltipHeight - arrowSize - caretPadding;
- break;
- default:
- 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
+ }
}
// 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 {
+ tooltipEl.style.top = `${tooltipY.toFixed(1)}px`;
+ tooltipEl.style.left = `${tooltipX.toFixed(1)}px`;
+
+ // Set arrow position
+ const arrowEl = tooltipEl.querySelector(".arrow");
+ let arrowLeftPosition = "";
+
+ if (arrowX !== undefined) {
// Calculate percentage X value depending on the tooltip's
// width to avoid hanging arrow out on tooltip width changes
const arrowXpercent = ((100 / tooltipWidth) * arrowX).toFixed(1);
- tooltipEl.querySelector(".arrow").style.left = arrowXpercent + "%";
+ arrowLeftPosition = `${arrowXpercent}%`;
}
- tooltipEl.style.opacity = 1;
-};
+ arrowEl.style.left = arrowLeftPosition;
+}
+
+globalThis.doughnutTooltip = tooltipLabel => {
+ if (tooltipLabel.parsed === 0) return "";
-// eslint-disable-next-line no-unused-vars
-function doughnutTooltip(tooltipLabel) {
- let 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
-
- const label = " " + tooltipLabel.label;
+ let percentageTotalShown = tooltipLabel.chart._metasets[0].total.toFixed(1);
+ const label = ` ${tooltipLabel.label}`;
let itemPercentage;
// if we only show < 1% percent of all, show each item with two decimals
@@ -327,41 +390,32 @@ function doughnutTooltip(tooltipLabel) {
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
+ // even if no doughnut slice is hidden, sometimes percentageTotalShown is slightly less than 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 + "%";
+ return `${label}: ${itemPercentage}%`;
}
// 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;
+ const percentageOfShownItems = ((tooltipLabel.parsed * 100) / percentageTotalShown).toFixed(1);
+
return (
- label +
- ": • " +
- itemPercentage +
- "% of all data • " +
- ((tooltipLabel.parsed * 100) / percentageTotalShown).toFixed(1) +
- "% of shown items"
+ `${label}: • ${itemPercentage}% of all data ` +
+ `• ${percentageOfShownItems}% of shown items`
);
-}
+};
// chartjs plugin used by the custom doughnut legend
-const getOrCreateLegendList = (chart, id) => {
+function getOrCreateLegendList(id) {
const legendContainer = document.getElementById(id);
let listContainer = legendContainer.querySelector("ul");
+ if (listContainer) return listContainer;
- 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);
- }
+ listContainer = document.createElement("ul");
+ legendContainer.append(listContainer);
return listContainer;
-};
+}
diff --git a/scripts/js/footer.js b/scripts/js/footer.js
index 873c47d7..a26b2740 100644
--- a/scripts/js/footer.js
+++ b/scripts/js/footer.js
@@ -29,12 +29,11 @@ const REFRESH_INTERVAL = {
clients: 600_000, // 10 min (dashboard)
};
-function secondsTimeSpanToHMS(s) {
- const h = Math.floor(s / 3600); //Get whole hours
- s -= h * 3600;
- const 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 secondsTimeSpanToHMS(seconds) {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = seconds % 60;
+ return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
function piholeChanged(blocking, timer = null) {
@@ -109,12 +108,6 @@ function countDown() {
}
function checkBlocking() {
- // Skip if page is hidden
- if (document.hidden) {
- utils.setTimer(checkBlocking, REFRESH_INTERVAL.blocking);
- return;
- }
-
$.ajax({
url: document.body.dataset.apiurl + "/dns/blocking",
method: "GET",
@@ -248,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")
@@ -353,18 +344,20 @@ 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").html(
+ $("#sysinfo-cpu").text(
system.cpu["%cpu"].toFixed(1) +
"% on " +
system.cpu.nprocs +
@@ -375,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");
@@ -561,7 +557,7 @@ function updateVersionInfo() {
v.name +
" " +
localVersion +
- ' · Update available!'
);
@@ -734,9 +730,6 @@ function addAdvancedInfo() {
advancedInfoTarget.append(
"Render time: " + (totaltime > 0.5 ? totaltime.toFixed(1) : totaltime.toFixed(3)) + " ms"
);
-
- // Show advanced info
- advancedInfoTarget.show();
}
$(() => {
diff --git a/scripts/js/gravity.js b/scripts/js/gravity.js
index 893763e8..425abdaa 100644
--- a/scripts/js/gravity.js
+++ b/scripts/js/gravity.js
@@ -5,87 +5,160 @@
* 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 */
+
"use strict";
function eventsource() {
- const alInfo = $("#alInfo");
- const alSuccess = $("#alSuccess");
- const ta = $("#output");
+ const $alertInfo = $("#alertInfo");
+ const $alertSuccess = $("#alertSuccess");
+ const outputElement = document.getElementById("output");
+ const gravityBtn = document.getElementById("gravityBtn");
+ const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
+ const url = `${document.body.dataset.apiurl}/action/gravity`;
- ta.html("");
- ta.show();
- alInfo.show();
- alSuccess.hide();
+ if (outputElement.innerHTML.length > 0) {
+ outputElement.innerHTML = "";
+ }
- fetch(document.body.dataset.apiurl + "/action/gravity", {
+ if (!outputElement.classList.contains("d-none")) {
+ outputElement.classList.add("d-none");
+ }
+
+ $alertSuccess.hide();
+ $alertInfo.show();
+
+ fetch(url, {
method: "POST",
- headers: { "X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr("content") },
+ headers: { "X-CSRF-TOKEN": csrfToken },
})
- // Retrieve its body as ReadableStream
+ .then(response => (response.ok ? response : apiFailure(response)))
+ // Retrieve the response 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);
- const string = new TextDecoder().decode(value);
- parseLines(ta, string);
-
- if (string.includes("Done.")) {
- alSuccess.show();
- }
-
- return pump();
- });
- }
- },
+ return handleResponseStream({
+ response,
+ outputElement,
+ alertInfo: $alertInfo,
+ gravityBtn,
+ alertSuccess: $alertSuccess,
});
})
.catch(error => console.error(error)); // eslint-disable-line no-console
}
-$("#gravityBtn").on("click", () => {
- $("#gravityBtn").prop("disabled", true);
- eventsource();
-});
+function handleResponseStream({ response, outputElement, alertInfo, gravityBtn, alertSuccess }) {
+ outputElement.classList.remove("d-none");
-// Handle hiding of alerts
-$(() => {
- $("[data-hide]").on("click", function () {
- $(this)
- .closest("." + $(this).attr("data-hide"))
- .hide();
+ const reader = response.body.getReader();
+
+ function pump(controller) {
+ return reader.read().then(({ done, value }) => {
+ // When no more data needs to be consumed, close the stream
+ if (done) {
+ controller.close();
+ alertInfo.hide();
+ gravityBtn.removeAttribute("disabled");
+ return;
+ }
+
+ // Enqueue the next data chunk into our target stream
+ controller.enqueue(value);
+ const text = new TextDecoder().decode(value);
+ parseLines(outputElement, text);
+
+ if (text.includes("Done.")) {
+ alertSuccess.show();
+ }
+
+ return pump(controller);
+ });
+ }
+
+ return new ReadableStream({
+ start(controller) {
+ return pump(controller);
+ },
});
-});
+}
-function parseLines(ta, str) {
- // str can contain multiple lines.
+function parseLines(outputElement, text) {
+ // text 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"
- const lines = str.split(/(?=\r)/g);
+ 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[K", "\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
- ta.text(ta.text().substring(0, ta.text().lastIndexOf("\n")));
+ const lastLineIndex = outputElement.innerHTML.lastIndexOf("\n");
+ outputElement.innerHTML = outputElement.innerHTML.substring(0, lastLineIndex);
}
+ // Track the number of opening spans
+ let spanCount = 0;
+
+ // Mapping of ANSI escape codes to their corresponding CSS class names.
+ const ansiMappings = {
+ "\u001B[1m": "text-bold", //COL_BOLD
+ "\u001B[90m": "log-gray", //COL_GRAY
+ "\u001B[91m": "log-red", //COL_RED
+ "\u001B[32m": "log-green", //COL_GREEN
+ "\u001B[33m": "log-yellow", //COL_YELLOW
+ "\u001B[94m": "log-blue", //COL_BLUE
+ "\u001B[95m": "log-purple", //COL_PURPLE
+ "\u001B[96m": "log-cyan", //COL_CYAN
+ };
+
+ // Create a regex that matches all ANSI codes (including reset)
+ /* eslint-disable-next-line no-control-regex */
+ const ansiRegex = /(\u001B\[(?:1|90|91|32|33|94|95|96|0)m)/g;
+
+ // Process the line sequentially, replacing ANSI codes with their corresponding HTML spans
+ // we use a counter to keep track of how many spans are open and close the correct number of spans when we encounter a reset code
+ /* eslint-disable-next-line unicorn/prefer-string-replace-all */
+ line = line.replace(ansiRegex, match => {
+ if (match === "\u001B[0m") {
+ // Reset/close all open spans
+ const closingTags = "".repeat(spanCount);
+ spanCount = 0;
+ return closingTags;
+ }
+
+ if (ansiMappings[match]) {
+ // Opening span
+ spanCount++;
+ return ``;
+ }
+
+ return match; // Return unchanged if not recognized
+ });
+
// Append the new text to the end of the output
- ta.append(line);
+ outputElement.innerHTML += line;
}
}
+
+document.addEventListener("DOMContentLoaded", () => {
+ const gravityBtn = document.getElementById("gravityBtn");
+
+ gravityBtn.addEventListener("click", () => {
+ gravityBtn.disabled = true;
+ eventsource();
+ });
+
+ // Handle hiding of alerts
+ const dataHideElements = document.querySelectorAll("[data-hide]");
+ for (const element of dataHideElements) {
+ element.addEventListener("click", () => {
+ const hideClass = element.dataset.hide;
+ const closestElement = element.closest(`.${hideClass}`);
+ if (closestElement) $(closestElement).hide();
+ });
+ }
+});
diff --git a/scripts/js/groups-clients.js b/scripts/js/groups-clients.js
index 5d4e970c..62d664c0 100644
--- a/scripts/js/groups-clients.js
+++ b/scripts/js/groups-clients.js
@@ -92,7 +92,6 @@ $(() => {
});
});
-// eslint-disable-next-line no-unused-vars
function initTable() {
table = $("#clientsTable").DataTable({
processing: true,
@@ -322,7 +321,7 @@ function initTable() {
}
table.on("init select deselect", () => {
- utils.changeBulkDeleteStates(table);
+ utils.changeTableButtonStates(table);
});
table.on("order.dt", () => {
diff --git a/scripts/js/groups-common.js b/scripts/js/groups-common.js
index 865443a0..7731c6f8 100644
--- a/scripts/js/groups-common.js
+++ b/scripts/js/groups-common.js
@@ -115,7 +115,7 @@ function delGroupItems(type, ids, table, listType = undefined) {
// Clear selection after deletion
table.rows().deselect();
- utils.changeBulkDeleteStates(table);
+ utils.changeTableButtonStates(table);
// Update number of items in the sidebar
updateFtlInfo();
diff --git a/scripts/js/groups-domains.js b/scripts/js/groups-domains.js
index ae7c56a7..0f4929f4 100644
--- a/scripts/js/groups-domains.js
+++ b/scripts/js/groups-domains.js
@@ -99,7 +99,6 @@ function hideSuggestDomains() {
$("#suggest_domains").slideUp("fast");
}
-// eslint-disable-next-line no-unused-vars
function initTable() {
table = $("#domainsTable").DataTable({
processing: true,
@@ -398,7 +397,7 @@ function initTable() {
}
table.on("init select deselect", () => {
- utils.changeBulkDeleteStates(table);
+ utils.changeTableButtonStates(table);
});
table.on("order.dt", () => {
diff --git a/scripts/js/groups-lists.js b/scripts/js/groups-lists.js
index cbac01ec..47c3db92 100644
--- a/scripts/js/groups-lists.js
+++ b/scripts/js/groups-lists.js
@@ -170,7 +170,6 @@ function setTypeIcon(type) {
return ` `;
}
-// eslint-disable-next-line no-unused-vars
function initTable() {
table = $("#listsTable").DataTable({
processing: true,
@@ -235,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(
- '' +
- utils.escapeHtml(data.address) +
- ""
- );
+ 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(
- '' +
- utils.escapeHtml(data.address) +
- ""
- );
+ 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(
@@ -440,7 +436,7 @@ function initTable() {
});
table.on("init select deselect", () => {
- utils.changeBulkDeleteStates(table);
+ utils.changeTableButtonStates(table);
});
table.on("order.dt", () => {
@@ -519,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);
diff --git a/scripts/js/groups.js b/scripts/js/groups.js
index df32260c..581902f6 100644
--- a/scripts/js/groups.js
+++ b/scripts/js/groups.js
@@ -207,7 +207,7 @@ $(() => {
table.rows(0).deselect();
}
- utils.changeBulkDeleteStates(table);
+ utils.changeTableButtonStates(table);
});
table.on("order.dt", () => {
diff --git a/scripts/js/index.js b/scripts/js/index.js
index 9bf49555..f79aa0df 100644
--- a/scripts/js/index.js
+++ b/scripts/js/index.js
@@ -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,
+ },
},
});
diff --git a/scripts/js/interfaces.js b/scripts/js/interfaces.js
index 0ad5d362..d09da152 100644
--- a/scripts/js/interfaces.js
+++ b/scripts/js/interfaces.js
@@ -5,462 +5,526 @@
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
-/* global utils: false */
+/* global utils:false, apiFailure:false */
"use strict";
-$(() => {
- $.ajax({
- url: document.body.dataset.apiurl + "/network/gateway",
- data: { detailed: true },
- }).done(data => {
- const 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);
- }
+document.addEventListener("DOMContentLoaded", () => {
+ const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
+ const url = `${document.body.dataset.apiurl}/network/gateway?detailed=true`;
- if (inet6 !== undefined) {
- gateways.add(inet6.gateway);
- }
+ fetch(url, {
+ method: "GET",
+ headers: {
+ "X-CSRF-TOKEN": csrfToken,
+ },
+ })
+ .then(response => (response.ok ? response.json() : apiFailure(response)))
+ .then(data => {
+ const intl = new Intl.NumberFormat();
+ const gateways = extractGateways(data.gateway);
+ const { interfaces, masterInterfaces } = processInterfaces(data.interfaces, gateways, intl);
+ const masterInterfacesSorted = sortInterfaces(interfaces, masterInterfaces);
+ const json = masterInterfacesSorted.map(iface => interfaces[iface]);
- const interfaces = {};
- const masterInterfaces = {};
-
- // For each interface in data.interface, create a new object and push it to json
- for (const iface of data.interfaces) {
- const carrierColor = iface.carrier ? "text-green" : "text-red";
- let stateText = iface.state.toUpperCase();
- if (stateText === "UNKNOWN" && iface.flags !== undefined && iface.flags.length > 0) {
- if (iface.flags.includes("pointopoint")) {
- // WireGuards, etc. -> the typo is intentional
- stateText = "P2P";
- } else if (iface.flags.includes("loopback")) {
- // Loopback interfaces
- stateText = "LOOPBACK";
- }
- }
-
- const status = `${stateText}`;
-
- let master = null;
- if (iface.master !== undefined) {
- // Find interface.master in data.interfaces
- master = data.interfaces.find(obj => obj.index === iface.master).name;
- }
-
- // Show an icon for indenting slave interfaces
- const indentIcon =
- master === null ? "" : " ⤷ ";
-
- const obj = {
- text: indentIcon + iface.name + " - " + status,
- class: gateways.has(iface.name) ? "text-bold" : null,
- icon: master === null ? "fa fa-network-wired fa-fw" : "",
- nodes: [],
- };
-
- if (master !== null) {
- obj.nodes.push({
- text: "Master interface: " + utils.escapeHtml(master) + "",
- icon: "fa fa-network-wired fa-fw",
- });
-
- if (master in masterInterfaces) {
- masterInterfaces[master].push(iface.name);
- } else {
- masterInterfaces[master] = [iface.name];
- }
- }
-
- if (iface.speed) {
- obj.nodes.push({
- text: "Speed: " + intl.format(iface.speed) + " Mbit/s",
- icon: "fa fa-tachometer-alt fa-fw",
- });
- }
-
- if (iface.type !== undefined) {
- obj.nodes.push({
- text: "Type: " + utils.escapeHtml(iface.type),
- icon: "fa fa-network-wired fa-fw",
- });
- }
-
- if (iface.flags !== undefined && iface.flags.length > 0) {
- obj.nodes.push({
- text: "Flags: " + utils.escapeHtml(iface.flags.join(", ")),
- icon: "fa fa-flag fa-fw",
- });
- }
-
- if (iface.address !== undefined) {
- let extra = "";
- if (iface.perm_address !== undefined && iface.perm_address !== iface.address) {
- extra = " (permanent: " + utils.escapeHtml(iface.perm_address) + ")";
- }
-
- obj.nodes.push({
- text: "Hardware address: " + utils.escapeHtml(iface.address) + "" + extra,
- icon: "fa fa-map-marker-alt fa-fw",
- });
- }
-
- if (iface.addresses !== undefined) {
- const addrs = {
- text:
- iface.addresses.length +
- (iface.addresses.length === 1 ? " address" : " addresses") +
- " connected to interface",
- icon: "fa fa-map-marker-alt fa-fw",
- nodes: [],
- };
-
- for (const addr of iface.addresses) {
- let extraaddr = "";
- if (addr.prefixlen !== undefined) {
- extraaddr += " / " + addr.prefixlen + "";
- }
-
- if (addr.address_type !== undefined) {
- let familyextra = "";
- 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 + " ";
- }
-
- const jaddr = {
- text:
- "Address: " + family + utils.escapeHtml(addr.address) + "" + extraaddr,
- icon: "fa fa-map-marker-alt fa-fw",
- nodes: [],
- };
- if (addr.local !== undefined) {
- jaddr.nodes.push({
- text: "Local: " + utils.escapeHtml(addr.local) + "",
- icon: "fa fa-map-marker-alt fa-fw",
- });
- }
-
- if (addr.broadcast !== undefined) {
- jaddr.nodes.push({
- text: "Broadcast: " + utils.escapeHtml(addr.broadcast) + "",
- 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 === 4_294_967_295 ? "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 === 4_294_967_295 ? "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 (iface.stats !== undefined) {
- const stats = {
- text: "Statistics",
- icon: "fa fa-chart-line fa-fw",
- expanded: false,
- nodes: [],
- };
- if (iface.stats.rx_bytes !== undefined) {
- stats.nodes.push({
- text:
- "RX bytes: " +
- intl.format(iface.stats.rx_bytes.value) +
- " " +
- iface.stats.rx_bytes.unit,
- icon: "fa fa-download fa-fw",
- });
- }
-
- if (iface.stats.tx_bytes !== undefined) {
- stats.nodes.push({
- text:
- "TX bytes: " +
- intl.format(iface.stats.tx_bytes.value) +
- " " +
- iface.stats.tx_bytes.unit,
- icon: "fa fa-upload fa-fw",
- });
- }
-
- if (iface.stats.rx_packets !== undefined) {
- stats.nodes.push({
- text: "RX packets: " + intl.format(iface.stats.rx_packets),
- icon: "fa fa-download fa-fw",
- });
- }
-
- if (iface.stats.rx_errors !== undefined) {
- stats.nodes.push({
- text:
- "RX errors: " +
- intl.format(iface.stats.rx_errors) +
- " (" +
- ((iface.stats.rx_errors / iface.stats.rx_packets) * 100).toFixed(1) +
- "%)",
- icon: "fa fa-download fa-fw",
- });
- }
-
- if (iface.stats.rx_dropped !== undefined) {
- stats.nodes.push({
- text:
- "RX dropped: " +
- intl.format(iface.stats.rx_dropped) +
- " (" +
- ((iface.stats.rx_dropped / iface.stats.rx_packets) * 100).toFixed(1) +
- "%)",
- icon: "fa fa-download fa-fw",
- });
- }
-
- if (iface.stats.tx_packets !== undefined) {
- stats.nodes.push({
- text: "TX packets: " + intl.format(iface.stats.tx_packets),
- icon: "fa fa-upload fa-fw",
- });
- }
-
- if (iface.stats.tx_errors !== undefined) {
- stats.nodes.push({
- text:
- "TX errors: " +
- intl.format(iface.stats.tx_errors) +
- " (" +
- ((iface.stats.tx_errors / iface.stats.tx_packets) * 100).toFixed(1) +
- "%)",
- icon: "fa fa-upload fa-fw",
- });
- }
-
- if (iface.stats.tx_dropped !== undefined) {
- stats.nodes.push({
- text:
- "TX dropped: " +
- intl.format(iface.stats.tx_dropped) +
- " (" +
- ((iface.stats.tx_dropped / iface.stats.tx_packets) * 100).toFixed(1) +
- "%)",
- icon: "fa fa-upload fa-fw",
- });
- }
-
- if (iface.stats.multicast !== undefined) {
- stats.nodes.push({
- text: "Multicast: " + intl.format(iface.stats.multicast),
- icon: "fa fa-broadcast-tower fa-fw",
- });
- }
-
- if (iface.stats.collisions !== undefined) {
- stats.nodes.push({
- text: "Collisions: " + intl.format(iface.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: [],
- };
-
- furtherDetails.nodes.push(
- {
- text:
- "Carrier: " +
- (iface.carrier
- ? "Connected"
- : "Disconnected"),
- icon: "fa fa-link fa-fw",
- },
- {
- text: "State: " + utils.escapeHtml(iface.state.toUpperCase()),
- icon: "fa fa-server fa-fw",
- }
- );
-
- if (iface.parent_dev_name !== undefined) {
- let extra = "";
- if (iface.parent_dev_bus_name !== undefined) {
- extra = " @ " + utils.escapeHtml(iface.parent_dev_bus_name);
- }
-
- furtherDetails.nodes.push({
- text:
- "Parent device: " + utils.escapeHtml(iface.parent_dev_name) + extra + "",
- icon: "fa fa-network-wired fa-fw",
- });
- }
-
- if (iface.carrier_changes !== undefined) {
- furtherDetails.nodes.push({
- text: "Carrier changes: " + intl.format(iface.carrier_changes),
- icon: "fa fa-exchange-alt fa-fw",
- });
- }
-
- if (iface.broadcast) {
- furtherDetails.nodes.push({
- text: "Broadcast: " + utils.escapeHtml(iface.broadcast) + "",
- icon: "fa fa-broadcast-tower fa-fw",
- });
- }
-
- if (iface.mtu) {
- let extra = "";
- if (iface.min_mtu !== undefined && iface.max_mtu !== undefined) {
- extra +=
- " (min: " +
- intl.format(iface.min_mtu) +
- " bytes, max: " +
- intl.format(iface.max_mtu) +
- " bytes)";
- }
-
- furtherDetails.nodes.push({
- text: "MTU: " + intl.format(iface.mtu) + " bytes" + extra,
- icon: "fa fa-arrows-alt-h fa-fw",
- });
- }
-
- if (iface.txqlen) {
- furtherDetails.nodes.push({
- text: "TX queue length: " + intl.format(iface.txqlen),
- icon: "fa fa-file-upload fa-fw",
- });
- }
-
- if (iface.promiscuity !== undefined) {
- furtherDetails.nodes.push({
- text: "Promiscuity mode: " + (iface.promiscuity ? "Yes" : "No"),
- icon: "fa fa-eye fa-fw",
- });
- }
-
- if (iface.qdisc !== undefined) {
- furtherDetails.nodes.push({
- text: "Scheduler: " + utils.escapeHtml(iface.qdisc),
- icon: "fa fa-network-wired fa-fw",
- });
- }
-
- if (furtherDetails.nodes.length > 0) {
- obj.nodes.push(furtherDetails);
- }
-
- interfaces[iface.name] = obj;
- }
-
- // Sort interfaces based on masterInterfaces. If an item is found in
- // masterInterfaces, it should be placed after the master interface
- const ifaces = Object.keys(interfaces);
- const interfaceList = Object.keys(masterInterfaces);
-
- // Add slave interfaces next to master interfaces
- for (const master of interfaceList) {
- if (master in masterInterfaces) {
- for (const slave of masterInterfaces[master]) {
- ifaces.splice(ifaces.indexOf(slave), 1);
- interfaceList.splice(interfaceList.indexOf(master) + 1, 0, slave);
- }
- }
- }
-
- // Add interfaces that are not slaves at the top of the list (in reverse order)
- for (const iface of ifaces.reverse()) {
- if (!interfaceList.includes(iface)) {
- interfaceList.unshift(iface);
- }
- }
-
- // Build the tree view
- const json = [];
- for (const iface of interfaceList) {
- json.push(interfaces[iface]);
- }
-
- $("#tree").bstreeview({
- data: json,
- expandIcon: "fa fa-angle-down fa-fw",
- collapseIcon: "fa fa-angle-right fa-fw",
- parentsMarginLeft: "0",
- indent: 2.5,
+ renderTreeView(json);
+ expandGatewayInterfaces(gateways);
});
- $("#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");
- }
- });
});
+
+function extractGateways(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);
+ }
+
+ return gateways;
+}
+
+function processInterfaces(interfacesData, gateways, intl) {
+ const interfaces = {};
+ const masterInterfaces = {};
+
+ // For each interface in data.interface, create a new object and push it to json
+ for (const iface of interfacesData) {
+ const obj = createInterfaceObject({ iface, gateways, intl, masterInterfaces, interfacesData });
+ interfaces[iface.name] = obj;
+ }
+
+ return { interfaces, masterInterfaces };
+}
+
+function createInterfaceObject({ iface, gateways, intl, masterInterfaces, interfacesData }) {
+ const carrierColor = iface.carrier ? "text-green" : "text-red";
+ const stateText = determineStateText(iface);
+ const status = `${stateText}`;
+ const master = findMasterInterface({
+ iface,
+ masterInterfaces,
+ interfacesData,
+ });
+ // Show an icon for indenting slave interfaces
+ const icon = master === null ? "" : ' ⤷ ';
+ const obj = {
+ text: `${icon + iface.name} - ${status}`,
+ class: gateways.has(iface.name) ? "text-bold" : null,
+ icon: master === null ? "fa fa-network-wired fa-fw" : "",
+ nodes: [],
+ };
+
+ addMasterDetails(obj, master);
+ addSpeedDetails(obj, iface, intl);
+ addTypeDetails(obj, iface);
+ addFlagsDetails(obj, iface);
+ addHardwareAddressDetails(obj, iface);
+ addAddressDetails(obj, iface, intl);
+ addStatisticsDetails(obj, iface, intl);
+ addFurtherDetails(obj, iface, intl);
+
+ return obj;
+}
+
+function determineStateText(iface) {
+ let stateText = iface.state.toUpperCase();
+
+ if (stateText === "UNKNOWN" && iface.flags !== undefined && iface.flags.length > 0) {
+ // WireGuards, etc. -> the typo is intentional
+ if (iface.flags.includes("pointopoint")) {
+ stateText = "P2P";
+ // Loopback interfaces
+ } else if (iface.flags.includes("loopback")) {
+ stateText = "LOOPBACK";
+ }
+ }
+
+ return stateText;
+}
+
+function findMasterInterface({ iface, masterInterfaces, interfacesData }) {
+ if (iface.master === undefined) return null;
+
+ const masterObj = interfacesData.find(obj => obj.index === iface.master);
+ if (!masterObj) return null;
+
+ const masterName = masterObj.name;
+ if (masterName in masterInterfaces) {
+ masterInterfaces[masterName].push(iface.name);
+ } else {
+ masterInterfaces[masterName] = [iface.name];
+ }
+
+ return masterName;
+}
+
+function addMasterDetails(obj, master) {
+ if (master !== null) {
+ obj.nodes.push({
+ text: `Master interface: ${utils.escapeHtml(master)}`,
+ icon: "fa fa-network-wired fa-fw",
+ });
+ }
+}
+
+function addSpeedDetails(obj, iface, intl) {
+ if (iface.speed) {
+ obj.nodes.push({
+ text: `Speed: ${intl.format(iface.speed)} Mbit/s`,
+ icon: "fa fa-tachometer-alt fa-fw",
+ });
+ }
+}
+
+function addTypeDetails(obj, iface) {
+ if (iface.type !== undefined) {
+ obj.nodes.push({
+ text: `Type: ${utils.escapeHtml(iface.type)}`,
+ icon: "fa fa-network-wired fa-fw",
+ });
+ }
+}
+
+function addFlagsDetails(obj, iface) {
+ if (iface.flags !== undefined && iface.flags.length > 0) {
+ obj.nodes.push({
+ text: `Flags: ${utils.escapeHtml(iface.flags.join(", "))}`,
+ icon: "fa fa-flag fa-fw",
+ });
+ }
+}
+
+function addHardwareAddressDetails(obj, iface) {
+ if (iface.address === undefined) return;
+
+ const extra =
+ iface.perm_address !== undefined && iface.perm_address !== iface.address
+ ? ` (permanent: ${utils.escapeHtml(iface.perm_address)})`
+ : "";
+
+ obj.nodes.push({
+ text: `Hardware address: ${utils.escapeHtml(iface.address)}${extra}`,
+ icon: "fa fa-map-marker-alt fa-fw",
+ });
+}
+
+function addAddressDetails(obj, iface, intl) {
+ if (iface.addresses === undefined) return;
+
+ const count = iface.addresses.length;
+ const label = count === 1 ? " address" : " addresses";
+ const text = `${count + label} connected to interface`;
+
+ const addresses = {
+ text,
+ icon: "fa fa-map-marker-alt fa-fw",
+ nodes: [],
+ };
+
+ for (const addr of iface.addresses) {
+ const jaddr = createAddressNode(addr, intl);
+ addresses.nodes.push(jaddr);
+ }
+
+ obj.nodes.push(addresses);
+}
+
+function createAddressNode(addr, intl) {
+ let extraAddr = addr.prefixlen !== undefined ? ` / ${addr.prefixlen}` : "";
+
+ if (addr.address_type !== undefined) {
+ let familyextra = "";
+ if (addr.family === "inet") {
+ familyextra = "IPv4 ";
+ } else if (addr.family === "inet6") {
+ familyextra = "IPv6 ";
+ }
+
+ extraAddr += ` (${familyextra}${utils.escapeHtml(addr.address_type)})`;
+ }
+
+ const family = addr.family !== undefined ? `${addr.family}` : "";
+
+ const jaddr = {
+ text: `Address: ${family}${utils.escapeHtml(addr.address)}${extraAddr}`,
+ icon: "fa fa-map-marker-alt fa-fw",
+ nodes: [],
+ };
+
+ if (addr.local !== undefined) {
+ jaddr.nodes.push({
+ text: `Local: ${utils.escapeHtml(addr.local)}`,
+ icon: "fa fa-map-marker-alt fa-fw",
+ });
+ }
+
+ if (addr.broadcast !== undefined) {
+ jaddr.nodes.push({
+ text: `Broadcast: ${utils.escapeHtml(addr.broadcast)}`,
+ 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 === 4_294_967_295 ? "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 === 4_294_967_295 ? "forever" : `${intl.format(addr.valid)} s`;
+ jaddr.nodes.push({
+ text: `Valid lifetime: ${valid}`,
+ icon: "fa fa-clock fa-fw",
+ });
+ }
+
+ if (addr.cstamp !== undefined || addr.tstamp !== undefined) {
+ const formatter = new Intl.DateTimeFormat(undefined, {
+ dateStyle: "short",
+ timeStyle: "short",
+ });
+
+ if (addr.cstamp !== undefined) {
+ jaddr.nodes.push({
+ text: `Created: ${formatter.format(new Date(addr.cstamp * 1000))}`,
+ icon: "fa fa-clock fa-fw",
+ });
+ }
+
+ if (addr.tstamp !== undefined) {
+ jaddr.nodes.push({
+ text: `Last updated: ${formatter.format(new Date(addr.tstamp * 1000))}`,
+ icon: "fa fa-clock fa-fw",
+ });
+ }
+ }
+
+ return jaddr;
+}
+
+function addStatisticsDetails(obj, iface, intl) {
+ if (iface.stats === undefined) return;
+
+ const stats = {
+ text: "Statistics",
+ icon: "fa fa-chart-line fa-fw",
+ expanded: false,
+ nodes: [],
+ };
+
+ if (iface.stats.rx_bytes !== undefined) {
+ stats.nodes.push({
+ text: `RX bytes: ${intl.format(iface.stats.rx_bytes.value)} ${iface.stats.rx_bytes.unit}`,
+ icon: "fa fa-download fa-fw",
+ });
+ }
+
+ if (iface.stats.tx_bytes !== undefined) {
+ stats.nodes.push({
+ text: `TX bytes: ${intl.format(iface.stats.tx_bytes.value)} ${iface.stats.tx_bytes.unit}`,
+ icon: "fa fa-upload fa-fw",
+ });
+ }
+
+ if (iface.stats.rx_packets !== undefined) {
+ stats.nodes.push({
+ text: `RX packets: ${intl.format(iface.stats.rx_packets)}`,
+ icon: "fa fa-download fa-fw",
+ });
+ }
+
+ if (iface.stats.rx_errors !== undefined && iface.stats.rx_packets) {
+ const rxErrorPercentage = ((iface.stats.rx_errors / iface.stats.rx_packets) * 100).toFixed(1);
+ stats.nodes.push({
+ text: `RX errors: ${intl.format(iface.stats.rx_errors)} (${rxErrorPercentage}%)`,
+ icon: "fa fa-download fa-fw",
+ });
+ }
+
+ if (iface.stats.rx_dropped !== undefined && iface.stats.rx_packets) {
+ const rxDropped = iface.stats.rx_dropped;
+ const rxPackets = iface.stats.rx_packets;
+ const rxDroppedPercentage = ((rxDropped / rxPackets) * 100).toFixed(1);
+ stats.nodes.push({
+ text: `RX dropped: ${intl.format(rxDropped)} (${rxDroppedPercentage}%)`,
+ icon: "fa fa-download fa-fw",
+ });
+ }
+
+ if (iface.stats.tx_packets !== undefined) {
+ stats.nodes.push({
+ text: `TX packets: ${intl.format(iface.stats.tx_packets)}`,
+ icon: "fa fa-upload fa-fw",
+ });
+ }
+
+ if (iface.stats.tx_errors !== undefined && iface.stats.tx_packets) {
+ const txErrorPercentage = ((iface.stats.tx_errors / iface.stats.tx_packets) * 100).toFixed(1);
+ stats.nodes.push({
+ text: `TX errors: ${intl.format(iface.stats.tx_errors)} (${txErrorPercentage}%)`,
+ icon: "fa fa-upload fa-fw",
+ });
+ }
+
+ if (iface.stats.tx_dropped !== undefined && iface.stats.tx_packets) {
+ const txDropped = iface.stats.tx_dropped;
+ const txPackets = iface.stats.tx_packets;
+ const txDroppedPercentage = ((txDropped / txPackets) * 100).toFixed(1);
+ stats.nodes.push({
+ text: `TX dropped: ${intl.format(txDropped)} (${txDroppedPercentage}%)`,
+ icon: "fa fa-upload fa-fw",
+ });
+ }
+
+ if (iface.stats.multicast !== undefined) {
+ stats.nodes.push({
+ text: `Multicast: ${intl.format(iface.stats.multicast)}`,
+ icon: "fa fa-broadcast-tower fa-fw",
+ });
+ }
+
+ if (iface.stats.collisions !== undefined) {
+ stats.nodes.push({
+ text: `Collisions: ${intl.format(iface.stats.collisions)}`,
+ icon: "fa fa-exchange-alt fa-fw",
+ });
+ }
+
+ obj.nodes.push(stats);
+}
+
+function addFurtherDetails(obj, iface, intl) {
+ const furtherDetails = {
+ text: "Further details",
+ icon: "fa fa-info-circle fa-fw",
+ expanded: false,
+ nodes: [],
+ };
+
+ const carrierStatus = iface.carrier
+ ? 'Connected'
+ : 'Disconnected';
+
+ furtherDetails.nodes.push(
+ {
+ text: `Carrier: ${carrierStatus}`,
+ icon: "fa fa-link fa-fw",
+ },
+ {
+ text: `State: ${utils.escapeHtml(iface.state.toUpperCase())}`,
+ icon: "fa fa-server fa-fw",
+ }
+ );
+
+ if (iface.parent_dev_name !== undefined) {
+ const extra =
+ iface.parent_dev_bus_name !== undefined
+ ? ` @ ${utils.escapeHtml(iface.parent_dev_bus_name)}`
+ : "";
+
+ furtherDetails.nodes.push({
+ text: `Parent device: ${utils.escapeHtml(iface.parent_dev_name)}${extra}`,
+ icon: "fa fa-network-wired fa-fw",
+ });
+ }
+
+ if (iface.carrier_changes !== undefined) {
+ furtherDetails.nodes.push({
+ text: `Carrier changes: ${intl.format(iface.carrier_changes)}`,
+ icon: "fa fa-exchange-alt fa-fw",
+ });
+ }
+
+ if (iface.broadcast) {
+ furtherDetails.nodes.push({
+ text: `Broadcast: ${utils.escapeHtml(iface.broadcast)}`,
+ icon: "fa fa-broadcast-tower fa-fw",
+ });
+ }
+
+ if (iface.mtu) {
+ let extra = "";
+ if (iface.min_mtu !== undefined && iface.max_mtu !== undefined) {
+ const minMtu = intl.format(iface.min_mtu);
+ const maxMtu = intl.format(iface.max_mtu);
+ extra += ` (min: ${minMtu} bytes, max: ${maxMtu} bytes)`;
+ }
+
+ furtherDetails.nodes.push({
+ text: `MTU: ${intl.format(iface.mtu)} bytes${extra}`,
+ icon: "fa fa-arrows-alt-h fa-fw",
+ });
+ }
+
+ if (iface.txqlen) {
+ furtherDetails.nodes.push({
+ text: `TX queue length: ${intl.format(iface.txqlen)}`,
+ icon: "fa fa-file-upload fa-fw",
+ });
+ }
+
+ if (iface.promiscuity !== undefined) {
+ furtherDetails.nodes.push({
+ text: `Promiscuity mode: ${iface.promiscuity ? "Yes" : "No"}`,
+ icon: "fa fa-eye fa-fw",
+ });
+ }
+
+ if (iface.qdisc !== undefined) {
+ furtherDetails.nodes.push({
+ text: `Scheduler: ${utils.escapeHtml(iface.qdisc)}`,
+ icon: "fa fa-network-wired fa-fw",
+ });
+ }
+
+ if (furtherDetails.nodes.length > 0) {
+ obj.nodes.push(furtherDetails);
+ }
+}
+
+function sortInterfaces(interfaces, masterInterfaces) {
+ // Sort interfaces based on masterInterfaces. If an item is found in
+ // masterInterfaces, it should be placed after the master interface
+ const ifaces = Object.keys(interfaces);
+ const interfaceList = Object.keys(masterInterfaces);
+
+ // Add slave interfaces next to master interfaces
+ for (const master of interfaceList) {
+ if (master in masterInterfaces) {
+ for (const slave of masterInterfaces[master]) {
+ ifaces.splice(ifaces.indexOf(slave), 1);
+ interfaceList.splice(interfaceList.indexOf(master) + 1, 0, slave);
+ }
+ }
+ }
+
+ // Add interfaces that are not slaves at the top of the list (in reverse order)
+ for (const iface of ifaces.reverse()) {
+ if (!interfaceList.includes(iface)) {
+ interfaceList.unshift(iface);
+ }
+ }
+
+ return interfaceList;
+}
+
+function renderTreeView(json) {
+ $("#tree").bstreeview({
+ data: json,
+ expandIcon: "fa fa-angle-down fa-fw",
+ collapseIcon: "fa fa-angle-right fa-fw",
+ parentsMarginLeft: "0",
+ indent: 2.5,
+ });
+ document.getElementById("spinner").classList.add("d-none");
+}
+
+function expandGatewayInterfaces(gateways) {
+ // Expand gateway interfaces by default
+ const tree = document.getElementById("tree");
+ if (!tree) return;
+
+ for (const gw of gateways) {
+ // Find all divs containing the gateway name
+ const divs = tree.querySelectorAll("div");
+ for (const div of divs) {
+ if (!div.textContent.includes(gw)) continue;
+
+ div.classList.remove("collapsed");
+ const nextDiv = div.nextElementSibling;
+ if (nextDiv) $(nextDiv).collapse("show");
+
+ // Change expand icon to collapse icon
+ const icon = div.querySelector("i");
+ if (!icon) continue;
+
+ icon.classList.remove("fa-angle-right");
+ icon.classList.add("fa-angle-down");
+ }
+ }
+}
diff --git a/scripts/js/ip-address-sorting.js b/scripts/js/ip-address-sorting.js
index a3cdd96e..93ea20b9 100644
--- a/scripts/js/ip-address-sorting.js
+++ b/scripts/js/ip-address-sorting.js
@@ -38,6 +38,7 @@ $.extend($.fn.dataTableExt.oSort, {
cidr = m[3].split("/");
if (cidr.length === 2) {
m.pop();
+ // eslint-disable-next-line unicorn/prefer-spread
m = m.concat(cidr);
}
diff --git a/scripts/js/login.js b/scripts/js/login.js
index bea57e67..46ec2dd4 100644
--- a/scripts/js/login.js
+++ b/scripts/js/login.js
@@ -64,12 +64,9 @@ function wrongPassword(isError = false, isSuccess = false, data = null) {
// 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");
+ const forgotPwBox = document.getElementById("forgot-pw-box");
+ forgotPwBox.classList.replace("box-info", "box-danger");
+ utils.toggleBoxCollapse(forgotPwBox, true);
}
return;
@@ -85,9 +82,9 @@ function wrongPassword(isError = false, isSuccess = false, data = null) {
}
$("#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");
+ const forgotPwBox = document.getElementById("forgot-pw-box");
+ forgotPwBox.classList.replace("box-danger", "box-info");
+ utils.toggleBoxCollapse(forgotPwBox, false);
}
function doLogin(password) {
diff --git a/scripts/js/messages.js b/scripts/js/messages.js
index 55ce9b14..d5fb7e59 100644
--- a/scripts/js/messages.js
+++ b/scripts/js/messages.js
@@ -32,7 +32,7 @@ $(() => {
{ 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: "html", orderable: false, render: (data, _type) => data },
{ data: null, width: "22px", orderable: false },
],
columnDefs: [
diff --git a/scripts/js/queries.js b/scripts/js/queries.js
index af86a0c5..41091a27 100644
--- a/scripts/js/queries.js
+++ b/scripts/js/queries.js
@@ -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(
@@ -345,7 +353,7 @@ function formatInfo(data) {
if (dnssec.color !== "") {
dnssecInfo =
divStart +
- 'DNSSEC status:  ' +
dnssec.text +
@@ -362,7 +370,7 @@ function formatInfo(data) {
let replyInfo = "";
replyInfo =
data.reply.type !== "UNKNOWN"
- ? divStart + "Reply:  " + data.reply.type + "
"
+ ? divStart + "Reply: " + data.reply.type + ""
: divStart + "Reply: No reply received";
// Show extended DNS error if applicable
@@ -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 =
- '';
+ if (doDNSSEC === true) {
+ const dnssec = parseDNSSEC(data);
+ dnssecIcon =
+ '';
+ }
// Escape HTML in domain
domain = dnssecIcon + utils.escapeHtml(domain);
diff --git a/scripts/js/settings-dns-records.js b/scripts/js/settings-dns-records.js
index 7824421c..0686cf32 100644
--- a/scripts/js/settings-dns-records.js
+++ b/scripts/js/settings-dns-records.js
@@ -145,11 +145,6 @@ function populateDataTable(endpoint) {
});
}
-$(() => {
- populateDataTable("hosts");
- populateDataTable("cnameRecords");
-});
-
function deleteRecord() {
if ($(this).attr("data-type") === "hosts") delHosts($(this).attr("data-tag"));
else delCNAME($(this).attr("data-tag"));
@@ -215,9 +210,12 @@ function delCNAME(elem) {
}
$(() => {
+ populateDataTable("hosts");
+ populateDataTable("cnameRecords");
+
$("#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({
@@ -241,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
diff --git a/scripts/js/settings-dns.js b/scripts/js/settings-dns.js
index 6b23a9ff..747c1844 100644
--- a/scripts/js/settings-dns.js
+++ b/scripts/js/settings-dns.js
@@ -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 applyCheckboxRadioStyle:false, setConfigValues: false, apiFailure: false */
+/* global utils:false, applyCheckboxRadioStyle:false, setConfigValues: false, apiFailure: false */
"use strict";
@@ -94,6 +94,12 @@ function fillDNSupstreams(value, servers) {
// Initialize textfield
updateDNSserversTextfield(value.value, customServers);
+ // Expand the box if there are custom servers
+ if (customServers > 0) {
+ const customBox = document.getElementById("custom-servers-box");
+ utils.toggleBoxCollapse(customBox, true);
+ }
+
// Hide the loading animation
$("#dns-upstreams-overlay").hide();
diff --git a/scripts/js/settings-system.js b/scripts/js/settings-system.js
index 346b397e..ae5ebb73 100644
--- a/scripts/js/settings-system.js
+++ b/scripts/js/settings-system.js
@@ -139,7 +139,7 @@ function setMetrics(data, prefix) {
cacheData[val.name] = val.count;
} else if (typeof val === "object") {
setMetrics(val, prefix + key + "-");
- } else if (prefix === "sysinfo-dns-replies-") {
+ } else if (prefix === "sysinfo-dns-replies-" && data.sum !== 0) {
// Compute and display percentage of DNS replies in addition to the absolute value
const lval = val.toLocaleString();
const percent = (100 * val) / data.sum;
@@ -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);
diff --git a/scripts/js/taillog.js b/scripts/js/taillog.js
index dcc26838..0f9a9e45 100644
--- a/scripts/js/taillog.js
+++ b/scripts/js/taillog.js
@@ -40,17 +40,17 @@ function formatDnsmasq(line) {
return txt;
}
-function formatFTL(line, prio) {
+function formatFTL(line, priority) {
// Colorize priority
- let prioClass = "";
- switch (prio) {
+ let priorityClass = "";
+ switch (priority) {
case "INFO": {
- prioClass = "text-success";
+ priorityClass = "text-success";
break;
}
case "WARNING": {
- prioClass = "text-warning";
+ priorityClass = "text-warning";
break;
}
@@ -59,44 +59,68 @@ function formatFTL(line, prio) {
case "EMERG":
case "ALERT":
case "CRIT": {
- prioClass = "text-danger";
+ priorityClass = "text-danger";
break;
}
default:
- prioClass = prio.startsWith("DEBUG") ? "text-info" : "text-muted";
+ priorityClass = priority.startsWith("DEBUG") ? "text-info" : "text-muted";
}
// Return formatted line
- return `${utils.escapeHtml(prio)} ${line}`;
+ return `${utils.escapeHtml(priority)} ${line}`;
}
let gAutoScrolling;
// Function that asks the API for new data
function getData() {
- // Only update when spinner is spinning
- if (!$("#feed-icon").hasClass("fa-play")) {
+ // Only update when the feed icon has the fa-play class
+ const feedIcon = document.getElementById("feed-icon");
+ if (!feedIcon.classList.contains("fa-play")) {
utils.setTimer(getData, REFRESH_INTERVAL.logs);
return;
}
- const GETDict = utils.parseQueryString();
- if (!("file" in GETDict)) {
- globalThis.location.href += "?file=dnsmasq";
+ const queryParams = utils.parseQueryString();
+ const outputElement = document.getElementById("output");
+ const allowedFileParams = ["dnsmasq", "ftl", "webserver"];
+
+ // Check if file parameter exists
+ if (!queryParams.file) {
+ // Add default file parameter and redirect
+ const url = new URL(globalThis.location.href);
+ url.searchParams.set("file", "dnsmasq");
+ globalThis.location.href = url.toString();
return;
}
- $.ajax({
- url: document.body.dataset.apiurl + "/logs/" + GETDict.file + "?nextID=" + nextID,
- timeout: 5000,
+ // Validate that file parameter is one of the allowed values
+ if (!allowedFileParams.includes(queryParams.file)) {
+ const errorMessage = `Invalid file parameter: ${queryParams.file}. Allowed values are: ${allowedFileParams.join(", ")}`;
+ outputElement.innerHTML = `
*** Error: ${errorMessage} ***
`;
+ return;
+ }
+
+ const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
+ const url = `${document.body.dataset.apiurl}/logs/${queryParams.file}?nextID=${nextID}`;
+
+ fetch(url, {
method: "GET",
+ headers: {
+ "X-CSRF-TOKEN": csrfToken,
+ },
})
- .done(data => {
+ .then(response => (response.ok ? response.json() : apiFailure(response)))
+ .then(data => {
+ // Set filename
+ document.getElementById("filename").textContent = data.file;
+
// Check if we have a new PID -> FTL was restarted
if (lastPID !== data.pid) {
if (lastPID !== -1) {
- $("#output").append("
*** FTL restarted ***
");
+ outputElement.innerHTML +=
+ '
*** FTL restarted ***
';
}
// Remember PID
@@ -111,119 +135,167 @@ function getData() {
// Set placeholder text if log file is empty and we have no new lines
if (data.log.length === 0) {
if (nextID === 0) {
- $("#output").html("
*** Log file is empty ***
");
+ outputElement.innerHTML = "
*** Log file is empty ***
";
}
utils.setTimer(getData, REFRESH_INTERVAL.logs);
return;
}
+ // Create a document fragment to batch the DOM updates
+ const fragment = document.createDocumentFragment();
+
// We have new lines
if (markUpdates && nextID > 0) {
// Add red fading out background to new lines
- $("#output").append('').children(":last").fadeOut(2000);
+ const hr = document.createElement("hr");
+ hr.className = "hr-small fade-2s";
+ fragment.append(hr);
+ }
+
+ // Limit output to lines
+ // Check if adding these new lines would exceed maxlines
+ const totalAfterAdding =
+ outputElement.children.length + data.log.length + (markUpdates && nextID > 0 ? 1 : 0);
+
+ // If we'll exceed maxlines, remove old elements first
+ if (totalAfterAdding > maxlines) {
+ const elementsToRemove = totalAfterAdding - maxlines;
+ const elements = [...outputElement.children];
+ const elementsToKeep = elements.slice(elementsToRemove);
+ outputElement.replaceChildren(...elementsToKeep);
}
for (const line of data.log) {
// 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(
- '
"
- );
- if (fadeIn) {
- //$(".left-line:last").fadeOut(2000);
- $("#output").children(":last").hide().fadeIn("fast");
+ // Format line if applicable
+ if (queryParams.file === "dnsmasq") {
+ line.message = formatDnsmasq(line.message);
+ } else if (queryParams.file === "ftl") {
+ line.message = formatFTL(line.message, line.prio);
}
+
+ // Create and add new log entry to fragment
+ const logEntry = document.createElement("div");
+ const logEntryDate = moment(1000 * line.timestamp).format("YYYY-MM-DD HH:mm:ss.SSS");
+ logEntry.className = `log-entry${fadeIn ? " hidden-entry" : ""}`;
+ logEntry.innerHTML = `${logEntryDate} ${line.message}`;
+
+ fragment.append(logEntry);
}
- // Limit output to lines
- const lines = $("#output").val().split("\n");
- if (lines.length > maxlines) {
- lines.splice(0, lines.length - maxlines);
- $("#output").val(lines.join("\n"));
+ // Append all new elements at once
+ outputElement.append(fragment);
+
+ if (fadeIn) {
+ // Fade in the new log entries
+ const newEntries = outputElement.querySelectorAll(".hidden-entry");
+
+ for (const entry of newEntries) {
+ entry.classList.add("fade-in-transition");
+ }
+
+ // Force a reflow once before changing opacity
+ void outputElement.offsetWidth; // eslint-disable-line no-void
+
+ requestAnimationFrame(() => {
+ for (const entry of newEntries) {
+ entry.classList.remove("hidden-entry");
+ entry.style.opacity = 1;
+ }
+ });
+
+ // Clean up after animation completes
+ setTimeout(() => {
+ for (const entry of newEntries) {
+ entry.classList.remove("fade-in-transition");
+ }
+ }, 200);
}
// Scroll to bottom of output if we are already at the bottom
if (gAutoScrolling) {
// Auto-scrolling is enabled
- $("#output").scrollTop($("#output")[0].scrollHeight);
+ requestAnimationFrame(() => {
+ outputElement.scrollTop = outputElement.scrollHeight;
+ });
}
// Update nextID
nextID = data.nextID;
- // Set filename
- $("#filename").text(data.file);
-
utils.setTimer(getData, REFRESH_INTERVAL.logs);
})
- .fail(data => {
- apiFailure(data);
+ .catch(error => {
+ apiFailure(error);
utils.setTimer(getData, 5 * REFRESH_INTERVAL.logs);
});
}
gAutoScrolling = true;
-$("#output").on("scroll", () => {
- // 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 * Number.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");
- }
-});
+document.getElementById("output").addEventListener(
+ "scroll",
+ event => {
+ const output = event.currentTarget;
+
+ // Check if we are at the bottom of the output
+ //
+ // - output.scrollHeight: This gets the entire height of the content
+ // of the "output" element, including the part that is not visible due to
+ // scrolling.
+ // - output.clientHeight: 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 { scrollHeight, clientHeight, scrollTop } = output;
+ // Add a tolerance of four line heights
+ const tolerance = 4 * Number.parseFloat(getComputedStyle(output).lineHeight);
+
+ // Determine if the output is scrolled to the bottom within the tolerance
+ const isAtBottom = scrollHeight - clientHeight - scrollTop <= tolerance;
+ gAutoScrolling = isAtBottom;
+
+ const autoScrollingElement = document.getElementById("autoscrolling");
+ if (isAtBottom) {
+ autoScrollingElement.classList.add("fa-check");
+ autoScrollingElement.classList.remove("fa-xmark");
+ } else {
+ autoScrollingElement.classList.add("fa-xmark");
+ autoScrollingElement.classList.remove("fa-check");
+ }
+ },
+ { passive: true }
+);
$(() => {
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");
+ const liveFeed = document.getElementById("live-feed");
+ const feedIcon = document.getElementById("feed-icon");
+ const title = document.getElementById("title");
+
+ // Clicking on the element with ID "live-feed" will toggle the play/pause state
+ liveFeed.addEventListener("click", event => {
+ // Determine current state based on whether feedIcon has the "fa-play" class
+ const isPlaying = feedIcon.classList.contains("fa-play");
+
+ if (isPlaying) {
+ feedIcon.classList.add("fa-pause");
+ feedIcon.classList.remove("fa-fade", "fa-play");
+ event.currentTarget.classList.add("btn-danger");
+ event.currentTarget.classList.remove("btn-success");
+ title.textContent = "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");
+ feedIcon.classList.add("fa-play", "fa-fade");
+ event.currentTarget.classList.add("btn-success");
+ event.currentTarget.classList.remove("btn-danger");
+ title.textContent = "Live";
}
});
});
diff --git a/scripts/js/utils.js b/scripts/js/utils.js
index 27cffcd8..e063168e 100644
--- a/scripts/js/utils.js
+++ b/scripts/js/utils.js
@@ -418,33 +418,6 @@ function checkMessages() {
});
}
-// Show only the appropriate delete buttons in datatables
-function changeBulkDeleteStates(table) {
- const allRows = table.rows({ filter: "applied" }).data().length;
- const pageLength = table.page.len();
- const 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(url) {
$.ajax({
url: document.body.dataset.apiurl + "/auth",
@@ -474,34 +447,35 @@ function renderTimespan(data, type) {
return data;
}
-function htmlPass(data, _type) {
- return data;
-}
-
-// Show only the appropriate buttons
+// Show only the appropriate delete buttons in datatables
function changeTableButtonStates(table) {
+ const selectAllElements = document.querySelectorAll(".selectAll");
+ const selectMoreElements = document.querySelectorAll(".selectMore");
+ const removeAllElements = document.querySelectorAll(".removeAll");
+ const deleteSelectedElements = document.querySelectorAll(".deleteSelected");
+
const allRows = table.rows({ filter: "applied" }).data().length;
const pageLength = table.page.len();
const selectedRows = table.rows(".selected").data().length;
if (selectedRows === 0) {
// Nothing selected
- $(".selectAll").removeClass("hidden");
- $(".selectMore").addClass("hidden");
- $(".removeAll").addClass("hidden");
- $(".deleteSelected").addClass("hidden");
+ for (const el of selectAllElements) el.classList.remove("hidden");
+ for (const el of selectMoreElements) el.classList.add("hidden");
+ for (const el of removeAllElements) el.classList.add("hidden");
+ for (const el of deleteSelectedElements) el.classList.add("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");
+ for (const el of selectAllElements) el.classList.add("hidden");
+ for (const el of selectMoreElements) el.classList.add("hidden");
+ for (const el of removeAllElements) el.classList.remove("hidden");
+ for (const el of deleteSelectedElements) el.classList.remove("hidden");
} else {
// Some rows are selected, but not all
- $(".selectAll").addClass("hidden");
- $(".selectMore").removeClass("hidden");
- $(".removeAll").addClass("hidden");
- $(".deleteSelected").removeClass("hidden");
+ for (const el of selectAllElements) el.classList.add("hidden");
+ for (const el of selectMoreElements) el.classList.remove("hidden");
+ for (const el of removeAllElements) el.classList.add("hidden");
+ for (const el of deleteSelectedElements) el.classList.remove("hidden");
}
}
@@ -517,26 +491,19 @@ function parseQueryString() {
return Object.fromEntries(params.entries());
}
-// https://stackoverflow.com/q/21647928
-function hexEncode(string) {
- let result = "";
- for (let i = 0; i < string.length; i++) {
- const hex = string.codePointAt(i).toString(16);
- result += ("000" + hex).slice(-4);
- }
+function hexEncode(text) {
+ if (typeof text !== "string" || text.length === 0) return "";
- return result;
+ return [...text].map(char => char.codePointAt(0).toString(16).padStart(4, "0")).join("");
}
-// https://stackoverflow.com/q/21647928
-function hexDecode(string) {
- const hexes = string.match(/.{1,4}/g) || [];
- let back = "";
- for (const hex of hexes) {
- back += String.fromCodePoint(Number.parseInt(hex, 16));
- }
+function hexDecode(text) {
+ if (typeof text !== "string" || text.length === 0) return "";
- return back;
+ const hexes = text.match(/.{1,4}/g);
+ if (!hexes || hexes.length === 0) return "";
+
+ return hexes.map(hex => String.fromCodePoint(Number.parseInt(hex, 16))).join("");
}
function listsAlert(type, items, data) {
@@ -680,6 +647,30 @@ function setInter(func, interval) {
globalThis.setTimeout(setInter, interval, func, interval);
}
+/**
+ * Toggle or set the collapse state of a box element
+ * @param {HTMLElement} box - The box element
+ * @param {boolean} [expand=true] - Whether to expand (true) or collapse (false) the box
+ */
+// Not using the AdminLTE API so that the expansion is not animated
+// Otherwise, we could use `$(customBox).boxWidget("expand")`
+function toggleBoxCollapse(box, expand = true) {
+ if (!box) return;
+
+ const icon = box.querySelector(".btn-box-tool > i");
+ const body = box.querySelector(".box-body");
+
+ if (expand) {
+ box.classList.remove("collapsed-box");
+ if (icon) icon.classList.replace("fa-plus", "fa-minus");
+ if (body) body.style = "";
+ } else {
+ box.classList.add("collapsed-box");
+ if (icon) icon.classList.replace("fa-minus", "fa-plus");
+ if (body) body.style.display = "none";
+ }
+}
+
globalThis.utils = (function () {
return {
escapeHtml,
@@ -702,11 +693,9 @@ globalThis.utils = (function () {
toPercent,
colorBar,
checkMessages,
- changeBulkDeleteStates,
doLogout,
renderTimestamp,
renderTimespan,
- htmlPass,
changeTableButtonStates,
getCSSval,
parseQueryString,
@@ -716,5 +705,6 @@ globalThis.utils = (function () {
loadingOverlay,
setTimer,
setInter,
+ toggleBoxCollapse,
};
})();
diff --git a/scripts/lua/header.lp b/scripts/lua/header.lp
index 8246e8e2..860a1d6f 100644
--- a/scripts/lua/header.lp
+++ b/scripts/lua/header.lp
@@ -1,4 +1,4 @@
-
+
-
-
+
- if startsWith(scriptname, 'groups') then
- -- Group management styles
-?>
-
- end ?>
if is_authenticated then ?>
+ if startsWith(scriptname, 'groups') then ?>
+
+ end ?>
+
+
- end ?>
-
+ end ?>
@@ -133,7 +131,5 @@ is_authenticated = mg.request_info.is_authenticated
-
-
diff --git a/scripts/lua/header_authenticated.lp b/scripts/lua/header_authenticated.lp
index 22db44ae..c544cf7a 100644
--- a/scripts/lua/header_authenticated.lp
+++ b/scripts/lua/header_authenticated.lp
@@ -9,20 +9,22 @@
]]--
mg.include('header.lp','r')
?>
+
-
+
+
-
+