From 95e03526c8f3877edc1011c2fa64dafc3303423c Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Thu, 11 Dec 2025 17:00:52 +0100 Subject: [PATCH 01/15] Refactor the response object of `retrieveRunStatus` and rename it to `retrieveRunInformation` --- QualityControl/lib/api.js | 2 +- .../lib/controllers/FilterController.js | 8 +-- .../lib/services/BookkeepingService.js | 64 ++++++++++++++++--- QualityControl/lib/services/FilterService.js | 13 ++-- QualityControl/lib/services/RunModeService.js | 4 +- .../lib/controllers/FiltersController.test.js | 31 ++++++--- .../lib/services/BookkeepingService.test.js | 24 +++---- .../test/lib/services/FilterService.test.js | 58 +++++++++++------ .../test/lib/services/RunModeService.test.js | 20 ++++-- 9 files changed, 154 insertions(+), 70 deletions(-) diff --git a/QualityControl/lib/api.js b/QualityControl/lib/api.js index 4f0bb4c89..3c02671cd 100644 --- a/QualityControl/lib/api.js +++ b/QualityControl/lib/api.js @@ -109,7 +109,7 @@ export const setup = async (http, ws, eventEmitter) => { http.get( '/filter/run-status/:runNumber', runStatusFilterMiddleware, - filterController.getRunStatusHandler.bind(filterController), + filterController.getRunInformationHandler.bind(filterController), ); http.get( '/filter/ongoingRuns', diff --git a/QualityControl/lib/controllers/FilterController.js b/QualityControl/lib/controllers/FilterController.js index d32da8a75..0fcd51908 100644 --- a/QualityControl/lib/controllers/FilterController.js +++ b/QualityControl/lib/controllers/FilterController.js @@ -46,12 +46,10 @@ export class FilterController { * @param {Request} req - HTTP request * @param {Response} res - HTTP response to provide run status information */ - async getRunStatusHandler(req, res) { + async getRunInformationHandler(req, res) { try { - const runStatus = await this._filterService.getRunStatus(req.params.runNumber); - res.status(200).json({ - runStatus, - }); + const runInformation = await this._filterService.getRunInformation(req.params.runNumber); + res.status(200).json(runInformation); } catch (error) { this._logger.errorMessage('Error getting run status:', error); updateAndSendExpressResponseFromNativeError(res, error); diff --git a/QualityControl/lib/services/BookkeepingService.js b/QualityControl/lib/services/BookkeepingService.js index 561e97fc0..565ac29be 100644 --- a/QualityControl/lib/services/BookkeepingService.js +++ b/QualityControl/lib/services/BookkeepingService.js @@ -127,17 +127,28 @@ export class BookkeepingService { } /** - * Retrieves the status of a specific run from the Bookkeeping service + * Retrieves the information of a specific run from the Bookkeeping service * @param {number} runNumber - The run number to check the status for - * @returns {Promise} - Returns a promise that resolves to the run status: - * - RunStatus.ONGOING if the run is ongoing - * - RunStatus.ENDED if the run has completed (has timeO2End) - * - RunStatus.NOT_FOUND if there was an error or data is not available + * @returns {Promise} - Returns a promise that resolves to the run information: + * - runStatus: A custom field created here for the front-end: + * - `RunStatus.ONGOING` if the run is ongoing + * - `RunStatus.ENDED` if the run has completed (has timeO2End) + * - `RunStatus.NOT_FOUND` if the data cannot be found + * - `RunStatus.UNKNOWN` if there was an error or data is not available + * - time at which the run has started - `startTime` + * - time at which the run has run ended - `endTime` + * - the run belongs to a partition also known as environment: `environmentId` + * - the run also is defined by multiple properties. + * Depending on which oneas are used the run has a definition: `definition` + * - the run has a quality that decides if it should be stored or not for long time: `runQuality` + * - run normally runs only during an LHC beam mode: `lhcBeamMode` + * - A run has multiple detectors taking data, + * thus we should also get the list of detectors and qualityies: `detectorQualities` */ - async retrieveRunStatus(runNumber) { + async retrieveRunInformation(runNumber) { if (!this.active) { this._logger.warnMessage('Could not connect to bookkeeping'); - return RunStatus.BOOKKEEPING_UNAVAILABLE; + return this.wrapRunStatus(RunStatus.BOOKKEEPING_UNAVAILABLE); } try { @@ -150,18 +161,51 @@ export class BookkeepingService { throw new Error('No data available'); } - return data.timeO2End ? RunStatus.ENDED : RunStatus.ONGOING; + const { + startTime, + endTime, + environmentId, + definition, + runQuality, + lhcBeamMode, + detectorQualities, + timeO2End, + } = data; + const runStatus = timeO2End ? RunStatus.ENDED : RunStatus.ONGOING; + + return { + startTime, + endTime, + environmentId, + definition, + runQuality, + lhcBeamMode, + detectorQualities, + ...this.wrapRunStatus(runStatus), + }; } catch (error) { const msg = error?.message ?? String(error); if (msg.includes('404')) { this._logger.warnMessage(`Run number ${runNumber} not found in bookkeeping`); - return RunStatus.NOT_FOUND; + return this.wrapRunStatus(RunStatus.NOT_FOUND); } this._logger.errorMessage(`Error fetching run status: ${error.message || error}`); - return RunStatus.UNKNOWN; + return this.wrapRunStatus(RunStatus.UNKNOWN); } } + /** + * Wraps a given run status value into a standardized result object. + * Use this helper when you need to return only a `runStatus` field without any + * additional payload. This ensures that all callers receive a consistent + * object shape, matching the structure returned by `retrieveRunInformation`. + * @param {RunStatus} runStatus The run status to wrap. Must be a valid `RunStatus` enum value. + * @returns {{ runStatus: RunStatus }} A simple object containing only the provided `runStatus`. + */ + wrapRunStatus(runStatus) { + return { runStatus }; + } + /** * Helper method to construct a URL path with the required authentication token. * Appends the service's token as a query parameter to the provided path. diff --git a/QualityControl/lib/services/FilterService.js b/QualityControl/lib/services/FilterService.js index 0612dc39c..c05655b3b 100644 --- a/QualityControl/lib/services/FilterService.js +++ b/QualityControl/lib/services/FilterService.js @@ -87,18 +87,17 @@ export class FilterService { } /** - * This method is used to retrieve the run status from the bookkeeping service - * @param {number} runNumber - run number to retrieve the status for - * @returns {Promise} - resolves with the run status + * This method is used to retrieve the run information from the bookkeeping service + * @param {number} runNumber - run number to retrieve the information for + * @returns {Promise} - resolves with the run information */ - async getRunStatus(runNumber) { + async getRunInformation(runNumber) { try { - const runStatus = await this._bookkeepingService.retrieveRunStatus(runNumber); - return runStatus; + return await this._bookkeepingService.retrieveRunInformation(runNumber); } catch (error) { const message = `Error while retrieving run status for run ${runNumber}: ${error.message || error}`; this._logger.errorMessage(message); - return RunStatus.UNKNOWN; + return this._bookkeepingService.wrapRunStatus(RunStatus.UNKNOWN); } } } diff --git a/QualityControl/lib/services/RunModeService.js b/QualityControl/lib/services/RunModeService.js index 12f48f8f1..4c773410a 100644 --- a/QualityControl/lib/services/RunModeService.js +++ b/QualityControl/lib/services/RunModeService.js @@ -63,7 +63,7 @@ export class RunModeService { return { paths: cachedPaths }; } - const runStatus = await this._bookkeepingService.retrieveRunStatus(runNumber); + const { runStatus } = await this._bookkeepingService.retrieveRunInformation(runNumber); const rawPaths = await this._dataService.getObjectsLatestVersionList({ filters: { RunNumber: runNumber }, }); @@ -88,7 +88,7 @@ export class RunModeService { async refreshRunsCache() { for (const [runNumber] of this._ongoingRuns.entries()) { try { - const runStatus = await this._bookkeepingService.retrieveRunStatus(runNumber); + const { runStatus } = await this._bookkeepingService.retrieveRunInformation(runNumber); if (runStatus === RunStatus.ONGOING) { const updatedPaths = await this._dataService.getObjectsLatestVersionList({ filters: { RunNumber: runNumber }, diff --git a/QualityControl/test/lib/controllers/FiltersController.test.js b/QualityControl/test/lib/controllers/FiltersController.test.js index 004a9e0b6..a5cdd026e 100644 --- a/QualityControl/test/lib/controllers/FiltersController.test.js +++ b/QualityControl/test/lib/controllers/FiltersController.test.js @@ -73,7 +73,9 @@ export const filtersControllerTestSuite = async () => { suite('getRunStatusHandler', async () => { test('should successfully retrieve run status from FilterService', async () => { const filterService = sinon.createStubInstance(FilterService); - filterService.getRunStatus.resolves(RunStatus.ONGOING); + filterService.getRunInformation.resolves({ + runStatus: RunStatus.ONGOING, + }); const req = { params: { @@ -86,9 +88,12 @@ export const filtersControllerTestSuite = async () => { }; const filterController = new FilterController(filterService); - await filterController.getRunStatusHandler(req, res); + await filterController.getRunInformationHandler(req, res); - ok(filterService.getRunStatus.calledWith(123456), 'FilterService.getRunStatus should be called with run number'); + ok( + filterService.getRunInformation.calledWith(123456), + 'FilterService.getRunInformation should be called with run number', + ); ok(res.status.calledWith(200), 'Response status should be 200'); ok(res.json.calledWith({ runStatus: RunStatus.ONGOING, @@ -98,7 +103,7 @@ export const filtersControllerTestSuite = async () => { test('should handle errors from FilterService and send error response', async () => { const filterService = sinon.createStubInstance(FilterService); const testError = new Error('Bookkeeping service unavailable'); - filterService.getRunStatus.rejects(testError); + filterService.getRunInformation.rejects(testError); const req = { params: { @@ -111,9 +116,12 @@ export const filtersControllerTestSuite = async () => { }; const filterController = new FilterController(filterService); - await filterController.getRunStatusHandler(req, res); + await filterController.getRunInformationHandler(req, res); - ok(filterService.getRunStatus.calledWith(123456), 'FilterService.getRunStatus should be called with run number'); + ok( + filterService.getRunInformation.calledWith(123456), + 'FilterService.getRunStatus should be called with run number', + ); ok(res.status.calledWith(500), 'Response status should be 500 for service errors'); ok(res.json.calledWithMatch({ message: 'Bookkeeping service unavailable', @@ -124,7 +132,9 @@ export const filtersControllerTestSuite = async () => { test('should return UNKNOWN status when FilterService returns invalid status', async () => { const filterService = sinon.createStubInstance(FilterService); - filterService.getRunStatus.resolves('UNKNOWN'); + filterService.getRunInformation.resolves({ + runStatus: RunStatus.UNKNOWN, + }); const req = { params: { @@ -137,9 +147,12 @@ export const filtersControllerTestSuite = async () => { }; const filterController = new FilterController(filterService); - await filterController.getRunStatusHandler(req, res); + await filterController.getRunInformationHandler(req, res); - ok(filterService.getRunStatus.calledWith(999999), 'FilterService.getRunStatus should be called with run number'); + ok( + filterService.getRunInformation.calledWith(999999), + 'FilterService.getRunStatus should be called with run number', + ); ok(res.status.calledWith(200), 'Response status should be 200'); ok(res.json.calledWith({ runStatus: RunStatus.UNKNOWN, diff --git a/QualityControl/test/lib/services/BookkeepingService.test.js b/QualityControl/test/lib/services/BookkeepingService.test.js index 972a18724..5ba82f732 100644 --- a/QualityControl/test/lib/services/BookkeepingService.test.js +++ b/QualityControl/test/lib/services/BookkeepingService.test.js @@ -256,8 +256,8 @@ export const bookkeepingServiceTestSuite = async () => { }; nock(VALID_CONFIG.bookkeeping.url).get(runsPathPattern).reply(200, mockResponse); - const result = await bkpService.retrieveRunStatus(123); - strictEqual(result, RunStatus.ENDED); + const { runStatus } = await bkpService.retrieveRunInformation(123); + strictEqual(runStatus, RunStatus.ENDED); }); test('should return ONGOING status when timeO2End is not present', async () => { @@ -265,22 +265,22 @@ export const bookkeepingServiceTestSuite = async () => { nock(VALID_CONFIG.bookkeeping.url).get(runsPathPattern).reply(200, mockResponse); - const result = await bkpService.retrieveRunStatus(456); - strictEqual(result, RunStatus.ONGOING); + const { runStatus } = await bkpService.retrieveRunInformation(456); + strictEqual(runStatus, RunStatus.ONGOING); }); test('should return UNKNOWN status when no data is returned', async () => { nock(VALID_CONFIG.bookkeeping.url).get(runsPathPattern).reply(200, {}); - const result = await bkpService.retrieveRunStatus(789); - strictEqual(result, RunStatus.UNKNOWN); + const { runStatus } = await bkpService.retrieveRunInformation(789); + strictEqual(runStatus, RunStatus.UNKNOWN); }); test('should return UNKNOWN status when request fails', async () => { nock(VALID_CONFIG.bookkeeping.url).get(runsPathPattern).reply(500); - const result = await bkpService.retrieveRunStatus(1010); - strictEqual(result, RunStatus.UNKNOWN); + const { runStatus } = await bkpService.retrieveRunInformation(1010); + strictEqual(runStatus, RunStatus.UNKNOWN); }); test('should return NOT_FOUND status when request fails', async () => { @@ -293,15 +293,15 @@ export const bookkeepingServiceTestSuite = async () => { ], }); - const result = await bkpService.retrieveRunStatus(1010); - strictEqual(result, RunStatus.NOT_FOUND); + const { runStatus } = await bkpService.retrieveRunInformation(1010); + strictEqual(runStatus, RunStatus.NOT_FOUND); }); test('should return BOOKKEEPING_UNAVAILABLE status when service is not active', async () => { bkpService.active = false; - const result = await bkpService.retrieveRunStatus(123); - strictEqual(result, RunStatus.BOOKKEEPING_UNAVAILABLE); + const { runStatus } = await bkpService.retrieveRunInformation(123); + strictEqual(runStatus, RunStatus.BOOKKEEPING_UNAVAILABLE); }); }); }); diff --git a/QualityControl/test/lib/services/FilterService.test.js b/QualityControl/test/lib/services/FilterService.test.js index 68b09b685..61b2c7859 100644 --- a/QualityControl/test/lib/services/FilterService.test.js +++ b/QualityControl/test/lib/services/FilterService.test.js @@ -17,6 +17,7 @@ import { suite, test, beforeEach, afterEach } from 'node:test'; import { FilterService } from '../../../lib/services/FilterService.js'; import { RunStatus } from '../../../common/library/runStatus.enum.js'; import { stub, restore } from 'sinon'; +import { BookkeepingService } from '../../../lib/services/BookkeepingService.js'; export const filterServiceTestSuite = async () => { let filterService = null; @@ -31,7 +32,8 @@ export const filterServiceTestSuite = async () => { bookkeepingServiceMock = { connect: stub(), retrieveRunTypes: stub(), - retrieveRunStatus: stub(), + retrieveRunInformation: stub(), + wrapRunStatus: stub(), active: true, // assume the bookkeeping service is active by default }; filterService = new FilterService(bookkeepingServiceMock, configMock); @@ -117,42 +119,62 @@ export const filterServiceTestSuite = async () => { }); }); - suite('getRunStatus', async () => { + suite('getRunInformation', async () => { test('should return run status from bookkeeping service when valid', async () => { - bookkeepingServiceMock.retrieveRunStatus.resolves(RunStatus.ONGOING); + bookkeepingServiceMock.retrieveRunInformation.resolves({ + runStatus: RunStatus.ONGOING, + }); - const result = await filterService.getRunStatus(123456); + const { runStatus } = await filterService.getRunInformation(123456); - deepStrictEqual(bookkeepingServiceMock.retrieveRunStatus.calledWith(123456), true); - deepStrictEqual(result, RunStatus.ONGOING); + deepStrictEqual(bookkeepingServiceMock.retrieveRunInformation.calledWith(123456), true); + deepStrictEqual(runStatus, RunStatus.ONGOING); }); test('should return ENDED status from bookkeeping service', async () => { - bookkeepingServiceMock.retrieveRunStatus.resolves(RunStatus.ENDED); + bookkeepingServiceMock.retrieveRunInformation.resolves({ + runStatus: RunStatus.ENDED, + }); - const result = await filterService.getRunStatus(789012); + const { runStatus } = await filterService.getRunInformation(789012); - deepStrictEqual(bookkeepingServiceMock.retrieveRunStatus.calledWith(789012), true); - deepStrictEqual(result, RunStatus.ENDED); + deepStrictEqual(bookkeepingServiceMock.retrieveRunInformation.calledWith(789012), true); + deepStrictEqual(runStatus, RunStatus.ENDED); }); test('should return NOT_FOUND status from bookkeeping service', async () => { - bookkeepingServiceMock.retrieveRunStatus.resolves(RunStatus.NOT_FOUND); + bookkeepingServiceMock.retrieveRunInformation.resolves({ + runStatus: RunStatus.NOT_FOUND, + }); - const result = await filterService.getRunStatus(345678); + const { runStatus } = await filterService.getRunInformation(345678); - deepStrictEqual(bookkeepingServiceMock.retrieveRunStatus.calledWith(345678), true); - deepStrictEqual(result, RunStatus.NOT_FOUND); + deepStrictEqual(bookkeepingServiceMock.retrieveRunInformation.calledWith(345678), true); + deepStrictEqual(runStatus, RunStatus.NOT_FOUND); }); test('should return UNKNOWN when bookkeeping service throws error', async () => { const testError = new Error('Bookkeeping service unavailable'); - bookkeepingServiceMock.retrieveRunStatus.rejects(testError); + bookkeepingServiceMock.retrieveRunInformation.rejects(testError); + bookkeepingServiceMock.wrapRunStatus.callsFake((status) => ({ runStatus: status })); - const result = await filterService.getRunStatus(123456); + const { runStatus } = await filterService.getRunInformation(123456); - deepStrictEqual(bookkeepingServiceMock.retrieveRunStatus.calledWith(123456), true); - deepStrictEqual(result, RunStatus.UNKNOWN); + deepStrictEqual(bookkeepingServiceMock.retrieveRunInformation.calledWith(123456), true); + deepStrictEqual(runStatus, RunStatus.UNKNOWN); + }); + }); + + suite('wrapRunStatus', async () => { + test('should wrap a given RunStatus value into a standardized object', async () => { + const realService = new BookkeepingService(); + bookkeepingServiceMock.wrapRunStatus.callsFake(realService.wrapRunStatus); + + const runStatusOngoing = bookkeepingServiceMock.wrapRunStatus(RunStatus.ONGOING); + const runStatusEnded = bookkeepingServiceMock.wrapRunStatus(RunStatus.ENDED); + + deepStrictEqual(runStatusOngoing, { runStatus: RunStatus.ONGOING }); + deepStrictEqual(runStatusEnded, { runStatus: RunStatus.ENDED }); }); }); }; diff --git a/QualityControl/test/lib/services/RunModeService.test.js b/QualityControl/test/lib/services/RunModeService.test.js index 02b4723cd..5802005a4 100644 --- a/QualityControl/test/lib/services/RunModeService.test.js +++ b/QualityControl/test/lib/services/RunModeService.test.js @@ -31,7 +31,7 @@ export const runModeServiceTestSuite = async () => { beforeEach(() => { bookkeepingService = { - retrieveRunStatus: sinon.stub(), + retrieveRunInformation: sinon.stub(), }; dataService = { @@ -46,7 +46,9 @@ export const runModeServiceTestSuite = async () => { const runNumber = 1234; const rawPaths = [{ path: '/run/path1' }]; - bookkeepingService.retrieveRunStatus.withArgs(runNumber).resolves(RunStatus.ONGOING); + bookkeepingService.retrieveRunInformation.withArgs(runNumber).resolves({ + runStatus: RunStatus.ONGOING, + }); dataService.getObjectsLatestVersionList.resolves(rawPaths); const result = await runModeService.retrievePathsAndSetRunStatus(runNumber); @@ -63,7 +65,9 @@ export const runModeServiceTestSuite = async () => { const runNumber = 1234; const rawPaths = [{ path: '/ended/path' }]; - bookkeepingService.retrieveRunStatus.resolves(RunStatus.ENDED); + bookkeepingService.retrieveRunInformation.resolves({ + runStatus: RunStatus.ENDED, + }); dataService.getObjectsLatestVersionList.resolves(rawPaths); const result = await runModeService.retrievePathsAndSetRunStatus(runNumber); @@ -85,7 +89,7 @@ export const runModeServiceTestSuite = async () => { paths: [{ name: '/cached/path' }], }); - sinon.assert.notCalled(bookkeepingService.retrieveRunStatus); + sinon.assert.notCalled(bookkeepingService.retrieveRunInformation); }); }); @@ -94,7 +98,9 @@ export const runModeServiceTestSuite = async () => { const runNumber = 1001; runModeService._ongoingRuns.set(runNumber, [{ path: '/old/path' }]); - bookkeepingService.retrieveRunStatus.withArgs(runNumber).resolves(RunStatus.FINISHED); + bookkeepingService.retrieveRunInformation.withArgs(runNumber).resolves({ + runStatus: RunStatus.ENDED, + }); await runModeService.refreshRunsCache(); strictEqual(runModeService._ongoingRuns.has(runNumber), false); @@ -106,7 +112,9 @@ export const runModeServiceTestSuite = async () => { runModeService._ongoingRuns.set(runNumber, [{ path: '/old/path' }]); - bookkeepingService.retrieveRunStatus.withArgs(runNumber).resolves(RunStatus.ONGOING); + bookkeepingService.retrieveRunInformation.withArgs(runNumber).resolves({ + runStatus: RunStatus.ONGOING, + }); dataService.getObjectsLatestVersionList.resolves(updatedPaths); await runModeService.refreshRunsCache(); From da4216c18122f245cbe0d1010144796f3396d2d4 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:54:30 +0100 Subject: [PATCH 02/15] Add panel with run information in QCG under filters --- QualityControl/public/Model.js | 2 +- .../public/common/filters/filterViews.js | 18 +++++- .../common/filters/model/FilterModel.js | 57 ++++++++++++++++++- .../public/services/Filter.service.js | 16 +++++- .../test/public/features/runMode.test.js | 1 + 5 files changed, 86 insertions(+), 8 deletions(-) diff --git a/QualityControl/public/Model.js b/QualityControl/public/Model.js index 4c8a238f8..d4022f362 100644 --- a/QualityControl/public/Model.js +++ b/QualityControl/public/Model.js @@ -169,7 +169,7 @@ export default class Model extends Observable { this.object.objects = {}; // Remove any in-memory loaded objects this._clearAllIntervals(); await this.filterModel.filterService.initFilterService(); - this.filterModel.setFilterFromURL(); + await this.filterModel.setFilterFromURL(); this.filterModel.setFilterToURL(); this.services.layout.getLayoutsByUserId(this.session.personid, RequestFields.LAYOUT_CARD); diff --git a/QualityControl/public/common/filters/filterViews.js b/QualityControl/public/common/filters/filterViews.js index 0e6fc1362..dfa91998d 100644 --- a/QualityControl/public/common/filters/filterViews.js +++ b/QualityControl/public/common/filters/filterViews.js @@ -18,6 +18,7 @@ import { filtersConfig, runModeFilterConfig } from './filtersConfig.js'; import { runModeCheckbox } from './runMode/runModeCheckbox.js'; import { lastUpdatePanel, runStatusPanel } from './runMode/runStatusPanel.js'; import { h, iconChevronBottom, iconChevronTop } from '/js/src/index.js'; +import { camelToTitleCase } from '../utils.js'; /** * Creates an input element for a specific metadata field; @@ -75,6 +76,7 @@ export function filtersPanel(filterModel, viewModel) { isVisible, lastRefresh, ONGOING_RUN_INTERVAL_MS: refreshRate, + runInformation, } = filterModel; const { fetchOngoingRuns } = filterService; const onInputCallback = setFilterValue.bind(filterModel); @@ -101,9 +103,23 @@ export function filtersPanel(filterModel, viewModel) { isRunModeActivated && runStatusPanel(runStatus), ]), lastUpdatePanel(runStatus, lastRefresh, refreshRate), + runInformation && h( + '.flex-row.g4.items-center.justify-center.f7.gray-darker.text-center.ph2', + { + id: 'runInformation', + style: 'overflow-x: auto;', + }, + Object.entries(runInformation).map(([key, value]) => + h('.flex-row.g1', { + style: 'flex: 0 0 auto;', + }, [ + h('strong', `${camelToTitleCase(key)}:`), + h('span', `${value}`), + ])), + ), ], ); -}; +} /** * Determines if runs mode is allowed based on current page and context diff --git a/QualityControl/public/common/filters/model/FilterModel.js b/QualityControl/public/common/filters/model/FilterModel.js index 69d02a196..d92c63ac8 100644 --- a/QualityControl/public/common/filters/model/FilterModel.js +++ b/QualityControl/public/common/filters/model/FilterModel.js @@ -16,8 +16,15 @@ import { Observable } from '/js/src/index.js'; import { buildQueryParametersString } from '../../buildQueryParametersString.js'; import FilterService from '../../../services/Filter.service.js'; import { RunStatus } from '../../../library/runStatus.enum.js'; +import { prettyFormatDate } from '../../utils.js'; + const CCDB_QUERY_PARAMS = ['PeriodName', 'PassName', 'RunNumber', 'RunType']; +const RUN_INFORMATION_MAP = { + startTime: prettyFormatDate, + endTime: prettyFormatDate, +}; + /** * Model namespace that manages the filter state in the application. */ @@ -39,6 +46,7 @@ export default class FilterModel extends Observable { this._runStatus = null; this._isRunModeActivated = false; this._lastRefresh = null; + this._runInformation = {}; this.ONGOING_RUN_INTERVAL_MS = 15000; } @@ -47,7 +55,7 @@ export default class FilterModel extends Observable { * Look for parameters used for filtering in URL and apply them in the layout if it exists * @returns {undefined} */ - setFilterFromURL() { + async setFilterFromURL() { const parameters = this.model.router.params; CCDB_QUERY_PARAMS.forEach((filterKey) => { if (parameters[filterKey]) { @@ -64,6 +72,8 @@ export default class FilterModel extends Observable { Other: () => null, }); + this._runNumber = this._filterMap['RunNumber']; + await this.updateRunInformation(); this.notify(); } @@ -114,12 +124,15 @@ export default class FilterModel extends Observable { */ async triggerFilter(baseViewModel) { this.setFilterToURL(); + + this.runNumber = this._filterMap['RunNumber']; + await this.updateRunInformation(); if (this.isRunModeActivated) { - this.runNumber = this._filterMap['RunNumber']; - this.runStatus = await this.filterService.getRunStatus(this.runNumber); + this.runStatus = this.runInformation.runStatus ?? RunStatus.UNKNOWN; this.notify(); this._manageRunsModeInterval(baseViewModel, true); } + baseViewModel.triggerFilter(); this._lastRefresh = Date.now(); this.notify(); @@ -285,6 +298,17 @@ export default class FilterModel extends Observable { } } + /** + * Updates the `runInformation` property by fetching data from the `filterService`. + * If `this.runNumber` is defined, it asynchronously retrieves the run information + * via `filterService.getRunInformation(runNumber)` and sets it to `runInformation`, + * automatically applying any filtering and transformations defined in the setter. + * If `this.runNumber` is not defined, `runInformation` is reset to an empty object. + */ + async updateRunInformation() { + this.runInformation = this.runNumber ? await this.filterService.getRunInformation(this.runNumber) : {}; + } + /** * Gets the current run number. * @returns {number} The run number. @@ -301,6 +325,33 @@ export default class FilterModel extends Observable { this._runNumber = value; } + /** + * Gets the current run information. + * @returns {object} The run information. + */ + get runInformation() { + return this._runInformation; + } + + /** + * Sets the run information after filtering and transforming values. + * - Filters out properties that are `null` or `undefined`. + * - Applies a transformation function from `RUN_INFORMATION_MAP` for any matching keys. + * @param {object} value - The new run information object to set. + * Keys corresponding to functions in `RUN_INFORMATION_MAP` will be transformed accordingly. + */ + set runInformation(value) { + const runInfo = value && typeof value === 'object' ? value : {}; + const transformed = Object.entries(runInfo) + // Filters out properties that are `null` or `undefined`. + .filter(([_, v]) => v !== null && v !== undefined) + // Applies a transformation function from `RUN_INFORMATION_MAP` for any matching keys. + .map(([key, value]) => + [key, typeof RUN_INFORMATION_MAP[key] === 'function' ? RUN_INFORMATION_MAP[key](value) : value]); + + this._runInformation = Object.fromEntries(transformed); + } + /** * Gets the current run status. * @returns {RemoteData} The run status. diff --git a/QualityControl/public/services/Filter.service.js b/QualityControl/public/services/Filter.service.js index d39ffac68..e61e0cfeb 100644 --- a/QualityControl/public/services/Filter.service.js +++ b/QualityControl/public/services/Filter.service.js @@ -50,12 +50,22 @@ export default class FilterService { /** * Method to get run status for a specific run number * @param {number} runNumber - The run number to get status for - * @returns {RemoteData} - result within a RemoteData object + * @returns {object} - result as an object containing run information */ - async getRunStatus(runNumber) { + async getRunInformation(runNumber) { const parsedRunNumber = parseInt(runNumber, 10); const { result, ok } = await this.loader.get(`/api/filter/run-status/${parsedRunNumber}`); - return ok ? result?.runStatus : RunStatus.UNKNOWN; + return ok ? result : {}; + } + + /** + * Method to get run status for a specific run number + * @param {number} runNumber - The run number to get status for + * @returns {RunStatus} - result as a run status + */ + async getRunStatus(runNumber) { + const { runStatus } = await this.getRunInformation(runNumber); + return runStatus ?? RunStatus.UNKNOWN; } /** diff --git a/QualityControl/test/public/features/runMode.test.js b/QualityControl/test/public/features/runMode.test.js index 1c500f01b..11443a65f 100644 --- a/QualityControl/test/public/features/runMode.test.js +++ b/QualityControl/test/public/features/runMode.test.js @@ -119,6 +119,7 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { await testParent.test('should persist runs mode between pages', { timeout }, async () => { await page.locator('.menu-item:nth-child(3) > .ph2').click(); + expectCountRunStatusCalls++;// fetches run information on page load await page.waitForSelector('#runStatusPanel'); const runInfo = await page.evaluate(() => { const status = document.querySelector('#runStatusBadge').textContent; From 6c2587dffc564ab46170facdd9f91f2d4de72e09 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Fri, 12 Dec 2025 21:51:34 +0100 Subject: [PATCH 03/15] Rewrite updateRunInformation so that it doesn't rely on runNumber to be set. Clear runInformation on clearFilters() --- .../public/common/filters/model/FilterModel.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/QualityControl/public/common/filters/model/FilterModel.js b/QualityControl/public/common/filters/model/FilterModel.js index d92c63ac8..6006b8ad9 100644 --- a/QualityControl/public/common/filters/model/FilterModel.js +++ b/QualityControl/public/common/filters/model/FilterModel.js @@ -72,7 +72,6 @@ export default class FilterModel extends Observable { Other: () => null, }); - this._runNumber = this._filterMap['RunNumber']; await this.updateRunInformation(); this.notify(); } @@ -124,10 +123,10 @@ export default class FilterModel extends Observable { */ async triggerFilter(baseViewModel) { this.setFilterToURL(); - - this.runNumber = this._filterMap['RunNumber']; await this.updateRunInformation(); + if (this.isRunModeActivated) { + this.runNumber = this._filterMap['RunNumber']; this.runStatus = this.runInformation.runStatus ?? RunStatus.UNKNOWN; this.notify(); this._manageRunsModeInterval(baseViewModel, true); @@ -163,6 +162,7 @@ export default class FilterModel extends Observable { */ clearFilters() { this._filterMap = {}; + this._runInformation = {}; this.setFilterToURL(true); this.notify(); } @@ -306,7 +306,8 @@ export default class FilterModel extends Observable { * If `this.runNumber` is not defined, `runInformation` is reset to an empty object. */ async updateRunInformation() { - this.runInformation = this.runNumber ? await this.filterService.getRunInformation(this.runNumber) : {}; + const runNumber = this._filterMap['RunNumber']; + this.runInformation = runNumber ? await this.filterService.getRunInformation(runNumber) : {}; } /** From e9be197d99766cfb8d47f83d1a6bba5eeaa47f35 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:21:10 +0100 Subject: [PATCH 04/15] Add and use BookkeepingDto.js --- QualityControl/lib/dtos/BookkeepingDto.js | 25 +++++++++++++++++++ .../lib/services/BookkeepingService.js | 21 ++++------------ QualityControl/lib/services/FilterService.js | 3 ++- .../test/lib/services/FilterService.test.js | 15 ----------- 4 files changed, 32 insertions(+), 32 deletions(-) create mode 100644 QualityControl/lib/dtos/BookkeepingDto.js diff --git a/QualityControl/lib/dtos/BookkeepingDto.js b/QualityControl/lib/dtos/BookkeepingDto.js new file mode 100644 index 000000000..6144b1006 --- /dev/null +++ b/QualityControl/lib/dtos/BookkeepingDto.js @@ -0,0 +1,25 @@ +/** + * @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. + */ + +/** + * Wraps a given run status value into a standardized result object. + * Use this helper when you need to return only a `runStatus` field without any + * additional payload. This ensures that all callers receive a consistent + * object shape, matching the structure returned by `retrieveRunInformation`. + * @param {RunStatus} runStatus The run status to wrap. Must be a valid `RunStatus` enum value. + * @returns {{ runStatus: RunStatus }} A simple object containing only the provided `runStatus`. + */ +export function wrapRunStatus(runStatus) { + return { runStatus }; +} diff --git a/QualityControl/lib/services/BookkeepingService.js b/QualityControl/lib/services/BookkeepingService.js index 565ac29be..64635074f 100644 --- a/QualityControl/lib/services/BookkeepingService.js +++ b/QualityControl/lib/services/BookkeepingService.js @@ -15,6 +15,7 @@ import { RunStatus } from '../../common/library/runStatus.enum.js'; import { httpGetJson } from '../utils/httpRequests.js'; import { LogManager } from '@aliceo2/web-ui'; +import { wrapRunStatus } from '../dtos/BookkeepingDto.js'; const GET_BKP_DATABASE_STATUS_PATH = '/api/status/database'; const GET_RUN_TYPES_PATH = '/api/runTypes'; @@ -148,7 +149,7 @@ export class BookkeepingService { async retrieveRunInformation(runNumber) { if (!this.active) { this._logger.warnMessage('Could not connect to bookkeeping'); - return this.wrapRunStatus(RunStatus.BOOKKEEPING_UNAVAILABLE); + return wrapRunStatus(RunStatus.BOOKKEEPING_UNAVAILABLE); } try { @@ -181,31 +182,19 @@ export class BookkeepingService { runQuality, lhcBeamMode, detectorQualities, - ...this.wrapRunStatus(runStatus), + ...wrapRunStatus(runStatus), }; } catch (error) { const msg = error?.message ?? String(error); if (msg.includes('404')) { this._logger.warnMessage(`Run number ${runNumber} not found in bookkeeping`); - return this.wrapRunStatus(RunStatus.NOT_FOUND); + return wrapRunStatus(RunStatus.NOT_FOUND); } this._logger.errorMessage(`Error fetching run status: ${error.message || error}`); - return this.wrapRunStatus(RunStatus.UNKNOWN); + return wrapRunStatus(RunStatus.UNKNOWN); } } - /** - * Wraps a given run status value into a standardized result object. - * Use this helper when you need to return only a `runStatus` field without any - * additional payload. This ensures that all callers receive a consistent - * object shape, matching the structure returned by `retrieveRunInformation`. - * @param {RunStatus} runStatus The run status to wrap. Must be a valid `RunStatus` enum value. - * @returns {{ runStatus: RunStatus }} A simple object containing only the provided `runStatus`. - */ - wrapRunStatus(runStatus) { - return { runStatus }; - } - /** * Helper method to construct a URL path with the required authentication token. * Appends the service's token as a query parameter to the provided path. diff --git a/QualityControl/lib/services/FilterService.js b/QualityControl/lib/services/FilterService.js index c05655b3b..d6da52c57 100644 --- a/QualityControl/lib/services/FilterService.js +++ b/QualityControl/lib/services/FilterService.js @@ -14,6 +14,7 @@ import { LogManager } from '@aliceo2/web-ui'; import { RunStatus } from '../../common/library/runStatus.enum.js'; +import { wrapRunStatus } from '../dtos/BookkeepingDto.js'; const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/filter-service`; @@ -97,7 +98,7 @@ export class FilterService { } catch (error) { const message = `Error while retrieving run status for run ${runNumber}: ${error.message || error}`; this._logger.errorMessage(message); - return this._bookkeepingService.wrapRunStatus(RunStatus.UNKNOWN); + return wrapRunStatus(RunStatus.UNKNOWN); } } } diff --git a/QualityControl/test/lib/services/FilterService.test.js b/QualityControl/test/lib/services/FilterService.test.js index 61b2c7859..509905506 100644 --- a/QualityControl/test/lib/services/FilterService.test.js +++ b/QualityControl/test/lib/services/FilterService.test.js @@ -33,7 +33,6 @@ export const filterServiceTestSuite = async () => { connect: stub(), retrieveRunTypes: stub(), retrieveRunInformation: stub(), - wrapRunStatus: stub(), active: true, // assume the bookkeeping service is active by default }; filterService = new FilterService(bookkeepingServiceMock, configMock); @@ -156,7 +155,6 @@ export const filterServiceTestSuite = async () => { test('should return UNKNOWN when bookkeeping service throws error', async () => { const testError = new Error('Bookkeeping service unavailable'); bookkeepingServiceMock.retrieveRunInformation.rejects(testError); - bookkeepingServiceMock.wrapRunStatus.callsFake((status) => ({ runStatus: status })); const { runStatus } = await filterService.getRunInformation(123456); @@ -164,17 +162,4 @@ export const filterServiceTestSuite = async () => { deepStrictEqual(runStatus, RunStatus.UNKNOWN); }); }); - - suite('wrapRunStatus', async () => { - test('should wrap a given RunStatus value into a standardized object', async () => { - const realService = new BookkeepingService(); - bookkeepingServiceMock.wrapRunStatus.callsFake(realService.wrapRunStatus); - - const runStatusOngoing = bookkeepingServiceMock.wrapRunStatus(RunStatus.ONGOING); - const runStatusEnded = bookkeepingServiceMock.wrapRunStatus(RunStatus.ENDED); - - deepStrictEqual(runStatusOngoing, { runStatus: RunStatus.ONGOING }); - deepStrictEqual(runStatusEnded, { runStatus: RunStatus.ENDED }); - }); - }); }; From c160e01d07a12dc2813bbd730b635599e331b669 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:26:46 +0100 Subject: [PATCH 05/15] Remove unused variable --- QualityControl/test/lib/services/FilterService.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/QualityControl/test/lib/services/FilterService.test.js b/QualityControl/test/lib/services/FilterService.test.js index 509905506..248671f04 100644 --- a/QualityControl/test/lib/services/FilterService.test.js +++ b/QualityControl/test/lib/services/FilterService.test.js @@ -17,7 +17,6 @@ import { suite, test, beforeEach, afterEach } from 'node:test'; import { FilterService } from '../../../lib/services/FilterService.js'; import { RunStatus } from '../../../common/library/runStatus.enum.js'; import { stub, restore } from 'sinon'; -import { BookkeepingService } from '../../../lib/services/BookkeepingService.js'; export const filterServiceTestSuite = async () => { let filterService = null; From a73ae42042f6f1399a5b5c0e5fc6aba1787f54e3 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:35:56 +0100 Subject: [PATCH 06/15] Use horizontal margin instead of justify center to scrolling bug --- QualityControl/public/common/filters/filterViews.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/QualityControl/public/common/filters/filterViews.js b/QualityControl/public/common/filters/filterViews.js index dfa91998d..f79ec53c2 100644 --- a/QualityControl/public/common/filters/filterViews.js +++ b/QualityControl/public/common/filters/filterViews.js @@ -104,10 +104,10 @@ export function filtersPanel(filterModel, viewModel) { ]), lastUpdatePanel(runStatus, lastRefresh, refreshRate), runInformation && h( - '.flex-row.g4.items-center.justify-center.f7.gray-darker.text-center.ph2', + '.flex-row.g4.items-center.f7.gray-darker.text-center.ph2', { id: 'runInformation', - style: 'overflow-x: auto;', + style: 'overflow-x: auto; margin: 0 auto;', }, Object.entries(runInformation).map(([key, value]) => h('.flex-row.g1', { From cf3ece558dabfc078ae88208b17f7e98c3f75d3e Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:02:37 +0100 Subject: [PATCH 07/15] Add typedef for RunInformation --- QualityControl/lib/dtos/BookkeepingDto.js | 33 ++++++++++++++++++- .../lib/services/BookkeepingService.js | 16 +-------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/QualityControl/lib/dtos/BookkeepingDto.js b/QualityControl/lib/dtos/BookkeepingDto.js index 6144b1006..68345bd34 100644 --- a/QualityControl/lib/dtos/BookkeepingDto.js +++ b/QualityControl/lib/dtos/BookkeepingDto.js @@ -12,13 +12,44 @@ * or submit itself to any jurisdiction. */ +/** + * Quality information for a single detector participating in the run. + * @typedef {object} DetectorQuality + * @property {number} id - Unique detector identifier. + * @property {string} name - The name (abbreviation) of the detector. + * @property {string} quality - Quality flag or classification for the detector. + */ + +/** + * Bookkeeping run information. + * @typedef {object} RunInformation + * @property {RunStatus} runStatus - Custom status for front-end consumption: + * - ONGOING: run is currently ongoing + * - ENDED: run has completed (timeO2End is present) + * - NOT_FOUND: run data does not exist + * - UNKNOWN: error occurred or data unavailable + * @property {number} startTime - Time (epoch) at which the run started. + * @property {number|undefined} endTime - Time (epoch) at which the run ended. If `undefined`, the run hasn't ended yet. + * @property {number|undefined} environmentId - Partition/environment the run belongs to. + * @property {string|undefined} definition - The definition of the run. + * @property {string} runQuality - Overall run quality. + * @property {string|undefined} lhcBeamMode - LHC beam mode during which the run was taken, if any. + * @property {DetectorQuality[]} detectorsQualities - Per-detector quality information. + */ + +/** + * Wrapped Run Status object + * @typedef {object} WrappedRunStatus + * @property {RunStatus} runStatus - The Run Status + */ + /** * Wraps a given run status value into a standardized result object. * Use this helper when you need to return only a `runStatus` field without any * additional payload. This ensures that all callers receive a consistent * object shape, matching the structure returned by `retrieveRunInformation`. * @param {RunStatus} runStatus The run status to wrap. Must be a valid `RunStatus` enum value. - * @returns {{ runStatus: RunStatus }} A simple object containing only the provided `runStatus`. + * @returns {WrappedRunStatus} A simple object containing only the provided `runStatus`. */ export function wrapRunStatus(runStatus) { return { runStatus }; diff --git a/QualityControl/lib/services/BookkeepingService.js b/QualityControl/lib/services/BookkeepingService.js index 64635074f..4b590e45f 100644 --- a/QualityControl/lib/services/BookkeepingService.js +++ b/QualityControl/lib/services/BookkeepingService.js @@ -130,21 +130,7 @@ export class BookkeepingService { /** * Retrieves the information of a specific run from the Bookkeeping service * @param {number} runNumber - The run number to check the status for - * @returns {Promise} - Returns a promise that resolves to the run information: - * - runStatus: A custom field created here for the front-end: - * - `RunStatus.ONGOING` if the run is ongoing - * - `RunStatus.ENDED` if the run has completed (has timeO2End) - * - `RunStatus.NOT_FOUND` if the data cannot be found - * - `RunStatus.UNKNOWN` if there was an error or data is not available - * - time at which the run has started - `startTime` - * - time at which the run has run ended - `endTime` - * - the run belongs to a partition also known as environment: `environmentId` - * - the run also is defined by multiple properties. - * Depending on which oneas are used the run has a definition: `definition` - * - the run has a quality that decides if it should be stored or not for long time: `runQuality` - * - run normally runs only during an LHC beam mode: `lhcBeamMode` - * - A run has multiple detectors taking data, - * thus we should also get the list of detectors and qualityies: `detectorQualities` + * @returns {Promise} - Returns a promise that resolves to the run information */ async retrieveRunInformation(runNumber) { if (!this.active) { From c00ecc3a97908b73e54d9624e6ae9b2b6c415374 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Tue, 16 Dec 2025 14:03:00 +0100 Subject: [PATCH 08/15] Rename `detectorQualities` to `detectorsQualities` and add default value `[]` --- QualityControl/lib/services/BookkeepingService.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/QualityControl/lib/services/BookkeepingService.js b/QualityControl/lib/services/BookkeepingService.js index 4b590e45f..f5d9f6353 100644 --- a/QualityControl/lib/services/BookkeepingService.js +++ b/QualityControl/lib/services/BookkeepingService.js @@ -155,7 +155,7 @@ export class BookkeepingService { definition, runQuality, lhcBeamMode, - detectorQualities, + detectorsQualities = [], timeO2End, } = data; const runStatus = timeO2End ? RunStatus.ENDED : RunStatus.ONGOING; @@ -167,7 +167,7 @@ export class BookkeepingService { definition, runQuality, lhcBeamMode, - detectorQualities, + detectorsQualities, ...wrapRunStatus(runStatus), }; } catch (error) { From c9b0c8ba12f98441e6cd1fc9afccdc10bc14869b Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Tue, 16 Dec 2025 21:00:24 +0100 Subject: [PATCH 09/15] Add a test to validate whether the run information endpoint returns the correct data on success --- .../lib/services/BookkeepingService.test.js | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/QualityControl/test/lib/services/BookkeepingService.test.js b/QualityControl/test/lib/services/BookkeepingService.test.js index 5ba82f732..9b1c0de5e 100644 --- a/QualityControl/test/lib/services/BookkeepingService.test.js +++ b/QualityControl/test/lib/services/BookkeepingService.test.js @@ -260,6 +260,34 @@ export const bookkeepingServiceTestSuite = async () => { strictEqual(runStatus, RunStatus.ENDED); }); + test('should return run information when data is present', async () => { + const mockResponse = { + data: { + startTime: 1, + endTime: 2, + definition: null, + runQuality: 'good', + lhcBeamMode: 'PHYSICS', + detectorsQualities: [], + }, + }; + + nock(VALID_CONFIG.bookkeeping.url).get(runsPathPattern).reply(200, mockResponse); + const { + startTime, + endTime, + definition, + runQuality, + lhcBeamMode, + detectorsQualities, + runStatus, + } = await bkpService.retrieveRunInformation(123); + const data = { startTime, endTime, definition, runQuality, lhcBeamMode, detectorsQualities }; + + deepStrictEqual(data, mockResponse.data); + ok(Object.values(RunStatus).includes(runStatus)); + }); + test('should return ONGOING status when timeO2End is not present', async () => { const mockResponse = { data: { timeO2End: undefined } }; From 2c1196ab0ef1ab49e937c80a175c24ce1adf7410 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:00:02 +0100 Subject: [PATCH 10/15] Add `statusBadgeSuccess`, `statusBadgeFail` and `statusBadge` helper functions --- QualityControl/public/app.css | 5 ++++ QualityControl/public/common/badge.js | 40 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 QualityControl/public/common/badge.js diff --git a/QualityControl/public/app.css b/QualityControl/public/app.css index 4b89e34b3..ecc2ed2fa 100644 --- a/QualityControl/public/app.css +++ b/QualityControl/public/app.css @@ -155,3 +155,8 @@ .cursor-pointer { cursor: pointer; } + +.b1 { border-style: solid; border-width: 1px; } + +.b-danger { border-color: var(--color-danger); } +.b-success { border-color: var(--color-success); } diff --git a/QualityControl/public/common/badge.js b/QualityControl/public/common/badge.js new file mode 100644 index 000000000..50ea3ea8f --- /dev/null +++ b/QualityControl/public/common/badge.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. + */ + +import { h, iconCheck, iconX } from '/js/src/index.js'; + +/** + * A green success badge with the tick icon + * @param {string} text - Text to display in the badge + * @returns {vnode} The badge virtual node + */ +export const statusBadgeSuccess = (text) => + h('.badge.success.b-success.b1', h('.flex-row.g1', [text, iconCheck()])); + +/** + * A red failure badge with the X icon + * @param {string} text - Text to display in the badge + * @returns {vnode} The badge virtual node + */ +export const statusBadgeFail = (text) => + h('.badge.danger.b-danger.b1', h('.flex-row.g1', [text, iconX()])); + +/** + * A status badge with dynamic color and icon depending on success or failure. + * @param {string} text - Text to display inside the badge + * @param {boolean} success - Whether the badge represents success (`true`) or failure (`false`) + * @returns {vnode} The badge virtual node + */ +export const statusBadge = (text, success) => + success ? statusBadgeSuccess(text) : statusBadgeFail(text); From c8d8cd2677e7263bebcd2a84ad3f428552f2ac63 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:35:15 +0100 Subject: [PATCH 11/15] Display the run detector qualities in badges on a new line beneath the run information --- .../public/common/filters/filterViews.js | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/QualityControl/public/common/filters/filterViews.js b/QualityControl/public/common/filters/filterViews.js index f79ec53c2..e40b3228f 100644 --- a/QualityControl/public/common/filters/filterViews.js +++ b/QualityControl/public/common/filters/filterViews.js @@ -19,6 +19,7 @@ import { runModeCheckbox } from './runMode/runModeCheckbox.js'; import { lastUpdatePanel, runStatusPanel } from './runMode/runStatusPanel.js'; import { h, iconChevronBottom, iconChevronTop } from '/js/src/index.js'; import { camelToTitleCase } from '../utils.js'; +import { statusBadge } from '../badge.js'; /** * Creates an input element for a specific metadata field; @@ -90,6 +91,7 @@ export function filtersPanel(filterModel, viewModel) { const filtersList = isRunModeActivated ? runModeFilterConfig(filterService) : filtersConfig(filterService); + const { detectorsQualities, ...cleanRunInformation } = runInformation; return h( '.w-100.flex-column.p2.g2.justify-center#filterElement', @@ -103,20 +105,37 @@ export function filtersPanel(filterModel, viewModel) { isRunModeActivated && runStatusPanel(runStatus), ]), lastUpdatePanel(runStatus, lastRefresh, refreshRate), - runInformation && h( - '.flex-row.g4.items-center.f7.gray-darker.text-center.ph2', + cleanRunInformation && Object.keys(cleanRunInformation).length > 0 && h( + '.flex-row.g4.items-center.f7.gray-darker.text-center.ph4', { - id: 'runInformation', + id: 'header-run-information', style: 'overflow-x: auto; margin: 0 auto;', }, - Object.entries(runInformation).map(([key, value]) => + Object.entries(cleanRunInformation).map(([key, value]) => h('.flex-row.g1', { + key: `${key}-${value}`, style: 'flex: 0 0 auto;', }, [ h('strong', `${camelToTitleCase(key)}:`), h('span', `${value}`), ])), ), + Array.isArray(detectorsQualities) && detectorsQualities.length > 0 && h( + '.flex-row.g3.items-center.f7.gray-darker.text-center.ph3', + { + id: 'header-detector-qualities', + style: 'overflow-x: auto;', + }, + detectorsQualities.map(({ id, name, quality }) => + h( + '.flex-row.g1', + { + key: `${id}-${name}-${quality}`, + style: 'flex: 0 0 auto;', + }, + statusBadge(name, quality === 'good'), + )), + ), ], ); } From 8bd40325d2cfd6d388c245a6f596a745b6845c7c Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:35:40 +0100 Subject: [PATCH 12/15] Add tests for the run information and detector qualities displays --- .../test/public/features/filterTest.test.js | 153 +++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) diff --git a/QualityControl/test/public/features/filterTest.test.js b/QualityControl/test/public/features/filterTest.test.js index bb4130d09..b73b28708 100644 --- a/QualityControl/test/public/features/filterTest.test.js +++ b/QualityControl/test/public/features/filterTest.test.js @@ -11,8 +11,9 @@ * or submit itself to any jurisdiction. */ -import { strictEqual } from 'node:assert'; +import { strictEqual, ok } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; +import { RunStatus } from '../../../common/library/runStatus.enum.js'; export const filterTests = async (url, page, timeout = 5000, testParent) => { await testParent.test('filter should persist between pages', { timeout }, async () => { @@ -50,7 +51,157 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { strictEqual(value, '0', 'RunNumber filter should still be set to 0 on layout show page'); }); + await testParent.test( + 'should display detector qualities when filtering by run number if it has any', + { timeout }, + async () => { + const requestHandler = async (interceptedRequest) => { + const url = interceptedRequest.url(); + + if (url.includes('/api/filter/run-status/0')) { + // Mock the response + await interceptedRequest.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + detectorsQualities: [ + { id: 1, name: 'DETECTOR_GOOD_1', quality: 'good' }, + { id: 1, name: 'DETECTOR_GOOD_2', quality: 'good' }, + { id: 2, name: 'DETECTOR_BAD', quality: 'bad' }, + ], + }), + }); + } else { + interceptedRequest.continue(); + } + }; + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.locator('#triggerFilterButton').click(); + const detectorQualities = await page.waitForSelector('#header-detector-qualities', { + visible: true, + timeout: 1000, + }); + ok(detectorQualities, 'Detector qualities should exist on the page'); + + const goodDetectorCount = await page.evaluate(() => + document.querySelectorAll('#header-detector-qualities .success').length); + const badDetectorCount = await page.evaluate(() => + document.querySelectorAll('#header-detector-qualities .danger').length); + + strictEqual(goodDetectorCount, 2, 'Two good detector qualities should exist on the page'); + strictEqual(badDetectorCount, 1, 'One bad detector qualities should exist on the page'); + } catch (error) { + // Test failed + ok(false, error.message); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + }, + ); + + await testParent.test( + 'should not display detector qualities if the run has none when filtering by run number', + { timeout }, + async () => { + const requestHandler = async (interceptedRequest) => { + const url = interceptedRequest.url(); + + if (url.includes('/api/filter/run-status/0')) { + // Mock the response + await interceptedRequest.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + detectorsQualities: [], + }), + }); + } else { + interceptedRequest.continue(); + } + }; + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.locator('#triggerFilterButton').click(); + await delay(100); + + const runDetectorQualitiesExists = await page.evaluate(() => + document.querySelector('#header-detector-qualities') !== null); + strictEqual(runDetectorQualitiesExists, false, 'Run detector qualities should not exists on the page'); + } catch (error) { + // Test failed + ok(false, error.message); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + }, + ); + + await testParent.test('not filtering by run number should not display any run information', { timeout }, async () => { + await page.locator('#runNumberFilter').fill('Backspace'); + await page.locator('#triggerFilterButton').click(); + await delay(100); + + // Not filtering by run number should not display the run information + const runInformationExists = await page.evaluate(() => document.querySelector('#header-run-information') !== null); + strictEqual(runInformationExists, false, 'Run information should not exists on the page'); + }); + + await testParent.test('filtering by run number should display the run information', { timeout }, async () => { + const requestHandler = async (interceptedRequest) => { + const url = interceptedRequest.url(); + + if (url.includes('/api/filter/run-status/0')) { + // Mock the response + await interceptedRequest.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + runStatus: RunStatus.UNKNOWN, + }), + }); + } else { + interceptedRequest.continue(); + } + }; + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + // Fill and trigger the filter + await page.locator('#runNumberFilter').fill('0'); + await page.locator('#triggerFilterButton').click(); + + // Filtering by run number should display the run information + const runInformation = await page.waitForSelector('#header-run-information', { visible: true, timeout: 1000 }); + ok(runInformation, 'Run information should exists on the page'); + } catch (error) { + // Test failed + ok(false, error.message); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + }); + await testParent.test('should list all objects when clearing filters', { timeout }, async () => { + await page.locator('#triggerFilterButton').click(); + await delay(100); //Navigate to object tree ///html/body/div[1]/div/nav/a[2] await page.locator('nav > a:nth-child(3)').click(); From 3947c8e6235285cba8c970c0462c48e29f538e89 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:04:18 +0100 Subject: [PATCH 13/15] Fix an issue with horizontal scrolling and the use of `justify-content: center;` --- QualityControl/public/app.css | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/QualityControl/public/app.css b/QualityControl/public/app.css index 717b37345..c6bdab4d0 100644 --- a/QualityControl/public/app.css +++ b/QualityControl/public/app.css @@ -156,7 +156,6 @@ cursor: pointer; } - .b1 { border-style: solid; border-width: 1px; } .b-danger { border-color: var(--color-danger); } @@ -194,3 +193,10 @@ white-space: nowrap; } +/* This hacky workaround is required due to `justify-content: center;` being unusable thanks to a horizontal scrolling bug */ +#header-detector-qualities { + &::before, &::after { + content: ''; + flex: 1; + } +} From f0ee16c49b493610989b3fe3345b393109f01184 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:10:00 +0100 Subject: [PATCH 14/15] Add components `cleanRunInformationPanel` and `detectorsQualitiesPanel` --- .../public/common/filters/filterViews.js | 48 ++++--------------- .../common/filters/runMode/runStatusPanel.js | 47 ++++++++++++++++++ 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/QualityControl/public/common/filters/filterViews.js b/QualityControl/public/common/filters/filterViews.js index e40b3228f..e5eb8edd7 100644 --- a/QualityControl/public/common/filters/filterViews.js +++ b/QualityControl/public/common/filters/filterViews.js @@ -16,10 +16,13 @@ import { filterInput, dynamicSelector, ongoingRunsSelector } from './filter.js'; import { FilterType } from './filterTypes.js'; import { filtersConfig, runModeFilterConfig } from './filtersConfig.js'; import { runModeCheckbox } from './runMode/runModeCheckbox.js'; -import { lastUpdatePanel, runStatusPanel } from './runMode/runStatusPanel.js'; +import { + cleanRunInformationPanel, + detectorsQualitiesPanel, + lastUpdatePanel, + runStatusPanel, +} from './runMode/runStatusPanel.js'; import { h, iconChevronBottom, iconChevronTop } from '/js/src/index.js'; -import { camelToTitleCase } from '../utils.js'; -import { statusBadge } from '../badge.js'; /** * Creates an input element for a specific metadata field; @@ -105,47 +108,12 @@ export function filtersPanel(filterModel, viewModel) { isRunModeActivated && runStatusPanel(runStatus), ]), lastUpdatePanel(runStatus, lastRefresh, refreshRate), - cleanRunInformation && Object.keys(cleanRunInformation).length > 0 && h( - '.flex-row.g4.items-center.f7.gray-darker.text-center.ph4', - { - id: 'header-run-information', - style: 'overflow-x: auto; margin: 0 auto;', - }, - Object.entries(cleanRunInformation).map(([key, value]) => - h('.flex-row.g1', { - key: `${key}-${value}`, - style: 'flex: 0 0 auto;', - }, [ - h('strong', `${camelToTitleCase(key)}:`), - h('span', `${value}`), - ])), - ), - Array.isArray(detectorsQualities) && detectorsQualities.length > 0 && h( - '.flex-row.g3.items-center.f7.gray-darker.text-center.ph3', - { - id: 'header-detector-qualities', - style: 'overflow-x: auto;', - }, - detectorsQualities.map(({ id, name, quality }) => - h( - '.flex-row.g1', - { - key: `${id}-${name}-${quality}`, - style: 'flex: 0 0 auto;', - }, - statusBadge(name, quality === 'good'), - )), - ), + cleanRunInformationPanel(cleanRunInformation), + detectorsQualitiesPanel(detectorsQualities), ], ); } -/** - * Determines if runs mode is allowed based on current page and context - * @param {object} viewModel - Model that manages the state of the page - * @returns {boolean} - whether runs mode is allowed - */ - /** * Button which will allow the user to update filter parameters after the input * @param {Function} onClickCallback - Function to trigger the filter mechanism diff --git a/QualityControl/public/common/filters/runMode/runStatusPanel.js b/QualityControl/public/common/filters/runMode/runStatusPanel.js index c876f741f..90b6f792a 100644 --- a/QualityControl/public/common/filters/runMode/runStatusPanel.js +++ b/QualityControl/public/common/filters/runMode/runStatusPanel.js @@ -13,6 +13,8 @@ import { RunStatus } from '../../../../../library/runStatus.enum.js'; import { h } from '/js/src/index.js'; +import { camelToTitleCase } from '../../utils.js'; +import { statusBadge } from '../../badge.js'; /** * Creates and returns a run status panel element displaying the current run number, @@ -53,3 +55,48 @@ export const lastUpdatePanel = (runStatus, lastRefresh, refreshRate = 15000) => ), ]); }; + +/** + * Renders the run information panel + * @param {object} cleanRunInformation - The `RunInformation` without `detectorsQualities` + * @returns {vnode} - virtual node element + */ +export const cleanRunInformationPanel = (cleanRunInformation) => + cleanRunInformation && Object.keys(cleanRunInformation).length > 0 && h( + '.flex-row.g4.items-center.f7.gray-darker.text-center.ph4', + { + id: 'header-run-information', + style: 'overflow-x: auto; margin: 0 auto;', + }, + Object.entries(cleanRunInformation).map(([key, value]) => + h('.flex-row.g1', { + key: `${key}-${value}`, + style: 'flex: 0 0 auto;', + }, [ + h('strong', `${camelToTitleCase(key)}:`), + h('span', `${value}`), + ])), + ); + +/** + * Renders the detector qualities panel + * @param {DetectorQuality[]} detectorsQualities - The detector qualities of the run + * @returns {vnode} - virtual node element + */ +export const detectorsQualitiesPanel = (detectorsQualities) => + Array.isArray(detectorsQualities) && detectorsQualities.length > 0 && h( + '.flex-row.g3.items-center.f7.gray-darker.text-center.ph3', + { + id: 'header-detector-qualities', + style: 'overflow-x: auto;', + }, + detectorsQualities.map(({ id, name, quality }) => + h( + '.flex-row.g1', + { + key: `${id}-${name}-${quality}`, + style: 'flex: 0 0 auto;', + }, + statusBadge(name, quality === 'good'), + )), + ); From 924bfbbb28a0699e621a154e7e36220d9e0f4dff Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:21:17 +0100 Subject: [PATCH 15/15] Small changes to some jsdoc --- QualityControl/public/common/filters/model/FilterModel.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/QualityControl/public/common/filters/model/FilterModel.js b/QualityControl/public/common/filters/model/FilterModel.js index 6006b8ad9..d2f209732 100644 --- a/QualityControl/public/common/filters/model/FilterModel.js +++ b/QualityControl/public/common/filters/model/FilterModel.js @@ -304,6 +304,7 @@ export default class FilterModel extends Observable { * via `filterService.getRunInformation(runNumber)` and sets it to `runInformation`, * automatically applying any filtering and transformations defined in the setter. * If `this.runNumber` is not defined, `runInformation` is reset to an empty object. + * @returns {undefined} */ async updateRunInformation() { const runNumber = this._filterMap['RunNumber']; @@ -338,8 +339,9 @@ export default class FilterModel extends Observable { * Sets the run information after filtering and transforming values. * - Filters out properties that are `null` or `undefined`. * - Applies a transformation function from `RUN_INFORMATION_MAP` for any matching keys. - * @param {object} value - The new run information object to set. + * @param {RunInformation} value - The new run information object to set. * Keys corresponding to functions in `RUN_INFORMATION_MAP` will be transformed accordingly. + * @returns {undefined} */ set runInformation(value) { const runInfo = value && typeof value === 'object' ? value : {};