From: Alexander Moisseev Date: Sun, 5 Jul 2026 13:51:58 +0000 (+0300) Subject: [Rework] WebUI: remove jQuery from stats module X-Git-Tag: 4.1.3~52^2~9 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=4fdb794c0bc481e6f1f1896a8a9c8cf8f38d2c1c;p=thirdparty%2Frspamd.git [Rework] WebUI: remove jQuery from stats module Migrate stats.js off jQuery. The delegated mouseenter/mouseleave hover highlighting (which doesn't bubble natively) is reimplemented on mouseover/mouseout with a relatedTarget-containment check; the jQuery sibling-traversal chains (prevAll/nextAll/slice/find) move to native sibling walks. $.each becomes Object.entries/forEach, $(html).appendTo becomes insertAdjacentHTML, and $.wrapAll becomes common.el DOM construction. The legacy /auth fan-out keeps a raw XHR to preserve its custom error message and alerted-session dedup; $.when().always becomes Promise.all().finally. Adds common.getAjaxTimeout so direct XHRs honour the configured timeout. --- diff --git a/interface/js/app/common.js b/interface/js/app/common.js index c6f6f17218..87ea0a78a7 100644 --- a/interface/js/app/common.js +++ b/interface/js/app/common.js @@ -64,6 +64,12 @@ define(["nprogress"], ajaxTimeout = ms; }; + // Read by callers that issue direct XHRs (outside common.query) and need + // to honour the configured AJAX timeout. + ui.getAjaxTimeout = function () { + return ajaxTimeout; + }; + ui.onAjaxStart = function (callback) { ajaxStartCallbacks.push(callback); }; diff --git a/interface/js/app/stats.js b/interface/js/app/stats.js index bd7a34ea6a..2c2d5d4bb6 100644 --- a/interface/js/app/stats.js +++ b/interface/js/app/stats.js @@ -2,8 +2,8 @@ * Copyright (C) 2017 Vsevolod Stakhov */ -define(["jquery", "app/common", "d3pie", "d3"], - ($, common, D3Pie, d3) => { +define(["app/common", "d3pie", "d3"], + (common, D3Pie, d3) => { "use strict"; // @ ms to date function msToTime(seconds) { @@ -37,58 +37,117 @@ define(["jquery", "app/common", "d3pie", "d3"], let rowspanHoverHandlersInitialized = false; + // Parse JSON, returning {} for an empty body or a non-JSON response + // (jQuery without dataType hands non-JSON bodies to success as raw text; + // {} yields the same undefined property reads downstream). + function parseJsonOrEmpty(text) { + try { + return text ? JSON.parse(text) : {}; + } catch (err) { + return {}; + } + } + function attachRowspanHoverHandlers(tableId) { - const $table = $(tableId); + const table = document.querySelector(tableId); + + function prevSiblingCount(el) { + let n = 0; + let prev = el.previousElementSibling; + while (prev) { + n++; + prev = prev.previousElementSibling; + } + return n; + } - function findRowspanCells($row) { - const headerCount = $table.find("thead th").length; - if ($row.find("td").length >= headerCount) return []; + // Cells with rowspan in rows above `row` that still span into it. + function findRowspanCells(row) { + const headerCount = table.querySelectorAll("thead th").length; + if (row.querySelectorAll("td").length >= headerCount) return []; + const rowIndex = prevSiblingCount(row); const result = []; - $row.prevAll().find("td[rowspan]").each((_, el) => { - const $cell = $(el); - const rowspan = parseInt($cell.attr("rowspan"), 10); - const distance = $row.prevAll().length - $cell.parent().prevAll().length; - if (distance < rowspan) { - result.push($cell); - } - }); + let prevRow = row.previousElementSibling; + while (prevRow) { + prevRow.querySelectorAll("td[rowspan]").forEach((cell) => { + const rowspan = parseInt(cell.getAttribute("rowspan"), 10); + const distance = rowIndex - prevSiblingCount(cell.parentElement); + if (distance < rowspan) result.push(cell); + }); + prevRow = prevRow.previousElementSibling; + } return result; } - $table.on("mouseenter", "tbody td", (event) => { - const $cell = $(event.currentTarget); - const $row = $cell.parent(); + function addClassTo(cells) { + cells.forEach((cell) => cell.classList.add("table-hover-cell")); + } + + function highlightCell(cell) { + const row = cell.parentElement; - if ($cell.attr("rowspan")) { + if (cell.getAttribute("rowspan")) { // Hovering over rowspan cell - highlight entire group - const rowspan = parseInt($cell.attr("rowspan"), 10); - $cell.addClass("table-hover-cell"); - $row.find("td").addClass("table-hover-cell"); - $row.nextAll().slice(0, rowspan - 1).find("td").addClass("table-hover-cell"); - - // Also highlight parent rowspan cells (e.g., server when hovering classifier) - findRowspanCells($row).forEach(($parentCell) => { - if ($parentCell[0] !== $cell[0]) $parentCell.addClass("table-hover-cell"); + const rowspan = parseInt(cell.getAttribute("rowspan"), 10); + addClassTo(row.querySelectorAll("td")); + let next = row.nextElementSibling; + for (let i = 0; i < rowspan - 1 && next; i++) { + addClassTo(next.querySelectorAll("td")); + next = next.nextElementSibling; + } + + // Also highlight parent rowspan cells (e.g. server when hovering classifier) + findRowspanCells(row).forEach((parentCell) => { + if (parentCell !== cell) parentCell.classList.add("table-hover-cell"); }); } else { // Hovering over regular cell - highlight current row - $row.find("td").addClass("table-hover-cell"); + addClassTo(row.querySelectorAll("td")); // Highlight all rowspan cells for this row - findRowspanCells($row).forEach(($rowspanCell) => $rowspanCell.addClass("table-hover-cell")); + addClassTo(findRowspanCells(row)); + } + } + + // jQuery's delegated mouseenter/mouseleave is simulated via mouseover/ + // mouseout (mouseenter/mouseleave don't bubble). Fire only on a genuine + // enter/leave of the matched td by checking relatedTarget containment. + // + // For mouseover `related` is the element the pointer left; for mouseout + // the element it entered. Either way the test is the same: a crossing + // happened iff `related` is null (entered/left the document) or lies + // outside `td`. td.contains(td) is true, so moving between a td and its + // own descendant — or staying within it — does not register as a crossing. + function crosses(td, related) { + return !related || !td.contains(related); + } + + table.addEventListener("mouseover", (event) => { + const td = event.target.closest("tbody td"); + if (td && table.contains(td) && crosses(td, event.relatedTarget)) { + highlightCell(td); } - }).on("mouseleave", "tbody td", () => $table.find("tbody td").removeClass("table-hover-cell")); + }); + table.addEventListener("mouseout", (event) => { + const td = event.target.closest("tbody td"); + if (td && table.contains(td) && crosses(td, event.relatedTarget)) { + table.querySelectorAll("tbody td").forEach((cell) => { + cell.classList.remove("table-hover-cell"); + }); + } + }); } function displayStatWidgets(checked_server) { const servers = JSON.parse(sessionStorage.getItem("Credentials") || "{}"); const data = servers[checked_server]?.data ?? {}; + const statWidgets = document.getElementById("statWidgets"); const stat_w = []; - $("#statWidgets").empty(); - common.hide("#statWidgets"); - $.each(data, (i, item) => { + statWidgets.replaceChildren(); + common.hide(statWidgets); + Object.entries(data).forEach(([i, item]) => { const widgetsOrder = ["scanned", "no action", "greylist", "add header", "rewrite subject", "reject", "learned"]; function widget(k, v, cls) { @@ -108,29 +167,36 @@ define(["jquery", "app/common", "d3pie", "d3"], cls = ""; val = msToTime(item); } - $('
' + - val + "" + i + "
") - .appendTo("#statWidgets"); + statWidgets.insertAdjacentHTML("beforeend", + '
' + + val + "" + i + "
"); } else if (i === "actions") { - $.each(item, (action, count) => { + Object.entries(item).forEach(([action, count]) => { stat_w[widgetsOrder.indexOf(action)] = widget(action, count); }); } else { stat_w[widgetsOrder.indexOf(i)] = widget(i, item, " text-capitalize"); } }); - $.each(stat_w, (i, item) => { - $(item).appendTo("#statWidgets"); - }); - $("#statWidgets > div:not(.stat-box)") - .wrapAll('
' + - '
'); - $("#statWidgets").find("div.float-end").appendTo("#statWidgets"); - common.show("#statWidgets"); - - $("#clusterTable tbody").empty(); - $("#selSrv").empty(); - $.each(servers, (key, val) => { + stat_w.forEach((html) => statWidgets.insertAdjacentHTML("beforeend", html)); + + // Wrap the uptime/version widgets (the non-stat-box children) in a + // trailing card, mirroring $.wrapAll + moving the float-end card last. + const nonStatBoxDivs = Array.from(statWidgets.children) + .filter((child) => child.tagName === "DIV" && !child.classList.contains("stat-box")); + if (nonStatBoxDivs.length) { + const inner = common.el("div", {class: "widget overflow-hidden p-2 text-capitalize"}, + ...nonStatBoxDivs); + statWidgets.append(common.el("div", + {class: "card stat-box text-center shadow-sm float-end"}, inner)); + } + common.show(statWidgets); + + const clusterTbody = document.querySelector("#clusterTable tbody"); + const selSrv = document.getElementById("selSrv"); + clusterTbody.replaceChildren(); + selSrv.replaceChildren(); + Object.entries(servers).forEach(([key, val]) => { let row_class = "danger"; let glyph_status = "fas fa-times"; let version = "???"; @@ -176,36 +242,40 @@ define(["jquery", "app/common", "d3pie", "d3"], } } - $("#clusterTable tbody").append('' + - '' + - "" + key + "" + - "" + val.host + "" + - '' + - '" + scan_times.data + "" + - '' + uptime + "" + - "" + version + "" + - "" + short_id + ""); - - $("#selSrv").append($('")); - - if (checked_server === key) { - $('#clusterTable tbody [value="' + key + '"]').prop("checked", true); - $('#selSrv [value="' + key + '"]').prop("selected", true); - } else if (!val.status) { - $('#clusterTable tbody [value="' + key + '"]').prop("disabled", true); - $('#selSrv [value="' + key + '"]').prop("disabled", true); - } + const checked = checked_server === key; + const disabled = !checked && !val.status; + const escKey = common.escapeHTML(key); + const escHost = common.escapeHTML(val.host); + const radioAttrs = 'value="' + escKey + '"' + + (checked ? " checked" : "") + (disabled ? " disabled" : ""); + + clusterTbody.insertAdjacentHTML("beforeend", + '' + + '" + + "" + escKey + "" + + "" + escHost + "" + + '' + + '" + scan_times.data + "" + + '' + uptime + "" + + "" + common.escapeHTML(version) + "" + + "" + common.escapeHTML(short_id) + "" + ); + + selSrv.insertAdjacentHTML("beforeend", + '"); }); function addStatfiles(server, statfiles) { const safeStatfiles = Array.isArray(statfiles) ? statfiles : []; const classToSymbolClass = {spam: "symbol-positive", ham: "symbol-negative"}; const rowsCount = safeStatfiles.length; + const bayesTbody = document.querySelector("#bayesTable tbody"); function coerceNumber(value) { return (Number.isFinite(value) ? value : Number(value) || 0); } @@ -235,7 +305,7 @@ define(["jquery", "app/common", "d3pie", "d3"], return cls ? `${value}` : `${value}`; } - $.each(safeStatfiles, (i, statfile) => { + safeStatfiles.forEach((statfile, i) => { const symbol = statfile.symbol ?? "-"; const classValue = statfile.class ?? guessClassFromSymbol(symbol); const cls = classToSymbolClass[classValue] || ""; @@ -255,7 +325,7 @@ define(["jquery", "app/common", "d3pie", "d3"], classifierCell = `${formatClassifierLabel(statfile)}`; } - $("#bayesTable tbody").append(`${serverCell}${classifierCell}${[ + bayesTbody.insertAdjacentHTML("beforeend", `${serverCell}${classifierCell}${[ renderCell(common.escapeHTML(classValue), cls), renderCell(common.escapeHTML(symbol), cls), renderCell(common.escapeHTML(statfile.type ?? "-"), cls), @@ -267,20 +337,22 @@ define(["jquery", "app/common", "d3pie", "d3"], function addFuzzyStorage(server, storages) { let i = 0; - $.each(storages, (storage, hashes) => { + const fuzzyTbody = document.querySelector("#fuzzyTable tbody"); + Object.entries(storages).forEach(([storage, hashes]) => { const serverCell = (i === 0) ? '' + common.escapeHTML(server) + "" : ""; - $("#fuzzyTable tbody").append("" + serverCell + + fuzzyTbody.insertAdjacentHTML("beforeend", "" + serverCell + "" + common.escapeHTML(storage) + "" + '' + hashes + ""); i++; }); } - $("#bayesTable tbody, #fuzzyTable tbody").empty(); + document.querySelectorAll("#bayesTable tbody, #fuzzyTable tbody") + .forEach((tbody) => tbody.replaceChildren()); if (checked_server === "All SERVERS") { - $.each(servers, (server, val) => { + Object.entries(servers).forEach(([server, val]) => { if (server !== "All SERVERS") { addStatfiles(server, val.data.statfiles); addFuzzyStorage(server, val.data.fuzzy_hashes); @@ -388,17 +460,14 @@ define(["jquery", "app/common", "d3pie", "d3"], // Get config_id, version and uptime using /auth query for Rspamd 2.5 and earlier function get_legacy_stat(e) { const alerted = "alerted_stats_legacy_" + neighbours_status[e].name; - promises.push($.ajax({ - url: neighbours_status[e].url + "auth", - headers: {Password: common.getPassword()}, - success: function (data) { - sessionStorage.removeItem(alerted); - ["config_id", "version", "uptime"].forEach((p) => { - neighbours_status[e].data[p] = data[p]; - }); - process_node_stat(e); - }, - error: function (jqXHR, textStatus, errorThrown) { + promises.push(new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open("GET", neighbours_status[e].url + "auth", true); + xhr.setRequestHeader("Password", common.getPassword()); + const timeout = common.getAjaxTimeout(); + if (timeout > 0) xhr.timeout = timeout; + + function onFailure(errorThrown) { if (!(alerted in sessionStorage)) { sessionStorage.setItem(alerted, true); common.logError({ @@ -406,12 +475,30 @@ define(["jquery", "app/common", "d3pie", "d3"], endpoint: "graph", message: "Cannot receive legacy stats data" + (errorThrown ? ": " + errorThrown : ""), - httpStatus: jqXHR.status, + httpStatus: xhr.status, errorType: "http_error" }); } process_node_stat(e); + resolve(); } + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + sessionStorage.removeItem(alerted); + const data = parseJsonOrEmpty(xhr.responseText); + ["config_id", "version", "uptime"].forEach((p) => { + neighbours_status[e].data[p] = data[p]; + }); + process_node_stat(e); + resolve(); + } else { + onFailure(xhr.statusText); + } + }; + xhr.onerror = () => onFailure(xhr.statusText); + xhr.ontimeout = () => onFailure("timeout"); + xhr.send(); })); } @@ -431,7 +518,7 @@ define(["jquery", "app/common", "d3pie", "d3"], } } setTimeout(() => { - $.when.apply($, promises).always(() => { + Promise.all(promises).finally(() => { neighbours_sum.uptime = Math.floor(neighbours_sum.uptime / status_count); to_Credentials["All SERVERS"].data = neighbours_sum; sessionStorage.setItem("Credentials", JSON.stringify(to_Credentials)); @@ -440,7 +527,11 @@ define(["jquery", "app/common", "d3pie", "d3"], }); }, promises.length ? 100 : 0); }, - complete: function () { $("#refresh").removeAttr("disabled").removeClass("disabled"); }, + complete: function () { + const refreshBtn = document.getElementById("refresh"); + refreshBtn.disabled = false; + refreshBtn.classList.remove("disabled"); + }, errorMessage: "Cannot receive stats data", errorOnceId: "alerted_stats_", server: "All SERVERS"