From 3f14a80965f37dfb00ce0d64cd5405987b4228c3 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Wed, 6 May 2026 17:06:36 +0200 Subject: [PATCH 01/11] Refactor filter button components into reusable --- InfoLogger/public/Model.js | 16 ++ InfoLogger/public/logFilter/commandFilters.js | 142 +++++++++--------- 2 files changed, 87 insertions(+), 71 deletions(-) diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index 7dcd8d802..faab7798d 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -81,6 +81,22 @@ export default class Model extends Observable { // Model can change very often we protect router with callRateLimiter // Router limit: 100 calls per 30 seconds max = 30ms, 2 FPS is enough (500ms) this.observe(callRateLimiter(this.updateRouteOnModelChange.bind(this), 500)); + + this._filterLevelsAllowed = [ + { label: 'Ops', index: 1, available: true }, + { label: 'Support', index: 6, available: false }, + { label: 'Devel', index: 11, available: false }, + { label: 'Trace', index: null, available: false }, + ]; + } + + /** + * Method to return filter levels allowed for filtering based on current user role email groups afilliation + * * Shifters are only allowed to filter by Ops level + * @returns {{label: string, index:number}[]} - filter levels allowed for filtering + */ + get filterLevelsAllowed() { + return this._filterLevelsAllowed; } /** diff --git a/InfoLogger/public/logFilter/commandFilters.js b/InfoLogger/public/logFilter/commandFilters.js index 6e3066819..58a91738d 100644 --- a/InfoLogger/public/logFilter/commandFilters.js +++ b/InfoLogger/public/logFilter/commandFilters.js @@ -14,92 +14,92 @@ import { h } from '/js/src/index.js'; +const LIMIT_LEVELS = [ + { label: '100k', value: 100000 }, + { label: '500k', value: 500000 }, + { label: '1M', value: 1000000 }, +]; +const SEVERITIES_ALLOWED = [ + { label: 'Debug', value: 'D' }, + { label: 'Info', value: 'I' }, + { label: 'Warn', value: 'W' }, + { label: 'Error', value: 'E' }, + { label: 'Fatal', value: 'F' }, +]; + /** * Filtering main options, in toolbar, top-right. * - severity * - level * - limit * - reset - * @param {Model} model - root model of the application + * @param {Log} logModel - log model of the application + * @param {{label: string, index:number}[]} filterLevelsAllowed - levels allowed for filtering * @returns {vnode} - the view of filters panel */ -export default (model) => [ +export default (logModel, filterLevelsAllowed) => [ 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), + '.btn-group', + SEVERITIES_ALLOWED.map(({ label, value }) => _selectableButtonComponent( + label, + { + title: `Match severity ${label.toLowerCase()}`, + isActive: logModel.filter.criterias.severity.in.includes(value), + onclick: () => logModel.setCriteria('severity', 'in', value), + }, + )), ), -]; -/** - * Makes a button to toggle severity - * @param {Model} model - root model of the application - * @param {string} label - button's label - * @param {string} title - button's title on mouse over - * @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); + h( + '.btn-group', + filterLevelsAllowed.map(({ label, index, available }) => _selectableButtonComponent( + label, + { + title: available ? `Filter level ≤ ${index}` : `You don't have access to level ${label}`, + isActive: logModel.filter.criterias.level.max === index, + onclick: () => logModel.setCriteria('level', 'max', index), + disabled: !available, + }, + )), + ), + + h( + '.btn-group', + LIMIT_LEVELS.map(({ label, value }) => + _selectableButtonComponent( + label, + { + title: `Keep only ${value / 1000}k logs in the view`, + isActive: logModel.limit === value, + onclick: () => logModel.setLimit(value), + }, + )), + ), + + _selectableButtonComponent( + 'Reset filters', + { + title: 'Reset date, time, matches, excludes, log levels', + isActive: false, + onclick: () => logModel.filter.resetCriteria(), + }, + ), +]; /** - * Makes a button to set filtering level (shifter, debug, etc) with number - * @param {Model} model - root model of the application + * Component representing the creation of a button for filtering header * @param {string} label - button's label - * @param {number} value - maximum level of filtering, from 1 to 21 + * @param {object} options - options for the button + * @param {string} options.title - button's title on mouse over + * @param {boolean} options.isActive - whether the button is active + * @param {void} options.onclick - function to call when button is clicked + * @param {boolean} options.disabled - whether the button is disabled * @returns {vnode} - component representing the creation of a button for filtering */ -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 _selectableButtonComponent = (label, { title, isActive, onclick, disabled }) => h('button.btn', { + className: [isActive ? 'active' : '', disabled ? 'disabled' : ''].join(' '), + onclick, + title, + disabled, -/** - * 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 - */ -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); - -/** - * Makes a button to reset filters - * @param {Model} model - root model of the application - * @returns {vnode} - component representing the creation of a button to reset filters - */ -const buttonReset = (model) => h('button.btn', { - onclick: () => model.log.filter.resetCriteria(), - title: 'Reset date, time, matches, excludes, log levels', -}, 'Reset filters'); From cd8c76d835afd940351220a60041203a7c4d2674 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Wed, 6 May 2026 17:07:02 +0200 Subject: [PATCH 02/11] Provide filters from model instead of hardcoded Co-authored-by: Copilot --- InfoLogger/public/view.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/InfoLogger/public/view.js b/InfoLogger/public/view.js index 8f744afce..18cb55d23 100644 --- a/InfoLogger/public/view.js +++ b/InfoLogger/public/view.js @@ -34,14 +34,12 @@ export default (model) => [ notification(model.notification), h('.flex-column absolute-fill', [ h('.shadow-level2', [ - h('header.p1.flex-row.f7', [ - h('', commandLogs(model)), + h('header.p1.flex-wrap.flex-row.f7.g2', [ + h('.flex-row', commandLogs(model)), h( - '.flex-grow', - { - style: 'display: flex; flex-direction:row-reverse;', - }, - commandFilters(model), + '.flex-row.g3', + { style: 'margin-left: auto;' }, + commandFilters(model.log, model.filterLevelsAllowed), ), ]), h('header.f7', tableFilters(model)), From fdfab139cfda9fae8c44a6bd22a430eed63338ae Mon Sep 17 00:00:00 2001 From: George Raduta Date: Wed, 6 May 2026 17:47:45 +0200 Subject: [PATCH 03/11] Add util function for checking if user has shifter but no admin role --- InfoLogger/public/common/utils.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/InfoLogger/public/common/utils.js b/InfoLogger/public/common/utils.js index 9c5d16168..7a37d4bf0 100644 --- a/InfoLogger/public/common/utils.js +++ b/InfoLogger/public/common/utils.js @@ -12,6 +12,8 @@ * or submit itself to any jurisdiction. */ +import { Role } from './../constants/role.const.js'; + /** * Limit the number of calls to `fn` to 1 per `time` maximum. * First call is immediate if `time` have been waited already. @@ -60,3 +62,12 @@ export function setBrowserTabTitle(title = undefined) { document.title = title; } } + +/** + * Method to check if the user has only shifter role and not admin role + * @param {string[]} access - array of user roles email groups affiliation + * @returns {boolean} true if the user has only shifter role and not admin role, false otherwise + */ +export function hasShifterButNoAdminRole(access = []) { + return access.includes(Role.SHIFTER) && !access.includes(Role.ADMIN); +} From 28efc397be6711ad7a1dfb4e125e72487a88995d Mon Sep 17 00:00:00 2001 From: George Raduta Date: Wed, 6 May 2026 17:59:39 +0200 Subject: [PATCH 04/11] On URL load, ensure shifters use only OPS level Co-authored-by: Copilot --- InfoLogger/public/Model.js | 21 +++------- InfoLogger/public/common/utils.js | 21 +++++++++- .../constants/infologger-level.const.js | 40 +++++++++++++++++++ InfoLogger/public/constants/role.const.js | 18 +++++++++ 4 files changed, 83 insertions(+), 17 deletions(-) create mode 100644 InfoLogger/public/constants/infologger-level.const.js create mode 100644 InfoLogger/public/constants/role.const.js diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index faab7798d..f651cd8c8 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, setBrowserTabTitle, hasShifterButNoAdminRole } from './common/utils.js'; import { ConfigurationService } from './services/ConfigurationService.js'; import { MODE } from './constants/mode.const.js'; import Log from './log/Log.js'; @@ -81,23 +81,8 @@ export default class Model extends Observable { // Model can change very often we protect router with callRateLimiter // Router limit: 100 calls per 30 seconds max = 30ms, 2 FPS is enough (500ms) this.observe(callRateLimiter(this.updateRouteOnModelChange.bind(this), 500)); - - this._filterLevelsAllowed = [ - { label: 'Ops', index: 1, available: true }, - { label: 'Support', index: 6, available: false }, - { label: 'Devel', index: 11, available: false }, - { label: 'Trace', index: null, available: false }, - ]; } - /** - * Method to return filter levels allowed for filtering based on current user role email groups afilliation - * * Shifters are only allowed to filter by Ops level - * @returns {{label: string, index:number}[]} - filter levels allowed for filtering - */ - get filterLevelsAllowed() { - return this._filterLevelsAllowed; - } /** * Handle websocket authentication success @@ -327,12 +312,16 @@ export default class Model extends Observable { /** * Delegates sub-model actions depending new location of the page + * If user is shifter but not admin, set Ops as maximum level for filtering */ handleLocationChange() { const { params } = this.router; if (params) { this.parseLocation(params); } + if (hasShifterButNoAdminRole(this.session.access)) { + this.log.filter.setCriteria('level', 'max', 1); + } } /** diff --git a/InfoLogger/public/common/utils.js b/InfoLogger/public/common/utils.js index 7a37d4bf0..93e9edba9 100644 --- a/InfoLogger/public/common/utils.js +++ b/InfoLogger/public/common/utils.js @@ -13,6 +13,7 @@ */ import { Role } from './../constants/role.const.js'; +import { INFOLOGGER_LEVEL_LIST } from './../constants/infologger-level.const.js'; /** * Limit the number of calls to `fn` to 1 per `time` maximum. @@ -69,5 +70,23 @@ export function setBrowserTabTitle(title = undefined) { * @returns {boolean} true if the user has only shifter role and not admin role, false otherwise */ export function hasShifterButNoAdminRole(access = []) { - return access.includes(Role.SHIFTER) && !access.includes(Role.ADMIN); + return !access.includes(Role.SHIFTER) && access.includes(Role.ADMIN); +} + +/** + * Method to return filter levels allowed for filtering based on current user role email groups affiliation + * * Shifters are only allowed to filter by Ops level + * @param {string[]} access - array of user roles email groups affiliation + * @returns {{label: string, index:number}[]} - filter levels allowed for filtering + */ +export function filterLevelsAllowed(access = []) { + return hasShifterButNoAdminRole(access) + ? INFOLOGGER_LEVEL_LIST.map((level) => ({ + ...level, + available: level.label === 'Ops', + })) + : INFOLOGGER_LEVEL_LIST.map((level) => ({ + ...level, + available: true, + })); } diff --git a/InfoLogger/public/constants/infologger-level.const.js b/InfoLogger/public/constants/infologger-level.const.js new file mode 100644 index 000000000..7829fee92 --- /dev/null +++ b/InfoLogger/public/constants/infologger-level.const.js @@ -0,0 +1,40 @@ +/** + * @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. + */ + +/** + * Object containing the different levels of logs that can be displayed in the application, + * with their label and index as in the database + * These values are as per InfoLogger defined levels: + * {@link https://github.com/AliceO2Group/InfoLogger/blob/master/doc/README.md} + */ +export const InfoLoggerLevel = { + OPS: { + label: 'Ops', + index: 1, + }, + SUPPORT: { + label: 'Support', + index: 6, + }, + DEVEL: { + label: 'Devel', + index: 11, + }, + TRACE: { + label: 'Trace', + index: null, + }, +}; + +export const INFOLOGGER_LEVEL_LIST = Object.values(InfoLoggerLevel); diff --git a/InfoLogger/public/constants/role.const.js b/InfoLogger/public/constants/role.const.js new file mode 100644 index 000000000..44aa6785b --- /dev/null +++ b/InfoLogger/public/constants/role.const.js @@ -0,0 +1,18 @@ +/** + * @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. + */ + +export const Role = { + SHIFTER: 'shifter', + ADMIN: 'admin', +}; From caa7eecbcd504195c0503369a77f27a605d30433 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Wed, 6 May 2026 18:01:27 +0200 Subject: [PATCH 05/11] Fix eslint warnings in changed files --- InfoLogger/public/Model.js | 1 - InfoLogger/public/common/utils.js | 2 +- InfoLogger/public/logFilter/LogFilter.js | 5 +++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index f651cd8c8..22c6d8f0c 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -83,7 +83,6 @@ export default class Model extends Observable { this.observe(callRateLimiter(this.updateRouteOnModelChange.bind(this), 500)); } - /** * Handle websocket authentication success */ diff --git a/InfoLogger/public/common/utils.js b/InfoLogger/public/common/utils.js index 93e9edba9..dd5626de7 100644 --- a/InfoLogger/public/common/utils.js +++ b/InfoLogger/public/common/utils.js @@ -21,7 +21,7 @@ import { INFOLOGGER_LEVEL_LIST } from './../constants/infologger-level.const.js' * All other calls before end of `time` window will lead to 1 exececution at the end of window. * @param {string} fn - function to be called * @param {string} time - ms - * @returns {Function} lambda function to be called to call `fn` + * @returns {void} lambda function to be called to call `fn` * @example * let f = callRateLimiter((arg) => console.log('called', arg), 1000); * 00:00:00 f(1);f(2);f(3);f(4); diff --git a/InfoLogger/public/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index 502b10def..f9de7ef47 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -22,7 +22,8 @@ import { Observable } from '/js/src/index.js'; */ /** - * @typedef Criteria * @type {Array.} + * @typedef Criteria + * @type {Array.} */ /** @@ -145,7 +146,7 @@ export default class LogFilter extends Observable { /** * Generates a function to filter a log passed as argument to it * Output of function is boolean. - * @returns {Function.} - function to filter logs + * @returns {void.} - function to filter logs */ toStringifyFunction() { /** From 9031c35e9b585043344c093145e51c494b3dc54e Mon Sep 17 00:00:00 2001 From: George Raduta Date: Thu, 7 May 2026 09:29:21 +0200 Subject: [PATCH 06/11] Extract access util function --- InfoLogger/public/common/utils.js | 4 ++-- InfoLogger/public/view.js | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/InfoLogger/public/common/utils.js b/InfoLogger/public/common/utils.js index dd5626de7..4466c0e19 100644 --- a/InfoLogger/public/common/utils.js +++ b/InfoLogger/public/common/utils.js @@ -70,7 +70,7 @@ export function setBrowserTabTitle(title = undefined) { * @returns {boolean} true if the user has only shifter role and not admin role, false otherwise */ export function hasShifterButNoAdminRole(access = []) { - return !access.includes(Role.SHIFTER) && access.includes(Role.ADMIN); + return access.includes(Role.SHIFTER) && !access.includes(Role.ADMIN); } /** @@ -79,7 +79,7 @@ export function hasShifterButNoAdminRole(access = []) { * @param {string[]} access - array of user roles email groups affiliation * @returns {{label: string, index:number}[]} - filter levels allowed for filtering */ -export function filterLevelsAllowed(access = []) { +export function getFilterLevelsAllowed(access = []) { return hasShifterButNoAdminRole(access) ? INFOLOGGER_LEVEL_LIST.map((level) => ({ ...level, diff --git a/InfoLogger/public/view.js b/InfoLogger/public/view.js index 18cb55d23..e7a00f844 100644 --- a/InfoLogger/public/view.js +++ b/InfoLogger/public/view.js @@ -24,6 +24,7 @@ import tableLogsContent from './log/tableLogsContent.js'; import tableLogsScrollMap from './log/tableLogsScrollMap.js'; import aboutComponent from './about/about.component.js'; import errorComponent from './common/errorComponent.js'; +import { getFilterLevelsAllowed } from './common/utils.js'; /** * Main view of the application @@ -39,7 +40,7 @@ export default (model) => [ h( '.flex-row.g3', { style: 'margin-left: auto;' }, - commandFilters(model.log, model.filterLevelsAllowed), + commandFilters(model.log, getFilterLevelsAllowed(model.session.access)), ), ]), h('header.f7', tableFilters(model)), From 78c22c0eae45cbc7d9945c80c9295943b0bc6c5f Mon Sep 17 00:00:00 2001 From: George Raduta Date: Thu, 7 May 2026 09:29:32 +0200 Subject: [PATCH 07/11] Add tests for shifter based roles Co-authored-by: Copilot --- InfoLogger/public/logFilter/commandFilters.js | 7 +- .../public/shifter-based-actions-mocha.js | 80 +++++++++++++++++++ InfoLogger/test/test-utils.js | 66 +++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 InfoLogger/test/public/shifter-based-actions-mocha.js create mode 100644 InfoLogger/test/test-utils.js diff --git a/InfoLogger/public/logFilter/commandFilters.js b/InfoLogger/public/logFilter/commandFilters.js index 58a91738d..2f410bda6 100644 --- a/InfoLogger/public/logFilter/commandFilters.js +++ b/InfoLogger/public/logFilter/commandFilters.js @@ -43,6 +43,7 @@ export default (logModel, filterLevelsAllowed) => [ SEVERITIES_ALLOWED.map(({ label, value }) => _selectableButtonComponent( label, { + id: `severity-${value}`, title: `Match severity ${label.toLowerCase()}`, isActive: logModel.filter.criterias.severity.in.includes(value), onclick: () => logModel.setCriteria('severity', 'in', value), @@ -55,6 +56,7 @@ export default (logModel, filterLevelsAllowed) => [ filterLevelsAllowed.map(({ label, index, available }) => _selectableButtonComponent( label, { + id: `level-${index}`, title: available ? `Filter level ≤ ${index}` : `You don't have access to level ${label}`, isActive: logModel.filter.criterias.level.max === index, onclick: () => logModel.setCriteria('level', 'max', index), @@ -69,6 +71,7 @@ export default (logModel, filterLevelsAllowed) => [ _selectableButtonComponent( label, { + id: `limit-${value}`, title: `Keep only ${value / 1000}k logs in the view`, isActive: logModel.limit === value, onclick: () => logModel.setLimit(value), @@ -90,13 +93,15 @@ export default (logModel, filterLevelsAllowed) => [ * Component representing the creation of a button for filtering header * @param {string} label - button's label * @param {object} options - options for the button + * @param {string} options.id - button's id * @param {string} options.title - button's title on mouse over * @param {boolean} options.isActive - whether the button is active * @param {void} options.onclick - function to call when button is clicked * @param {boolean} options.disabled - whether the button is disabled * @returns {vnode} - component representing the creation of a button for filtering */ -const _selectableButtonComponent = (label, { title, isActive, onclick, disabled }) => h('button.btn', { +const _selectableButtonComponent = (label, { id, title, isActive, onclick, disabled }) => h('button.btn', { + id, className: [isActive ? 'active' : '', disabled ? 'disabled' : ''].join(' '), onclick, title, diff --git a/InfoLogger/test/public/shifter-based-actions-mocha.js b/InfoLogger/test/public/shifter-based-actions-mocha.js new file mode 100644 index 000000000..847e349ce --- /dev/null +++ b/InfoLogger/test/public/shifter-based-actions-mocha.js @@ -0,0 +1,80 @@ +/** + * @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. + */ + +const assert = require('assert'); +const test = require('../mocha-index'); +const { INFOLOGGER_LEVEL_LIST } = require('../../public/constants/infologger-level.const'); +const { baseUrl, getShifterAuthQueryParams } = require('../test-utils.js'); + +describe('Shifter Based Actions Test Suite', async () => { + // eslint-disable-next-line init-declarations + let page; + const shifterQueryParams = getShifterAuthQueryParams(); + + before(() => { + ({ page } = test); + }); + + it('should successfully load a page with shifter access token and level set to 1', async () => { + await page.goto(`${baseUrl}?${shifterQueryParams}`, { waitUntil: 'networkidle0' }); + + const location = await page.evaluate(() => window.location); + const search = decodeURIComponent(location.search); + assert.strictEqual(search, '?q={"severity":{"in":"I W E F"},"level":{"max":1}}'); + + const criterias = await page.evaluate(() => window.model.log.filter.criterias); + assert.strictEqual(criterias.level.max, 1); + }); + + it( + 'should successfully load page with shifter role and level 1 even if query parameter contains a higher level', + async () => { + await page.goto( + `${baseUrl}?${shifterQueryParams}&q={"level":{"max":6}}`, + { waitUntil: 'networkidle0' }, + ); + + const location = await page.evaluate(() => window.location); + const search = decodeURIComponent(location.search); + assert.strictEqual(search, '?q={"severity":{"in":"I W E F"},"level":{"max":1}}'); + + const criterias = await page.evaluate(() => window.model.log.filter.criterias); + assert.strictEqual(criterias.level.max, 1); + }, + ); + + it('should disable buttons for level filter if user is shifter but not admin', async () => { + const currentAccess = await page.evaluate(() => window.model.session.access); + + await page.evaluate(() => { + window.model.session.access = ['shifter']; + window.model.notify(); + }); + + for (const { label, index } of INFOLOGGER_LEVEL_LIST) { + const isLevelDisabled = await page.$eval(`#level-${index}`, (button) => button.classList.contains('disabled')); + assert.strictEqual( + isLevelDisabled, + label === 'Ops' ? false : true, + `Level ${label} should be ${label === 'Ops' ? 'enabled' : 'disabled'} for shifter access`, + ); + } + + // Restore access rights for the following tests + await page.evaluate((access) => { + window.model.session.access = access; + window.model.notify(); + }, currentAccess); + }); +}); diff --git a/InfoLogger/test/test-utils.js b/InfoLogger/test/test-utils.js new file mode 100644 index 000000000..dbfb0ec84 --- /dev/null +++ b/InfoLogger/test/test-utils.js @@ -0,0 +1,66 @@ +/** + * @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. + */ + +const testConfig = require('./test-config.js'); +const { O2TokenService } = require('@aliceo2/web-ui'); + +/** + * Generates a JWT token for testing purposes with the given payload + * @param {object} payload - payload to include in the token + * @param {number} payload.personid - user's person id + * @param {string} payload.username - user's username + * @param {string} payload.name - user's name + * @param {string} payload.access - user's access rights + * @returns {string} - generated JWT token + */ +const generateToken = ({ personid, username, name, access }) => { + const tokenService = new O2TokenService(testConfig.jwt); + const token = tokenService.generateToken(personid, username, name, access); + return token; +}; + +/** + * Generates a JWT token for testing purposes with shifter access rights + * @returns {string} - generated JWT token with shifter access rights + */ +const generateShifterAccessToken = () => { + const payload = { + personid: 2, + username: 'testshifter', + name: 'Test Shifter', + access: 'shifter,guest', + }; + return generateToken(payload); +}; + +/** + * Generates query parameters for authentication with shifter access rights + * @returns {string} - query parameters for authentication with shifter access rights + */ +const getShifterAuthQueryParams = () => { + const shiftToken = generateShifterAccessToken(); + return `personid=2&name=Test Shifter&username=testshifter&access=shifter&token=${shiftToken}`; +}; + +/** + * Base URL for the application used in tests, constructed from the test configuration + * and can be used to navigate to the application in tests + * @returns {string} - base URL for the application + */ +const baseUrl = `http://${testConfig.http.hostname}:${testConfig.http.port}/`; + +module.exports = { + getShifterAuthQueryParams, + baseUrl, +}; From b6310693f0b648a1599d9ca2a4776378c5d9675c Mon Sep 17 00:00:00 2001 From: George Raduta Date: Thu, 7 May 2026 09:31:42 +0200 Subject: [PATCH 08/11] Add documentation for role-based --- InfoLogger/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/InfoLogger/README.md b/InfoLogger/README.md index 9fdf37be3..eab877439 100644 --- a/InfoLogger/README.md +++ b/InfoLogger/README.md @@ -5,8 +5,10 @@ - [InfoLogger GUI (ILG)](#infologger-gui-ilg) - [Interface User Guide](#interface-user-guide) + - [Shifter based role](#shifter-based-role) - [Requirements](#requirements) - [Installation](#installation) + - [Development database installation](#development-database-installation) - [Dummy InfoLogger test server](#dummy-infologger-test-server) - [InfoLogger insights](#infologger-insights) - [Continuous Integration Workflows](#continuous-integration-workflows) @@ -34,6 +36,10 @@ It interfaces with the system using two modes: - Use arrows keys to navigate quickly between logs - Download the logs in a file via the top left download icon +### Shifter based role + +If the authenticated user is defined as having one of the access roles 'shifter' but does **not** have 'admin', then the UI should restrict the levels by which the user can filter messages. More specifically, the shifters are only allowed to filter messages by `Ops` (Operations) + ## Requirements - `nodejs` >= `16.x` - InfoLogger MariaDB database for Query mode From 8c6e7d62e556804dec8729db3b9b269b27ba583d Mon Sep 17 00:00:00 2001 From: George Raduta Date: Thu, 7 May 2026 09:33:02 +0200 Subject: [PATCH 09/11] Include newly added test suite --- InfoLogger/test/mocha-index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index 051a18aa1..7c703dd03 100644 --- a/InfoLogger/test/mocha-index.js +++ b/InfoLogger/test/mocha-index.js @@ -101,6 +101,7 @@ describe('InfoLogger', function() { }); require('./public/user-actions-mocha'); + require('./public/shifter-based-actions-mocha'); require('./public/log-filter-actions-mocha'); require('./public/live-mode-mocha'); require('./public/query-mode-mocha'); From 6337802b52cdf0fc3d9a1822bb81e8f1fe166628 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Thu, 7 May 2026 09:34:17 +0200 Subject: [PATCH 10/11] Fix eslint in mocha-index --- InfoLogger/test/mocha-index.js | 39 +++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index 7c703dd03..40666763b 100644 --- a/InfoLogger/test/mocha-index.js +++ b/InfoLogger/test/mocha-index.js @@ -10,15 +10,17 @@ * 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 no-console */ +/* eslint-disable init-declarations */ + const puppeteer = require('puppeteer'); const assert = require('assert'); -const {spawn} = require('child_process'); +const { spawn } = require('child_process'); const config = require('./test-config.js'); -const {createServer, closeServer} = require('./live-simulator/infoLoggerServer.js'); +const { createServer, closeServer } = require('./live-simulator/infoLoggerServer.js'); // APIs: // https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md @@ -28,16 +30,16 @@ const {createServer, closeServer} = require('./live-simulator/infoLoggerServer.j // Network and rendering can have delays this can leads to random failures // if they are tested just after their initialization. -describe('InfoLogger', function() { +describe('InfoLogger', function () { let browser; let page; let subprocess; // web-server runs into a subprocess let subprocessOutput = ''; let ilgServer; - + this.timeout(30000); this.slow(1000); - + const baseUrl = `http://${config.http.hostname}:${config.http.port}/`; before(async () => { @@ -62,25 +64,30 @@ describe('InfoLogger', function() { ilgServer = createServer(); // Start web-server in background - subprocess = spawn('node', ['index.js', 'test/test-config.js'], {stdio: 'pipe'}); - subprocess.stdout.on('data', (chunk) => subprocessOutput += chunk.toString()); - subprocess.stderr.on('data', (chunk) => subprocessOutput += chunk.toString()); - subprocess.on('error', (error) => console.error(`Server failed due to: ${error}`)) + subprocess = spawn('node', ['index.js', 'test/test-config.js'], { stdio: 'pipe' }); + subprocess.stdout.on('data', (chunk) => { + subprocessOutput += chunk.toString(); + }); + subprocess.stderr.on('data', (chunk) => { + subprocessOutput += chunk.toString(); + }); + subprocess.on('error', (error) => console.error(`Server failed due to: ${error}`)); // Start browser to test UI - browser = await puppeteer.launch({headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox']}); + browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] }); page = await browser.newPage(); // Export page and configurations for the other mocha files exports.page = page; - exports.helpers = {baseUrl, jwt: config.jwt}; + + exports.helpers = { baseUrl, jwt: config.jwt }; }); it('should load first page "/"', async () => { // try many times until backend server is ready for (let i = 0; i < 10; i++) { try { - await page.goto(baseUrl, {waitUntil: 'networkidle0'}); + await page.goto(baseUrl, { waitUntil: 'networkidle0' }); break; // connection ok, this test passed } catch (e) { if (e.message.includes('net::ERR_CONNECTION_REFUSED')) { @@ -92,8 +99,8 @@ describe('InfoLogger', function() { } }); - it('should have redirected to default page "/?q={"severity":{"in":"I W E F"}}"', async function() { - await page.goto(baseUrl, {waitUntil: 'networkidle0'}); + it('should have redirected to default page "/?q={"severity":{"in":"I W E F"}}"', async () => { + await page.goto(baseUrl, { waitUntil: 'networkidle0' }); const location = await page.evaluate(() => window.location); const search = decodeURIComponent(location.search); @@ -115,7 +122,5 @@ describe('InfoLogger', function() { console.log('---------------------------------------------'); subprocess.kill(); closeServer(ilgServer); - }); }); - From 78b27f34b0d4dee0e006a8140c86500bdab733d5 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Thu, 7 May 2026 09:38:47 +0200 Subject: [PATCH 11/11] Improve newly added consts --- InfoLogger/public/constants/infologger-level.const.js | 9 +++++++-- InfoLogger/public/constants/role.const.js | 8 ++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/InfoLogger/public/constants/infologger-level.const.js b/InfoLogger/public/constants/infologger-level.const.js index 7829fee92..b6fac2957 100644 --- a/InfoLogger/public/constants/infologger-level.const.js +++ b/InfoLogger/public/constants/infologger-level.const.js @@ -18,7 +18,7 @@ * These values are as per InfoLogger defined levels: * {@link https://github.com/AliceO2Group/InfoLogger/blob/master/doc/README.md} */ -export const InfoLoggerLevel = { +export const InfoLoggerLevel = Object.freeze({ OPS: { label: 'Ops', index: 1, @@ -35,6 +35,11 @@ export const InfoLoggerLevel = { label: 'Trace', index: null, }, -}; +}); +/** + * Array containing the different levels of logs that can be displayed in the application, + * with their label and index as in the database, used for iterating over the levels in the UI + * These values are as per InfoLogger defined levels: + */ export const INFOLOGGER_LEVEL_LIST = Object.values(InfoLoggerLevel); diff --git a/InfoLogger/public/constants/role.const.js b/InfoLogger/public/constants/role.const.js index 44aa6785b..95907e126 100644 --- a/InfoLogger/public/constants/role.const.js +++ b/InfoLogger/public/constants/role.const.js @@ -12,7 +12,11 @@ * or submit itself to any jurisdiction. */ -export const Role = { +/** + * Object containing the different roles that a user can have in the application, used for checking + * permissions and access levels. These roles are defined in CERN Application Service + */ +export const Role = Object.freeze({ SHIFTER: 'shifter', ADMIN: 'admin', -}; +});