]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Rework] WebUI: migrate symbols table to Tabulator
authorAlexander Moisseev <moiseev@mezonplus.ru>
Thu, 25 Jun 2026 15:26:57 +0000 (18:26 +0300)
committerAlexander Moisseev <moiseev@mezonplus.ru>
Wed, 1 Jul 2026 11:25:44 +0000 (14:25 +0300)
Phase 2 of the FooTable→Tabulator migration:

- Replace FooTable.init with Tabulator in symbols.js: columns, local
  pagination (25/page), per-column header filters, responsive collapse
- Custom group dropdown filter: native <select> with "Any group" clear
  option, auto-sorted, refreshes on Update
- Score column: formatter:"html" preserves inline editing; row-click
  toggle skips form elements (tab-utils updated)
- Frequency/stddev: raw number storage + closure formatter for
  exponential display (e.g. "1.23e-5"), sorter:"number"
- Events: ready→tableBuilt (read-only), postdraw→renderComplete
- Update: rows.load→setData + dropdown refresh
- rspamd.css: scorebar specificity fix for dark mode, combined hover
  selector for rrd-table + symbolsTable, form-select for group dropdown
- Playwright: update symbols test selector for Tabulator DOM

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

index cbce1d39f68c687e362355e9683c558a332ab8ac..c4efd291858ee2962d0515f3542908ee68bff7e8 100644 (file)
@@ -260,6 +260,7 @@ textarea {
 .tabulator .tabulator-header-filter input {
     -webkit-appearance: none;
     appearance: none;
+    width: 100%;
     color: var(--bs-body-color);
     background-color: var(--bs-body-bg);
     border: 1px solid var(--bs-border-color);
@@ -439,7 +440,7 @@ input.form-control[type="number"] {
 input.action-scores {
     margin: 5px -7em 5px 0;
 }
-table#symbolsTable input[type="number"] {
+#symbolsTable input[type="number"] {
     width: 6em;
     font-size: 11px;
 }
@@ -659,7 +660,8 @@ table#symbolsTable input[type="number"] {
 #rrd-table .tabulator-row .tabulator-cell {
     color: var(--rrd-row-color, inherit);
 }
-#rrd-table .tabulator-row:hover {
+#rrd-table .tabulator-row:hover,
+#symbolsTable .tabulator-row:hover {
     background-color: var(--rspamd-nav-active-bg) !important;
 }
 #rrd-table .tabulator-row .tabulator-cell {
@@ -749,11 +751,14 @@ table#symbolsTable input[type="number"] {
   background-image: none;
 }
 
-.scorebar-spam {
+/* Tabulator's input styling has higher specificity than .scorebar-*, so
+   scope these to #symbolsTable to win the cascade. scorebar is used
+   exclusively in the symbols table. */
+#symbolsTable .scorebar-spam {
     background-color: rgb(240 0 0 / 0.1) !important;
 }
