From 306edb2f9ef4ae6f81eeac0044a9a1a15793790f Mon Sep 17 00:00:00 2001 From: XhmikosR Date: Sat, 5 Apr 2025 23:28:18 +0300 Subject: [PATCH 1/2] tailog: convert to vanilla JS and improve performance Signed-off-by: XhmikosR --- scripts/js/taillog.js | 234 ++++++++++++++++++++++++++---------------- style/pi-hole.css | 11 ++ 2 files changed, 157 insertions(+), 88 deletions(-) diff --git a/scripts/js/taillog.js b/scripts/js/taillog.js index dcc26838..d3cda1f5 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,24 +59,25 @@ 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; } @@ -87,16 +88,25 @@ function getData() { return; } - $.ajax({ - url: document.body.dataset.apiurl + "/logs/" + GETDict.file + "?nextID=" + nextID, - timeout: 5000, + const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content"); + const outputElement = document.getElementById("output"); + + fetch(`${document.body.dataset.apiurl}/logs/${GETDict.file}?nextID=${nextID}`, { 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 +121,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( - '
' + - moment(1000 * line.timestamp).format("YYYY-MM-DD HH:mm:ss.SSS") + - " " + - line.message + - "
" - ); - if (fadeIn) { - //$(".left-line:last").fadeOut(2000); - $("#output").children(":last").hide().fadeIn("fast"); + // 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); } + + // 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/style/pi-hole.css b/style/pi-hole.css index 849a33d8..c23c7640 100644 --- a/style/pi-hole.css +++ b/style/pi-hole.css @@ -1576,3 +1576,14 @@ textarea.field-sizing-content { line-height: 0.5em; font-size: 1.7em; } + +.hidden-entry { + opacity: 0; +} +.fade-2s { + animation: fadeOut 2s forwards; +} +.fade-in-transition { + opacity: 0; + transition: opacity 200ms ease-in-out; +} From 59a43c7d2652150508ec147dd5a1f675ac791812 Mon Sep 17 00:00:00 2001 From: XhmikosR Date: Sun, 13 Apr 2025 18:25:26 +0300 Subject: [PATCH 2/2] tailog: validate file URL param and guard against infinite loop Signed-off-by: XhmikosR --- scripts/js/taillog.js | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/scripts/js/taillog.js b/scripts/js/taillog.js index d3cda1f5..0f9a9e45 100644 --- a/scripts/js/taillog.js +++ b/scripts/js/taillog.js @@ -82,16 +82,30 @@ function getData() { 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; + } + + // 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 outputElement = document.getElementById("output"); + const url = `${document.body.dataset.apiurl}/logs/${queryParams.file}?nextID=${nextID}`; - fetch(`${document.body.dataset.apiurl}/logs/${GETDict.file}?nextID=${nextID}`, { + fetch(url, { method: "GET", headers: { "X-CSRF-TOKEN": csrfToken, @@ -157,9 +171,9 @@ function getData() { line.message = utils.escapeHtml(line.message); // Format line if applicable - if (GETDict.file === "dnsmasq") { + if (queryParams.file === "dnsmasq") { line.message = formatDnsmasq(line.message); - } else if (GETDict.file === "ftl") { + } else if (queryParams.file === "ftl") { line.message = formatFTL(line.message, line.prio); }