]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Rework] WebUI: migrate history/scan to Tabulator
authorAlexander Moisseev <moiseev@mezonplus.ru>
Wed, 1 Jul 2026 11:06:40 +0000 (14:06 +0300)
committerAlexander Moisseev <moiseev@mezonplus.ru>
Wed, 1 Jul 2026 11:25:44 +0000 (14:25 +0300)
Migrate the last two FooTable tables, #historyTable_history and
#historyTable_scan, to Tabulator. Both share rendering code in libft.js
(process_history_v2/columns_v2, initHistoryTable, the column-options
dropdown and the symbol-order toggle).

- Action column uses a custom headerFilter (select + "not" checkbox)
  with headerFilterFunc for exact matching.
- Column-options dropdown keeps Visible/Hidden plus "Row", emulated
  via responsive:100 and persisted to localStorage.
- Symbol-order "Sort by:" buttons are embedded in the symbols column
  title, so they are recreated on every render; the active state is
  reapplied on tableBuilt/renderComplete.
- columnDefaults uses the "html" formatter so upstream-escaped values
  decode instead of being re-escaped by "plaintext".
- Shared helpers (scroll preservation, etc.) live in tab-utils.js.

Update the scan/symbols Playwright specs for the Tabulator DOM and
replace the racy "disabled during reload" check with response waits.

interface/css/rspamd.css
interface/index.html
interface/js/app/history.js
interface/js/app/libft.js
interface/js/app/tab-utils.js
interface/js/app/upload.js
test/playwright/tests/scan.spec.mjs
test/playwright/tests/symbols.spec.mjs