-.scorebar-ham {
-    background: rgb(100 230 80 / 0.1) !important;
+#symbolsTable .scorebar-ham {
+    background-color: rgb(100 230 80 / 0.1) !important;
 }
 
 .danger .icon {
index 4c99cd400eb66be12a20df04fea6345508fe8754..0a9c9868b923082838a48af9c100fda86e087a46 100644 (file)
                                                </div>
                                        </div>
                                        <div class="card-body p-0">
-                                               <table class="table table-hover" id="symbolsTable"></table>
+                                               <div id="symbolsTable"></div>
                                        </div>
                                </div>
                        </div>
index d318b7016c148ce5a717f23bffaa05d466827f68..c2d18c2ef2637df0ea5b5de692effee6588613f8 100644 (file)
@@ -2,13 +2,12 @@
  * Copyright (C) 2017 Vsevolod Stakhov <vsevolod@highsecure.ru>
  */
 
-/* global FooTable */
-
-define(["jquery", "app/common", "footable"],
-    ($, common) => {
+define(["jquery", "app/common", "app/tab-utils", "tabulator"],
+    ($, common, tabUtils, Tabulator) => {
         "use strict";
         const ui = {};
         let altered = {};
+        let groupSelectEl = null;
 
         function clear_altered() {
             $("#save-alert").addClass("d-none");
@@ -94,18 +93,25 @@ define(["jquery", "app/common", "footable"],
                 }
             }
 
-            function formatFrequency(value) {
-                return {
-                    value: (value * mult).toFixed(2) + ((exp > 0) ? "e-" + exp : ""),
-                    options: {sortValue: value}
-                };
-            }
-            $.each(items, (i, item) => {
-                item.frequency = formatFrequency(item.frequency);
-                item.frequency_stddev = formatFrequency(item.frequency_stddev);
-            });
-            return [items, distinct_groups];
+            return [items, distinct_groups, {mult, exp}];
+        }
+        // Populate a native <select> with distinct sorted groups from the
+        // table data, plus an empty option to clear the filter.
+        function formatFreq(value, params) {
+            return (value * params.mult).toFixed(2) + ((params.exp > 0) ? "e-" + params.exp : "");
         }
+
+        function populateGroupSelect(select, rows) {
+            const current = select.value;
+            select.innerHTML = "";
+            select.appendChild(new Option("Any group", ""));
+            const data = rows || (common.tables.symbols && common.tables.symbols.getData()) || [];
+            [...new Set(data.map((r) => r.group))]
+                .sort((a, b) => a.localeCompare(b))
+                .forEach((g) => select.appendChild(new Option(g, g)));
+            select.value = current;
+        }
+
         // @get symbols into modal form
         ui.getSymbols = function () {
             $("#refresh, #updateSymbols").attr("disabled", true);
@@ -113,111 +119,107 @@ define(["jquery", "app/common", "footable"],
             common.query("symbols", {
                 success: function (json) {
                     const [{data}] = json;
-                    const items = process_symbols_data(data);
+                    const [rows, , freqParams] = process_symbols_data(data);
 
-                    /* eslint-disable consistent-this, no-underscore-dangle */
-                    FooTable.groupFilter = FooTable.Filtering.extend({
-                        construct: function (instance) {
-                            this._super(instance);
-                            [,this.groups] = items;
-                            this.groups.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
-                            this.def = "Any group";
-                            this.$group = null;
-                        },
-                        $create: function () {
-                            this._super();
-                            const self = this;
-                            const $form_grp = $("<div/>", {
-                                class: "form-group"
-                            }).append($("<label/>", {
-                                class: "sr-only",
-                                text: "Group"
-                            })).prependTo(self.$form);
-
-                            self.$group = $("<select/>", {
-                                class: "form-select"
-                            }).on("change", {
-                                self: self
-                            }, self._onStatusDropdownChanged).append(
-                                $("<option/>", {
-                                    text: self.def
-                                })).appendTo($form_grp);
-
-                            $.each(self.groups, (i, group) => {
-                                self.$group.append($("<option/>").text(group));
-                            });
-
-                            common.appendButtonsToFtFilterDropdown(self);
-                        },
-                        _onStatusDropdownChanged: function (e) {
-                            const {self} = e.data;
-                            const selected = $(this).val();
-                            if (selected !== self.def) {
-                                self.addFilter("group", selected, ["group"]);
-                            } else {
-                                self.removeFilter("group");
-                            }
-                            self.filter();
-                        },
-                        draw: function () {
-                            this._super();
-                            const group = this.find("group");
-                            if (group instanceof FooTable.Filter) {
-                                this.$group.val(group.query.val());
-                            } else {
-                                this.$group.val(this.def);
-                            }
-                        }
-                    });
-                    /* eslint-enable consistent-this, no-underscore-dangle */
-
-                    common.tables.symbols = FooTable.init("#symbolsTable", {
-                        breakpoints: common.breakpoints,
-                        cascade: true,
+                    common.tables.symbols = new Tabulator("#symbolsTable", {
+                        layout: "fitColumns",
+                        responsiveLayout: "collapse",
+                        responsiveLayoutCollapseStartOpen: false,
+                        selectable: false,
+                        pagination: "local",
+                        paginationSize: 25,
+                        paginationButtonCount: 5,
+                        data: rows,
+                        initialSort: [{column: "group", dir: "asc"}],
                         columns: [
-                            {sorted: true, direction: "ASC", name: "group", title: "Group"},
-                            {name: "symbol", title: "Symbol"},
-                            {name: "description", title: "Description", breakpoints: "md"},
-                            {name: "weight", title: "Score"},
-                            {name: "frequency",
+                            {
+                                // Toggle to expand collapsed (responsive) columns
+                                formatter: "responsiveCollapse",
+                                width: 36,
+                                minWidth: 36,
+                                responsive: 0,
+                                hozAlign: "center",
+                                resizable: false,
+                                headerSort: false,
+                            },
+                            {
+                                title: "Group",
+                                field: "group",
+                                headerFilter: (cell, onRendered, success) => {
+                                    const select = document.createElement("select");
+                                    select.className = "form-select";
+                                    populateGroupSelect(select, rows);
+                                    select.refreshGroups = () => populateGroupSelect(select);
+                                    select.addEventListener("change", () => success(select.value));
+                                    groupSelectEl = select;
+                                    return select;
+                                },
+                                headerFilterFunc: (filterVal, cellVal) => !filterVal || filterVal === cellVal,
+                                minWidth: 110,
+                                width: 205,
+                                responsive: 0,
+                            },
+                            {
+                                title: "Symbol",
+                                field: "symbol",
+                                headerFilter: "input",
+                                minWidth: 270,
+                                responsive: 0,
+                                widthGrow: 2,
+                            },
+                            {
+                                title: "Description",
+                                field: "description",
+                                headerFilter: "input",
+                                minWidth: 105,
+                                responsive: 2,
+                                widthGrow: 4,
+                            },
+                            {
+                                title: "Score",
+                                field: "weight",
+                                formatter: "html",
+                                minWidth: 75,
+                                width: 90,
+                                responsive: 0,
+                            },
+                            {
                                 title: "Frequency, <nobr>hits/s</nobr>",
-                                breakpoints: "md",
-                                sortValue: (val) => val.options.sortValue},
-                            {name: "frequency_stddev",
+                                field: "frequency",
+                                sorter: "number",
+                                formatter: (cell, params) => formatFreq(cell.getValue(), params),
+                                formatterParams: freqParams,
+                                minWidth: 140,
+                            },
+                            {
                                 title: "Stddev, <nobr>hits/s</nobr>",
-                                breakpoints: "lg",
-                                sortValue: (val) => val.options.sortValue},
-                            {name: "time",
-                                title: "Avg. time, s",
-                                breakpoints: "md",
-                                sortValue: (val) => parseFloat(val)},
-                        ],
-                        rows: items[0],
-                        paging: {
-                            enabled: true,
-                            limit: 5,
-                            size: 25
-                        },
-                        filtering: {
-                            enabled: true,
-                            position: "left",
-                            connectors: false
-                        },
-                        sorting: {
-                            enabled: true
-                        },
-                        components: {
-                            filtering: FooTable.groupFilter
-                        },
-                        on: {
-                            "ready.ft.table": function () {
-                                if (common.read_only) {
-                                    $(".mb-disabled").attr("disabled", true);
-                                }
+                                field: "frequency_stddev",
+                                sorter: "number",
+                                formatter: (cell, params) => formatFreq(cell.getValue(), params),
+                                formatterParams: freqParams,
+                                minWidth: 120,
+                                responsive: 3,
                             },
-                            "postdraw.ft.table":
-                                () => $("#refresh, #updateSymbols").removeAttr("disabled")
+                            {title: "Avg. time, s", field: "time", sorter: "number", minWidth: 110},
+                        ],
+                    });
+
+                    tabUtils.hideFooterOnSinglePage("symbols");
+                    tabUtils.stripTableholderTabindex("symbols");
+                    tabUtils.bindRowClickToggle("symbols");
+                    tabUtils.patchScrollIntoViewOnce();
+                    tabUtils.installScrollPreservation("symbols", {
+                        armTriggers: [document.getElementById("updateSymbols")].filter(Boolean),
+                    });
+
+                    common.tables.symbols.on("tableBuilt", () => {
+                        if (common.read_only) {
+                            $(".mb-disabled").attr("disabled", true);
                         }
+                        $("#refresh, #updateSymbols").removeAttr("disabled");
+                    });
+                    common.tables.symbols.on("renderComplete", () => {
+                        $("#refresh, #updateSymbols").removeAttr("disabled");
                     });
                 },
                 error: () => $("#refresh, #updateSymbols").removeAttr("disabled"),
@@ -232,8 +234,12 @@ define(["jquery", "app/common", "footable"],
             clear_altered();
             common.query("symbols", {
                 success: function (data) {
-                    const [items] = process_symbols_data(data[0].data);
-                    common.tables.symbols.rows.load(items);
+                    const [rows, , freqParams] = process_symbols_data(data[0].data);
+
+                    common.tables.symbols.setData(rows);
+                    if (groupSelectEl) populateGroupSelect(groupSelectEl);
+                    common.tables.symbols.updateColumnDefinition("frequency", {formatterParams: freqParams});
+                    common.tables.symbols.updateColumnDefinition("frequency_stddev", {formatterParams: freqParams});
                 },
                 error: () => $("#refresh, #updateSymbols").removeAttr("disabled"),
                 server: common.getServer()
index dbca605f0ceb7461909f4b1e7c03a4f6c4b17a04..2b88461b4830f48c8bedb8cd1fb4e86d988ec145 100644 (file)
@@ -54,13 +54,15 @@ define(["app/common"],
 
         /**
          * Allow clicking anywhere on a row to toggle responsive-collapse
-         * (instead of just the tiny toggle icon). Skips when selecting text
+         * (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).
          */
         tabUtils.bindRowClickToggle = function (table) {
             common.tables[table].element.addEventListener("click", (e) => {
                 const row = e.target.closest(".tabulator-row");
                 if (!row) return;
+                if (e.target.closest("input, select, textarea, button")) return;
                 const sel = window.getSelection && window.getSelection();
                 if (sel && sel.toString()) return;
                 const toggle = row.querySelector(".tabulator-responsive-collapse-toggle");
index 2a1cca74b5f71b1fc877734c94a6141c481c3c6e..26fc0c6bf217545c8dc14f82da1cd18c02b09293 100644 (file)
@@ -8,7 +8,7 @@ test.describe("Symbols", () => {
         await page.locator("#symbols_nav").click();
         await expect(page.locator("#symbolsTable")).toBeVisible();
         // Ensure table data has been loaded before running tests
-        await expect(page.locator("#symbolsTable tbody tr").first()).toBeVisible();
+        await expect(page.locator("#symbolsTable .tabulator-table .tabulator-row").first()).toBeVisible();
     });
 
     test("shows list and allows filtering by group", async ({page}) => {