diff --git a/InfoLogger/package-lock.json b/InfoLogger/package-lock.json index cb35c3282..a45c4202e 100644 --- a/InfoLogger/package-lock.json +++ b/InfoLogger/package-lock.json @@ -12,7 +12,7 @@ ], "license": "GPL-3.0", "dependencies": { - "@aliceo2/web-ui": "2.12.0", + "@aliceo2/web-ui": "2.11.1", "mariadb": "3.5.4" }, "devDependencies": { @@ -31,19 +31,19 @@ } }, "node_modules/@aliceo2/web-ui": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@aliceo2/web-ui/-/web-ui-2.12.0.tgz", - "integrity": "sha512-7x382tuQXosKkEVEMpImqEvMHc3GSJMJkMmZ/EooxV0gMuwIEPQL2BQEZ1HIoc0mqGVA1kMwlkUmAX0EKVAg+Q==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@aliceo2/web-ui/-/web-ui-2.11.1.tgz", + "integrity": "sha512-XcuSD7s5V+Y2TKVTHe23BAtXqcsNZGBXymXXQHBvIv8Ndds7tyP8/nKLFFGpmGmaOPyK5MS7seWXIJN9tr1ozQ==", "inBundle": true, "license": "GPL-3.0", "dependencies": { - "express": "4.22.3", + "express": "4.22.2", "helmet": "8.1.0", "jsonwebtoken": "9.0.3", "kafkajs": "2.2.4", "mithril": "1.1.7", "openid-client": "5.6.5", - "protobufjs": "8.8.0", + "protobufjs": "8.7.1", "winston": "3.19.0", "ws": "8.21.2" }, @@ -1237,9 +1237,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.8", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", - "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "inBundle": true, "license": "MIT", "dependencies": { @@ -1251,7 +1251,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.16.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -2227,9 +2227,9 @@ } }, "node_modules/express": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", - "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "inBundle": true, "license": "MIT", "dependencies": { @@ -2252,9 +2252,9 @@ "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.13", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.16.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -2697,9 +2697,9 @@ } }, "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "inBundle": true, "license": "MIT", "dependencies": { @@ -4169,9 +4169,9 @@ } }, "node_modules/protobufjs": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz", - "integrity": "sha512-N3xhQ5yyBx3vQq4gubBfASzYhJGNzeDbjqBpu61g7UVylsN/qyffU96TKWD3GbbLOKF82VGNRNvv1+BFgE31Eg==", + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.7.1.tgz", + "integrity": "sha512-agdGHrXNTv0IrYscJPDou/PlEJk1c/hBZ9o/B5NH2i/nSPtPqacNxzgwf1CebXxFMjMrZH5sqv9uQuw96aGt/A==", "inBundle": true, "license": "BSD-3-Clause", "dependencies": { @@ -4182,9 +4182,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", - "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "inBundle": true, "license": "MIT", "dependencies": { @@ -4193,10 +4193,6 @@ }, "engines": { "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, "node_modules/punycode": { @@ -4292,14 +4288,13 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "inBundle": true, "license": "BSD-3-Clause", "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -4560,15 +4555,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "inBundle": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, diff --git a/InfoLogger/package.json b/InfoLogger/package.json index 1c32132e7..10277e4ee 100644 --- a/InfoLogger/package.json +++ b/InfoLogger/package.json @@ -29,7 +29,7 @@ }, "main": "index.js", "dependencies": { - "@aliceo2/web-ui": "2.12.0", + "@aliceo2/web-ui": "2.11.1", "mariadb": "3.5.4" }, "devDependencies": { diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index 98f334a32..3ac07791d 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -547,11 +547,13 @@ export default class Log extends Observable { } /** - * Empty the list of all logs, reset stats, clear query mode request if any - * and close the inspector panel + * Empty the list of all logs, reset stats, clear query mode request if any, + * deselect the current log and close the inspector panel */ empty() { this.list = []; + this.item = null; + this.autoScrollToItem = false; this.limitReached = null; this.model.inspectorEnabled = false; this.resetStats(); @@ -575,12 +577,19 @@ export default class Log extends Observable { } /** - * Set auto-scroll for live mode and notify the UI + * Set auto-scroll for live mode and optionally notify the UI * @param {boolean} enabled - whether auto-scroll should be enabled + * @param {boolean} notify - whether to notify the UI if there is a change */ - setAutoScrollLive(enabled) { + setAutoScrollLive(enabled, notify = true) { + if (this.autoScrollLive === enabled) { + return; + } this.autoScrollLive = enabled; - this.notify(); + + if (notify) { + this.notify(); + } } /** diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 1543ecb09..840ecec00 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -42,7 +42,7 @@ const LIVE_BUTTON_TYPES_BY_MODE = { export const commandLogs = (model) => [ userActionsDropdown(model), h('', interactionModesGroupButton(model)), - h('', h('button.btn', { onclick: () => model.log.empty(), style: 'font-weight: bold' }, 'Clear')), + h('', h('button.btn', { id: 'clear-button', onclick: () => model.log.empty(), style: 'font-weight: bold' }, 'Clear')), h('.btn-group', [ h('button.btn', { disabled: !model.log.list.length, @@ -80,7 +80,7 @@ export const commandLogs = (model) => [ * @returns {vnode} - the view of the interaction mode buttons */ const interactionModesGroupButton = (model) => { - const { frameworkInfo, log } = model; + const { frameworkInfo } = model; return frameworkInfo.match({ NotAsked: () => h('button.btn', { disabled: true }, ''), @@ -88,8 +88,8 @@ const interactionModesGroupButton = (model) => { Failure: () => null, Success: () => h('.btn-group', [ - queryButton(log), - liveButton(log), + queryButton(model.log), + liveButton(model.log), ]), }); }; diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 13c822940..9655cbfff 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -198,35 +198,72 @@ const linkToWikiErrors = (errcode) => h('a', { const tableContainerHooks = (model) => ({ /** - * Hook. Listen to events needed for handling scrolling like window size change - * And set scroll change handler to internal state of dom element + * Hook. Listen to scroll and window size changes to keep the model's viewport in sync * @param {vnode} vnode - the vnode of the element */ oncreate(vnode) { + const container = vnode.dom; + /** - * THis handler allow to notify model of element scrolling change (.tableLogsContent) + * Current scroll position, cancels negative position due to Safari bounce scrolling + * @returns {number} scrollTop clamped to 0 */ - const onTableScroll = () => { - const container = vnode.dom; + const getScrollTop = () => Math.max(container.scrollTop, 0); + + /** + * Update the viewport size and scroll position in the model + * @param {number} scrollTop - current scroll position + */ + const updateViewport = (scrollTop = getScrollTop()) => { const { height } = container.getBoundingClientRect(); - const scrollTop = Math.max(container.scrollTop, 0); // cancel negative position due to Safari bounce scrolling - if (container.scrollTop < model.log.scrollTop) { - model.log.setAutoScrollLive(false); // stop auto-scrolling if user scrolls up - } model.log.setScrollTop(scrollTop, height); }; - // call the function when scrolling is updated - vnode.dom.addEventListener('scroll', onTableScroll); - model.log.dom.table = vnode.dom; - // setup window size listener - view needs redraw for smart scrolling - window.addEventListener('resize', onTableScroll); + /** + * Whether the viewport is at the bottom of the table, <=1 tolerates fractional heights + * @param {number} scrollTop - current scroll position + * @returns {boolean} true if at the bottom + */ + const isAtBottom = (scrollTop) => container.scrollHeight - scrollTop - container.clientHeight <= 1; + + /** + * Disable auto-scroll when the user performs an action that creates an up scroll event, then update the viewport. + * Needed as well as onTableWheel as onTableWheel doesn't cover all cases of user-initiated scrolls. + * However unlike wheel listeners, scroll events are not processed immediately. + * The user can scroll up but the autoScroll manager can get executed before the scroll listener + * and will put the viewport back at the bottom before the scroll listener has a chance to react. + */ + const onTableScroll = () => { + const scrollTop = getScrollTop(); + if (scrollTop < model.log.scrollTop && !isAtBottom(scrollTop)) { + model.log.setAutoScrollLive(false, false); + } + updateViewport(scrollTop); + }; - // remember this function for later (destroy) - vnode.dom.onTableScroll = onTableScroll; + /** + * Disable auto-scroll as soon as the user wheels up. + * onTableScroll can miss them if an incoming log pushes the scroll to the bottom before the check runs. + * @param {WheelEvent} e - the wheel event + */ + const onTableWheel = (e) => { + if (e.deltaY < 0 && !e.ctrlKey && !e.metaKey && getScrollTop() > 0) { + model.log.setAutoScrollLive(false, false); + } + }; + + const listeners = new AbortController(); + const { signal } = listeners; + + container.addEventListener('scroll', onTableScroll, { signal }); + container.addEventListener('wheel', onTableWheel, { signal, passive: true }); + // setup window size listener - view needs redraw for smart scrolling + window.addEventListener('resize', () => updateViewport(), { signal }); + container.listeners = listeners; + model.log.dom.table = container; // call the function once on next frame when we know sizes - onTableScroll(); + updateViewport(); }, /** @@ -242,8 +279,7 @@ const tableContainerHooks = (model) => ({ * @param {vnode} vnode - the vnode of the element */ ondestroy(vnode) { - vnode.dom.removeEventListener('scroll', vnode.dom.onTableScroll); - window.removeEventListener('resize', vnode.dom.onTableScroll); + vnode.dom.listeners.abort(); }, }); diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index b8e50c737..b7bbc0b40 100644 --- a/InfoLogger/test/mocha-index.js +++ b/InfoLogger/test/mocha-index.js @@ -115,6 +115,7 @@ describe('InfoLogger', function () { require('./public/status-bar-mocha'); require('./public/zoom.mocha'); require('./public/log-context-menu-mocha'); + require('./public/auto-scroll-mocha'); after(async () => { await browser.close(); diff --git a/InfoLogger/test/public/auto-scroll-mocha.js b/InfoLogger/test/public/auto-scroll-mocha.js new file mode 100644 index 000000000..6916159c2 --- /dev/null +++ b/InfoLogger/test/public/auto-scroll-mocha.js @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const assert = require('assert'); +const test = require('../mocha-index'); +const { injectLogs } = require('../utils/utils'); + +/** + * Inject logs and wait for the table to be scrolled down. + * Useful for live mode, as inserting is much quicker than waiting for the table to fill at 100ms. + * @param {Page} page - puppeteer page + */ +const injectAndWaitForScrolledDown = async (page) => { + await injectLogs(page, Array.from({ length: 200 }, (_, i) => ({ + severity: 'I', + timestamp: Date.now() + i, + }))); + await waitForScrolledDown(page); +}; + +/** + * Waits for the scroll handler to sync it's at the bottom, + * otherwise the next scroll gets compared against a stale position. + * @param {Page} page - puppeteer page + */ +const waitForScrolledDown = async (page) => { + await page.waitForFunction(() => { + const { scrollTop: realScrollTop } = model.log.dom.table; + return realScrollTop > 0 && model.log.scrollTop === realScrollTop; + }, { timeout: 5000 }); +}; + +/** + * Scrolls the logs table without a wheel event, as a scrollbar drag or keyboard scroll would + * @param {Page} page - puppeteer page + * @param {number} deltaY - scroll delta + * @returns {Promise} resolves once the scroll event has been handled + */ +const scrollOnTable = (page, deltaY) => + page.evaluate((deltaY) => new Promise((resolve) => { + const { table } = model.log.dom; + const previousScrollTop = table.scrollTop; + + table.addEventListener('scroll', () => resolve(), { once: true }); + + table.scrollTop += deltaY; + // no scroll event fires when already at the top/bottom so resolve to avoid hanging + if (table.scrollTop === previousScrollTop) { + resolve(); + } + }), deltaY); + +/** + * Dispatches a wheel event on the logs table + * @param {Page} page - puppeteer page + * @param {number} deltaY - wheel delta + * @returns {Promise} resolves once the wheel event has been handled + */ +const wheelOnTable = (page, deltaY) => + page.evaluate((deltaY) => { + model.log.dom.table.dispatchEvent(new WheelEvent('wheel', { deltaY })); + }, deltaY); + +/** + * Asserts that autoScrollLive is the expected value. + * @param {Page} page - puppeteer page + * @param {boolean} expected - expected value of autoScrollLive + */ +const assertAutoScrollLive = async (page, expected) => { + assert.strictEqual(await page.evaluate(() => model.log.autoScrollLive), expected); +}; + +describe('Auto-scroll test-suite', async () => { + let page = null; + let baseUrl = null; + + before(async () => { + ({ helpers: { baseUrl }, page } = test); + await page.goto(baseUrl, { waitUntil: 'networkidle0' }); + }); + + describe('in live mode', async () => { + beforeEach(async () => { + await page.waitForSelector('#live-button:not([disabled])'); + await page.evaluate(() => model.log.liveStop()); + await page.click('#live-button'); + }); + + after(async () => { + await page.evaluate(() => model.log.liveStop()); + await page.evaluate(() => model.zoom.resetZoom()); + }); + + it('should jump to the bottom when autoscroll is re-enabled from the status bar', async () => { + await injectAndWaitForScrolledDown(page); + + await scrollOnTable(page, -500); + await assertAutoScrollLive(page, false); + + await page.click('#status-bar-auto-scroll'); + + await assertAutoScrollLive(page, true); + await page.waitForFunction(() => { + const { scrollHeight, scrollTop, clientHeight } = model.log.dom.table; + return scrollHeight - scrollTop - clientHeight <= 1; + }, { timeout: 5000 }); + }); + + it('should not disable autoscroll when switching from a full query table to live mode', async () => { + await page.evaluate(() => model.log.liveStop()); + + await injectLogs(page, Array.from({ length: 200 }, (_, i) => ({ + severity: 'I', + message: `info log ${i}`, + timestamp: Date.now() + i, + }))); + await page.evaluate(() => model.log.goToLastItem()); + await waitForScrolledDown(page); + await page.click('#live-button'); + + await assertAutoScrollLive(page, true); + }); + + it('should not disable autoscroll when zooming in and out', async () => { + await page.evaluate(() => { + model.log.autoScrollLive = true; + }); + await injectAndWaitForScrolledDown(page); + await page.click('#zoom-in-button'); + await assertAutoScrollLive(page, true); + + await page.click('#zoom-out-button'); + await assertAutoScrollLive(page, true); + }); + + const scrollActions = [ + { name: 'wheel', action: (deltaY) => wheelOnTable(page, deltaY) }, + { name: 'scroll', action: (deltaY) => scrollOnTable(page, deltaY) }, + ]; + + scrollActions.forEach((movement) => { + describe(`when performing via: ${movement.name}`, async () => { + beforeEach(async () => { + await page.evaluate(() => model.log.liveStop()); + await page.click('#live-button'); + }); + + it('should disable autoscroll when the user moves up', async () => { + await injectAndWaitForScrolledDown(page); + + await movement.action(-100); + + await assertAutoScrollLive(page, false); + }); + + it('should not disable autoscroll when the user moves up with the table already at the top', async () => { + await movement.action(-100); + + await assertAutoScrollLive(page, true); + }); + + it('should not disable autoscroll when the user moves down', async () => { + await injectAndWaitForScrolledDown(page); + + await movement.action(100); + + await assertAutoScrollLive(page, true); + }); + }); + }); + }); +}); diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index 271786f8e..7664dce16 100644 --- a/InfoLogger/test/public/live-mode-mocha.js +++ b/InfoLogger/test/public/live-mode-mocha.js @@ -24,6 +24,10 @@ describe('Live Mode test-suite', async () => { ({ helpers: { baseUrl }, page } = test); }); + after(async () => { + await page.evaluate(() => model.log.liveStop('Query')); + }); + it('should successfully go to homepage with predefined filters', async () => { await page.goto(baseUrl, { waitUntil: 'networkidle0' }); const location = await page.evaluate(() => window.location); diff --git a/InfoLogger/test/public/log-context-menu-mocha.js b/InfoLogger/test/public/log-context-menu-mocha.js index ed2ba06e6..d194b37d3 100644 --- a/InfoLogger/test/public/log-context-menu-mocha.js +++ b/InfoLogger/test/public/log-context-menu-mocha.js @@ -91,6 +91,10 @@ describe('Cell Context Menu', async () => { }); }); + after(async () => { + await page.evaluate(() => model.log.contextMenu.hide()); + }); + describe('Menu visibility', async () => { it('should show context menu on right-click', async () => { await page.evaluate(() => {