diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index fbd4d5cb3..cda40dc3c 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'; @@ -94,6 +94,7 @@ export default class Model extends Observable { handleWSAuthed() { // Tell server not to stream by default this.ws.setFilter(() => false); + this.notify(); } /** @@ -115,12 +116,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 cca66aa25..09f73523d 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 7b689a2b8..ef655c6ff 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 91aae4be4..03608b52c 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 06ca8932f..1b10e0d54 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 e0a207a48..2250078ca 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 33cf83426..862f75c12 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 f15ea3b11..0435a8193 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 6002f4685..3c0d46609 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;