]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Rework] WebUI: migrate rrd-table to Tabulator
authorAlexander Moisseev <moiseev@mezonplus.ru>
Tue, 23 Jun 2026 16:06:46 +0000 (19:06 +0300)
committerAlexander Moisseev <moiseev@mezonplus.ru>
Wed, 1 Jul 2026 11:25:44 +0000 (14:25 +0300)
Phase 1 of the FooTable→Tabulator migration:

- Extract reusable Tabulator helpers (scroll-prevention, tabindex
  removal, footer-hide, row-click-toggle, scrollIntoView guard) from
  history.js into a new tab-utils.js module — no footable dependency,
  so graph.js no longer loads footable.min.js for the throughput tab
- Refactor history.js initErrorsTable to use tab-utils helpers
- Migrate the rrd summary table (graph.js) from FooTable to Tabulator:
  row coloring via CSS variable, in-place updates via updateData,
  dynamic column titles via updateColumnDefinition, header sorting
- Update rspamd.css: rrd-table selectors for Tabulator DOM, row color
  via --rrd-row-color (visible in dark mode), row hover, layout tweaks

interface/css/rspamd.css
interface/index.html
interface/js/app/graph.js
interface/js/app/history.js
interface/js/app/tab-utils.js [new file with mode: 0644]

index fbd67d786c41f991e02461cea454f4d2b6f1ce76..cbce1d39f68c687e362355e9683c558a332ab8ac 100644 (file)
@@ -626,8 +626,8 @@ table#symbolsTable input[type="number"] {
 
 /* RRD summary */
 #summary-row {
-    padding-left: 80px;
-    padding-right: 80px;
+    padding-left: 50px;
+    padding-right: 50px;
 }
 .col-fixed,
 .col-fluid {
@@ -635,7 +635,7 @@ table#symbolsTable input[type="number"] {
     float: left;
 }
 .col-fixed {
-    width: 200px;
+    width: 192px;
     min-height: 1px; /* make an empty div take space */
 }
 .col-fluid {
@@ -651,13 +651,18 @@ table#symbolsTable input[type="number"] {
     margin-bottom: 2px;
     width: 100% !important;
     text-align: left;
-    font-size: 12px;
     z-index: 100;
 
     --bs-table-bg: var(--rspamd-bs-table-bg);
 }
-#rrd-table td {
-    color: inherit;
+#rrd-table .tabulator-row,
+#rrd-table .tabulator-row .tabulator-cell {
+    color: var(--rrd-row-color, inherit);
+}
+#rrd-table .tabulator-row:hover {
+    background-color: var(--rspamd-nav-active-bg) !important;
+}
+#rrd-table .tabulator-row .tabulator-cell {
     padding-top: 2px;
     padding-bottom: 2px;
 }
index 7629746c3c828fede0e4cf25fdcc3ae6ba91b7b2..4c99cd400eb66be12a20df04fea6345508fe8754 100644 (file)
                                                        <div id="summary-row" class="row">
                                                                <div class="col-fixed" id="rrd-pie"></div>
                                                                <div class="col-fluid">
-                                                                       <table id="rrd-table" class="table table-striped table-hover"></table>
+                                                                       <div id="rrd-table"></div>
                                                                        <div id="rrd-table_toggle"></div>
                                                                        <div id="rrd-total">Total messages: <span id="rrd-total-value"></span></div>
                                                                </div>
index 336162a901857d8d8c7294d2ec1118c4ec88e82e..e99654815651a8a3c4951966b410c1d9b0e361ba 100644 (file)
@@ -3,10 +3,9 @@
  * Copyright (C) 2017 Alexander Moisseev
  */
 
