From d2a398cc88f62c2f2ad5682c11e38f42347160d5 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Mon, 19 Oct 2026 14:51:54 +0200 Subject: [PATCH 1/4] [O2B-1631] Display simulation passes linked to log entries Return the simulation passes ({ id, name }) of each log from the log overview endpoint and from the standard log query shared by the run, LHC fill, environment and tag log endpoints, so embedded log tables also get them. In the front-end, add a "Simulation passes" column to the logs table and a field to the log details, both linking to the runs of the simulation pass. The title column is narrowed (w-30 to w-20) to keep the total table width unchanged, otherwise the actions column becomes unclickable. --- .../components/Log/logDisplayComponent.js | 10 ++++++- .../Logs/ActiveColumns/logsActiveColumns.js | 12 ++++++++- .../format/formatSimulationPassesList.js | 26 +++++++++++++++++++ lib/server/services/log/LogService.js | 1 + lib/usecases/log/GetAllLogsUseCase.js | 1 + test/api/environments.test.js | 1 + test/api/lhcFills.test.js | 1 + test/api/logs.test.js | 11 ++++++++ test/api/runs.test.js | 1 + test/api/tags.test.js | 1 + .../server/services/log/LogService.test.js | 1 + .../usecases/log/GetAllLogsUseCase.test.js | 7 +++++ test/lib/usecases/log/GetLogUseCase.test.js | 6 +++++ test/public/logs/detail.test.js | 11 ++++++++ test/public/logs/overview.test.js | 8 ++++++ 15 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 lib/public/views/SimulationPasses/format/formatSimulationPassesList.js diff --git a/lib/public/components/Log/logDisplayComponent.js b/lib/public/components/Log/logDisplayComponent.js index f28e919838..ca6105bda7 100644 --- a/lib/public/components/Log/logDisplayComponent.js +++ b/lib/public/components/Log/logDisplayComponent.js @@ -71,6 +71,14 @@ const logFields = () => ({ parameters: { dataPassId: id }, })), }, + simulationPasses: { + name: 'Simulation passes', + format: (simulationPasses) => detailsFrontLinks(simulationPasses, ({ id, name }) => ({ + content: name, + page: 'runs-per-simulation-pass', + parameters: { simulationPassId: id }, + })), + }, attachments: { name: 'Attachments', visible: true, @@ -121,7 +129,7 @@ const displayLogText = (log, options) => { ); }; -const expandedLogFields = ['runs', 'environments', 'lhcFills', 'dataPasses', 'attachments']; +const expandedLogFields = ['runs', 'environments', 'lhcFills', 'dataPasses', 'simulationPasses', 'attachments']; /** * Returns a card for the given log diff --git a/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js b/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js index 7379d9d21e..27ed139c1b 100644 --- a/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js +++ b/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js @@ -23,6 +23,7 @@ import { formatRunsList } from '../../Runs/format/formatRunsList.js'; import { profiles } from '../../../components/common/table/profiles.js'; import { formatLhcFillsList } from '../../LhcFills/format/formatLhcFillsList.js'; import { formatDataPassesList } from '../../DataPasses/format/formatDataPassesList.js'; +import { formatSimulationPassesList } from '../../SimulationPasses/format/formatSimulationPassesList.js'; import { formatTagsList } from '../../Tags/format/formatTagsList.js'; import { timeRangeFilter } from '../../../components/Filters/common/filters/timeRangeFilter.js'; import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; @@ -68,7 +69,7 @@ export const logsActiveColumns = { name: 'Title', visible: true, sortable: true, - size: 'w-30', + size: 'w-20', /** * Title filter component @@ -243,6 +244,15 @@ export const logsActiveColumns = { balloon: true, profiles: [profiles.none, 'embeded'], }, + simulationPasses: { + name: 'Simulation passes', + visible: true, + sortable: false, + size: 'w-10', + format: formatSimulationPassesList, + balloon: true, + profiles: [profiles.none, 'embeded'], + }, lhcPeriods: { name: 'LHC Periods', visible: false, diff --git a/lib/public/views/SimulationPasses/format/formatSimulationPassesList.js b/lib/public/views/SimulationPasses/format/formatSimulationPassesList.js new file mode 100644 index 0000000000..242b10a515 --- /dev/null +++ b/lib/public/views/SimulationPasses/format/formatSimulationPassesList.js @@ -0,0 +1,26 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * 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 { frontLinks } from '../../../components/common/navigation/frontLinks.js'; + +/** + * Format a given simulation passes list as a list of hyperlink to their runs page + * + * @param {{id: number, name: string}[]} simulationPasses the list of simulation passes to display + * @return {Component} the list of simulation passes + */ +export const formatSimulationPassesList = (simulationPasses) => frontLinks(simulationPasses, ({ id, name }) => ({ + content: name, + page: 'runs-per-simulation-pass', + parameters: { simulationPassId: id }, +})); diff --git a/lib/server/services/log/LogService.js b/lib/server/services/log/LogService.js index b7896972af..883b08e3d4 100644 --- a/lib/server/services/log/LogService.js +++ b/lib/server/services/log/LogService.js @@ -207,6 +207,7 @@ class LogService { .include({ association: 'runs', attributes: ['id', 'runNumber'] }) .include({ association: 'lhcFills', attributes: ['fillNumber'] }) .include({ association: 'dataPasses', attributes: ['id', 'name'] }) + .include({ association: 'simulationPasses', attributes: ['id', 'name'] }) .include({ association: 'environments', attributes: ['id'] }) .include({ association: 'user', attributes: ['name'] }) .include('tags') diff --git a/lib/usecases/log/GetAllLogsUseCase.js b/lib/usecases/log/GetAllLogsUseCase.js index 3384674a16..7639720dcd 100644 --- a/lib/usecases/log/GetAllLogsUseCase.js +++ b/lib/usecases/log/GetAllLogsUseCase.js @@ -253,6 +253,7 @@ class GetAllLogsUseCase { .include({ association: 'runs', attributes: ['id', 'runNumber'] }) .include({ association: 'lhcFills', attributes: ['fillNumber'] }) .include({ association: 'dataPasses', attributes: ['id', 'name'] }) + .include({ association: 'simulationPasses', attributes: ['id', 'name'] }) .include('attachments') .include({ association: 'environments', attributes: ['id'] }); diff --git a/test/api/environments.test.js b/test/api/environments.test.js index 4c573c3e1b..bd6bfd3535 100644 --- a/test/api/environments.test.js +++ b/test/api/environments.test.js @@ -349,6 +349,7 @@ module.exports = () => { expect(log.lhcFills.map(({ fillNumber }) => fillNumber)).to.have.members([5, 6]); expect(log.environments.map(({ id }) => id)).to.have.members(['8E4aZTjY', 'eZF99lH6']); expect(log.dataPasses.map(({ name }) => name)).to.have.members(['LHC22b_apass1', 'LHC22b_skimming']); + expect(log.simulationPasses.map(({ name }) => name)).to.have.members(['LHC23k6c', 'LHC23k6b']); expect(log.attachments).to.be.an('array'); }); }); diff --git a/test/api/lhcFills.test.js b/test/api/lhcFills.test.js index adb661ae45..65214e52db 100644 --- a/test/api/lhcFills.test.js +++ b/test/api/lhcFills.test.js @@ -964,6 +964,7 @@ module.exports = () => { expect(log.lhcFills.map(({ fillNumber }) => fillNumber)).to.have.members([5, 6]); expect(log.environments.map(({ id }) => id)).to.have.members(['8E4aZTjY', 'eZF99lH6']); expect(log.dataPasses.map(({ name }) => name)).to.have.members(['LHC22b_apass1', 'LHC22b_skimming']); + expect(log.simulationPasses.map(({ name }) => name)).to.have.members(['LHC23k6c', 'LHC23k6b']); expect(log.attachments).to.be.an('array'); }); }); diff --git a/test/api/logs.test.js b/test/api/logs.test.js index 1801f5785a..739a617485 100644 --- a/test/api/logs.test.js +++ b/test/api/logs.test.js @@ -256,6 +256,17 @@ module.exports = () => { } }); + it('should return the simulation passes linked to each log', async () => { + const response = await request(server).get('/api/logs?sort[id]=asc'); + expect(response.status).to.equal(200); + + const log1 = response.body.data.find(({ id }) => id === 1); + expect(log1.simulationPasses.map(({ name }) => name).sort()).to.deep.equal(['LHC23k6b', 'LHC23k6c']); + for (const { simulationPasses } of response.body.data) { + expect(simulationPasses).to.be.an('array'); + } + }); + it('should return the data passes linked to each log', async () => { const response = await request(server).get('/api/logs?sort[id]=asc'); expect(response.status).to.equal(200); diff --git a/test/api/runs.test.js b/test/api/runs.test.js index 67747b9b99..17f74ed5d2 100644 --- a/test/api/runs.test.js +++ b/test/api/runs.test.js @@ -1071,6 +1071,7 @@ module.exports = () => { expect(log.lhcFills.map(({ fillNumber }) => fillNumber)).to.have.members([5, 6]); expect(log.environments.map(({ id }) => id)).to.have.members(['8E4aZTjY', 'eZF99lH6']); expect(log.dataPasses.map(({ name }) => name)).to.have.members(['LHC22b_apass1', 'LHC22b_skimming']); + expect(log.simulationPasses.map(({ name }) => name)).to.have.members(['LHC23k6c', 'LHC23k6b']); expect(log.attachments).to.be.an('array'); // Tags must be complete to be displayed with their color and archived state expect(log.tags).to.not.be.empty; diff --git a/test/api/tags.test.js b/test/api/tags.test.js index 7cd52b2837..073da4a64d 100644 --- a/test/api/tags.test.js +++ b/test/api/tags.test.js @@ -484,6 +484,7 @@ module.exports = () => { expect(log.lhcFills.map(({ fillNumber }) => fillNumber)).to.have.members([5, 6]); expect(log.environments.map(({ id }) => id)).to.have.members(['8E4aZTjY', 'eZF99lH6']); expect(log.dataPasses.map(({ name }) => name)).to.have.members(['LHC22b_apass1', 'LHC22b_skimming']); + expect(log.simulationPasses.map(({ name }) => name)).to.have.members(['LHC23k6c', 'LHC23k6b']); expect(log.attachments).to.be.an('array'); }); }); diff --git a/test/lib/server/services/log/LogService.test.js b/test/lib/server/services/log/LogService.test.js index 3d272cdf02..8648802c18 100644 --- a/test/lib/server/services/log/LogService.test.js +++ b/test/lib/server/services/log/LogService.test.js @@ -127,6 +127,7 @@ module.exports = () => { expect(log.lhcFills.map(({ fillNumber }) => fillNumber)).to.have.members([5, 6]); expect(log.environments.map(({ id }) => id)).to.have.members(['8E4aZTjY', 'eZF99lH6']); expect(log.dataPasses.map(({ name }) => name)).to.have.members(['LHC22b_apass1', 'LHC22b_skimming']); + expect(log.simulationPasses.map(({ name }) => name)).to.have.members(['LHC23k6c', 'LHC23k6b']); // Complete tags (with their color and archived state) are required to display the logs, the author is only exposed by name expect(log.tags).to.not.be.empty; expect(log.tags.every(({ archived, updatedAt }) => typeof archived === 'boolean' && typeof updatedAt === 'number')).to.be.true; diff --git a/test/lib/usecases/log/GetAllLogsUseCase.test.js b/test/lib/usecases/log/GetAllLogsUseCase.test.js index ea07a40fa6..07141bfc77 100644 --- a/test/lib/usecases/log/GetAllLogsUseCase.test.js +++ b/test/lib/usecases/log/GetAllLogsUseCase.test.js @@ -39,6 +39,13 @@ module.exports = () => { expect(logs.find(({ id }) => id === 119).dataPasses.map(({ name }) => name)).to.deep.equal(['LHC22b_apass1']); }); + it('should return the simulation passes of each log', async () => { + const { logs } = await new GetAllLogsUseCase().execute(); + + expect(logs.every(({ simulationPasses }) => Array.isArray(simulationPasses))).to.be.true; + expect(logs.find(({ id }) => id === 119).simulationPasses.map(({ name }) => name)).to.deep.equal(['LHC23k6c']); + }); + it('should return an array, only containing human originated logs', async () => { getAllLogsDto.query = { filter: { origin: 'human' } }; const { logs } = await new GetAllLogsUseCase() diff --git a/test/lib/usecases/log/GetLogUseCase.test.js b/test/lib/usecases/log/GetLogUseCase.test.js index b750787728..6c56fd93de 100644 --- a/test/lib/usecases/log/GetLogUseCase.test.js +++ b/test/lib/usecases/log/GetLogUseCase.test.js @@ -36,6 +36,12 @@ module.exports = () => { expect(result.id).to.equal(1); }); + it('should return the simulation passes linked to the log', async () => { + const result = await new GetLogUseCase().execute(getLogDto); + + expect(result.simulationPasses.map(({ name }) => name).sort()).to.deep.equal(['LHC23k6b', 'LHC23k6c']); + }); + it('should return the data passes linked to the log', async () => { const result = await new GetLogUseCase().execute(getLogDto); diff --git a/test/public/logs/detail.test.js b/test/public/logs/detail.test.js index 0ba3aad20d..c4d7e59612 100644 --- a/test/public/logs/detail.test.js +++ b/test/public/logs/detail.test.js @@ -164,6 +164,17 @@ module.exports = () => { expectUrlParams(page, { page: 'runs-per-data-pass', dataPassId: '1' }); }); + it('allows navigating to an associated simulation pass', async () => { + const logId = 119; + + await goToPage(page, 'log-detail', { queryParameters: { id: logId } }); + + await expectInnerText(page, `#log-${logId}-simulationPasses`, 'Simulation passes:\nLHC23k6c'); + + await waitForNavigation(page, () => pressElement(page, `#log-${logId}-simulationPasses a`)); + expectUrlParams(page, { page: 'runs-per-simulation-pass', simulationPassId: '1' }); + }); + it('should have a button to reply on a entry', async () => { const parentLogId = 2; await goToPage(page, 'log-detail', { queryParameters: { id: parentLogId } }); diff --git a/test/public/logs/overview.test.js b/test/public/logs/overview.test.js index ae63b31f76..3a0d872ace 100644 --- a/test/public/logs/overview.test.js +++ b/test/public/logs/overview.test.js @@ -352,6 +352,14 @@ module.exports = () => { expectUrlParams(page, { page: 'runs-per-data-pass', dataPassId: '1' }); }); + it('should successfully display the list of related simulation passes as hyperlinks to their runs page', async () => { + await goToPage(page, 'log-overview'); + await page.evaluate(() => window.model.disableInputDebounce()); + + await waitForNavigation(page, () => pressElement(page, '#row119-simulationPasses a')); + expectUrlParams(page, { page: 'runs-per-simulation-pass', simulationPassId: '1' }); + }); + it('should successfult navigate to run details via popover', async () => { await goToPage(page, 'log-overview'); await waitForFirstRowToHaveId(page, 'row119'); From e3d9467bb11b335b6ed3f9731b5ca39510115c01 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Mon, 19 Oct 2026 14:54:32 +0200 Subject: [PATCH 2/4] [O2B-1631] Filter log entries by simulation passes Add the `simulationPasses` filter (comma separated names) to GET /api/logs: a log matches if it is linked to at least one of the given simulation passes, like the data passes filter. Add the matching text filter to the logs overview page. Includes use case, API and UI tests. --- lib/domain/dtos/GetAllLogsDto.js | 1 + .../Logs/ActiveColumns/logsActiveColumns.js | 9 +++++ .../views/Logs/Overview/LogsOverviewModel.js | 1 + lib/usecases/log/GetAllLogsUseCase.js | 10 +++++ test/api/logs.test.js | 15 ++++++- .../usecases/log/GetAllLogsUseCase.test.js | 39 +++++++++++++++++++ test/public/logs/overview.test.js | 13 +++++++ 7 files changed, 86 insertions(+), 2 deletions(-) diff --git a/lib/domain/dtos/GetAllLogsDto.js b/lib/domain/dtos/GetAllLogsDto.js index 4018cdffa7..d483bf20c1 100644 --- a/lib/domain/dtos/GetAllLogsDto.js +++ b/lib/domain/dtos/GetAllLogsDto.js @@ -38,6 +38,7 @@ const FilterDto = Joi.object({ rootOnly: Joi.boolean(), environmentIds: EnvironmentsFilterDto, dataPasses: StringsFilterDto, + simulationPasses: StringsFilterDto, lhcPeriods: StringsFilterDto, }); diff --git a/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js b/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js index 27ed139c1b..fe2d8ad5e1 100644 --- a/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js +++ b/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js @@ -250,6 +250,15 @@ export const logsActiveColumns = { sortable: false, size: 'w-10', format: formatSimulationPassesList, + + /** + * Simulation passes filter component + * + * @param {object} logOverviewModel the overview model + * @param {FilteringModel} logOverviewModel.filteringModel filtering model + * @return {Component} the filter component + */ + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'simulationPasses', 'e.g. LHC23k6c, LHC23k6b'), balloon: true, profiles: [profiles.none, 'embeded'], }, diff --git a/lib/public/views/Logs/Overview/LogsOverviewModel.js b/lib/public/views/Logs/Overview/LogsOverviewModel.js index 263404fb6f..93e5ad4d84 100644 --- a/lib/public/views/Logs/Overview/LogsOverviewModel.js +++ b/lib/public/views/Logs/Overview/LogsOverviewModel.js @@ -44,6 +44,7 @@ export class LogsOverviewModel extends FilterableOverviewPageModel { environmentIds: new RawTextFilterModel(), fillNumbers: new RawTextFilterModel(), dataPasses: new RawTextFilterModel(), + simulationPasses: new RawTextFilterModel(), lhcPeriods: new RawTextFilterModel(), created: new TimeRangeInputModel(), }, diff --git a/lib/usecases/log/GetAllLogsUseCase.js b/lib/usecases/log/GetAllLogsUseCase.js index 7639720dcd..9ba516bc8b 100644 --- a/lib/usecases/log/GetAllLogsUseCase.js +++ b/lib/usecases/log/GetAllLogsUseCase.js @@ -21,6 +21,7 @@ const { LogLhcFillsRepository, LogEnvironmentsRepository, LogDataPassesRepository, + LogSimulationPassesRepository, }, } = require('../../database'); const { getTagsByText } = require('../../server/services/tag/getTagsByText.js'); @@ -54,6 +55,7 @@ const applyFilter = async (dataSource, queryBuilder, filter) => { environmentIds, fillNumbers, dataPasses, + simulationPasses, lhcPeriods, } = filter; @@ -164,6 +166,14 @@ const applyFilter = async (dataSource, queryBuilder, filter) => { queryBuilder.where('id').oneOf(...new Set(logDataPasses.map(({ logId }) => logId))); } + if (simulationPasses) { + // The log must be linked to at least one of the given simulation passes + const logSimulationPasses = await LogSimulationPassesRepository.findAll(dataSource.createQueryBuilder() + .include({ association: 'simulationPass', attributes: [], required: true, where: { name: { [Op.in]: simulationPasses } } })); + + queryBuilder.where('id').oneOf(...new Set(logSimulationPasses.map(({ logId }) => logId))); + } + if (lhcPeriods) { // Logs are not directly linked to LHC periods, use the period of their linked data passes. At least one period must match const logDataPasses = await LogDataPassesRepository.findAll(dataSource.createQueryBuilder() diff --git a/test/api/logs.test.js b/test/api/logs.test.js index 739a617485..b79ec63a9d 100644 --- a/test/api/logs.test.js +++ b/test/api/logs.test.js @@ -289,6 +289,17 @@ module.exports = () => { expect(singleResponse.body.data.map(({ id }) => id).sort((a, b) => a - b)).to.deep.equal([1, 119]); }); + it('should successfully filter by simulation pass names', async () => { + // At least one of the simulation passes must be linked to the log + const response = await request(server).get('/api/logs?filter[simulationPasses]=LHC23k6b,LHC23k6a'); + expect(response.status).to.equal(200); + expect(response.body.data.map(({ id }) => id).sort((a, b) => a - b)).to.deep.equal([1, 2]); + + const singleResponse = await request(server).get('/api/logs?filter[simulationPasses]=LHC23k6c'); + expect(singleResponse.status).to.equal(200); + expect(singleResponse.body.data.map(({ id }) => id).sort((a, b) => a - b)).to.deep.equal([1, 119]); + }); + it('should successfully filter by LHC period names', async () => { const response = await request(server).get('/api/logs?filter[lhcPeriods]=LHC22a'); expect(response.status).to.equal(200); @@ -299,8 +310,8 @@ module.exports = () => { expect(multipleResponse.body.data.map(({ id }) => id).sort((a, b) => a - b)).to.deep.equal([1, 2, 119]); }); - it('should return an empty list when filtering by unknown data pass or LHC period', async () => { - for (const [filterKey, value] of [['dataPasses', 'NOT_A_DATA_PASS'], ['lhcPeriods', 'NOT_A_PERIOD']]) { + it('should return an empty list when filtering by unknown data pass, simulation pass or LHC period', async () => { + for (const [filterKey, value] of [['dataPasses', 'NOT_A_DATA_PASS'], ['simulationPasses', 'NOT_A_SIMULATION_PASS'], ['lhcPeriods', 'NOT_A_PERIOD']]) { const response = await request(server).get(`/api/logs?filter[${filterKey}]=${value}`); expect(response.status).to.equal(200); expect(response.body.data).to.lengthOf(0); diff --git a/test/lib/usecases/log/GetAllLogsUseCase.test.js b/test/lib/usecases/log/GetAllLogsUseCase.test.js index 07141bfc77..ea2db55cbc 100644 --- a/test/lib/usecases/log/GetAllLogsUseCase.test.js +++ b/test/lib/usecases/log/GetAllLogsUseCase.test.js @@ -163,6 +163,45 @@ module.exports = () => { expect(logs.map(({ id }) => id)).to.include(2); }); + it('should successfully filter on simulation passes, requiring at least one of them', async () => { + // Other tests of the suite may have created logs linked to simulation passes, so only check seeded logs and the filter semantic + const getSimulationPassNames = (log) => log.simulationPasses.map(({ name }) => name); + + getAllLogsDto.query = { filter: { simulationPasses: ['LHC23k6c'] } }; + let { logs } = await new GetAllLogsUseCase().execute(getAllLogsDto); + expect(logs.map(({ id }) => id)).to.include.members([1, 119]); + expect(logs.map(({ id }) => id)).to.not.include(2); + expect(logs.every((log) => getSimulationPassNames(log).includes('LHC23k6c'))).to.be.true; + + getAllLogsDto.query = { filter: { simulationPasses: ['LHC23k6b', 'LHC23k6a'] } }; + ({ logs } = await new GetAllLogsUseCase().execute(getAllLogsDto)); + expect(logs.map(({ id }) => id)).to.include.members([1, 2]); + expect(logs.map(({ id }) => id)).to.not.include(119); + expect(logs.every((log) => ['LHC23k6b', 'LHC23k6a'].some((name) => getSimulationPassNames(log).includes(name)))).to.be.true; + // Linked simulation passes are all returned, not only the filtered ones + expect(getSimulationPassNames(logs.find(({ id }) => id === 1)).sort()).to.deep.equal(['LHC23k6b', 'LHC23k6c']); + + getAllLogsDto.query = { filter: { simulationPasses: ['NOT_A_SIMULATION_PASS'] } }; + ({ logs } = await new GetAllLogsUseCase().execute(getAllLogsDto)); + expect(logs).to.lengthOf(0); + + // Unknown simulation passes are ignored when at least another one matches + getAllLogsDto.query = { filter: { simulationPasses: ['LHC23k6a', 'NOT_A_SIMULATION_PASS'] } }; + ({ logs } = await new GetAllLogsUseCase().execute(getAllLogsDto)); + expect(logs.map(({ id }) => id)).to.include(2); + }); + + it('should successfully combine the data passes and simulation passes filters', async () => { + getAllLogsDto.query = { filter: { dataPasses: ['LHC22b_skimming'], simulationPasses: ['LHC23k6b'] } }; + let { logs } = await new GetAllLogsUseCase().execute(getAllLogsDto); + expect(logs.map(({ id }) => id)).to.include(1); + expect(logs.map(({ id }) => id)).to.not.include(119); + + getAllLogsDto.query = { filter: { dataPasses: ['LHC22b_skimming'], simulationPasses: ['LHC23k6a'] } }; + ({ logs } = await new GetAllLogsUseCase().execute(getAllLogsDto)); + expect(logs).to.lengthOf(0); + }); + it('should successfully filter on the LHC periods of the linked data passes', async () => { const getPeriodsDataPassNames = (log) => log.dataPasses.map(({ name }) => name.split('_')[0]); diff --git a/test/public/logs/overview.test.js b/test/public/logs/overview.test.js index 3a0d872ace..c50e49cb8c 100644 --- a/test/public/logs/overview.test.js +++ b/test/public/logs/overview.test.js @@ -461,6 +461,19 @@ module.exports = () => { await waitForEmptyTable(page); }); + it('can filter by simulation passes', async () => { + await openFilteringPanel(page); + await fillInput(page, '.simulationPasses-textFilter', 'LHC23k6c', ['change']); + await waitForTableLength(page, 2); + + // At least one of the simulation passes must be linked to the log entry + await fillInput(page, '.simulationPasses-textFilter', 'LHC23k6b, LHC23k6a', ['change']); + await waitForTableLength(page, 2); + + await fillInput(page, '.simulationPasses-textFilter', 'NOT_A_SIMULATION_PASS', ['change']); + await waitForEmptyTable(page); + }); + it('can filter by LHC periods inherited from the data passes and explain it with an info tooltip', async () => { await openFilteringPanel(page); From d84a078bb9133895db3eceeb911676b97c595df6 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Mon, 19 Oct 2026 14:56:18 +0200 Subject: [PATCH 3/4] [O2B-1631] Display the count of logs linked to each simulation pass Return a `logsCount` for each simulation pass (computed with a subquery so the other joins are not multiplied) and add a "Logs" column to the simulation passes tables. It shows the number of logs as a link to the logs overview filtered on that simulation pass, or "No logs". Includes service, API and UI tests. --- .../adapters/SimulationPassAdapter.js | 2 ++ .../typedefs/SequelizeSimulationPass.js | 1 + lib/domain/entities/SimulationPass.js | 1 + .../simulationPassesActiveColumns.js | 14 ++++++++++ .../simulationPasses/SimulationPassService.js | 5 ++++ test/api/simulationPasses.test.js | 3 ++ .../SimulationPassesService.test.js | 3 ++ .../overviewAnchoredSimulationPasses.test.js | 1 + .../overviewPerLhcPeriod.test.js | 28 +++++++++++++++++++ 9 files changed, 58 insertions(+) diff --git a/lib/database/adapters/SimulationPassAdapter.js b/lib/database/adapters/SimulationPassAdapter.js index 6b09fd1356..64b372820f 100644 --- a/lib/database/adapters/SimulationPassAdapter.js +++ b/lib/database/adapters/SimulationPassAdapter.js @@ -43,6 +43,7 @@ class SimulationPassAdapter { const dataPassesCount = databaseObject.get('dataPassesCount'); const runsCount = databaseObject.get('runsCount'); + const logsCount = databaseObject.get('logsCount'); const pdpBeamTypes = databaseObject.get('pdpBeamTypes')?.split(',') || []; return { @@ -56,6 +57,7 @@ class SimulationPassAdapter { outputSize, runsCount, dataPassesCount, + logsCount, pdpBeamTypes, }; } diff --git a/lib/database/models/typedefs/SequelizeSimulationPass.js b/lib/database/models/typedefs/SequelizeSimulationPass.js index 29b0de55bb..90da6d9715 100644 --- a/lib/database/models/typedefs/SequelizeSimulationPass.js +++ b/lib/database/models/typedefs/SequelizeSimulationPass.js @@ -23,4 +23,5 @@ * @property {number} outputSize * @property {number} dataPassesCount * @property {number} runsCount + * @property {number} logsCount */ diff --git a/lib/domain/entities/SimulationPass.js b/lib/domain/entities/SimulationPass.js index 4af583e24c..055fce7086 100644 --- a/lib/domain/entities/SimulationPass.js +++ b/lib/domain/entities/SimulationPass.js @@ -23,4 +23,5 @@ * @property {number} outputSize * @property {number} dataPassesCount * @property {number} runsCount + * @property {number} logsCount */ diff --git a/lib/public/views/SimulationPasses/ActiveColumns/simulationPassesActiveColumns.js b/lib/public/views/SimulationPasses/ActiveColumns/simulationPassesActiveColumns.js index 3085772121..6b454c2647 100644 --- a/lib/public/views/SimulationPasses/ActiveColumns/simulationPassesActiveColumns.js +++ b/lib/public/views/SimulationPasses/ActiveColumns/simulationPassesActiveColumns.js @@ -63,6 +63,20 @@ export const simulationPassesActiveColumns = { classes: 'w-10 f6', }, + associatedLogs: { + name: 'Logs', + visible: true, + format: (_, { name, logsCount }) => + logsCount === 0 + ? 'No logs' + : frontLink( + badge(logsCount), + 'log-overview', + { 'filter[simulationPasses]': name }, + ), + classes: 'w-10 f6', + }, + description: { name: 'Description', visible: true, diff --git a/lib/server/services/simulationPasses/SimulationPassService.js b/lib/server/services/simulationPasses/SimulationPassService.js index 957a7ff3a5..23f9976303 100644 --- a/lib/server/services/simulationPasses/SimulationPassService.js +++ b/lib/server/services/simulationPasses/SimulationPassService.js @@ -140,6 +140,11 @@ class SimulationPassService { query: (sequelize) => sequelize.fn('count', sequelize.fn('distinct', sequelize.col('dataPasses.id'))), alias: 'dataPassesCount', }) + // Use a subquery to not multiply the joined rows by the number of logs + .includeAttribute({ + query: '(SELECT COUNT(*) FROM log_simulation_passes WHERE log_simulation_passes.simulation_pass_id = `SimulationPass`.id)', + alias: 'logsCount', + }) .includeAttribute({ query: (sequelize) => sequelize.fn('GROUP_CONCAT', sequelize.fn('DISTINCT', sequelize.col('runs.pdp_beam_type'))), alias: 'pdpBeamTypes', diff --git a/test/api/simulationPasses.test.js b/test/api/simulationPasses.test.js index c4c154b212..6d68ddeced 100644 --- a/test/api/simulationPasses.test.js +++ b/test/api/simulationPasses.test.js @@ -28,6 +28,7 @@ const LHC23k6c = { outputSize: 14013600611699, dataPassesCount: 2, runsCount: 3, + logsCount: 2, }; const LHC23k6b = { @@ -42,6 +43,7 @@ const LHC23k6b = { outputSize: 157000310748, dataPassesCount: 1, runsCount: 3, + logsCount: 1, }; const LHC23k6a = { @@ -56,6 +58,7 @@ const LHC23k6a = { outputSize: 147000310748, dataPassesCount: 1, runsCount: 1, + logsCount: 1, }; module.exports = () => { diff --git a/test/lib/server/services/simulationPasses/SimulationPassesService.test.js b/test/lib/server/services/simulationPasses/SimulationPassesService.test.js index 01a1ed321b..cf4971dfd3 100644 --- a/test/lib/server/services/simulationPasses/SimulationPassesService.test.js +++ b/test/lib/server/services/simulationPasses/SimulationPassesService.test.js @@ -29,6 +29,7 @@ const LHC23k6c = { outputSize: 14013600611699, dataPassesCount: 2, runsCount: 3, + logsCount: 2, }; const LHC23k6b = { @@ -43,6 +44,7 @@ const LHC23k6b = { outputSize: 157000310748, dataPassesCount: 1, runsCount: 3, + logsCount: 1, }; const LHC23k6a = { @@ -57,6 +59,7 @@ const LHC23k6a = { outputSize: 147000310748, dataPassesCount: 1, runsCount: 1, + logsCount: 1, }; module.exports = () => { diff --git a/test/public/simulationPasses/overviewAnchoredSimulationPasses.test.js b/test/public/simulationPasses/overviewAnchoredSimulationPasses.test.js index 0c801f9c00..f88e951286 100644 --- a/test/public/simulationPasses/overviewAnchoredSimulationPasses.test.js +++ b/test/public/simulationPasses/overviewAnchoredSimulationPasses.test.js @@ -61,6 +61,7 @@ module.exports = () => { name: (name) => periodNameRegex.test(name), associatedRuns: (display) => /(No runs)|(\d+)/.test(display), associatedDataPasses: (display) => /(No anchorage)|(\d+)/.test(display), + associatedLogs: (display) => /(No logs)|(\d+)/.test(display), pwg: (pwg) => /PWG.+/.test(pwg), jiraId: (jiraId) => /[A-Z][A-Z0-9]+-[0-9]+/.test(jiraId), requestedEventsCount: (requestedEventsCount) => !isNaN(requestedEventsCount.replace(/,/g, '')), diff --git a/test/public/simulationPasses/overviewPerLhcPeriod.test.js b/test/public/simulationPasses/overviewPerLhcPeriod.test.js index bcad3fab5b..eb0ffced29 100644 --- a/test/public/simulationPasses/overviewPerLhcPeriod.test.js +++ b/test/public/simulationPasses/overviewPerLhcPeriod.test.js @@ -22,6 +22,11 @@ const { expectColumnValues, validateTableData, expectInnerText, + getTableDataSlice, + waitForNavigation, + openFilteringPanel, + expectInputValue, + expectUrlParams, } = require('../defaults.js'); const { resetDatabaseContent } = require('../../utilities/resetDatabaseContent.js'); @@ -62,6 +67,7 @@ module.exports = () => { name: (name) => periodNameRegex.test(name), associatedRuns: (display) => /(No runs)|(\d+)/.test(display), associatedDataPasses: (display) => /(No anchorage)|(\d+)/.test(display), + associatedLogs: (display) => /(No logs)|(\d+)/.test(display), pwg: (pwg) => /PWG.+/.test(pwg), jiraId: (jiraId) => /[A-Z]+[A-Z0-9]+-\d+/.test(jiraId), requestedEventsCount: (requestedEventsCount) => !isNaN(requestedEventsCount.replace(/,/g, '')), @@ -75,6 +81,28 @@ module.exports = () => { await validateTableData(page, new Map(Object.entries(headerDatatypes))); }); + it('shows the amount of logs linked to each simulation pass', async () => { + await goToPage(page, 'simulation-passes-per-lhc-period-overview', { queryParameters: { lhcPeriodId: 1 } }); + + const tableSlice = await getTableDataSlice(page, ['name', 'associatedLogs']); + expect(tableSlice.map(({ name, associatedLogs }) => ({ name, logsCount: Number(associatedLogs.split('\n')[0]) || 0 }))) + .to.have.all.deep.members([ + { name: 'LHC23k6b', logsCount: 1 }, + { name: 'LHC23k6a', logsCount: 1 }, + ]); + }); + + it('can navigate to the logs overview filtered by simulation pass', async () => { + await goToPage(page, 'simulation-passes-per-lhc-period-overview', { queryParameters: { lhcPeriodId: 1 } }); + const [{ name }] = await getTableDataSlice(page, ['name']); + + await waitForNavigation(page, () => pressElement(page, 'tbody tr:nth-of-type(1) td:nth-of-type(4) a')); + expectUrlParams(page, { page: 'log-overview' }, ['filter[simulationPasses]', 'filter%5BsimulationPasses%5D']); + expect(decodeURIComponent(page.url())).to.include(`filter[simulationPasses]=${name}`); + await openFilteringPanel(page); + await expectInputValue(page, '.simulationPasses-textFilter', name); + }); + it('Should display the correct items counter at the bottom of the page', async () => { await goToPage(page, 'simulation-passes-per-lhc-period-overview', { queryParameters: { lhcPeriodId: 1 } }); From 41241328709c7267681d5e9c320459241499f678 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Mon, 19 Oct 2026 15:50:21 +0200 Subject: [PATCH 4/4] Fix tests incorrectly --- test/public/simulationPasses/overviewPerLhcPeriod.test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/public/simulationPasses/overviewPerLhcPeriod.test.js b/test/public/simulationPasses/overviewPerLhcPeriod.test.js index eb0ffced29..71e8371c9e 100644 --- a/test/public/simulationPasses/overviewPerLhcPeriod.test.js +++ b/test/public/simulationPasses/overviewPerLhcPeriod.test.js @@ -23,6 +23,7 @@ const { validateTableData, expectInnerText, getTableDataSlice, + waitForTableLength, waitForNavigation, openFilteringPanel, expectInputValue, @@ -83,6 +84,7 @@ module.exports = () => { it('shows the amount of logs linked to each simulation pass', async () => { await goToPage(page, 'simulation-passes-per-lhc-period-overview', { queryParameters: { lhcPeriodId: 1 } }); + await waitForTableLength(page, 2); const tableSlice = await getTableDataSlice(page, ['name', 'associatedLogs']); expect(tableSlice.map(({ name, associatedLogs }) => ({ name, logsCount: Number(associatedLogs.split('\n')[0]) || 0 }))) @@ -94,6 +96,7 @@ module.exports = () => { it('can navigate to the logs overview filtered by simulation pass', async () => { await goToPage(page, 'simulation-passes-per-lhc-period-overview', { queryParameters: { lhcPeriodId: 1 } }); + await waitForTableLength(page, 2); const [{ name }] = await getTableDataSlice(page, ['name']); await waitForNavigation(page, () => pressElement(page, 'tbody tr:nth-of-type(1) td:nth-of-type(4) a'));