diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4783404f..27be9826 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -26,21 +26,21 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 with: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.18 + uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 #v4.30.9 with: config-file: ./.github/codeql/codeql-config.yml languages: "javascript" queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v3.28.18 + uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 #v4.30.9 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.18 + uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 #v4.30.9 with: category: "/language:javascript" diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index f0545956..6d18000d 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 with: persist-credentials: false - name: Spell-Checking - uses: codespell-project/actions-codespell@master + uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 #v2.1 with: ignore_words_file: .codespellignore skip: ./vendor,./package.json,./package-lock.json diff --git a/.github/workflows/editorconfig-checker.yml b/.github/workflows/editorconfig-checker.yml index 6aa9f269..5a081611 100644 --- a/.github/workflows/editorconfig-checker.yml +++ b/.github/workflows/editorconfig-checker.yml @@ -10,8 +10,8 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 with: persist-credentials: false - - uses: editorconfig-checker/action-editorconfig-checker@main + - uses: editorconfig-checker/action-editorconfig-checker@5ecdd656fe347c26f76b1b435b90e1d74fb5e787 # tag v2. is really out of date - run: editorconfig-checker diff --git a/.github/workflows/merge-conflict.yml b/.github/workflows/merge-conflict.yml index 457ef61f..745879d9 100644 --- a/.github/workflows/merge-conflict.yml +++ b/.github/workflows/merge-conflict.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check if PRs are have merge conflicts - uses: eps1lon/actions-label-merge-conflict@v3.0.3 + uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 #v3.0.3 with: dirtyLabel: "Merge Conflicts" repoToken: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 2e87c1f1..ef651f03 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: issues: write steps: - - uses: actions/stale@v9.1.0 + - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 #v10.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 30 @@ -41,7 +41,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 with: persist-credentials: false - name: Remove 'stale' label diff --git a/.github/workflows/stale_pr.yml b/.github/workflows/stale_pr.yml index 08033013..79102019 100644 --- a/.github/workflows/stale_pr.yml +++ b/.github/workflows/stale_pr.yml @@ -16,7 +16,7 @@ jobs: pull-requests: write steps: - - uses: actions/stale@v9.1.0 + - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 #v10.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} # Do not automatically mark PR/issue as stale diff --git a/.github/workflows/sync-back-to-dev.yml b/.github/workflows/sync-back-to-dev.yml index 0116402c..504d9d2c 100644 --- a/.github/workflows/sync-back-to-dev.yml +++ b/.github/workflows/sync-back-to-dev.yml @@ -11,7 +11,7 @@ jobs: name: Syncing branches steps: - name: Clone repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 with: persist-credentials: false - name: Opening pull request diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0df0b50b..b8b17260 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,12 +19,12 @@ jobs: steps: - name: Clone repository - uses: actions/checkout@v4.2.2 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 with: persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0 with: node-version: "22.x" cache: npm diff --git a/error403.lp b/error403.lp index e9e41c51..bdf54a4f 100644 --- a/error403.lp +++ b/error403.lp @@ -9,14 +9,15 @@ mg.include('scripts/lua/header.lp','r') ?> - + +

403

Oops! Access denied.

- You don't have permission to access on this server.
+ You don't have permission to access this URL.
Did you mean to go to your Pi-hole's dashboard instead?

diff --git a/error404.lp b/error404.lp index 7dc63e9f..db032d50 100644 --- a/error404.lp +++ b/error404.lp @@ -9,7 +9,8 @@ mg.include('scripts/lua/header.lp','r') ?> - + + -
diff --git a/login.lp b/login.lp index 1ac17150..5816082f 100644 --- a/login.lp +++ b/login.lp @@ -9,7 +9,8 @@ mg.include('scripts/lua/header.lp','r') ?> - + +
- " + ? 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( - '
' + - 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 (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 @@ - + - - + - - - + + + + + - - + @@ -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') ?> + - + + - +