diff --git a/InfoLogger/public/app.css b/InfoLogger/public/app.css index 5a76d735c..9d2e934a9 100644 --- a/InfoLogger/public/app.css +++ b/InfoLogger/public/app.css @@ -14,6 +14,12 @@ /* make the sidebar big enough to contain inspector */ +:root { + --color-gray-button-background-hover: #a0a0a0; + --color-gray-button-background: #e0e0e0; + --color-gray-button: #404040; + --color-gray-button-active: #c0c0c0; +} .sidebar, .sidebar .sidebar-content { width: 20rem; } @@ -120,9 +126,12 @@ footer { border-top: 1px solid var(--color-gray); } .query-item:active { background-color: var(--color-gray-dark); color: var(--color-black); } .query-item.selected { background-color: var(--color-primary); color: var(--color-white); } -.btn { background-color: #e0e0e0; color: #404040; } -.btn:hover { background-color: #a0a0a0; color: var(--color-white); } -.btn:active, .btn.active, .dropdown-open > .btn { background-color: #c0c0c0; } +.btn { background-color: var(--color-gray-button-background); color: var(--color-gray-button); } +.btn:hover { background-color: var(--color-gray-button-background-hover); color: var(--color-white); } +.btn:active, .btn.active, .dropdown-open > .btn { background-color: var(--color-gray-button-active); } + +.select-btn { background-color: var(--color-gray-button-background); color: var(--color-gray-button); border: 0; border-radius: .25rem; padding: 0em 0.5em; font-size: 1em; font-family: inherit; cursor: pointer; } +.select-btn:hover { background-color: var(--color-gray-button-background-hover); color: var(--color-white); } .text-area-for-message:focus { width: 50%; height: 10rem !important; right: 0; position: absolute; } a.disabled { pointer-events: none; cursor: default; } diff --git a/InfoLogger/public/log/commandLogs.js b/InfoLogger/public/log/commandLogs.js index df3dd3020..06ca8932f 100644 --- a/InfoLogger/public/log/commandLogs.js +++ b/InfoLogger/public/log/commandLogs.js @@ -36,39 +36,37 @@ let liveButtonIcon = iconMediaPlay(); */ export const commandLogs = (model) => [ userActionsDropdown(model), - h('div.btn-group.mh3', interactionModesGroupButton(model)), - h('button.btn.mh3', { onclick: () => model.log.empty(), style: 'font-weight: bold' }, 'Clear'), - h('button.btn', { - disabled: !model.log.list.length, - onclick: () => model.log.firstError(), - title: 'Go to first error/fatal (ALT + left arrow)', - }, '|←'), - ' ', - h('button.btn', { - disabled: !model.log.list.length, - onclick: () => model.log.previousError(), - title: 'Go to previous error/fatal (left arrow)', - }, '←'), - ' ', - h('button.btn', { - disabled: !model.log.list.length, - onclick: () => model.log.nextError(), - title: 'Go to next error/fatal (left arrow)', - }, '→'), - ' ', - h('button.btn', { - disabled: !model.log.list.length, - onclick: () => model.log.lastError(), - title: 'Go to last error/fatal (ALT + right arrow)', - }, '→|'), - ' ', - h('button.btn', { - disabled: !model.log.list.length, - onclick: () => model.log.goToLastItem(), - title: 'Go to last log message (ALT + down arrow)', - }, '↓'), - downloadButtonGroup(model.log), - zoomButtonGroup(model.zoom), + h('', interactionModesGroupButton(model)), + h('', h('button.btn', { onclick: () => model.log.empty(), style: 'font-weight: bold' }, 'Clear')), + h('.btn-group', [ + h('button.btn', { + disabled: !model.log.list.length, + onclick: () => model.log.firstError(), + title: 'Go to first error/fatal (ALT + left arrow)', + }, '|←'), + h('button.btn', { + disabled: !model.log.list.length, + onclick: () => model.log.previousError(), + title: 'Go to previous error/fatal (left arrow)', + }, '←'), + h('button.btn', { + disabled: !model.log.list.length, + onclick: () => model.log.nextError(), + title: 'Go to next error/fatal (left arrow)', + }, '→'), + h('button.btn', { + disabled: !model.log.list.length, + onclick: () => model.log.lastError(), + title: 'Go to last error/fatal (ALT + right arrow)', + }, '→|'), + h('button.btn', { + disabled: !model.log.list.length, + onclick: () => model.log.goToLastItem(), + title: 'Go to last log message (ALT + down arrow)', + }, '↓'), + ]), + h('', downloadButtonGroup(model.log)), + h('', zoomButtonGroup(model.zoom)), ]; /** @@ -82,12 +80,12 @@ const interactionModesGroupButton = (model) => { return frameworkInfo.match({ NotAsked: () => h('button.btn', { disabled: true }, ''), Loading: () => h('button.btn', { disabled: true, className: 'loading' }, 'Loading'), - Failure: () => [], + Failure: () => null, Success: (frameworkInfo) => - [ + h('.btn-group', [ queryButton(model, frameworkInfo), liveButton(model, frameworkInfo), - ], + ]), }); }; @@ -194,7 +192,7 @@ const saveUserProfileMenuItem = (model) => */ const downloadButtonGroup = (logModel) => h('.dropdown', { class: logModel.download.isVisible ? 'dropdown-open' : '' }, [ - h('button.btn.mh3', { + h('button.btn', { onclick: () => { if (!logModel.download.isVisible) { logModel.generateLogDownloadContent(); diff --git a/InfoLogger/public/logFilter/commandFilters.js b/InfoLogger/public/logFilter/commandFilters.js index 210563363..1fd7245e3 100644 --- a/InfoLogger/public/logFilter/commandFilters.js +++ b/InfoLogger/public/logFilter/commandFilters.js @@ -24,31 +24,16 @@ import { h } from '/js/src/index.js'; * @returns {vnode} - the view of filters panel */ export default (model) => [ - h( - '', - h('.btn-group', [ - buttonSeverity(model, 'Debug', 'Match severity debug', 'D'), - buttonSeverity(model, 'Info', 'Match severity info', 'I'), - buttonSeverity(model, 'Warn', 'Match severity warnings', 'W'), - buttonSeverity(model, 'Error', 'Match severity errors', 'E'), - buttonSeverity(model, 'Fatal', 'Match severity fatal', 'F'), - ]), - h('span.mh3'), - h('.btn-group', [ - buttonFilterLevel(model, 'Ops', 1), - buttonFilterLevel(model, 'Support', 6), - buttonFilterLevel(model, 'Devel', 11), - buttonFilterLevel(model, 'Trace', null), // 21 - ]), - h('span.mh3'), - h('.btn-group', [ - buttonLogLimit(model, '100k', 100000), - buttonLogLimit(model, '500k', 500000), - buttonLogLimit(model, '1M', 1000000), - ]), - h('span.mh3'), - buttonReset(model), - ), + h('.btn-group', [ + buttonSeverity(model, 'Debug', 'Match severity debug', 'D'), + buttonSeverity(model, 'Info', 'Match severity info', 'I'), + buttonSeverity(model, 'Warn', 'Match severity warnings', 'W'), + buttonSeverity(model, 'Error', 'Match severity errors', 'E'), + buttonSeverity(model, 'Fatal', 'Match severity fatal', 'F'), + ]), + selectFilterLevel(model.log), + selectLogLimit(model.log), + buttonReset(model), ]; /** @@ -73,30 +58,63 @@ const buttonSeverity = (model, label, title, value) => { }; /** - * Makes a button to set filtering level (shifter, debug, etc) with number - * @param {Model} model - root model of the application - * @param {string} label - button's label - * @param {number} value - maximum level of filtering, from 1 to 21 - * @returns {vnode} - component representing the creation of a button for filtering + * Build a vnode for a select component with given options and onchange callback + * @param {string} title - tooltip shown on hover + * @param {string} id - id of the element + * @param {Array<{label: string, value: string, selected: boolean}>} options - list of options to render + * @param {void} onchange - callback receiving the raw string value from the select + * @param {string} [optGroupLabel] - optional non-selectable group header shown inside the dropdown + * @returns {vnode} - component representing the select element */ -const buttonFilterLevel = (model, label, value) => h('button.btn', { - className: model.log.filter.criterias.level.max === value ? 'active' : '', - onclick: () => model.log.setCriteria('level', 'max', value), - title: `Filter level ≤ ${value}`, -}, label); +const selectBtn = (title, id, options, onchange, optGroupLabel = null) => { + const optionsMap = options.map(({ label, value, selected }) => h('option', { value, selected }, label)); + return h( + 'select.select-btn', + { + id, + title, + onchange: (e) => onchange(e.target.value), + }, + optGroupLabel + ? h('optgroup', { label: optGroupLabel }, optionsMap) + : optionsMap, + ); +}; /** - * Makes a button to set log limit, maximum logs in memory - * @param {Model} model - root model of the application - * @param {string} label - button's label - * @param {number} limit - how much logs to keep in memory - * @returns {vnode} - component representing the creation of a button for log limit + * Makes a select to set filtering level (Ops, Support, Devel, Trace) + * @param {Log} logModel - log model of the application + * @returns {vnode} - component representing the selection of a log level filter + */ +const selectFilterLevel = (logModel) => selectBtn( + 'Filter by log level', + 'filter-level', + [ + { label: 'Ops', value: '1', selected: logModel.filter.criterias.level.max === 1 }, + { label: 'Support', value: '6', selected: logModel.filter.criterias.level.max === 6 }, + { label: 'Devel', value: '11', selected: logModel.filter.criterias.level.max === 11 }, + { label: 'Trace', value: '', selected: logModel.filter.criterias.level.max === null }, + ], + (value) => logModel.setCriteria('level', 'max', value === '' ? null : parseInt(value, 10)), + 'Log Level', +); + +/** + * Makes a select to set the maximum number of logs to keep in memory + * @param {Log} logModel - log model of the application + * @returns {vnode} - component representing the selection of a log limit */ -const buttonLogLimit = (model, label, limit) => h('button.btn', { - className: model.log.limit === limit ? 'active' : '', - onclick: () => model.log.setLimit(limit), - title: `Keep only ${label} logs in the view`, -}, label); +const selectLogLimit = (logModel) => selectBtn( + 'Maximum logs to keep in the view', + 'log-limit', + [ + { label: '100k', value: '100000', selected: logModel.limit === 100000 }, + { label: '500k', value: '500000', selected: logModel.limit === 500000 }, + { label: '1M', value: '1000000', selected: logModel.limit === 1000000 }, + ], + (value) => logModel.setLimit(parseInt(value, 10)), + 'Buffer Limit', +); /** * Makes a button to reset filters diff --git a/InfoLogger/public/view.js b/InfoLogger/public/view.js index dc45e9efa..33cf83426 100644 --- a/InfoLogger/public/view.js +++ b/InfoLogger/public/view.js @@ -36,19 +36,13 @@ export default (model) => [ cellContextMenu(model), h('.flex-column absolute-fill', [ h('.shadow-level2', [ - h('header.p1.flex-row.f7', [ - h('', commandLogs(model)), - h( - '.flex-grow', - { - style: 'display: flex; flex-direction:row-reverse;', - }, - commandFilters(model), - ), + 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('div.flex-grow.flex-row.shadow-level0.logs-container', [ + h('.flex-grow.flex-row.shadow-level0.logs-container', [ aboutComponent(model), logsTable(model), inspectorSide(model), diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index 32a4afa87..cf71d231e 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -10,9 +10,8 @@ * 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. -*/ + */ -/* eslint-disable max-len */ const assert = require('assert'); const test = require('../mocha-index'); @@ -26,8 +25,8 @@ describe('Filter actions test-suite', async () => { }); // "physicist" is not a distinct stored profile; the server returns defaultCriterias for any name - it('should succesfully load a page with profile in the URI', async function() { - await page.goto(baseUrl + "?profile=physicist", {waitUntil: 'networkidle0'}); + it('should succesfully load a page with profile in the URI', async () => { + await page.goto(`${baseUrl}?profile=physicist`, { waitUntil: 'networkidle0' }); const location = await page.evaluate(() => window.location); const search = decodeURIComponent(location.search); @@ -37,60 +36,58 @@ describe('Filter actions test-suite', async () => { it('should update column headers based on profile when passed in the URI', async () => { const expectedColumns = { - date: {size: 'cell-m', visible: false}, - time: {size: 'cell-m', visible: true}, - hostname: {size: 'cell-m', visible: true}, - rolename: {size: 'cell-m', visible: false}, - pid: {size: 'cell-s', visible: false}, - username: {size: 'cell-m', visible: false}, - system: {size: 'cell-s', visible: true}, - facility: {size: 'cell-m', visible: true}, - detector: {size: 'cell-s', visible: true}, - partition: {size: 'cell-m', visible: true}, - run: {size: 'cell-s', visible: true}, - errcode: {size: 'cell-s', visible: false}, - errline: {size: 'cell-s', visible: false}, - errsource: {size: 'cell-m', visible: false}, - message: {size: 'cell-xl', visible: true} + date: { size: 'cell-m', visible: false }, + time: { size: 'cell-m', visible: true }, + hostname: { size: 'cell-m', visible: true }, + rolename: { size: 'cell-m', visible: false }, + pid: { size: 'cell-s', visible: false }, + username: { size: 'cell-m', visible: false }, + system: { size: 'cell-s', visible: true }, + facility: { size: 'cell-m', visible: true }, + detector: { size: 'cell-s', visible: true }, + partition: { size: 'cell-m', visible: true }, + run: { size: 'cell-s', visible: true }, + errcode: { size: 'cell-s', visible: false }, + errline: { size: 'cell-s', visible: false }, + errsource: { size: 'cell-m', visible: false }, + message: { size: 'cell-xl', visible: true }, }; - const columns = await page.evaluate(() => { - return window.model.table.colsHeader; - }); + const columns = await page.evaluate(() => window.model.table.colsHeader); assert.deepStrictEqual(columns, expectedColumns); }); it('should update filters based on profile when passed in the URI', async () => { - // for now check if the filters are reset once the profile is passed + // for now check if the filters are reset once the profile is passed const expectedParams = '?q={%22severity%22:{%22in%22:%22I%20W%20E%20F%22},%22level%22:{%22max%22:1}}'; const searchParams = await page.evaluate(() => { - const params = {profile: 'physicist'}; + const params = { profile: 'physicist' }; window.model.parseLocation(params); return window.location.search; }); - await page.waitForFunction(`window.model.notification.state === 'shown'`); - await page.waitForFunction(`window.model.notification.type === 'success'`); - await page.waitForFunction(`window.model.notification.message === "The profile PHYSICIST was loaded successfully"`); + await page.waitForFunction('window.model.notification.state === \'shown\''); + await page.waitForFunction('window.model.notification.type === \'success\''); + await page.waitForFunction('window.model.notification.message === "The profile PHYSICIST was loaded successfully"'); assert.strictEqual(searchParams, expectedParams); }); it('should reset filters and show warning message when profile and filters are passed', async () => { // wait until the previous notification is hidden - await page.waitForFunction(`window.model.notification.state === 'hidden'`); + await page.waitForFunction('window.model.notification.state === \'hidden\''); const expectedParams = '?q={%22severity%22:{%22in%22:%22I%20W%20E%20F%22},%22level%22:{%22max%22:1}}'; const searchParams = await page.evaluate(() => { - const params = {profile: "physicist", q: '"severity":{"in":"I W E F"}}'}; + const params = { profile: 'physicist', q: '"severity":{"in":"I W E F"}}' }; window.model.parseLocation(params); return window.location.search; }); - await page.waitForFunction(`window.model.notification.state === 'shown'`); - await page.waitForFunction(`window.model.notification.type === 'warning'`); - await page.waitForFunction(`window.model.notification.message === "URL can contain only filters or profile, not both"`); + await page.waitForFunction('window.model.notification.state === \'shown\''); + await page.waitForFunction('window.model.notification.type === \'warning\''); + await page.waitForFunction('window.model.notification.message === "URL can contain only filters or profile, not both"'); assert.strictEqual(searchParams, expectedParams); }); @@ -111,11 +108,11 @@ describe('Filter actions test-suite', async () => { // CI/CD runs on Chromium so this assertion is based on Chromium's JSON engine's error message assert.strictEqual( locationAndNotification.notification.message, - 'Invalid URL filter format: Expected \',\' or \'}\' after property value in JSON at position 27 (line 1 column 28)'); + 'Invalid URL filter format: Expected \',\' or \'}\' after property value in JSON at position 27 (line 1 column 28)', + ); }); it('should update URI with new encoded "match" criteria', async () => { - /* eslint-disable max-len */ const decodedParams = '?q={"hostname":{"match":"\\"%ald_qdip01%"},"severity":{"in":"I W E F"}}'; const expectedParams = '?q={%22hostname%22:{%22match%22:%22%5C%22%25ald_qdip01%25%22},%22severity%22:{%22in%22:%22I%20W%20E%20F%22}}'; const searchParams = await page.evaluate(() => { @@ -129,7 +126,6 @@ describe('Filter actions test-suite', async () => { }); it('should update URI with new encoded "exclude" criteria', async () => { - /* eslint-disable max-len */ const decodedParams = '?q={"hostname":{"exclude":"\\"%ald_qdip01%"},"severity":{"in":"I W E F"}}'; const expectedParams = '?q={%22hostname%22:{%22exclude%22:%22%5C%22%25ald_qdip01%25%22},%22severity%22:{%22in%22:%22I%20W%20E%20F%22}}'; const searchParams = await page.evaluate(() => { @@ -310,4 +306,86 @@ describe('Filter actions test-suite', async () => { }); }); }); + + describe('Level filter select', async () => { + it('should build a select components options for level to filter by', async () => { + const options = await page.evaluate(() => { + const select = document.getElementById('filter-level'); + return select ? Array.from(select.options) + .map((element) => ({ value: element.value, text: element.text })) : null; + }); + + assert.deepStrictEqual(options, [ + { value: '1', text: 'Ops' }, + { value: '6', text: 'Support' }, + { value: '11', text: 'Devel' }, + { value: '', text: 'Trace' }, + ]); + }); + + it('should mark the currently active level option as selected', async () => { + await page.evaluate(() => model.log.filter.setCriteria('level', 'max', 6)); + await page.waitForFunction(() => document.getElementById('filter-level')?.value === '6'); + + const selectedValue = await page.evaluate(() => document.getElementById('filter-level')?.value); + assert.strictEqual(selectedValue, '6'); + }); + + it('should update level criteria when an option is selected', async () => { + const level = await page.evaluate(() => { + const select = document.getElementById('filter-level'); + select.value = '11'; + select.dispatchEvent(new Event('change')); + return window.model.log.filter.criterias.level.max; + }); + + assert.strictEqual(level, 11); + }); + + it('should set level to null when Trace option is selected', async () => { + const level = await page.evaluate(() => { + const select = document.getElementById('filter-level'); + select.value = ''; + select.dispatchEvent(new Event('change')); + return window.model.log.filter.criterias.level.max; + }); + + assert.strictEqual(level, null); + }); + }); + + describe('Log limit select', async () => { + it('should display a select component for limit to filter by', async () => { + const options = await page.evaluate(() => { + const select = document.getElementById('log-limit'); + return select ? Array.from(select.options) + .map((element) => ({ value: element.value, text: element.text })) : null; + }); + + assert.deepStrictEqual(options, [ + { value: '100000', text: '100k' }, + { value: '500000', text: '500k' }, + { value: '1000000', text: '1M' }, + ]); + }); + + it('should mark the currently active limit option as selected', async () => { + await page.evaluate(() => window.model.log.setLimit(500000)); + await page.waitForFunction(() => document.getElementById('log-limit')?.value === '500000'); + + const selectedValue = await page.evaluate(() => document.getElementById('log-limit')?.value); + assert.strictEqual(selectedValue, '500000'); + }); + + it('should update log limit when an option is selected', async () => { + const limit = await page.evaluate(() => { + const select = document.getElementById('log-limit'); + select.value = '1000000'; + select.dispatchEvent(new Event('change')); + return window.model.log.limit; + }); + + assert.strictEqual(limit, 1000000); + }); + }); });