diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index 2dfce481a..d3f5a645f 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -58,11 +58,25 @@ export default class Model extends Observable { this.frameworkInfoEnabled = false; this.frameworkInfo = RemoteData.notAsked(); - this.getFrameworkInfo(); + + /** @type {Promise} resolves once the framework info has loaded (success or failure) */ + this.frameworkInfoLoaded = this.getFrameworkInfo(); this.inspectorEnabled = false; this.accountMenuEnabled = false; + // Setup WS connection before the router as parsing live=true awaits wsSettled + this.ws = new WebSocketClient(); + this.ws.addListener('command', this.handleWSCommand.bind(this)); + this.ws.addListener('authed', this.handleWSAuthed.bind(this)); + + /** @type {Promise} resolves once the websocket is authenticated or closed */ + this.wsSettled = new Promise((resolve) => { + this.ws.addListener('authed', resolve); + this.ws.addListener('close', resolve); + }); + this.ws.addListener('close', this.handleWSClose.bind(this)); + // Setup router this.router = new QueryRouter(); this.router.observe(this.handleLocationChange.bind(this)); @@ -73,12 +87,6 @@ export default class Model extends Observable { window.addEventListener('keydown', this.handleKeyboardDown.bind(this)); window.addEventListener('wheel', this.handleWheel.bind(this), { passive: false }); - // Setup WS connection - this.ws = new WebSocketClient(); - this.ws.addListener('command', this.handleWSCommand.bind(this)); - this.ws.addListener('authed', this.handleWSAuthed.bind(this)); - this.ws.addListener('close', this.handleWSClose.bind(this)); - // update router on model change // Model can change very often we protect router with callRateLimiter // Router limit: 100 calls per 30 seconds max = 30ms, 2 FPS is enough (500ms) @@ -344,10 +352,14 @@ export default class Model extends Observable { /** * Delegates sub-model actions depending new location of the page */ - handleLocationChange() { + async handleLocationChange() { const { params } = this.router; if (params) { - this.parseLocation(params); + try { + await this.parseLocation(params); + } catch (error) { + this.notification.show(`Unable to load page from URL: ${error.message}`, 'danger'); + } } } @@ -355,26 +367,62 @@ 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; - } 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'); + filterError = error; } - } else { - this.getUserProfile(); } + + // Profile loading shows its own notification; wait so it doesn't overwrite the filter error + 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') { + await 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()) { + 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); + return; + } + + this.log.liveStart(); } /** diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index 7664dce16..088269d55 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; @@ -25,7 +33,7 @@ describe('Live Mode test-suite', async () => { }); after(async () => { - await page.evaluate(() => model.log.liveStop('Query')); + await page.evaluate(() => model.log.liveStop()); }); it('should successfully go to homepage with predefined filters', async () => { @@ -122,6 +130,68 @@ 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' }); + await page.waitForFunction( + () => model.notification.message.startsWith('Invalid URL filter format'), + { timeout: 5000 }, + ); + + const { activeMode, notification } = await page.evaluate(() => ({ + activeMode: model.log.activeMode, + notification: 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' }); + await page.waitForFunction(() => model.log.activeMode === 'Running', { timeout: 5000 }); + + 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' }); + await page.waitForFunction(() => model.log.activeMode === 'Running', { timeout: 5000 }); + + 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}`); + }); + + it('startLiveModeFromURL 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'));