-/* global FooTable */
-
-define(["jquery", "app/common", "d3evolution", "d3pie", "d3", "footable"],
-    ($, common, D3Evolution, D3Pie, d3) => {
+define(["jquery", "app/common", "app/tab-utils", "d3evolution", "d3pie", "d3", "tabulator"],
+    // eslint-disable-next-line max-params -- AMD factory: each dep needs a param
+    ($, common, tabUtils, D3Evolution, D3Pie, d3, Tabulator) => {
         "use strict";
 
         const rrd_pie_config = {
@@ -39,6 +38,13 @@ define(["jquery", "app/common", "d3evolution", "d3pie", "d3", "footable"],
         const ui = {};
         let prevUnit = "msg/s";
 
+        const rrdUnitColumns = ["min", "avg", "max", "last"];
+        function rrdColumnTitle(field, unit) {
+            const titles = {min: "Minimum", avg: "Average", max: "Maximum", last: "Last"};
+            const label = titles[field];
+            return label ? label + ", <span class=\"unit\">" + unit + "</span>" : "";
+        }
+
         ui.draw = function (graphs, neighbours, checked_server, type) {
             const graph_options = {
                 title: "Rspamd throughput",
@@ -107,36 +113,38 @@ define(["jquery", "app/common", "d3evolution", "d3pie", "d3", "footable"],
             }
 
             function initSummaryTable(rows, unit) {
-                common.tables.rrd_summary = FooTable.init("#rrd-table", {
-                    breakpoints: common.breakpoints,
-                    cascade: true,
-                    sorting: {
-                        enabled: true
-                    },
+                common.tables.rrd_summary = new Tabulator("#rrd-table", {
+                    layout: "fitColumns",
+                    selectable: false,
+                    index: "label",
+                    data: rows,
                     columns: [
-                        {name: "label", title: "Action"},
-                        {name: "value", title: "Messages", defaultContent: ""},
-                        {name: "min", title: "Minimum, <span class=\"unit\">" + unit + "</span>", defaultContent: ""},
-                        {name: "avg", title: "Average, <span class=\"unit\">" + unit + "</span>", defaultContent: ""},
-                        {name: "max", title: "Maximum, <span class=\"unit\">" + unit + "</span>", defaultContent: ""},
-                        {name: "last", title: "Last, " + unit},
+                        {title: "Action", field: "label", minWidth: 90},
+                        {title: "Messages", field: "value", minWidth: 100},
+                        {title: rrdColumnTitle("min", unit), field: "min", minWidth: 149},
+                        {title: rrdColumnTitle("avg", unit), field: "avg", minWidth: 143},
+                        {title: rrdColumnTitle("max", unit), field: "max", minWidth: 151},
+                        {title: rrdColumnTitle("last", unit), field: "last", minWidth: 121},
                     ],
-                    rows: rows.map((curr, i) => ({
-                        options: {
-                            style: {
-                                color: graph_options.legend.entries[i].color
-                            }
-                        },
-                        value: curr
-                    }), [])
+                    rowFormatter: function (row) {
+                        const c = row.getData().color;
+                        if (c) row.getElement().style.setProperty("--rrd-row-color", c);
+                    },
                 });
+                tabUtils.patchScrollIntoViewOnce();
+                tabUtils.stripTableholderTabindex("rrd_summary");
             }
 
             function drawRrdTable(rows, unit) {
                 if (Object.prototype.hasOwnProperty.call(common.tables, "rrd_summary")) {
-                    $.each(common.tables.rrd_summary.rows.all, (i, row) => {
-                        row.val(rows[i], false, true);
-                    });
+                    common.tables.rrd_summary.updateData(rows);
+                    if (unit !== prevUnit) {
+                        rrdUnitColumns.forEach((field) => {
+                            common.tables.rrd_summary.updateColumnDefinition(field, {
+                                title: rrdColumnTitle(field, unit),
+                            });
+                        });
+                    }
                 } else {
                     initSummaryTable(rows, unit);
                 }
index f8a87225fe7457074b818da612eecaa48d17e8e8..f253384dca0af163554e3a234ddfa8dd39ac4020 100644 (file)
@@ -2,12 +2,11 @@
  * Copyright (C) 2017 Vsevolod Stakhov <vsevolod@highsecure.ru>
  */
 
-define(["jquery", "app/common", "app/libft", "tabulator"],
-    ($, common, libft, Tabulator) => {
+define(["jquery", "app/common", "app/libft", "app/tab-utils", "tabulator"],
+    ($, common, libft, tabUtils, Tabulator) => {
         "use strict";
         const ui = {};
         let prevVersion = null;
-        let scrollIntoViewPatched = false;
 
         // History range: offset and count
         const histFromDef = 0;
@@ -257,115 +256,14 @@ define(["jquery", "app/common", "app/libft", "tabulator"],
                 ],
             });
 
-            // Scroll-prevention state (shared by the click/renderStarted/scroll
-            // handlers below).
-            let preserveY = 0;
-            let preserveUntil = 0;
-            let clickArmed = false;
-
-            // FooTable parity: hide the pagination footer when only one page.
-            // renderComplete fires after data load, filtering and sorting,
-            // when getPageMax() is up to date. Also clears clickArmed so that
-            // non-click renders (initial load, filter) don't extend the scroll-
-            // prevention window with a stale preserveY.
-            common.tables.errors.on("renderComplete", () => {
-                const footer = common.tables.errors.element.querySelector(".tabulator-footer");
-                if (footer) footer.style.display = common.tables.errors.getPageMax() > 1 ? "" : "none";
-                clickArmed = false;
+            // Common Tabulator UI setup (helpers in libft.js).
+            tabUtils.hideFooterOnSinglePage("errors");
+            tabUtils.stripTableholderTabindex("errors");
+            tabUtils.bindRowClickToggle("errors");
+            tabUtils.patchScrollIntoViewOnce();
+            tabUtils.installScrollPreservation("errors", {
+                armTriggers: [document.getElementById("updateErrors")].filter(Boolean),
             });
-
-            // Clicking a body cell focuses the tableholder (tabindex=0), and the
-            // browser scrolls it into view — for a tall table that shifts the page
-            // ~one viewport (the "expand flicker"). The tiny toggle icon is
-            // in-view so out-of-the-box it never happened; clicking anywhere on
-            // the row exposes it. Strip the tableholder's tabindex and keep it
-            // stripped (Tabulator may re-add it).
-            const errorsHolder = common.tables.errors.element.querySelector(".tabulator-tableholder");
-            if (errorsHolder) {
-                errorsHolder.removeAttribute("tabindex");
-                new MutationObserver(() => errorsHolder.removeAttribute("tabindex"))
-                    .observe(errorsHolder, {attributes: true, attributeFilter: ["tabindex"]});
-            }
-
-            // Toggle responsive-collapse by clicking anywhere on the row (not
-            // just the tiny toggle icon). The toggle's own handler calls
-            // stopImmediatePropagation, so clicks on the icon don't bubble here
-            // (no double-toggle). Skip while selecting/copying text.
-            common.tables.errors.element.addEventListener("click", (e) => {
-                const row = e.target.closest(".tabulator-row");
-                if (!row) return;
-                const sel = window.getSelection && window.getSelection();
-                if (sel && sel.toString()) return;
-                const toggle = row.querySelector(".tabulator-responsive-collapse-toggle");
-                // Only toggle when collapse is active (toggle column is visible).
-                // On a wide screen there are no collapsed columns and the toggle
-                // is hidden — clicking it would otherwise pre-mark the row as
-                // expanded, so it shows open when the screen is later narrowed.
-                if (toggle && toggle.offsetParent) toggle.click();
-            });
-
-            // Neutralize scrollIntoView for elements in the errors table.
-            // Tabulator smooth-scrolls the focused/expanded element into view
-            // (scrollIntoView {behavior:"smooth"}); a smooth animation spans many
-            // frames, so it can't be countered before paint without flickering.
-            // No-op it for elements inside the current errors table. Guarded so
-            // it patches the prototype only once (even if initErrorsTable runs
-            // again); uses common.tables.errors.element (live) so a re-created
-            // table is picked up without re-patching.
-            if (!scrollIntoViewPatched) {
-                const nativeScrollIntoView = Element.prototype.scrollIntoView;
-                Element.prototype.scrollIntoView = function (...args) {
-                    const el = common.tables.errors && common.tables.errors.element;
-                    if (!(el && el.contains(this))) {
-                        nativeScrollIntoView.apply(this, args);
-                    }
-                };
-                scrollIntoViewPatched = true;
-            }
-
-            // Tabulator scrolls the page on clicks/sort/expand and after Update.
-            // Restore the position synchronously in the scroll handler (capture,
-            // before paint) so the jump is never painted. Both clicks use a 250ms
-            // window for sync scrolls; async renders (Update's fetch → setData)
-            // are covered by renderStarted, which extends the window — no fixed
-            // timeout, so even a slow response is handled.
-            function arm(ms) {
-                return () => {
-                    const y = window.scrollY;
-                    preserveY = y;
-                    preserveUntil = performance.now() + ms;
-                    clickArmed = true;
-                    Promise.resolve().then(() => {
-                        // Microtask (after sync handlers, before paint): catch
-                        // synchronous scrolls.
-                        if (window.scrollY !== y) window.scrollTo(0, y);
-                        // This rAF is queued from the microtask, so it lands AFTER
-                        // Tabulator's render rAF (queued during the click). It
-                        // runs in the same frame, after the render-scroll but
-                        // before paint — catching the async expand-scroll without
-                        // the 1-frame flicker that the scroll-event handler (one
-                        // frame later) would cause.
-                        requestAnimationFrame(() => {
-                            if (window.scrollY !== y) window.scrollTo(0, y);
-                        });
-                    });
-                };
-            }
-            common.tables.errors.element.addEventListener("click", arm(250), true);
-            const updateBtn = document.getElementById("updateErrors");
-            if (updateBtn) updateBtn.addEventListener("click", arm(250), true);
-            // Async renders (e.g., Update's fetch → setData) fire renderStarted
-            // after the click's window expires. Extend it (if a click recently
-            // armed) to catch the render-scroll. clickArmed is cleared on
-            // renderComplete (above) so non-click renders don't extend with a
-            // stale preserveY.
-            common.tables.errors.on("renderStarted", () => {
-                if (clickArmed) preserveUntil = performance.now() + 400;
-            });
-            window.addEventListener("scroll", () => {
-                if (performance.now() >= preserveUntil) return;
-                if (window.scrollY !== preserveY) window.scrollTo(0, preserveY);
-            }, true);
         }
 
         ui.getErrors = function () {
diff --git a/interface/js/app/tab-utils.js b/interface/js/app/tab-utils.js
new file mode 100644 (file)
index 0000000..dbca605
--- /dev/null
@@ -0,0 +1,120 @@
+/*
+ * Tabulator UI helpers shared across tables.
+ * Extracted from history.js Phase 0. Depends only on common (for
+ * common.tables); no jQuery, no FooTable.
+ */
+
+define(["app/common"],
+    (common) => {
+        "use strict";
+        const tabUtils = {};
+
+        let scrollIntoViewPatched = false;
+
+        /**
+         * Patch Element.prototype.scrollIntoView once so that calls for elements
+         * inside ANY Tabulator table (.tabulator container) are no-ops. Guarded
+         * by a module-level flag so the prototype is patched only once regardless
+         * of how many tables call this.
+         */
+        tabUtils.patchScrollIntoViewOnce = function () {
+            if (scrollIntoViewPatched) return;
+            const native = Element.prototype.scrollIntoView;
+            Element.prototype.scrollIntoView = function (...args) {
+                if (!this.closest(".tabulator")) {
+                    native.apply(this, args);
+                }
+            };
+            scrollIntoViewPatched = true;
+        };
+
+        /**
+         * Remove tabindex from the tableholder and keep it removed (Tabulator
+         * re-adds it on render). Prevents focus-scroll when clicking body cells.
+         */
+        tabUtils.stripTableholderTabindex = function (table) {
+            const holder = common.tables[table].element.querySelector(".tabulator-tableholder");
+            if (!holder) return;
+            holder.removeAttribute("tabindex");
+            new MutationObserver(() => holder.removeAttribute("tabindex"))
+                .observe(holder, {attributes: true, attributeFilter: ["tabindex"]});
+        };
+
+        /**
+         * Hide the pagination footer when the table has only one page (FooTable
+         * parity). Registers a renderComplete handler.
+         */
+        tabUtils.hideFooterOnSinglePage = function (table) {
+            common.tables[table].on("renderComplete", () => {
+                const t = common.tables[table];
+                const footer = t.element.querySelector(".tabulator-footer");
+                if (footer) footer.style.display = t.getPageMax() > 1 ? "" : "none";
+            });
+        };
+
+        /**
+         * Allow clicking anywhere on a row to toggle responsive-collapse
+         * (instead of just the tiny toggle icon). Skips when selecting text
+         * and when collapse is not active (toggle hidden).
+         */
+        tabUtils.bindRowClickToggle = function (table) {
+            common.tables[table].element.addEventListener("click", (e) => {
+                const row = e.target.closest(".tabulator-row");
+                if (!row) 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();
+            });
+        };
+
+        /**
+         * Install scroll-position preservation for a table. Tabulator scrolls
+         * the page on interactions; this restores the position synchronously
+         * (before paint) so the jump is never visible.
+         *
+         * @param {string} table - Key in common.tables
+         * @param {Object} options - { armTriggers: HTMLElement[], clickMs?: 250, renderMs?: 400 }
+         */
+        tabUtils.installScrollPreservation = function (table, options) {
+            const t = common.tables[table];
+            const {clickMs = 250, renderMs = 400, armTriggers = []} = options || {};
+            let preserveY = 0;
+            let preserveUntil = 0;
+            let clickArmed = false;
+
+            function arm() {
+                const y = window.scrollY;
+                preserveY = y;
+                preserveUntil = performance.now() + clickMs;
+                clickArmed = true;
+                Promise.resolve().then(() => {
+                    // Microtask (after sync handlers, before paint): catch sync scrolls.
+                    if (window.scrollY !== y) window.scrollTo(0, y);
+                    // rAF queued from the microtask lands AFTER Tabulator's render rAF
+                    // (queued during the click), in the same frame after the render-
+                    // scroll but before paint.
+                    requestAnimationFrame(() => {
+                        if (window.scrollY !== y) window.scrollTo(0, y);
+                    });
+                });
+            }
+
+            t.element.addEventListener("click", arm, true);
+            armTriggers.forEach((el) => el.addEventListener("click", arm, true));
+
+            t.on("renderStarted", () => {
+                if (clickArmed) preserveUntil = performance.now() + renderMs;
+            });
+            t.on("renderComplete", () => {
+                clickArmed = false;
+            });
+
+            window.addEventListener("scroll", () => {
+                if (performance.now() >= preserveUntil) return;
+                if (window.scrollY !== preserveY) window.scrollTo(0, preserveY);
+            }, true);
+        };
+
+        return tabUtils;
+    });