From: Alexander Moisseev Date: Mon, 5 Jan 2026 17:03:48 +0000 (+0300) Subject: [Feature] WebUI: add backend API interaction error log X-Git-Tag: 3.14.3~10^2~1 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=b0b10f4b6e2426ce7901a3b6882a10f784800d6f;p=thirdparty%2Frspamd.git [Feature] WebUI: add backend API interaction error log Add an error log modal with a responsive table providing: - tracking of the last 50 errors using a circular buffer - an "unseen since last view" counter on the badge in bottom-right corner - copy-to-clipboard support with execCommand fallback for HTTP connections - color-coded error types - automatic column hiding on smaller screens --- diff --git a/interface/css/rspamd.css b/interface/css/rspamd.css index e3703ff95b..d8b35ee65d 100644 --- a/interface/css/rspamd.css +++ b/interface/css/rspamd.css @@ -749,6 +749,15 @@ table#symbolsTable input[type="number"] { } } +/* Error log badge: enable hover effect on semi-transparent button */ +#error-log-badge .btn:hover { + background-color: var(--bs-danger) !important; + color: var(--bs-white) !important; +} +[data-theme="dark"] #error-log-badge .btn:hover { + border-color: var(--bs-light); +} + /* Dark mode overrides for Bootstrap tables */ [data-theme="dark"] .table { --bs-table-color-state: var(--rspamd-text-primary); diff --git a/interface/index.html b/interface/index.html index 909b16ffa7..9cf7f4cbc5 100644 --- a/interface/index.html +++ b/interface/index.html @@ -809,6 +809,62 @@ + +
+ +
+ + + + diff --git a/interface/js/app/common.js b/interface/js/app/common.js index da3a40b3cd..035e8660a9 100644 --- a/interface/js/app/common.js +++ b/interface/js/app/common.js @@ -57,6 +57,156 @@ define(["jquery", "nprogress"], }, 5000); } + // Forward declare updateErrorBadge to resolve circular dependency + // This function is called by errorLog methods but uses errorLog data + // Safe due to hoisting: function is called AFTER errorLog initialization + function updateErrorBadge() { + const unseenCount = errorLog.getUnseenCount(); // eslint-disable-line no-use-before-define + const totalCount = errorLog.errors.length; // eslint-disable-line no-use-before-define + const badge = $("#error-log-badge"); + const counter = $("#error-count"); + + // Show badge if there are any errors + if (totalCount > 0) { + badge.removeClass("d-none"); + // Show counter only if there are unseen errors + if (unseenCount > 0) { + counter.removeClass("d-none"); + counter.text(unseenCount); + } else { + counter.addClass("d-none"); + } + } else { + badge.addClass("d-none"); + } + } + + // Error log storage + const errorLog = { + errors: [], + maxSize: 50, + lastViewedIndex: -1, // Track last viewed error for "unseen" counter + + add(entry) { + this.errors.push({ + timestamp: new Date(), + server: entry.server ?? "Unknown", + endpoint: entry.endpoint ?? "", + message: entry.message ?? "Unknown error", + httpStatus: entry.httpStatus ?? null, + errorType: entry.errorType ?? "unknown" + }); + + // Keep last 50 errors + if (this.errors.length > this.maxSize) { + this.errors.shift(); + // Adjust lastViewedIndex after shift + if (this.lastViewedIndex >= 0) { + this.lastViewedIndex--; + } + } + + updateErrorBadge(); + }, + + clear() { + this.errors = []; + this.lastViewedIndex = -1; + updateErrorBadge(); + }, + + getAll() { + return this.errors; + }, + + markAsViewed() { + // Mark all current errors as viewed + this.lastViewedIndex = this.errors.length - 1; + updateErrorBadge(); + }, + + getUnseenCount() { + // Return count of errors added since last view + return Math.max(0, this.errors.length - this.lastViewedIndex - 1); + } + }; + + function updateErrorLogTable() { + const tbody = $("#errorLogTable tbody"); + const noErrors = $("#noErrorsMessage"); + const copyBtn = $("#copyErrorLog"); + const clearBtn = $("#clearErrorLog"); + + tbody.empty(); + + const hasErrors = errorLog.errors.length > 0; + + if (!hasErrors) { + $("#errorLogTable").hide(); + noErrors.show(); + copyBtn.prop("disabled", true); + clearBtn.prop("disabled", true); + return; + } + + $("#errorLogTable").show(); + noErrors.hide(); + copyBtn.prop("disabled", false); + clearBtn.prop("disabled", false); + + // Show errors in reverse chronological order (newest first) + errorLog.errors.slice().reverse().forEach((err) => { + const time = ui.locale + ? err.timestamp.toLocaleString(ui.locale) + : err.timestamp.toLocaleString(); + const status = err.httpStatus ?? "-"; + const row = $(""); + + // Map error types to Bootstrap badge colors + const errorTypeColors = { + auth: "text-bg-danger", + network: "text-bg-primary", + timeout: "text-bg-info", + http_error: "text-bg-warning", + data_inconsistency: "text-bg-secondary" + }; + const badgeClass = errorTypeColors[err.errorType] || "text-bg-secondary"; + + // Column order: Time | Error | Server | Endpoint | HTTP Status | Type + row.append($('').text(time)); + row.append($("").text(err.message)); + row.append($('').text(err.server)); + row.append($('') + .append($('').text(err.endpoint))); + row.append($('').text(status)); + row.append($('') + .append($(``).text(err.errorType))); + tbody.append(row); + }); + } + + /** + * Log error and optionally show alert message + * + * @param {Object} options - Error details + * @param {string} options.server - Server name or "Multi-server" for cluster-wide issues + * @param {string} [options.endpoint=""] - API endpoint or empty string + * @param {string} options.message - Error message + * @param {number} [options.httpStatus=null] - HTTP status code or null + * @param {string} options.errorType - Error type: timeout|auth|http_error|network|data_inconsistency + * @param {boolean} [options.showAlert=true] - Whether to show alert message + */ + function logError({httpStatus, endpoint, errorType, message, server, showAlert}) { + errorLog.add({httpStatus, endpoint, errorType, message, server}); + + if (showAlert !== false) { + const fullMessage = (server !== "Multi-server") + ? server + " > " + message + : message; + alertMessage("alert-danger", fullMessage); + } + } + /** * Perform a request to a single Rspamd neighbour server. * @@ -102,23 +252,42 @@ define(["jquery", "nprogress"], }, error: function (jqXHR, textStatus, errorThrown) { neighbours_status[ind].checked = true; - function errorMessage() { - alertMessage("alert-danger", neighbours_status[ind].name + " > " + - (o.errorMessage ? o.errorMessage : "Request failed") + - (errorThrown ? ": " + errorThrown : "")); + + // Determine error type and create detailed message + let errorType = "network"; + let detailedMessage = errorThrown || "Request failed"; + + if (textStatus === "timeout") { + errorType = "timeout"; + detailedMessage = "Request timeout"; + } else if (jqXHR.status === 401 || jqXHR.status === 403) { + errorType = "auth"; + detailedMessage = "Authentication failed"; + } else if (jqXHR.status >= 400 && jqXHR.status < 600) { + errorType = "http_error"; + detailedMessage = "HTTP " + jqXHR.status + (errorThrown ? ": " + errorThrown : ""); + } else if (textStatus === "error" && jqXHR.status === 0) { + errorType = "network"; + detailedMessage = "Network error"; } - if (o.error) { - o.error(neighbours_status[ind], - jqXHR, textStatus, errorThrown); - } else if (o.errorOnceId) { - const alert_status = o.errorOnceId + neighbours_status[ind].name; - if (!(alert_status in sessionStorage)) { - sessionStorage.setItem(alert_status, true); - errorMessage(); - } - } else { - errorMessage(); + + // Log error and show alert + const shouldShowAlert = !o.error && + !(o.errorOnceId && (o.errorOnceId + neighbours_status[ind].name) in sessionStorage); + if (o.errorOnceId && shouldShowAlert) { + sessionStorage.setItem(o.errorOnceId + neighbours_status[ind].name, true); } + logError({ + server: neighbours_status[ind].name, + endpoint: req_url, + message: o.errorMessage ? o.errorMessage + ": " + detailedMessage : detailedMessage, + httpStatus: jqXHR.status, + errorType: errorType, + showAlert: shouldShowAlert + }); + + // Call custom error handler if provided + if (o.error) o.error(neighbours_status[ind], jqXHR, textStatus, errorThrown); }, complete: function (jqXHR) { if (neighbours_status.every((elt) => elt.checked)) { @@ -153,6 +322,7 @@ define(["jquery", "nprogress"], ui.alertMessage = alertMessage; ui.getPassword = getPassword; + ui.logError = logError; // Get selectors' current state ui.getSelector = function (id) { @@ -405,5 +575,120 @@ define(["jquery", "nprogress"], } }; + // Error log event handlers + $(document).ready(() => { + // Update error log table when modal is shown + $("#errorLogModal").on("show.bs.modal", () => { + updateErrorLogTable(); + // Mark all errors as viewed when modal is opened + errorLog.markAsViewed(); + }); + + // Clear error log + $("#clearErrorLog").on("click", () => { + errorLog.clear(); + updateErrorLogTable(); + }); + + // Copy to clipboard + $("#copyErrorLog").on("click", () => { + if (errorLog.errors.length === 0) return; + + const selection = window.getSelection(); + let textToCopy = ""; + + // Check if user has selected text in the table + if (selection.toString().trim().length > 0) { + textToCopy = selection.toString(); + } else { + // Copy entire log + const headers = ["Time", "Error", "Server", "Endpoint", "HTTP Status", "Type"]; + textToCopy = headers.join("\t") + "\n"; + + errorLog.errors.slice().reverse().forEach((err) => { + const time = ui.locale + ? err.timestamp.toLocaleString(ui.locale) + : err.timestamp.toLocaleString(); + const status = err.httpStatus ?? "-"; + const row = [time, err.message, err.server, err.endpoint, status, err.errorType]; + textToCopy += row.join("\t") + "\n"; + }); + } + + // Copy to clipboard with fallback for HTTP + function copyToClipboard(text) { + // Try modern Clipboard API first (HTTPS only) + const clip = navigator.clipboard; + if (clip && clip.writeText) return clip.writeText(text); + + // Fallback for HTTP or older browsers using execCommand + return new Promise((resolve, reject) => { + let textarea = null; + function cleanup(o) { + if (o && o.parentNode) o.parentNode.removeChild(textarea); + } + + try { + textarea = document.createElement("textarea"); + textarea.value = text; + + // Critical: must be visible and in viewport for some browsers + textarea.style.position = "fixed"; + textarea.style.top = "50%"; + textarea.style.left = "50%"; + textarea.style.width = "1px"; + textarea.style.height = "1px"; + textarea.style.padding = "0"; + textarea.style.border = "none"; + textarea.style.outline = "none"; + textarea.style.boxShadow = "none"; + textarea.style.background = "transparent"; + textarea.style.zIndex = "99999"; + + // Add to modal body instead of document.body to avoid focus trap + const modalBody = document.querySelector("#errorLogModal .modal-body"); + if (modalBody) { + modalBody.appendChild(textarea); + } else { + document.body.appendChild(textarea); + } + + // Force reflow to ensure textarea is rendered + textarea.offsetHeight; // eslint-disable-line no-unused-expressions + + // Select all text + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + // Execute copy immediately while focused + const successful = document.execCommand("copy"); + + cleanup(textarea); + + if (successful) { + resolve(); + } else { + reject(new Error("Copy command failed (execCommand returned false)")); + } + } catch (err) { + cleanup(textarea); + reject(err); + } + }); + } + + copyToClipboard(textToCopy) + .then(() => { + // Show success feedback + const btn = $("#copyErrorLog"); + const originalHtml = btn.html(); + btn.html(' Copied!'); + setTimeout(() => btn.html(originalHtml), 2000); + }) + .catch((err) => alertMessage("alert-danger", "Failed to copy to clipboard: " + err.message)); + }); + }); + return ui; }); diff --git a/interface/js/app/graph.js b/interface/js/app/graph.js index 588da61c6f..336162a901 100644 --- a/interface/js/app/graph.js +++ b/interface/js/app/graph.js @@ -196,8 +196,13 @@ define(["jquery", "app/common", "d3evolution", "d3pie", "d3", "footable"], if ((curr[0][0].x !== res[0][0].x) || (curr[0][curr[0].length - 1].x !== res[0][res[0].length - 1].x)) { time_match = false; - common.alertMessage("alert-danger", - "Neighbours time extents do not match. Check if time is synchronized on all servers."); + common.logError({ + server: "Multi-server", + endpoint: "graph", + message: "Neighbours time extents do not match. " + + "Check if time is synchronized on all servers.", + errorType: "data_inconsistency" + }); arr.splice(1); // Break out of .reduce() by mutating the source array } return curr; diff --git a/interface/js/app/history.js b/interface/js/app/history.js index 96709434cc..e35f51fea8 100644 --- a/interface/js/app/history.js +++ b/interface/js/app/history.js @@ -144,8 +144,12 @@ define(["jquery", "app/common", "app/libft", "footable"], function differentVersions(neighbours_data) { const dv = neighbours_data.some((e) => e.version !== neighbours_data[0].version); if (dv) { - common.alertMessage("alert-danger", - "Neighbours history backend versions do not match. Cannot display history."); + common.logError({ + server: "Multi-server", + endpoint: "history", + message: "Neighbours history backend versions do not match. Cannot display history.", + errorType: "data_inconsistency" + }); return true; } return false; diff --git a/interface/js/app/stats.js b/interface/js/app/stats.js index aa777b9e7d..bba3095267 100644 --- a/interface/js/app/stats.js +++ b/interface/js/app/stats.js @@ -348,8 +348,14 @@ define(["jquery", "app/common", "d3pie", "d3"], error: function (jqXHR, textStatus, errorThrown) { if (!(alerted in sessionStorage)) { sessionStorage.setItem(alerted, true); - common.alertMessage("alert-danger", neighbours_status[e].name + " > " + - "Cannot receive legacy stats data" + (errorThrown ? ": " + errorThrown : "")); + common.logError({ + server: neighbours_status[e].name, + endpoint: "graph", + message: "Cannot receive legacy stats data" + + (errorThrown ? ": " + errorThrown : ""), + httpStatus: jqXHR.status, + errorType: "http_error" + }); } process_node_stat(e); } diff --git a/interface/js/app/upload.js b/interface/js/app/upload.js index 5c281c3dee..2bb85cd08b 100644 --- a/interface/js/app/upload.js +++ b/interface/js/app/upload.js @@ -108,7 +108,13 @@ define(["jquery", "app/common", "app/libft"], errorMessage: "Cannot upload data", statusCode: { 404: function () { - common.alertMessage("alert-danger", "Cannot upload data, no server found"); + common.logError({ + server: common.getServer(), + endpoint: "checkv2", + message: "Cannot upload data, no server found", + httpStatus: 404, + errorType: "http_error" + }); }, 500: function () { common.alertMessage("alert-danger", "Cannot tokenize message: no text data");