]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Feature] WebUI: add backend API interaction error log
authorAlexander Moisseev <moiseev@mezonplus.ru>
Mon, 5 Jan 2026 17:03:48 +0000 (20:03 +0300)
committerAlexander Moisseev <moiseev@mezonplus.ru>
Mon, 5 Jan 2026 17:03:48 +0000 (20:03 +0300)
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

interface/css/rspamd.css
interface/index.html
interface/js/app/common.js
interface/js/app/graph.js
interface/js/app/history.js
interface/js/app/stats.js
interface/js/app/upload.js

index e3703ff95b772cdb7911c17b9da50d020ea3f284..d8b35ee65deb7ffb1842db0fd5b924e5d4049386 100644 (file)
@@ -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);
index 909b16ffa70523c2a8dfdd9e33244c6ddbfa0f52..9cf7f4cbc5499492c0870b7f3b22ba1e11d2241a 100644 (file)
        </div>
 </div>
 
+<!-- Error Log Badge -->
+<div id="error-log-badge" class="position-fixed bottom-0 end-0 m-3 d-none" style="z-index: 1050;">
+       <button class="btn btn-sm btn-danger bg-light bg-opacity-75 text-danger shadow position-relative"
+                       data-bs-toggle="modal"
+                       data-bs-target="#errorLogModal"
+                       title="View backend API interaction errors"
+                       aria-label="View backend API interaction errors">
+               <i class="fas fa-exclamation-triangle"></i>
+               <span id="error-count" class="badge text-bg-warning bg-opacity-100 position-absolute top-0 start-100 translate-middle d-none">0</span>
+       </button>
+</div>
+
+<!-- Backend API Interaction Errors Modal -->
+<div class="modal fade" id="errorLogModal" tabindex="-1" aria-labelledby="errorLogModalLabel" aria-hidden="true">
+       <div class="modal-dialog modal-xl modal-dialog-scrollable">
+               <div class="modal-content">
+                       <div class="modal-header text-secondary py-2 d-flex align-items-center">
+                               <span class="icon me-3"><i class="fas fa-exclamation-triangle"></i></span>
+                               <span class="h6 fw-bolder my-auto" id="errorLogModalLabel">Backend API interaction errors</span>
+                               <button type="button" class="btn-close ms-auto" data-bs-dismiss="modal" aria-label="Close"></button>
+                       </div>
+                       <div class="modal-body">
+                               <div class="table-responsive">
+                                       <table class="table table-sm table-hover table-striped" id="errorLogTable">
+                                               <thead>
+                                                       <tr>
+                                                               <th class="text-nowrap">Time</th>
+                                                               <th>Error</th>
+                                                               <th class="d-none d-sm-table-cell">Server</th>
+                                                               <th class="d-none d-md-table-cell">Endpoint</th>
+                                                               <th class="d-none d-lg-table-cell text-center">HTTP status</th>
+                                                               <th class="d-none d-lg-table-cell">Type</th>
+                                                       </tr>
+                                               </thead>
+                                               <tbody>
+                                                       <!-- Populated dynamically -->
+                                               </tbody>
+                                       </table>
+                               </div>
+                               <p class="text-muted text-center" id="noErrorsMessage" style="display: none;">
+                                       No errors recorded in this session
+                               </p>
+                       </div>
+                       <div class="modal-footer py-1">
+                               <button type="button" class="btn btn-secondary" id="copyErrorLog" title="Copy selection or entire log" disabled>
+                                       <i class="fas fa-copy"></i> Copy to clipboard
+                               </button>
+                               <button type="button" class="btn btn-secondary" id="clearErrorLog" disabled>
+                                       <i class="fas fa-trash-can"></i> Clear log
+                               </button>
+                               <button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
+                       </div>
+               </div>
+       </div>
+</div>
+
 <script data-main="./js/main.js" src="./js/lib/require.min.js"></script>
 </body>
 </html>
index da3a40b3cde2fc032eddbb2ab315d91ec06db671..035e8660a9382dfba3e02dd9598381dae80e104c 100644 (file)
@@ -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 = $("<tr></tr>");
+
+                // 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($('<td class="text-nowrap"></td>').text(time));
+                row.append($("<td></td>").text(err.message));
+                row.append($('<td class="d-none d-sm-table-cell"></td>').text(err.server));
+                row.append($('<td class="d-none d-md-table-cell"></td>')
+                    .append($('<code class="small"></code>').text(err.endpoint)));
+                row.append($('<td class="d-none d-lg-table-cell text-center"></td>').text(status));
+                row.append($('<td class="d-none d-lg-table-cell"></td>')
+                    .append($(`<span class="badge ${badgeClass}"></span>`).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('<i class="fas fa-check"></i> Copied!');
+                        setTimeout(() => btn.html(originalHtml), 2000);
+                    })
+                    .catch((err) => alertMessage("alert-danger", "Failed to copy to clipboard: " + err.message));
+            });
+        });
+
         return ui;
     });
index 588da61c6f9fb05c65ff9c5607589298ad8578ee..336162a901857d8d8c7294d2ec1118c4ec88e82e 100644 (file)
@@ -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;
index 96709434ccf08e88e1f4c591737173884004c9ac..e35f51fea85b82dea35cf1bf4bf6fb3a6bf0aab0 100644 (file)
@@ -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;
index aa777b9e7dd51de811a5a037ef0c0f5b067d06a1..bba3095267fa8b05cedcbba1de3c3be566c80aae 100644 (file)
@@ -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);
                                 }
index 5c281c3dee403bc119f62f490270d27ad682d074..2bb85cd08bcbecde9cb3b3140639bb66e397c456 100644 (file)
@@ -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");