diff --git a/InfoLogger/lib/ProfileService.js b/InfoLogger/lib/ProfileService.js index ce0801542..b24b28736 100644 --- a/InfoLogger/lib/ProfileService.js +++ b/InfoLogger/lib/ProfileService.js @@ -59,7 +59,7 @@ class ProfileService { errsource: { match: '', exclude: '' }, message: { match: '', exclude: '' }, severity: { in: 'I W E F' }, - level: { max: null }, + level: { max: 1 }, }; } diff --git a/InfoLogger/public/constants/log-level-filters.const.js b/InfoLogger/public/constants/log-level-filters.const.js new file mode 100644 index 000000000..a3161ee0b --- /dev/null +++ b/InfoLogger/public/constants/log-level-filters.const.js @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * 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. + */ + +/** + * Maps each log level to the set of severity codes that are NOT available at that level. + * @type {Map} + */ +const DISABLED_SEVERITIES_BY_LEVEL = new Map([ + [1, ['D']], + [6, []], + [11, []], + [null, []], +]); + +/** + * Return the severity codes that are disabled for a given log level. + * @param {number|null} level - the current log level (1=Ops, 6=Support, 11=Developer, null=Trace) + * If provided a level not in the map, we enable all severities as this will be a custom event + * @returns {string[]} severity codes that should be disabled at the given log level + */ +export const getDisabledSeverities = (level) => DISABLED_SEVERITIES_BY_LEVEL.get(level) ?? []; diff --git a/InfoLogger/public/log/Log.js b/InfoLogger/public/log/Log.js index ad7a75458..fbcd8e10f 100644 --- a/InfoLogger/public/log/Log.js +++ b/InfoLogger/public/log/Log.js @@ -33,6 +33,7 @@ export default class Log extends Observable { this.filter = new LogFilter(model); this.filter.bubbleTo(this); + this.filter.observe(this.onFilterChange.bind(this)); this.focus = { // show date picker on focus timestampSince: false, @@ -400,17 +401,22 @@ export default class Log extends Observable { } value = copy.join(' '); } - if (this.filter.setCriteria(field, operator, value)) { - if (this.isLiveModeRunning()) { - this.model.ws.setFilter(this.model.log.filter.toStringifyFunction()); - this.model.notification.show( - 'The current live session has been adapted to the new filter configuration.', - 'primary', - 2000, - ); - } else if (this.isActiveModeQuery()) { - this.model.notification.show('Filters have changed. Query again for updated results', 'primary', 2000); - } + this.filter.setCriteria(field, operator, value); + } + + /** + * Notify the active mode (live or query) that filters have changed. + */ + onFilterChange() { + if (this.isLiveModeRunning()) { + this.model.ws.setFilter(this.filter.toStringifyFunction()); + this.model.notification.show( + 'The current live session has been adapted to the new filter configuration.', + 'primary', + 2000, + ); + } else if (this.isActiveModeQuery()) { + this.model.notification.show('Filters have changed. Query again for updated results', 'primary', 2000); } } diff --git a/InfoLogger/public/log/cellContextMenu.js b/InfoLogger/public/log/cellContextMenu.js index 25af0b0d0..009f20bcf 100644 --- a/InfoLogger/public/log/cellContextMenu.js +++ b/InfoLogger/public/log/cellContextMenu.js @@ -124,10 +124,10 @@ export const cellContextMenu = (model) => { hideMenu(); }, ), - createMenuItem(iconTrash(), 'danger', 'Clear Level Filter', () => { - model.log.setCriteria('level', 'max', null); + createMenuItem(iconTrash(), 'danger', 'Reset Level Filter', () => { + model.log.setCriteria('level', 'max', 1); hideMenu(); - }, model.log.filter.criterias.level.max === null), + }, model.log.filter.criterias.level.max === 1), ]; } return [ diff --git a/InfoLogger/public/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index 2410dcb00..7e70dd646 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -14,6 +14,7 @@ import { Observable } from '/js/src/index.js'; import { TEXT_FILTER_OPERATORS } from '../constants/text-filter-operators.const.js'; +import { getDisabledSeverities } from '../constants/log-level-filters.const.js'; /** * @typedef Criteria @@ -88,6 +89,11 @@ export default class LogFilter extends Observable { throw new Error('unknown operator'); } + // enforces on both severity and level as fromObject can set them in either order + if (field === 'severity' || field === 'level') { + this.enforceDisabledSeverities(); + } + this.notify(); return true; } else { @@ -153,6 +159,37 @@ export default class LogFilter extends Observable { TEXT_FILTER_OPERATORS.some((operator) => criteria[operator]?.trim())); } + /** + * Check whether a severity is disabled for the current log level. + * @param {string} severityCode - [D, I, W, E, F] + * @returns {boolean} true if the severity is not allowed at the current level + */ + isSeverityDisabled(severityCode) { + return getDisabledSeverities(this.criterias.level.max).includes(severityCode); + } + + /** + * Remove any active severity selections that are disallowed by the current level. + */ + enforceDisabledSeverities() { + const disabled = getDisabledSeverities(this.criterias.level.max); + if (disabled.length === 0 || !this.criterias.severity.$in) { + return; + } + + const current = this.criterias.severity.$in; + if (!current) { + return; + } + + const filteredSeverities = current.filter((s) => !disabled.includes(s)); + // Only update if there is a change + if (filteredSeverities.length !== current.length) { + this.criterias.severity.$in = filteredSeverities; + this.criterias.severity.in = filteredSeverities.join(' '); + } + } + /** * Generates a function to filter a log passed as argument to it * Output of function is boolean. @@ -389,11 +426,11 @@ export default class LogFilter extends Observable { }, severity: { in: 'I W E F', - $in: ['W', 'I', 'E', 'F'], + $in: ['I', 'W', 'E', 'F'], }, level: { - max: null, // 0, 1, 6, 11, 21 - $max: null, // 0, 1, 6, 11, 21 + max: 1, + $max: 1, }, }; this.notify(); diff --git a/InfoLogger/public/logFilter/commandFilters.js b/InfoLogger/public/logFilter/commandFilters.js index 6e3066819..210563363 100644 --- a/InfoLogger/public/logFilter/commandFilters.js +++ b/InfoLogger/public/logFilter/commandFilters.js @@ -59,14 +59,18 @@ export default (model) => [ * @param {string} value - a char to represent severity: W E F or I, can be many with spaces like 'W E' * @returns {vnode} - the button to toggle severity */ -const buttonSeverity = (model, label, title, value) => h('button.btn', { - className: model.log.filter.criterias.severity.in.includes(value) ? 'active' : '', - onclick: (e) => { - model.log.setCriteria('severity', 'in', value); - e.target.blur(); // remove focus so user can 'enter' without actually toggle again the button - }, - title: title, -}, label); +const buttonSeverity = (model, label, title, value) => { + const disabled = model.log.filter.isSeverityDisabled(value); + return h('button.btn', { + className: disabled ? 'disabled' : model.log.filter.criterias.severity.in.includes(value) ? 'active' : '', + onclick: disabled ? null : (e) => { + model.log.setCriteria('severity', 'in', value); + e.target.blur(); + }, + disabled, + title: disabled ? `${label} is not available at the current log level` : title, + }, label); +}; /** * Makes a button to set filtering level (shifter, debug, etc) with number diff --git a/InfoLogger/test/lib/mocha-profile-service.js b/InfoLogger/test/lib/mocha-profile-service.js index d2776e994..cecffae2c 100644 --- a/InfoLogger/test/lib/mocha-profile-service.js +++ b/InfoLogger/test/lib/mocha-profile-service.js @@ -94,7 +94,7 @@ const DEFAULT_PROFILE = { errsource: { match: '', exclude: '' }, message: { match: '', exclude: '' }, severity: { in: 'I W E F' }, - level: { max: null }, + level: { max: 1 }, }, }, }; @@ -135,7 +135,7 @@ const FULL_PROFILE = { errsource: { match: '', exclude: '' }, message: { match: '', exclude: '' }, severity: { in: 'I W E F' }, - level: { max: null }, + level: { max: 1 }, }, }, }; diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index d277067fb..3ec3929bb 100644 --- a/InfoLogger/test/mocha-index.js +++ b/InfoLogger/test/mocha-index.js @@ -93,12 +93,12 @@ describe('InfoLogger', function() { } }); - it('should have redirected to default page "/?q={"severity":{"in":"I W E F"}}"', async function() { + it('should have redirected to default page "/?q={"severity":{"in":"I W E F"},"level":{"max":1}}"', async function() { await page.goto(baseUrl, {waitUntil: 'networkidle0'}); const location = await page.evaluate(() => window.location); const search = decodeURIComponent(location.search); - assert.deepStrictEqual(search, '?q={"severity":{"in":"I W E F"}}'); + assert.deepStrictEqual(search, '?q={"severity":{"in":"I W E F"},"level":{"max":1}}'); }); require('./public/user-actions-mocha'); diff --git a/InfoLogger/test/public/live-mode-mocha.js b/InfoLogger/test/public/live-mode-mocha.js index 0f3ae17ef..0b7cef97e 100644 --- a/InfoLogger/test/public/live-mode-mocha.js +++ b/InfoLogger/test/public/live-mode-mocha.js @@ -27,7 +27,7 @@ describe('Live Mode test-suite', async () => { const location = await page.evaluate(() => window.location); const search = decodeURIComponent(location.search); - assert.deepStrictEqual(search, '?q={"severity":{"in":"I W E F"}}'); + assert.deepStrictEqual(search, '?q={"severity":{"in":"I W E F"},"level":{"max":1}}'); }); it('should successfully enable LIVE mode', async () => { @@ -60,6 +60,7 @@ describe('Live Mode test-suite', async () => { await page.evaluate(() => window.model.log.liveStop('Paused')); await page.evaluate(() => { window.model.log.filter.resetCriteria(); + window.model.log.filter.setCriteria('level', 'max', null); window.model.log.filter.setCriteria('hostname', 'match', 'aldaqecs01-v1'); }); await page.evaluate(() => window.model.log.liveStart()); @@ -76,6 +77,7 @@ describe('Live Mode test-suite', async () => { await page.evaluate(() => window.model.log.liveStop('Query')); await page.evaluate(() => { window.model.log.filter.resetCriteria(); + window.model.log.filter.setCriteria('level', 'max', null); window.model.log.filter.setCriteria('hostname', 'exclude', 'aldaqdip01'); }); await page.evaluate(() => window.model.log.liveStart()); @@ -90,7 +92,10 @@ describe('Live Mode test-suite', async () => { it('should filter messages based on SQL Wildcards `hostname` excluding `%ldaqdip%` and username matching `a_iceda_`' + ' without changing state of live mode', async () => { - await page.evaluate(() => window.model.log.filter.resetCriteria()); + await page.evaluate(() => { + window.model.log.filter.resetCriteria(); + window.model.log.filter.setCriteria('level', 'max', null); + }); await page.evaluate(() => { window.model.log.setCriteria('hostname', 'exclude', '%ldaqdip%'); window.model.log.setCriteria('username', 'match', 'a_iceda_'); diff --git a/InfoLogger/test/public/log-context-menu-mocha.js b/InfoLogger/test/public/log-context-menu-mocha.js index 522f6cf3b..5a5b37b25 100644 --- a/InfoLogger/test/public/log-context-menu-mocha.js +++ b/InfoLogger/test/public/log-context-menu-mocha.js @@ -276,7 +276,7 @@ describe('Cell Context Menu', async () => { it('should show correct actions for level field', async () => { await openContextMenu(page, 'level', '3', 100, 120); const labels = await getMenuActionLabels(page); - assert.deepStrictEqual(labels, ['Set Level To Support', 'Set Level To Ops', 'Clear Level Filter', 'Copy', 'Open Inspector']); + assert.deepStrictEqual(labels, ['Set Level To Support', 'Set Level To Ops', 'Reset Level Filter', 'Copy', 'Open Inspector']); }); }); @@ -561,7 +561,7 @@ describe('Cell Context Menu', async () => { }); }); - describe('Set/Clear level filter for level field', async () => { + describe('Set/Reset Level Filter for level field', async () => { it('should set level to nearest threshold above via include', async () => { await openContextMenu(page, 'level', '3', 100, 120); @@ -586,34 +586,38 @@ describe('Cell Context Menu', async () => { assert.strictEqual(level.$max, 1); }); - it('should disable "Clear Level Filter" when no level filter is set', async () => { + it('should disable "Reset Level Filter" when level is already cleared', async () => { + await page.evaluate(() => { + window.model.log.filter.setCriteria('level', 'max', 1); + }); + await openContextMenu(page, 'level', '3', 100, 120); - assert.strictEqual(await isMenuItemDisabled(page, 'Clear Level Filter'), true); + assert.strictEqual(await isMenuItemDisabled(page, 'Reset Level Filter'), true); }); - it('should enable "Clear Level Filter" when a level filter is active', async () => { + it('should enable "Reset Level Filter" when a level filter is active', async () => { await page.evaluate(() => { window.model.log.filter.setCriteria('level', 'max', 6); }); await openContextMenu(page, 'level', '3', 100, 120); - assert.strictEqual(await isMenuItemDisabled(page, 'Clear Level Filter'), false); + assert.strictEqual(await isMenuItemDisabled(page, 'Reset Level Filter'), false); }); - it('should clear level filter back to null', async () => { + it('should reset level filter back to default', async () => { await page.evaluate(() => { window.model.log.filter.setCriteria('level', 'max', 6); }); await openContextMenu(page, 'level', '3', 100, 120); - await clickMenuItemByLabel(page, 'Clear Level Filter'); + await clickMenuItemByLabel(page, 'Reset Level Filter'); const level = await page.evaluate(() => window.model.log.filter.criterias.level); - assert.strictEqual(level.max, null); - assert.strictEqual(level.$max, null); + assert.strictEqual(level.max, 1); + assert.strictEqual(level.$max, 1); }); }); diff --git a/InfoLogger/test/public/log-filter-actions-mocha.js b/InfoLogger/test/public/log-filter-actions-mocha.js index 904b53ad0..21eb8a098 100644 --- a/InfoLogger/test/public/log-filter-actions-mocha.js +++ b/InfoLogger/test/public/log-filter-actions-mocha.js @@ -25,13 +25,14 @@ describe('Filter actions test-suite', async () => { page = test.page; }); + // "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'}); const location = await page.evaluate(() => window.location); const search = decodeURIComponent(location.search); // for now, check if redirected to default page - assert.strictEqual(search, '?q={"severity":{"in":"I W E F"}}'); + assert.strictEqual(search, '?q={"severity":{"in":"I W E F"},"level":{"max":1}}'); }); it('should update column headers based on profile when passed in the URI', async () => { @@ -62,7 +63,7 @@ describe('Filter actions test-suite', async () => { 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 - const expectedParams = '?q={%22severity%22:{%22in%22:%22I%20W%20E%20F%22}}'; + 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'}; @@ -80,7 +81,7 @@ describe('Filter actions test-suite', async () => { 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'`); - const expectedParams = '?q={%22severity%22:{%22in%22:%22I%20W%20E%20F%22}}'; + 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"}}'}; window.model.parseLocation(params); @@ -94,7 +95,7 @@ describe('Filter actions test-suite', async () => { }); it('should redirect to default filters and show JSON parse error on malformed q in URI', async () => { - const expectedDefaultParams = '?q={"severity":{"in":"I W E F"}}'; + const expectedDefaultParams = '?q={"severity":{"in":"I W E F"},"level":{"max":1}}'; const locationAndNotification = await page.evaluate(() => { const params = { q: '{"severity":{"in":"W I E F"' }; @@ -115,8 +116,8 @@ describe('Filter actions test-suite', async () => { 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 decodedParams = '?q={"hostname":{"match":"\\"%ald_qdip01%"},"severity":{"in":"I W E F"},"level":{"max":1}}'; + const expectedParams = '?q={%22hostname%22:{%22match%22:%22%5C%22%25ald_qdip01%25%22},%22severity%22:{%22in%22:%22I%20W%20E%20F%22},%22level%22:{%22max%22:1}}'; const searchParams = await page.evaluate(() => { window.model.log.filter.setCriteria('hostname', 'match', '"%ald_qdip01%'); window.model.updateRouteOnModelChange(); @@ -129,8 +130,8 @@ 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 decodedParams = '?q={"hostname":{"exclude":"\\"%ald_qdip01%"},"severity":{"in":"I W E F"},"level":{"max":1}}'; + const expectedParams = '?q={%22hostname%22:{%22exclude%22:%22%5C%22%25ald_qdip01%25%22},%22severity%22:{%22in%22:%22I%20W%20E%20F%22},%22level%22:{%22max%22:1}}'; const searchParams = await page.evaluate(() => { window.model.log.filter.resetCriteria(); window.model.log.filter.setCriteria('hostname', 'exclude', '"%ald_qdip01%'); @@ -222,6 +223,91 @@ describe('Filter actions test-suite', async () => { assert.strictEqual(criterias.timestamp.since, ''); assert.strictEqual(criterias.timestamp.$since, null); assert.strictEqual(criterias.severity.in, 'I W E F'); - assert.deepStrictEqual(criterias.severity.$in, ['W', 'I', 'E', 'F']); + assert.deepStrictEqual(criterias.severity.$in, ['I', 'W', 'E', 'F']); + }); + + describe('Severity filter disabled states', async () => { + it('should report DEBUG severity as disabled at OPS level', async () => { + const disabled = await page.evaluate(() => { + window.model.log.filter.setCriteria('level', 'max', 1); + return window.model.log.filter.isSeverityDisabled('D'); + }); + + assert.strictEqual(disabled, true); + }); + + it('should report DEBUG severity as enabled when level allows it', async () => { + const disabled = await page.evaluate(() => { + window.model.log.filter.setCriteria('level', 'max', 6); + return window.model.log.filter.isSeverityDisabled('D'); + }); + + assert.strictEqual(disabled, false); + }); + + it('should strip DEBUG from severity filter when switching to OPS', async () => { + const severity = await page.evaluate(() => { + window.model.log.filter.setCriteria('level', 'max', 11); + window.model.log.filter.setCriteria('severity', 'in', 'I W E F D'); + window.model.log.filter.setCriteria('level', 'max', 1); + return { + in: window.model.log.filter.criterias.severity.in, + $in: window.model.log.filter.criterias.severity.$in, + }; + }); + + assert.ok(!severity.$in.includes('D')); + assert.ok(!severity.in.includes('D')); + }); + + it('should strip DEBUG from URL when severity is set before level', async () => { + const severity = await page.evaluate(() => { + window.model.log.filter.fromObject({ severity: { in: 'I W E F D' }, level: { max: 1 } }); + return { + in: window.model.log.filter.criterias.severity.in, + $in: window.model.log.filter.criterias.severity.$in, + }; + }); + + assert.ok(!severity.$in.includes('D')); + assert.ok(!severity.in.includes('D')); + }); + + it('should strip DEBUG from URL when level is set before severity', async () => { + const severity = await page.evaluate(() => { + window.model.log.filter.fromObject({ level: { max: 1 }, severity: { in: 'I W E F D' } }); + return { + in: window.model.log.filter.criterias.severity.in, + $in: window.model.log.filter.criterias.severity.$in, + }; + }); + + assert.ok(!severity.$in.includes('D')); + assert.ok(!severity.in.includes('D')); + }); + + it('should disable DEBUG button at OPS level', async () => { + await page.evaluate(() => { + window.model.log.filter.setCriteria('level', 'max', 1); + }); + + await page.waitForFunction(() => { + const buttons = Array.from(document.querySelectorAll('.btn-group button.btn')); + const debugBtn = buttons.find((b) => b.textContent.trim() === 'Debug'); + return debugBtn?.classList.contains('disabled'); + }); + }); + + it('should enable DEBUG button when level is not OPS', async () => { + await page.evaluate(() => { + window.model.log.filter.setCriteria('level', 'max', 11); + }); + + await page.waitForFunction(() => { + const buttons = Array.from(document.querySelectorAll('.btn-group button.btn')); + const debugBtn = buttons.find((b) => b.textContent.trim() === 'Debug'); + return !debugBtn?.classList.contains('disabled'); + }); + }); }); });