← Back
Editing: stats.js
/* * Copyright (C) 2017 Vsevolod Stakhov <vsevolod@highsecure.ru> */ define(["app/common", "d3pie", "d3"], (common, D3Pie, d3) => { "use strict"; // @ ms to date function msToTime(seconds) { if (!Number.isFinite(seconds)) return "???"; /* eslint-disable no-bitwise */ const years = seconds / 31536000 >> 0; // 3600*24*365 const months = seconds % 31536000 / 2628000 >> 0; // 3600*24*365/12 const days = seconds % 31536000 % 2628000 / 86400 >> 0; // 24*3600 const hours = seconds % 31536000 % 2628000 % 86400 / 3600 >> 0; const minutes = seconds % 31536000 % 2628000 % 86400 % 3600 / 60 >> 0; /* eslint-enable no-bitwise */ // eslint-disable-next-line no-useless-assignment let out = null; if (years > 0) { if (months > 0) { out = years + "yr " + months + "mth"; } else { out = years + "yr " + days + "d"; } } else if (months > 0) { out = months + "mth " + days + "d"; } else if (days > 0) { out = days + "d " + hours + "hr"; } else if (hours > 0) { out = hours + "hr " + minutes + "min"; } else { out = minutes + "min"; } return out; } 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 = document.querySelector(tableId); function prevSiblingCount(el) { let n = 0; let prev = el.previousElementSibling; while (prev) { n++; prev = prev.previousElementSibling; } return n; } // 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 = []; 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; } function addClassTo(cells) { cells.forEach((cell) => cell.classList.add("table-hover-cell")); } function highlightCell(cell) { const row = cell.parentElement; if (cell.getAttribute("rowspan")) { // Hovering over rowspan cell - highlight entire group 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 addClassTo(row.querySelectorAll("td")); // Highlight all rowspan cells for this row 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); } }); 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.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) { const c = (typeof cls === "undefined") ? "" : cls; const titleAtt = d3.format(",")(v) + " " + k; return '<div class="card stat-box d-inline-block text-center shadow-sm me-3 px-3">' + '<div class="widget overflow-hidden p-2' + c + '" title="' + titleAtt + '"><strong class="d-block mt-2 mb-1 fw-bold">' + d3.format(".3~s")(v) + "</strong>" + k + "</div></div>"; } if (i === "auth" || i === "error") return; // Skip to the next iteration if (i === "uptime" || i === "version") { let cls = "border-end "; let val = item; if (i === "uptime") { cls = ""; val = msToTime(item); } statWidgets.insertAdjacentHTML("beforeend", '<div class="' + cls + 'float-start px-3"><strong class="d-block mt-2 mb-1 fw-bold">' + val + "</strong>" + i + "</div>"); } else if (i === "actions") { 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"); } }); 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 = "???"; let uptime = "???"; let short_id = "???"; let scan_times = { data: "???", title: "" }; if (val.status) { row_class = "success"; glyph_status = "fas fa-check"; if (Number.isFinite(val.data.uptime)) { uptime = msToTime(val.data.uptime); } if ("version" in val.data) { ({version} = val.data); } if (key === "All SERVERS") { short_id = ""; scan_times.data = ""; } else { if ("config_id" in val.data) { short_id = val.data.config_id.substring(0, 8); } if ("scan_times" in val.data) { const [min, max] = d3.extent(val.data.scan_times); if (max) { const f = d3.format(".3f"); scan_times = { data: "<small>" + f(min) + "/</small>" + f(d3.mean(val.data.scan_times)) + "<small>/" + f(max) + "</small>", title: ' title="min/avg/max"' }; } else { scan_times = { data: "-", title: ' title="Have not scanned anything yet"' }; } } } } 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", '<tr class="' + row_class + '">' + '<td class="align-middle"><input type="radio" class="form-check m-auto" name="clusterName" ' + radioAttrs + "></td>" + "<td>" + escKey + "</td>" + "<td>" + escHost + "</td>" + '<td class="text-center"><span class="icon"><i class="' + glyph_status + '"></i></span></td>' + '<td class="text-center"' + scan_times.title + ">" + scan_times.data + "</td>" + '<td class="text-end' + ((Number.isFinite(val.data.uptime) && val.data.uptime < 3600) ? ' warning" title="Has been restarted within the last hour"' : "") + '">' + uptime + "</td>" + "<td>" + common.escapeHTML(version) + "</td>" + "<td>" + common.escapeHTML(short_id) + "</td></tr>" ); selSrv.insertAdjacentHTML("beforeend", '<option value="' + escKey + '"' + (checked ? " selected" : "") + (disabled ? " disabled" : "") + ">" + escKey + "</option>"); }); 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); } function guessClassFromSymbol(symbol) { if (!symbol) return "-"; const upperSymbol = symbol.toUpperCase(); if (upperSymbol.includes("SPAM")) return "spam"; if (upperSymbol.includes("HAM")) return "ham"; return "-"; } function formatClassifierLabel(statfile) { const classifier = statfile.classifier ?? {}; const badges = []; function badge(cls, text) { return ` <span class="badge ${cls} ms-1">${text}</span>`; } if (classifier.type === "multi-class") badges.push(badge("bg-secondary", "multi-class")); if (classifier.per_user) badges.push(badge("bg-info", "per-user")); return common.escapeHTML(classifier.name ?? "-") + badges.join(""); } function renderCell(value, className) { const cls = className?.trim(); return cls ? `<td class="${cls}">${value}</td>` : `<td>${value}</td>`; } safeStatfiles.forEach((statfile, i) => { const symbol = statfile.symbol ?? "-"; const classValue = statfile.class ?? guessClassFromSymbol(symbol); const cls = classToSymbolClass[classValue] || ""; const clName = statfile.classifier?.name ?? "-"; const prevClName = i > 0 ? (safeStatfiles[i - 1].classifier?.name ?? "-") : null; const serverCell = i === 0 ? `<td rowspan="${rowsCount}">${common.escapeHTML(server)}</td>` : ""; let classifierCell = ""; if (clName !== prevClName) { let groupSize = 1; for (let k = i + 1; k < safeStatfiles.length; k++) { if ((safeStatfiles[k].classifier?.name ?? "-") === clName) { groupSize++; } else break; } classifierCell = `<td rowspan="${groupSize}">${formatClassifierLabel(statfile)}</td>`; } bayesTbody.insertAdjacentHTML("beforeend", `<tr>${serverCell}${classifierCell}${[ renderCell(common.escapeHTML(classValue), cls), renderCell(common.escapeHTML(symbol), cls), renderCell(common.escapeHTML(statfile.type ?? "-"), cls), renderCell(coerceNumber(statfile.revision), `text-end ${cls}`), renderCell(coerceNumber(statfile.users), `text-end ${cls}`), ].join("")}</tr>`); }); } function addFuzzyStorage(server, storages) { let i = 0; const fuzzyTbody = document.querySelector("#fuzzyTable tbody"); Object.entries(storages || {}).forEach(([storage, hashes]) => { const serverCell = (i === 0) ? '<td rowspan="' + Object.keys(storages || {}).length + '">' + common.escapeHTML(server) + "</td>" : ""; fuzzyTbody.insertAdjacentHTML("beforeend", "<tr>" + serverCell + "<td>" + common.escapeHTML(storage) + "</td>" + '<td class="text-end">' + hashes + "</td></tr>"); i++; }); } document.querySelectorAll("#bayesTable tbody, #fuzzyTable tbody") .forEach((tbody) => tbody.replaceChildren()); if (checked_server === "All SERVERS") { Object.entries(servers).forEach(([server, val]) => { if (server !== "All SERVERS") { addStatfiles(server, val.data.statfiles); addFuzzyStorage(server, val.data.fuzzy_hashes); } }); } else { addStatfiles(checked_server, data.statfiles); addFuzzyStorage(checked_server, data.fuzzy_hashes); } if (!rowspanHoverHandlersInitialized) { attachRowspanHoverHandlers("#bayesTable"); attachRowspanHoverHandlers("#fuzzyTable"); rowspanHoverHandlersInitialized = true; } } function getChart(graphs, checked_server) { if (!graphs.chart) { graphs.chart = new D3Pie("chart", { labels: { inner: { offset: 0 }, outer: { collideHeight: 18, } }, size: { pieInnerRadius: "50%" }, title: "Rspamd filter stats", total: { enabled: true, label: "Scanned" } }); } const data = []; const creds = JSON.parse(sessionStorage.getItem("Credentials") || "{}"); // Controller doesn't return the 'actions' object until at least one message is scanned if (creds[checked_server]?.data?.scanned) { const {actions} = creds[checked_server].data; ["no action", "soft reject", "add header", "rewrite subject", "greylist", "reject"] .forEach((action) => { data.push({ color: common.chartLegend.find((item) => item.label === action).color, label: action, value: actions[action] }); }); } graphs.chart.data(data); } // Public API const ui = { statWidgets: function (graphs, checked_server) { common.query("stat", { success: function (neighbours_status) { const neighbours_sum = { version: neighbours_status[0].data.version, uptime: 0, scanned: 0, learned: 0, actions: { "no action": 0, "add header": 0, "rewrite subject": 0, "greylist": 0, "reject": 0, "soft reject": 0, } }; let status_count = 0; const promises = []; const to_Credentials = { "All SERVERS": { name: "All SERVERS", url: "", host: "", checked: true, status: true } }; function process_node_stat(e) { const {data} = neighbours_status[e]; // Controller doesn't return the 'actions' object until at least one message is scanned if (data.scanned) { for (const action in neighbours_sum.actions) { if ({}.hasOwnProperty.call(neighbours_sum.actions, action)) { neighbours_sum.actions[action] += data.actions[action]; } } } ["learned", "scanned", "uptime"].forEach((p) => { neighbours_sum[p] += data[p]; }); status_count++; } // 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(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({ server: neighbours_status[e].name, endpoint: "graph", message: "Cannot receive legacy stats data" + (errorThrown ? ": " + errorThrown : ""), 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(); })); } for (const e in neighbours_status) { if ({}.hasOwnProperty.call(neighbours_status, e)) { to_Credentials[neighbours_status[e].name] = neighbours_status[e]; if (neighbours_status[e].status === true) { // Remove alert status sessionStorage.removeItem("alerted_stats_" + neighbours_status[e].name); if ({}.hasOwnProperty.call(neighbours_status[e].data, "version")) { process_node_stat(e); } else { get_legacy_stat(e); } } } } setTimeout(() => { 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)); displayStatWidgets(checked_server); getChart(graphs, checked_server); }); }, promises.length ? 100 : 0); }, 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" }); }, }; return ui; } );
Save File
Cancel