From a92a978515e273b1dca455a3e713de0de6898a19 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:31:40 +0200 Subject: [PATCH 01/24] Derive UI state from model Button style, live icon, browser tab title and auto-scroll were set inside the click handler via module-level state, so any mode change not triggered by a click left the UI stale. Derive them from log.activeMode in the model/view instead. --- InfoLogger/public/Model.js | 8 +- InfoLogger/public/common/utils.js | 2 +- InfoLogger/public/index.js | 1 - InfoLogger/public/log/Log.js | 45 ++++++--- InfoLogger/public/log/commandLogs.js | 105 ++++++--------------- InfoLogger/public/log/tableLogsContent.js | 2 +- InfoLogger/public/view.js | 59 +++++++++--- InfoLogger/test/public/query-mode-mocha.js | 25 +++-- InfoLogger/test/public/status-bar-mocha.js | 2 + 9 files changed, 129 insertions(+), 120 deletions(-) diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index fbd4d5cb32..41e193f80c 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -17,7 +17,7 @@ import { Observable, WebSocketClient, QueryRouter, Loader, RemoteData, sessionService, Notification, } from '/js/src/index.js'; -import { callRateLimiter, setBrowserTabTitle } from './common/utils.js'; +import { callRateLimiter } from './common/utils.js'; import { ConfigurationService } from './services/ConfigurationService.js'; import { MODE } from './constants/mode.const.js'; import Log from './log/Log.js'; @@ -115,12 +115,6 @@ export default class Model extends Observable { this.frameworkInfo = RemoteData.failure(result.message); } else { this.frameworkInfo = RemoteData.success(result); - if (result['infoLogger-gui'].name && result['infoLogger-gui'].name.trim()) { - window.ILG = { - name: `ILG - ${result['infoLogger-gui'].name}`, - }; - setBrowserTabTitle(window.ILG.name); - } } this.notify(); return; diff --git a/InfoLogger/public/common/utils.js b/InfoLogger/public/common/utils.js index cca66aa25c..09f73523d3 100644 --- a/InfoLogger/public/common/utils.js +++ b/InfoLogger/public/common/utils.js @@ -56,7 +56,7 @@ export function callRateLimiter(fn, time) { * @returns {void} */ export function setBrowserTabTitle(title = undefined) { - if (document && title) { + if (document && title && document.title !== title) { document.title = title; } } diff --git a/InfoLogger/public/index.js b/InfoLogger/public/index.js index 7b689a2b81..ef655c6ff4 100644 --- a/InfoLogger/public/index.js +++ b/InfoLogger/public/index.js @@ -30,4 +30,3 @@ mount(document.body, view, model, debug); // Expose model to interact with it the browser's console window.model = model; -window.ILG = { name: 'ILG - ' }; diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index 91aae4be4a..03608b52c1 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -87,6 +87,22 @@ export default class Log extends Observable { return this.activeMode === MODE.QUERY; } + /** + * Toggles the live mode between running and paused + */ + toggleLiveMode() { + this.download.isVisible = false; + if (this.isLiveModeRunning()) { + this.liveStop(MODE.LIVE.PAUSED); + } else { + try { + this.liveStart(); + } catch (error) { + this.model.notification.show(error.toString(), 'danger', 3000); + } + } + } + /** * Toggle a dropdown with the full SQL query */ @@ -338,7 +354,7 @@ export default class Log extends Observable { * @returns {Promise} null if query is aborted, result of the query otherwise */ async query() { - if (!this.model.frameworkInfo.isSuccess() || !this.model.frameworkInfo.payload.mysql.status.ok) { + if (!this.isQueryModeAvailable()) { throw new Error('Query service is not available'); } @@ -354,6 +370,7 @@ export default class Log extends Observable { } else { this.activeMode = MODE.QUERY; } + this.download.isVisible = false; const previousQueryResult = this.queryResult; this.queryResult = RemoteData.loading(); @@ -463,10 +480,7 @@ export default class Log extends Observable { if (this.queryResult.isLoading()) { throw new Error('Query is loading, wait before starting live'); } - if (!this.model.ws.authed) { - throw new Error('WS is not yet ready'); - } - if (!this.model.frameworkInfo.isSuccess() || !this.model.frameworkInfo.payload.infoLoggerServer.status.ok) { + if (!this.isLiveModeAvailable()) { throw new Error('Live service is not available'); } if (this.isLiveModeRunning()) { @@ -486,6 +500,7 @@ export default class Log extends Observable { this.model.ws.setFilter(this.model.log.filter.toStringifyFunction()); + this.autoScrollLive = true; this.notify(); } @@ -500,6 +515,7 @@ export default class Log extends Observable { this.activeMode = mode; clearInterval(this.liveInterval); this.model.ws.setFilter(() => false); + this.autoScrollLive = false; this.notify(); } @@ -568,19 +584,22 @@ export default class Log extends Observable { } /** - * Enables auto-scroll, this is used when entering Live mode + * Returns whether the live mode service is available + * @returns {boolean} true if the live mode service is available, false otherwise */ - enableAutoScroll() { - this.autoScrollLive = true; - this.notify(); + isLiveModeAvailable() { + return Boolean(this.model.ws?.authed + && this.model.frameworkInfo.isSuccess() + && this.model.frameworkInfo.payload.infoLoggerServer?.status?.ok); } /** - * Disable auto-scroll, this is used when leaving Live mode + * Returns whether the query mode service is available + * @returns {boolean} true if the query mode service is available, false otherwise */ - disableAutoScroll() { - this.autoScrollLive = false; - this.notify(); + isQueryModeAvailable() { + return Boolean(this.model.frameworkInfo.isSuccess() + && this.model.frameworkInfo.payload.mysql?.status?.ok); } /** diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 06ca8932f7..1b10e0d54d 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -23,11 +23,16 @@ import { h, } from '/js/src/index.js'; import { BUTTON } from '../constants/button-states.const.js'; import { MODE } from '../constants/mode.const.js'; -import { setBrowserTabTitle } from '../common/utils.js'; -let queryButtonType = BUTTON.PRIMARY; -let liveButtonType = BUTTON.DEFAULT; -let liveButtonIcon = iconMediaPlay(); +/** + * Maps active modes to a live button style + * @type {Record vnode}>} + */ +const LIVE_BUTTON_TYPES_BY_MODE = { + [MODE.QUERY]: { className: BUTTON.DEFAULT, icon: iconMediaPlay }, + [MODE.LIVE.RUNNING]: { className: BUTTON.SUCCESS_ACTIVE, icon: iconMediaStop }, + [MODE.LIVE.PAUSED]: { className: BUTTON.PRIMARY, icon: iconMediaPlay }, +}; /** * Component for the command buttons (Query, Live, Clear, navigation between errors and download) @@ -81,10 +86,10 @@ const interactionModesGroupButton = (model) => { NotAsked: () => h('button.btn', { disabled: true }, ''), Loading: () => h('button.btn', { disabled: true, className: 'loading' }, 'Loading'), Failure: () => null, - Success: (frameworkInfo) => + Success: () => h('.btn-group', [ - queryButton(model, frameworkInfo), - liveButton(model, frameworkInfo), + queryButton(model.log), + liveButton(model.log), ]), }); }; @@ -94,14 +99,11 @@ const interactionModesGroupButton = (model) => { * - services lookup * - services result * - query lookup - * @param {Model} model - root model of the application - * @param {RemoteData.payload} frameworkInfo - the payload containing framework information + * @param {Log} logModel - log model of the application * @returns {vnode} - the view of the query button */ -const queryButton = (model, frameworkInfo) => { - const { log: logModel } = model; +const queryButton = (logModel) => { const { queryResult } = logModel; - const { mysql: { status: { ok: isDbReady = false } = {} } = {} } = frameworkInfo; if (queryResult.isLoading()) { return h('button.btn.bold', { @@ -112,12 +114,14 @@ const queryButton = (model, frameworkInfo) => { }, 'Cancel'); } + const isQueryModeAvailable = logModel.isQueryModeAvailable(); + return h('button.btn.bold', { id: 'query-button', - title: isDbReady ? 'Query database with filters (Enter)' : 'Query service not configured', - disabled: !isDbReady || queryResult.isLoading(), - className: queryButtonType, - onclick: () => toggleButtonStates(model, false), + title: isQueryModeAvailable ? 'Query database with filters (Enter)' : 'Query service not configured', + disabled: !isQueryModeAvailable, + className: logModel.isActiveModeQuery() ? BUTTON.PRIMARY : BUTTON.DEFAULT, + onclick: () => logModel.query(), }, 'Query'); }; @@ -126,25 +130,23 @@ const queryButton = (model, frameworkInfo) => { * - services lookup * - services result * - websocket status - * @param {Model} model - root model of the application - * @param {RemoteData.payload} frameworkInfo - the payload containing framework information + * @param {Log} logModel - log model of the application * @returns {vnode} - the view of the live button */ -const liveButton = (model, frameworkInfo) => { - const { log: logModel, ws } = model; - const { queryResult } = logModel; - const { authed: isWsAuthedAndReady = false } = ws; - const { infoLoggerServer: { status: { ok: isLiveServiceReady = false } = {} } = {} } = frameworkInfo; +const liveButton = (logModel) => { + const { queryResult, activeMode } = logModel; + const { className, icon } = LIVE_BUTTON_TYPES_BY_MODE[activeMode]; - const isLiveModeReady = isLiveServiceReady && isWsAuthedAndReady; - const title = isLiveModeReady ? 'Stream logs with filtering' : 'Live service not configured'; + const isLiveModeAvailable = logModel.isLiveModeAvailable(); + const title = isLiveModeAvailable ? 'Stream logs with filtering' : 'Live service not configured'; return h('button.btn.bold', { + id: 'live-button', title, - disabled: !isLiveModeReady || queryResult.isLoading(), - className: !isLiveModeReady ? 'loading' : liveButtonType, - onclick: () => toggleButtonStates(model, true), - }, 'Live', ' ', liveButtonIcon); + disabled: !isLiveModeAvailable || queryResult.isLoading(), + className: !isLiveModeAvailable ? 'loading' : className, + onclick: () => logModel.toggleLiveMode(), + }, 'Live', ' ', icon()); }; /** @@ -241,48 +243,3 @@ const zoomButtonGroup = (zoom) => title: 'Zoom in (Ctrl/Cmd + +)', }, h('span', { style: 'font-size:0.8em' }, iconPlus())), ]); - -/** - * Method to toggle states of the buttons(Query/Live) depending on the mode the tool is running on - * @param {Model} model - root model of the application - * @param {boolean} wasLivePressed - flag to check if the live button was pressed - */ -function toggleButtonStates(model, wasLivePressed) { - model.log.download.isVisible = false; // set visibility of download dropdown to false - if (wasLivePressed) { - switch (model.log.activeMode) { - case MODE.QUERY: - case MODE.LIVE.PAUSED: - try { - model.log.liveStart(); - setButtonsType(BUTTON.DEFAULT, BUTTON.SUCCESS_ACTIVE, iconMediaStop()); - model.log.enableAutoScroll(); - setBrowserTabTitle(`${window.ILG.name} LIVE`); - } catch (error) { - model.notification.show(error.toString(), 'danger', 3000); - } - break; - default: // MODE.LIVE.RUNNING - model.log.liveStop(MODE.LIVE.PAUSED); - setBrowserTabTitle(`${window.ILG.name} LIVE PAUSED`); - setButtonsType(BUTTON.DEFAULT, BUTTON.PRIMARY, iconMediaPlay()); - model.log.disableAutoScroll(); - } - } else { - model.log.query(); - setBrowserTabTitle(`${window.ILG.name} QUERY`); - setButtonsType(BUTTON.PRIMARY, BUTTON.DEFAULT, iconMediaPlay()); - } - - /** - * Method to change types of the buttons based on the mode being run - * @param {string} queryType Type of the Query Button - * @param {string} liveType Type of the Live Button - * @param {Icon} liveIcon Icon of the Live Button - */ - function setButtonsType(queryType, liveType, liveIcon) { - queryButtonType = queryType; - liveButtonType = liveType; - liveButtonIcon = liveIcon; - } -} diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index e0a207a48d..2250078cad 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -211,7 +211,7 @@ const tableContainerHooks = (model) => ({ 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.disableAutoScroll(); // stop auto-scrolling if user scroll sup + model.log.autoScrollLive = false; // stop auto-scrolling if user scrolls up } model.log.setScrollTop(scrollTop, height); }; diff --git a/InfoLogger/public/view.js b/InfoLogger/public/view.js index 33cf834263..862f75c129 100644 --- a/InfoLogger/public/view.js +++ b/InfoLogger/public/view.js @@ -25,30 +25,59 @@ import tableLogsScrollMap from './log/tableLogsScrollMap.js'; import aboutComponent from './about/about.component.js'; import errorComponent from './common/errorComponent.js'; import { cellContextMenu } from './log/cellContextMenu.js'; +import { setBrowserTabTitle } from './common/utils.js'; +import { MODE } from './constants/mode.const.js'; + +const BROWSER_TAB_TITLE_BY_MODE = { + [MODE.QUERY]: 'QUERY', + [MODE.LIVE.RUNNING]: 'LIVE', + [MODE.LIVE.PAUSED]: 'LIVE PAUSED', +}; + +/** + * Given a frameworkInfo and the active mode, it will update the browser tab title accordingly + * @param {RemoteData} frameworkInfo - the framework information + * @param {string} activeMode - the current mode of the application + * @returns {void} + */ +function updateBrowserTabTitle(frameworkInfo, activeMode) { + const instanceName = frameworkInfo.isSuccess() + ? (frameworkInfo.payload['infoLogger-gui'].name || '').trim() + : ''; + const prefix = instanceName ? `ILG - ${instanceName}` : 'ILG'; + setBrowserTabTitle(`${prefix} ${BROWSER_TAB_TITLE_BY_MODE[activeMode]}`); +} /** * Main view of the application * @param {Model} model - root model of the application - * @returns {vnode} - the view of the application + * @returns {vnode[]} - the view of the application */ export default (model) => [ notification(model.notification), cellContextMenu(model), - h('.flex-column absolute-fill', [ - h('.shadow-level2', [ - h('header.p1.flex-row.f7.g1.justify-between', [ - h('.flex-row.g3', commandLogs(model)), - h('.flex-row.g3', commandFilters(model)), + h( + '.flex-column absolute-fill', + { + oncreate: () => updateBrowserTabTitle(model.frameworkInfo, model.log.activeMode), + onupdate: () => updateBrowserTabTitle(model.frameworkInfo, model.log.activeMode), + }, + [ + h('.shadow-level2', [ + h('header.p1.flex-row.f7.g1.justify-between', [ + h('.flex-row.g3', commandLogs(model)), + h('.flex-row.g3', commandFilters(model)), + ]), + h('header.f7', tableFilters(model)), + ]), + h('.flex-grow.flex-row.shadow-level0.logs-container', [ + aboutComponent(model), + logsTable(model), + inspectorSide(model), ]), - h('header.f7', tableFilters(model)), - ]), - h('.flex-grow.flex-row.shadow-level0.logs-container', [ - aboutComponent(model), - logsTable(model), - inspectorSide(model), - ]), - h('footer.f7.ph1', [statusBar(model)]), - ]), + h('footer.f7.ph1', [statusBar(model)]), + ], + ), ]; /** diff --git a/InfoLogger/test/public/query-mode-mocha.js b/InfoLogger/test/public/query-mode-mocha.js index f15ea3b11d..0435a81937 100644 --- a/InfoLogger/test/public/query-mode-mocha.js +++ b/InfoLogger/test/public/query-mode-mocha.js @@ -51,8 +51,8 @@ const setupQueryTestState = (page) => window.confirm = () => true; window.model.frameworkInfo = { isSuccess: () => true, - payload: { mysql: { status: { ok: true } } }, - match: ({ Success }) => Success({ mysql: { status: { ok: true } } }), + payload: { mysql: { status: { ok: true } }, 'infoLogger-gui': { name: 'LOCAL-TEST' } }, + match: ({ Success }) => Success({ mysql: { status: { ok: true } }, 'infoLogger-gui': { name: 'LOCAL-TEST' } }), }; window.model.log.filter.resetCriteria(); window.model.log.empty(); @@ -95,14 +95,14 @@ const runQueryWithMocks = (page, { confirmReturn, textFilterOperator }) => postCalls += 1; return { ok: true, status: 200, - json: async () => [] }; + json: async () => ({ rows: [], count: 0 }) }; }; // Mock the frameworkInfo to make the query method think the query service is available in its check window.model.frameworkInfo = { isSuccess: () => true, - payload: { mysql: { status: { ok: true } } }, - match: ({ Success }) => Success({ mysql: { status: { ok: true } } }), + payload: { mysql: { status: { ok: true } }, 'infoLogger-gui': { name: 'LOCAL-TEST' } }, + match: ({ Success }) => Success({ mysql: { status: { ok: true } }, 'infoLogger-gui': { name: 'LOCAL-TEST' } }), }; // Default state of filters includes no text filters @@ -125,10 +125,9 @@ const runQueryWithMocks = (page, { confirmReturn, textFilterOperator }) => }); describe('Query Mode test-suite', async () => { - let page; - + let page = null; before(async () => { - page = test.page; + ({ page } = test); }); it('should fail because it is not configured', async () => { @@ -140,6 +139,16 @@ describe('Query Mode test-suite', async () => { } }); + it('should show correct browser tab title', async () => { + await page.evaluate(() => { + document.title = 'stale'; + }); + + await runQueryWithMocks(page, { confirmReturn: true }); + + await page.waitForFunction(() => document.title === 'ILG - LOCAL-TEST QUERY', { timeout: 1000 }); + }); + it('should copy multiple rows in the correct format', async () => { await injectLogs(page, [ { severity: 'I', message: 'info log', timestamp: Date.now() }, diff --git a/InfoLogger/test/public/status-bar-mocha.js b/InfoLogger/test/public/status-bar-mocha.js index 6002f4685e..3c0d466097 100644 --- a/InfoLogger/test/public/status-bar-mocha.js +++ b/InfoLogger/test/public/status-bar-mocha.js @@ -79,6 +79,7 @@ describe('Status Bar test-suite', async () => { window.model.frameworkInfo = RemoteData.success({ mysql: { host: 'test-host.cern.ch', status: { ok: true } }, infoLoggerServer: { status: { ok: true } }, + 'infoLogger-gui': { name: 'LOCAL-TEST' }, }); window.model.log.activeMode = 'Running'; window.model.log.liveStartedAt = new Date(); @@ -94,6 +95,7 @@ describe('Status Bar test-suite', async () => { window.model.frameworkInfo = RemoteData.success({ mysql: { host: 'localhost', status: { ok: true } }, infoLoggerServer: { status: { ok: true } }, + 'infoLogger-gui': { name: 'LOCAL-TEST' }, }); window.model.log.activeMode = 'Query'; window.model.log.liveStartedAt = null; From 353a963b9998982fad85e81f763ff272bdc7a66f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:00:23 +0200 Subject: [PATCH 02/24] Notify on websocket authentication success --- InfoLogger/public/Model.js | 1 + 1 file changed, 1 insertion(+) diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index 41e193f80c..cda40dc3c9 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -94,6 +94,7 @@ export default class Model extends Observable { handleWSAuthed() { // Tell server not to stream by default this.ws.setFilter(() => false); + this.notify(); } /** From 1a61426567e300b6905e6aaea010646ee15988d5 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:00:23 +0200 Subject: [PATCH 03/24] Notify on websocket authentication success From 5712629a2dc4540710d5a5d04a24eb73b7adcc07 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:10:34 +0200 Subject: [PATCH 04/24] Only show autoscroll option whilst live mode is running Hides the autoscroll checkbox unless live mode is actively running. --- InfoLogger/public/log/statusBar.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/InfoLogger/public/log/statusBar.js b/InfoLogger/public/log/statusBar.js index 79e08f66f3..3d99f41983 100644 --- a/InfoLogger/public/log/statusBar.js +++ b/InfoLogger/public/log/statusBar.js @@ -90,18 +90,20 @@ const applicationMessage = (model) => model.log.list.length > model.log.applicat : null; /** - * Show some application preferences: auto-scroll and inspector checkboxes + * Show some application preferences: inspector checkbox, plus autoscroll whilst live mode is running * (could be evolve into a preference panel in the future if more options are added) * @param {Model} model - root model of the application * @returns {vnode} - the view of the application options */ const applicationOptions = (model) => [ - h('label.d-inline', { title: 'Scroll down in live mode on new log incoming' }, h('input', { - type: 'checkbox', - checked: model.log.autoScrollLive, - onchange: () => model.log.toggleAutoScroll(), - }), ' Autoscroll'), - h('span.mh1'), + model.log.isLiveModeRunning() && [ + h('label.d-inline', { title: 'Scroll down in live mode on new log incoming' }, h('input', { + type: 'checkbox', + checked: model.log.autoScrollLive, + onchange: () => model.log.toggleAutoScroll(), + }), ' Autoscroll'), + h('span.mh1'), + ], h('label.d-inline', { title: 'Show details of selected log' }, h('input', { type: 'checkbox', checked: model.inspectorEnabled, From 28b21bc642c84f19358a3c5828b32aac9d02bbbf Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:15:06 +0200 Subject: [PATCH 05/24] Adds tests to cover this hiding/showing in each mode --- InfoLogger/test/public/status-bar-mocha.js | 54 +++++++++++++++++++--- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/InfoLogger/test/public/status-bar-mocha.js b/InfoLogger/test/public/status-bar-mocha.js index 3c0d466097..4080ae3372 100644 --- a/InfoLogger/test/public/status-bar-mocha.js +++ b/InfoLogger/test/public/status-bar-mocha.js @@ -319,14 +319,56 @@ describe('Status Bar test-suite', async () => { }); describe('Application Options', () => { - it('should toggle autoscroll when its checkbox is clicked', async () => { - await page.click(AUTOSCROLL_SELECTOR); + describe('Autoscroll', () => { + describe('in query mode', () => { + it('should not display autoscroll option when in MODE.QUERY', async () => { + await page.evaluate(() => model.log.liveStop()); + await page.waitForFunction( + (selector) => !document.querySelector(selector), + { timeout: 2000 }, + AUTOSCROLL_SELECTOR, + ); + }); + }); - const checkedAfter = await page.$eval(AUTOSCROLL_SELECTOR, (el) => el.checked); - const autoScrollModelValue = await page.evaluate(() => window.model.log.autoScrollLive); + describe('in live mode', () => { + beforeEach(async () => { + await page.evaluate(() => model.log.liveStop()); + await page.waitForSelector('#live-button:not([disabled])'); + await page.click('#live-button'); + await page.waitForFunction(() => model.log.isLiveModeRunning()); + }); - assert.strictEqual(autoScrollModelValue, checkedAfter); - assert.strictEqual(checkedAfter, true); + after(async () => { + await page.evaluate(() => model.log.liveStop()); + }); + + it('should not display autoscroll option when in MODE.LIVE.PAUSED', async () => { + await page.click('#live-button'); + + await page.waitForFunction( + (selector) => !document.querySelector(selector), + { timeout: 2000 }, + AUTOSCROLL_SELECTOR, + ); + }); + + it('should display autoscroll option when in MODE.LIVE.RUNNING', async () => { + await page.waitForSelector(AUTOSCROLL_SELECTOR, { timeout: 2000 }); + }); + + it('should toggle autoscroll when its checkbox is clicked', async () => { + await page.waitForSelector(AUTOSCROLL_SELECTOR, { timeout: 2000 }); + const checkedBefore = await page.$eval(AUTOSCROLL_SELECTOR, (el) => el.checked); + await page.click(AUTOSCROLL_SELECTOR); + + const checkedAfter = await page.$eval(AUTOSCROLL_SELECTOR, (el) => el.checked); + const autoScrollModelValue = await page.evaluate(() => model.log.autoScrollLive); + + assert.strictEqual(checkedAfter, !checkedBefore); + assert.strictEqual(autoScrollModelValue, checkedAfter); + }); + }); }); it('should toggle inspector when its checkbox is clicked', async () => { From 8ba8790f8ab1dc904ae3c989b72ff7b35b94089e Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:00:26 +0200 Subject: [PATCH 06/24] Revamp autoscroll status control Replace the autoscroll checkbox with a clearer UI. Show a success label when enabled and a warning button when disabled, which when clicked re-enables it. --- InfoLogger/public/log/Log.js | 7 +++---- InfoLogger/public/log/statusBar.js | 26 +++++++++++++++++++------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index 03608b52c1..db9af7368c 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -575,11 +575,10 @@ export default class Log extends Observable { } /** - * Enable or disable auto-scroll for live mode, a checkbox is used to control it + * Enable auto-scroll for live mode and scroll to the bottom of the table */ - toggleAutoScroll() { - this.autoScrollLive = !this.autoScrollLive; - this.dom.table.focus(); + enableAutoScrollLiveAndNotify() { + this.autoScrollLive = true; this.notify(); } diff --git a/InfoLogger/public/log/statusBar.js b/InfoLogger/public/log/statusBar.js index 3d99f41983..bbcfa3d26b 100644 --- a/InfoLogger/public/log/statusBar.js +++ b/InfoLogger/public/log/statusBar.js @@ -12,11 +12,14 @@ * or submit itself to any jurisdiction. */ -import { h } from '/js/src/index.js'; +import { + h, + iconWarning, +} from '/js/src/index.js'; /** * Bottom bar, showing status of the log's list and its details, - * some application messages and some basic options like auto-scroll checkbox. + * some application messages and some basic options like auto-scroll label/button. * @param {Model} model - root model of the application * @returns {vnode} - the view of the bottom bar */ @@ -97,11 +100,20 @@ const applicationMessage = (model) => model.log.list.length > model.log.applicat */ const applicationOptions = (model) => [ model.log.isLiveModeRunning() && [ - h('label.d-inline', { title: 'Scroll down in live mode on new log incoming' }, h('input', { - type: 'checkbox', - checked: model.log.autoScrollLive, - onchange: () => model.log.toggleAutoScroll(), - }), ' Autoscroll'), + model.log.autoScrollLive + ? h('span.success', { id: 'status-bar-auto-scroll', title: 'Autoscroll is enabled' }, 'Autoscroll Enabled') + : h( + 'button.btn.btn-sm.btn-warning', + { + id: 'status-bar-auto-scroll', + title: 'Click here to re-enable autoscroll', + onclick: () => model.log.enableAutoScrollLiveAndNotify(), + }, + [ + iconWarning(), + h('span.d-inline', 'Autoscroll Disabled'), + ], + ), h('span.mh1'), ], h('label.d-inline', { title: 'Show details of selected log' }, h('input', { From a0f51637c85282f07286251654ffb3e1214d2e25 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:01:28 +0200 Subject: [PATCH 07/24] Reset last autoScroll log when autoScroll is disabled Delete the table's last scrolled log marker when autoscroll is off so re-enabling immediately jumps to the newest logs. --- InfoLogger/public/log/tableLogsContent.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 2250078cad..b72db7a201 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -270,6 +270,9 @@ const autoscrollManager = (model, vnode) => { return; } + // forget the last scrolled log so re-enabling autoscroll jumps to bottom straight away on a redraw + delete vnode.dom.dataset.lastLogId; + // Autoscroll to selected item if (model.log.item) { // Scroll only if we did not previously, save last try in DOM dataset From eb5053ae60237c0c6f3042221807ebdb3299993d Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:02:03 +0200 Subject: [PATCH 08/24] Update status-bar-mocha.js Update and add status bar tests to cover the new enabled/disabled states and re-enable behaviour. --- InfoLogger/test/public/status-bar-mocha.js | 25 ++++++++++++++-------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/InfoLogger/test/public/status-bar-mocha.js b/InfoLogger/test/public/status-bar-mocha.js index 4080ae3372..bbaf9ad4a0 100644 --- a/InfoLogger/test/public/status-bar-mocha.js +++ b/InfoLogger/test/public/status-bar-mocha.js @@ -35,7 +35,7 @@ async function getSeverityCounts(page) { } describe('Status Bar test-suite', async () => { - const AUTOSCROLL_SELECTOR = '#status-bar-application-options label[title*="Scroll down"] input'; + const AUTOSCROLL_SELECTOR = '#status-bar-auto-scroll'; const INSPECTOR_SELECTOR = '#status-bar-application-options label[title*="Show details"] input'; const SQL_QUERY = 'SELECT * FROM `messages` WHERE `timestamp`>=? AND `severity` IN (?) ' + 'ORDER BY `TIMESTAMP` LIMIT 100000'; @@ -357,16 +357,23 @@ describe('Status Bar test-suite', async () => { await page.waitForSelector(AUTOSCROLL_SELECTOR, { timeout: 2000 }); }); - it('should toggle autoscroll when its checkbox is clicked', async () => { - await page.waitForSelector(AUTOSCROLL_SELECTOR, { timeout: 2000 }); - const checkedBefore = await page.$eval(AUTOSCROLL_SELECTOR, (el) => el.checked); - await page.click(AUTOSCROLL_SELECTOR); + it('should show correct label when autoscroll is enabled', async () => { + await waitForTextInElement(page, AUTOSCROLL_SELECTOR, 'Autoscroll Enabled'); + }); - const checkedAfter = await page.$eval(AUTOSCROLL_SELECTOR, (el) => el.checked); - const autoScrollModelValue = await page.evaluate(() => model.log.autoScrollLive); + it('should show correct button when autoscroll is disabled', async () => { + await page.evaluate(() => model.log.setAutoScrollLive(false)); + await waitForTextInElement(page, AUTOSCROLL_SELECTOR, 'Autoscroll Disabled'); + }); + + it('should re-enable autoscroll when disabled autoscroll button is clicked', async () => { + await page.evaluate(() => model.log.setAutoScrollLive(false)); + await waitForTextInElement(page, AUTOSCROLL_SELECTOR, 'Autoscroll Disabled'); + + await page.click(AUTOSCROLL_SELECTOR); - assert.strictEqual(checkedAfter, !checkedBefore); - assert.strictEqual(autoScrollModelValue, checkedAfter); + await waitForTextInElement(page, AUTOSCROLL_SELECTOR, 'Autoscroll Enabled'); + assert.strictEqual(await page.evaluate(() => model.log.autoScrollLive), true); }); }); }); From 77b9aeeba78cbf65314ae7b8cafe46a0fcd89a13 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:37:05 +0200 Subject: [PATCH 09/24] Fix status bar autoscroll test --- InfoLogger/test/public/status-bar-mocha.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/InfoLogger/test/public/status-bar-mocha.js b/InfoLogger/test/public/status-bar-mocha.js index bbaf9ad4a0..5335c6638f 100644 --- a/InfoLogger/test/public/status-bar-mocha.js +++ b/InfoLogger/test/public/status-bar-mocha.js @@ -362,12 +362,16 @@ describe('Status Bar test-suite', async () => { }); it('should show correct button when autoscroll is disabled', async () => { - await page.evaluate(() => model.log.setAutoScrollLive(false)); + await page.evaluate(() => { + model.log.autoScrollLive = false; + }); await waitForTextInElement(page, AUTOSCROLL_SELECTOR, 'Autoscroll Disabled'); }); it('should re-enable autoscroll when disabled autoscroll button is clicked', async () => { - await page.evaluate(() => model.log.setAutoScrollLive(false)); + await page.evaluate(() => { + model.log.autoScrollLive = false; + }); await waitForTextInElement(page, AUTOSCROLL_SELECTOR, 'Autoscroll Disabled'); await page.click(AUTOSCROLL_SELECTOR); From 8051ec0113aeaf3ddb6241d848674ac9d155e43f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:24:25 +0200 Subject: [PATCH 10/24] Keep status bar height stable when autoscroll toggles Plain span is 1.5em. .btn-sm is 1.6em. Give both states the same div box sized to 1.5em. --- InfoLogger/public/app.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index b3decfe967..3519af1167 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -264,3 +264,10 @@ a.disabled { pointer-events: none; cursor: default; } flex-direction: column; gap: 0.25rem; } + +#status-bar-auto-scroll { + display: inline-block; + padding: .25em .7em; + line-height: 1; + margin: 0; +} From c91ae5091b3b73ed476f94de3196d5130e5e9ed0 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:59:29 +0200 Subject: [PATCH 11/24] Make auto-scroll status a toggle button of sorts Adds new setter for autoScrollLive. Shows a success/warning button in each state. Drops unneeded CSS. Rewrites test names and add one for disabling via the button. --- InfoLogger/public/app.css | 7 ---- InfoLogger/public/log/Log.js | 7 ++-- InfoLogger/public/log/statusBar.js | 41 ++++++++++++---------- InfoLogger/test/public/status-bar-mocha.js | 20 ++++++++--- 4 files changed, 43 insertions(+), 32 deletions(-) diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index 3519af1167..b3decfe967 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -264,10 +264,3 @@ a.disabled { pointer-events: none; cursor: default; } flex-direction: column; gap: 0.25rem; } - -#status-bar-auto-scroll { - display: inline-block; - padding: .25em .7em; - line-height: 1; - margin: 0; -} diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index db9af7368c..98f334a32e 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -575,10 +575,11 @@ export default class Log extends Observable { } /** - * Enable auto-scroll for live mode and scroll to the bottom of the table + * Set auto-scroll for live mode and notify the UI + * @param {boolean} enabled - whether auto-scroll should be enabled */ - enableAutoScrollLiveAndNotify() { - this.autoScrollLive = true; + setAutoScrollLive(enabled) { + this.autoScrollLive = enabled; this.notify(); } diff --git a/InfoLogger/public/log/statusBar.js b/InfoLogger/public/log/statusBar.js index bbcfa3d26b..d72286c29e 100644 --- a/InfoLogger/public/log/statusBar.js +++ b/InfoLogger/public/log/statusBar.js @@ -98,30 +98,35 @@ const applicationMessage = (model) => model.log.list.length > model.log.applicat * @param {Model} model - root model of the application * @returns {vnode} - the view of the application options */ -const applicationOptions = (model) => [ - model.log.isLiveModeRunning() && [ - model.log.autoScrollLive - ? h('span.success', { id: 'status-bar-auto-scroll', title: 'Autoscroll is enabled' }, 'Autoscroll Enabled') - : h( - 'button.btn.btn-sm.btn-warning', +const applicationOptions = (model) => { + const { log } = model; + const autoScrollEnabled = log.autoScrollLive; + return [ + log.isLiveModeRunning() && [ + h( + `button.btn.btn-sm${autoScrollEnabled ? '.btn-success' : '.btn-warning'}`, { id: 'status-bar-auto-scroll', - title: 'Click here to re-enable autoscroll', - onclick: () => model.log.enableAutoScrollLiveAndNotify(), + title: autoScrollEnabled ? 'Click here to disable autoscroll' : 'Click here to re-enable autoscroll', + onclick: (e) => { + e.currentTarget.blur(); + log.setAutoScrollLive(!autoScrollEnabled); + }, }, [ - iconWarning(), - h('span.d-inline', 'Autoscroll Disabled'), + !autoScrollEnabled && iconWarning(), + h('span.d-inline', autoScrollEnabled ? 'Autoscroll Enabled' : 'Autoscroll Disabled'), ], ), - h('span.mh1'), - ], - h('label.d-inline', { title: 'Show details of selected log' }, h('input', { - type: 'checkbox', - checked: model.inspectorEnabled, - onchange: () => model.toggleInspector(), - }), ' Inspector'), -]; + h('span.mh1'), + ], + h('label.d-inline', { title: 'Show details of selected log' }, h('input', { + type: 'checkbox', + checked: model.inspectorEnabled, + onchange: () => model.toggleInspector(), + }), ' Inspector'), + ]; +}; /** * Status for query mode, showing how many logs are really in DB and how much time it took to load it diff --git a/InfoLogger/test/public/status-bar-mocha.js b/InfoLogger/test/public/status-bar-mocha.js index 5335c6638f..bc11f67562 100644 --- a/InfoLogger/test/public/status-bar-mocha.js +++ b/InfoLogger/test/public/status-bar-mocha.js @@ -361,16 +361,28 @@ describe('Status Bar test-suite', async () => { await waitForTextInElement(page, AUTOSCROLL_SELECTOR, 'Autoscroll Enabled'); }); - it('should show correct button when autoscroll is disabled', async () => { + it('should show correct label when autoscroll is disabled', async () => { await page.evaluate(() => { - model.log.autoScrollLive = false; + model.log.setAutoScrollLive(false); }); await waitForTextInElement(page, AUTOSCROLL_SELECTOR, 'Autoscroll Disabled'); }); - it('should re-enable autoscroll when disabled autoscroll button is clicked', async () => { + it('should disable auto-scroll when clicked whilst auto-scroll is enabled', async () => { await page.evaluate(() => { - model.log.autoScrollLive = false; + model.log.setAutoScrollLive(true); + }); + await waitForTextInElement(page, AUTOSCROLL_SELECTOR, 'Autoscroll Enabled'); + + await page.click(AUTOSCROLL_SELECTOR); + + await waitForTextInElement(page, AUTOSCROLL_SELECTOR, 'Autoscroll Disabled'); + assert.strictEqual(await page.evaluate(() => model.log.autoScrollLive), false); + }); + + it('should enable auto-scroll when clicked whilst auto-scroll is disabled', async () => { + await page.evaluate(() => { + model.log.setAutoScrollLive(false); }); await waitForTextInElement(page, AUTOSCROLL_SELECTOR, 'Autoscroll Disabled'); From e9f1154168ae9835d85c0d7abe1f28329dd725fb Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:03:34 +0200 Subject: [PATCH 12/24] Use new setAutoScrollLive method --- InfoLogger/public/log/tableLogsContent.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index b72db7a201..13c8229402 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -211,7 +211,7 @@ const tableContainerHooks = (model) => ({ 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.autoScrollLive = false; // stop auto-scrolling if user scrolls up + model.log.setAutoScrollLive(false); // stop auto-scrolling if user scrolls up } model.log.setScrollTop(scrollTop, height); }; From 0d6712cdf9b6523bb023764392142602415fa17c Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:35:09 +0200 Subject: [PATCH 13/24] Keep autoscroll enabled when the logs table shrinks Clearing logs or switching from a full query table to an empty live mode table shrinks the table. The scroll handler treated any decrease in `scrollTop` as the user scrolling up and disabled autoscroll. A decrease now only counts if the view is no longer at the bottom. --- InfoLogger/public/log/Log.js | 22 +++++++-- InfoLogger/public/log/tableLogsContent.js | 59 +++++++++++++++-------- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index 98f334a32e..a92b2a2242 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -547,15 +547,20 @@ 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, close the inspector panel and re-enable auto-scroll in live mode */ empty() { this.list = []; + this.item = null; + this.autoScrollToItem = false; this.limitReached = null; this.model.inspectorEnabled = false; this.resetStats(); this.queryResult = RemoteData.notAsked(); + if (this.isLiveModeRunning()) { + this.setAutoScrollLive(true, false); + } this.notify(); } @@ -575,12 +580,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/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 13c8229402..45600d7724 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -198,35 +198,57 @@ 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; - // remember this function for later (destroy) - vnode.dom.onTableScroll = onTableScroll; + /** + * Disable auto-scroll when the user scrolls up, then update the viewport. + * A shrinking table clamps scrollTop down but stays at the bottom, so it doesn't count. + */ + const onTableScroll = () => { + const scrollTop = getScrollTop(); + if (scrollTop < model.log.scrollTop && !isAtBottom(scrollTop)) { + model.log.setAutoScrollLive(false, false); + } + updateViewport(scrollTop); + }; + + const listeners = new AbortController(); + const { signal } = listeners; + + container.addEventListener('scroll', onTableScroll, { signal }); + // 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 +264,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(); }, }); From 70f8520ccba987a791e2ad3edc4ede1f3bf4800f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:35:19 +0200 Subject: [PATCH 14/24] Add logs table autoscroll tests Adds tests covering scrolling up, switching from a full query table to an empty live mode table, clearing the logs and zooming in/out. --- InfoLogger/public/log/commandLogs.js | 2 +- InfoLogger/test/mocha-index.js | 1 + InfoLogger/test/public/logs-table-mocha.js | 172 +++++++++++++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 InfoLogger/test/public/logs-table-mocha.js diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 1b10e0d54d..840ecec00f 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, diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index b8e50c7373..2ee7465102 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/logs-table-mocha'); after(async () => { await browser.close(); diff --git a/InfoLogger/test/public/logs-table-mocha.js b/InfoLogger/test/public/logs-table-mocha.js new file mode 100644 index 0000000000..cdf21b7ac4 --- /dev/null +++ b/InfoLogger/test/public/logs-table-mocha.js @@ -0,0 +1,172 @@ +/** + * @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'); + +/** + * Waits for the logs table to be scrolled past the bottom. + * @param {Page} page - puppeteer page + */ +const waitForScrollPastBottom = async (page) => { + await page.waitForFunction(() => model.log.dom.table.scrollTop > 0, { timeout: 5000 }); +}; + +/** + * Fills the logs table with 200 logs and scrolls to the bottom to test autoscroll behavior. + * @param {Page} page - puppeteer page + */ +const fillTableAndScrollToBottom = async (page) => { + // ensure table has more rows than fit on the screen + 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 waitForScrollPastBottom(page); +}; + +/** + * 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); +}; + +/** + * Scrolls the logs table with a real mouse wheel event, so the resulting scroll event is handled as a user scroll + * @param {Page} page - puppeteer page + * @param {number} deltaY - wheel delta, negative scrolls up + */ +const wheelOverTable = async (page, deltaY) => { + const box = await (await page.$('.tableLogsContent')).boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel({ deltaY }); +}; + +/** + * Allows waiting for a specified number of animation frames + * Useful when dealing with scroll and layout changes that require dealing with what occurs in each animation frame. + * @param {Page} page - puppeteer page + * @param {*} frames - number of frames to wait for + * @returns {Promise} resolves after the specified number of animation frames + */ +const waitForAnimationFrame = (page, frames = 1) => + page.evaluate((frames) => new Promise((resolve) => { + const step = () => { + if (frames <= 0) { + resolve(); + } else { + frames--; + requestAnimationFrame(step); + } + }; + step(); + }), frames); + +describe('Logs Table test-suite', async () => { + let page = null; + let baseUrl = null; + + before(async () => { + ({ helpers: { baseUrl }, page } = test); + await page.goto(baseUrl, { waitUntil: 'networkidle0' }); + }); + + describe('Autoscroll behavior', async () => { + describe('in live mode', async () => { + beforeEach(async () => { + await page.waitForSelector('#live-button:not([disabled])'); + await page.evaluate(() => { + model.log.liveStop('Query'); + model.zoom.resetZoom(); + model.log.empty(); + }); + await page.waitForFunction(() => model.log.dom.table.scrollTop === 0); + await page.click('#live-button'); + }); + + after(async () => { + await page.evaluate(() => model.log.liveStop('Query')); + await page.evaluate(() => model.zoom.resetZoom()); + }); + + it('should disable autoscroll when the user scrolls up', async () => { + await assertAutoScrollLive(page, true); + + // wait until live logs overflow the table and autoscroll has moved it down + await waitForScrollPastBottom(page); + + await wheelOverTable(page, -100); + await waitForAnimationFrame(page, 2); + + await assertAutoScrollLive(page, false); + }); + + it('should not disable autoscroll when switching from a full query table to live mode', async () => { + await page.evaluate(() => model.log.liveStop('Query')); + await fillTableAndScrollToBottom(page); + await page.click('#live-button'); + + await waitForAnimationFrame(page, 2); + + await assertAutoScrollLive(page, true); + }); + + it('should not disable autoscroll when the log list is cleared', async () => { + await assertAutoScrollLive(page, true); + + // wait until live logs overflow the table and autoscroll has moved it down + await waitForScrollPastBottom(page); + + await page.click('#clear-button'); + await waitForAnimationFrame(page, 2); + + await assertAutoScrollLive(page, true); + }); + + it('should re-enable autoscroll when the log list is cleared', async () => { + await waitForScrollPastBottom(page); + + await wheelOverTable(page, -100); + await waitForAnimationFrame(page, 2); + await assertAutoScrollLive(page, false); + + await page.click('#clear-button'); + await waitForAnimationFrame(page, 2); + + await assertAutoScrollLive(page, true); + }); + + it('should not disable autoscroll when zooming in and out', async () => { + await assertAutoScrollLive(page, true); + await waitForScrollPastBottom(page); + + await page.click('#zoom-in-button'); + await waitForAnimationFrame(page, 2); + await assertAutoScrollLive(page, true); + + // zooming out shrinks the table, so the browser clamps scrollTop down to the new bottom + await page.click('#zoom-out-button'); + await waitForAnimationFrame(page, 2); + await assertAutoScrollLive(page, true); + }); + }); + }); +}); From 5c7f3b59e45e044a93c3b187c9798aac51c3fbf8 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:36:18 +0200 Subject: [PATCH 15/24] Isolate live mode and context menu test suites --- InfoLogger/test/public/live-mode-mocha.js | 4 ++++ InfoLogger/test/public/log-context-menu-mocha.js | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index 271786f8e8..7664dce165 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 ed2ba06e6b..d194b37d36 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(() => { From 2b940248d3019a625634a8f8054d0dd95dfdddd2 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:26:53 +0200 Subject: [PATCH 16/24] Refactor autoscroll logs table tests --- InfoLogger/test/public/logs-table-mocha.js | 31 +++++++--------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/InfoLogger/test/public/logs-table-mocha.js b/InfoLogger/test/public/logs-table-mocha.js index cdf21b7ac4..b44a24e2ac 100644 --- a/InfoLogger/test/public/logs-table-mocha.js +++ b/InfoLogger/test/public/logs-table-mocha.js @@ -24,22 +24,6 @@ const waitForScrollPastBottom = async (page) => { await page.waitForFunction(() => model.log.dom.table.scrollTop > 0, { timeout: 5000 }); }; -/** - * Fills the logs table with 200 logs and scrolls to the bottom to test autoscroll behavior. - * @param {Page} page - puppeteer page - */ -const fillTableAndScrollToBottom = async (page) => { - // ensure table has more rows than fit on the screen - 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 waitForScrollPastBottom(page); -}; - /** * Asserts that autoScrollLive is the expected value. * @param {Page} page - puppeteer page @@ -50,9 +34,9 @@ const assertAutoScrollLive = async (page, expected) => { }; /** - * Scrolls the logs table with a real mouse wheel event, so the resulting scroll event is handled as a user scroll + * Scrolls the logs table with a real mouse wheel event * @param {Page} page - puppeteer page - * @param {number} deltaY - wheel delta, negative scrolls up + * @param {number} deltaY - wheel delta */ const wheelOverTable = async (page, deltaY) => { const box = await (await page.$('.tableLogsContent')).boundingBox(); @@ -121,7 +105,14 @@ describe('Logs Table test-suite', async () => { it('should not disable autoscroll when switching from a full query table to live mode', async () => { await page.evaluate(() => model.log.liveStop('Query')); - await fillTableAndScrollToBottom(page); + 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 waitForScrollPastBottom(page); await page.click('#live-button'); await waitForAnimationFrame(page, 2); @@ -132,7 +123,6 @@ describe('Logs Table test-suite', async () => { it('should not disable autoscroll when the log list is cleared', async () => { await assertAutoScrollLive(page, true); - // wait until live logs overflow the table and autoscroll has moved it down await waitForScrollPastBottom(page); await page.click('#clear-button'); @@ -162,7 +152,6 @@ describe('Logs Table test-suite', async () => { await waitForAnimationFrame(page, 2); await assertAutoScrollLive(page, true); - // zooming out shrinks the table, so the browser clamps scrollTop down to the new bottom await page.click('#zoom-out-button'); await waitForAnimationFrame(page, 2); await assertAutoScrollLive(page, true); From cb485b5be03092b295e0feb009c8edfec721135a Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:13:12 +0200 Subject: [PATCH 17/24] Disable live auto-scroll on wheel up Add a wheel listener to the log table so upward scrolling immediately disables live auto-scroll. This was an issue in a CI/CD test where the scroll handler only checks scrollTop after the browser has actually scrolled. If a new log has made the browser jump back to the bottom before the check runs then the scroll is missed and autoScrollLive is kept enabled. --- InfoLogger/public/log/tableLogsContent.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 45600d7724..f5c571e7ba 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -229,6 +229,7 @@ const tableContainerHooks = (model) => ({ /** * Disable auto-scroll when the user scrolls up, then update the viewport. * A shrinking table clamps scrollTop down but stays at the bottom, so it doesn't count. + * Needed as well as onTableWheel as */ const onTableScroll = () => { const scrollTop = getScrollTop(); @@ -238,10 +239,22 @@ const tableContainerHooks = (model) => ({ updateViewport(scrollTop); }; + /** + * 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; From cf30accc2d5dfd1b82243b204db4082886830627 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:14:27 +0200 Subject: [PATCH 18/24] Fix Typo --- InfoLogger/public/log/tableLogsContent.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index f5c571e7ba..1552cdc70c 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -229,7 +229,7 @@ const tableContainerHooks = (model) => ({ /** * Disable auto-scroll when the user scrolls up, then update the viewport. * A shrinking table clamps scrollTop down but stays at the bottom, so it doesn't count. - * Needed as well as onTableWheel as + * Needed as well as onTableWheel as onTableWheel doesn't cover all cases of user-initiated scrolls. */ const onTableScroll = () => { const scrollTop = getScrollTop(); From 50ca48b544f5454a508e09d061214cca09861fdd Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:39:56 +0200 Subject: [PATCH 19/24] Speed up live mode logs tests --- InfoLogger/test/public/logs-table-mocha.js | 26 +++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/InfoLogger/test/public/logs-table-mocha.js b/InfoLogger/test/public/logs-table-mocha.js index b44a24e2ac..20d84b13fa 100644 --- a/InfoLogger/test/public/logs-table-mocha.js +++ b/InfoLogger/test/public/logs-table-mocha.js @@ -17,10 +17,16 @@ const test = require('../mocha-index'); const { injectLogs } = require('../utils/utils'); /** - * Waits for the logs table to be scrolled past the bottom. + * Inserts and checks that the logs table is scrolled past the bottom. + * For Live mode inserting is much quicker than waiting for the table to naturally populate. * @param {Page} page - puppeteer page */ -const waitForScrollPastBottom = async (page) => { +const insertAndWaitForScrollPastBottom = async (page) => { + await injectLogs(page, Array.from({ length: 200 }, (_, i) => ({ + severity: 'I', + message: `info log ${i}`, + timestamp: Date.now() + i, + }))); await page.waitForFunction(() => model.log.dom.table.scrollTop > 0, { timeout: 5000 }); }; @@ -94,8 +100,7 @@ describe('Logs Table test-suite', async () => { it('should disable autoscroll when the user scrolls up', async () => { await assertAutoScrollLive(page, true); - // wait until live logs overflow the table and autoscroll has moved it down - await waitForScrollPastBottom(page); + await insertAndWaitForScrollPastBottom(page); await wheelOverTable(page, -100); await waitForAnimationFrame(page, 2); @@ -105,14 +110,9 @@ describe('Logs Table test-suite', async () => { it('should not disable autoscroll when switching from a full query table to live mode', async () => { await page.evaluate(() => model.log.liveStop('Query')); - 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 waitForScrollPastBottom(page); + await insertAndWaitForScrollPastBottom(page); await page.click('#live-button'); await waitForAnimationFrame(page, 2); @@ -123,7 +123,7 @@ describe('Logs Table test-suite', async () => { it('should not disable autoscroll when the log list is cleared', async () => { await assertAutoScrollLive(page, true); - await waitForScrollPastBottom(page); + await insertAndWaitForScrollPastBottom(page); await page.click('#clear-button'); await waitForAnimationFrame(page, 2); @@ -132,7 +132,7 @@ describe('Logs Table test-suite', async () => { }); it('should re-enable autoscroll when the log list is cleared', async () => { - await waitForScrollPastBottom(page); + await insertAndWaitForScrollPastBottom(page); await wheelOverTable(page, -100); await waitForAnimationFrame(page, 2); @@ -146,7 +146,7 @@ describe('Logs Table test-suite', async () => { it('should not disable autoscroll when zooming in and out', async () => { await assertAutoScrollLive(page, true); - await waitForScrollPastBottom(page); + await insertAndWaitForScrollPastBottom(page); await page.click('#zoom-in-button'); await waitForAnimationFrame(page, 2); From 269787e63fc87a62d4af57451d00ffff6f397643 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:44:38 +0200 Subject: [PATCH 20/24] Fix failing CI test due to last commit Last commit changed one too many instances of the old waitForScrollPastBottom calls --- InfoLogger/test/public/logs-table-mocha.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/InfoLogger/test/public/logs-table-mocha.js b/InfoLogger/test/public/logs-table-mocha.js index 20d84b13fa..04236b9146 100644 --- a/InfoLogger/test/public/logs-table-mocha.js +++ b/InfoLogger/test/public/logs-table-mocha.js @@ -27,6 +27,14 @@ const insertAndWaitForScrollPastBottom = async (page) => { message: `info log ${i}`, timestamp: Date.now() + i, }))); + await waitForScrollPastBottom(page); +}; + +/** + * Waits for the logs table to be scrolled past the bottom. + * @param {Page} page - puppeteer page + */ +const waitForScrollPastBottom = async (page) => { await page.waitForFunction(() => model.log.dom.table.scrollTop > 0, { timeout: 5000 }); }; @@ -111,8 +119,13 @@ describe('Logs Table test-suite', async () => { it('should not disable autoscroll when switching from a full query table to live mode', async () => { await page.evaluate(() => model.log.liveStop('Query')); + 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 insertAndWaitForScrollPastBottom(page); + await waitForScrollPastBottom(page); await page.click('#live-button'); await waitForAnimationFrame(page, 2); From 0cc2a428ba47f9ff15b776c81fa43ad76ee69579 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:19:29 +0200 Subject: [PATCH 21/24] Improve auto-scroll regression tests Update tests to check both scrolling events and wheel events. Note the scroll race condition in the table auto-scroll logic where scroll listeners may lag behind the viewport update and thus not register and disable autoscroll. --- InfoLogger/public/log/tableLogsContent.js | 6 +- InfoLogger/test/mocha-index.js | 2 +- InfoLogger/test/public/auto-scroll-mocha.js | 193 ++++++++++++++++++++ InfoLogger/test/public/logs-table-mocha.js | 174 ------------------ 4 files changed, 198 insertions(+), 177 deletions(-) create mode 100644 InfoLogger/test/public/auto-scroll-mocha.js delete mode 100644 InfoLogger/test/public/logs-table-mocha.js diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 1552cdc70c..9655cbfffa 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -227,9 +227,11 @@ const tableContainerHooks = (model) => ({ const isAtBottom = (scrollTop) => container.scrollHeight - scrollTop - container.clientHeight <= 1; /** - * Disable auto-scroll when the user scrolls up, then update the viewport. - * A shrinking table clamps scrollTop down but stays at the bottom, so it doesn't count. + * 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(); diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index 2ee7465102..b7bbc0b40c 100644 --- a/InfoLogger/test/mocha-index.js +++ b/InfoLogger/test/mocha-index.js @@ -115,7 +115,7 @@ describe('InfoLogger', function () { require('./public/status-bar-mocha'); require('./public/zoom.mocha'); require('./public/log-context-menu-mocha'); - require('./public/logs-table-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 0000000000..d5580a7287 --- /dev/null +++ b/InfoLogger/test/public/auto-scroll-mocha.js @@ -0,0 +1,193 @@ +/** + * @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 re-enable autoscroll when the log list is cleared', async () => { + await injectAndWaitForScrolledDown(page); + await page.evaluate(() => { + model.log.autoScrollLive = false; + }); + await assertAutoScrollLive(page, false); + + await page.click('#clear-button'); + + await assertAutoScrollLive(page, true); + }); + + it('should not disable autoscroll when zooming in and out', async () => { + await assertAutoScrollLive(page, 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/logs-table-mocha.js b/InfoLogger/test/public/logs-table-mocha.js deleted file mode 100644 index 04236b9146..0000000000 --- a/InfoLogger/test/public/logs-table-mocha.js +++ /dev/null @@ -1,174 +0,0 @@ -/** - * @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'); - -/** - * Inserts and checks that the logs table is scrolled past the bottom. - * For Live mode inserting is much quicker than waiting for the table to naturally populate. - * @param {Page} page - puppeteer page - */ -const insertAndWaitForScrollPastBottom = async (page) => { - await injectLogs(page, Array.from({ length: 200 }, (_, i) => ({ - severity: 'I', - message: `info log ${i}`, - timestamp: Date.now() + i, - }))); - await waitForScrollPastBottom(page); -}; - -/** - * Waits for the logs table to be scrolled past the bottom. - * @param {Page} page - puppeteer page - */ -const waitForScrollPastBottom = async (page) => { - await page.waitForFunction(() => model.log.dom.table.scrollTop > 0, { timeout: 5000 }); -}; - -/** - * 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); -}; - -/** - * Scrolls the logs table with a real mouse wheel event - * @param {Page} page - puppeteer page - * @param {number} deltaY - wheel delta - */ -const wheelOverTable = async (page, deltaY) => { - const box = await (await page.$('.tableLogsContent')).boundingBox(); - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - await page.mouse.wheel({ deltaY }); -}; - -/** - * Allows waiting for a specified number of animation frames - * Useful when dealing with scroll and layout changes that require dealing with what occurs in each animation frame. - * @param {Page} page - puppeteer page - * @param {*} frames - number of frames to wait for - * @returns {Promise} resolves after the specified number of animation frames - */ -const waitForAnimationFrame = (page, frames = 1) => - page.evaluate((frames) => new Promise((resolve) => { - const step = () => { - if (frames <= 0) { - resolve(); - } else { - frames--; - requestAnimationFrame(step); - } - }; - step(); - }), frames); - -describe('Logs Table test-suite', async () => { - let page = null; - let baseUrl = null; - - before(async () => { - ({ helpers: { baseUrl }, page } = test); - await page.goto(baseUrl, { waitUntil: 'networkidle0' }); - }); - - describe('Autoscroll behavior', async () => { - describe('in live mode', async () => { - beforeEach(async () => { - await page.waitForSelector('#live-button:not([disabled])'); - await page.evaluate(() => { - model.log.liveStop('Query'); - model.zoom.resetZoom(); - model.log.empty(); - }); - await page.waitForFunction(() => model.log.dom.table.scrollTop === 0); - await page.click('#live-button'); - }); - - after(async () => { - await page.evaluate(() => model.log.liveStop('Query')); - await page.evaluate(() => model.zoom.resetZoom()); - }); - - it('should disable autoscroll when the user scrolls up', async () => { - await assertAutoScrollLive(page, true); - - await insertAndWaitForScrollPastBottom(page); - - await wheelOverTable(page, -100); - await waitForAnimationFrame(page, 2); - - await assertAutoScrollLive(page, false); - }); - - it('should not disable autoscroll when switching from a full query table to live mode', async () => { - await page.evaluate(() => model.log.liveStop('Query')); - - 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 waitForScrollPastBottom(page); - await page.click('#live-button'); - - await waitForAnimationFrame(page, 2); - - await assertAutoScrollLive(page, true); - }); - - it('should not disable autoscroll when the log list is cleared', async () => { - await assertAutoScrollLive(page, true); - - await insertAndWaitForScrollPastBottom(page); - - await page.click('#clear-button'); - await waitForAnimationFrame(page, 2); - - await assertAutoScrollLive(page, true); - }); - - it('should re-enable autoscroll when the log list is cleared', async () => { - await insertAndWaitForScrollPastBottom(page); - - await wheelOverTable(page, -100); - await waitForAnimationFrame(page, 2); - await assertAutoScrollLive(page, false); - - await page.click('#clear-button'); - await waitForAnimationFrame(page, 2); - - await assertAutoScrollLive(page, true); - }); - - it('should not disable autoscroll when zooming in and out', async () => { - await assertAutoScrollLive(page, true); - await insertAndWaitForScrollPastBottom(page); - - await page.click('#zoom-in-button'); - await waitForAnimationFrame(page, 2); - await assertAutoScrollLive(page, true); - - await page.click('#zoom-out-button'); - await waitForAnimationFrame(page, 2); - await assertAutoScrollLive(page, true); - }); - }); - }); -}); From ec2d3397fe54d2994c61f02f7cba9e97cd574ee4 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:09:37 +0200 Subject: [PATCH 22/24] Do not enable auto-scroll on clear On a second thought the clear button should not take any other action but to clear the table and any secondary actions that need to be taken because of that. --- InfoLogger/public/log/Log.js | 5 +---- InfoLogger/test/public/auto-scroll-mocha.js | 14 ++------------ 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index a92b2a2242..3ac07791df 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -548,7 +548,7 @@ export default class Log extends Observable { /** * Empty the list of all logs, reset stats, clear query mode request if any, - * deselect the current log, close the inspector panel and re-enable auto-scroll in live mode + * deselect the current log and close the inspector panel */ empty() { this.list = []; @@ -558,9 +558,6 @@ export default class Log extends Observable { this.model.inspectorEnabled = false; this.resetStats(); this.queryResult = RemoteData.notAsked(); - if (this.isLiveModeRunning()) { - this.setAutoScrollLive(true, false); - } this.notify(); } diff --git a/InfoLogger/test/public/auto-scroll-mocha.js b/InfoLogger/test/public/auto-scroll-mocha.js index d5580a7287..6916159c29 100644 --- a/InfoLogger/test/public/auto-scroll-mocha.js +++ b/InfoLogger/test/public/auto-scroll-mocha.js @@ -132,20 +132,10 @@ describe('Auto-scroll test-suite', async () => { await assertAutoScrollLive(page, true); }); - it('should re-enable autoscroll when the log list is cleared', async () => { - await injectAndWaitForScrolledDown(page); + it('should not disable autoscroll when zooming in and out', async () => { await page.evaluate(() => { - model.log.autoScrollLive = false; + model.log.autoScrollLive = true; }); - await assertAutoScrollLive(page, false); - - await page.click('#clear-button'); - - await assertAutoScrollLive(page, true); - }); - - it('should not disable autoscroll when zooming in and out', async () => { - await assertAutoScrollLive(page, true); await injectAndWaitForScrolledDown(page); await page.click('#zoom-in-button'); await assertAutoScrollLive(page, true); From d657491ed4dd621ab953a11b90860257231056d7 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:41:23 +0200 Subject: [PATCH 23/24] Fix merge --- InfoLogger/public/log/tableLogsContent.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/InfoLogger/public/log/tableLogsContent.js b/InfoLogger/public/log/tableLogsContent.js index 54552391e6..9655cbfffa 100644 --- a/InfoLogger/public/log/tableLogsContent.js +++ b/InfoLogger/public/log/tableLogsContent.js @@ -216,10 +216,6 @@ const tableContainerHooks = (model) => ({ */ 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.disableAutoScroll(); // stop auto-scrolling if user scroll sup - } model.log.setScrollTop(scrollTop, height); }; From 57005428aa65a7b0e43d0949966cce7dfc6c8367 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:56:01 +0200 Subject: [PATCH 24/24] Restore branch state to commit ec2d339 --- InfoLogger/package-lock.json | 71 +++++++++++++--------------- InfoLogger/package.json | 2 +- InfoLogger/public/log/commandLogs.js | 6 +-- 3 files changed, 37 insertions(+), 42 deletions(-) diff --git a/InfoLogger/package-lock.json b/InfoLogger/package-lock.json index cb35c3282d..a45c4202e4 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 1c32132e72..10277e4ee1 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/commandLogs.js b/InfoLogger/public/log/commandLogs.js index 10ee8bd341..840ecec00f 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -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), ]), }); };