/* RRD summary */
#summary-row {
- padding-left: 80px;
- padding-right: 80px;
+ padding-left: 50px;
+ padding-right: 50px;
}
.col-fixed,
.col-fluid {
float: left;
}
.col-fixed {
- width: 200px;
+ width: 192px;
min-height: 1px; /* make an empty div take space */
}
.col-fluid {
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;
}
<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>
* 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 = {
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",
}
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);
}
* 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;
],
});
- // 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 () {
--- /dev/null
+/*
+ * 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;
+ });