From 18d10507892ef2b47b9f69086842321d540bf5c2 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Thu, 5 Feb 2026 15:43:37 +0100 Subject: [PATCH 01/11] Increase restriction on GET lock API to role-detector --- Control/lib/api.js | 5 ++++- Control/test/api/lock/api-get-locks.test.js | 12 +++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Control/lib/api.js b/Control/lib/api.js index 56caeb28a..09b086181 100644 --- a/Control/lib/api.js +++ b/Control/lib/api.js @@ -247,7 +247,10 @@ module.exports.setup = (http, ws) => { http.post('/execute/o2-roc-config', coreMiddleware, (req, res) => ctrlService.createAutoEnvironment(req, res)); // Lock Service - http.get('/locks', lockController.getLocksStateHandler.bind(lockController)); + http.get('/locks', + minimumRoleMiddleware(Role.DETECTOR), + lockController.getLocksStateHandler.bind(lockController) + ); http.put(`/locks/:action/${DetectorId.ALL}`, minimumRoleMiddleware(Role.GLOBAL), diff --git a/Control/test/api/lock/api-get-locks.test.js b/Control/test/api/lock/api-get-locks.test.js index 6276ba311..1509b697d 100644 --- a/Control/test/api/lock/api-get-locks.test.js +++ b/Control/test/api/lock/api-get-locks.test.js @@ -14,7 +14,7 @@ */ const request = require('supertest'); -const { ADMIN_TEST_TOKEN, TEST_URL } = require('../generateToken.js'); +const { ADMIN_TEST_TOKEN, GUEST_TEST_TOKEN, TEST_URL } = require('../generateToken.js'); describe(`'API - GET - /locks' test suite`, () => { before(async () => { @@ -50,4 +50,14 @@ describe(`'API - GET - /locks' test suite`, () => { message: 'Invalid JWT token provided' }); }); + + it('should return unauthorized error for insufficient role token requests', async () => { + await request(`${TEST_URL}/api/locks`) + .get(`/?token=${GUEST_TEST_TOKEN}`) + .expect(403, { + status: 403, + message: 'Not enough permissions for this operation', + title: 'Unauthorized Access', + }); + }); }); From 523301e703a5c86723762f1b3d7ba4d0c3176669 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Thu, 5 Feb 2026 15:44:44 +0100 Subject: [PATCH 02/11] Add documentation on LOCKS --- Control/README.md | 2 ++ Control/docs/LOCKS.md | 69 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 Control/docs/LOCKS.md diff --git a/Control/README.md b/Control/README.md index 9bfe68f09..5982be415 100644 --- a/Control/README.md +++ b/Control/README.md @@ -8,6 +8,7 @@ - [Requirements](#requirements) - [Installation](#installation) - [Business logic for Developers to know](#business-logic-for-developers-to-know) + - [Locks](#locks) - [Configuration](#configuration) - [O2Control gRPC](#o2control-grpc) - [Apricot gRPC](#apricot-grpc) @@ -55,6 +56,7 @@ It communicates with [Control agent](https://github.com/AliceO2Group/Control) ov 7. Open browser and navigate to http://localhost:8080 ## [Business logic for Developers to know](./docs/BUSINESS_FOR_DEVELOPER_TO_KNOW.md) +## [Locks](./docs/LOCKS.md) ## Configuration ### O2Control gRPC * `hostname` - gRPC hostname diff --git a/Control/docs/LOCKS.md b/Control/docs/LOCKS.md new file mode 100644 index 000000000..62a4c18b8 --- /dev/null +++ b/Control/docs/LOCKS.md @@ -0,0 +1,69 @@ +# Locks + +## Overview + +The locks functionality in Control/ECS GUI provides a mechanism to ensure exclusive access to detectors during operations. This prevents conflicts when multiple users attempt to configure or control the same detector simultaneously. + +All LOCKs operations require an authenticated user session and moreover: +- Lock Operations require a role of minimum `Role.DETECTOR`. +- Lock Operations on `ALL` detectors require a minimum role of `Role.GLOBAL`. +- Forced Lock Operations require a role of minimum `Role.GLOBAL`. +- Forced Lock Operations on `ALL` detectors require a minimum role of `Role.ADMIN`. + +## Lock Management + +### Lock States + +Each detector can be in one of the following lock states: +- **Released**: The detector is available to be locked by any user +- **Taken**: The detector is currently locked by a specific user + +### Get Lock States + +### Take Lock + +Acquires a lock on a detector for the current user. + +**Parameters**: +- `detectorId`: The individual detector identifier or `ALL` to lock all detectors (excluding TST) +- `action`: `TAKE` +- `shouldForce` (optional): Boolean flag to force taking the lock even if held by another user + +**Behavior**: +- When `detectorId` is `ALL`, locks all detectors **except** TST +- If a lock is already held by another user and: + - `shouldForce` is `false`, the request will fail. + - `shouldForce` is `true` and the user has a role of at least `GLOBAL` for individual detector and at least `ADMIN` for detector `ALL`, the lock will be taken regardless of current ownership + +### Release Lock + +Releases a lock on a detector. + +**Parameters**: +- `detectorId`: The detector identifier or `ALL` to release all locks (excluding TST) +- `action`: `RELEASE` +- `shouldForce` (optional): Boolean flag to force releasing the lock even if held by another user + +**Behavior**: +- When `detectorId` is `ALL`, releases locks on all detectors **except** TST +- If a lock is already held by another user and: + - `shouldForce` is `false`, the request will fail. + - `shouldForce` is `true` and the user has a role of at least `GLOBAL` for individual detector and at least `ADMIN` for detector `ALL`, the lock will be released regardless of current ownership + +## Special Cases + +### TST Detector + +The TST (test) detector is treated specially: +- When using `ALL` as the detector ID, TST is excluded from both TAKE and RELEASE operations +- TST must be locked/unlocked explicitly by using `TST` as the detector ID +- Moreover, front-end pages are also: + - excluding TST from being displayed if on `Global` page + - displaying TST at the end with separator if on `+Create` or `Locks` pages + +### Error Handling + +The lock controller handles the following error cases: +- Missing `detectorId` parameter → Returns `InvalidInputError` +- Invalid `action` parameter → Returns `InvalidInputError` +- Lock conflicts (when not forcing) → Error from LockService From d8c032867406d746a311b517875c39eae70d5d0d Mon Sep 17 00:00:00 2001 From: George Raduta Date: Thu, 5 Feb 2026 15:45:57 +0100 Subject: [PATCH 03/11] Update logic for lock ALL --- Control/lib/common/detectorId.enum.js | 1 + Control/lib/controllers/Lock.controller.js | 14 ++- .../controllers/mocha-lock.controller.test.js | 108 +++++++++++++++--- 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/Control/lib/common/detectorId.enum.js b/Control/lib/common/detectorId.enum.js index f8541d847..9f8f1bc09 100644 --- a/Control/lib/common/detectorId.enum.js +++ b/Control/lib/common/detectorId.enum.js @@ -17,6 +17,7 @@ */ const DetectorId = Object.freeze({ ALL: 'ALL', + TST: 'TST', }); exports.DetectorId = DetectorId; diff --git a/Control/lib/controllers/Lock.controller.js b/Control/lib/controllers/Lock.controller.js index bc9b77a11..97d5fb190 100644 --- a/Control/lib/controllers/Lock.controller.js +++ b/Control/lib/controllers/Lock.controller.js @@ -16,10 +16,10 @@ const { LogManager, LogLevel } = require('@aliceo2/web-ui'); const { updateAndSendExpressResponseFromNativeError, InvalidInputError } = require('@aliceo2/web-ui'); const { DetectorLockAction } = require('./../common/lock/detectorLockAction.enum.js'); +const { DetectorId } = require('./../common/DetectorId.enum.js'); const {User} = require('./../dtos/User.js'); const LOG_FACILITY = 'cog/log-ctrl'; -const DETECTOR_ALL = 'ALL'; /** * Controller for dealing with all API requests on actions and state of the locks used for detectors @@ -70,8 +70,10 @@ class LockController { } const user = new User(username, name, personid, access); if (action.toLocaleUpperCase() === DetectorLockAction.TAKE) { - if (detectorId === DETECTOR_ALL) { - Object.keys(this._lockService.locksByDetector).forEach((detector) => { + if (detectorId === DetectorId.ALL) { + Object.keys(this._lockService.locksByDetector) + .filter((detector) => detector !== DetectorId.TST) // Skip TST detector when locking all + .forEach((detector) => { try { this._lockService.takeLock(detector, user, shouldForce); } catch (error) { @@ -83,8 +85,10 @@ class LockController { } res.status(200).json(this._lockService.locksByDetectorToJSON()); } else if (action.toLocaleUpperCase() === DetectorLockAction.RELEASE) { - if (detectorId === DETECTOR_ALL) { - Object.keys(this._lockService.locksByDetector).forEach((detector) => { + if (detectorId === DetectorId.ALL) { + Object.keys(this._lockService.locksByDetector) + .filter((detector) => detector !== DetectorId.TST) // Skip TST detector when releasing all + .forEach((detector) => { try { this._lockService.releaseLock(detector, user, shouldForce); } catch (error) { diff --git a/Control/test/lib/controllers/mocha-lock.controller.test.js b/Control/test/lib/controllers/mocha-lock.controller.test.js index 0b5377c03..776cea378 100644 --- a/Control/test/lib/controllers/mocha-lock.controller.test.js +++ b/Control/test/lib/controllers/mocha-lock.controller.test.js @@ -16,11 +16,12 @@ const assert = require('assert'); const sinon = require('sinon'); -const {LockController} = require('./../../../lib/controllers/Lock.controller.js'); -const {LockService} = require('./../../../lib/services/Lock.service.js'); -const {User} = require('./../../../lib/dtos/User.js'); -const {DetectorLockAction} = require('../../../lib/common/lock/detectorLockAction.enum.js'); -const {DetectorLockState} = require('../../../lib/common/lock/detectorLockState.enum.js'); +const { LockController } = require('./../../../lib/controllers/Lock.controller.js'); +const { LockService } = require('./../../../lib/services/Lock.service.js'); +const { User } = require('./../../../lib/dtos/User.js'); +const { DetectorLockAction } = require('../../../lib/common/lock/detectorLockAction.enum.js'); +const { DetectorLockState } = require('../../../lib/common/lock/detectorLockState.enum.js'); +const { DetectorId } = require('../../../lib/common/DetectorId.enum.js'); describe(`'LockController' test suite`, () => { const res = { @@ -41,7 +42,7 @@ describe(`'LockController' test suite`, () => { }); it('should successfully return the state of locks if service is enabled and with detectors', () => { - lockService.setLockStatesForDetectors(['ABC', 'XYZ']) + lockService.setLockStatesForDetectors(['ABC', 'XYZ', DetectorId.TST]) lockController.getLocksStateHandler({}, res); assert.ok(res.status.calledWith(200)); assert.ok(res.json.calledWith({ @@ -55,6 +56,11 @@ describe(`'LockController' test suite`, () => { state: DetectorLockState.FREE, owner: undefined, }, + TST: { + name: DetectorId.TST, + state: DetectorLockState.FREE, + owner: undefined, + }, })); }); }); @@ -88,6 +94,11 @@ describe(`'LockController' test suite`, () => { state: DetectorLockState.FREE, owner: undefined, }, + TST: { + name: DetectorId.TST, + state: DetectorLockState.FREE, + owner: undefined, + } })); }); @@ -152,6 +163,11 @@ describe(`'LockController' test suite`, () => { state: DetectorLockState.FREE, owner: undefined, }, + TST: { + name: DetectorId.TST, + state: DetectorLockState.FREE, + owner: undefined, + }, })); }); @@ -209,12 +225,12 @@ describe(`'LockController' test suite`, () => { })); }); - it('should successfully lock all detectors available to lock', () => { + it(`should successfully lock all detectors available to lock but ${DetectorId.TST}`, () => { lockService.takeLock('ABC', new User('anonymous', 'Anonymous', 0, ['global'])); lockController.actionLockHandler({ params: { action: DetectorLockAction.TAKE, - detectorId: 'ALL' + detectorId: DetectorId.ALL }, session: { personid: 1, name: 'OneAnonymous', @@ -241,20 +257,38 @@ describe(`'LockController' test suite`, () => { username: 'oneanonymous', }, }, + TST: { + name: DetectorId.TST, + state: DetectorLockState.FREE, + owner: undefined, + }, })); }); - it('should successfully release all detectors available to lock', () => { + it(`should successfully release all detectors available to lock but ${DetectorId.TST}`, () => { + // take lock for TST to verify it is not released + lockController.actionLockHandler({ + params: { + action: DetectorLockAction.TAKE, + detectorId: DetectorId.TST, + }, session: { + personid: 1, + name: 'OneAnonymous', + username: 'oneanonymous', + } + }, res); + lockController.actionLockHandler({ params: { action: DetectorLockAction.RELEASE, - detectorId: 'ALL' + detectorId: DetectorId.ALL }, session: { personid: 1, name: 'OneAnonymous', username: 'oneanonymous', } }, res); + assert.ok(res.status.calledWith(200)); assert.ok(res.json.calledWith({ ABC: { @@ -271,6 +305,15 @@ describe(`'LockController' test suite`, () => { state: DetectorLockState.FREE, owner: undefined, }, + TST: { + name: DetectorId.TST, + state: DetectorLockState.TAKEN, + owner: { + fullName: 'OneAnonymous', + personid: 1, + username: 'oneanonymous', + }, + }, })); }); }); @@ -304,6 +347,15 @@ describe(`'LockController' test suite`, () => { state: DetectorLockState.FREE, owner: undefined, }, + TST: { + name: DetectorId.TST, + state: DetectorLockState.TAKEN, + owner: { + fullName: 'OneAnonymous', + personid: 1, + username: 'oneanonymous', + }, + }, })); res.status.resetHistory() res.json.resetHistory(); @@ -334,14 +386,23 @@ describe(`'LockController' test suite`, () => { state: DetectorLockState.FREE, owner: undefined, }, + TST: { + name: DetectorId.TST, + state: DetectorLockState.TAKEN, + owner: { + fullName: 'OneAnonymous', + personid: 1, + username: 'oneanonymous', + }, + }, })); }); - it('should successfully respond to a request to take ALL locks by force', () => { + it('should successfully respond to a request to take ALL locks by force but TST', () => { lockController.actionForceLockHandler({ params: { action: DetectorLockAction.TAKE, - detectorId: 'ALL', + detectorId: DetectorId.ALL, }, session: { personid: 22, username: 'twotwoanonymous', @@ -369,15 +430,25 @@ describe(`'LockController' test suite`, () => { username: 'twotwoanonymous', } }, + TST: { + name: DetectorId.TST, + state: DetectorLockState.TAKEN, + owner: { + fullName: 'OneAnonymous', + personid: 1, + username: 'oneanonymous', + }, + }, + })); }); - it('should successfully respond to a request to release ALL locks by force', () => { + it('should successfully respond to a request to release ALL locks by force but TST', () => { lockService.takeLock('ABC', new User('anonymous', 'Anonymous', 0, ['global']), true); // now one lock is under personid 0 lockController.actionForceLockHandler({ params: { action: DetectorLockAction.RELEASE, - detectorId: 'ALL', + detectorId: DetectorId.ALL, }, session: { personid: 22, username: 'twotwoanonymous', @@ -397,6 +468,15 @@ describe(`'LockController' test suite`, () => { state: DetectorLockState.FREE, owner: undefined, }, + TST: { + name: DetectorId.TST, + state: DetectorLockState.TAKEN, + owner: { + fullName: 'OneAnonymous', + personid: 1, + username: 'oneanonymous', + }, + }, })); }); }); From 62afb130e135f5ba159ade5e28d7994efa9930a2 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Thu, 5 Feb 2026 15:50:17 +0100 Subject: [PATCH 04/11] Fix eslint reported errors --- Control/lib/controllers/Lock.controller.js | 24 +++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Control/lib/controllers/Lock.controller.js b/Control/lib/controllers/Lock.controller.js index 97d5fb190..d0132a728 100644 --- a/Control/lib/controllers/Lock.controller.js +++ b/Control/lib/controllers/Lock.controller.js @@ -74,12 +74,12 @@ class LockController { Object.keys(this._lockService.locksByDetector) .filter((detector) => detector !== DetectorId.TST) // Skip TST detector when locking all .forEach((detector) => { - try { - this._lockService.takeLock(detector, user, shouldForce); - } catch (error) { - console.error(error); - } - }); + try { + this._lockService.takeLock(detector, user, shouldForce); + } catch (error) { + console.error(error); + } + }); } else { this._lockService.takeLock(detectorId, user, shouldForce); } @@ -89,12 +89,12 @@ class LockController { Object.keys(this._lockService.locksByDetector) .filter((detector) => detector !== DetectorId.TST) // Skip TST detector when releasing all .forEach((detector) => { - try { - this._lockService.releaseLock(detector, user, shouldForce); - } catch (error) { - console.error(error); - } - }); + try { + this._lockService.releaseLock(detector, user, shouldForce); + } catch (error) { + console.error(error); + } + }); } else { this._lockService.releaseLock(detectorId, user, shouldForce); } From c8cf96d88cc9e1ae1d96e734afff2dd7f852230f Mon Sep 17 00:00:00 2001 From: George Raduta Date: Thu, 5 Feb 2026 15:53:08 +0100 Subject: [PATCH 05/11] Fix miss-spelled named import --- Control/lib/controllers/Lock.controller.js | 2 +- Control/test/lib/controllers/mocha-lock.controller.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Control/lib/controllers/Lock.controller.js b/Control/lib/controllers/Lock.controller.js index d0132a728..3cdc41ae6 100644 --- a/Control/lib/controllers/Lock.controller.js +++ b/Control/lib/controllers/Lock.controller.js @@ -16,7 +16,7 @@ const { LogManager, LogLevel } = require('@aliceo2/web-ui'); const { updateAndSendExpressResponseFromNativeError, InvalidInputError } = require('@aliceo2/web-ui'); const { DetectorLockAction } = require('./../common/lock/detectorLockAction.enum.js'); -const { DetectorId } = require('./../common/DetectorId.enum.js'); +const { DetectorId } = require('./../common/detectorId.enum.js'); const {User} = require('./../dtos/User.js'); const LOG_FACILITY = 'cog/log-ctrl'; diff --git a/Control/test/lib/controllers/mocha-lock.controller.test.js b/Control/test/lib/controllers/mocha-lock.controller.test.js index 776cea378..5fb8393ff 100644 --- a/Control/test/lib/controllers/mocha-lock.controller.test.js +++ b/Control/test/lib/controllers/mocha-lock.controller.test.js @@ -21,7 +21,7 @@ const { LockService } = require('./../../../lib/services/Lock.service.js'); const { User } = require('./../../../lib/dtos/User.js'); const { DetectorLockAction } = require('../../../lib/common/lock/detectorLockAction.enum.js'); const { DetectorLockState } = require('../../../lib/common/lock/detectorLockState.enum.js'); -const { DetectorId } = require('../../../lib/common/DetectorId.enum.js'); +const { DetectorId } = require('../../../lib/common/detectorId.enum.js'); describe(`'LockController' test suite`, () => { const res = { From e841b11989f6cf8870fed8dcb381de3aa98780ff Mon Sep 17 00:00:00 2001 From: George Raduta Date: Thu, 5 Feb 2026 15:57:10 +0100 Subject: [PATCH 06/11] Complement docs on LOCKS --- Control/docs/LOCKS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Control/docs/LOCKS.md b/Control/docs/LOCKS.md index 62a4c18b8..958199e5f 100644 --- a/Control/docs/LOCKS.md +++ b/Control/docs/LOCKS.md @@ -30,7 +30,7 @@ Acquires a lock on a detector for the current user. - `shouldForce` (optional): Boolean flag to force taking the lock even if held by another user **Behavior**: -- When `detectorId` is `ALL`, locks all detectors **except** TST +- When `detectorId` is `ALL` and the user has a role of at least `GLOBAL`, locks all detectors **except** TST - If a lock is already held by another user and: - `shouldForce` is `false`, the request will fail. - `shouldForce` is `true` and the user has a role of at least `GLOBAL` for individual detector and at least `ADMIN` for detector `ALL`, the lock will be taken regardless of current ownership @@ -45,7 +45,7 @@ Releases a lock on a detector. - `shouldForce` (optional): Boolean flag to force releasing the lock even if held by another user **Behavior**: -- When `detectorId` is `ALL`, releases locks on all detectors **except** TST +- When `detectorId` is `ALL` and the user has a role of at least `GLOBAL`, releases locks on all detectors **except** TST - If a lock is already held by another user and: - `shouldForce` is `false`, the request will fail. - `shouldForce` is `true` and the user has a role of at least `GLOBAL` for individual detector and at least `ADMIN` for detector `ALL`, the lock will be released regardless of current ownership @@ -56,7 +56,7 @@ Releases a lock on a detector. The TST (test) detector is treated specially: - When using `ALL` as the detector ID, TST is excluded from both TAKE and RELEASE operations -- TST must be locked/unlocked explicitly by using `TST` as the detector ID +- TST must be taken/released explicitly by using `TST` as the detector ID - Moreover, front-end pages are also: - excluding TST from being displayed if on `Global` page - displaying TST at the end with separator if on `+Create` or `Locks` pages From de3c1d62e7457a51076d4bdbafe4df670f43e527 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Fri, 6 Feb 2026 16:49:43 +0100 Subject: [PATCH 07/11] FlpSelection model to observe Lock changes --- .../workflow/panels/flps/FlpSelection.js | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/Control/public/workflow/panels/flps/FlpSelection.js b/Control/public/workflow/panels/flps/FlpSelection.js index c960a2fea..aa3058a40 100644 --- a/Control/public/workflow/panels/flps/FlpSelection.js +++ b/Control/public/workflow/panels/flps/FlpSelection.js @@ -21,8 +21,9 @@ export default class FlpSelection extends Observable { /** * Initialize FLPs Selection Component * @param {Object} workflow + * @param {Lock} lockModel - model of the lock state which will be used to automatically unselect detectors that are no longer locked by the current user */ - constructor(workflow) { + constructor(workflow, lockModel) { super(); this.loader = workflow.model.loader; this.workflow = workflow; @@ -38,6 +39,9 @@ export default class FlpSelection extends Observable { this.unavailableDetectors = []; // detectors which are loaded from configuration but active in AliECS this.missingHosts = []; this.detectorViewConfigurationError = false; + + // Observe lock changes to automatically unselect detectors when locks are released + lockModel.lock.observe(() => this._handleLockChanges()); } /** @@ -111,13 +115,12 @@ export default class FlpSelection extends Observable { } /** - * Toggle selection of a detector. A detector can have one of the 3 states: - * * active - * * available - * * unavailable + * Toggle selection of a detector. A detector can have one of the states taken/released: + * This toggle will also notify the observer by default unless specified otherwise via shouldNotify * @param {String} name + * @param {boolean} shouldNotify - specify if the method should trigger a notification at the end of the process. */ - toggleDetectorSelection(name) { + toggleDetectorSelection(name, shouldNotify = true) { const indexUnavailable = this.unavailableDetectors.indexOf(name) if (indexUnavailable >= 0) { this.unavailableDetectors.splice(indexUnavailable, 1); @@ -131,7 +134,9 @@ export default class FlpSelection extends Observable { this.setHostsForDetector(name, true); } } - this.notify(); + if (shouldNotify) { + this.notify(); + } } /** @@ -162,15 +167,7 @@ export default class FlpSelection extends Observable { isDetectorActive(name) { return this.activeDetectors.isSuccess() && this.activeDetectors.payload.detectors.includes(name) } - /** - * Unselects given detector and its FLPs - * @param {string} name - */ - unselectDetector(name) { - if (this.selectedDetectors.includes(name)) { - this.toggleDetectorSelection(name); - } - } + /** * Toggle the selection of an FLP from the form host * The user can also use SHIFT key to select between 2 FLP machines, thus @@ -303,4 +300,24 @@ export default class FlpSelection extends Observable { } this.notify(); } + + /** + * Handler for lock state changes so that it automatically unselects detectors that are no longer locked by the current user. + * This method will be called either by: + * * changes triggered by the user on the lock button of a detector panel/locks page + * * changes triggered by a different user and received via websocket subscription to lock changes + * In all cases, if a detector is no longer locked by the current user, it will be unselected from the workflow and its hosts will be removed from the form selection. + * @private + * @returns {void} + */ + _handleLockChanges() { + this.selectedDetectors + .filter((detector) => !this.workflow.model.lock.isLockedByCurrentUser(detector)) + .forEach((detector) => { + if (this.selectedDetectors.includes(detector)) { + this.toggleDetectorSelection(detector, false); + } + }); + this.notify(); + } } From de460ae81fd02983d51aa3fd00876d0b525e2e3e Mon Sep 17 00:00:00 2001 From: George Raduta Date: Fri, 6 Feb 2026 16:55:25 +0100 Subject: [PATCH 08/11] Fix FlpSelection to observe Lock state changes --- Control/public/lock/lockButton.js | 5 +---- Control/public/workflow/Workflow.js | 2 +- Control/public/workflow/panels/flps/FlpSelection.js | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Control/public/lock/lockButton.js b/Control/public/lock/lockButton.js index 2c1084252..c82a591c9 100644 --- a/Control/public/lock/lockButton.js +++ b/Control/public/lock/lockButton.js @@ -36,10 +36,7 @@ export const detectorLockButton = (model, detector, lockState, isIcon = false) = if (isDetectorLockTaken) { if (lockModel.isLockedByCurrentUser(detector)) { detectorLockButtonClass = '.success'; - detectorLockHandler = () => { - lockModel.actionOnLock(detector, DetectorLockAction.RELEASE, false); - model.workflow.flpSelection.unselectDetector(detector); - }; + detectorLockHandler = () => lockModel.actionOnLock(detector, DetectorLockAction.RELEASE, false); } else { detectorLockButtonClass = '.warning.disabled.disabled-item'; } diff --git a/Control/public/workflow/Workflow.js b/Control/public/workflow/Workflow.js index 6308a3e2c..cf7d94fff 100644 --- a/Control/public/workflow/Workflow.js +++ b/Control/public/workflow/Workflow.js @@ -32,7 +32,7 @@ export default class Workflow extends Observable { constructor(model) { super(); this.model = model; - this.flpSelection = new FlpSelection(this); + this.flpSelection = new FlpSelection(this, model.lock); this.flpSelection.bubbleTo(this); this.form = new WorkflowForm(); diff --git a/Control/public/workflow/panels/flps/FlpSelection.js b/Control/public/workflow/panels/flps/FlpSelection.js index aa3058a40..b9a7d2cd9 100644 --- a/Control/public/workflow/panels/flps/FlpSelection.js +++ b/Control/public/workflow/panels/flps/FlpSelection.js @@ -41,7 +41,7 @@ export default class FlpSelection extends Observable { this.detectorViewConfigurationError = false; // Observe lock changes to automatically unselect detectors when locks are released - lockModel.lock.observe(() => this._handleLockChanges()); + lockModel.observe(() => this._handleLockChanges()); } /** From 8e8271c473e146b1d8393ccd25a38daf7beb2d75 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Fri, 6 Feb 2026 17:05:21 +0100 Subject: [PATCH 09/11] Improve readability and accessibility for component --- .../detectorLock/detectionLockActionButton.js | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/Control/public/common/detectorLock/detectionLockActionButton.js b/Control/public/common/detectorLock/detectionLockActionButton.js index 603752f6b..bd51c2ea4 100644 --- a/Control/public/common/detectorLock/detectionLockActionButton.js +++ b/Control/public/common/detectorLock/detectionLockActionButton.js @@ -12,23 +12,46 @@ * or submit itself to any jurisdiction. */ -import {h} from '/js/src/index.js'; -import {DetectorLockState} from './../enums/DetectorLockState.enum.js'; +import { h } from '/js/src/index.js'; +import { DetectorLockState } from './../enums/DetectorLockState.enum.js'; +import { DetectorLockAction } from '../enums/DetectorLockAction.enum.js'; /** - * Button with action to force take/release lock for a detector + * Button with action to no-force/force take/release lock for a detector * @param {Lock} lockModel - model of the lock service - * @param {String} detector - detector name - * @param {DetectorLockState} lockState - lock state of the detector + * @param {String} detector - detector name in prefix format, e.g. "ITS", "MFT", "TST" + * @param {object} lockState - lock state of the detector + * @param {DetectorLockState} lockState.state - state of the lock * @param {DetectorLockAction} action - action to be performed + * @param {boolean} shouldForce - if the action should be forced or not * @param {String} label - button label to be displayed to the user - * @return {vnode} + * @returns {vnode} */ export const detectorLockActionButton = ( lockModel, detector, lockState, action, shouldForce = false, label = `${action}` ) => { + const isFree = lockState?.state === DetectorLockState.FREE; + const isReleaseAction = action === DetectorLockAction.RELEASE; + const isTakeAction = action === DetectorLockAction.TAKE; + + let isDisabled = false; + let titleAndAriaLabel = `${action} lock for ${detector}`; + + if (isFree && isReleaseAction) { + titleAndAriaLabel = `Cannot release lock for ${detector} - lock is not taken`; + isDisabled = true; + } else if (isFree && isTakeAction && shouldForce) { + titleAndAriaLabel = `Cannot force take lock for ${detector} - lock is already free`; + isDisabled = true; + } else if (shouldForce) { + titleAndAriaLabel = `Force ${action} lock for ${detector}`; + } + return h('button.btn.btn-sm.btn-danger', { - disabled: lockState?.state === DetectorLockState.FREE, + disabled: isDisabled, + title: titleAndAriaLabel, + 'aria-label': titleAndAriaLabel, + 'aria-disabled': isDisabled ? 'true' : 'false', onclick: () => lockModel.actionOnLock(detector, action, shouldForce) }, label); }; From dc5d60a862dadfba91fef4210c451e7cd8a78459 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Fri, 6 Feb 2026 17:22:11 +0100 Subject: [PATCH 10/11] Decouple TST detector logic from lock handling and improve detector list management --- Control/public/common/detectorUtils.js | 35 +++++++++ Control/public/lock/lockButton.js | 5 +- Control/public/lock/lockPage.js | 77 +++++++++++-------- Control/public/services/DetectorService.js | 8 ++ .../workflow/panels/flps/detectorsPanel.js | 56 ++++++++------ 5 files changed, 123 insertions(+), 58 deletions(-) create mode 100644 Control/public/common/detectorUtils.js diff --git a/Control/public/common/detectorUtils.js b/Control/public/common/detectorUtils.js new file mode 100644 index 000000000..6de944f12 --- /dev/null +++ b/Control/public/common/detectorUtils.js @@ -0,0 +1,35 @@ +/** + * @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 TST_DETECTOR_NAME = 'TST'; + +/** + * Method to return a detector list with TST detector at the end if it exists + * @param {String[]} detectorList - list of detector names + * @return {String[]} reordered list of detector names + */ +export const getDetectorListWithTstAtEnd = (detectorList) => { + let hasTstDetector = false; + const detectorsWithoutTst = detectorList.filter((detector) => { + if (detector === TST_DETECTOR_NAME) { + hasTstDetector = true; + return false; + } + return true; + }); + const detectorsWithTst = detectorList.filter(detector => detector.toLocaleUpperCase().includes(TST_DETECTOR_NAME)); + return [ + ...detectorsWithoutTst, + ...(hasTstDetector ? detectorsWithTst : []) + ]; +} diff --git a/Control/public/lock/lockButton.js b/Control/public/lock/lockButton.js index c82a591c9..6d41935de 100644 --- a/Control/public/lock/lockButton.js +++ b/Control/public/lock/lockButton.js @@ -22,12 +22,11 @@ import {DetectorLockAction} from './../common/enums/DetectorLockAction.enum.js'; * - see who owns the lock * * When the user releases a lock, the detector also has to be unselected from the workflow. - * @param {Model} model - root model of the application + * @param {LockModel} lockModel - model of the lock state and actions * @param {String} detector - detector name * @param {Object} lockState - lock state of the detector */ -export const detectorLockButton = (model, detector, lockState, isIcon = false) => { - const lockModel = model.lock; +export const detectorLockButton = (lockModel, detector, lockState, isIcon = false) => { const isDetectorLockTaken = lockModel.isLocked(detector); let detectorLockHandler = null; diff --git a/Control/public/lock/lockPage.js b/Control/public/lock/lockPage.js index 7836d8a7d..6f8708872 100644 --- a/Control/public/lock/lockPage.js +++ b/Control/public/lock/lockPage.js @@ -21,6 +21,7 @@ import errorPage from './../common/errorPage.js'; import loading from './../common/loading.js'; import {DetectorLockAction} from '../common/enums/DetectorLockAction.enum.js'; import {isUserAllowedRole} from './../common/userRole.js'; +import { getDetectorListWithTstAtEnd, TST_DETECTOR_NAME } from '../common/detectorUtils.js'; const LOCK_TABLE_HEADER_KEYS = ['Detector', 'Owner']; const DETECTOR_ALL = 'ALL'; @@ -31,16 +32,12 @@ const DETECTOR_ALL = 'ALL'; /** * Header of the lock page - * @param {Object} model * @return {vnode} */ -export const header = (model) => [ +export const header = () => [ h('.w-100.text-center', [ h('h4', 'Locks') ]), - model.detectors.selected === 'GLOBAL' && h('.flex-row.text-right', { - style: 'position: absolute; right: 0px;' - }) ]; /** @@ -49,8 +46,8 @@ export const header = (model) => [ * @return {vnode} */ export const content = (model) => { - const padlockState = model.lock.padlockState; - const lock = model.lock; + const {lock: lockModel, detectors: detectorsService} = model; + const { padlockState } = lockModel; return [ detectorHeader(model), h('.text-center.scroll-y.absolute-fill', {style: 'top: 40px'}, [ @@ -59,19 +56,23 @@ export const content = (model) => { Loading: () => loading(3), Failure: (error) => errorPage(error), Success: (detectorsLocksState) => h('.flex-column', [ - h('.flex-row.g2.pv2', [ - isUserAllowedRole(ROLES.Admin) && [ - detectorLockActionButton(lock, DETECTOR_ALL, {}, DetectorLockAction.RELEASE, true, 'Force Release ALL'), - detectorLockActionButton(lock, DETECTOR_ALL, {}, DetectorLockAction.TAKE, true, 'Force Take ALL'), - ], - isUserAllowedRole(ROLES.Global) && [ - detectorLockActionButton(lock, DETECTOR_ALL, {}, DetectorLockAction.RELEASE, false, 'Release ALL*'), - detectorLockActionButton(lock, DETECTOR_ALL, {}, DetectorLockAction.TAKE, false, 'Take ALL*'), - ], - ]), - h('small.text-left.ph2', - 'Note: Release/Take all will only affect the detectors you have access to and detectors that are available.' - ), + detectorsService.isGlobalView() && [ + h('.flex-row.g2.p2', [ + isUserAllowedRole(ROLES.Admin) && [ + h('strong', 'Admin actions: '), + detectorLockActionButton(lockModel, DETECTOR_ALL, {}, DetectorLockAction.RELEASE, true, 'Force Release ALL'), + detectorLockActionButton(lockModel, DETECTOR_ALL, {}, DetectorLockAction.TAKE, true, 'Force Take ALL'), + ], + isUserAllowedRole(ROLES.Global) && [ + h('strong', 'Global actions: '), + detectorLockActionButton(lockModel, DETECTOR_ALL, {}, DetectorLockAction.RELEASE, false, 'Release ALL*'), + detectorLockActionButton(lockModel, DETECTOR_ALL, {}, DetectorLockAction.TAKE, false, 'Take ALL*'), + ], + ]), + h('small.text-left.ph2', + 'Note: Release/Take all will only affect the detectors you have access to and detectors that are available.' + ), + ], detectorLocksTable(model, detectorsLocksState) ]) }) @@ -86,18 +87,24 @@ export const content = (model) => { * @return {vnode} */ const detectorLocksTable = (model, detectorLocksState) => { - const {detectors} = model; + const { detectors: detectorsService, lock: lockModel } = model; const isUserGlobal = isUserAllowedRole(ROLES.Global); - - const detectorRows = Object.keys(detectorLocksState) - .filter((detector) => { + const detectorKeysWithTstLast = getDetectorListWithTstAtEnd(Object.keys(detectorLocksState)); + const detectorRows = detectorKeysWithTstLast + .filter((detectorName) => { const isSelectedDetectorViewGlobalOrCurrent = ( - detectors.selected === 'GLOBAL' || detectors.selected === detector + detectorsService.isGlobalView() || detectorsService.selected === detectorName ); - const isUserAllowedDetector = detectors.authed.includes(detector); + const isUserAllowedDetector = detectorsService.authed.includes(detectorName); return (isUserGlobal && isSelectedDetectorViewGlobalOrCurrent) || isUserAllowedDetector; }) - .map((detector) => detectorLockRow(model, detector, detectorLocksState[detector])) + .map((detectorName) => { + if (detectorName.toLocaleUpperCase().includes(TST_DETECTOR_NAME)) { + return [emptyRowSeparator(), detectorLockRow(lockModel, detectorName, detectorLocksState[detectorName])]; + } else { + return detectorLockRow(lockModel, detectorName, detectorLocksState[detectorName]) + } + }); return h('table.table.table-sm', h('thead', h('tr', @@ -121,26 +128,32 @@ const detectorLocksTable = (model, detectorLocksState) => { /** * Build a vnode for a row in the detector lock table which contains state of the lock and owner - * @param {Model} model - root model of the application + * @param {LockModel} lockModel - model of the lock state and actions * @param {String} detector - detector name * @param {DetectorLock} lockState - state of the lock {owner: {fullName: String}, isLocked: Boolean * @return {vnode} */ -const detectorLockRow = (model, detector, lockState) => { +const detectorLockRow = (lockModel, detector, lockState) => { const ownerName = lockState?.owner?.fullName || '-'; return h('tr', { id: `detector-row-${detector}`, }, [ h('td', h('.flex-row.g2.items-center.f5', [ - detectorLockButton(model, detector, lockState), + detectorLockButton(lockModel, detector, lockState), detector ]) ), h('td', ownerName), isUserAllowedRole(ROLES.Global) && h('td', [ - detectorLockActionButton(model.lock, detector, lockState, DetectorLockAction.RELEASE, true, 'Force Release'), - detectorLockActionButton(model.lock, detector, lockState, DetectorLockAction.TAKE, true, 'Force Take') + detectorLockActionButton(lockModel, detector, lockState, DetectorLockAction.RELEASE, true, 'Force Release'), + detectorLockActionButton(lockModel, detector, lockState, DetectorLockAction.TAKE, true, 'Force Take') ]) ]); }; + +/** + * Empty table row separator vnode + * @return {vnode} + */ +const emptyRowSeparator = () => h('tr', h('td', {colspan: 3}, h('hr'))); diff --git a/Control/public/services/DetectorService.js b/Control/public/services/DetectorService.js index dd639876b..a73bed424 100644 --- a/Control/public/services/DetectorService.js +++ b/Control/public/services/DetectorService.js @@ -76,6 +76,14 @@ export default class DetectorService extends Observable { return this._selected && this._selected !== 'GLOBAL'; } + /** + * Checks if the user selected detector view is GLOBAL + * @returns {boolean} + */ + isGlobalView() { + return this._selected === 'GLOBAL'; + } + /** * Update selection for detector view in LocalStorage * Format: {SELECTED: } diff --git a/Control/public/workflow/panels/flps/detectorsPanel.js b/Control/public/workflow/panels/flps/detectorsPanel.js index e35bac727..898bd8c6a 100644 --- a/Control/public/workflow/panels/flps/detectorsPanel.js +++ b/Control/public/workflow/panels/flps/detectorsPanel.js @@ -17,6 +17,7 @@ import pageLoading from './../../../common/pageLoading.js'; import {detectorLockButton} from './../../../lock/lockButton.js'; import {dcsPropertiesRow} from '../../../common/dcs/dcsPropertiesRow.js'; import {DetectorLockAction} from '../../../common/enums/DetectorLockAction.enum.js'; +import { TST_DETECTOR_NAME } from '../../../common/detectorUtils.js'; /** * Create a selection area for all detectors retrieved from AliECS @@ -28,38 +29,47 @@ export default (model, onlyGlobal = false) => { const {activeDetectors} = model.workflow.flpSelection; const detectors = model.lock.padlockState; let allowedDetectors = []; + let hasTstDetector = false; const areDetectorsReady = activeDetectors.isSuccess() && detectors.isSuccess(); if (areDetectorsReady) { allowedDetectors = JSON.parse(JSON.stringify(detectors.payload)); - if (onlyGlobal) { - delete allowedDetectors.TST; - } + hasTstDetector = Object.keys(allowedDetectors).includes(TST_DETECTOR_NAME); + + delete allowedDetectors.TST; allowedDetectors = Object.keys(allowedDetectors); } - return h('.w-100', [ - h('.w-100.flex-row.panel-title.p2.f6', [ - areDetectorsReady && h('button.btn.btn-sm', { + return h('.w-100.flex-column', [ + h('.flex-row.panel-title.p2.f6', { + style: 'flex-wrap: wrap; gap: 0.5rem;' + }, [ + areDetectorsReady && h('.flex-row.items-center', h('button.btn', { onclick: async () => { await model.lock.actionOnLock('ALL', DetectorLockAction.TAKE, false); if (onlyGlobal) { await model.lock.actionOnLock('TST', DetectorLockAction.RELEASE, false); } } - }, 'Lock Available'), - h('h5.w-100.bg-gray-light.flex-grow.items-center.flex-row.justify-center', 'Detectors Selection'), - areDetectorsReady && h('button.btn.btn-primary.btn-sm', { + }, 'Lock Available')), + h('h5.flex-grow.items-center.flex-row.justify-center', { + style: 'min-width: 60px;' + }, 'Detectors Selection'), + areDetectorsReady && h('.flex-row.items-center', h('button.btn.btn-primary', { onclick: async () => { model.workflow.flpSelection.selectAllAvailableDetectors(allowedDetectors); } - }, 'Select Available') + }, 'Select Available')) ]), - h('.w-100.p2.panel', + h('.p2.panel', (activeDetectors.isLoading() || detectors.isLoading()) && pageLoading(2), (!areDetectorsReady) && h('.f7.flex-column', `Loading detectors...active: ${activeDetectors.kind} and all: ${detectors.kind}`), (areDetectorsReady) && detectorsSelectionArea(model, allowedDetectors), + (areDetectorsReady && !onlyGlobal) && [ + hasTstDetector && h('hr.m2'), // add visual separation between TST and other detectors + hasTstDetector && detectorsSelectionArea(model, [TST_DETECTOR_NAME]), + ], (activeDetectors.isFailure() || detectors.isFailure()) && h('.f7.flex-column', 'Unavailable to load detectors'), ) ]); @@ -72,8 +82,7 @@ export default (model, onlyGlobal = false) => { * @return {vnode} */ const detectorsSelectionArea = (model, detectors) => { - return h('.w-100.m1.text-left.shadow-level1.grid.g2', { - style: 'max-height: 40em;' + return h('.w-100.m1.text-left.grid.g2', { }, [ detectors .filter((name) => (name === model.detectors.selected || !model.detectors.isSingleView())) @@ -88,18 +97,19 @@ const detectorsSelectionArea = (model, detectors) => { * @return {vnode} */ const detectorSelectionPanel = (model, name) => { + const {workflow, lock: lockModel, services: {detectors: {availability = {}} = {}}} = model; + let className = ''; let title = ''; let style = 'font-weight: 150;flex-grow:2'; - const {lock, services: {detectors: {availability = {}} = {}}} = model; - const lockState = lock.padlockState.payload?.[name]; - const isDetectorActive = model.workflow.flpSelection.isDetectorActive(name); + const lockState = lockModel.padlockState.payload?.[name]; + const isDetectorActive = workflow.flpSelection.isDetectorActive(name); if (isDetectorActive - || (model.lock.isLocked(name) && !model.lock.isLockedByCurrentUser(name))) { + || (lockModel.isLocked(name) && !lockModel.isLockedByCurrentUser(name))) { className = 'disabled-item warning'; title = 'Detector is running and/or locked'; - } else if (model.lock.isLockedByCurrentUser(name)) { - if (model.workflow.flpSelection.selectedDetectors.indexOf(name) >= 0) { + } else if (lockModel.isLockedByCurrentUser(name)) { + if (workflow.flpSelection.selectedDetectors.indexOf(name) >= 0) { className += 'selected '; title = 'Detector is locked and selected'; } @@ -112,18 +122,18 @@ const detectorSelectionPanel = (model, name) => { id: `detector-selection-panel-${name}'`, }, [ h('.flex-row', [ - detectorLockButton(model, name, lockState, true), + detectorLockButton(lockModel, name, lockState, true), h('a.menu-item.w-wrapped', { className, id: `detectorSelectionButtonFor${name}`, title, style, onclick: () => { - if (model.lock.isLockedByCurrentUser(name)) { - model.workflow.flpSelection.toggleDetectorSelection(name); + if (lockModel.isLockedByCurrentUser(name)) { + workflow.flpSelection.toggleDetectorSelection(name); } } - }, model.workflow.flpSelection.getDetectorWithIndexes(name) + }, workflow.flpSelection.getDetectorWithIndexes(name) ) ]), h('.f6.flex-row.g2', [ From c7d5d084a0d9d510b2376b9a2a19776625cd6d1f Mon Sep 17 00:00:00 2001 From: George Raduta Date: Fri, 6 Feb 2026 17:27:47 +0100 Subject: [PATCH 11/11] Fix eslint reported issues --- Control/public/lock/lockPage.js | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Control/public/lock/lockPage.js b/Control/public/lock/lockPage.js index 6f8708872..98c7cdfda 100644 --- a/Control/public/lock/lockPage.js +++ b/Control/public/lock/lockPage.js @@ -60,18 +60,23 @@ export const content = (model) => { h('.flex-row.g2.p2', [ isUserAllowedRole(ROLES.Admin) && [ h('strong', 'Admin actions: '), - detectorLockActionButton(lockModel, DETECTOR_ALL, {}, DetectorLockAction.RELEASE, true, 'Force Release ALL'), - detectorLockActionButton(lockModel, DETECTOR_ALL, {}, DetectorLockAction.TAKE, true, 'Force Take ALL'), + detectorLockActionButton( + lockModel, DETECTOR_ALL, {}, DetectorLockAction.RELEASE, true, 'Force Release ALL' + ), + detectorLockActionButton( + lockModel, DETECTOR_ALL, {}, DetectorLockAction.TAKE, true, 'Force Take ALL' + ), ], isUserAllowedRole(ROLES.Global) && [ h('strong', 'Global actions: '), - detectorLockActionButton(lockModel, DETECTOR_ALL, {}, DetectorLockAction.RELEASE, false, 'Release ALL*'), - detectorLockActionButton(lockModel, DETECTOR_ALL, {}, DetectorLockAction.TAKE, false, 'Take ALL*'), + detectorLockActionButton( + lockModel, DETECTOR_ALL, {}, DetectorLockAction.RELEASE, false, 'Release ALL*' + ), + detectorLockActionButton( + lockModel, DETECTOR_ALL, {}, DetectorLockAction.TAKE, false, 'Take ALL*' + ), ], ]), - h('small.text-left.ph2', - 'Note: Release/Take all will only affect the detectors you have access to and detectors that are available.' - ), ], detectorLocksTable(model, detectorsLocksState) ])