From aaad543ece5922f40ffa6851d2dd5a4c9744d5f1 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 1/5] 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 10200f9fdacd8dfb28de8413f6aded38ffb9f9bd Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:55:08 +0200 Subject: [PATCH 2/5] Start live mode from live=true URL parameter --- InfoLogger/public/Model.js | 41 +++++++++++++++++-- InfoLogger/test/public/live-mode-mocha.js | 50 +++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index 41e193f80c..21e867fb8c 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -58,7 +58,7 @@ export default class Model extends Observable { this.frameworkInfoEnabled = false; this.frameworkInfo = RemoteData.notAsked(); - this.getFrameworkInfo(); + this.frameworkInfoLoaded = this.getFrameworkInfo(); this.inspectorEnabled = false; this.accountMenuEnabled = false; @@ -67,7 +67,6 @@ export default class Model extends Observable { this.router = new QueryRouter(); this.router.observe(this.handleLocationChange.bind(this)); this.router.bubbleTo(this); - this.handleLocationChange(); // Init first page // Setup keyboard and wheel dispatchers window.addEventListener('keydown', this.handleKeyboardDown.bind(this)); @@ -77,6 +76,10 @@ export default class Model extends Observable { this.ws = new WebSocketClient(); this.ws.addListener('command', this.handleWSCommand.bind(this)); this.ws.addListener('authed', this.handleWSAuthed.bind(this)); + this.wsSettled = new Promise((resolve) => { + this.ws.addListener('authed', resolve); + this.ws.addListener('close', resolve); + }); this.ws.addListener('close', this.handleWSClose.bind(this)); // update router on model change @@ -86,6 +89,9 @@ export default class Model extends Observable { this.zoom = new Zoom(); this.zoom.bubbleTo(this); + + // Must run last as live=true awaits frameworkInfoLoaded and wsSettled, so both must exist before the URL is parsed + this.handleLocationChange(); // Init first page } /** @@ -361,7 +367,7 @@ export default class Model extends Observable { return; } else if (params.profile) { this.getProfile(params.profile); - return; + return; // live=true ignored if a profile is used } else if (params.q) { this.getUserProfile(); try { @@ -370,10 +376,39 @@ export default class Model extends Observable { this.log.filter.resetCriteria(); this.updateRouteOnModelChange(); this.notification.show(`Invalid URL filter format: ${error.message}`, 'danger'); + return; // don't go live when q is invalid } } else { this.getUserProfile(); } + + if (params.live === 'true') { + this.startLiveModeFromURL(); + } + } + + /** + * Start live mode from URL once dependencies are ready + */ + async startLiveModeFromURL() { + await Promise.all([ + this.frameworkInfoLoaded, + this.wsSettled, + ]); + + if (!this.ws.authed) { + return; // connection lost before auth, handleWSClose has already notified the user + } + + if (!this.log.queryResult.isNotAsked()) { + return; // user started a query as framework has loaded but WS not yet + } + if (!this.log.isLiveModeAvailable()) { + this.notification.show('Live mode is currently unavailable, loaded in query mode', 'danger', 3000); + return; + } + + this.log.liveStart(); } /** diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index 271786f8e8..a120311d11 100644 --- a/InfoLogger/test/public/live-mode-mocha.js +++ b/InfoLogger/test/public/live-mode-mocha.js @@ -17,6 +17,14 @@ const test = require('../mocha-index'); const isFieldEmpty = (value) => value === undefined || value === null || value === ''; +const getLiveState = (page) => page.evaluate(() => ({ + activeMode: window.model.log.activeMode, + className: document.querySelector('#live-button').className, + title: document.title, + autoScroll: window.model.log.autoScrollLive, + search: decodeURIComponent(window.location.search), +})); + describe('Live Mode test-suite', async () => { let baseUrl = null; let page = null; @@ -24,6 +32,10 @@ describe('Live Mode test-suite', async () => { ({ helpers: { baseUrl }, page } = test); }); + after(async () => { + await page.evaluate(() => window.model.log.liveStop()); + }); + it('should successfully go to homepage with predefined filters', async () => { await page.goto(baseUrl, { waitUntil: 'networkidle0' }); const location = await page.evaluate(() => window.location); @@ -118,6 +130,44 @@ describe('Live Mode test-suite', async () => { assert.ok(isUserNameMatching); }); + it('should stay in QUERY mode when live=true is combined with an invalid url filter', async () => { + await page.goto(`${baseUrl}?q={"severity":{"in":"E F"}&live=true`, { waitUntil: 'networkidle0' }); + const { activeMode, notification } = await page.evaluate(() => ({ + activeMode: window.model.log.activeMode, + notification: window.model.notification, + })); + + assert.strictEqual(activeMode, 'Query'); + assert.strictEqual(notification.type, 'danger'); + assert.ok(notification.message.startsWith('Invalid URL filter format')); + }); + + it('should successfully enable LIVE mode from url parameter with the defined filter', async () => { + await page.goto(`${baseUrl}?q={"severity":{"in":"E F"}}&live=true`, { waitUntil: 'networkidle0' }); + + const { activeMode, className, search, autoScroll } = await getLiveState(page); + + assert.strictEqual(autoScroll, true); + assert.strictEqual(activeMode, 'Running'); + assert.strictEqual(className, 'btn bold btn-success active'); + + // Check if filter is still applied + assert.strictEqual(search, '?q={"severity":{"in":"E F"}}'); + }); + + it('should successfully enable LIVE mode from url parameter with no filter', async () => { + await page.goto(`${baseUrl}?live=true`, { waitUntil: 'networkidle0' }); + const { activeMode, className, search, title, autoScroll } = await getLiveState(page); + + assert.strictEqual(autoScroll, true); + assert.strictEqual(activeMode, 'Running'); + assert.strictEqual(className, 'btn bold btn-success active'); + + assert.strictEqual(search, '?q={"severity":{"in":"I W E F"}}'); + + assert.ok(title.endsWith(' LIVE'), `unexpected title: ${title}`); + }); + describe('Empty field filters in live mode', async () => { it('should only receive logs with empty rolename when emptyFor is set to "match"', async () => { await page.evaluate(() => window.model.log.liveStop('Paused')); From e0ff4f2e50454c3fb23d7d5feaf0c6feac28f4bf Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:55:08 +0200 Subject: [PATCH 3/5] Start live mode from live=true URL parameter --- InfoLogger/public/Model.js | 41 +++++++++++++++++-- InfoLogger/test/public/live-mode-mocha.js | 50 +++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index cda40dc3c9..89186b423d 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -58,7 +58,7 @@ export default class Model extends Observable { this.frameworkInfoEnabled = false; this.frameworkInfo = RemoteData.notAsked(); - this.getFrameworkInfo(); + this.frameworkInfoLoaded = this.getFrameworkInfo(); this.inspectorEnabled = false; this.accountMenuEnabled = false; @@ -67,7 +67,6 @@ export default class Model extends Observable { this.router = new QueryRouter(); this.router.observe(this.handleLocationChange.bind(this)); this.router.bubbleTo(this); - this.handleLocationChange(); // Init first page // Setup keyboard and wheel dispatchers window.addEventListener('keydown', this.handleKeyboardDown.bind(this)); @@ -77,6 +76,10 @@ export default class Model extends Observable { this.ws = new WebSocketClient(); this.ws.addListener('command', this.handleWSCommand.bind(this)); this.ws.addListener('authed', this.handleWSAuthed.bind(this)); + this.wsSettled = new Promise((resolve) => { + this.ws.addListener('authed', resolve); + this.ws.addListener('close', resolve); + }); this.ws.addListener('close', this.handleWSClose.bind(this)); // update router on model change @@ -86,6 +89,9 @@ export default class Model extends Observable { this.zoom = new Zoom(); this.zoom.bubbleTo(this); + + // Must run last as live=true awaits frameworkInfoLoaded and wsSettled, so both must exist before the URL is parsed + this.handleLocationChange(); // Init first page } /** @@ -362,7 +368,7 @@ export default class Model extends Observable { return; } else if (params.profile) { this.getProfile(params.profile); - return; + return; // live=true ignored if a profile is used } else if (params.q) { this.getUserProfile(); try { @@ -371,10 +377,39 @@ export default class Model extends Observable { this.log.filter.resetCriteria(); this.updateRouteOnModelChange(); this.notification.show(`Invalid URL filter format: ${error.message}`, 'danger'); + return; // don't go live when q is invalid } } else { this.getUserProfile(); } + + if (params.live === 'true') { + this.startLiveModeFromURL(); + } + } + + /** + * Start live mode from URL once dependencies are ready + */ + async startLiveModeFromURL() { + await Promise.all([ + this.frameworkInfoLoaded, + this.wsSettled, + ]); + + if (!this.ws.authed) { + return; // connection lost before auth, handleWSClose has already notified the user + } + + if (!this.log.queryResult.isNotAsked()) { + return; // user started a query as framework has loaded but WS not yet + } + if (!this.log.isLiveModeAvailable()) { + this.notification.show('Live mode is currently unavailable, loaded in query mode', 'danger', 3000); + return; + } + + this.log.liveStart(); } /** diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index 271786f8e8..a120311d11 100644 --- a/InfoLogger/test/public/live-mode-mocha.js +++ b/InfoLogger/test/public/live-mode-mocha.js @@ -17,6 +17,14 @@ const test = require('../mocha-index'); const isFieldEmpty = (value) => value === undefined || value === null || value === ''; +const getLiveState = (page) => page.evaluate(() => ({ + activeMode: window.model.log.activeMode, + className: document.querySelector('#live-button').className, + title: document.title, + autoScroll: window.model.log.autoScrollLive, + search: decodeURIComponent(window.location.search), +})); + describe('Live Mode test-suite', async () => { let baseUrl = null; let page = null; @@ -24,6 +32,10 @@ describe('Live Mode test-suite', async () => { ({ helpers: { baseUrl }, page } = test); }); + after(async () => { + await page.evaluate(() => window.model.log.liveStop()); + }); + it('should successfully go to homepage with predefined filters', async () => { await page.goto(baseUrl, { waitUntil: 'networkidle0' }); const location = await page.evaluate(() => window.location); @@ -118,6 +130,44 @@ describe('Live Mode test-suite', async () => { assert.ok(isUserNameMatching); }); + it('should stay in QUERY mode when live=true is combined with an invalid url filter', async () => { + await page.goto(`${baseUrl}?q={"severity":{"in":"E F"}&live=true`, { waitUntil: 'networkidle0' }); + const { activeMode, notification } = await page.evaluate(() => ({ + activeMode: window.model.log.activeMode, + notification: window.model.notification, + })); + + assert.strictEqual(activeMode, 'Query'); + assert.strictEqual(notification.type, 'danger'); + assert.ok(notification.message.startsWith('Invalid URL filter format')); + }); + + it('should successfully enable LIVE mode from url parameter with the defined filter', async () => { + await page.goto(`${baseUrl}?q={"severity":{"in":"E F"}}&live=true`, { waitUntil: 'networkidle0' }); + + const { activeMode, className, search, autoScroll } = await getLiveState(page); + + assert.strictEqual(autoScroll, true); + assert.strictEqual(activeMode, 'Running'); + assert.strictEqual(className, 'btn bold btn-success active'); + + // Check if filter is still applied + assert.strictEqual(search, '?q={"severity":{"in":"E F"}}'); + }); + + it('should successfully enable LIVE mode from url parameter with no filter', async () => { + await page.goto(`${baseUrl}?live=true`, { waitUntil: 'networkidle0' }); + const { activeMode, className, search, title, autoScroll } = await getLiveState(page); + + assert.strictEqual(autoScroll, true); + assert.strictEqual(activeMode, 'Running'); + assert.strictEqual(className, 'btn bold btn-success active'); + + assert.strictEqual(search, '?q={"severity":{"in":"I W E F"}}'); + + assert.ok(title.endsWith(' LIVE'), `unexpected title: ${title}`); + }); + describe('Empty field filters in live mode', async () => { it('should only receive logs with empty rolename when emptyFor is set to "match"', async () => { await page.evaluate(() => window.model.log.liveStop('Paused')); From c4a1f74ecb12448ae1b6f2a27b3fdbe7290d921f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:54:39 +0200 Subject: [PATCH 4/5] Make tests more reliable and robust --- InfoLogger/test/public/live-mode-mocha.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index d1790c84b6..e510791301 100644 --- a/InfoLogger/test/public/live-mode-mocha.js +++ b/InfoLogger/test/public/live-mode-mocha.js @@ -133,8 +133,8 @@ describe('Live Mode test-suite', async () => { it('should stay in QUERY mode when live=true is combined with an invalid url filter', async () => { await page.goto(`${baseUrl}?q={"severity":{"in":"E F"}&live=true`, { waitUntil: 'networkidle0' }); const { activeMode, notification } = await page.evaluate(() => ({ - activeMode: window.model.log.activeMode, - notification: window.model.notification, + activeMode: model.log.activeMode, + notification: model.notification, })); assert.strictEqual(activeMode, 'Query'); @@ -144,6 +144,7 @@ describe('Live Mode test-suite', async () => { it('should successfully enable LIVE mode from url parameter with the defined filter', async () => { await page.goto(`${baseUrl}?q={"severity":{"in":"E F"}}&live=true`, { waitUntil: 'networkidle0' }); + await page.waitForFunction(() => model.log.activeMode === 'Running', { timeout: 5000 }); const { activeMode, className, search, autoScroll } = await getLiveState(page); @@ -157,6 +158,8 @@ describe('Live Mode test-suite', async () => { it('should successfully enable LIVE mode from url parameter with no filter', async () => { await page.goto(`${baseUrl}?live=true`, { waitUntil: 'networkidle0' }); + await page.waitForFunction(() => model.log.activeMode === 'Running', { timeout: 5000 }); + const { activeMode, className, search, title, autoScroll } = await getLiveState(page); assert.strictEqual(autoScroll, true); @@ -168,6 +171,22 @@ describe('Live Mode test-suite', async () => { assert.ok(title.endsWith(' LIVE'), `unexpected title: ${title}`); }); + it('should notify and stay in QUERY when live service is unavailable', async () => { + const { activeMode, message } = await page.evaluate(async () => { + model.log.liveStop(); + const { status } = window.model.frameworkInfo.payload.infoLoggerServer; + status.ok = false; + try { + await model.startLiveModeFromURL(); + return { activeMode: model.log.activeMode, message: model.notification.message }; + } finally { + status.ok = true; + } + }); + assert.strictEqual(activeMode, 'Query'); + assert.ok(message.startsWith('Live mode is currently unavailable')); + }); + describe('Empty field filters in live mode', async () => { it('should only receive logs with empty rolename when emptyFor is set to "match"', async () => { await page.evaluate(() => window.model.log.liveStop('Paused')); From 15fd08cdb9252f0c1f7b5a61c7917e8d12272351 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:46:34 +0200 Subject: [PATCH 5/5] Fix InfoLogger URL parsing and notification order parseLocation is now async and awaits profile loading before showing filter errors. Profile loading and filter parsing now run together, so a profile can be combined with filters and live=true is no longer ignored for profiles. Also show a warning when a query ran before live mode was ready, instead of silently staying in query mode. updateRouteOnModelChange now keeps live=true in the URL while in Running mode. --- InfoLogger/public/Model.js | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index 89186b423d..993d9dabbc 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -361,26 +361,34 @@ export default class Model extends Observable { * Delegates sub-model actions depending if location is filters or profile * @param {object} params - URL parameters */ - parseLocation(params) { + async parseLocation(params) { if (params.profile && params.q) { this.log.filter.resetCriteria(); this.notification.show('URL can contain only filters or profile, not both', 'warning'); return; - } else if (params.profile) { - this.getProfile(params.profile); - return; // live=true ignored if a profile is used - } else if (params.q) { - this.getUserProfile(); + }; + + const profileLoaded = params.profile ? this.getProfile(params.profile) : this.getUserProfile(); + + let filterError = null; + if (params.q) { try { this.log.filter.fromObject(JSON.parse(params.q)); } catch (error) { this.log.filter.resetCriteria(); this.updateRouteOnModelChange(); - this.notification.show(`Invalid URL filter format: ${error.message}`, 'danger'); - return; // don't go live when q is invalid + filterError = error; // don't go live when q is invalid } - } else { - this.getUserProfile(); + } + + // await as our notification system only shows one notification at a time + // and profile loading will trigger a notification + // and we don't want it and error messages to interfere with each other + await profileLoaded; + + if (filterError) { + this.notification.show(`Invalid URL filter format: ${filterError.message}`, 'danger'); + return; // don't go live when q is invalid } if (params.live === 'true') { @@ -402,7 +410,8 @@ export default class Model extends Observable { } if (!this.log.queryResult.isNotAsked()) { - return; // user started a query as framework has loaded but WS not yet + this.notification.show('A query was run before live mode was ready, staying in query mode', 'warning', 3000); + return; } if (!this.log.isLiveModeAvailable()) { this.notification.show('Live mode is currently unavailable, loaded in query mode', 'danger', 3000); @@ -417,7 +426,7 @@ export default class Model extends Observable { * do it silently to avoid infinite loop */ updateRouteOnModelChange() { - this.router.go(this.log.filter.queryString, true, true); + this.router.go(this.log.filter.queryString + (this.log.activeMode === 'Running' ? '&live=true' : ''), true, true); } /**