From: Alexander Moisseev Date: Tue, 23 Jun 2026 16:06:46 +0000 (+0300) Subject: [Rework] WebUI: migrate rrd-table to Tabulator X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=b2b9c9d04c92fe0a7b69a513627bfbcfc03763f7;p=thirdparty%2Frspamd.git [Rework] WebUI: migrate rrd-table to Tabulator 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 --- diff --git a/interface/css/rspamd.css b/interface/css/rspamd.css index fbd67d786c..cbce1d39f6 100644 --- a/interface/css/rspamd.css +++ b/interface/css/rspamd.css @@ -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; } diff --git a/interface/index.html b/interface/index.html index 7629746c3c..4c99cd400e 100644 --- a/interface/index.html +++ b/interface/index.html @@ -291,7 +291,7 @@
-
+
Total messages:
diff --git a/interface/js/app/graph.js b/interface/js/app/graph.js index 336162a901..e996548156 100644 --- a/interface/js/app/graph.js +++ b/interface/js/app/graph.js @@ -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 + ", " + unit + "" : ""; + } + 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, " + unit + "", defaultContent: ""}, - {name: "avg", title: "Average, " + unit + "", defaultContent: ""}, - {name: "max", title: "Maximum, " + unit + "", 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); } diff --git a/interface/js/app/history.js b/interface/js/app/history.js index f8a87225fe..f253384dca 100644 --- a/interface/js/app/history.js +++ b/interface/js/app/history.js @@ -2,12 +2,11 @@ * Copyright (C) 2017 Vsevolod Stakhov */ -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 index 0000000000..dbca605f0c --- /dev/null +++ b/interface/js/app/tab-utils.js @@ -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; + });