/* Pi-hole: A black hole for Internet advertisements
* (c) 2017 Pi-hole, LLC (https://pi-hole.net)
* Network-wide ad blocking via your own hardware.
*
* This file is copyright under the latest version of the EUPL.
* Please see LICENSE file for your rights under this license. */
/* global luxon: false, apiFailure: false, utils: false, REFRESH_INTERVAL: false */
"use strict";
let nextID = 0;
let lastPID = -1;
// Maximum number of lines to display
const maxlines = 5000;
// Fade in new lines
const fadeIn = true;
// Mark new lines with a red line above them
const markUpdates = true;
// Format a line of the dnsmasq log
function formatDnsmasq(line) {
// Remove dnsmasq + PID
let txt = line.replaceAll(/ dnsmasq\[\d*]/g, "");
if (line.includes("denied") || line.includes("gravity blocked")) {
// Red bold text for blocked domains
txt = `${txt}`;
} else if (line.includes("query[A") || line.includes("query[DHCP")) {
// Bold text for initial query lines
txt = `${txt}`;
} else {
// Grey text for all other lines
txt = `${txt}`;
}
return txt;
}
function formatFTL(line, priority) {
// Colorize priority
let priorityClass = "";
switch (priority) {
case "INFO": {
priorityClass = "text-success";
break;
}
case "WARNING": {
priorityClass = "text-warning";
break;
}
case "ERR":
case "ERROR":
case "EMERG":
case "ALERT":
case "CRIT": {
priorityClass = "text-danger";
break;
}
default:
priorityClass = priority.startsWith("DEBUG") ? "text-info" : "text-muted";
}
// Return formatted line
return `${utils.escapeHtml(priority)} ${line}`;
}
let gAutoScrolling;
// Function that asks the API for new data
function getData() {
// 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 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;
}
// 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,
},
})
.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) {
outputElement.innerHTML +=
'*** FTL restarted ***
';
}
// Remember PID
lastPID = data.pid;
// Reset nextID
nextID = 0;
getData();
return;
}
// Set placeholder text if log file is empty and we have no new lines
if (data.log.length === 0) {
if (nextID === 0) {
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
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 (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 = luxon.DateTime.fromMillis(1000 * line.timestamp).toFormat(
"yyyy-MM-dd HH:mm:ss.SSS"
);
logEntry.className = `log-entry${fadeIn ? " hidden-entry" : ""}`;
logEntry.innerHTML = `${logEntryDate} ${line.message}`;
fragment.append(logEntry);
}
// 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
requestAnimationFrame(() => {
outputElement.scrollTop = outputElement.scrollHeight;
});
}
// Update nextID
nextID = data.nextID;
utils.setTimer(getData, REFRESH_INTERVAL.logs);
})
.catch(error => {
apiFailure(error);
utils.setTimer(getData, 5 * REFRESH_INTERVAL.logs);
});
}
gAutoScrolling = true;
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();
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 {
feedIcon.classList.add("fa-play", "fa-fade");
event.currentTarget.classList.add("btn-success");
event.currentTarget.classList.remove("btn-danger");
title.textContent = "Live";
}
});
});