diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index cda40dc3c..993d9dabb 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 } /** @@ -355,26 +361,64 @@ 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; // 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') { + 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(); } /** @@ -382,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); } /** diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index 7664dce16..e51079130 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,63 @@ 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: 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('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'));