From: Alexander Moisseev Date: Wed, 1 Jul 2026 11:06:40 +0000 (+0300) Subject: [Rework] WebUI: migrate history/scan to Tabulator X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=ca264b2a12a44dd36654ee622dd389972272196c;p=thirdparty%2Frspamd.git [Rework] WebUI: migrate history/scan to Tabulator 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. --- diff --git a/interface/css/rspamd.css b/interface/css/rspamd.css index c4efd29185..5d90678397 100644 --- a/interface/css/rspamd.css +++ b/interface/css/rspamd.css @@ -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 { diff --git a/interface/index.html b/interface/index.html index 0a9c9868b9..7d64166f11 100644 --- a/interface/index.html +++ b/interface/index.html @@ -609,7 +609,7 @@
-
+
@@ -737,7 +737,7 @@
-
+
diff --git a/interface/js/app/history.js b/interface/js/app/history.js index f253384dca..93860c0f88 100644 --- a/interface/js/app/history.js +++ b/interface/js/app/history.js @@ -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, diff --git a/interface/js/app/libft.js b/interface/js/app/libft.js index bccc918bd7..4f73b4d50d 100644 --- a/interface/js/app/libft.js +++ b/interface/js/app/libft.js @@ -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("
\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 `
${action}
`; + } + + 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 `${score.toFixed(2)} / ${required}`; + } - // Public functions + function symOrderTitle(table) { + return "Symbols" + + '
' + + '
Sort by:
' + + '
' + + '' + + '' + + '' + + "
" + + "
"; + } + + // ── 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: '
Pass-through module
', - 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" + - '
' + - '
Sort by:
' + - '
' + - '' + - '' + - '' + - "
" + - "
", - 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 = $("", { - 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 = $("
", { - class: "form-group d-inline-flex align-items-center" - }).append($("