index c4efd291858ee2962d0515f3542908ee68bff7e8..5d90678397dd84e103db8e0fbf351ca38adf58df 100644 (file)
@@ -253,11 +253,18 @@ textarea {
    (e.g. the "W" in "Worker type" was clipped on the left). */
 .tabulator .tabulator-header .tabulator-col .tabulator-col-content .tabulator-col-title {
     padding-left: 1px;
+    white-space: normal;
+    overflow-wrap: break-word;
+}
+.tabulator .tabulator-header .tabulator-col.tabulator-sortable .tabulator-col-content .tabulator-col-title {
+    padding-right: 15px;
 }
 /* Header filter input — render as a Bootstrap form-control.
    appearance:none drops the UA search-input chrome that ignored the
-   dark background (input looked too light in dark mode). */
-.tabulator .tabulator-header-filter input {
+   dark background (input looked too light in dark mode).
+   Scoped to text-like inputs so it does not strip the native checkbox
+   (the action filter's "not" toggle) of its checkmark and stretch it. */
+.tabulator .tabulator-header-filter input:not([type="checkbox"], [type="radio"]) {
     -webkit-appearance: none;
     appearance: none;
     width: 100%;
@@ -302,7 +309,7 @@ textarea {
 }
 
 .tabulator-row .tabulator-cell {
-    padding: 8px;
+    padding: 4px;
     border-right: none;
     white-space: normal;
     overflow-wrap: break-word;
@@ -381,13 +388,18 @@ textarea {
 
 /* Responsive collapse (detail row) */
 .tabulator-row .tabulator-responsive-collapse {
-    border-top: 1px solid var(--bs-border-color);
     border-bottom: 1px solid var(--bs-border-color);
     background-color: var(--bs-body-bg);
     color: var(--bs-body-color);
 }
 .tabulator-row .tabulator-responsive-collapse table {
-    font-size: 14px;
+    font-size: 11px;
+    border-collapse: collapse;
+}
+/* Separator between the detail rows (one per collapsed column); except for
+   the first row, whose top border is omitted to avoid doubling the detail's top edge. */
+.tabulator-row .tabulator-responsive-collapse tr:not(:first-child) {
+    border-top: 1px solid var(--bs-border-color);
 }
 
 /* Footer / pagination */
@@ -594,6 +606,7 @@ input.action-scores {
 .status-table td:last-child {
     border-right: none;
 }
+/* stylelint-disable no-descending-specificity -- tabulator selectors are grouped before the legacy .status-table/.footable rules below; harmless until those are removed */
 .status-table thead tr {
     border-top: none;
 }
@@ -601,6 +614,7 @@ input.action-scores {
 .status-table tr:last-child td {
     border-bottom: none;
 }
+/* stylelint-enable no-descending-specificity */
 
 .footable-header,
 .footable tr:not(.footable-detail-row) > td {
index 0a9c9868b923082838a48af9c100fda86e087a46..7d64166f1183b79710d21ce932bc628bcbffd9c6 100644 (file)
                                        </div>
                                        <div class="card-body p-0">
                                                <div id="scanLog">
-                                                       <table class="table mb-0" id="historyTable_scan"></table>
+                                                       <div id="historyTable_scan"></div>
                                                </div>
                                        </div>
                                </div>
                                        </div>
                                        <div class="card-body p-0">
                                                <div id="historyLog">
-                                                       <table class="table" id="historyTable_history"></table>
+                                                       <div id="historyTable_history"></div>
                                                </div>
                                        </div>
                                </div>
index f253384dca0af163554e3a234ddfa8dd39ac4020..93860c0f88d5bbb2d7513df5f369fef76a5c9873 100644 (file)
@@ -22,19 +22,13 @@ define(["jquery", "app/common", "app/libft", "app/tab-utils", "tabulator"],
             common.hide("#selSymOrder_history, label[for='selSymOrder_history']");
 
             $.each(data, (i, item) => {
-                item.time = libft.unix_time_format(item.unix_time);
                 libft.preprocess_item(item);
                 item.symbols = Object.keys(item.symbols)
                     .map((key) => item.symbols[key])
                     .sort(compare)
                     .map((e) => e.name)
                     .join(", ");
-                item.time = {
-                    value: libft.unix_time_format(item.unix_time),
-                    options: {
-                        sortValue: item.unix_time
-                    }
-                };
+                item.time = item.unix_time;
 
                 items.push(item);
             });
@@ -44,58 +38,75 @@ define(["jquery", "app/common", "app/libft", "app/tab-utils", "tabulator"],
 
         function columns_legacy() {
             return [{
-                name: "id",
+                formatter: "responsiveCollapse",
+                width: 23,
+                minWidth: 23,
+                responsive: 0,
+                hozAlign: "center",
+                resizable: false,
+                headerSort: false,
+            }, {
                 title: "ID",
-                style: {
-                    width: 300,
-                    maxWidth: 300,
-                    overflow: "hidden",
-                    textOverflow: "ellipsis",
-                    wordBreak: "keep-all",
-                    whiteSpace: "nowrap"
-                }
+                field: "id",
+                responsive: 0,
+                minWidth: 300,
             }, {
-                name: "ip",
                 title: "IP address",
-                breakpoints: "md",
-                style: {width: 150, maxWidth: 150}
+                field: "ip",
+                responsive: 2,
+                minWidth: 98,
+                width: 98,
             }, {
-                name: "action",
                 title: "Action",
-                style: {width: 110, maxWidth: 110}
+                field: "action",
+                responsive: 0,
+                minWidth: 110,
+                width: 110,
             }, {
-                name: "score",
                 title: "Score",
-                style: {maxWidth: 110},
-                sortValue: (val) => Number(val.options.sortValue)
+                field: "score",
+                responsive: 0,
+                sorter: "number",
+                minWidth: 110,
+                width: 110,
             }, {
-                name: "symbols",
                 title: "Symbols",
-                breakpoints: "all",
-                style: {width: 550, maxWidth: 550}
+                field: "symbols",
+                formatter: "html",
+                // responsive:100 collapses first; the large minWidth forces
+                // overflow at any realistic width so symbols always renders in
+                // the detail row.
+                responsive: 100,
+                minWidth: 4000,
             }, {
-                name: "size",
                 title: "Message size",
-                breakpoints: "md",
-                style: {width: 120, maxWidth: 120},
-                formatter: libft.formatBytesIEC
+                field: "size",
+                responsive: 2,
+                sorter: "number",
+                formatter: (cell) => libft.formatBytesIEC(cell.getValue()),
+                minWidth: 120,
+                width: 120,
             }, {
-                name: "scan_time",
                 title: "Scan time",
-                breakpoints: "md",
-                style: {maxWidth: 80},
-                sortValue: (val) => Number(val)
+                field: "scan_time",
+                responsive: 2,
+                sorter: "number",
+                minWidth: 80,
+                width: 80,
             }, {
-                sorted: true,
-                direction: "DESC",
-                name: "time",
                 title: "Time",
-                sortValue: (val) => Number(val.options.sortValue)
+                field: "time",
+                responsive: 0,
+                sorter: "number",
+                formatter: (cell) => libft.unix_time_format(cell.getValue()),
+                minWidth: 130,
+                width: 130,
             }, {
-                name: "user",
                 title: "Authenticated user",
-                breakpoints: "md",
-                style: {width: 200, maxWidth: 200}
+                field: "user",
+                responsive: 2,
+                minWidth: 200,
+                width: 200,
             }];
         }
 
@@ -175,16 +186,15 @@ define(["jquery", "app/common", "app/libft", "app/tab-utils", "tabulator"],
 
                         if (Object.prototype.hasOwnProperty.call(common.tables, "history") &&
                             version === prevVersion) {
-                            common.tables.history.rows.load(items);
+                            common.tables.history.setData(items);
                         } else {
-                            libft.destroyTable("history").then(() => {
-                                libft.initHistoryTable(data, items, "history", get_history_columns(data), false,
-                                    () => {
-                                        $("#history .ft-columns-dropdown .btn-dropdown-apply").removeAttr("disabled");
-                                        ui.updateHistoryControlsState();
-                                        if (version) libft.bindFuzzyHashButtons("history");
-                                    });
-                            });
+                            libft.destroyTable("history");
+                            libft.initHistoryTable(data, items, "history", get_history_columns(data), false,
+                                () => {
+                                    $("#history .ft-columns-dropdown .btn-dropdown-apply").removeAttr("disabled");
+                                    ui.updateHistoryControlsState();
+                                    if (version) libft.bindFuzzyHashButtons("history");
+                                });
                         }
                         prevVersion = version;
                     } else {
@@ -201,6 +211,9 @@ define(["jquery", "app/common", "app/libft", "app/tab-utils", "tabulator"],
                 layout: "fitColumns",
                 responsiveLayout: "collapse",
                 responsiveLayoutCollapseStartOpen: false,
+                // Values are HTML-escaped upstream (getErrors); render as HTML so
+                // entities decode instead of showing literally via "plaintext".
+                columnDefaults: {formatter: "html"},
                 selectable: false,
                 pagination: "local",
                 paginationSize: common.page_size.errors,
@@ -211,8 +224,8 @@ define(["jquery", "app/common", "app/libft", "app/tab-utils", "tabulator"],
                     {
                         // Toggle to expand collapsed (responsive) columns
                         formatter: "responsiveCollapse",
-                        width: 36,
-                        minWidth: 36,
+                        width: 23,
+                        minWidth: 23,
                         responsive: 0,
                         hozAlign: "center",
                         resizable: false,
index bccc918bd72ad1cb824285a60e9463b62dd12454..4f73b4d50dcbcfe1e07a1f134380ac0047cdf61c 100644 (file)
@@ -1,11 +1,15 @@
-/* global FooTable */
-
-define(["jquery", "app/common", "footable"],
-    ($, common) => {
+define(["jquery", "app/common", "app/tab-utils", "tabulator"],
+    ($, common, tabUtils, Tabulator) => {
         "use strict";
         const ui = {};
         const columnsCustom = JSON.parse(localStorage.getItem("columns")) || {};
 
+        // responsive:100 alone only collapses a column when the table already
+        // overflows the viewport; pairing it with a very large minWidth forces
+        // the overflow, so the column always renders in the detail row ("Row"
+        // mode). Same trick the symbols/rcpt_mime columns use by default.
+        const FORCE_COLLAPSE_MIN_WIDTH = 4000;
+
         let pageSizeTimerId = null;
 
         function get_compare_function(table) {
@@ -32,11 +36,109 @@ define(["jquery", "app/common", "footable"],
                 .join("<br>\n");
         }
 
+        // Highlight the active symbol-order button for a table. The buttons are
+        // part of the symbols column title, which is rendered inside each row's
+        // collapsed detail, so they are recreated on every render — call this
+        // after each build/render, not at handler bind time.
+        function setActiveSymOrderButton(table, order) {
+            const active = order || common.getSelector("selSymOrder_" + table);
+            if (active) {
+                $(".btn-sym-" + table + "-" + active).addClass("active").siblings().removeClass("active");
+            }
+        }
+
+        function ipSorter(a, b) {
+            function norm(ip) {
+                return (typeof ip === "string" ? ip.split(".").map((x) => x.padStart(3, "0")).join("") : "0");
+            }
+            return norm(a).localeCompare(norm(b));
+        }
+
+        // ── Action filter for history/scan tables ─────────────────────────────
+
+        const actionValues = ["reject", "add header", "greylist", "no action", "soft reject", "rewrite subject"];
+
+        function actionHeaderFilter(cell, onRendered, success) {
+            const container = document.createElement("div");
+            container.style.display = "flex";
+            container.style.gap = "4px";
+            container.style.alignItems = "center";
+
+            const select = document.createElement("select");
+            select.className = "form-select form-select-sm";
+            select.appendChild(new Option("Any action", ""));
+            actionValues.forEach((a) => select.appendChild(new Option(a, a)));
+
+            const notLabel = document.createElement("label");
+            notLabel.style.whiteSpace = "nowrap";
+            notLabel.title = "Invert action match";
+            const not = document.createElement("input");
+            not.type = "checkbox";
+            notLabel.append(not, " not");
+
+            container.append(notLabel, select);
+
+            function onChange() {
+                if (!select.value) {
+                    success(false);
+                } else {
+                    success({action: select.value, not: not.checked});
+                }
+            }
+            select.addEventListener("change", onChange);
+            not.addEventListener("change", onChange);
+
+            return container;
+        }
+
+        function actionFilterFunc(filterVal, cellVal) {
+            if (!filterVal || !filterVal.action) return true;
+            const match = cellVal === filterVal.action;
+            return filterVal.not ? !match : match;
+        }
+
+        // ── Column formatters ────────────────────────────────────────────────
+
+        function actionFormatter(cell) {
+            const action = cell.getValue();
+            const cls = {
+                "clean": "success",
+                "no action": "success",
+                "rewrite subject": "warning",
+                "add header": "warning",
+                "probable spam": "warning",
+                "spam": "danger",
+                "reject": "danger"
+            }[action] || "info";
+            return `<div style="font-size:11px" class="badge text-bg-${cls}">${action}</div>`;
+        }
+
+        function scoreFormatter(cell) {
+            const data = cell.getData();
+            const score = cell.getValue();
+            const required = data.required_score;
+            const cls = score < required ? "text-success" : "text-danger";
+            return `<span class="${cls}">${score.toFixed(2)} / ${required}</span>`;
+        }
 
-        // Public functions
+        function symOrderTitle(table) {
+            return "Symbols" +
+                '<div class="sym-order-toggle">' +
+                    '<br><span style="font-weight:normal;">Sort by:</span><br>' +
+                    '<div class="btn-group btn-group-xs btn-sym-order-' + table + '">' +
+                        '<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-magnitude">' +
+                            '<input type="radio" class="btn-check" value="magnitude">Magnitude</label>' +
+                        '<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-score">' +
+                            '<input type="radio" class="btn-check" value="score">Value</label>' +
+                        '<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-name">' +
+                            '<input type="radio" class="btn-check" value="name">Name</label>' +
+                    "</div>" +
+                "</div>";
+        }
+
+        // ── Public functions ─────────────────────────────────────────────────
 
         ui.formatBytesIEC = function (bytes) {
-            // FooTable represents data as text even column type is "number".
             if (!Number.isInteger(Number(bytes)) || bytes < 0) return "NaN";
 
             const base = 1024;
@@ -52,134 +154,130 @@ define(["jquery", "app/common", "footable"],
         };
 
         ui.columns_v2 = function (table) {
-            return [{
-                name: "id",
+            const cols = [{
+                // Toggle to expand collapsed (responsive) columns
+                formatter: "responsiveCollapse",
+                width: 23,
+                minWidth: 23,
+                responsive: 0,
+                hozAlign: "center",
+                resizable: false,
+                headerSort: false,
+            }, {
                 title: "ID",
-                style: {
-                    minWidth: 130,
-                    overflow: "hidden",
-                    textOverflow: "ellipsis",
-                    wordBreak: "break-all",
-                    whiteSpace: "normal"
-                }
+                field: "id",
+                responsive: 0,
+                minWidth: 130,
+                widthGrow: 2,
             }, {
-                name: "file",
                 title: "File name",
-                breakpoints: "sm",
-                sortValue: (val) => ((typeof val === "undefined") ? "" : val)
+                field: "file",
+                responsive: 1,
+                minWidth: 260,
+                widthGrow: 4,
             }, {
-                name: "ip",
                 title: "IP address",
-                breakpoints: "lg",
-                style: {
-                    "minWidth": "calc(14ch + 8px)",
-                    "word-break": "break-all"
-                },
-                // Normalize IPv4
-                sortValue: (ip) => ((typeof ip === "string") ? ip.split(".").map((x) => x.padStart(3, "0")).join("") : "0")
+                field: "ip",
+                responsive: 3,
+                minWidth: 98,
+                width: 98,
+                sorter: ipSorter,
             }, {
-                name: "sender_mime",
                 title: "[Envelope From] From",
-                breakpoints: "lg",
-                style: {
-                    "minWidth": 100,
-                    "maxWidth": 200,
-                    "word-wrap": "break-word"
-                }
+                field: "sender_mime",
+                responsive: 3,
+                minWidth: 100,
+                maxWidth: 200,
             }, {
-                name: "rcpt_mime_short",
                 title: "[Envelope To] To/Cc/Bcc",
-                breakpoints: "lg",
-                filterable: false,
-                classes: "d-none d-xl-table-cell",
-                style: {
-                    "minWidth": 100,
-                    "maxWidth": 200,
-                    "word-wrap": "break-word"
-                }
+                field: "rcpt_mime_short",
+                responsive: 3,
+                headerFilter: false,
+                minWidth: 100,
+                maxWidth: 200,
             }, {
-                name: "rcpt_mime",
                 title: "[Envelope To] To/Cc/Bcc",
-                breakpoints: "all",
-                style: {"word-wrap": "break-word"}
+                field: "rcpt_mime",
+                responsive: 100,
+                minWidth: FORCE_COLLAPSE_MIN_WIDTH,
             }, {
-                name: "subject",
                 title: "Subject",
-                breakpoints: "lg",
-                style: {
-                    "word-break": "break-all",
-                    "minWidth": 150
-                }
+                field: "subject",
+                responsive: 3,
+                minWidth: 150,
+                widthGrow: 2,
             }, {
-                name: "action",
                 title: "Action",
-                style: {minwidth: 82}
+                field: "action",
+                responsive: 0,
+                minWidth: 108,
+                width: 108,
+                formatter: actionFormatter,
+                headerFilter: actionHeaderFilter,
+                headerFilterFunc: actionFilterFunc,
             }, {
-                name: "passthrough_module",
                 title: '<div title="The module that has set the pre-result"><nobr>Pass-through</nobr> module</div>',
-                breakpoints: "sm",
-                style: {minWidth: 98, maxWidth: 98},
-                sortValue: (val) => ((typeof val === "undefined") ? "" : val)
+                field: "passthrough_module",
+                minWidth: 98,
+                width: 98,
             }, {
-                name: "score",
                 title: "Score",
-                style: {
-                    "maxWidth": 110,
-                    "text-align": "right",
-                    "white-space": "nowrap"
-                },
-                sortValue: (val) => Number(val.options.sortValue)
+                field: "score",
+                responsive: 0,
+                sorter: "number",
+                minWidth: 64,
+                width: 64,
+                formatter: scoreFormatter,
             }, {
-                name: "symbols",
-                title: "Symbols" +
-                        '<div class="sym-order-toggle">' +
-                            '<br><span style="font-weight:normal;">Sort by:</span><br>' +
-                            '<div class="btn-group btn-group-xs btn-sym-order-' + table + '">' +
-                                '<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-magnitude">' +
-                                    '<input type="radio" class="btn-check" value="magnitude">Magnitude</label>' +
-                                '<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-score">' +
-                                    '<input type="radio" class="btn-check" value="score">Value</label>' +
-                                '<label type="button" class="btn btn-outline-secondary btn-sym-' + table + '-name">' +
-                                    '<input type="radio" class="btn-check" value="name">Name</label>' +
-                            "</div>" +
-                        "</div>",
-                breakpoints: "all",
-                style: {width: 550, maxWidth: 550}
+                title: symOrderTitle(table),
+                field: "symbols",
+                formatter: "html",
+                headerSort: false,
+                // Highest responsive priority, so symbols collapses first. The
+                // large minWidth forces overflow at any realistic width, so the
+                // column always renders in the detail row (Tabulator has no native
+                // "always collapse" mode).
+                responsive: 100,
+                minWidth: FORCE_COLLAPSE_MIN_WIDTH,
             }, {
-                name: "size",
                 title: "Msg size",
-                breakpoints: "lg",
-                style: {minwidth: 50},
-                formatter: ui.formatBytesIEC
+                field: "size",
+                responsive: 3,
+                sorter: "number",
+                formatter: (cell) => ui.formatBytesIEC(cell.getValue()),
+                minWidth: 56,
+                width: 56,
             }, {
-                name: "time_real",
                 title: "Scan time",
-                breakpoints: "lg",
-                style: {maxWidth: 72},
-                sortValue: (val) => Number(val)
+                field: "time_real",
+                responsive: 3,
+                sorter: "number",
+                minWidth: 60,
+                width: 60,
             }, {
-                classes: "history-col-time",
-                sorted: true,
-                direction: "DESC",
-                name: "time",
                 title: "Time",
-                sortValue: (val) => Number(val.options.sortValue)
+                field: "time",
+                responsive: 0,
+                sorter: "number",
+                formatter: (cell) => ui.unix_time_format(cell.getValue()),
+                minWidth: 72,
+                width: 72,
             }, {
-                name: "user",
                 title: "Authenticated user",
-                breakpoints: "lg",
-                style: {
-                    "minWidth": 100,
-                    "maxWidth": 130,
-                    "word-wrap": "break-word"
-                }
-            }].filter((col) => {
+                field: "user",
+                responsive: 3,
+                minWidth: 100,
+                maxWidth: 130,
+            }];
+
+            return cols.filter((col) => {
+                if (!col.field) return true; // Toggle column
                 switch (table) {
                     case "history":
-                        return (col.name !== "file");
+                        return (col.field !== "file");
                     case "scan":
                         return ["ip", "sender_mime", "rcpt_mime_short", "rcpt_mime", "subject", "size", "user"]
-                            .every((name) => col.name !== name);
+                            .every((name) => col.field !== name);
                     default:
                         return null;
                 }
@@ -187,41 +285,36 @@ define(["jquery", "app/common", "footable"],
         };
 
         ui.set_page_size = function (table, page_size, changeTablePageSize) {
-            const n = parseInt(page_size, 10); // HTML Input elements return string representing a number
+            const n = parseInt(page_size, 10);
             if (n > 0) {
                 common.page_size[table] = n;
 
-                if (changeTablePageSize &&
-                    $("#historyTable_" + table + " tbody").is(":parent")) { // Table is not empty
-                    if (common.tables[table]) {
-                        // Table exists - debounce rapid changes (e.g., spin button clicks)
-                        clearTimeout(pageSizeTimerId);
-                        pageSizeTimerId = setTimeout(() => {
-                            common.tables[table]?.pageSize(n);
-                        }, 1000);
-                    } else {
-                        // Table doesn't exist - wait for initialization with event
-                        $("#historyTable_" + table).one("postinit.ft.table", () => {
-                            common.tables[table]?.pageSize(n);
-                        });
-                    }
+                if (changeTablePageSize && common.tables[table]) {
+                    clearTimeout(pageSizeTimerId);
+                    pageSizeTimerId = setTimeout(() => {
+                        common.tables[table]?.setPageSize(n);
+                    }, 1000);
                 }
             }
         };
 
-        ui.bindHistoryTableEventHandlers = function (table, symbolsCol) {
+        ui.bindHistoryTableEventHandlers = function (table) {
             function change_symbols_order(order) {
-                $(".btn-sym-" + table + "-" + order).addClass("active").siblings().removeClass("active");
                 const compare_function = get_compare_function(table);
-                $.each(common.tables[table].rows.all, (i, row) => {
-                    const cell_val = sort_symbols(common.symbols[table][i], compare_function);
-                    row.cells[symbolsCol].val(cell_val, false, true);
+                common.tables[table].getRows().forEach((row) => {
+                    const cell_val = sort_symbols(row.getData().symbols_obj, compare_function);
+                    // row.update (not cell.setValue) so the responsive-collapse
+                    // detail row is regenerated; setValue only re-renders the
+                    // hidden cell element and never refreshes the collapsed view.
+                    row.update({symbols: cell_val});
                 });
+                // row.update recreates each detail's buttons (without active);
+                // re-apply the active state to the fresh elements.
+                setActiveSymOrderButton(table, order);
             }
 
             $("#selSymOrder_" + table).unbind().change(function () {
-                const order = this.value;
-                change_symbols_order(order);
+                change_symbols_order(this.value);
             });
             $("#" + table + "_page_size").change((e) => ui.set_page_size(table, e.target.value, true));
             $(document).on("click", ".btn-sym-order-" + table + " input", function () {
@@ -232,166 +325,85 @@ define(["jquery", "app/common", "footable"],
         };
 
         ui.destroyTable = function (table) {
-            $("#" + table + " .ft-columns-btn.show").trigger("click.bs.dropdown"); // Hide dropdown
+            $("#" + table + " .ft-columns-btn.show").trigger("click.bs.dropdown");
             $("#" + table + " .ft-columns-btn").attr("disabled", true);
             if (common.tables[table]) {
-                const promise = common.tables[table].destroy();
+                common.tables[table].destroy();
                 delete common.tables[table];
-                return promise;
             }
-            return new $.Deferred().resolve().promise();
         };
 
         ui.initHistoryTable = function (data, items, table, columnsDefault, expandFirst, postdrawCallback) {
-            /* eslint-disable no-underscore-dangle */
-            FooTable.Cell.extend("collapse", function () {
-                // call the original method
-                this._super();
-                // Copy cell classes to detail row tr element
-                this._setClasses(this.$detail);
-            });
-            /* eslint-enable no-underscore-dangle */
-
-            /* eslint-disable consistent-this, no-underscore-dangle */
-            FooTable.actionFilter = FooTable.Filtering.extend({
-                construct: function (instance) {
-                    this._super(instance);
-                    this.actions = ["reject", "add header", "greylist",
-                        "no action", "soft reject", "rewrite subject"];
-                    this.def = "Any action";
-                    this.$action = null;
-                },
-                $create: function () {
-                    this._super();
-                    const self = this;
-
-                    if (self.$input && self.$input.length && !self.$input.parent().find(".search-syntax-icon").length) {
-                        self.$input.parent().css("position", "relative");
-                        const $icon = $("<i/>", {
-                            class: "fas fa-circle-question search-syntax-icon text-muted",
-                            title: "Search syntax: match all rows containing\n\n" +
-                                   "\"exact phrase\" — exact string (including spaces)\n" +
-                                   "term1 OR term2 — either term\n" +
-                                   "term1 AND term2 — both terms\n" +
-                                   "term1 term2 — both terms (same as AND)\n" +
-                                   "term1 -term2 — term1 but exclude rows with term2"
-                        });
-                        $icon.insertAfter(self.$input);
-                    }
-
-                    const $form_grp = $("<div/>", {
-                        class: "form-group d-inline-flex align-items-center"
-                    }).append($("<label/>", {
-                        class: "sr-only",
-                        text: "Action"
-                    })).prependTo(self.$form);
-
-                    $("<div/>", {
-                        class: "form-check form-check-inline",
-                        title: "Invert action match."
-                    }).append(
-                        self.$not = $("<input/>", {
-                            type: "checkbox",
-                            class: "form-check-input",
-                            id: "not_" + table
-                        }).on("change", {self: self}, self._onStatusDropdownChanged),
-                        $("<label/>", {
-                            class: "form-check-label",
-                            for: "not_" + table,
-                            text: "not"
-                        })
-                    ).appendTo($form_grp);
-
-                    self.$action = $("<select/>", {
-                        class: "form-select"
-                    }).on("change", {
-                        self: self
-                    }, self._onStatusDropdownChanged).append(
-                        $("<option/>", {
-                            text: self.def
-                        })).appendTo($form_grp);
-
-                    $.each(self.actions, (i, action) => {
-                        self.$action.append($("<option/>").text(action));
-                    });
-
-                    common.appendButtonsToFtFilterDropdown(self);
-                },
-                _onStatusDropdownChanged: function (e) {
-                    const {self} = e.data;
-                    const selected = self.$action.val();
-                    if (selected !== self.def) {
-                        const not = self.$not.is(":checked");
-                        // eslint-disable-next-line no-useless-assignment
-                        let query = null;
-
-                        if (selected === "reject") {
-                            query = not ? "-reject OR soft" : "reject -soft";
-                        } else {
-                            query = not ? selected.replace(/(\b\w+\b)/g, "-$1") : selected;
-                        }
-
-                        self.addFilter("action", query, ["action"]);
-                    } else {
-                        self.removeFilter("action");
-                    }
-                    self.filter();
-                },
-                draw: function () {
-                    // Ensure the dropdown reflects the default value when filters are cleared.
-                    this._super();
-                    const actionFilter = this.find("action");
-                    const isActionFilterApplied = actionFilter instanceof FooTable.Filter;
-                    if (this.$action && !isActionFilterApplied) this.$action.val(this.def);
-                }
+            // A persisted "Row" column is stored as responsive:100; it must also
+            // receive the force-collapse minWidth so it renders in the detail row.
+            const baseColumns = (table in columnsCustom)
+                ? columnsDefault.map((column) => $.extend({}, column, columnsCustom[table][column.field]))
+                : columnsDefault.map((column) => column);
+            const columns = baseColumns.map((column) => (column.responsive === 100
+                ? $.extend({}, column, {minWidth: FORCE_COLLAPSE_MIN_WIDTH})
+                : column));
+
+            common.tables[table] = new Tabulator("#historyTable_" + table, {
+                layout: "fitColumns",
+                responsiveLayout: "collapse",
+                responsiveLayoutCollapseStartOpen: expandFirst,
+                // Cell values are HTML-escaped upstream (preprocess_item); render
+                // them as HTML so entities decode. The default "plaintext"
+                // formatter re-escapes and would show &quot;/&amp; literally.
+                // Explicit column formatters override this.
+                columnDefaults: {formatter: "html"},
+                selectable: false,
+                pagination: "local",
+                paginationSize: common.page_size[table],
+                paginationButtonCount: 5,
+                data: items,
+                initialSort: [{column: "time", dir: "desc"}],
+                columns: columns,
             });
-            /* eslint-enable consistent-this, no-underscore-dangle */
 
-            const columns = (table in columnsCustom)
-                ? columnsDefault.map((column) => $.extend({}, column, columnsCustom[table][column.name]))
-                : columnsDefault.map((column) => column);
+            tabUtils.hideFooterOnSinglePage(table);
+            tabUtils.stripTableholderTabindex(table);
+            tabUtils.bindRowClickToggle(table);
+            tabUtils.patchScrollIntoViewOnce();
+            tabUtils.installScrollPreservation(table);
 
-            common.tables[table] = FooTable.init("#historyTable_" + table, {
-                breakpoints: common.breakpoints,
-                cascade: true,
-                columns: columns,
-                rows: items,
-                expandFirst: expandFirst,
-                paging: {
-                    enabled: true,
-                    limit: 5,
-                    size: common.page_size[table]
-                },
-                filtering: {
-                    enabled: true,
-                    position: "left",
-                    connectors: false
-                },
-                sorting: {
-                    enabled: true
-                },
-                components: {
-                    filtering: FooTable.actionFilter
-                },
-                on: {
-                    "expanded.ft.row": function (e, ft, row) {
-                        const detail_row = row.$el.next();
-                        const order = common.getSelector("selSymOrder_" + table);
-                        detail_row.find(".btn-sym-" + table + "-" + order)
-                            .addClass("active").siblings().removeClass("active");
-                    },
-                    "postdraw.ft.table": postdrawCallback
-                }
+            common.tables[table].on("tableBuilt", () => {
+                setActiveSymOrderButton(table);
             });
+            if (postdrawCallback) common.tables[table].on("renderComplete", postdrawCallback);
+            // The "Sort by:" buttons are rendered inside each row's collapsed
+            // detail (via the column title), so they are recreated on every
+            // render — re-apply the active state each time.
+            common.tables[table].on("renderComplete", () => setActiveSymOrderButton(table));
 
             // Column options dropdown
             (() => {
-                function updateValue(checked, column, cellIdx) {
-                    const option = ["breakpoints", "visible"][cellIdx];
-                    const value = [(checked) ? "all" : column.breakpoints, !checked][cellIdx];
+                // Changes the ResponsiveLayout module owns — "Row" toggles and
+                // visibility on always-collapsed (responsive:100) columns — can't be
+                // applied incrementally, so they're deferred to dropdown close / Save.
+                // This keeps the dropdown open while toggling several at once.
+                let rebuildPending = false;
+
+                // Column visibility and the emulated "Row" mode (responsive:100) both
+                // depend on ResponsiveLayout's collapse pointer, which is set once at
+                // construction and never reconciled after a runtime definition change —
+                // the root cause of the erratic collapse/expand behaviour (columns that
+                // expand but never collapse back, neighbours collapsing in sympathy).
+                // Rather than poke at the module's private state, rebuild the whole
+                // table: a fresh instance converges to the correct collapse set, exactly
+                // as on first load. The current data is preserved; the initial-render
+                // callback is skipped (buttons/fuzzy are already wired and delegated).
+                function rebuild() {
+                    const rows = common.tables[table].getData();
+                    ui.destroyTable(table);
+                    ui.initHistoryTable(data, rows, table, columnsDefault, expandFirst);
+                }
 
-                    FooTable.get("#historyTable_" + table).columns.get(column.name)[option] = value;
-                    return value;
+                // Apply deferred "Row" changes (if any) and clear the dirty flag.
+                function applyPendingRebuild() {
+                    if (!rebuildPending) return;
+                    rebuildPending = false;
+                    rebuild();
                 }
 
                 const tbody = $("<tbody/>", {class: "table-group-divider"});
@@ -399,7 +411,7 @@ define(["jquery", "app/common", "footable"],
                     $("<table/>", {class: "table table-sm table-striped text-center"}).append(
                         $("<thead/>").append(
                             $("<tr/>").append(
-                                $("<th/>", {text: "Row", title: "Display column cells in a detail row on all screen widths"}),
+                                $("<th/>", {text: "Row", title: "Display column cells in a detail row"}),
                                 $("<th/>", {text: "Hidden", title: "Hide column completely"}),
                                 $("<th/>", {text: "Column name", class: "text-start"})
                             )
@@ -411,59 +423,95 @@ define(["jquery", "app/common", "footable"],
                         class: "btn btn-xs btn-secondary float-start",
                         text: "Reset to default",
                         click: () => {
-                            columnsDefault.forEach((column, i) => {
-                                const row = tbody[0].rows[i];
-                                [(column.breakpoints === "all"), (column.visible === false)].forEach((checked, cellIdx) => {
-                                    if (row.cells[cellIdx].getElementsByTagName("input")[0].checked !== checked) {
-                                        row.cells[cellIdx].getElementsByTagName("input")[0].checked = checked;
-
-                                        updateValue(checked, column, cellIdx);
-                                        delete columnsCustom[table];
-                                    }
-                                });
+                            const custom = columnsCustom[table] || {};
+                            // A reset needs a rebuild whenever the live table is in a
+                            // state ResponsiveLayout owns: a "Row" override, or a hidden
+                            // always-collapsed (responsive:100) column. Plain hidden
+                            // columns restore via showColumn with no rebuild.
+                            const needsRebuild = rebuildPending || columnsDefault.some((c) => {
+                                if (!c.field) return false;
+                                const cfg = custom[c.field];
+                                return cfg && ((cfg.responsive === 100 && c.responsive !== 100) ||
+                                    (c.responsive === 100 && cfg.visible === false));
                             });
+                            // Only plain (non-responsive:100) hidden columns can be
+                            // restored without a rebuild; responsive ones are rebuilt.
+                            const hiddenFields = columnsDefault
+                                .filter((c) => c.field && c.responsive !== 100 &&
+                                    custom[c.field]?.visible === false)
+                                .map((c) => c.field);
+                            delete columnsCustom[table];
+                            localStorage.setItem("columns", JSON.stringify(columnsCustom));
+                            // Clear before rebuild: the rebuild closes this open dropdown,
+                            // whose close handler (applyPendingRebuild) must see nothing
+                            // pending — otherwise it re-inits again.
+                            rebuildPending = false;
+                            if (needsRebuild) {
+                                rebuild();
+                            } else {
+                                const tab = common.tables[table];
+                                hiddenFields.forEach((f) => {
+                                    tab.showColumn(f);
+                                    tbody.find('input[data-name="' + f + '"][data-option="visible"]')
+                                        .prop("checked", false);
+                                });
+                            }
                         }
                     }),
                     $("<button/>", {
                         type: "button",
                         class: "btn btn-xs btn-primary float-end btn-dropdown-apply",
-                        text: "Apply",
-                        title: "Save settings and redraw the table",
-                        click: (e) => {
-                            $(e.target).attr("disabled", true);
-                            FooTable.get("#historyTable_" + table).draw();
+                        text: "Save",
+                        title: "Save column settings to browser storage",
+                        click: () => {
                             localStorage.setItem("columns", JSON.stringify(columnsCustom));
+                            applyPendingRebuild();
                         }
                     })
                 );
 
                 function checkbox(i, column, cellIdx) {
-                    const option = ["breakpoints", "visible"][cellIdx];
+                    const option = ["responsive", "visible"][cellIdx];
+                    const isRow = option === "responsive";
                     return $("<td/>").append($("<input/>", {
                         "type": "checkbox",
                         "class": "form-check-input",
                         "data-table": table,
-                        "data-name": column.name,
-                        "checked": (option === "breakpoints" && column.breakpoints === "all") ||
-                            (option === "visible" && column.visible === false),
-                        "disabled": (option === "breakpoints" && columnsDefault[i].breakpoints === "all")
+                        "data-name": column.field,
+                        "data-option": option,
+                        "checked": (isRow && column.responsive === 100) ||
+                            (!isRow && column.visible === false),
+                        "disabled": isRow && columnsDefault[i].responsive === 100
                     }).change((e) => {
-                        const value = updateValue(e.target.checked, columnsDefault[i], cellIdx);
-                        if (value == null) { // eslint-disable-line no-eq-null, eqeqeq
-                            delete columnsCustom[table][column.name][option];
-                        } else {
-                            $.extend(true, columnsCustom, {
-                                [table]: {
-                                    [column.name]: {
-                                        [option]: value
-                                    }
+                        const {checked} = e.target;
+                        columnsCustom[table] = columnsCustom[table] || {};
+                        columnsCustom[table][column.field] = columnsCustom[table][column.field] || {};
+                        // Columns currently in "Row" mode (responsive:100) are owned by
+                        // ResponsiveLayout: hiding them no-ops and showing them pops them
+                        // out of the detail row, because the module can't reconcile at
+                        // runtime. So both "Row" and visibility changes on such columns
+                        // are deferred to a rebuild; plain columns toggle in place.
+                        if (isRow || column.responsive === 100) {
+                            if (isRow) {
+                                if (checked) {
+                                    columnsCustom[table][column.field].responsive = 100;
+                                } else {
+                                    delete columnsCustom[table][column.field].responsive;
                                 }
-                            });
+                            } else {
+                                columnsCustom[table][column.field].visible = !checked;
+                            }
+                            rebuildPending = true;
+                        } else {
+                            columnsCustom[table][column.field].visible = !checked;
+                            const tab = common.tables[table];
+                            if (checked) tab.hideColumn(column.field); else tab.showColumn(column.field);
                         }
                     }));
                 }
 
                 $.each(columns, (i, column) => {
+                    if (!column.field) return; // responsiveCollapse toggle is a control, not a column
                     tbody.append(
                         $("<tr/>").append(
                             checkbox(i, column, 0),
@@ -471,10 +519,10 @@ define(["jquery", "app/common", "footable"],
                             $("<td/>", {
                                 class: "text-start",
                                 text: () => {
-                                    switch (column.name) {
+                                    switch (column.field) {
                                         case "passthrough_module": return "Pass-through module";
                                         case "symbols": return "Symbols";
-                                        default: return column.title;
+                                        default: return (column.title || "").replace(/<[^>]*>/g, "");
                                     }
                                 }
                             })
@@ -482,7 +530,13 @@ define(["jquery", "app/common", "footable"],
                     );
                 });
 
-                $("#" + table + " .ft-columns-btn").removeAttr("disabled");
+                // Apply deferred "Row" changes when the dropdown closes. The trigger
+                // element survives rebuilds, so namespace the handler and re-bind it
+                // each build to avoid stacking.
+                $("#" + table + " .ft-columns-btn")
+                    .off("hidden.bs.dropdown.rspamd")
+                    .on("hidden.bs.dropdown.rspamd", applyPendingRebuild)
+                    .removeAttr("disabled");
             })();
         };
 
@@ -518,27 +572,6 @@ define(["jquery", "app/common", "footable"],
                         if (typeof item[prop] === "string") item[prop] = common.escapeHTML(item[prop]);
                 }
             }
-
-            if (item.action === "clean" || item.action === "no action") {
-                item.action = "<div style='font-size:11px' class='badge text-bg-success'>" + item.action + "</div>";
-            } else if (item.action === "rewrite subject" || item.action === "add header" || item.action === "probable spam") {
-                item.action = "<div style='font-size:11px' class='badge text-bg-warning'>" + item.action + "</div>";
-            } else if (item.action === "spam" || item.action === "reject") {
-                item.action = "<div style='font-size:11px' class='badge text-bg-danger'>" + item.action + "</div>";
-            } else {
-                item.action = "<div style='font-size:11px' class='badge text-bg-info'>" + item.action + "</div>";
-            }
-
-            const score_content = (item.score < item.required_score)
-                ? "<span class='text-success'>" + item.score.toFixed(2) + " / " + item.required_score + "</span>"
-                : "<span class='text-danger'>" + item.score.toFixed(2) + " / " + item.required_score + "</span>";
-
-            item.score = {
-                options: {
-                    sortValue: item.score
-                },
-                value: score_content
-            };
         };
 
         ui.unix_time_format = function (tm) {
@@ -620,7 +653,7 @@ define(["jquery", "app/common", "footable"],
             const unsorted_symbols = [];
             const compare_function = get_compare_function(table);
 
-            common.show("#selSymOrder_" + table + ", label[for='selSymOrder_" + table + "]");
+            common.show("#selSymOrder_" + table + ", label[for='selSymOrder_" + table + "']");
 
             $.each(data.rows,
                 (i, item) => {
@@ -690,16 +723,12 @@ define(["jquery", "app/common", "footable"],
                         }
                     });
                     unsorted_symbols.push(item.symbols);
+                    item.symbols_obj = item.symbols;
                     item.symbols = sort_symbols(item.symbols, compare_function);
                     if (table === "scan") {
                         item.unix_time = (new Date()).getTime() / 1000;
                     }
-                    item.time = {
-                        value: ui.unix_time_format(item.unix_time),
-                        options: {
-                            sortValue: item.unix_time
-                        }
-                    };
+                    item.time = item.unix_time;
                     item.time_real = item.time_real.toFixed(3);
                     item.id = item["message-id"];
 
index 2b88461b4830f48c8bedb8cd1fb4e86d988ec145..ed50b92185298b2c9264a0fb5dabfe306d6706f3 100644 (file)
@@ -10,6 +10,10 @@ define(["app/common"],
         const tabUtils = {};
 
         let scrollIntoViewPatched = false;
+        // Tabulator's destroy() clears a table's children but leaves the mount
+        // element (and listeners attached to it) in place, so a destroy+re-init
+        // would stack handlers. Keyed by element to keep one handler per table.
+        const rowToggleHandlers = new WeakMap();
 
         /**
          * Patch Element.prototype.scrollIntoView once so that calls for elements
@@ -57,17 +61,27 @@ define(["app/common"],
          * (instead of just the tiny toggle icon). Skips when selecting text,
          * clicking form elements (inputs, selects — e.g. the Score column),
          * and when collapse is not active (toggle hidden).
+         *
+         * Idempotent across destroy+re-init: destroy() leaves the mount element
+         * and its listeners in place, so without removing the previous handler a
+         * rebuild would bind a second one and row clicks would no-op (the two
+         * handlers toggle the row open then shut).
          */
         tabUtils.bindRowClickToggle = function (table) {
-            common.tables[table].element.addEventListener("click", (e) => {
+            const el = common.tables[table].element;
+            const prev = rowToggleHandlers.get(el);
+            if (prev) el.removeEventListener("click", prev);
+            function handler(e) {
                 const row = e.target.closest(".tabulator-row");
                 if (!row) return;
-                if (e.target.closest("input, select, textarea, button")) return;
+                if (e.target.closest("input, select, textarea, button, .sym-order-toggle")) return;
                 const sel = window.getSelection && window.getSelection();
                 if (sel && sel.toString()) return;
                 const toggle = row.querySelector(".tabulator-responsive-collapse-toggle");
                 if (toggle && toggle.offsetParent) toggle.click();
-            });
+            }
+            el.addEventListener("click", handler);
+            rowToggleHandlers.set(el, handler);
         };
 
         /**
@@ -102,13 +116,23 @@ define(["app/common"],
                 });
             }
 
-            t.element.addEventListener("click", arm, true);
+            // Arm on click plus change/input. A header-filter <select> changes
+            // (and triggers a filter re-render) long after its opening click, by
+            // which time an intervening renderComplete may have disarmed us;
+            // re-arming on change/input keeps the preservation live for that render.
+            ["click", "change", "input"].forEach((evt) => t.element.addEventListener(evt, arm, true));
             armTriggers.forEach((el) => el.addEventListener("click", arm, true));
 
             t.on("renderStarted", () => {
                 if (clickArmed) preserveUntil = performance.now() + renderMs;
             });
             t.on("renderComplete", () => {
+                // The render path scrolls the window asynchronously — the scroll
+                // event (and the listener below) lands a frame late, so the jump
+                // flashes for one frame. Restore synchronously here, before paint.
+                if (clickArmed && window.scrollY !== preserveY) {
+                    window.scrollTo(0, preserveY);
+                }
                 clickArmed = false;
             });
 
index 8d8f52fc4cd078af09f453c3ca08007026a93418..7f191cf7cbedd2593b067fe1b065b97d1235d11c 100644 (file)
@@ -2,8 +2,6 @@
  * Copyright (C) 2017 Vsevolod Stakhov <vsevolod@highsecure.ru>
  */
 
-/* global require */
-
 define(["jquery", "app/common", "app/libft"],
     ($, common, libft) => {
         "use strict";
@@ -84,32 +82,30 @@ define(["jquery", "app/common", "app/libft"],
                         if (fileSet.files) items[0].file = fileSet.files[fileSet.index].name;
 
                         if (Object.prototype.hasOwnProperty.call(common.tables, "scan")) {
-                            common.tables.scan.rows.load(items, true);
+                            common.tables.scan.addData(items);
                         } else {
-                            require(["footable"], () => {
-                                libft.initHistoryTable(data, items, "scan", libft.columns_v2("scan"), true,
-                                    () => {
-                                        const {files} = fileSet;
-                                        if (files && fileSet.index < files.length - 1) {
-                                            common.fileUtils.readFile(files, (result) => {
-                                                const {index} = fileSet;
-                                                if (index === files.length - 1) {
-                                                    $("#scanMsgSource").val(result);
-                                                    common.fileUtils.setFileInputFiles("#formFile", files, index);
-                                                }
-                                                scanText(result);
-                                            }, ++fileSet.index);
-                                        } else {
-                                            enable_disable_scan_btn();
-                                            $("#cleanScanHistory, #scan .ft-columns-dropdown .btn-dropdown-apply")
-                                                .removeAttr("disabled");
-                                            libft.bindFuzzyHashButtons("scan");
-                                            $("html, body").animate({
-                                                scrollTop: $("#scanResult").offset().top
-                                            }, 1000);
-                                        }
-                                    });
-                            });
+                            libft.initHistoryTable(data, items, "scan", libft.columns_v2("scan"), true,
+                                () => {
+                                    const {files} = fileSet;
+                                    if (files && fileSet.index < files.length - 1) {
+                                        common.fileUtils.readFile(files, (result) => {
+                                            const {index} = fileSet;
+                                            if (index === files.length - 1) {
+                                                $("#scanMsgSource").val(result);
+                                                common.fileUtils.setFileInputFiles("#formFile", files, index);
+                                            }
+                                            scanText(result);
+                                        }, ++fileSet.index);
+                                    } else {
+                                        enable_disable_scan_btn();
+                                        $("#cleanScanHistory, #scan .ft-columns-dropdown .btn-dropdown-apply")
+                                            .removeAttr("disabled");
+                                        libft.bindFuzzyHashButtons("scan");
+                                        $("html, body").animate({
+                                            scrollTop: $("#scanResult").offset().top
+                                        }, 1000);
+                                    }
+                                });
                         }
                     } else {
                         common.alertMessage("alert-danger", "Cannot scan data");
index 6947263a986ca7bfbd1755a9f9a2a3d8f0f39ef7..34e196e954bbb12201624485669748345a6b485b 100644 (file)
@@ -59,22 +59,26 @@ test.describe.serial("Scan flow across WebUI tabs", () => {
      * @param {boolean} needsExpand - Whether row needs to be expanded
      */
     async function testSymbolOrdering(table, needsExpand) {
-        const tableLocator = page.locator(`#historyTable_${table} tbody`);
-        const firstRow = tableLocator.locator("tr").first();
+        const tableLocator = page.locator(`#historyTable_${table} .tabulator-table`);
+        const firstRow = tableLocator.locator(".tabulator-row").first();
         await expect(firstRow).toBeVisible();
 
-        if (needsExpand) {
+        // Tabulator renders the responsive-collapse detail as a descendant of the
+        // row element (.tabulator-responsive-collapse), not a FooTable sibling row.
+        // Only expand when collapsed: a prior test may have left the row open, and a
+        // blind click would collapse it again.
+        const detailRow = firstRow.locator(".tabulator-responsive-collapse");
+        if (needsExpand && !(await detailRow.isVisible())) {
             await firstRow.click();
-            await page.waitForTimeout(200); // Wait for expand animation
         }
-
-        // Get the detail row that immediately follows the row we clicked/selected
-        const detailRow = firstRow.locator("+ tr.footable-detail-row");
         await expect(detailRow).toBeVisible();
 
-        // Find the symbols row within the detail table (identified by the th containing sort buttons)
-        const symbolsRow = detailRow.locator("tr:has(th:has(.sym-order-toggle))");
-        const symbolsCell = symbolsRow.locator("td");
+        // The collapse lays each hidden column out as
+        // <tr><td><strong>TITLE</strong></td><td>VALUE</td></tr>. The symbols column
+        // title embeds the .sym-order-toggle sort buttons, and the symbol HTML is the
+        // value cell (the second td in that row).
+        const symbolsRow = detailRow.locator("tr:has(.sym-order-toggle)");
+        const symbolsCell = symbolsRow.locator("td").nth(1);
 
         // Get initial symbols order
         let previousSymbols = await symbolsCell.innerHTML();
@@ -137,8 +141,9 @@ test.describe.serial("Scan flow across WebUI tabs", () => {
 
                 await expectAlertSuccess("Data successfully scanned");
                 await expect(
-                    page.locator(`#historyTable_scan tbody tr:first-child td.footable-first-visible:has-text("${msgId}")`)
-                ).toBeVisible();
+                    page.locator("#historyTable_scan .tabulator-row").first()
+                        .locator('.tabulator-cell[tabulator-field="id"]')
+                ).toContainText(msgId);
             }
         });
 
@@ -154,10 +159,11 @@ test.describe.serial("Scan flow across WebUI tabs", () => {
             // Check both scanned messages are present in reverse order
             for (let i = 0; i < scannedSubjects.length; i++) {
                 const subject = scannedSubjects[scannedSubjects.length - 1 - i]; // reverse order
-                await expect(
-                    page.locator("#historyTable_history tbody tr").nth(i)
-                        .locator(`td:has-text("${subject}")`)
-                ).toBeVisible();
+                const row = page.locator("#historyTable_history .tabulator-row").nth(i);
+                // The Subject column can collapse into the detail row depending on
+                // viewport width, so expand the row and match against its full text.
+                await row.click();
+                await expect(row).toContainText(subject);
             }
         });
 
@@ -172,11 +178,19 @@ test.describe.serial("Scan flow across WebUI tabs", () => {
             page.once("dialog", (dialog) => dialog.accept());
             await resetBtn.click();
 
+            // The reload disables #updateHistory only for the duration of the
+            // fast, local history request, which is too brief to assert on the
+            // DOM. Wait for the reset and the subsequent reload responses instead.
+            await page.waitForResponse((r) => r.url().includes("historyreset"), {timeout: 10000});
+            await page.waitForResponse(
+                (r) => r.url().includes("history") && r.url().includes("from="),
+                {timeout: 10000}
+            );
+
             const updateHistoryBtn = page.locator("#updateHistory");
-            await expect(updateHistoryBtn).toBeDisabled();
-            await expect(updateHistoryBtn).not.toBeDisabled();
+            await expect(updateHistoryBtn).toBeEnabled();
 
-            const rows = await page.locator("#historyTable_history tbody tr").count();
+            const rows = await page.locator("#historyTable_history .tabulator-row").count();
             // Known bug: Rspamd leaves one row after history reset
             expect([0, 1]).toContain(rows);
         });
index 26fc0c6bf217545c8dc14f82da1cd18c02b09293..8f70a91dc7fac06a5376157825bdfeb33a923612 100644 (file)
@@ -12,20 +12,20 @@ test.describe("Symbols", () => {
     });
 
     test("shows list and allows filtering by group", async ({page}) => {
-        // Check filtering by group (if selector exists)
-        const groupSelect = page.locator(".footable-filtering select.form-select").first();
+        // The group filter is a Tabulator header-filter <select> on the Group column.
+        const groupSelect = page.locator("#symbolsTable .tabulator-header select.form-select").first();
         if (await groupSelect.count()) {
             // Ensure there is at least one real group besides "Any group"
             const optionCount = await groupSelect.evaluate((el) => el.options.length);
             expect(optionCount).toBeGreaterThan(1);
 
-            // Read target group's value and text BEFORE selection to avoid FooTable redraw races
+            // Read target group's value and text BEFORE selection to avoid redraw races
             const target = await groupSelect.evaluate((el) => {
                 const [, op] = Array.from(el.options); // first non-default option
                 return {text: op.text, value: op.value};
             });
 
-            const groupCells = page.locator("#symbolsTable tbody tr td.footable-first-visible");
+            const groupCells = page.locator("#symbolsTable .tabulator-cell[tabulator-field='group']");
             const beforeTexts = await groupCells.allTextContents();
 
             await groupSelect.selectOption({value: target.value});