From bb5a9c3845fe2de0ed76e2c584314b634529c551 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:51:18 +0100 Subject: [PATCH 01/72] [O2B-1545] Add GAQ summary models, adapters and migration --- lib/database/adapters/GaqSummaryAdapter.js | 56 +++++++++++++ .../adapters/GaqSummaryInvalidationAdapter.js | 46 +++++++++++ lib/database/adapters/index.js | 6 ++ ...0260223120000-create-gaq-summary-tables.js | 82 +++++++++++++++++++ lib/database/models/gaqSummary.js | 52 ++++++++++++ lib/database/models/gaqSummaryInvalidation.js | 37 +++++++++ lib/database/models/index.js | 4 + 7 files changed, 283 insertions(+) create mode 100644 lib/database/adapters/GaqSummaryAdapter.js create mode 100644 lib/database/adapters/GaqSummaryInvalidationAdapter.js create mode 100644 lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js create mode 100644 lib/database/models/gaqSummary.js create mode 100644 lib/database/models/gaqSummaryInvalidation.js diff --git a/lib/database/adapters/GaqSummaryAdapter.js b/lib/database/adapters/GaqSummaryAdapter.js new file mode 100644 index 0000000000..f069b0bfa7 --- /dev/null +++ b/lib/database/adapters/GaqSummaryAdapter.js @@ -0,0 +1,56 @@ +/** + * @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. + */ + +/** + * GaqSummaryAdapter + */ +class GaqSummaryAdapter { + /** + * Constructor + */ + constructor() { + this.toEntity = this.toEntity.bind(this); + } + + /** + * Converts the given database object to an entity object. + * + * @param {SequelizeGaqSummary} databaseObject Object to convert. + * @returns {GaqSummary} Converted entity object. + */ + toEntity(databaseObject) { + const { + dataPassId, + runNumber, + badEffectiveRunCoverage, + explicitlyNotBadEffectiveRunCoverage, + mcReproducible, + missingVerificationsCount, + undefinedQualityPeriodsCount, + computedAt, + } = databaseObject; + + return { + dataPassId, + runNumber, + badEffectiveRunCoverage, + explicitlyNotBadEffectiveRunCoverage, + mcReproducible, + missingVerificationsCount, + undefinedQualityPeriodsCount, + computedAt, + }; + } +} + +module.exports = { GaqSummaryAdapter }; diff --git a/lib/database/adapters/GaqSummaryInvalidationAdapter.js b/lib/database/adapters/GaqSummaryInvalidationAdapter.js new file mode 100644 index 0000000000..63d3ebce73 --- /dev/null +++ b/lib/database/adapters/GaqSummaryInvalidationAdapter.js @@ -0,0 +1,46 @@ +/** + * @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. + */ + +/** + * GaqSummaryInvalidationAdapter + */ +class GaqSummaryInvalidationAdapter { + /** + * Constructor + */ + constructor() { + this.toEntity = this.toEntity.bind(this); + } + + /** + * Converts the given database object to an entity object. + * + * @param {SequelizeGaqSummaryInvalidation} databaseObject Object to convert. + * @returns {GaqSummaryInvalidation} Converted entity object. + */ + toEntity(databaseObject) { + const { + dataPassId, + runNumber, + invalidatedAt, + } = databaseObject; + + return { + dataPassId, + runNumber, + invalidatedAt, + }; + } +} + +module.exports = { GaqSummaryInvalidationAdapter }; diff --git a/lib/database/adapters/index.js b/lib/database/adapters/index.js index 5ff6404b3d..97d22b927f 100644 --- a/lib/database/adapters/index.js +++ b/lib/database/adapters/index.js @@ -27,6 +27,8 @@ const EorReasonAdapter = require('./EorReasonAdapter'); const FlpRoleAdapter = require('./FlpRoleAdapter'); const { HostAdapter } = require('./HostAdapter.js'); const { GaqDetectorAdapter } = require('./GaqDetectorAdapter.js'); +const { GaqSummaryAdapter } = require('./GaqSummaryAdapter.js'); +const { GaqSummaryInvalidationAdapter } = require('./GaqSummaryInvalidationAdapter.js'); const { LhcFillAdapter } = require('./LhcFillAdapter.js'); const { LhcFillStatisticsAdapter } = require('./LhcFillStatisticsAdapter.js'); const LhcPeriodAdapter = require('./LhcPeriodAdapter'); @@ -63,6 +65,8 @@ const environmentHistoryItemAdapter = new EnvironmentHistoryItemAdapter(); const eorReasonAdapter = new EorReasonAdapter(); const flpRoleAdapter = new FlpRoleAdapter(); const gaqDetectorAdapter = new GaqDetectorAdapter(); +const gaqSummaryAdapter = new GaqSummaryAdapter(); +const gaqSummaryInvalidationAdapter = new GaqSummaryInvalidationAdapter(); const hostAdapter = new HostAdapter(); const lhcFillAdapter = new LhcFillAdapter(); const lhcFillStatisticsAdapter = new LhcFillStatisticsAdapter(); @@ -159,6 +163,8 @@ module.exports = { eorReasonAdapter, flpRoleAdapter, gaqDetectorAdapter, + gaqSummaryAdapter, + gaqSummaryInvalidationAdapter, hostAdapter, lhcFillAdapter, lhcFillStatisticsAdapter, diff --git a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js new file mode 100644 index 0000000000..9ead5d5f32 --- /dev/null +++ b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js @@ -0,0 +1,82 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: async (queryInterface, Sequelize) => queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.createTable('gaq_summaries', { + data_pass_id: { + type: Sequelize.INTEGER, + primaryKey: true, + allowNull: false, + references: { + model: 'data_passes', + key: 'id', + }, + }, + run_number: { + type: Sequelize.INTEGER, + primaryKey: true, + allowNull: false, + references: { + model: 'runs', + key: 'run_number', + }, + }, + bad_effective_run_coverage: { + type: Sequelize.FLOAT, + allowNull: false, + }, + explicitly_not_bad_effective_run_coverage: { + type: Sequelize.FLOAT, + allowNull: false, + }, + mc_reproducible: { + type: Sequelize.BOOLEAN, + allowNull: false, + }, + missing_verifications_count: { + type: Sequelize.INTEGER, + allowNull: false, + }, + undefined_quality_periods_count: { + type: Sequelize.INTEGER, + allowNull: false, + }, + computed_at: { + type: Sequelize.DATE(3), + allowNull: false, + }, + }, { transaction }); + + await queryInterface.createTable('gaq_summary_invalidations', { + data_pass_id: { + type: Sequelize.INTEGER, + primaryKey: true, + allowNull: false, + references: { + model: 'data_passes', + key: 'id', + }, + }, + run_number: { + type: Sequelize.INTEGER, + primaryKey: true, + allowNull: false, + references: { + model: 'runs', + key: 'run_number', + }, + }, + invalidated_at: { + type: Sequelize.DATE(3), + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP(3)'), + }, + }, { transaction }); + }), + + down: async (queryInterface) => queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.dropTable('gaq_summary_invalidations', { transaction }); + await queryInterface.dropTable('gaq_summaries', { transaction }); + }), +}; diff --git a/lib/database/models/gaqSummary.js b/lib/database/models/gaqSummary.js new file mode 100644 index 0000000000..80e21f0145 --- /dev/null +++ b/lib/database/models/gaqSummary.js @@ -0,0 +1,52 @@ +/** + * @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. + */ + +module.exports = (sequelize) => { + const Sequelize = require('sequelize'); + + const GaqSummary = sequelize.define('GaqSummary', { + dataPassId: { + type: Sequelize.INTEGER, + }, + runNumber: { + type: Sequelize.INTEGER, + }, + badEffectiveRunCoverage: { + type: Sequelize.FLOAT, + }, + explicitlyNotBadEffectiveRunCoverage: { + type: Sequelize.FLOAT, + }, + mcReproducible: { + type: Sequelize.BOOLEAN, + }, + missingVerificationsCount: { + type: Sequelize.INTEGER, + }, + undefinedQualityPeriodsCount: { + type: Sequelize.INTEGER, + }, + computedAt: { + type: Sequelize.DATE(3), + }, + }, { tableName: 'gaq_summaries' }); + + GaqSummary.removeAttribute('id'); + + GaqSummary.associate = (models) => { + GaqSummary.belongsTo(models.Run, { foreignKey: 'runNumber', as: 'run' }); + GaqSummary.belongsTo(models.DataPass, { foreignKey: 'dataPassId', as: 'dataPass' }); + }; + + return GaqSummary; +}; diff --git a/lib/database/models/gaqSummaryInvalidation.js b/lib/database/models/gaqSummaryInvalidation.js new file mode 100644 index 0000000000..f99b5d0895 --- /dev/null +++ b/lib/database/models/gaqSummaryInvalidation.js @@ -0,0 +1,37 @@ +/** + * @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. + */ + +module.exports = (sequelize) => { + const Sequelize = require('sequelize'); + + const GaqSummaryInvalidation = sequelize.define('GaqSummaryInvalidation', { + dataPassId: { + type: Sequelize.INTEGER, + }, + runNumber: { + type: Sequelize.INTEGER, + }, + invalidatedAt: { + type: Sequelize.DATE(3), + }, + }, { tableName: 'gaq_summary_invalidations' }); + + GaqSummaryInvalidation.removeAttribute('id'); + + GaqSummaryInvalidation.associate = (models) => { + GaqSummaryInvalidation.belongsTo(models.Run, { foreignKey: 'runNumber', as: 'run' }); + GaqSummaryInvalidation.belongsTo(models.DataPass, { foreignKey: 'dataPassId', as: 'dataPass' }); + }; + + return GaqSummaryInvalidation; +}; diff --git a/lib/database/models/index.js b/lib/database/models/index.js index 87d793fac3..36736ce905 100644 --- a/lib/database/models/index.js +++ b/lib/database/models/index.js @@ -27,6 +27,8 @@ const EorReason = require('./eorreason'); const EpnRoleSession = require('./epnrolesession'); const FlpRole = require('./flprole'); const GaqDetector = require('./gaqDetector.js'); +const GaqSummary = require('./gaqSummary.js'); +const GaqSummaryInvalidation = require('./gaqSummaryInvalidation.js'); const Host = require('./host.js'); const LhcFill = require('./lhcFill'); const LhcFillStatistics = require('./lhcFillStatistics.js'); @@ -66,6 +68,8 @@ module.exports = (sequelize) => { EpnRoleSessionkey: EpnRoleSession(sequelize), FlpRole: FlpRole(sequelize), GaqDetector: GaqDetector(sequelize), + GaqSummary: GaqSummary(sequelize), + GaqSummaryInvalidation: GaqSummaryInvalidation(sequelize), Host: Host(sequelize), LhcFill: LhcFill(sequelize), LhcFillStatistics: LhcFillStatistics(sequelize), From 4fade0f12b6ac96baa67f298431e7025cafd7cca Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:08:20 +0100 Subject: [PATCH 02/72] [O2B-1545] Add GAQ summary repositories and timestamps Add GaqSummaryRepository and GaqSummaryInvalidationRepository and export them from the repositories index. Update migration to replace the previous invalidated_at field with created_at and add updated_at to mirror default Sequelize tables. --- .../adapters/GaqSummaryInvalidationAdapter.js | 6 ++-- ...0260223120000-create-gaq-summary-tables.js | 7 +++- lib/database/models/gaqSummaryInvalidation.js | 3 -- .../GaqSummaryInvalidationRepository.js | 33 +++++++++++++++++++ .../repositories/GaqSummaryRepository.js | 33 +++++++++++++++++++ lib/database/repositories/index.js | 4 +++ 6 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 lib/database/repositories/GaqSummaryInvalidationRepository.js create mode 100644 lib/database/repositories/GaqSummaryRepository.js diff --git a/lib/database/adapters/GaqSummaryInvalidationAdapter.js b/lib/database/adapters/GaqSummaryInvalidationAdapter.js index 63d3ebce73..dbd76df3a8 100644 --- a/lib/database/adapters/GaqSummaryInvalidationAdapter.js +++ b/lib/database/adapters/GaqSummaryInvalidationAdapter.js @@ -32,13 +32,15 @@ class GaqSummaryInvalidationAdapter { const { dataPassId, runNumber, - invalidatedAt, + createdAt, + updatedAt, } = databaseObject; return { dataPassId, runNumber, - invalidatedAt, + createdAt, + updatedAt, }; } } diff --git a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js index 9ead5d5f32..7f08357a89 100644 --- a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js +++ b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js @@ -67,11 +67,16 @@ module.exports = { key: 'run_number', }, }, - invalidated_at: { + created_at: { type: Sequelize.DATE(3), allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP(3)'), }, + updated_at: { + type: Sequelize.DATE(3), + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), + }, }, { transaction }); }), diff --git a/lib/database/models/gaqSummaryInvalidation.js b/lib/database/models/gaqSummaryInvalidation.js index f99b5d0895..3c0944b9bc 100644 --- a/lib/database/models/gaqSummaryInvalidation.js +++ b/lib/database/models/gaqSummaryInvalidation.js @@ -21,9 +21,6 @@ module.exports = (sequelize) => { runNumber: { type: Sequelize.INTEGER, }, - invalidatedAt: { - type: Sequelize.DATE(3), - }, }, { tableName: 'gaq_summary_invalidations' }); GaqSummaryInvalidation.removeAttribute('id'); diff --git a/lib/database/repositories/GaqSummaryInvalidationRepository.js b/lib/database/repositories/GaqSummaryInvalidationRepository.js new file mode 100644 index 0000000000..c6608f9e83 --- /dev/null +++ b/lib/database/repositories/GaqSummaryInvalidationRepository.js @@ -0,0 +1,33 @@ +/** + * @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. + */ + +const { + models: { + GaqSummaryInvalidation, + }, +} = require('..'); +const Repository = require('./Repository'); + +/** + * GaqSummaryInvalidation repository + */ +class GaqSummaryInvalidationRepository extends Repository { + /** + * Creates a new `GaqSummaryInvalidationRepository` instance. + */ + constructor() { + super(GaqSummaryInvalidation); + } +} + +module.exports = new GaqSummaryInvalidationRepository(); diff --git a/lib/database/repositories/GaqSummaryRepository.js b/lib/database/repositories/GaqSummaryRepository.js new file mode 100644 index 0000000000..98986de513 --- /dev/null +++ b/lib/database/repositories/GaqSummaryRepository.js @@ -0,0 +1,33 @@ +/** + * @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. + */ + +const { + models: { + GaqSummary, + }, +} = require('..'); +const Repository = require('./Repository'); + +/** + * GaqSummary repository + */ +class GaqSummaryRepository extends Repository { + /** + * Creates a new `GaqSummaryRepository` instance. + */ + constructor() { + super(GaqSummary); + } +} + +module.exports = new GaqSummaryRepository(); diff --git a/lib/database/repositories/index.js b/lib/database/repositories/index.js index 0c79279752..417ccd5375 100644 --- a/lib/database/repositories/index.js +++ b/lib/database/repositories/index.js @@ -27,6 +27,8 @@ const EnvironmentRepository = require('./EnvironmentRepository'); const EorReasonRepository = require('./EorReasonRepository'); const FlpRoleRepository = require('./FlpRoleRepository'); const GaqDetectorRepository = require('./GaqDetectorRepository.js'); +const GaqSummaryInvalidationRepository = require('./GaqSummaryInvalidationRepository.js'); +const GaqSummaryRepository = require('./GaqSummaryRepository.js'); const HostRepository = require('./HostRepository.js'); const LhcFillRepository = require('./LhcFillRepository'); const LhcFillStatisticsRepository = require('./LhcFillStatisticsRepository.js'); @@ -70,6 +72,8 @@ module.exports = { EorReasonRepository, FlpRoleRepository, GaqDetectorRepository, + GaqSummaryInvalidationRepository, + GaqSummaryRepository, HostRepository, LhcFillRepository, LhcFillStatisticsRepository, From 15afdaac85826129a0b127db26be1a3d355c5fdf Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:15:14 +0100 Subject: [PATCH 03/72] [O2B-1545] Use default Sequelize createdAt/updatedAt instead of computedAt --- lib/database/adapters/GaqSummaryAdapter.js | 6 ++++-- .../v1/20260223120000-create-gaq-summary-tables.js | 8 +++++++- lib/database/models/gaqSummary.js | 3 --- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/database/adapters/GaqSummaryAdapter.js b/lib/database/adapters/GaqSummaryAdapter.js index f069b0bfa7..88d7394126 100644 --- a/lib/database/adapters/GaqSummaryAdapter.js +++ b/lib/database/adapters/GaqSummaryAdapter.js @@ -37,7 +37,8 @@ class GaqSummaryAdapter { mcReproducible, missingVerificationsCount, undefinedQualityPeriodsCount, - computedAt, + createdAt, + updatedAt, } = databaseObject; return { @@ -48,7 +49,8 @@ class GaqSummaryAdapter { mcReproducible, missingVerificationsCount, undefinedQualityPeriodsCount, - computedAt, + createdAt, + updatedAt, }; } } diff --git a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js index 7f08357a89..1e404d3645 100644 --- a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js +++ b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js @@ -42,9 +42,15 @@ module.exports = { type: Sequelize.INTEGER, allowNull: false, }, - computed_at: { + created_at: { type: Sequelize.DATE(3), allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP(3)'), + }, + updated_at: { + type: Sequelize.DATE(3), + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), }, }, { transaction }); diff --git a/lib/database/models/gaqSummary.js b/lib/database/models/gaqSummary.js index 80e21f0145..55b33b3521 100644 --- a/lib/database/models/gaqSummary.js +++ b/lib/database/models/gaqSummary.js @@ -36,9 +36,6 @@ module.exports = (sequelize) => { undefinedQualityPeriodsCount: { type: Sequelize.INTEGER, }, - computedAt: { - type: Sequelize.DATE(3), - }, }, { tableName: 'gaq_summaries' }); GaqSummary.removeAttribute('id'); From 363f09bed1b972877e491ca6f5c7a224617b8392 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:03:35 +0100 Subject: [PATCH 04/72] [O2B-1545] Use mcReproducibleCoverage float instead of boolean Rename and change mcReproducible to be the coverage float not the boolean. --- lib/database/adapters/GaqSummaryAdapter.js | 4 ++-- .../migrations/v1/20260223120000-create-gaq-summary-tables.js | 4 ++-- lib/database/models/gaqSummary.js | 3 +++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/database/adapters/GaqSummaryAdapter.js b/lib/database/adapters/GaqSummaryAdapter.js index 88d7394126..fd9ba4aa0e 100644 --- a/lib/database/adapters/GaqSummaryAdapter.js +++ b/lib/database/adapters/GaqSummaryAdapter.js @@ -34,7 +34,7 @@ class GaqSummaryAdapter { runNumber, badEffectiveRunCoverage, explicitlyNotBadEffectiveRunCoverage, - mcReproducible, + mcReproducibleCoverage, missingVerificationsCount, undefinedQualityPeriodsCount, createdAt, @@ -46,7 +46,7 @@ class GaqSummaryAdapter { runNumber, badEffectiveRunCoverage, explicitlyNotBadEffectiveRunCoverage, - mcReproducible, + mcReproducibleCoverage, missingVerificationsCount, undefinedQualityPeriodsCount, createdAt, diff --git a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js index 1e404d3645..8122607a37 100644 --- a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js +++ b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js @@ -30,8 +30,8 @@ module.exports = { type: Sequelize.FLOAT, allowNull: false, }, - mc_reproducible: { - type: Sequelize.BOOLEAN, + mc_reproducible_coverage: { + type: Sequelize.FLOAT, allowNull: false, }, missing_verifications_count: { diff --git a/lib/database/models/gaqSummary.js b/lib/database/models/gaqSummary.js index 55b33b3521..de91be13f2 100644 --- a/lib/database/models/gaqSummary.js +++ b/lib/database/models/gaqSummary.js @@ -30,6 +30,9 @@ module.exports = (sequelize) => { mcReproducible: { type: Sequelize.BOOLEAN, }, + mcReproducibleCoverage: { + type: Sequelize.FLOAT, + }, missingVerificationsCount: { type: Sequelize.INTEGER, }, From 002f3012a18f31ebaff85827935529156ce2909f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:49:41 +0100 Subject: [PATCH 05/72] [O2B-1545] Remove mcReproducible field Forgot to remove. --- lib/database/models/gaqSummary.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/database/models/gaqSummary.js b/lib/database/models/gaqSummary.js index de91be13f2..f3338e5557 100644 --- a/lib/database/models/gaqSummary.js +++ b/lib/database/models/gaqSummary.js @@ -27,9 +27,6 @@ module.exports = (sequelize) => { explicitlyNotBadEffectiveRunCoverage: { type: Sequelize.FLOAT, }, - mcReproducible: { - type: Sequelize.BOOLEAN, - }, mcReproducibleCoverage: { type: Sequelize.FLOAT, }, From 5bebbb5ac73f011462d327823b0e6c5f1ace192e Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:24:00 +0100 Subject: [PATCH 06/72] [O2B-1563] Invalidate GAQ summaries on related changes Add GAQ summary invalidation whenever underlying data affecting GAQ changes. These changes ensure GAQ summary caches/records are marked for recomputation whenever detectors, QC flags, or run QC times that influence GAQ summaries are modified. --- .../services/gaq/GaqDetectorsService.js | 16 +++++- .../qualityControlFlag/QcFlagService.js | 50 +++++++++++++++++-- lib/server/services/run/updateRun.js | 25 +++++++++- 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/lib/server/services/gaq/GaqDetectorsService.js b/lib/server/services/gaq/GaqDetectorsService.js index 1ac2a0dc7e..6c19d1cd4a 100644 --- a/lib/server/services/gaq/GaqDetectorsService.js +++ b/lib/server/services/gaq/GaqDetectorsService.js @@ -12,7 +12,7 @@ */ const { gaqDetectorAdapter, detectorAdapter } = require('../../../database/adapters'); -const { GaqDetectorRepository, RunRepository, DetectorRepository } = require('../../../database/repositories'); +const { GaqDetectorRepository, RunRepository, DetectorRepository, GaqSummaryInvalidationRepository } = require('../../../database/repositories'); const { BadParameterError } = require('../../errors/BadParameterError'); const { dataSource } = require('../../../database/DataSource.js'); const { Op } = require('sequelize'); @@ -57,6 +57,13 @@ class GaqDetectorService { .flatMap((runNumber) => detectorIds .map((detectorId) => ({ dataPassId, runNumber, detectorId }))); const createdEntries = await GaqDetectorRepository.insertAll(gaqEntries); + + // Invalidate GAQ summaries for all affected runs + await Promise.all(runNumbers.map((runNumber) => GaqSummaryInvalidationRepository.upsert({ + dataPassId, + runNumber, + }))); + return createdEntries.map(gaqDetectorAdapter.toEntity); }); } @@ -101,6 +108,13 @@ class GaqDetectorService { .flatMap(({ runNumber, detectors }) => detectors .map(({ id: detectorId }) => ({ dataPassId, runNumber, detectorId }))); const createdEntries = await GaqDetectorRepository.insertAll(gaqEntries); + + // Invalidate GAQ summaries for all affected runs + await Promise.all(runNumbers.map((runNumber) => GaqSummaryInvalidationRepository.upsert({ + dataPassId, + runNumber, + }))); + return createdEntries.map(gaqDetectorAdapter.toEntity); }, { transaction }); } diff --git a/lib/server/services/qualityControlFlag/QcFlagService.js b/lib/server/services/qualityControlFlag/QcFlagService.js index 3d34e1446b..3533716572 100644 --- a/lib/server/services/qualityControlFlag/QcFlagService.js +++ b/lib/server/services/qualityControlFlag/QcFlagService.js @@ -20,6 +20,7 @@ const { RunRepository, QcFlagVerificationRepository, QcFlagEffectivePeriodRepository, + GaqSummaryInvalidationRepository, }, } = require('../../../database/index.js'); const { dataSource } = require('../../../database/DataSource.js'); @@ -209,6 +210,14 @@ class QcFlagService { ], }); + if (dataPass) { + // Invalidate GAQ summary for the dataPass and runNumber of the created flag + await GaqSummaryInvalidationRepository.upsert({ + dataPassId: dataPassIdentifier, + runNumber, + }); + } + createdFlags.push(qcFlagAdapter.toEntity(createdFlag)); } catch (error) { this._logger.warnMessage(`Failed to create QC flag with properties: ${JSON.stringify(qcFlag)}. Error: ${error}`); @@ -284,6 +293,15 @@ class QcFlagService { { const { id, from, to, origin, createdById, runNumber, dplDetectorId, flagTypeId, createdAt } = qcFlag; + + if (dataPassId) { + // Invalidate GAQ summary for the dataPass and runNumber of the created flag + await GaqSummaryInvalidationRepository.upsert({ + dataPassId: dataPassId, + runNumber, + }); + } + const qcFlagPropertiesToLog = { id, from, @@ -338,8 +356,8 @@ class QcFlagService { await QcFlagEffectivePeriodRepository.removeAll({ where: { id: { [Op.in]: effectivePeriodIds } } }); // Sequelize update requires a where and can't work only using association - const qcFlagIds = (await QcFlagRepository.findAll({ - attributes: ['id'], + const qcFlagIdsToRunNumbers = (await QcFlagRepository.findAll({ + attributes: ['id', 'runNumber'], include: { association: 'dataPasses', attributes: [], @@ -349,13 +367,23 @@ class QcFlagService { }, }, raw: true, - })).map(({ id }) => id); + })).map(({ id, runNumber }) => ({ id, runNumber })); + + const qcFlagIds = qcFlagIdsToRunNumbers.map(({ id }) => id); + const runNumbers = new Set(); + qcFlagIdsToRunNumbers.map(({ runNumber }) => runNumbers.add(runNumber)); await QcFlagRepository.updateAll( { deleted: true }, { where: { id: qcFlagIds } }, ); + // Invalidate GAQ summary for the dataPass and all runNumbers of the deleted flags + await Promise.all(Array.from(runNumbers).map((runNumber) => GaqSummaryInvalidationRepository.upsert({ + dataPassId, + runNumber, + }))); + return qcFlagIds.length; }); } @@ -389,7 +417,21 @@ class QcFlagService { createdById: user.id, }); - return await this.getOneOrFail(flagId); + const updatedQcFlag = await this.getOneOrFail(flagId); + + // Get data pass id for the flag (if exists) to invalidate only related GAQ summaries + const dataPassQcFlag = await DataPassQcFlagRepository.findOne({ where: { qualityControlFlagId: flagId } }); + const dataPassId = dataPassQcFlag?.dataPassId; + + // Invalidate GAQ summary if it's the first verification + if (dataPassId && (!qcFlag.verifications || qcFlag.verifications.length === 0)) { + await GaqSummaryInvalidationRepository.upsert({ + dataPassId, + runNumber: updatedQcFlag.runNumber, + }); + } + + return updatedQcFlag; }, { transaction }); } diff --git a/lib/server/services/run/updateRun.js b/lib/server/services/run/updateRun.js index 2e8b50048c..98c7247c53 100644 --- a/lib/server/services/run/updateRun.js +++ b/lib/server/services/run/updateRun.js @@ -12,6 +12,8 @@ */ const RunRepository = require('../../../database/repositories/RunRepository.js'); +const GaqSummaryInvalidationRepository = require('../../../database/repositories/GaqSummaryInvalidationRepository.js'); +const DataPassRunRepository = require('../../../database/repositories/DataPassRunRepository.js'); const { getRunOrFail } = require('./getRunOrFail.js'); const { utilities: { TransactionHelper } } = require('../../../database'); const { checkLhcFill } = require('../../../usecases/lhcFill/checkLhcFill.js'); @@ -82,7 +84,12 @@ exports.updateRun = async (identifier, payload, transaction) => { } // Store the run quality to create a log if it changed - const previousRun = { runQuality: runModel?.runQuality, calibrationStatus: runModel?.calibrationStatus }; + const previousRun = { + runQuality: runModel?.runQuality, + calibrationStatus: runModel?.calibrationStatus, + qcTimeStart: runModel?.qcTimeStart, + qcTimeEnd: runModel?.qcTimeEnd, + }; runPatch.definition = runPatch.definition ?? getRunDefinition({ ...runModel.dataValues, @@ -204,6 +211,22 @@ exports.updateRun = async (identifier, payload, transaction) => { } } + // Need to reload the runModel as qcTimeStart and qcTimeEnd are virtual columns not in the patch so will stay stale after update + await runModel.reload(); + if (previousRun.qcTimeStart?.getTime() !== runModel.qcTimeStart?.getTime() + || previousRun.qcTimeEnd?.getTime() !== runModel.qcTimeEnd?.getTime()) { + const dataPassRuns = await DataPassRunRepository.findAll({ + attributes: ['dataPassId'], + where: { runNumber: runModel.runNumber }, + }); + for (const { dataPassId } of dataPassRuns) { + await GaqSummaryInvalidationRepository.upsert({ + dataPassId, + runNumber: runModel.runNumber, + }); + } + } + return runModel; }, { transaction }); }; From ee55f5972849abe0d6b35abbba346bbbcd32ac36 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:06:51 +0100 Subject: [PATCH 07/72] Add GAQ worker and summary recalculation Introduce background processing for GAQ summary invalidations and wire it into the app scheduler. Changes include: - Add gaq config to services config. - Move GaqService to lib/server/services/gaq and update imports across controllers/use-cases/tests. - Extend GaqService to pop invalidations and recalculate summaries within a transaction. - Add GaqWorker that guards concurrent runs and calls GaqService to process a batch of invalid summaries. - Schedule the GaqWorker in application startup when GAQ recalculation is enabled. - Add a soft-delete filter (where: { deleted: false }) when querying QC flags to map ids to run numbers as otherwise summary invalidations occur even on already deleted flags. These changes enable periodic recalculation of GAQ summaries when invalidations are queued in the table. --- lib/application.js | 14 +++++- lib/config/services.js | 6 +++ lib/server/controllers/qcFlag.controller.js | 2 +- .../{qualityControlFlag => gaq}/GaqService.js | 45 ++++++++++++++++++- lib/server/services/gaq/GaqWorker.js | 35 +++++++++++++++ .../qualityControlFlag/QcFlagService.js | 1 + lib/usecases/run/GetAllRunsUseCase.js | 2 +- .../qualityControlFlag/QcFlagService.test.js | 2 +- 8 files changed, 102 insertions(+), 5 deletions(-) rename lib/server/services/{qualityControlFlag => gaq}/GaqService.js (73%) create mode 100644 lib/server/services/gaq/GaqWorker.js diff --git a/lib/application.js b/lib/application.js index 70a4ac6839..0f24120f48 100644 --- a/lib/application.js +++ b/lib/application.js @@ -15,7 +15,7 @@ const database = require('./database'); const { webUiServer } = require('./server'); const { GRPCConfig, ServicesConfig } = require('./config'); -const { userCertificate, monalisa: monalisaConfig, ccdb: ccdbConfig, enableHousekeeping } = ServicesConfig; +const { userCertificate, monalisa: monalisaConfig, ccdb: ccdbConfig, gaq: gaqConfig, enableHousekeeping } = ServicesConfig; const { handleLostRunsAndEnvironments } = require('./server/services/housekeeping/handleLostRunsAndEnvironments.js'); const { isInTestMode } = require('./utilities/env-utils.js'); const { ScheduledProcessesManager } = require('./server/services/ScheduledProcessesManager.js'); @@ -27,6 +27,7 @@ const { AliEcsSynchronizer } = require('./server/kafka/AliEcsSynchronizer.js'); const { environmentService } = require('./server/services/environment/EnvironmentService.js'); const { runService } = require('./server/services/run/RunService.js'); const { CcdbSynchronizer } = require('./server/externalServicesSynchronization/ccdb/CcdbSynchronizer.js'); +const { GaqWorker } = require('./server/services/gaq/GaqWorker.js'); const { promises: fs } = require('fs'); const { MonAlisaClient } = require('./server/externalServicesSynchronization/monalisa/MonAlisaClient.js'); const https = require('https'); @@ -131,6 +132,17 @@ class BookkeepingApplication { }, ); } + + if (gaqConfig.enableRecalculation) { + const gaqWorker = new GaqWorker(); + this.scheduledProcessesManager.schedule( + () => gaqWorker.recalculateGaqSummaries(gaqConfig.batchSize), + { + wait: 10 * 1000, + every: gaqConfig.recalculationPeriod, + }, + ); + } } catch (error) { this._logger.errorMessage(`Error while starting: ${error}`); return this.stop(); diff --git a/lib/config/services.js b/lib/config/services.js index 9e535881a9..2c9f941c6a 100644 --- a/lib/config/services.js +++ b/lib/config/services.js @@ -68,4 +68,10 @@ exports.services = { synchronizationPeriod: Number(CCDB_SYNCHRONIZATION_PERIOD) || 24 * 60 * 60 * 1000, // 1d in milliseconds runInfoUrl: CCDB_RUN_INFO_URL, }, + + gaq: { + enableRecalculation: process.env?.GAQ_ENABLE_RECALCULATION?.toLowerCase() === 'true' || true, + recalculationPeriod: Number(process.env?.GAQ_RECALCULATION_PERIOD) || 60 * 1000, // 1m in milliseconds + batchSize: Number(process.env?.GAQ_RECALCULATION_BATCH_SIZE) || 1, + }, }; diff --git a/lib/server/controllers/qcFlag.controller.js b/lib/server/controllers/qcFlag.controller.js index 7a088a3eb3..b7e4cf15f3 100644 --- a/lib/server/controllers/qcFlag.controller.js +++ b/lib/server/controllers/qcFlag.controller.js @@ -21,7 +21,7 @@ const { PaginationDto } = require('../../domain/dtos'); const { ApiConfig } = require('../../config'); const { countedItemsToHttpView } = require('../utilities/countedItemsToHttpView'); const { qcFlagService } = require('../services/qualityControlFlag/QcFlagService.js'); -const { gaqService } = require('../services/qualityControlFlag/GaqService.js'); +const { gaqService } = require('../services/gaq/GaqService.js'); const { qcFlagSummaryService } = require('../services/qualityControlFlag/QcFlagSummaryService.js'); const qcFlagFilterDTO = Joi.object({ diff --git a/lib/server/services/qualityControlFlag/GaqService.js b/lib/server/services/gaq/GaqService.js similarity index 73% rename from lib/server/services/qualityControlFlag/GaqService.js rename to lib/server/services/gaq/GaqService.js index 422e48bcac..5336ac68b4 100644 --- a/lib/server/services/qualityControlFlag/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -28,15 +28,24 @@ */ const { getOneDataPassOrFail } = require('../dataPasses/getOneDataPassOrFail.js'); -const { QcFlagRepository } = require('../../../database/repositories/index.js'); +const { QcFlagRepository, GaqSummaryRepository, GaqSummaryInvalidationRepository } = require('../../../database/repositories/index.js'); const { qcFlagAdapter } = require('../../../database/adapters/index.js'); const { Op } = require('sequelize'); const { QcSummarProperties } = require('../../../domain/enums/QcSummaryProperties.js'); +const { dataSource } = require('../../../database/DataSource.js'); +const { LogManager } = require('@aliceo2/web-ui'); /** * Globally aggregated quality (QC flags aggregated for a predefined list of detectors per runs) service */ class GaqService { + /** + * Constructor + */ + constructor() { + this._logger = LogManager.getLogger('GAQ_SERVICE'); + } + /** * Get GAQ summary * @@ -113,6 +122,40 @@ class GaqService { contributingFlags: contributingFlagIds.map((id) => idToFlag[id]), })); } + + /** + * Calculate and store GAQ summary for given data pass and run + * @param {number} dataPassId id of data pass + * @param {number} runNumber run number + * @return {Promise} promise + */ + async calculateAndStoreGaqSummary(dataPassId, runNumber) { + const summary = await this.getSummary(dataPassId, { runNumber }); + await GaqSummaryRepository.upsert({ dataPassId, runNumber, ...summary }); + } + + /** + * Remove invalid GAQ summaries and recalculate them + * @param {number} batchSize maximum number of invalid summaries to process + * @return {Promise} promise + */ + async popNInvalidSummaryAndRecalculate(batchSize = 1) { + const invalidCount = await GaqSummaryInvalidationRepository.count(); + const remaining = Math.min(batchSize, invalidCount); + if (remaining === 0) { + return; + } + await dataSource.transaction(async () => { + for (let i = 0; i < remaining; i++) { + const invalidation = await GaqSummaryInvalidationRepository.removeOne({ where: {}, order: [['createdAt', 'ASC']] }); + if (!invalidation) { + break; + } + const { dataPassId, runNumber } = invalidation; + await this.calculateAndStoreGaqSummary(dataPassId, runNumber); + } + }); + } } exports.GaqService = GaqService; diff --git a/lib/server/services/gaq/GaqWorker.js b/lib/server/services/gaq/GaqWorker.js new file mode 100644 index 0000000000..1fa01920c0 --- /dev/null +++ b/lib/server/services/gaq/GaqWorker.js @@ -0,0 +1,35 @@ +const { gaqService } = require('../../services/gaq/GaqService.js'); +const { LogManager } = require('@aliceo2/web-ui'); + +/** + * Worker responsible for processing pending GAQ summary invalidations + */ +class GaqWorker { + /** + * Constructor + */ + constructor() { + this._logger = LogManager.getLogger(GaqWorker.name); + } + + /** + * Process pending GAQ summary invalidations. Skips if a previous call is still in progress. + * @param {number} batchSize number of invalid summaries to recalculate + * @return {Promise} promise + */ + async recalculateGaqSummaries(batchSize) { + if (this._isSynchronizing) { + return; + } + this._isSynchronizing = true; + try { + await gaqService.popNInvalidSummaryAndRecalculate(batchSize); + } catch (error) { + this._logger.errorMessage(`Error recalculating GAQ summaries: ${error.message}\n${error.stack}`); + } finally { + this._isSynchronizing = false; + } + } +} + +exports.GaqWorker = GaqWorker; diff --git a/lib/server/services/qualityControlFlag/QcFlagService.js b/lib/server/services/qualityControlFlag/QcFlagService.js index 3533716572..6ae491966c 100644 --- a/lib/server/services/qualityControlFlag/QcFlagService.js +++ b/lib/server/services/qualityControlFlag/QcFlagService.js @@ -358,6 +358,7 @@ class QcFlagService { // Sequelize update requires a where and can't work only using association const qcFlagIdsToRunNumbers = (await QcFlagRepository.findAll({ attributes: ['id', 'runNumber'], + where: { deleted: false }, include: { association: 'dataPasses', attributes: [], diff --git a/lib/usecases/run/GetAllRunsUseCase.js b/lib/usecases/run/GetAllRunsUseCase.js index df1b5f7f5b..3fd43b8f76 100644 --- a/lib/usecases/run/GetAllRunsUseCase.js +++ b/lib/usecases/run/GetAllRunsUseCase.js @@ -20,7 +20,7 @@ const sequelize = require('sequelize'); const { EorReasonRepository } = require('../../database/repositories'); const { PhysicalConstant } = require('../../domain/enums/PhysicalConstant'); const { BadParameterError } = require('../../server/errors/BadParameterError'); -const { gaqService } = require('../../server/services/qualityControlFlag/GaqService.js'); +const { gaqService } = require('../../server/services/gaq/GaqService.js'); const { qcFlagSummaryService } = require('../../server/services/qualityControlFlag/QcFlagSummaryService.js'); const { DetectorType } = require('../../domain/enums/DetectorTypes.js'); const { unpackNumberRange } = require('../../utilities/rangeUtils.js'); diff --git a/test/lib/server/services/qualityControlFlag/QcFlagService.test.js b/test/lib/server/services/qualityControlFlag/QcFlagService.test.js index f4c533444f..e62fd06b4e 100644 --- a/test/lib/server/services/qualityControlFlag/QcFlagService.test.js +++ b/test/lib/server/services/qualityControlFlag/QcFlagService.test.js @@ -21,7 +21,7 @@ const { Op } = require('sequelize'); const { qcFlagAdapter } = require('../../../../../lib/database/adapters'); const { runService } = require('../../../../../lib/server/services/run/RunService'); const { gaqDetectorService } = require('../../../../../lib/server/services/gaq/GaqDetectorsService'); -const { gaqService } = require('../../../../../lib/server/services/qualityControlFlag/GaqService.js'); +const { gaqService } = require('../../../../../lib/server/services/gaq/GaqService.js'); const { qcFlagSummaryService } = require('../../../../../lib/server/services/qualityControlFlag/QcFlagSummaryService.js'); const { dataPassService } = require('../../../../../lib/server/services/dataPasses/DataPassService.js'); From 530216b98a8e8484a6cd2e5a1e77c049a5408049 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:26:13 +0100 Subject: [PATCH 08/72] [O2B-1564] Create computeSummary to separate getting and computing --- lib/server/services/gaq/GaqService.js | 36 ++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index 5336ac68b4..178a810dee 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -92,6 +92,37 @@ class GaqService { return Object.fromEntries(gaqSummary); } + /** + * Find GAQ summary for given data pass and run. Returns null if no summary can be computed (e.g. no QC flags in GAQ periods) + * @param {number} dataPassId id of data pass + * @param {number} runNumber run number + * @return {Promise} promise of GAQ summary or null if it can't be computed + */ + async _computeSummary(dataPassId, runNumber) { + const gaqCoverages = await QcFlagRepository.getGaqCoverages(dataPassId, runNumber); + const entry = gaqCoverages[runNumber]; + if (!entry) { + return null; + } + + const { + badCoverage, + mcReproducibleCoverage, + goodCoverage, + flagsIds, + verifiedFlagsIds, + undefinedQualityPeriodsCount, + } = entry; + + return { + badEffectiveRunCoverage: badCoverage + mcReproducibleCoverage, + explicitlyNotBadEffectiveRunCoverage: goodCoverage, + mcReproducibleCoverage, + missingVerificationsCount: flagsIds.length - verifiedFlagsIds.length, + undefinedQualityPeriodsCount, + }; + } + /** * Find QC flags in GAQ effective periods for given data pass and run * @@ -130,7 +161,10 @@ class GaqService { * @return {Promise} promise */ async calculateAndStoreGaqSummary(dataPassId, runNumber) { - const summary = await this.getSummary(dataPassId, { runNumber }); + const summary = await this._computeSummary(dataPassId, runNumber); + if (!summary) { + return; + } await GaqSummaryRepository.upsert({ dataPassId, runNumber, ...summary }); } From e1f3223e6b8c8f5f6414a999962e56cbf72ba78c Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:41:46 +0100 Subject: [PATCH 09/72] [O2B-1564] Exclude reproducible coverage from badEffectiveRunCoverage --- lib/server/services/gaq/GaqService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index 178a810dee..82edf93ed5 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -115,7 +115,7 @@ class GaqService { } = entry; return { - badEffectiveRunCoverage: badCoverage + mcReproducibleCoverage, + badEffectiveRunCoverage: badCoverage, explicitlyNotBadEffectiveRunCoverage: goodCoverage, mcReproducibleCoverage, missingVerificationsCount: flagsIds.length - verifiedFlagsIds.length, From 16d3563ef0813e8c10de405ef7d9cda50eed8b3c Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:34:34 +0100 Subject: [PATCH 10/72] [O2B-1545] Rename GAQ summary coverage fields --- lib/database/adapters/GaqSummaryAdapter.js | 8 ++++---- .../v1/20260223120000-create-gaq-summary-tables.js | 4 ++-- lib/database/models/gaqSummary.js | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/database/adapters/GaqSummaryAdapter.js b/lib/database/adapters/GaqSummaryAdapter.js index fd9ba4aa0e..77ff939d69 100644 --- a/lib/database/adapters/GaqSummaryAdapter.js +++ b/lib/database/adapters/GaqSummaryAdapter.js @@ -32,8 +32,8 @@ class GaqSummaryAdapter { const { dataPassId, runNumber, - badEffectiveRunCoverage, - explicitlyNotBadEffectiveRunCoverage, + badRunCoverage, + explicitlyNotBadRunCoverage, mcReproducibleCoverage, missingVerificationsCount, undefinedQualityPeriodsCount, @@ -44,8 +44,8 @@ class GaqSummaryAdapter { return { dataPassId, runNumber, - badEffectiveRunCoverage, - explicitlyNotBadEffectiveRunCoverage, + badRunCoverage, + explicitlyNotBadRunCoverage, mcReproducibleCoverage, missingVerificationsCount, undefinedQualityPeriodsCount, diff --git a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js index 8122607a37..b734f32e32 100644 --- a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js +++ b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js @@ -22,11 +22,11 @@ module.exports = { key: 'run_number', }, }, - bad_effective_run_coverage: { + bad_run_coverage: { type: Sequelize.FLOAT, allowNull: false, }, - explicitly_not_bad_effective_run_coverage: { + explicitly_not_bad_run_coverage: { type: Sequelize.FLOAT, allowNull: false, }, diff --git a/lib/database/models/gaqSummary.js b/lib/database/models/gaqSummary.js index f3338e5557..480a9a4943 100644 --- a/lib/database/models/gaqSummary.js +++ b/lib/database/models/gaqSummary.js @@ -21,10 +21,10 @@ module.exports = (sequelize) => { runNumber: { type: Sequelize.INTEGER, }, - badEffectiveRunCoverage: { + badRunCoverage: { type: Sequelize.FLOAT, }, - explicitlyNotBadEffectiveRunCoverage: { + explicitlyNotBadRunCoverage: { type: Sequelize.FLOAT, }, mcReproducibleCoverage: { From 87afd8c256ec966b5bde13dc40dc03c6f379b9d3 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:51:55 +0100 Subject: [PATCH 11/72] [O2B-1564] Rename coverage fields to match table cols in GaqService output --- lib/server/services/gaq/GaqService.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index 82edf93ed5..79997ad06c 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -115,8 +115,8 @@ class GaqService { } = entry; return { - badEffectiveRunCoverage: badCoverage, - explicitlyNotBadEffectiveRunCoverage: goodCoverage, + badRunCoverage: badCoverage, + explicitlyNotBadRunCoverage: goodCoverage, mcReproducibleCoverage, missingVerificationsCount: flagsIds.length - verifiedFlagsIds.length, undefinedQualityPeriodsCount, From 37e97a3164afd31706abc110470928efd9ab1fc2 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:11:32 +0100 Subject: [PATCH 12/72] [O2B-1565] Use GaqSummaryRepository in getSummary Replace manual aggregation via getGaqCoverages with a direct query to GaqSummary table. getSummary now builds a where clause and formats the summaries, if returned, via a new _formatSummary helper that now the mcReproducibleAsNotBad logic resides in. --- lib/server/services/gaq/GaqService.js | 56 +++++++++++++-------------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index 79997ad06c..bb91be8aee 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -58,38 +58,34 @@ class GaqService { */ async getSummary(dataPassId, { mcReproducibleAsNotBad = false, runNumber } = {}) { await getOneDataPassOrFail({ id: dataPassId }); - const gaqCoverages = await QcFlagRepository.getGaqCoverages(dataPassId, runNumber); - const gaqSummary = Object.entries(gaqCoverages).map(([ - runNumberMapped, - { - badCoverage, - mcReproducibleCoverage, - goodCoverage, - flagsIds, - verifiedFlagsIds, - undefinedQualityPeriodsCount, - }, - ]) => [ - runNumberMapped, - { - [QcSummarProperties.BAD_EFFECTIVE_RUN_COVERAGE]: badCoverage + (mcReproducibleAsNotBad ? 0 : mcReproducibleCoverage), - [QcSummarProperties.EXPLICITELY_NOT_BAD_EFFECTIVE_RUN_COVERAGE]: - goodCoverage + (mcReproducibleAsNotBad ? mcReproducibleCoverage : 0), - [QcSummarProperties.MC_REPRODUCIBLE]: mcReproducibleCoverage > 0, - [QcSummarProperties.MISSING_VERIFICATIONS]: flagsIds.length - verifiedFlagsIds.length, - [QcSummarProperties.UNDEFINED_QUALITY_PERIODS_COUNT]: undefinedQualityPeriodsCount, - }, - ]); - - /** - * If runNumber is specified, only one summary is returned but the getGaqCoverages - * returns still with runNumber as key, so we extract the single value from the array. - */ - if (runNumber && gaqSummary.length === 1) { - return Object.fromEntries(gaqSummary)[runNumber]; + + const where = { dataPassId }; + if (runNumber) { + where.runNumber = runNumber; } - return Object.fromEntries(gaqSummary); + const summaries = await GaqSummaryRepository.findAll({ where }); + const summaryByRun = Object.fromEntries(summaries.map((s) => [s.runNumber, this._formatSummary(s, mcReproducibleAsNotBad)])); + + return runNumber ? summaryByRun[runNumber] ?? {} : summaryByRun; + } + + /** + * Format a raw GAQ summary record into its API representation + * @param {object} summary raw summary from the database + * @param {boolean} mcReproducibleAsNotBad whether MC reproducible coverage counts as not-bad + * @return {RunGaqSummary} formatted summary + */ + _formatSummary(summary, mcReproducibleAsNotBad) { + return { + [QcSummarProperties.BAD_EFFECTIVE_RUN_COVERAGE]: + summary.badEffectiveRunCoverage + (mcReproducibleAsNotBad ? 0 : summary.mcReproducibleCoverage), + [QcSummarProperties.EXPLICITELY_NOT_BAD_EFFECTIVE_RUN_COVERAGE]: + summary.explicitlyNotBadEffectiveRunCoverage + (mcReproducibleAsNotBad ? summary.mcReproducibleCoverage : 0), + [QcSummarProperties.MC_REPRODUCIBLE]: summary.mcReproducibleCoverage > 0, + [QcSummarProperties.MISSING_VERIFICATIONS]: summary.missingVerificationsCount, + [QcSummarProperties.UNDEFINED_QUALITY_PERIODS_COUNT]: summary.undefinedQualityPeriodsCount, + }; } /** From 903a0e847a7a6c4054af85826b25a1a219b61765 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:29:56 +0100 Subject: [PATCH 13/72] [O2B-1567] Allow passing fetch options (headers) to remote fetch This will be useful for when we want to set the cache header. --- lib/public/utilities/fetch/RemoteDataSource.js | 10 ++++++---- lib/public/utilities/fetch/getRemoteData.js | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/public/utilities/fetch/RemoteDataSource.js b/lib/public/utilities/fetch/RemoteDataSource.js index b1cd89fd86..332eb5ee4e 100644 --- a/lib/public/utilities/fetch/RemoteDataSource.js +++ b/lib/public/utilities/fetch/RemoteDataSource.js @@ -43,9 +43,10 @@ export class RemoteDataSource { * Fetch the given endpoint to fill current data * * @param {string} endpoint the endpoint to fetch + * @param {Object} [options] additional fetch options (e.g. headers) * @return {Promise} resolves once the data fetching has ended */ - async fetch(endpoint) { + async fetch(endpoint, options) { if (!this._observableData) { return null; } @@ -58,7 +59,7 @@ export class RemoteDataSource { this._abortController.abort(); } this._abortController = abortController; - const data = await this.getRemoteData(endpoint); + const data = await this.getRemoteData(endpoint, options); this._observableData.setCurrent(RemoteData.success(data)); } catch (error) { // Use local variable because the class member (this._abortController) may already have been override in another call @@ -72,9 +73,10 @@ export class RemoteDataSource { * Fetch the endpoint and return the result * * @param {string} endpoint the endpoint to fetch + * @param {Object} [options] additional fetch options (e.g. headers) * @return {Promise<*>} the result */ - async getRemoteData(endpoint) { - return getRemoteData(endpoint, { signal: this._abortController.signal }); + async getRemoteData(endpoint, options) { + return getRemoteData(endpoint, { ...options, signal: this._abortController.signal }); } } diff --git a/lib/public/utilities/fetch/getRemoteData.js b/lib/public/utilities/fetch/getRemoteData.js index f29e021544..bb2e21e57c 100644 --- a/lib/public/utilities/fetch/getRemoteData.js +++ b/lib/public/utilities/fetch/getRemoteData.js @@ -19,6 +19,7 @@ import { jsonFetch } from './jsonFetch.js'; * @param {string} endpoint the remote endpoint to send request to * @param {Object} options eventual options for the request * @param {AbortSignal} [options.signal] optionally, an abort signal to abort the request + * @param {Object} [options.headers] optionally, headers to include in the request * @return {Promise<*>} resolve with the result of the request or reject with the list of errors if any error occurred */ -export const getRemoteData = (endpoint, { signal } = {}) => jsonFetch(endpoint, { method: 'GET', signal }); +export const getRemoteData = (endpoint, { signal, headers } = {}) => jsonFetch(endpoint, { method: 'GET', signal, headers }); From 7f4ce670d3d1f7883450ceacf95768579ef72268 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:53:44 +0100 Subject: [PATCH 14/72] [O2B-1567] Add GAQ recalc endpoint and service logic Add a POST /gaq/recalculate API and corresponding handler to trigger recalculation of GAQ summaries for a data pass and optional run ranges. In GaqService: include invalidation flag on fetched summaries, propagate it to formatted output, ensure upserts update updatedAt, adjust transaction handling, and add recalculateSummaries(dataPassId, runNumbers) which resolves run ranges, enqueues invalidations for matching DataPassRun entries, and returns the number of summaries scheduled for recalculation. --- lib/server/controllers/qcFlag.controller.js | 30 +++++++++ lib/server/routers/qcFlag.router.js | 8 +++ lib/server/services/gaq/GaqService.js | 75 ++++++++++++++++++--- 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/lib/server/controllers/qcFlag.controller.js b/lib/server/controllers/qcFlag.controller.js index b7e4cf15f3..b5c7739443 100644 --- a/lib/server/controllers/qcFlag.controller.js +++ b/lib/server/controllers/qcFlag.controller.js @@ -23,6 +23,7 @@ const { countedItemsToHttpView } = require('../utilities/countedItemsToHttpView' const { qcFlagService } = require('../services/qualityControlFlag/QcFlagService.js'); const { gaqService } = require('../services/gaq/GaqService.js'); const { qcFlagSummaryService } = require('../services/qualityControlFlag/QcFlagSummaryService.js'); +const { validateRange, RANGE_INVALID } = require('../../utilities/rangeUtils.js'); const qcFlagFilterDTO = Joi.object({ createdBy: Joi.object({ @@ -401,6 +402,34 @@ const getGaqSummaryHandler = async (request, response) => { } }; +/** + * Recalculate GAQ summary for given data pass and optionally run number + */ +const recalculateGaqSummaryHandler = async (request, response) => { + const validatedDTO = await dtoValidator( + DtoFactory.queryOnly(Joi.object({ + dataPassId: Joi.number().positive().required(), + runNumbers: Joi.string().trim().custom(validateRange).messages({ + [RANGE_INVALID]: '{{#message}}', + 'string.base': 'Run numbers must be comma-separated numbers or ranges (e.g. 12,15-18)', + }), + onlyInvalidated: Joi.boolean(), + })), + request, + response, + ); + if (validatedDTO) { + try { + const { dataPassId, runNumbers, onlyInvalidated } = validatedDTO.query; + + const data = await gaqService.recalculateSummaries(dataPassId, runNumbers, onlyInvalidated); + response.json({ data }); + } catch (error) { + updateExpressResponseFromNativeError(response, error); + } + } +}; + exports.QcFlagController = { getQcFlagByIdHandler, listQcFlagsPerDataPassHandler, @@ -413,4 +442,5 @@ exports.QcFlagController = { getQcFlagsSummaryHandler, getGaqQcFlagsHandler, getGaqSummaryHandler, + recalculateGaqSummaryHandler, }; diff --git a/lib/server/routers/qcFlag.router.js b/lib/server/routers/qcFlag.router.js index 569a6802ec..3c24782fc8 100644 --- a/lib/server/routers/qcFlag.router.js +++ b/lib/server/routers/qcFlag.router.js @@ -33,6 +33,14 @@ exports.qcFlagsRouter = { path: 'gaq', method: 'get', controller: QcFlagController.getGaqSummaryHandler, + + children: [ + { + path: 'recalculate', + method: 'post', + controller: [rbacMiddleware([BkpRoles.DPG_ASYNC_QC_ADMIN]), QcFlagController.recalculateGaqSummaryHandler], + }, + ], }, ], }, diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index bb91be8aee..bc9bfeea38 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -28,12 +28,18 @@ */ const { getOneDataPassOrFail } = require('../dataPasses/getOneDataPassOrFail.js'); -const { QcFlagRepository, GaqSummaryRepository, GaqSummaryInvalidationRepository } = require('../../../database/repositories/index.js'); +const { QcFlagRepository, + GaqSummaryRepository, + GaqSummaryInvalidationRepository, + DataPassRunRepository, +} = require('../../../database/repositories/index.js'); const { qcFlagAdapter } = require('../../../database/adapters/index.js'); -const { Op } = require('sequelize'); +const { Op, Sequelize } = require('sequelize'); const { QcSummarProperties } = require('../../../domain/enums/QcSummaryProperties.js'); const { dataSource } = require('../../../database/DataSource.js'); const { LogManager } = require('@aliceo2/web-ui'); +const { unpackNumberRange } = require('../../../utilities/rangeUtils.js'); +const { splitStringToStringsTrimmed } = require('../../../utilities/stringUtils.js'); /** * Globally aggregated quality (QC flags aggregated for a predefined list of detectors per runs) service @@ -64,8 +70,29 @@ class GaqService { where.runNumber = runNumber; } - const summaries = await GaqSummaryRepository.findAll({ where }); - const summaryByRun = Object.fromEntries(summaries.map((s) => [s.runNumber, this._formatSummary(s, mcReproducibleAsNotBad)])); + const summaries = await GaqSummaryRepository.findAll({ + where, + attributes: { + include: [ + [ + Sequelize.literal( + 'EXISTS (SELECT 1 FROM `gaq_summary_invalidations` AS `gsi`' + + ' WHERE `gsi`.`data_pass_id` = `GaqSummary`.`data_pass_id`' + + ' AND `gsi`.`run_number` = `GaqSummary`.`run_number`)', + ), + 'invalidated', + ], + ], + }, + }); + + const summaryByRun = Object.fromEntries(summaries.map((s) => { + const formatted = this._formatSummary(s, mcReproducibleAsNotBad); + if (s.get('invalidated')) { + formatted.invalidated = true; + } + return [s.runNumber, formatted]; + })); return runNumber ? summaryByRun[runNumber] ?? {} : summaryByRun; } @@ -122,7 +149,7 @@ class GaqService { /** * Find QC flags in GAQ effective periods for given data pass and run * - * @param {number} dataPassId id od data pass + * @param {number} dataPassId id of data pass * @param {number} runNumber run number * @return {Promise} promise of aggregated QC flags */ @@ -161,7 +188,7 @@ class GaqService { if (!summary) { return; } - await GaqSummaryRepository.upsert({ dataPassId, runNumber, ...summary }); + await GaqSummaryRepository.upsert({ dataPassId, runNumber, ...summary, updatedAt: new Date() }); } /** @@ -175,16 +202,44 @@ class GaqService { if (remaining === 0) { return; } - await dataSource.transaction(async () => { - for (let i = 0; i < remaining; i++) { + for (let i = 0; i < remaining; i++) { + await dataSource.transaction(async () => { const invalidation = await GaqSummaryInvalidationRepository.removeOne({ where: {}, order: [['createdAt', 'ASC']] }); if (!invalidation) { - break; + return; } const { dataPassId, runNumber } = invalidation; await this.calculateAndStoreGaqSummary(dataPassId, runNumber); - } + }); + } + } + + /** + * Recalculate summaries for given data pass and runs + * @param {number} dataPassId data pass id + * @param {number[]} [runNumbers] optional list of run numbers to recalculate. If not set, all runs will be considered + * @return {Promise} number of recalculated summaries + */ + async recalculateSummaries(dataPassId, runNumbers) { + const where = { dataPassId }; + let finalRunNumberList; + + if (runNumbers) { + const runNumberCriteria = splitStringToStringsTrimmed(runNumbers, ','); + finalRunNumberList = Array.from(unpackNumberRange(runNumberCriteria)); + where.runNumber = { [Op.in]: finalRunNumberList }; + } + + const summariesToRecalculate = await DataPassRunRepository.findAll({ + where, + attributes: ['dataPassId', 'runNumber'], }); + + for (const summary of summariesToRecalculate) { + await GaqSummaryInvalidationRepository.upsert({ dataPassId: summary.dataPassId, runNumber: summary.runNumber }); + } + + return { summariesToRecalculate: summariesToRecalculate.length }; } } From cfd05383cfc88b6c17e2f4f474a9cf4c5a70417e Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:04:33 +0100 Subject: [PATCH 15/72] [O2B-1567] Add GAQ summary recalculation UI support - Add new getGAQSummaryDisplay.js to render GAQ state and per-run recalculate button for admins. - Update getQcSummaryDisplay to accept an options.classes param and render an "invalidated" clock indicator when summary.invalidated is set. - implement recalculateGaqSummary(runNumbers) to POST to /api/qcFlags/summary/gaq/recalculate and refresh summaries. --- .../ActiveColumns/getGAQSummaryDisplay.js | 124 ++++++++++++++++++ .../Runs/ActiveColumns/getQcSummaryDisplay.js | 28 +++- .../RunsPerDataPassOverviewModel.js | 73 +++++++++-- .../RunsPerDataPassOverviewPage.js | 59 +++++---- 4 files changed, 240 insertions(+), 44 deletions(-) create mode 100644 lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js diff --git a/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js b/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js new file mode 100644 index 0000000000..df279080c2 --- /dev/null +++ b/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js @@ -0,0 +1,124 @@ +/** + * @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 { + h, + iconWarning, + iconReload, + iconClock, + sessionService, +} from '/js/src/index.js'; +import { tooltip } from '../../../components/common/popover/tooltip.js'; +import { getQcSummaryDisplay } from './getQcSummaryDisplay.js'; +import { BkpRoles } from '../../../domain/enums/BkpRoles.js'; +import spinner from '../../../components/common/spinner.js'; +import { frontLink } from '../../../components/common/navigation/frontLink.js'; + +/** + * Render an invalidated status indicator with tooltip + * + * @return {Component} invalidated display + */ +const invalidatedDisplay = () => h( + '.d-inline-block.va-t-bottom', + tooltip(h('.f7', iconClock()), 'Summary is invalid. New summary will be calculated shortly.'), +); + +/** + * Render a failure warning indicator with tooltip + * + * @return {Component} failure warning display + */ +const failureWarningDisplay = () => h( + '.d-inline-block.va-t-bottom', + tooltip(h('.f7', iconWarning()), 'GAQ Summary failed, please click to view GAQ flags'), +); + +/** + * Render a recalculate button for GAQ summary + * + * @param {() => void} onRecalculate callback for recalculating GAQ summary + * @return {Component} reload button + */ +const recalculateButton = (onRecalculate) => h( + 'button.btn.btn-group-item.last-item', + { + title: 'Recalculate GAQ for this run', + onclick: (e) => { + e.preventDefault(); + onRecalculate(); + }, + }, + iconReload(), +); + +/** + * Render the GAQ display for a successfully loaded summary + * + * @param {RunGaqSummary} gaqSummary the GAQ summary data + * @param {boolean} isFirstInGroup whether this is the first button in a button group (affects styling) + * @return {Component} the display element + */ +const getSuccessGaqDisplay = (gaqSummary, isFirstInGroup) => { + const { undefinedQualityPeriodsCount, invalidated } = gaqSummary; + + if (undefinedQualityPeriodsCount === 0) { + return getQcSummaryDisplay(gaqSummary, isFirstInGroup ? { classes: 'first-item' } : {}); + } + + return h(`button.btn.btn-primary.w-100${isFirstInGroup ? '.first-item' : ''}`, [ + 'GAQ', + invalidated ? invalidatedDisplay() : null, + ]); +}; + +/** + * Render a loading indicator for GAQ summary + * + * @return {Component} loading spinner with tooltip + */ +const loadingDisplay = () => tooltip( + h('.flex-row.items-center.justify-center.black', spinner({ size: 2, absolute: false })), + 'Loading GAQ summary...', +); + +/** + * Render display for GAQ summaries + * + * @param {RemoteData} remoteSummary the remote GAQ summary data + * @param {number} dataPassId the data pass ID + * @param {number} runNumber the run number to display GAQ for + * @param {() => void} onRecalculate callback for recalculating GAQ summary + * @return {Component} display + */ +export const getGAQSummaryDisplay = (remoteSummary, dataPassId, runNumber, onRecalculate) => { + const hasRecalculateAccess = sessionService.hasAccess([BkpRoles.DPG_ASYNC_QC_ADMIN]); + + const gaqFlagLink = (content) => frontLink(content, 'gaq-flags', { dataPassId, runNumber }); + + return remoteSummary.match({ + Success: (gaqSummary) => h( + `.flex-row.items-center${hasRecalculateAccess ? '.btn-group' : ''}`, + [ + gaqFlagLink(getSuccessGaqDisplay(gaqSummary, hasRecalculateAccess)), + hasRecalculateAccess ? recalculateButton(onRecalculate) : null, + ], + ), + Loading: loadingDisplay, + NotAsked: loadingDisplay, + Failure: () => gaqFlagLink(h( + 'button.btn.btn-primary.w-100', + ['GAQ', failureWarningDisplay()], + )), + }); +}; diff --git a/lib/public/views/Runs/ActiveColumns/getQcSummaryDisplay.js b/lib/public/views/Runs/ActiveColumns/getQcSummaryDisplay.js index 60ea52b338..26d575aaa1 100644 --- a/lib/public/views/Runs/ActiveColumns/getQcSummaryDisplay.js +++ b/lib/public/views/Runs/ActiveColumns/getQcSummaryDisplay.js @@ -15,6 +15,7 @@ import { h, iconWarning, iconBolt, + iconClock, PopoverAnchors, PopoverTriggerPreConfiguration, } from '/js/src/index.js'; @@ -31,15 +32,18 @@ const FULL_COVERAGE = 1; * Render display common for QC and GAQ summaries * * @param {RunDetectorQcSummary|RunGaqSummary} summary summary + * @param {Object} [options] display options + * @param {string} [options.classes] additional CSS classes to add to the display element * @return {Component} display */ -export const getQcSummaryDisplay = (summary) => { +export const getQcSummaryDisplay = (summary, { classes } = {}) => { const { badEffectiveRunCoverage, explicitlyNotBadEffectiveRunCoverage, missingVerificationsCount, mcReproducible, minifiedFlags = [], + invalidated = false, } = summary; const missingVerificationDisplay = missingVerificationsCount @@ -49,6 +53,13 @@ export const getQcSummaryDisplay = (summary) => { ) : null; + const invalidatedDisplay = invalidated + ? h( + '.d-inline-block.va-t-bottom', + tooltip(h('.f7', iconClock()), 'Summary is invalid. New summary will be calculated shortly.'), + ) + : null; + const notBadPercentageFormat = h('span', formatFloat((1 - badEffectiveRunCoverage) * 100, { precision: 0 })); const nonMcReproducibleQcSummaryContent = [ @@ -65,7 +76,7 @@ export const getQcSummaryDisplay = (summary) => { * @return {Component} QC summary display */ const getQcSummaryDisplayWithColor = (content, { color }) => h( - '.btn.w-100', + `.btn.w-100${classes ? `.${classes}` : ''}`, { style: { backgroundColor: color, color: getContrastColor(color) } }, content, ); @@ -79,12 +90,16 @@ export const getQcSummaryDisplay = (summary) => { 'Run start or stop is missing and time-based flag was assigned, coverage cannot be calculated', )), missingVerificationDisplay, + invalidatedDisplay, ], { color: QcSummaryColors.INCALCULABLE_COVERAGE }, ); } else if (!badEffectiveRunCoverage) { qcSummaryDisplay = getQcSummaryDisplayWithColor( - nonMcReproducibleQcSummaryContent, + [ + ...nonMcReproducibleQcSummaryContent, + invalidatedDisplay, + ], { color: explicitlyNotBadEffectiveRunCoverage === FULL_COVERAGE ? QcSummaryColors.ALL_GOOD @@ -97,12 +112,17 @@ export const getQcSummaryDisplay = (summary) => { notBadPercentageFormat, h('em.d-inline-block.va-top.f7', 'MC.R'), missingVerificationDisplay, + invalidatedDisplay, + ], { color: QcSummaryColors.LIMITED_ACCEPTANCE_MC_REPRODUCIBLE }, ); } else { qcSummaryDisplay = getQcSummaryDisplayWithColor( - nonMcReproducibleQcSummaryContent, + [ + ...nonMcReproducibleQcSummaryContent, + invalidatedDisplay, + ], { color: badEffectiveRunCoverage === FULL_COVERAGE ? QcSummaryColors.ALL_BAD diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js index 0aca49d627..c5c6eb2b18 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js @@ -76,6 +76,9 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo this._freezeOrUnfreezeActionState$ = new ObservableData(RemoteData.notAsked()); this._freezeOrUnfreezeActionState$.bubbleTo(this); + this._recalculateGaqActionState$ = new ObservableData(RemoteData.notAsked()); + this._recalculateGaqActionState$.bubbleTo(this); + this._discardAllQcFlagsActionState$ = new ObservableData(RemoteData.notAsked()); this._discardAllQcFlagsActionState$.bubbleTo(this); @@ -153,13 +156,6 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo return buildUrl(super.getRootEndpoint(), { filter }); } - /** - * @inheritdoc - */ - resetFiltering(fetch = true) { - super.resetFiltering(fetch); - } - /** * Freeze the current data pass * @@ -207,6 +203,49 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo return this._freezeOrUnfreezeActionState$.getCurrent(); } + /** + * Recalculate GAQ summary for current data pass and optionally given run numbers + * + * @param {number[]} [runNumbers] optional run numbers to recalculate GAQ summary for + * if not provided, GAQ summary will be recalculated for all runs of the data pass + * @return {Promise} resolves once request is handled + */ + async recalculateGaqSummary(runNumbers) { + this._recalculateGaqActionState$.setCurrent(RemoteData.loading()); + try { + const queryParams = { dataPassId: this._dataPassId, onlyInvalidated: false }; + if (runNumbers?.length) { + queryParams.runNumbers = runNumbers.join(','); + } + await jsonFetch( + buildUrl('/api/qcFlags/summary/gaq/recalculate', queryParams), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + ); + this._recalculateGaqActionState$.setCurrent(RemoteData.success(null)); + if (runNumbers?.length) { + for (const runNumber of runNumbers) { + this._fetchGaqSummary(runNumber); + } + } else { + this._fetchGaqSummaryForCurrentRuns(); + } + } catch (error) { + this._recalculateGaqActionState$.setCurrent(RemoteData.failure(error)); + } + } + + /** + * Return the state of the recalculate GAQ action + * + * @return {RemoteData} the recalculate GAQ action state + */ + get recalculateGaqActionState() { + return this._recalculateGaqActionState$.getCurrent(); + } + /** * Discard all the QC for the data-pass * @@ -330,9 +369,17 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo } } + /** + * When view is removed callback + * @return {void} + */ + dispose() { + this._abortGaqFetches(); + } + /** * Cancel all ongoing and future GAQ summary fetches - * @return {void} promise + * @return {void} */ _abortGaqFetches() { // Aborts the overall sequence fetch, i.e. stops further individual run fetches @@ -366,7 +413,7 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo mcReproducibleAsNotBad: this._mcReproducibleAsNotBad, runNumber: runNumber, }); - await this._gaqSummarySources[runNumber].fetch(url); + await this._gaqSummarySources[runNumber].fetch(url, { headers: { 'Cache-Control': 'no-cache' } }); } /** @@ -386,10 +433,7 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo const runNumbers = runs.map((run) => run.runNumber); // Prepare GAQ summary object with NotAsked RemoteData state for all runs - let gaqSummary = {}; - for (const runNumber of runNumbers) { - gaqSummary = { ...gaqSummary, [runNumber]: RemoteData.notAsked() }; - } + const gaqSummary = Object.fromEntries(runNumbers.map((rn) => [rn, RemoteData.notAsked()])); this._gaqSummary$.setCurrent(gaqSummary); // Trigger GAQ summary fetch for each run @@ -414,7 +458,8 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo async _fetchSkimmableRuns() { this._skimmableRuns$.setCurrent(RemoteData.loading()); try { - const { data: skimmableRuns } = await getRemoteData(buildUrl('/api/dataPasses/skimming/runs', { dataPassId: this._dataPassId })); + const url = buildUrl('/api/dataPasses/skimming/runs', { dataPassId: this._dataPassId }); + const { data: skimmableRuns } = await getRemoteData(url, { headers: { 'Cache-Control': 'no-cache' } }); const runToReadyForSkimmingFlag = Object.fromEntries(skimmableRuns .map(({ runNumber, readyForSkimming }) => [runNumber, readyForSkimming])); this._skimmableRuns$.setCurrent(RemoteData.success(runToReadyForSkimmingFlag)); diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js index 8f63fb608b..ec160afce3 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js @@ -25,7 +25,6 @@ import { runNumbersFilter } from '../../../components/Filters/RunsFilter/runNumb import { qcSummaryLegendTooltip } from '../../../components/qcFlags/qcSummaryLegendTooltip.js'; import { isRunNotSubjectToQc } from '../../../components/qcFlags/isRunNotSubjectToQc.js'; import { frontLink } from '../../../components/common/navigation/frontLink.js'; -import { getQcSummaryDisplay } from '../ActiveColumns/getQcSummaryDisplay.js'; import errorAlert from '../../../components/common/errorAlert.js'; import { switchInput } from '../../../components/common/form/switchInput.js'; import { PdpBeamType } from '../../../domain/enums/PdpBeamType.js'; @@ -39,6 +38,7 @@ import { BkpRoles } from '../../../domain/enums/BkpRoles.js'; import { getInelasticInteractionRateColumns } from '../ActiveColumns/getInelasticInteractionRateActiveColumns.js'; import { exportTriggerAndModal } from '../../../components/common/dataExport/exportTriggerAndModal.js'; import { mcReproducibleAsNotBadToggle } from '../mcReproducibleAsNotBadToggle.js'; +import { getGAQSummaryDisplay } from '../ActiveColumns/getGAQSummaryDisplay.js'; const TABLEROW_HEIGHT = 59; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -112,6 +112,7 @@ export const RunsPerDataPassOverviewPage = ({ markAsSkimmableRequestResult, skimmableRuns: remoteSkimmableRuns, freezeOrUnfreezeActionState, + recalculateGaqActionState, discardAllQcFlagsActionState, } = perDataPassOverviewModel; @@ -121,7 +122,7 @@ export const RunsPerDataPassOverviewPage = ({ return h( '.intermediate-flex-column', { onremove: () => { - perDataPassOverviewModel._abortGaqFetches(); + perDataPassOverviewModel.dispose(); } }, mergeRemoteData([remoteDataPass, remoteRuns, remoteDetectors, remoteQcSummary]).match({ NotAsked: () => null, @@ -165,30 +166,14 @@ export const RunsPerDataPassOverviewPage = ({ ), visible: true, format: (_, { runNumber }) => { - const gaqLoadingSpinner = h('.flex-row.items-center.justify-center.black', spinner({ size: 2, absolute: false })); - const runGaqSummary = remoteGaqSummary[runNumber]; + const handleRecalculate = () => perDataPassOverviewModel.recalculateGaqSummary([runNumber]); - return runGaqSummary.match({ - Success: (gaqSummary) => { - const gaqDisplay = gaqSummary?.undefinedQualityPeriodsCount === 0 - ? getQcSummaryDisplay(gaqSummary) - : h('button.btn.btn-primary.w-100', 'GAQ'); - - return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, runNumber }); - }, - Loading: () => tooltip(gaqLoadingSpinner, 'Loading GAQ summary...'), - NotAsked: () => tooltip(gaqLoadingSpinner, 'Loading GAQ summary...'), - Failure: () => { - const gaqDisplay = h('button.btn.btn-primary.w-100', [ - 'GAQ', - h( - '.d-inline-block.va-t-bottom', - tooltip(h('.f7', iconWarning()), 'GAQ Summary failed, please click to view GAQ flags'), - ), - ]); - return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, runNumber }); - }, - }); + return getGAQSummaryDisplay( + remoteGaqSummary[runNumber], + dataPassId, + runNumber, + handleRecalculate, + ); }, filter: ({ filteringModel }) => numericalComparisonFilter( filteringModel.get('gaq[notBadFraction]'), @@ -239,7 +224,7 @@ export const RunsPerDataPassOverviewPage = ({ h('.flex-column.p2.g2', [ exportTriggerAndModal(perDataPassOverviewModel.exportModel, modalModel, { autoMarginLeft: false }), frontLink( - h('button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', { + h('button.btn.btn-primary.w-100.h2#set-qc-flags-trigger', { disabled: runDetectorsSelectionIsEmpty, }, 'Set QC Flags'), 'qc-flag-creation-for-data-pass', @@ -249,6 +234,24 @@ export const RunsPerDataPassOverviewPage = ({ }, ), sessionService.hasAccess([BkpRoles.DPG_ASYNC_QC_ADMIN]) && [ + h( + 'button.btn.btn-primary.w-100.h2', + { + ...recalculateGaqActionState.match({ + Loading: () => ({ + disabled: true, + title: 'Loading', + }), + Other: () => ({}), + }), + onclick: () => { + if (confirm('Recalculate the GAQ summary for the entire data pass?')) { + perDataPassOverviewModel.recalculateGaqSummary(); + } + }, + }, + 'Recalculate GAQ summary', + ), h( 'button.btn.btn-danger', { @@ -296,6 +299,10 @@ export const RunsPerDataPassOverviewPage = ({ Failure: (errors) => errorAlert(errors), Other: () => null, }), + recalculateGaqActionState.match({ + Failure: (errors) => errorAlert(errors), + Other: () => null, + }), discardAllQcFlagsActionState.match({ Failure: (errors) => errorAlert(errors), Other: () => null, From 86bdfdb9a0155bc34a8ac5de0c5f0f545f416120 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:07:10 +0100 Subject: [PATCH 16/72] [O2B-1565] Forgot to rename db column names in formatSummary --- lib/server/services/gaq/GaqService.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index bb91be8aee..82e5d9c620 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -79,9 +79,9 @@ class GaqService { _formatSummary(summary, mcReproducibleAsNotBad) { return { [QcSummarProperties.BAD_EFFECTIVE_RUN_COVERAGE]: - summary.badEffectiveRunCoverage + (mcReproducibleAsNotBad ? 0 : summary.mcReproducibleCoverage), + summary.badRunCoverage + (mcReproducibleAsNotBad ? 0 : summary.mcReproducibleCoverage), [QcSummarProperties.EXPLICITELY_NOT_BAD_EFFECTIVE_RUN_COVERAGE]: - summary.explicitlyNotBadEffectiveRunCoverage + (mcReproducibleAsNotBad ? summary.mcReproducibleCoverage : 0), + summary.explicitlyNotBadRunCoverage + (mcReproducibleAsNotBad ? summary.mcReproducibleCoverage : 0), [QcSummarProperties.MC_REPRODUCIBLE]: summary.mcReproducibleCoverage > 0, [QcSummarProperties.MISSING_VERIFICATIONS]: summary.missingVerificationsCount, [QcSummarProperties.UNDEFINED_QUALITY_PERIODS_COUNT]: summary.undefinedQualityPeriodsCount, From 170c1b37f6556e0e39c8f9b54e62bf737523587b Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:41:48 +0200 Subject: [PATCH 17/72] [O2B-1563] Add GAQ summary invalidation trigger tests Validate GAQ summary invalidation behaviour on QC flag create/verify/delete, deleteAllForDataPass, explicit/default GAQ detector changes, and run QC time updates. --- .../server/services/gaq/GaqSummary.test.js | 121 ++++++++++++++++++ test/lib/server/services/gaq/index.js | 2 + test/lib/server/services/index.js | 4 +- 3 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 test/lib/server/services/gaq/GaqSummary.test.js diff --git a/test/lib/server/services/gaq/GaqSummary.test.js b/test/lib/server/services/gaq/GaqSummary.test.js new file mode 100644 index 0000000000..bffd5c5f05 --- /dev/null +++ b/test/lib/server/services/gaq/GaqSummary.test.js @@ -0,0 +1,121 @@ +/** + * @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. + */ + +const { expect } = require('chai'); +const { resetDatabaseContent } = require('../../../../utilities/resetDatabaseContent.js'); +const { repositories: { GaqSummaryInvalidationRepository } } = require('../../../../../lib/database'); +const { qcFlagService } = require('../../../../../lib/server/services/qualityControlFlag/QcFlagService.js'); +const { gaqDetectorService } = require('../../../../../lib/server/services/gaq/GaqDetectorsService.js'); +const { updateRun } = require('../../../../../lib/server/services/run/updateRun.js'); + +module.exports = () => { + // Test resets the database before running and clears the invalidation table between each case + before(async () => { + await resetDatabaseContent(); + }); + + const relations = { user: { roles: ['admin'], externalUserId: 1 } }; + const dataPassId = 4; // LHC22a_apass2 + const runNumber = 56; + + /** + * Check whether an invalidation entry exists for a given data pass and run + * + * @param {number} expectedDataPassId + * @param {number} expectedRunNumber + * @param {boolean} toBeNull + * + * @return {Promise} + */ + const expectInvalidation = async (expectedDataPassId, expectedRunNumber, toBeNull = false) => { + const invalidation = await GaqSummaryInvalidationRepository.findOne({ + where: { dataPassId: expectedDataPassId, runNumber: expectedRunNumber }, + }); + if (toBeNull) { + expect(invalidation, `Expected no invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.be.null; + } else { + expect(invalidation, `Expected invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.not.be.null; + } + }; + + describe("GAQ Summary Invalidation", async () => { + afterEach(async () => { + await GaqSummaryInvalidationRepository.removeAll({ truncate: true }); + }); + + it('should invalidate GAQ summary when a QC flag is created for a data pass', async () => { + await qcFlagService.create( + [{ from: null, to: null, flagTypeId: 3 }], + { runNumber, detectorIdentifier: { detectorId: 7 }, dataPassIdentifier: { id: dataPassId } }, + relations, + ); + + await expectInvalidation(dataPassId, runNumber); + }); + + it('should invalidate GAQ summary when a QC flag is verified for the first time for a data pass', async () => { + const flagId = 8; // Seeded flag in data pass 4, run 100, with no verifications + + await qcFlagService.verifyFlag({ flagId }, relations); + + await expectInvalidation(dataPassId, 100); + + // Clear invalidation + await GaqSummaryInvalidationRepository.removeAll({ where: { dataPassId, runNumber: 100 } }); + + // Verify again to check that no new invalidation is created when the flag is already verified + await qcFlagService.verifyFlag({ flagId }, relations); + await expectInvalidation(dataPassId, 100, true); + }); + + it('should invalidate GAQ summary when a QC flag is deleted for a data pass', async () => { + const flagId = 9; // Seeded flag in data pass 4, run 105, with no verifications (so deletion is allowed) + await qcFlagService.delete(flagId); + + await expectInvalidation(dataPassId, 105); + }); + + it('should invalidate GAQ summary for all runs when all QC flags are deleted for a data pass', async () => { + await qcFlagService.deleteAllForDataPass(dataPassId); + + await expectInvalidation(dataPassId, 100); + await expectInvalidation(dataPassId, 105); + }); + + it('should invalidate GAQ summary when GAQ detectors are explicitly set for a data pass and run', async () => { + const gaqDataPassId = 3; // LHC22a_apass1 (has run 56 and detectors set up in GaqDetectorService tests) + const detectorIds = [4, 7]; + + await gaqDetectorService.setGaqDetectors(gaqDataPassId, [runNumber], detectorIds); + + await expectInvalidation(gaqDataPassId, runNumber); + }); + + it('should invalidate GAQ summary when default GAQ detectors are used for a data pass and run', async () => { + const gaqDataPassId = 3; + + await gaqDetectorService.useDefaultGaqDetectors(gaqDataPassId, [runNumber]); + + await expectInvalidation(gaqDataPassId, runNumber); + }); + + it('should invalidate GAQ summary when the QC time of a run changes', async () => { + await updateRun( + { runNumber }, + { runPatch: { timeTrgStart: new Date('2019-08-08 20:30:00') } }, + ); + + await expectInvalidation(dataPassId, runNumber); + }); + }); +}; diff --git a/test/lib/server/services/gaq/index.js b/test/lib/server/services/gaq/index.js index 985f80fe84..93e10b2ac6 100644 --- a/test/lib/server/services/gaq/index.js +++ b/test/lib/server/services/gaq/index.js @@ -12,7 +12,9 @@ */ const GaqDetectorServiceSuite = require('./GaqDetectorService.test.js'); +const GaqSummarySuite = require('./GaqSummary.test.js'); module.exports = () => { describe('GaqDetectorService', GaqDetectorServiceSuite); + describe('GaqSummary', GaqSummarySuite); }; diff --git a/test/lib/server/services/index.js b/test/lib/server/services/index.js index 01df33fcaf..38aabbf6dd 100644 --- a/test/lib/server/services/index.js +++ b/test/lib/server/services/index.js @@ -32,7 +32,7 @@ const UserSuite = require('./user/index.js'); const SimulationPassesSuite = require('./simulationPasses/index.js'); const QcFlagsSuite = require('./qualityControlFlag/index.js'); const CtpTriggerCountersSuite = require('./ctpTriggerCounters/index.js'); -const GaqDetectorSuite = require('./gaq'); +const GaqSuite = require('./gaq'); module.exports = () => { before(resetDatabaseContent); @@ -46,7 +46,7 @@ module.exports = () => { describe('Environment history item', EnvironmentHistoryItemSuite); describe('EOS report', EosReportSuite); describe('Flp role', FlpRoleSuite); - describe('GaqDetector', GaqDetectorSuite); + describe('GAQ', GaqSuite); describe('LHC fill suite', LhcFillSuite); describe('Logs', LogSuite); describe('RunType', RunTypeSuite); From 78a2181986fd52f57a28435efdf160897919c9c1 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:46:57 +0200 Subject: [PATCH 18/72] [O2B-1563] Use dataPass.id in GAQ invalidation TYPO --- lib/server/services/qualityControlFlag/QcFlagService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server/services/qualityControlFlag/QcFlagService.js b/lib/server/services/qualityControlFlag/QcFlagService.js index 3533716572..3f0ca96d79 100644 --- a/lib/server/services/qualityControlFlag/QcFlagService.js +++ b/lib/server/services/qualityControlFlag/QcFlagService.js @@ -213,7 +213,7 @@ class QcFlagService { if (dataPass) { // Invalidate GAQ summary for the dataPass and runNumber of the created flag await GaqSummaryInvalidationRepository.upsert({ - dataPassId: dataPassIdentifier, + dataPassId: dataPass.id, runNumber, }); } From abb263b8c3afd4d3e064960f5b504f654bd6567f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:11:00 +0200 Subject: [PATCH 19/72] Make GAQ worker singleton and add pause/resume Converted gaqWorker to a singleton. This allows adding of pause() and resume() methods to prevent the worker from processing invalidated summaries during test execution. Reduced the default GAQ recalculation period from 1 minute to 10 seconds to improve test suite performance. --- lib/application.js | 3 +-- lib/config/services.js | 2 +- lib/server/services/gaq/GaqWorker.js | 26 +++++++++++++++++++++++--- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/application.js b/lib/application.js index 0f24120f48..f7d64dce01 100644 --- a/lib/application.js +++ b/lib/application.js @@ -27,7 +27,7 @@ const { AliEcsSynchronizer } = require('./server/kafka/AliEcsSynchronizer.js'); const { environmentService } = require('./server/services/environment/EnvironmentService.js'); const { runService } = require('./server/services/run/RunService.js'); const { CcdbSynchronizer } = require('./server/externalServicesSynchronization/ccdb/CcdbSynchronizer.js'); -const { GaqWorker } = require('./server/services/gaq/GaqWorker.js'); +const { gaqWorker } = require('./server/services/gaq/GaqWorker.js'); const { promises: fs } = require('fs'); const { MonAlisaClient } = require('./server/externalServicesSynchronization/monalisa/MonAlisaClient.js'); const https = require('https'); @@ -134,7 +134,6 @@ class BookkeepingApplication { } if (gaqConfig.enableRecalculation) { - const gaqWorker = new GaqWorker(); this.scheduledProcessesManager.schedule( () => gaqWorker.recalculateGaqSummaries(gaqConfig.batchSize), { diff --git a/lib/config/services.js b/lib/config/services.js index 2c9f941c6a..4c77a1c314 100644 --- a/lib/config/services.js +++ b/lib/config/services.js @@ -71,7 +71,7 @@ exports.services = { gaq: { enableRecalculation: process.env?.GAQ_ENABLE_RECALCULATION?.toLowerCase() === 'true' || true, - recalculationPeriod: Number(process.env?.GAQ_RECALCULATION_PERIOD) || 60 * 1000, // 1m in milliseconds + recalculationPeriod: Number(process.env?.GAQ_RECALCULATION_PERIOD) || 10 * 1000, // 10s in milliseconds batchSize: Number(process.env?.GAQ_RECALCULATION_BATCH_SIZE) || 1, }, }; diff --git a/lib/server/services/gaq/GaqWorker.js b/lib/server/services/gaq/GaqWorker.js index 1fa01920c0..a89671aba6 100644 --- a/lib/server/services/gaq/GaqWorker.js +++ b/lib/server/services/gaq/GaqWorker.js @@ -10,15 +10,33 @@ class GaqWorker { */ constructor() { this._logger = LogManager.getLogger(GaqWorker.name); + this._isPaused = false; + this._isSynchronizing = false; } /** - * Process pending GAQ summary invalidations. Skips if a previous call is still in progress. + * Pause the worker so it skips processing on the next scheduled calls + * @return {void} + */ + pause() { + this._isPaused = true; + } + + /** + * Resume the worker after a pause + * @return {void} + */ + resume() { + this._isPaused = false; + } + + /** + * Process pending GAQ summary invalidations. Skips if a previous call is still in progress or if paused. * @param {number} batchSize number of invalid summaries to recalculate * @return {Promise} promise */ async recalculateGaqSummaries(batchSize) { - if (this._isSynchronizing) { + if (this._isSynchronizing || this._isPaused) { return; } this._isSynchronizing = true; @@ -32,4 +50,6 @@ class GaqWorker { } } -exports.GaqWorker = GaqWorker; +const gaqWorker = new GaqWorker(); + +exports.gaqWorker = gaqWorker; From 10fac20a9189a4a5685ee4dcd4905fec12e1cd8f Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:23:50 +0200 Subject: [PATCH 20/72] [O2B-1564] Add GAQ worker tests; pause worker in DB reset Adds tests that verify the worker removes an invalidation and adds a summary, upserts for an already present summary, batch processes correctly, and doesn't run concurrent recalculations. Update resetDatabaseContent to pause/resume the GAQ worker to avoid worker failures when the invalidation table is dropped. --- .../server/services/gaq/GaqSummary.test.js | 168 +++++++++++++++--- test/utilities/resetDatabaseContent.js | 5 + 2 files changed, 149 insertions(+), 24 deletions(-) diff --git a/test/lib/server/services/gaq/GaqSummary.test.js b/test/lib/server/services/gaq/GaqSummary.test.js index bffd5c5f05..ae95a7a140 100644 --- a/test/lib/server/services/gaq/GaqSummary.test.js +++ b/test/lib/server/services/gaq/GaqSummary.test.js @@ -12,11 +12,41 @@ */ const { expect } = require('chai'); +const sinon = require('sinon'); const { resetDatabaseContent } = require('../../../../utilities/resetDatabaseContent.js'); -const { repositories: { GaqSummaryInvalidationRepository } } = require('../../../../../lib/database'); +const { repositories: { GaqSummaryInvalidationRepository, GaqSummaryRepository } } = require('../../../../../lib/database'); const { qcFlagService } = require('../../../../../lib/server/services/qualityControlFlag/QcFlagService.js'); const { gaqDetectorService } = require('../../../../../lib/server/services/gaq/GaqDetectorsService.js'); const { updateRun } = require('../../../../../lib/server/services/run/updateRun.js'); +const { gaqWorker } = require('../../../../../lib/server/services/gaq/GaqWorker.js'); +const { gaqService } = require('../../../../../lib/server/services/gaq/GaqService.js'); + +/** + * Wait for a given number of milliseconds + * @param {number} ms milliseconds to wait + * @return {Promise} + */ +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Check whether an invalidation entry exists for a given data pass and run + * + * @param {number} expectedDataPassId + * @param {number} expectedRunNumber + * @param {boolean} toBePresent whether the invalidation is expected to be present + * + * @return {Promise} + */ +const expectInvalidation = async (expectedDataPassId, expectedRunNumber, toBePresent = true) => { + const invalidation = await GaqSummaryInvalidationRepository.findOne({ + where: { dataPassId: expectedDataPassId, runNumber: expectedRunNumber }, + }); + if (!toBePresent) { + expect(invalidation, `Expected no invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.be.null; + } else { + expect(invalidation, `Expected invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.not.be.null; + } +}; module.exports = () => { // Test resets the database before running and clears the invalidation table between each case @@ -28,27 +58,10 @@ module.exports = () => { const dataPassId = 4; // LHC22a_apass2 const runNumber = 56; - /** - * Check whether an invalidation entry exists for a given data pass and run - * - * @param {number} expectedDataPassId - * @param {number} expectedRunNumber - * @param {boolean} toBeNull - * - * @return {Promise} - */ - const expectInvalidation = async (expectedDataPassId, expectedRunNumber, toBeNull = false) => { - const invalidation = await GaqSummaryInvalidationRepository.findOne({ - where: { dataPassId: expectedDataPassId, runNumber: expectedRunNumber }, - }); - if (toBeNull) { - expect(invalidation, `Expected no invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.be.null; - } else { - expect(invalidation, `Expected invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.not.be.null; - } - }; - - describe("GAQ Summary Invalidation", async () => { + describe('GAQ Summary Invalidation', () => { + before(() => gaqWorker.pause()); + after(() => gaqWorker.resume()); + afterEach(async () => { await GaqSummaryInvalidationRepository.removeAll({ truncate: true }); }); @@ -75,7 +88,7 @@ module.exports = () => { // Verify again to check that no new invalidation is created when the flag is already verified await qcFlagService.verifyFlag({ flagId }, relations); - await expectInvalidation(dataPassId, 100, true); + await expectInvalidation(dataPassId, 100, false); }); it('should invalidate GAQ summary when a QC flag is deleted for a data pass', async () => { @@ -89,7 +102,6 @@ module.exports = () => { await qcFlagService.deleteAllForDataPass(dataPassId); await expectInvalidation(dataPassId, 100); - await expectInvalidation(dataPassId, 105); }); it('should invalidate GAQ summary when GAQ detectors are explicitly set for a data pass and run', async () => { @@ -118,4 +130,112 @@ module.exports = () => { await expectInvalidation(dataPassId, runNumber); }); }); + + describe('GAQ Worker', () => { + beforeEach(async () => { + await resetDatabaseContent(); + }); + + after(() => gaqWorker.pause()); + + it('should process invalidations and update the summary', async () => { + const workerDataPassId = 1; + const workerRunNumber = 107; + + await qcFlagService.create( + [{ from: null, to: null, flagTypeId: 3 }], + { runNumber: workerRunNumber, detectorIdentifier: { detectorId: 1 }, dataPassIdentifier: { id: workerDataPassId } }, + relations, + ); + + // confirm that the invalidation is made + await expectInvalidation(workerDataPassId, workerRunNumber); + + // wait at least 11s, default recalculation period is 10s, for the worker to process the invalidation + await sleep(11000); + + await expectInvalidation(workerDataPassId, workerRunNumber, false); + const summary = await GaqSummaryRepository.findOne({ where: { dataPassId: workerDataPassId, runNumber: workerRunNumber } }); + expect(summary).to.not.be.null; + }); + + it('should only upsert an existing summary row rather than creating a duplicate', async () => { + const workerDataPassId = 1; + const workerRunNumber = 107; + + await gaqService.calculateAndStoreGaqSummary(workerDataPassId, workerRunNumber); + const firstSummary = await GaqSummaryRepository.findOne({ where: { dataPassId: workerDataPassId, runNumber: workerRunNumber } }); + expect(firstSummary).to.not.be.null; + + // Trigger an invalidation + await qcFlagService.create( + [{ from: null, to: null, flagTypeId: 3 }], + { runNumber: workerRunNumber, detectorIdentifier: { detectorId: 1 }, dataPassIdentifier: { id: workerDataPassId } }, + relations, + ); + await expectInvalidation(workerDataPassId, workerRunNumber); + + // wait 11s for the worker to process + await sleep(11000); + + await expectInvalidation(workerDataPassId, workerRunNumber, false); + + // confirm only one summary row exists (upsert, not duplicate) + const summaries = await GaqSummaryRepository.findAll({ where: { dataPassId: workerDataPassId, runNumber: workerRunNumber } }); + expect(summaries).to.have.lengthOf(1); + }); + + it('should process multiple invalidations in a single batch', async () => { + // Create invalidations for two different runs in data pass 1 + await qcFlagService.create( + [{ from: null, to: null, flagTypeId: 3 }], + { runNumber: 106, detectorIdentifier: { detectorId: 1 }, dataPassIdentifier: { id: 1 } }, + relations, + ); + await qcFlagService.create( + [{ from: null, to: null, flagTypeId: 3 }], + { runNumber: 107, detectorIdentifier: { detectorId: 1 }, dataPassIdentifier: { id: 1 } }, + relations, + ); + + await expectInvalidation(1, 106); + await expectInvalidation(1, 107); + + // Manually call the worker with batchSize=2 to process both in one go + await gaqWorker.recalculateGaqSummaries(2); + + await expectInvalidation(1, 106, false); + await expectInvalidation(1, 107, false); + + const summary106 = await GaqSummaryRepository.findOne({ where: { dataPassId: 1, runNumber: 106 } }); + const summary107 = await GaqSummaryRepository.findOne({ where: { dataPassId: 1, runNumber: 107 } }); + expect(summary106).to.not.be.null; + expect(summary107).to.not.be.null; + }); + + it('should skip processing if a previous call is still in progress', async () => { + // Stub gaqService to be slow so the first call blocks + let resolveFirst; + const slowPromise = new Promise((resolve) => { resolveFirst = resolve; }); + const stub = sinon.stub(gaqService, 'popNInvalidSummaryAndRecalculate').returns(slowPromise); + + try { + // First call — will be held open by the slow stub + const firstCall = gaqWorker.recalculateGaqSummaries(1); + + // Second call — should be skipped because _isSynchronizing is true + await gaqWorker.recalculateGaqSummaries(1); + + // Stub should only have been called once + expect(stub.callCount).to.equal(1); + + // Release the first call + resolveFirst(); + await firstCall; + } finally { + sinon.restore(); + } + }); + + }); }; diff --git a/test/utilities/resetDatabaseContent.js b/test/utilities/resetDatabaseContent.js index 9e04ebddab..704a986219 100644 --- a/test/utilities/resetDatabaseContent.js +++ b/test/utilities/resetDatabaseContent.js @@ -12,9 +12,14 @@ */ const { database } = require('../../lib/application.js'); +const { gaqWorker } = require('../../lib/server/services/gaq/GaqWorker.js'); exports.resetDatabaseContent = async () => { + // Pause GAQ worker when resetDatabaseContent() runs in between tests in the different test suites + // Otherwise, worker fails as the invalidation table is DROPPED. This avoids an ERROR message appearing in test logs even if the suite passed + gaqWorker.pause(); await database.dropAllTables(); await database.migrate(); await database.seed(); + gaqWorker.resume(); }; From 6aa96175346fb2e440e3f63be6a19dd347bc83ec Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:53:43 +0200 Subject: [PATCH 21/72] [O2B-1564] Add GaqService tests and register suite GaqService summary recalculation and invalidation processing functions tested directly circumventing worker. Tests verify correct computed fields, summary upsert, behaviour when no coverage exists, and batched invalidation processing. --- .../server/services/gaq/GaqService.test.js | 118 ++++++++++++++++++ test/lib/server/services/gaq/index.js | 2 + 2 files changed, 120 insertions(+) create mode 100644 test/lib/server/services/gaq/GaqService.test.js diff --git a/test/lib/server/services/gaq/GaqService.test.js b/test/lib/server/services/gaq/GaqService.test.js new file mode 100644 index 0000000000..3857f41397 --- /dev/null +++ b/test/lib/server/services/gaq/GaqService.test.js @@ -0,0 +1,118 @@ +/** + * @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. + */ + +const { expect } = require('chai'); +const { resetDatabaseContent } = require('../../../../utilities/resetDatabaseContent.js'); +const { repositories: { GaqSummaryRepository, GaqSummaryInvalidationRepository } } = require('../../../../../lib/database'); +const { gaqService } = require('../../../../../lib/server/services/gaq/GaqService.js'); + +/** + * Find the GAQ summary row for a given data pass and run + * @param {number} dataPassId data pass id + * @param {number} runNumber run number + * @return {Promise} + */ +const findSummary = (dataPassId, runNumber) => GaqSummaryRepository.findOne({ where: { dataPassId, runNumber } }); + +/** + * Insert an invalidation entry + * @param {number} dataPassId data pass id + * @param {number} runNumber run number + * @param {string} createdAt ISO timestamp string + * @return {Promise} + */ +const insertInvalidation = (dataPassId, runNumber, createdAt) => + GaqSummaryInvalidationRepository.insert({ dataPassId, runNumber, createdAt: new Date(createdAt) }); + +// Tests for GaqService are split between QcFlagService.test.js and GaqSummary.test.js +// GaqService.test.js (this file) focuses on the summary recalculation and invalidation processing logic + +module.exports = () => { + before(resetDatabaseContent); + + // Data pass 1 (LHC22b_apass1), run 107 has GAQ detectors CPV (1) and ACO (2) seeded + // and has seeded QC flags, so a summary can always be computed + const dataPassId = 1; + const runNumber = 107; + + describe('calculateAndStoreGaqSummary', () => { + afterEach(async () => { + await GaqSummaryRepository.removeAll({ where: { dataPassId, runNumber } }); + }); + + it('should compute and store a summary row with correct values', async () => { + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + + const summary = await findSummary(dataPassId, runNumber); + expect(summary).to.not.be.null; + expect(summary.dataPassId).to.equal(dataPassId); + expect(summary.runNumber).to.equal(runNumber); + expect(summary.badRunCoverage).to.equal(0); + expect(summary.explicitlyNotBadRunCoverage).to.equal(0.759654); + expect(summary.mcReproducibleCoverage).to.equal(0.240346); + expect(summary.missingVerificationsCount).to.equal(3); + expect(summary.undefinedQualityPeriodsCount).to.equal(0); + }); + + it('should upsert when a summary already exists', async () => { + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + + const rows = await GaqSummaryRepository.findAll({ where: { dataPassId, runNumber } }); + expect(rows).to.have.lengthOf(1); + }); + + it('should not store a summary when there is no coverage data for the run', async () => { + // Run 49 has no QC flags seeded for data pass 1 + await gaqService.calculateAndStoreGaqSummary(dataPassId, 49); + + const summary = await findSummary(dataPassId, 49); + expect(summary).to.be.null; + }); + }); + + describe('popNInvalidSummaryAndRecalculate', () => { + beforeEach(async () => { + await GaqSummaryInvalidationRepository.removeAll({ truncate: true }); + }); + + it('should do nothing when the invalidation table is empty', async () => { + await gaqService.popNInvalidSummaryAndRecalculate(5); + // No error thrown and nothing written + const summary = await findSummary(dataPassId, runNumber); + expect(summary).to.be.null; + }); + + it('should process exactly N invalidations ordered by createdAt', async () => { + // Insert invalidations for runs 106 and 107 in data pass 1 with different timestamps + await insertInvalidation(dataPassId, 106, '2024-01-01 10:00:00'); + await insertInvalidation(dataPassId, 107, '2024-01-01 11:00:00'); + + // Process only 1 — should pick run 106 (oldest) + await gaqService.popNInvalidSummaryAndRecalculate(1); + + const remaining = await GaqSummaryInvalidationRepository.findOne({ where: { dataPassId, runNumber: 107 } }); + expect(remaining).to.not.be.null; + }); + + it('should process all invalidations when batchSize covers them all', async () => { + await insertInvalidation(dataPassId, 106, '2024-01-01 10:00:00'); + await insertInvalidation(dataPassId, 107, '2024-01-01 11:00:00'); + + await gaqService.popNInvalidSummaryAndRecalculate(10); + + const count = await GaqSummaryInvalidationRepository.count({ where: { dataPassId } }); + expect(count).to.equal(0); + }); + }); +}; diff --git a/test/lib/server/services/gaq/index.js b/test/lib/server/services/gaq/index.js index 93e10b2ac6..32200b17d0 100644 --- a/test/lib/server/services/gaq/index.js +++ b/test/lib/server/services/gaq/index.js @@ -12,9 +12,11 @@ */ const GaqDetectorServiceSuite = require('./GaqDetectorService.test.js'); +const GaqServiceSuite = require('./GaqService.test.js'); const GaqSummarySuite = require('./GaqSummary.test.js'); module.exports = () => { describe('GaqDetectorService', GaqDetectorServiceSuite); + describe('GaqService', GaqServiceSuite); describe('GaqSummary', GaqSummarySuite); }; From c2ccbd476f83ce5ffb60097db8d1f373c61812b5 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:52:40 +0200 Subject: [PATCH 22/72] Add calculationFailed to GAQ summaries Add a calculation_failed boolean column to gaq_summaries. This allows us to know whether a summary has been attempted to be calculated but unsuccessful due to limited data etc. --- lib/database/adapters/GaqSummaryAdapter.js | 2 ++ .../v1/20260223120000-create-gaq-summary-tables.js | 5 +++++ lib/database/models/gaqSummary.js | 3 +++ 3 files changed, 10 insertions(+) diff --git a/lib/database/adapters/GaqSummaryAdapter.js b/lib/database/adapters/GaqSummaryAdapter.js index 77ff939d69..21cd7b7004 100644 --- a/lib/database/adapters/GaqSummaryAdapter.js +++ b/lib/database/adapters/GaqSummaryAdapter.js @@ -37,6 +37,7 @@ class GaqSummaryAdapter { mcReproducibleCoverage, missingVerificationsCount, undefinedQualityPeriodsCount, + calculationFailed, createdAt, updatedAt, } = databaseObject; @@ -49,6 +50,7 @@ class GaqSummaryAdapter { mcReproducibleCoverage, missingVerificationsCount, undefinedQualityPeriodsCount, + calculationFailed, createdAt, updatedAt, }; diff --git a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js index b734f32e32..3a787b02e7 100644 --- a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js +++ b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js @@ -42,6 +42,11 @@ module.exports = { type: Sequelize.INTEGER, allowNull: false, }, + calculation_failed: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, created_at: { type: Sequelize.DATE(3), allowNull: false, diff --git a/lib/database/models/gaqSummary.js b/lib/database/models/gaqSummary.js index 480a9a4943..aab9a04dfd 100644 --- a/lib/database/models/gaqSummary.js +++ b/lib/database/models/gaqSummary.js @@ -36,6 +36,9 @@ module.exports = (sequelize) => { undefinedQualityPeriodsCount: { type: Sequelize.INTEGER, }, + calculationFailed: { + type: Sequelize.BOOLEAN, + }, }, { tableName: 'gaq_summaries' }); GaqSummary.removeAttribute('id'); From 35112e5e34b7c8f75240e086dd1cd100abc395da Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:15:47 +0200 Subject: [PATCH 23/72] [O2B-1545] Make GAQ summary columns nullable Remove not-null constraints from several GAQ summary columns in the migration to allow NULL when values are unavailable. --- .../v1/20260223120000-create-gaq-summary-tables.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js index 3a787b02e7..4540a01c4a 100644 --- a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js +++ b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js @@ -24,23 +24,18 @@ module.exports = { }, bad_run_coverage: { type: Sequelize.FLOAT, - allowNull: false, }, explicitly_not_bad_run_coverage: { type: Sequelize.FLOAT, - allowNull: false, }, mc_reproducible_coverage: { type: Sequelize.FLOAT, - allowNull: false, }, missing_verifications_count: { type: Sequelize.INTEGER, - allowNull: false, }, undefined_quality_periods_count: { type: Sequelize.INTEGER, - allowNull: false, }, calculation_failed: { type: Sequelize.BOOLEAN, From 83ddf1d3ab908de5254ea51f3b3a715c610f12c2 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:35:00 +0200 Subject: [PATCH 24/72] [O2B-1564] Persist GAQ calculation failure Stop returning early when summary is missing; always upsert a GaqSummary record and include a calculationFailed boolean. This ensures failed calculations are persisted for monitoring instead of being silently skipped. --- lib/server/services/gaq/GaqService.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index 79997ad06c..d7b9de7676 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -162,10 +162,7 @@ class GaqService { */ async calculateAndStoreGaqSummary(dataPassId, runNumber) { const summary = await this._computeSummary(dataPassId, runNumber); - if (!summary) { - return; - } - await GaqSummaryRepository.upsert({ dataPassId, runNumber, ...summary }); + await GaqSummaryRepository.upsert({ dataPassId, runNumber, ...summary, calculationFailed: summary === null }); } /** From 00c1e2bb2e981b9b1adcf8cc4d27d7c99ecce01e Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:46:46 +0200 Subject: [PATCH 25/72] [O2B-1564] Assert calculationFailed in created summary Update test to expect that when there is no coverage data for a run, a GAQ summary is stored with calculationFailed set to true. --- test/lib/server/services/gaq/GaqService.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/lib/server/services/gaq/GaqService.test.js b/test/lib/server/services/gaq/GaqService.test.js index 3857f41397..e2f66d3e3d 100644 --- a/test/lib/server/services/gaq/GaqService.test.js +++ b/test/lib/server/services/gaq/GaqService.test.js @@ -72,12 +72,13 @@ module.exports = () => { expect(rows).to.have.lengthOf(1); }); - it('should not store a summary when there is no coverage data for the run', async () => { + it('should store a summary with calculation failed when there is no coverage data for the run', async () => { // Run 49 has no QC flags seeded for data pass 1 await gaqService.calculateAndStoreGaqSummary(dataPassId, 49); const summary = await findSummary(dataPassId, 49); - expect(summary).to.be.null; + expect(summary).to.not.be.null; + expect(summary.calculationFailed).to.be.true; }); }); From 055967f47b78e776f3401184068d77eb3aaa4091 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Sat, 2 May 2026 14:24:15 +0200 Subject: [PATCH 26/72] [O2B-1567] Handle GAQ invalidations; add calc failure flag Add support for GAQ summary invalidations and a calculation failure flag. Changes include: - Add CALCULATION_FAILED to QcSummaryProperties enum. - Update GAQ/QC UI: improve tooltip messaging for invalidated summaries, add element IDs for clock/warning icons, show a warning state when no GAQ summary exists yet, and pass style to GAQ flag frontLink. Also add id to the "Recalculate GAQ summary" trigger. - Server-side: remove unused Sequelize import, fetch invalidation records separately and merge them with summaries so runs that are invalidated but lack a summary row are reported as invalidated. Return combined results keyed by run number. Also map the calculationFailed field into formatted summaries. --- lib/domain/enums/QcSummaryProperties.js | 1 + .../ActiveColumns/getGAQSummaryDisplay.js | 24 +++++++-- .../Runs/ActiveColumns/getQcSummaryDisplay.js | 2 +- .../RunsPerDataPassOverviewPage.js | 1 + lib/server/services/gaq/GaqService.js | 50 +++++++++++-------- 5 files changed, 54 insertions(+), 24 deletions(-) diff --git a/lib/domain/enums/QcSummaryProperties.js b/lib/domain/enums/QcSummaryProperties.js index 741b102a8e..c437b72079 100644 --- a/lib/domain/enums/QcSummaryProperties.js +++ b/lib/domain/enums/QcSummaryProperties.js @@ -18,4 +18,5 @@ exports.QcSummarProperties = { MC_REPRODUCIBLE: 'mcReproducible', MINIFIED_FLAGS: 'minifiedFlags', UNDEFINED_QUALITY_PERIODS_COUNT: 'undefinedQualityPeriodsCount', + CALCULATION_FAILED: 'calculationFailed', }; diff --git a/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js b/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js index df279080c2..f8edad4a42 100644 --- a/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js +++ b/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js @@ -31,7 +31,11 @@ import { frontLink } from '../../../components/common/navigation/frontLink.js'; */ const invalidatedDisplay = () => h( '.d-inline-block.va-t-bottom', - tooltip(h('.f7', iconClock()), 'Summary is invalid. New summary will be calculated shortly.'), + tooltip(h( + '.f7', + { id: 'clock-icon' }, + iconClock(), + ), 'Summary is invalid. New summary will be calculated shortly. Please wait and refresh the page.'), ); /** @@ -41,7 +45,7 @@ const invalidatedDisplay = () => h( */ const failureWarningDisplay = () => h( '.d-inline-block.va-t-bottom', - tooltip(h('.f7', iconWarning()), 'GAQ Summary failed, please click to view GAQ flags'), + tooltip(h('.f7', { id: 'warning-icon' }, iconWarning()), 'GAQ Summary failed, please click to view GAQ flags'), ); /** @@ -72,6 +76,20 @@ const recalculateButton = (onRecalculate) => h( const getSuccessGaqDisplay = (gaqSummary, isFirstInGroup) => { const { undefinedQualityPeriodsCount, invalidated } = gaqSummary; + // No summary exists at all (no QC flags configured for GAQ detectors) + if (Object.keys(gaqSummary).length === 0) { + return h(`button.btn.btn-primary.w-100${isFirstInGroup ? '.first-item' : ''}`, [ + 'GAQ', + h( + '.d-inline-block.va-t-bottom', + tooltip( + h('.f7', { id: 'warning-icon' }, iconWarning()), + 'GAQ summary has not yet been calculated', + ), + ), + ]); + } + if (undefinedQualityPeriodsCount === 0) { return getQcSummaryDisplay(gaqSummary, isFirstInGroup ? { classes: 'first-item' } : {}); } @@ -104,7 +122,7 @@ const loadingDisplay = () => tooltip( export const getGAQSummaryDisplay = (remoteSummary, dataPassId, runNumber, onRecalculate) => { const hasRecalculateAccess = sessionService.hasAccess([BkpRoles.DPG_ASYNC_QC_ADMIN]); - const gaqFlagLink = (content) => frontLink(content, 'gaq-flags', { dataPassId, runNumber }); + const gaqFlagLink = (content) => frontLink(content, 'gaq-flags', { dataPassId, runNumber }, { style: 'flex: 1' }); return remoteSummary.match({ Success: (gaqSummary) => h( diff --git a/lib/public/views/Runs/ActiveColumns/getQcSummaryDisplay.js b/lib/public/views/Runs/ActiveColumns/getQcSummaryDisplay.js index 26d575aaa1..3678964965 100644 --- a/lib/public/views/Runs/ActiveColumns/getQcSummaryDisplay.js +++ b/lib/public/views/Runs/ActiveColumns/getQcSummaryDisplay.js @@ -56,7 +56,7 @@ export const getQcSummaryDisplay = (summary, { classes } = {}) => { const invalidatedDisplay = invalidated ? h( '.d-inline-block.va-t-bottom', - tooltip(h('.f7', iconClock()), 'Summary is invalid. New summary will be calculated shortly.'), + tooltip(h('.f7', iconClock()), 'Summary is invalid. New summary will be calculated shortly. Please wait and refresh the page.'), ) : null; diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js index ec160afce3..cfc3e971dd 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js @@ -249,6 +249,7 @@ export const RunsPerDataPassOverviewPage = ({ perDataPassOverviewModel.recalculateGaqSummary(); } }, + id: 'recalculate-gaq-summary-trigger', }, 'Recalculate GAQ summary', ), diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index f42439a96b..74fb87ab99 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -34,7 +34,7 @@ const { QcFlagRepository, DataPassRunRepository, } = require('../../../database/repositories/index.js'); const { qcFlagAdapter } = require('../../../database/adapters/index.js'); -const { Op, Sequelize } = require('sequelize'); +const { Op } = require('sequelize'); const { QcSummarProperties } = require('../../../domain/enums/QcSummaryProperties.js'); const { dataSource } = require('../../../database/DataSource.js'); const { LogManager } = require('@aliceo2/web-ui'); @@ -72,29 +72,38 @@ class GaqService { const summaries = await GaqSummaryRepository.findAll({ where, - attributes: { - include: [ - [ - Sequelize.literal( - 'EXISTS (SELECT 1 FROM `gaq_summary_invalidations` AS `gsi`' - + ' WHERE `gsi`.`data_pass_id` = `GaqSummary`.`data_pass_id`' - + ' AND `gsi`.`run_number` = `GaqSummary`.`run_number`)', - ), - 'invalidated', - ], - ], - }, }); - const summaryByRun = Object.fromEntries(summaries.map((s) => { - const formatted = this._formatSummary(s, mcReproducibleAsNotBad); - if (s.get('invalidated')) { - formatted.invalidated = true; + const invalidatedSummaries = await GaqSummaryInvalidationRepository.findAll({ + where, + }); + + const combinedSummaries = []; + for (const summary of summaries) { + const invalidation = invalidatedSummaries.find((s) => s.runNumber === summary.runNumber); + if (invalidation) { + const formattedSummary = { ...this._formatSummary(summary.get(), mcReproducibleAsNotBad), invalidated: true }; + combinedSummaries.push([summary.runNumber, formattedSummary]); + } else { + const formattedSummary = { ...this._formatSummary(summary.get(), mcReproducibleAsNotBad), invalidated: false }; + combinedSummaries.push([summary.runNumber, formattedSummary]); } - return [s.runNumber, formatted]; - })); + } + + /* + * Add summaries which are invalidated but not yet recalculated + * (e.g. no summary record in the database, but an invalidation record exists) + */ + for (const invalidation of invalidatedSummaries) { + if (!combinedSummaries.find(([runNumber]) => runNumber === invalidation.runNumber)) { + const invalidSummary = { + invalidated: true, + }; + combinedSummaries.push([invalidation.runNumber, invalidSummary]); + } + } - return runNumber ? summaryByRun[runNumber] ?? {} : summaryByRun; + return runNumber ? combinedSummaries.find(([rn]) => rn === runNumber)?.[1] ?? {} : Object.fromEntries(combinedSummaries); } /** @@ -112,6 +121,7 @@ class GaqService { [QcSummarProperties.MC_REPRODUCIBLE]: summary.mcReproducibleCoverage > 0, [QcSummarProperties.MISSING_VERIFICATIONS]: summary.missingVerificationsCount, [QcSummarProperties.UNDEFINED_QUALITY_PERIODS_COUNT]: summary.undefinedQualityPeriodsCount, + [QcSummarProperties.CALCULATION_FAILED]: summary.calculationFailed, }; } From 4fb1b55b2f21b640010905ef4c7bb6b6bc1a59a3 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Sat, 2 May 2026 14:39:53 +0200 Subject: [PATCH 27/72] [O2B-1567] Add GAQ summary API & UI tests Add comprehensive tests for GAQ summaries: a new API test suite (test/api/gaqSummary.test.js) covering recalculate endpoint and summary retrieval. Update the runs-per-data-pass overview tests to seed GAQ summaries, adjust action button indexes, and add UI tests for GAQ summary buttons, admin-only recalculate actions, invalidation creation, and the full invalidation->recalculation lifecycle. Also add minor test helpers and repository usage to support these checks. --- test/api/gaqSummary.test.js | 248 ++++++++++++++++++ test/api/index.js | 2 + .../runs/runsPerDataPass.overview.test.js | 180 ++++++++++++- 3 files changed, 424 insertions(+), 6 deletions(-) create mode 100644 test/api/gaqSummary.test.js diff --git a/test/api/gaqSummary.test.js b/test/api/gaqSummary.test.js new file mode 100644 index 0000000000..0d92dba448 --- /dev/null +++ b/test/api/gaqSummary.test.js @@ -0,0 +1,248 @@ +/** + * @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. + */ + +const { expect } = require('chai'); +const request = require('supertest'); +const { server } = require('../../lib/application'); +const { resetDatabaseContent } = require('../utilities/resetDatabaseContent.js'); +const { BkpRoles } = require('../../lib/domain/enums/BkpRoles'); +const { repositories: { GaqSummaryInvalidationRepository, GaqSummaryRepository } } = require('../../lib/database'); + +/** + * Check whether an invalidation entry exists for a given data pass and run + * + * @param {number} expectedDataPassId + * @param {number} expectedRunNumber + * @param {boolean} toBePresent whether the invalidation is expected to be present + * + * @return {Promise} + */ +const expectInvalidation = async (expectedDataPassId, expectedRunNumber, toBePresent = true) => { + const invalidation = await GaqSummaryInvalidationRepository.findOne({ + where: { dataPassId: expectedDataPassId, runNumber: expectedRunNumber }, + }); + if (!toBePresent) { + expect(invalidation, `Expected no invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.be.null; + } else { + expect(invalidation, `Expected invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.not.be.null; + } +}; + +module.exports = () => { + before(resetDatabaseContent); + + describe('POST /api/qcFlags/summary/gaq/recalculate', () => { + it('should fail to recalculate the summaries because of insufficient permission', async () => { + const response = await request(server).post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=1&token=${BkpRoles.GUEST}`); + + expect(response.status).to.equal(403); + const { errors } = response.body; + expect(errors.find(({ title }) => title === 'Access denied')).to.not.be.null; + }); + + it('should return 400 when dataPassId parameter is missing', async () => { + const response = await request(server) + .post(`/api/qcFlags/summary/gaq/recalculate?runNumbers=107&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(400); + const { errors } = response.body; + const dataPassIdError = errors.find((error) => error.source.pointer === '/data/attributes/query/dataPassId'); + expect(dataPassIdError.detail).to.equal('"query.dataPassId" is required'); + }); + + it('should return 400 if dataPassId is not positive', async () => { + const response = await request(server) + .post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=-1&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('"query.dataPassId" must be a positive number'); + }); + + it('should return 400 if dataPassId is not a number', async () => { + const response = await request(server) + .post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=abc&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('"query.dataPassId" must be a number'); + }); + + it('should return 200 when runNumbers is a comma-separated list', async () => { + const response = await request(server) + .post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=1&runNumbers=106,107&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(200); + expect(response.body.data).to.deep.equal({ summariesToRecalculate: 2 }); + }); + + it('should return 400 if runNumbers contains a negative value', async () => { + const response = await request(server) + .post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=1&runNumbers=-1&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('Invalid range: -1'); + }); + + it('should return 400 if runNumbers contains an invalid range (start > end)', async () => { + const response = await request(server) + .post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=1&runNumbers=200-100&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('Invalid range: 200-100'); + }); + + it('should return 400 if runNumbers contains a range exceeding the max size of 100', async () => { + const response = await request(server) + .post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=1&runNumbers=1-200&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('Given range exceeds max size of 100 range: 1-200'); + }); + + it('should return 200 with the number of summaries that will be recalculated', async () => { + const response = await request(server).post(`/api/qcFlags/summary/gaq/recalculate?dataPassId=1&runNumbers=107&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); + + expect(response.status).to.equal(200); + const { data } = response.body; + expect(data).to.deep.equal({ summariesToRecalculate: 1 }); + + await expectInvalidation(1, 107, true); + }); + }); + + describe('GET /api/qcFlags/summary/gaq', () => { + + before(async () => { + await resetDatabaseContent(); + + await GaqSummaryRepository.upsert({ + dataPassId: 1, + runNumber: 107, + badRunCoverage: 0, + explicitlyNotBadRunCoverage: 0.759654, + mcReproducibleCoverage: 0.240346, + missingVerificationsCount: 3, + undefinedQualityPeriodsCount: 0, + calculationFailed: false, + }); + }); + + it('should return 200 with the correct GAQ summary with mcReproducibleAsNotBad=false', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&mcReproducibleAsNotBad=false&runNumber=107'); + + expect(response.status).to.equal(200); + const { data } = response.body; + expect(data).to.deep.equal({ + badEffectiveRunCoverage: 0.240346, + explicitlyNotBadEffectiveRunCoverage: 0.759654, + mcReproducible: true, + missingVerificationsCount: 3, + undefinedQualityPeriodsCount: 0, + calculationFailed: false, + invalidated: false, + }); + }); + + it('should return 200 with the correct GAQ summary with mcReproducibleAsNotBad=true', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&mcReproducibleAsNotBad=true&runNumber=107'); + + expect(response.status).to.equal(200); + const { data } = response.body; + expect(data).to.deep.equal({ + badEffectiveRunCoverage: 0, + explicitlyNotBadEffectiveRunCoverage: 1, + mcReproducible: true, + missingVerificationsCount: 3, + undefinedQualityPeriodsCount: 0, + calculationFailed: false, + invalidated: false, + }); + }); + + it('should return 200 with a calculated summary that is invalidated', async () => { + await GaqSummaryInvalidationRepository.insert({ dataPassId: 1, runNumber: 107 }); + + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&mcReproducibleAsNotBad=false&runNumber=107'); + + expect(response.status).to.equal(200); + const { data } = response.body; + expect(data).to.deep.equal({ + badEffectiveRunCoverage: 0.240346, + explicitlyNotBadEffectiveRunCoverage: 0.759654, + mcReproducible: true, + missingVerificationsCount: 3, + undefinedQualityPeriodsCount: 0, + calculationFailed: false, + invalidated: true, + }); + }); + + it('should return 200 with a not-calculated summary that is invalidated', async () => { + await GaqSummaryInvalidationRepository.insert({ dataPassId: 1, runNumber: 106 }); + + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&mcReproducibleAsNotBad=false&runNumber=106'); + + expect(response.status).to.equal(200); + const { data } = response.body; + expect(data).to.deep.equal({ + invalidated: true, + }); + }); + + it('should return 400 if dataPassId is not positive', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=-1&runNumber=107'); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('"query.dataPassId" must be a positive number'); + }); + + it('should return 400 if runNumber is not positive', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&runNumber=-1'); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('"query.runNumber" must be a positive number'); + }); + + it('should return 400 if dataPassId is not a number', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=abc&runNumber=107'); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('"query.dataPassId" must be a number'); + }); + + it('should return 400 if runNumber is not a number', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&runNumber=abc'); + + expect(response.status).to.equal(400); + expect(response.body.errors[0].detail).to.equal('"query.runNumber" must be a number'); + }); + + it('should return 400 when runNumber parameter is missing', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1'); + + expect(response.status).to.equal(400); + const { errors } = response.body; + const runNumberError = errors.find((error) => error.source.pointer === '/data/attributes/query/runNumber'); + expect(runNumberError.detail).to.equal('"query.runNumber" is required'); + }); + + it('should return 400 when dataPassId parameter is missing', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?runNumber=107'); + + expect(response.status).to.equal(400); + const { errors } = response.body; + const dataPassIdError = errors.find((error) => error.source.pointer === '/data/attributes/query/dataPassId'); + expect(dataPassIdError.detail).to.equal('"query.dataPassId" is required'); + }); + }); + +}; diff --git a/test/api/index.js b/test/api/index.js index e368ca5e61..98661ee237 100644 --- a/test/api/index.js +++ b/test/api/index.js @@ -35,6 +35,7 @@ const DplDetectorsSuite = require('./dplDetectors.test.js'); const QcFlagsSuite = require('./qcFlags.test.js'); const CtpTriggerCountersSuite = require('./ctpTriggerCounters.test'); const GaqDetectorsSuite = require('./gaqDetectors.test.js'); +const GaqSummarySuite = require('./gaqSummary.test.js'); module.exports = () => { describe('Attachments API', AttachmentsSuite); @@ -61,4 +62,5 @@ module.exports = () => { describe('QcFlagTypes API', QcFlagTypesSuite); describe('QcFlags API', QcFlagsSuite); describe('CtpTriggerCounters API', CtpTriggerCountersSuite); + describe('GaqSummary API', GaqSummarySuite); }; diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js index f45d004e55..1b4f157d42 100644 --- a/test/public/runs/runsPerDataPass.overview.test.js +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -38,12 +38,24 @@ const { checkPopoverInnerText, } = require('../defaults.js'); const { resetDatabaseContent } = require('../../utilities/resetDatabaseContent.js'); +const { repositories: { GaqSummaryRepository, GaqSummaryInvalidationRepository } } = require('../../../lib/database'); const DataPassRepository = require('../../../lib/database/repositories/DataPassRepository.js'); const { BkpRoles } = require('../../../lib/domain/enums/BkpRoles.js'); const { navigateToRunsPerDataPass } = require('./navigationUtils.js'); +const { invalid } = require('joi'); +const { gaqWorker } = require('../../../lib/server/services/gaq/GaqWorker.js'); + const { expect } = chai; +/** + * Wait for a given number of milliseconds + * @param {number} ms milliseconds to wait + * @return {Promise} + */ +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + const DETECTORS = [ 'CPV', 'EMC', @@ -74,6 +86,27 @@ module.exports = () => { deviceScaleFactor: 1, }); await resetDatabaseContent(); + + await GaqSummaryRepository.upsert({ + dataPassId: 1, + runNumber: 106, + badRunCoverage: 0, + explicitlyNotBadRunCoverage: 0, + mcReproducibleCoverage: 0, + missingVerificationsCount: 3, + undefinedQualityPeriodsCount: 11, + calculationFailed: false, + }); + await GaqSummaryRepository.upsert({ + dataPassId: 1, + runNumber: 107, + badRunCoverage: 0, + explicitlyNotBadRunCoverage: 0.759654, + mcReproducibleCoverage: 0.240346, + missingVerificationsCount: 3, + undefinedQualityPeriodsCount: 0, + calculationFailed: false, + }); }); after(async () => { @@ -602,8 +635,8 @@ module.exports = () => { await pressElement(page, '#actions-dropdown-button .popover-trigger', true); const popoverSelector = await getPopoverSelector(await page.waitForSelector('#actions-dropdown-button .popover-trigger')); - await expectInnerText(page, `${popoverSelector} button:nth-child(3)`, 'Freeze the data pass'); - await pressElement(page, `${popoverSelector} button:nth-child(3)`, true); + await expectInnerText(page, `${popoverSelector} button:nth-child(4)`, 'Freeze the data pass'); + await pressElement(page, `${popoverSelector} button:nth-child(4)`, true); }); it('should successfully disable QC flag creation when data pass is frozen', async () => { @@ -617,8 +650,8 @@ module.exports = () => { it('should successfully un-freeze a given data pass', async () => { const popoverSelector = await getPopoverSelector(await page.waitForSelector('#actions-dropdown-button .popover-trigger')); - await expectInnerText(page, `${popoverSelector} button:nth-child(3)`, 'Unfreeze the data pass'); - await pressElement(page, `${popoverSelector} button:nth-child(3)`); + await expectInnerText(page, `${popoverSelector} button:nth-child(4)`, 'Unfreeze the data pass'); + await pressElement(page, `${popoverSelector} button:nth-child(4)`); }); it('should successfully enable QC flag creation when data pass is un-frozen', async () => { @@ -638,7 +671,7 @@ module.exports = () => { it('should successfully not display button to discard all QC flags for the data pass', async () => { await pressElement(page, '#actions-dropdown-button .popover-trigger', true); const popoverSelector = await getPopoverSelector(await page.waitForSelector('#actions-dropdown-button .popover-trigger')); - await page.waitForSelector(`${popoverSelector} button:nth-child(4)`, { hidden: true }); + await page.waitForSelector(`${popoverSelector} button:nth-child(5)`, { hidden: true }); await pressElement(page, '#actions-dropdown-button .popover-trigger', true); }); @@ -654,7 +687,7 @@ module.exports = () => { // Press again actions dropdown to re-trigger render await pressElement(page, '#actions-dropdown-button .popover-trigger', true); setConfirmationDialogToBeAccepted(page); - await pressElement(page, `${popoverSelector} button:nth-child(4)`, true); + await pressElement(page, `${popoverSelector} button:nth-child(5)`, true); const oldTable = await page.waitForSelector('table').then((table) => table.evaluate((t) => t.innerHTML)); await pressElement(page, '#actions-dropdown-button .popover-trigger', true); await waitForTableLength(page, 3, undefined, oldTable); @@ -682,5 +715,140 @@ module.exports = () => { await page.waitForSelector('#MUD'); }); + describe('GAQ summary button', async () => { + before(async () => { + await page.evaluate((role) => { + // eslint-disable-next-line no-undef + const session = sessionService.get(); + session.access = session.access.filter((r) => r !== role); + }, BkpRoles.DPG_ASYNC_QC_ADMIN); + }); + + it('should not display the recalculate GAQ summary button for non-admin users', async () => { + await navigateToRunsPerDataPass(page, 2, 1, 3); + await pressElement(page, '#actions-dropdown-button .popover-trigger', true); + await page.waitForSelector('#recalculate-gaq-summary-trigger', { hidden: true }); + await pressElement(page, '#actions-dropdown-button .popover-trigger', true); + await page.waitForSelector('[title="Recalculate GAQ for this run"]', { hidden: true }); + }); + + it('should display a secondary GAQ button for runs without a calculated summary', async () => { + await navigateToRunsPerDataPass(page, 2, 1, 3); + + // Run 108 has no seeded GAQ summary + await page.waitForSelector('#row108-globalAggregatedQuality button'); + expect(await getPopoverInnerText(await page.waitForSelector('#row108-globalAggregatedQuality .popover-trigger'))) + .to.be.equal('GAQ summary has not yet been calculated'); + }); + + describe('with DPG_ASYNC_QC_ADMIN role', async () => { + before(async () => { + await page.evaluate((role) => { + // eslint-disable-next-line no-undef + sessionService.get().token = role; + // eslint-disable-next-line no-undef + sessionService.get().access.push(role); + }, BkpRoles.DPG_ASYNC_QC_ADMIN); + + gaqWorker.pause(); + }); + + after(async () => { + gaqWorker.resume(); + + await page.evaluate((role) => { + // eslint-disable-next-line no-undef + const session = sessionService.get(); + session.access = session.access.filter((r) => r !== role); + }, BkpRoles.DPG_ASYNC_QC_ADMIN); + }); + + it('should display the recalculate GAQ summary button for admin users', async () => { + await pressElement(page, '#actions-dropdown-button .popover-trigger', true); + await expectInnerText(page, '#recalculate-gaq-summary-trigger', 'Recalculate GAQ summary'); + await pressElement(page, '#actions-dropdown-button .popover-trigger', true); + }); + + it('should display a per-row recalculate GAQ button for each run with a GAQ summary', async () => { + await page.waitForSelector('#row106 [title="Recalculate GAQ for this run"]'); + await page.waitForSelector('#row107 [title="Recalculate GAQ for this run"]'); + }); + + describe('GAQ summary invalidation and recalculation', async () => { + + beforeEach(async () => { + await resetDatabaseContent(); + await navigateToRunsPerDataPass(page, 2, 1, 3); + }); + + it('should create GAQ summary invalidation entries when the specific run recalculate button is clicked', async () => { + await expectInnerText(page, '#row107-globalAggregatedQuality', 'GAQ'); + + await pressElement(page, '#row107 [title="Recalculate GAQ for this run"]'); + + // After clicking recalculate, the cell should show a clock icon indicating the summary has been fetched again and we can check db entries + await page.waitForSelector('#row107-globalAggregatedQuality #clock-icon'); + + const allInvalidations = await GaqSummaryInvalidationRepository.findAll({}); + expect(allInvalidations).to.have.lengthOf(1); + const invalidatedRunNumbers = allInvalidations.map((i) => i.runNumber); + expect(invalidatedRunNumbers).to.be.eql([107]); + }); + + it('should create GAQ summary invalidation entries when the recalculate for the whole dataPass button is clicked', async () => { + await expectInnerText(page, '#row107-globalAggregatedQuality', 'GAQ'); + + await pressElement(page, '#actions-dropdown-button .popover-trigger', true); + setConfirmationDialogToBeAccepted(page); + await pressElement(page, '#recalculate-gaq-summary-trigger', true); + unsetConfirmationDialogActions(page); + await page.waitForSelector('#row107-globalAggregatedQuality #clock-icon'); + + const allInvalidationsBefore = await GaqSummaryInvalidationRepository.findAll({}); + expect(allInvalidationsBefore).to.have.lengthOf(3); + const invalidatedRunNumbers = allInvalidationsBefore.map((i) => i.runNumber); + expect(invalidatedRunNumbers).to.have.members([106, 107, 108]); + }); + + it('should update the GAQ cell appropriately after each step in the GAQ summary lifecycle', async () => { + await page.waitForSelector('#row107-globalAggregatedQuality #warning-icon'); + await expectInnerText(page, '#row107-globalAggregatedQuality', 'GAQ'); + + const notYetCalculatedIconPopoverContent = await getPopoverContent(await page.waitForSelector('#row107-globalAggregatedQuality .popover-trigger')); + expect(notYetCalculatedIconPopoverContent).to.equal('GAQ summary has not yet been calculated'); + + await pressElement(page, '#row107 [title="Recalculate GAQ for this run"]'); + + await page.waitForSelector('#row107-globalAggregatedQuality #clock-icon'); + + // Normal popover content executes too fast and grabs the old popover content thus the waitForFunction + await page.waitForFunction( + (triggerSelector, expectedText) => { + const trigger = document.querySelector(triggerSelector); + const key = trigger.dataset.popoverKey; + + const popover = document.querySelector(`.popover[data-popover-key="${key}"]`); + return popover?.innerText === expectedText; + }, + { timeout: 5000 }, + '#row107-globalAggregatedQuality .popover-trigger', + 'Summary is invalid. New summary will be calculated shortly. Please wait and refresh the page.', + ); + + const invalidatedIconPopoverContent = await getPopoverContent(await page.waitForSelector('#row107-globalAggregatedQuality .popover-trigger')); + expect(invalidatedIconPopoverContent).to.equal('Summary is invalid. New summary will be calculated shortly. Please wait and refresh the page.'); + + await gaqWorker.recalculateGaqSummaries(1); + + await navigateToRunsPerDataPass(page, 2, 1, 3); + + await page.waitForSelector('#row107-globalAggregatedQuality #clock-icon', { hidden: true }); + await expectInnerText(page, '#row107-globalAggregatedQuality', '76'); + }); + + }); + }); + }); + }; From e6fa4839c54e29da9a1a1ab33f4f9c2018e4b3cc Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Sat, 2 May 2026 15:12:14 +0200 Subject: [PATCH 28/72] [O2B-1567] Add orderMatters option to expectColumnValues Introduce an orderMatters option (default true) to expectColumnValues to allow order-independent assertions. Added to help stabilise the Run Duration test on the runs per data pass page. --- test/public/defaults.js | 24 ++++++++++++------- .../runs/runsPerDataPass.overview.test.js | 7 ++++-- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/test/public/defaults.js b/test/public/defaults.js index d841c4bc05..33ec6aa9de 100644 --- a/test/public/defaults.js +++ b/test/public/defaults.js @@ -726,30 +726,38 @@ module.exports.expectUrlParams = (page, expectedUrlParameters, ignoreParams = [] * * @return {Promise} resolve once column values were checked */ -module.exports.expectColumnValues = async (page, columnId, expectedInnerTextValues) => { +module.exports.expectColumnValues = async (page, columnId, expectedInnerTextValues, { orderMatters = true } = {}) => { try { await page.waitForFunction( - (columnId, expectedInnerTextValues) => { + (columnId, expectedInnerTextValues, orderMatters) => { const cells = document.querySelectorAll(`table tbody .column-${columnId}`); if (cells.length !== expectedInnerTextValues.length) { return false; } - for (const rowIndex in expectedInnerTextValues) { - if (cells[rowIndex].innerText !== expectedInnerTextValues[rowIndex]) { - return false; - } + const actualValues = [...cells].map((cell) => cell.innerText); + + if (orderMatters) { + return actualValues.every((value, index) => value === expectedInnerTextValues[index]); } - return true; + const sortedActual = [...actualValues].sort(); + const sortedExpected = [...expectedInnerTextValues].sort(); + return sortedActual.every((value, index) => value === sortedExpected[index]); }, {}, columnId, expectedInnerTextValues, + orderMatters, ); } catch (_) { // Use expect to have explicit error message - expect(await getColumnCellsInnerTexts(page, columnId)).to.deep.equal(expectedInnerTextValues); + const actual = await getColumnCellsInnerTexts(page, columnId); + if (orderMatters) { + expect(actual).to.deep.equal(expectedInnerTextValues); + } else { + expect(actual).to.have.members(expectedInnerTextValues); + } } }; diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js index 1b4f157d42..5426d95822 100644 --- a/test/public/runs/runsPerDataPass.overview.test.js +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -32,6 +32,7 @@ const { getPopoverSelector, getInnerText, getPopoverInnerText, + getColumnCellsInnerTexts, testTableSortingByColumn, setConfirmationDialogToBeAccepted, unsetConfirmationDialogActions, @@ -497,11 +498,13 @@ module.exports = () => { await fillInput(page, '#duration-operand', '10', ['change']); await waitForTableLength(page, 2); - await expectColumnValues(page, 'runNumber', ['55', '1']); + // Made order-independent assertion as sometimes the run with 55 minutes duration is loaded before the one with 1 minute duration and sometimes the opposite happens, which causes test to fail if exact order is asserted + await expectColumnValues(page, 'runNumber', ['55', '1'], { orderMatters: false }); await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters'); - await expectColumnValues(page, 'runNumber', ['55', '2', '1']); + await waitForTableLength(page, 3); + await expectColumnValues(page, 'runNumber', ['55', '2', '1'], { orderMatters: false }); }); it('should successfully apply alice currents filters', async () => { From 9d5f825fac91b5587b8486b53192bc730b3e3c28 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 4 May 2026 09:12:24 +0200 Subject: [PATCH 29/72] [O2B-1567] Don't show QC summary for invalidated GAQs Only return the QC summary display when there are no undefined quality periods and the GAQ is not invalidated. --- lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js b/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js index f8edad4a42..0e43707f96 100644 --- a/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js +++ b/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js @@ -90,7 +90,7 @@ const getSuccessGaqDisplay = (gaqSummary, isFirstInGroup) => { ]); } - if (undefinedQualityPeriodsCount === 0) { + if (undefinedQualityPeriodsCount === 0 && !invalidated) { return getQcSummaryDisplay(gaqSummary, isFirstInGroup ? { classes: 'first-item' } : {}); } From 5059082f89dae13ed149d910b413e0757e87c0b3 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 4 May 2026 14:20:48 +0200 Subject: [PATCH 30/72] [O2B-1545] Add invalidatedAt to gaq_summaries, remove invalidation table Store invalidation timestamp directly on gaq_summaries instead of a separate gaq_summary_invalidations table. --- lib/database/adapters/GaqSummaryAdapter.js | 2 ++ ...0260223120000-create-gaq-summary-tables.js | 33 ++----------------- lib/database/models/gaqSummary.js | 3 ++ 3 files changed, 8 insertions(+), 30 deletions(-) diff --git a/lib/database/adapters/GaqSummaryAdapter.js b/lib/database/adapters/GaqSummaryAdapter.js index 21cd7b7004..262fceb5be 100644 --- a/lib/database/adapters/GaqSummaryAdapter.js +++ b/lib/database/adapters/GaqSummaryAdapter.js @@ -38,6 +38,7 @@ class GaqSummaryAdapter { missingVerificationsCount, undefinedQualityPeriodsCount, calculationFailed, + invalidatedAt, createdAt, updatedAt, } = databaseObject; @@ -51,6 +52,7 @@ class GaqSummaryAdapter { missingVerificationsCount, undefinedQualityPeriodsCount, calculationFailed, + invalidatedAt, createdAt, updatedAt, }; diff --git a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js index 4540a01c4a..05b2693702 100644 --- a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js +++ b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js @@ -42,36 +42,10 @@ module.exports = { allowNull: false, defaultValue: false, }, - created_at: { + invalidated_at: { type: Sequelize.DATE(3), - allowNull: false, - defaultValue: Sequelize.literal('CURRENT_TIMESTAMP(3)'), - }, - updated_at: { - type: Sequelize.DATE(3), - allowNull: false, - defaultValue: Sequelize.literal('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), - }, - }, { transaction }); - - await queryInterface.createTable('gaq_summary_invalidations', { - data_pass_id: { - type: Sequelize.INTEGER, - primaryKey: true, - allowNull: false, - references: { - model: 'data_passes', - key: 'id', - }, - }, - run_number: { - type: Sequelize.INTEGER, - primaryKey: true, - allowNull: false, - references: { - model: 'runs', - key: 'run_number', - }, + allowNull: true, + defaultValue: null, }, created_at: { type: Sequelize.DATE(3), @@ -87,7 +61,6 @@ module.exports = { }), down: async (queryInterface) => queryInterface.sequelize.transaction(async (transaction) => { - await queryInterface.dropTable('gaq_summary_invalidations', { transaction }); await queryInterface.dropTable('gaq_summaries', { transaction }); }), }; diff --git a/lib/database/models/gaqSummary.js b/lib/database/models/gaqSummary.js index aab9a04dfd..97099f256d 100644 --- a/lib/database/models/gaqSummary.js +++ b/lib/database/models/gaqSummary.js @@ -39,6 +39,9 @@ module.exports = (sequelize) => { calculationFailed: { type: Sequelize.BOOLEAN, }, + invalidatedAt: { + type: Sequelize.DATE(3), + }, }, { tableName: 'gaq_summaries' }); GaqSummary.removeAttribute('id'); From 9893667ba7ccb7629c8c66d1b77cec5fb11c580d Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 4 May 2026 14:23:39 +0200 Subject: [PATCH 31/72] [O2B-1545] Rename calculationFailed to notComputable --- lib/database/adapters/GaqSummaryAdapter.js | 4 ++-- .../migrations/v1/20260223120000-create-gaq-summary-tables.js | 2 +- lib/database/models/gaqSummary.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/database/adapters/GaqSummaryAdapter.js b/lib/database/adapters/GaqSummaryAdapter.js index 262fceb5be..53b6baf61a 100644 --- a/lib/database/adapters/GaqSummaryAdapter.js +++ b/lib/database/adapters/GaqSummaryAdapter.js @@ -37,7 +37,7 @@ class GaqSummaryAdapter { mcReproducibleCoverage, missingVerificationsCount, undefinedQualityPeriodsCount, - calculationFailed, + notComputable, invalidatedAt, createdAt, updatedAt, @@ -51,7 +51,7 @@ class GaqSummaryAdapter { mcReproducibleCoverage, missingVerificationsCount, undefinedQualityPeriodsCount, - calculationFailed, + notComputable, invalidatedAt, createdAt, updatedAt, diff --git a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js index 05b2693702..9366d050ce 100644 --- a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js +++ b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js @@ -37,7 +37,7 @@ module.exports = { undefined_quality_periods_count: { type: Sequelize.INTEGER, }, - calculation_failed: { + not_computable: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false, diff --git a/lib/database/models/gaqSummary.js b/lib/database/models/gaqSummary.js index 97099f256d..efa76e04fd 100644 --- a/lib/database/models/gaqSummary.js +++ b/lib/database/models/gaqSummary.js @@ -36,7 +36,7 @@ module.exports = (sequelize) => { undefinedQualityPeriodsCount: { type: Sequelize.INTEGER, }, - calculationFailed: { + notComputable: { type: Sequelize.BOOLEAN, }, invalidatedAt: { From 47449a7da2403962c4e7417076d06807f38ad453 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 4 May 2026 14:31:43 +0200 Subject: [PATCH 32/72] [O2B-1545] Remove unused GaqSummaryInvalidation files and imports --- .../adapters/GaqSummaryInvalidationAdapter.js | 48 ------------------- lib/database/adapters/index.js | 3 -- lib/database/models/gaqSummaryInvalidation.js | 34 ------------- lib/database/models/index.js | 2 - .../GaqSummaryInvalidationRepository.js | 33 ------------- lib/database/repositories/index.js | 2 - 6 files changed, 122 deletions(-) delete mode 100644 lib/database/adapters/GaqSummaryInvalidationAdapter.js delete mode 100644 lib/database/models/gaqSummaryInvalidation.js delete mode 100644 lib/database/repositories/GaqSummaryInvalidationRepository.js diff --git a/lib/database/adapters/GaqSummaryInvalidationAdapter.js b/lib/database/adapters/GaqSummaryInvalidationAdapter.js deleted file mode 100644 index dbd76df3a8..0000000000 --- a/lib/database/adapters/GaqSummaryInvalidationAdapter.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @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. - */ - -/** - * GaqSummaryInvalidationAdapter - */ -class GaqSummaryInvalidationAdapter { - /** - * Constructor - */ - constructor() { - this.toEntity = this.toEntity.bind(this); - } - - /** - * Converts the given database object to an entity object. - * - * @param {SequelizeGaqSummaryInvalidation} databaseObject Object to convert. - * @returns {GaqSummaryInvalidation} Converted entity object. - */ - toEntity(databaseObject) { - const { - dataPassId, - runNumber, - createdAt, - updatedAt, - } = databaseObject; - - return { - dataPassId, - runNumber, - createdAt, - updatedAt, - }; - } -} - -module.exports = { GaqSummaryInvalidationAdapter }; diff --git a/lib/database/adapters/index.js b/lib/database/adapters/index.js index 97d22b927f..9c858e1bc2 100644 --- a/lib/database/adapters/index.js +++ b/lib/database/adapters/index.js @@ -28,7 +28,6 @@ const FlpRoleAdapter = require('./FlpRoleAdapter'); const { HostAdapter } = require('./HostAdapter.js'); const { GaqDetectorAdapter } = require('./GaqDetectorAdapter.js'); const { GaqSummaryAdapter } = require('./GaqSummaryAdapter.js'); -const { GaqSummaryInvalidationAdapter } = require('./GaqSummaryInvalidationAdapter.js'); const { LhcFillAdapter } = require('./LhcFillAdapter.js'); const { LhcFillStatisticsAdapter } = require('./LhcFillStatisticsAdapter.js'); const LhcPeriodAdapter = require('./LhcPeriodAdapter'); @@ -66,7 +65,6 @@ const eorReasonAdapter = new EorReasonAdapter(); const flpRoleAdapter = new FlpRoleAdapter(); const gaqDetectorAdapter = new GaqDetectorAdapter(); const gaqSummaryAdapter = new GaqSummaryAdapter(); -const gaqSummaryInvalidationAdapter = new GaqSummaryInvalidationAdapter(); const hostAdapter = new HostAdapter(); const lhcFillAdapter = new LhcFillAdapter(); const lhcFillStatisticsAdapter = new LhcFillStatisticsAdapter(); @@ -164,7 +162,6 @@ module.exports = { flpRoleAdapter, gaqDetectorAdapter, gaqSummaryAdapter, - gaqSummaryInvalidationAdapter, hostAdapter, lhcFillAdapter, lhcFillStatisticsAdapter, diff --git a/lib/database/models/gaqSummaryInvalidation.js b/lib/database/models/gaqSummaryInvalidation.js deleted file mode 100644 index 3c0944b9bc..0000000000 --- a/lib/database/models/gaqSummaryInvalidation.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @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. - */ - -module.exports = (sequelize) => { - const Sequelize = require('sequelize'); - - const GaqSummaryInvalidation = sequelize.define('GaqSummaryInvalidation', { - dataPassId: { - type: Sequelize.INTEGER, - }, - runNumber: { - type: Sequelize.INTEGER, - }, - }, { tableName: 'gaq_summary_invalidations' }); - - GaqSummaryInvalidation.removeAttribute('id'); - - GaqSummaryInvalidation.associate = (models) => { - GaqSummaryInvalidation.belongsTo(models.Run, { foreignKey: 'runNumber', as: 'run' }); - GaqSummaryInvalidation.belongsTo(models.DataPass, { foreignKey: 'dataPassId', as: 'dataPass' }); - }; - - return GaqSummaryInvalidation; -}; diff --git a/lib/database/models/index.js b/lib/database/models/index.js index 36736ce905..2549209c5b 100644 --- a/lib/database/models/index.js +++ b/lib/database/models/index.js @@ -28,7 +28,6 @@ const EpnRoleSession = require('./epnrolesession'); const FlpRole = require('./flprole'); const GaqDetector = require('./gaqDetector.js'); const GaqSummary = require('./gaqSummary.js'); -const GaqSummaryInvalidation = require('./gaqSummaryInvalidation.js'); const Host = require('./host.js'); const LhcFill = require('./lhcFill'); const LhcFillStatistics = require('./lhcFillStatistics.js'); @@ -69,7 +68,6 @@ module.exports = (sequelize) => { FlpRole: FlpRole(sequelize), GaqDetector: GaqDetector(sequelize), GaqSummary: GaqSummary(sequelize), - GaqSummaryInvalidation: GaqSummaryInvalidation(sequelize), Host: Host(sequelize), LhcFill: LhcFill(sequelize), LhcFillStatistics: LhcFillStatistics(sequelize), diff --git a/lib/database/repositories/GaqSummaryInvalidationRepository.js b/lib/database/repositories/GaqSummaryInvalidationRepository.js deleted file mode 100644 index c6608f9e83..0000000000 --- a/lib/database/repositories/GaqSummaryInvalidationRepository.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @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. - */ - -const { - models: { - GaqSummaryInvalidation, - }, -} = require('..'); -const Repository = require('./Repository'); - -/** - * GaqSummaryInvalidation repository - */ -class GaqSummaryInvalidationRepository extends Repository { - /** - * Creates a new `GaqSummaryInvalidationRepository` instance. - */ - constructor() { - super(GaqSummaryInvalidation); - } -} - -module.exports = new GaqSummaryInvalidationRepository(); diff --git a/lib/database/repositories/index.js b/lib/database/repositories/index.js index 417ccd5375..4be7600145 100644 --- a/lib/database/repositories/index.js +++ b/lib/database/repositories/index.js @@ -27,7 +27,6 @@ const EnvironmentRepository = require('./EnvironmentRepository'); const EorReasonRepository = require('./EorReasonRepository'); const FlpRoleRepository = require('./FlpRoleRepository'); const GaqDetectorRepository = require('./GaqDetectorRepository.js'); -const GaqSummaryInvalidationRepository = require('./GaqSummaryInvalidationRepository.js'); const GaqSummaryRepository = require('./GaqSummaryRepository.js'); const HostRepository = require('./HostRepository.js'); const LhcFillRepository = require('./LhcFillRepository'); @@ -72,7 +71,6 @@ module.exports = { EorReasonRepository, FlpRoleRepository, GaqDetectorRepository, - GaqSummaryInvalidationRepository, GaqSummaryRepository, HostRepository, LhcFillRepository, From 0b8b161e72b0787da3ce644529ce7b0bb5d7c1d9 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 4 May 2026 15:05:13 +0200 Subject: [PATCH 33/72] [O2B-1563] Use GaqSummaryRepository invalidation Replace usage of GaqSummaryInvalidationRepository with GaqSummaryRepository across services and tests. Upsert calls now set an invalidatedAt timestamp to mark GAQ summaries as invalidated. --- lib/server/services/gaq/GaqDetectorsService.js | 8 +++++--- .../services/qualityControlFlag/QcFlagService.js | 14 +++++++++----- lib/server/services/run/updateRun.js | 5 +++-- test/lib/server/services/gaq/GaqSummary.test.js | 14 +++++++------- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/lib/server/services/gaq/GaqDetectorsService.js b/lib/server/services/gaq/GaqDetectorsService.js index 6c19d1cd4a..4411acdefa 100644 --- a/lib/server/services/gaq/GaqDetectorsService.js +++ b/lib/server/services/gaq/GaqDetectorsService.js @@ -12,7 +12,7 @@ */ const { gaqDetectorAdapter, detectorAdapter } = require('../../../database/adapters'); -const { GaqDetectorRepository, RunRepository, DetectorRepository, GaqSummaryInvalidationRepository } = require('../../../database/repositories'); +const { GaqDetectorRepository, RunRepository, DetectorRepository, GaqSummaryRepository } = require('../../../database/repositories'); const { BadParameterError } = require('../../errors/BadParameterError'); const { dataSource } = require('../../../database/DataSource.js'); const { Op } = require('sequelize'); @@ -59,9 +59,10 @@ class GaqDetectorService { const createdEntries = await GaqDetectorRepository.insertAll(gaqEntries); // Invalidate GAQ summaries for all affected runs - await Promise.all(runNumbers.map((runNumber) => GaqSummaryInvalidationRepository.upsert({ + await Promise.all(runNumbers.map((runNumber) => GaqSummaryRepository.upsert({ dataPassId, runNumber, + invalidatedAt: new Date(), }))); return createdEntries.map(gaqDetectorAdapter.toEntity); @@ -110,9 +111,10 @@ class GaqDetectorService { const createdEntries = await GaqDetectorRepository.insertAll(gaqEntries); // Invalidate GAQ summaries for all affected runs - await Promise.all(runNumbers.map((runNumber) => GaqSummaryInvalidationRepository.upsert({ + await Promise.all(runNumbers.map((runNumber) => GaqSummaryRepository.upsert({ dataPassId, runNumber, + invalidatedAt: new Date(), }))); return createdEntries.map(gaqDetectorAdapter.toEntity); diff --git a/lib/server/services/qualityControlFlag/QcFlagService.js b/lib/server/services/qualityControlFlag/QcFlagService.js index 3f0ca96d79..e3dd1a9dda 100644 --- a/lib/server/services/qualityControlFlag/QcFlagService.js +++ b/lib/server/services/qualityControlFlag/QcFlagService.js @@ -20,7 +20,7 @@ const { RunRepository, QcFlagVerificationRepository, QcFlagEffectivePeriodRepository, - GaqSummaryInvalidationRepository, + GaqSummaryRepository, }, } = require('../../../database/index.js'); const { dataSource } = require('../../../database/DataSource.js'); @@ -212,9 +212,10 @@ class QcFlagService { if (dataPass) { // Invalidate GAQ summary for the dataPass and runNumber of the created flag - await GaqSummaryInvalidationRepository.upsert({ + await GaqSummaryRepository.upsert({ dataPassId: dataPass.id, runNumber, + invalidatedAt: new Date(), }); } @@ -296,9 +297,10 @@ class QcFlagService { if (dataPassId) { // Invalidate GAQ summary for the dataPass and runNumber of the created flag - await GaqSummaryInvalidationRepository.upsert({ + await GaqSummaryRepository.upsert({ dataPassId: dataPassId, runNumber, + invalidatedAt: new Date(), }); } @@ -379,9 +381,10 @@ class QcFlagService { ); // Invalidate GAQ summary for the dataPass and all runNumbers of the deleted flags - await Promise.all(Array.from(runNumbers).map((runNumber) => GaqSummaryInvalidationRepository.upsert({ + await Promise.all(Array.from(runNumbers).map((runNumber) => GaqSummaryRepository.upsert({ dataPassId, runNumber, + invalidatedAt: new Date(), }))); return qcFlagIds.length; @@ -425,9 +428,10 @@ class QcFlagService { // Invalidate GAQ summary if it's the first verification if (dataPassId && (!qcFlag.verifications || qcFlag.verifications.length === 0)) { - await GaqSummaryInvalidationRepository.upsert({ + await GaqSummaryRepository.upsert({ dataPassId, runNumber: updatedQcFlag.runNumber, + invalidatedAt: new Date(), }); } diff --git a/lib/server/services/run/updateRun.js b/lib/server/services/run/updateRun.js index 98c7247c53..a7833ed107 100644 --- a/lib/server/services/run/updateRun.js +++ b/lib/server/services/run/updateRun.js @@ -12,7 +12,7 @@ */ const RunRepository = require('../../../database/repositories/RunRepository.js'); -const GaqSummaryInvalidationRepository = require('../../../database/repositories/GaqSummaryInvalidationRepository.js'); +const GaqSummaryRepository = require('../../../database/repositories/GaqSummaryRepository.js'); const DataPassRunRepository = require('../../../database/repositories/DataPassRunRepository.js'); const { getRunOrFail } = require('./getRunOrFail.js'); const { utilities: { TransactionHelper } } = require('../../../database'); @@ -220,9 +220,10 @@ exports.updateRun = async (identifier, payload, transaction) => { where: { runNumber: runModel.runNumber }, }); for (const { dataPassId } of dataPassRuns) { - await GaqSummaryInvalidationRepository.upsert({ + await GaqSummaryRepository.upsert({ dataPassId, runNumber: runModel.runNumber, + invalidatedAt: new Date(), }); } } diff --git a/test/lib/server/services/gaq/GaqSummary.test.js b/test/lib/server/services/gaq/GaqSummary.test.js index bffd5c5f05..b4f6ae920d 100644 --- a/test/lib/server/services/gaq/GaqSummary.test.js +++ b/test/lib/server/services/gaq/GaqSummary.test.js @@ -13,13 +13,12 @@ const { expect } = require('chai'); const { resetDatabaseContent } = require('../../../../utilities/resetDatabaseContent.js'); -const { repositories: { GaqSummaryInvalidationRepository } } = require('../../../../../lib/database'); +const { repositories: { GaqSummaryRepository } } = require('../../../../../lib/database'); const { qcFlagService } = require('../../../../../lib/server/services/qualityControlFlag/QcFlagService.js'); const { gaqDetectorService } = require('../../../../../lib/server/services/gaq/GaqDetectorsService.js'); const { updateRun } = require('../../../../../lib/server/services/run/updateRun.js'); module.exports = () => { - // Test resets the database before running and clears the invalidation table between each case before(async () => { await resetDatabaseContent(); }); @@ -38,19 +37,20 @@ module.exports = () => { * @return {Promise} */ const expectInvalidation = async (expectedDataPassId, expectedRunNumber, toBeNull = false) => { - const invalidation = await GaqSummaryInvalidationRepository.findOne({ + const summary = await GaqSummaryRepository.findOne({ where: { dataPassId: expectedDataPassId, runNumber: expectedRunNumber }, }); if (toBeNull) { - expect(invalidation, `Expected no invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.be.null; + expect(summary?.invalidatedAt, `Expected no invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.be.null; } else { - expect(invalidation, `Expected invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.not.be.null; + expect(summary?.invalidatedAt, `Expected invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.not.be.null; } }; describe("GAQ Summary Invalidation", async () => { + // Resetting the invalidated column between each case afterEach(async () => { - await GaqSummaryInvalidationRepository.removeAll({ truncate: true }); + await GaqSummaryRepository.updateAll({ invalidatedAt: null }, { where: {} }); }); it('should invalidate GAQ summary when a QC flag is created for a data pass', async () => { @@ -71,7 +71,7 @@ module.exports = () => { await expectInvalidation(dataPassId, 100); // Clear invalidation - await GaqSummaryInvalidationRepository.removeAll({ where: { dataPassId, runNumber: 100 } }); + await GaqSummaryRepository.updateAll({ invalidatedAt: null }, { where: { dataPassId, runNumber: 100 } }); // Verify again to check that no new invalidation is created when the flag is already verified await qcFlagService.verifyFlag({ flagId }, relations); From f4d2f0d724c82e3a75bf09a8370c1a9b7245cd88 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 4 May 2026 16:35:43 +0200 Subject: [PATCH 34/72] [O2B-1564] Use invalidatedAt on GaqSummary in background worker --- lib/server/services/gaq/GaqService.js | 13 ++++++++----- test/lib/server/services/gaq/GaqService.test.js | 15 ++++++++------- test/lib/server/services/gaq/GaqSummary.test.js | 12 ++++++------ 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index d7b9de7676..b649141dad 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -28,7 +28,7 @@ */ const { getOneDataPassOrFail } = require('../dataPasses/getOneDataPassOrFail.js'); -const { QcFlagRepository, GaqSummaryRepository, GaqSummaryInvalidationRepository } = require('../../../database/repositories/index.js'); +const { QcFlagRepository, GaqSummaryRepository } = require('../../../database/repositories/index.js'); const { qcFlagAdapter } = require('../../../database/adapters/index.js'); const { Op } = require('sequelize'); const { QcSummarProperties } = require('../../../domain/enums/QcSummaryProperties.js'); @@ -162,7 +162,7 @@ class GaqService { */ async calculateAndStoreGaqSummary(dataPassId, runNumber) { const summary = await this._computeSummary(dataPassId, runNumber); - await GaqSummaryRepository.upsert({ dataPassId, runNumber, ...summary, calculationFailed: summary === null }); + await GaqSummaryRepository.upsert({ dataPassId, runNumber, ...summary, notComputable: summary === null, invalidatedAt: null }); } /** @@ -171,16 +171,19 @@ class GaqService { * @return {Promise} promise */ async popNInvalidSummaryAndRecalculate(batchSize = 1) { - const invalidCount = await GaqSummaryInvalidationRepository.count(); + const invalidCount = await GaqSummaryRepository.count({ where: { invalidatedAt: { [Op.not]: null } } }); const remaining = Math.min(batchSize, invalidCount); if (remaining === 0) { return; } await dataSource.transaction(async () => { for (let i = 0; i < remaining; i++) { - const invalidation = await GaqSummaryInvalidationRepository.removeOne({ where: {}, order: [['createdAt', 'ASC']] }); + const invalidation = await GaqSummaryRepository.findOne({ + where: { invalidatedAt: { [Op.not]: null } }, + order: [['invalidatedAt', 'ASC']], + }); if (!invalidation) { - break; + return; } const { dataPassId, runNumber } = invalidation; await this.calculateAndStoreGaqSummary(dataPassId, runNumber); diff --git a/test/lib/server/services/gaq/GaqService.test.js b/test/lib/server/services/gaq/GaqService.test.js index e2f66d3e3d..baf6cbfb0b 100644 --- a/test/lib/server/services/gaq/GaqService.test.js +++ b/test/lib/server/services/gaq/GaqService.test.js @@ -13,8 +13,9 @@ const { expect } = require('chai'); const { resetDatabaseContent } = require('../../../../utilities/resetDatabaseContent.js'); -const { repositories: { GaqSummaryRepository, GaqSummaryInvalidationRepository } } = require('../../../../../lib/database'); +const { repositories: { GaqSummaryRepository} } = require('../../../../../lib/database'); const { gaqService } = require('../../../../../lib/server/services/gaq/GaqService.js'); +const { Op } = require('sequelize'); /** * Find the GAQ summary row for a given data pass and run @@ -32,7 +33,7 @@ const findSummary = (dataPassId, runNumber) => GaqSummaryRepository.findOne({ wh * @return {Promise} */ const insertInvalidation = (dataPassId, runNumber, createdAt) => - GaqSummaryInvalidationRepository.insert({ dataPassId, runNumber, createdAt: new Date(createdAt) }); + GaqSummaryRepository.upsert({ dataPassId, runNumber, invalidatedAt: new Date(createdAt) }); // Tests for GaqService are split between QcFlagService.test.js and GaqSummary.test.js // GaqService.test.js (this file) focuses on the summary recalculation and invalidation processing logic @@ -72,19 +73,19 @@ module.exports = () => { expect(rows).to.have.lengthOf(1); }); - it('should store a summary with calculation failed when there is no coverage data for the run', async () => { + it('should store a summary with notComputable set to true when there is no coverage data for the run', async () => { // Run 49 has no QC flags seeded for data pass 1 await gaqService.calculateAndStoreGaqSummary(dataPassId, 49); const summary = await findSummary(dataPassId, 49); expect(summary).to.not.be.null; - expect(summary.calculationFailed).to.be.true; + expect(summary.notComputable).to.be.true; }); }); describe('popNInvalidSummaryAndRecalculate', () => { beforeEach(async () => { - await GaqSummaryInvalidationRepository.removeAll({ truncate: true }); + await GaqSummaryRepository.updateAll({ invalidatedAt: null }, { where: {} }); }); it('should do nothing when the invalidation table is empty', async () => { @@ -102,7 +103,7 @@ module.exports = () => { // Process only 1 — should pick run 106 (oldest) await gaqService.popNInvalidSummaryAndRecalculate(1); - const remaining = await GaqSummaryInvalidationRepository.findOne({ where: { dataPassId, runNumber: 107 } }); + const remaining = await GaqSummaryRepository.findOne({ where: { dataPassId, runNumber: 107, invalidatedAt: { [Op.not]: null } } }); expect(remaining).to.not.be.null; }); @@ -112,7 +113,7 @@ module.exports = () => { await gaqService.popNInvalidSummaryAndRecalculate(10); - const count = await GaqSummaryInvalidationRepository.count({ where: { dataPassId } }); + const count = await GaqSummaryRepository.count({ where: { dataPassId, invalidatedAt: { [Op.not]: null } } }); expect(count).to.equal(0); }); }); diff --git a/test/lib/server/services/gaq/GaqSummary.test.js b/test/lib/server/services/gaq/GaqSummary.test.js index b3e459682e..9ccc94d4dc 100644 --- a/test/lib/server/services/gaq/GaqSummary.test.js +++ b/test/lib/server/services/gaq/GaqSummary.test.js @@ -88,7 +88,7 @@ module.exports = () => { // Verify again to check that no new invalidation is created when the flag is already verified await qcFlagService.verifyFlag({ flagId }, relations); - await expectInvalidation(dataPassId, 100, false); + await expectInvalidation(dataPassId, 100, true); }); it('should invalidate GAQ summary when a QC flag is deleted for a data pass', async () => { @@ -154,9 +154,9 @@ module.exports = () => { // wait at least 11s, default recalculation period is 10s, for the worker to process the invalidation await sleep(11000); - await expectInvalidation(workerDataPassId, workerRunNumber, false); + await expectInvalidation(workerDataPassId, workerRunNumber, true); const summary = await GaqSummaryRepository.findOne({ where: { dataPassId: workerDataPassId, runNumber: workerRunNumber } }); - expect(summary).to.not.be.null; + expect(summary.badRunCoverage).to.not.be.null; }); it('should only upsert an existing summary row rather than creating a duplicate', async () => { @@ -178,7 +178,7 @@ module.exports = () => { // wait 11s for the worker to process await sleep(11000); - await expectInvalidation(workerDataPassId, workerRunNumber, false); + await expectInvalidation(workerDataPassId, workerRunNumber, true); // confirm only one summary row exists (upsert, not duplicate) const summaries = await GaqSummaryRepository.findAll({ where: { dataPassId: workerDataPassId, runNumber: workerRunNumber } }); @@ -204,8 +204,8 @@ module.exports = () => { // Manually call the worker with batchSize=2 to process both in one go await gaqWorker.recalculateGaqSummaries(2); - await expectInvalidation(1, 106, false); - await expectInvalidation(1, 107, false); + await expectInvalidation(1, 106, true); + await expectInvalidation(1, 107, true); const summary106 = await GaqSummaryRepository.findOne({ where: { dataPassId: 1, runNumber: 106 } }); const summary107 = await GaqSummaryRepository.findOne({ where: { dataPassId: 1, runNumber: 107 } }); From 7e5e5533e93c3f496d77db88c76255d67c9768c6 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 4 May 2026 17:33:59 +0200 Subject: [PATCH 35/72] [O2B-1567] Add back invalidatedAt and notComputable properties --- lib/domain/enums/QcSummaryProperties.js | 3 ++- .../Runs/ActiveColumns/getGAQSummaryDisplay.js | 6 +++--- lib/server/services/gaq/GaqService.js | 13 +++++++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/domain/enums/QcSummaryProperties.js b/lib/domain/enums/QcSummaryProperties.js index c437b72079..a4ffd457a2 100644 --- a/lib/domain/enums/QcSummaryProperties.js +++ b/lib/domain/enums/QcSummaryProperties.js @@ -18,5 +18,6 @@ exports.QcSummarProperties = { MC_REPRODUCIBLE: 'mcReproducible', MINIFIED_FLAGS: 'minifiedFlags', UNDEFINED_QUALITY_PERIODS_COUNT: 'undefinedQualityPeriodsCount', - CALCULATION_FAILED: 'calculationFailed', + NOT_COMPUTABLE: 'notComputable', + INVALIDATED_AT: 'invalidatedAt', }; diff --git a/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js b/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js index 0e43707f96..d7f1fa6d9b 100644 --- a/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js +++ b/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js @@ -74,7 +74,7 @@ const recalculateButton = (onRecalculate) => h( * @return {Component} the display element */ const getSuccessGaqDisplay = (gaqSummary, isFirstInGroup) => { - const { undefinedQualityPeriodsCount, invalidated } = gaqSummary; + const { undefinedQualityPeriodsCount, invalidatedAt } = gaqSummary; // No summary exists at all (no QC flags configured for GAQ detectors) if (Object.keys(gaqSummary).length === 0) { @@ -90,13 +90,13 @@ const getSuccessGaqDisplay = (gaqSummary, isFirstInGroup) => { ]); } - if (undefinedQualityPeriodsCount === 0 && !invalidated) { + if (undefinedQualityPeriodsCount === 0 && !invalidatedAt) { return getQcSummaryDisplay(gaqSummary, isFirstInGroup ? { classes: 'first-item' } : {}); } return h(`button.btn.btn-primary.w-100${isFirstInGroup ? '.first-item' : ''}`, [ 'GAQ', - invalidated ? invalidatedDisplay() : null, + invalidatedAt ? invalidatedDisplay() : null, ]); }; diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index b04ef35297..7d2c72c2ad 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -70,7 +70,15 @@ class GaqService { where, }); - return runNumber ? summaries.find((s) => s.runNumber === runNumber)?.[1] ?? {} : Object.fromEntries(summaries); + const formattedSummaries = summaries.map((summary) => ({ + runNumber: summary.runNumber, + summary: this._formatSummary(summary, mcReproducibleAsNotBad), + })); + + if (runNumber) { + return formattedSummaries.find((s) => s.runNumber === runNumber)?.summary ?? {}; + } + return Object.fromEntries(formattedSummaries.map((s) => [s.runNumber, s.summary])); } /** @@ -88,7 +96,8 @@ class GaqService { [QcSummarProperties.MC_REPRODUCIBLE]: summary.mcReproducibleCoverage > 0, [QcSummarProperties.MISSING_VERIFICATIONS]: summary.missingVerificationsCount, [QcSummarProperties.UNDEFINED_QUALITY_PERIODS_COUNT]: summary.undefinedQualityPeriodsCount, - [QcSummarProperties.CALCULATION_FAILED]: summary.calculationFailed, + [QcSummarProperties.NOT_COMPUTABLE]: summary.notComputable, + [QcSummarProperties.INVALIDATED_AT]: summary.invalidatedAt, }; } From a0b44cc2e72d8de350ddefe02d019796b30886f4 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 5 May 2026 09:40:32 +0200 Subject: [PATCH 36/72] [O2B-1564] Fix GAQ recalculation config and default env vars --- docker-compose.dev.yml | 3 +++ docker-compose.test.yml | 3 +++ lib/config/services.js | 6 +++--- test/lib/server/services/gaq/GaqSummary.test.js | 7 +++---- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f782a26d3d..f8b026a81a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -10,6 +10,9 @@ services: JWT_SECRET: BOOKKEEPING-DEV GRPC_INTERNAL_ORIGIN: '[::]:4001' GRPC_AUTHENTICATED_ORIGIN: '[::]:4002' + GAQ_ENABLE_RECALCULATION: "True" + GAQ_RECALCULATION_PERIOD: 10000 + GAQ_RECALCULATION_BATCH_SIZE: 20 ports: - "4000:4000" - "4001:4001" diff --git a/docker-compose.test.yml b/docker-compose.test.yml index b43a5c8ef9..3d4bce9081 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -10,6 +10,9 @@ services: JWT_SECRET: BOOKKEEPING-TEST-SUITE PAGE_ITEMS_LIMIT: 100 CCDB_SYNCHRONIZATION_PERIOD: 3153600000000 # 100y in milliseconds, to be sure all the runs are included when testing sync + GAQ_ENABLE_RECALCULATION: "True" + GAQ_RECALCULATION_PERIOD: 1000 + GAQ_RECALCULATION_BATCH_SIZE: 1 restart: "no" database: diff --git a/lib/config/services.js b/lib/config/services.js index 4c77a1c314..e2a548565b 100644 --- a/lib/config/services.js +++ b/lib/config/services.js @@ -70,8 +70,8 @@ exports.services = { }, gaq: { - enableRecalculation: process.env?.GAQ_ENABLE_RECALCULATION?.toLowerCase() === 'true' || true, - recalculationPeriod: Number(process.env?.GAQ_RECALCULATION_PERIOD) || 10 * 1000, // 10s in milliseconds - batchSize: Number(process.env?.GAQ_RECALCULATION_BATCH_SIZE) || 1, + enableRecalculation: process.env?.GAQ_ENABLE_RECALCULATION?.toLowerCase() === 'true', + recalculationPeriod: Number(process.env?.GAQ_RECALCULATION_PERIOD) || 30 * 1000, // 30s in milliseconds + batchSize: Number(process.env?.GAQ_RECALCULATION_BATCH_SIZE) || 10, }, }; diff --git a/test/lib/server/services/gaq/GaqSummary.test.js b/test/lib/server/services/gaq/GaqSummary.test.js index 9ccc94d4dc..704fac682e 100644 --- a/test/lib/server/services/gaq/GaqSummary.test.js +++ b/test/lib/server/services/gaq/GaqSummary.test.js @@ -151,8 +151,8 @@ module.exports = () => { // confirm that the invalidation is made await expectInvalidation(workerDataPassId, workerRunNumber); - // wait at least 11s, default recalculation period is 10s, for the worker to process the invalidation - await sleep(11000); + // wait at least 2s, recalculation period is 1s in test env, for the worker to process the invalidation + await sleep(2000); await expectInvalidation(workerDataPassId, workerRunNumber, true); const summary = await GaqSummaryRepository.findOne({ where: { dataPassId: workerDataPassId, runNumber: workerRunNumber } }); @@ -175,8 +175,7 @@ module.exports = () => { ); await expectInvalidation(workerDataPassId, workerRunNumber); - // wait 11s for the worker to process - await sleep(11000); + await sleep(2000); await expectInvalidation(workerDataPassId, workerRunNumber, true); From b2662340285e9f2b2b24f150ffa6796dc11111e7 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 5 May 2026 09:58:40 +0200 Subject: [PATCH 37/72] [O2B-1567] Use GaqSummary.invalidatedAt for invalidations and notComputable --- test/api/gaqSummary.test.js | 31 ++++++++++--------- .../runs/runsPerDataPass.overview.test.js | 16 ++++++---- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/test/api/gaqSummary.test.js b/test/api/gaqSummary.test.js index 0d92dba448..ff8a764654 100644 --- a/test/api/gaqSummary.test.js +++ b/test/api/gaqSummary.test.js @@ -16,7 +16,8 @@ const request = require('supertest'); const { server } = require('../../lib/application'); const { resetDatabaseContent } = require('../utilities/resetDatabaseContent.js'); const { BkpRoles } = require('../../lib/domain/enums/BkpRoles'); -const { repositories: { GaqSummaryInvalidationRepository, GaqSummaryRepository } } = require('../../lib/database'); +const { repositories: { GaqSummaryRepository } } = require('../../lib/database'); +const { Op } = require('sequelize'); /** * Check whether an invalidation entry exists for a given data pass and run @@ -28,8 +29,8 @@ const { repositories: { GaqSummaryInvalidationRepository, GaqSummaryRepository } * @return {Promise} */ const expectInvalidation = async (expectedDataPassId, expectedRunNumber, toBePresent = true) => { - const invalidation = await GaqSummaryInvalidationRepository.findOne({ - where: { dataPassId: expectedDataPassId, runNumber: expectedRunNumber }, + const invalidation = await GaqSummaryRepository.findOne({ + where: { dataPassId: expectedDataPassId, runNumber: expectedRunNumber, invalidatedAt: { [Op.not]: null } }, }); if (!toBePresent) { expect(invalidation, `Expected no invalidation for dataPassId=${expectedDataPassId} runNumber=${expectedRunNumber}`).to.be.null; @@ -132,7 +133,8 @@ module.exports = () => { mcReproducibleCoverage: 0.240346, missingVerificationsCount: 3, undefinedQualityPeriodsCount: 0, - calculationFailed: false, + notComputable: false, + invalidatedAt: null, }); }); @@ -147,8 +149,8 @@ module.exports = () => { mcReproducible: true, missingVerificationsCount: 3, undefinedQualityPeriodsCount: 0, - calculationFailed: false, - invalidated: false, + notComputable: false, + invalidatedAt: null, }); }); @@ -163,39 +165,38 @@ module.exports = () => { mcReproducible: true, missingVerificationsCount: 3, undefinedQualityPeriodsCount: 0, - calculationFailed: false, - invalidated: false, + notComputable: false, + invalidatedAt: null, }); }); it('should return 200 with a calculated summary that is invalidated', async () => { - await GaqSummaryInvalidationRepository.insert({ dataPassId: 1, runNumber: 107 }); + await GaqSummaryRepository.upsert({ dataPassId: 1, runNumber: 107, invalidatedAt: new Date() }); const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&mcReproducibleAsNotBad=false&runNumber=107'); expect(response.status).to.equal(200); const { data } = response.body; + expect(data.invalidatedAt).to.not.be.null; + delete data.invalidatedAt; expect(data).to.deep.equal({ badEffectiveRunCoverage: 0.240346, explicitlyNotBadEffectiveRunCoverage: 0.759654, mcReproducible: true, missingVerificationsCount: 3, undefinedQualityPeriodsCount: 0, - calculationFailed: false, - invalidated: true, + notComputable: false, }); }); it('should return 200 with a not-calculated summary that is invalidated', async () => { - await GaqSummaryInvalidationRepository.insert({ dataPassId: 1, runNumber: 106 }); + await GaqSummaryRepository.insert({ dataPassId: 1, runNumber: 106, invalidatedAt: new Date() }); const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&mcReproducibleAsNotBad=false&runNumber=106'); expect(response.status).to.equal(200); const { data } = response.body; - expect(data).to.deep.equal({ - invalidated: true, - }); + expect(data.invalidatedAt).to.not.be.null; }); it('should return 400 if dataPassId is not positive', async () => { diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js index 5426d95822..89f07b02a5 100644 --- a/test/public/runs/runsPerDataPass.overview.test.js +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -39,12 +39,13 @@ const { checkPopoverInnerText, } = require('../defaults.js'); const { resetDatabaseContent } = require('../../utilities/resetDatabaseContent.js'); -const { repositories: { GaqSummaryRepository, GaqSummaryInvalidationRepository } } = require('../../../lib/database'); +const { repositories: { GaqSummaryRepository } } = require('../../../lib/database'); const DataPassRepository = require('../../../lib/database/repositories/DataPassRepository.js'); const { BkpRoles } = require('../../../lib/domain/enums/BkpRoles.js'); const { navigateToRunsPerDataPass } = require('./navigationUtils.js'); const { invalid } = require('joi'); const { gaqWorker } = require('../../../lib/server/services/gaq/GaqWorker.js'); +const { Op } = require('sequelize'); const { expect } = chai; @@ -792,7 +793,9 @@ module.exports = () => { // After clicking recalculate, the cell should show a clock icon indicating the summary has been fetched again and we can check db entries await page.waitForSelector('#row107-globalAggregatedQuality #clock-icon'); - const allInvalidations = await GaqSummaryInvalidationRepository.findAll({}); + const allInvalidations = await GaqSummaryRepository.findAll({ + where: { invalidatedAt: { [Op.not]: null } }, + }); expect(allInvalidations).to.have.lengthOf(1); const invalidatedRunNumbers = allInvalidations.map((i) => i.runNumber); expect(invalidatedRunNumbers).to.be.eql([107]); @@ -806,10 +809,11 @@ module.exports = () => { await pressElement(page, '#recalculate-gaq-summary-trigger', true); unsetConfirmationDialogActions(page); await page.waitForSelector('#row107-globalAggregatedQuality #clock-icon'); - - const allInvalidationsBefore = await GaqSummaryInvalidationRepository.findAll({}); - expect(allInvalidationsBefore).to.have.lengthOf(3); - const invalidatedRunNumbers = allInvalidationsBefore.map((i) => i.runNumber); + const allInvalidations = await GaqSummaryRepository.findAll({ + where: { invalidatedAt: { [Op.not]: null } }, + }); + expect(allInvalidations).to.have.lengthOf(3); + const invalidatedRunNumbers = allInvalidations.map((i) => i.runNumber); expect(invalidatedRunNumbers).to.have.members([106, 107, 108]); }); From 8bc1d4168baf27b538a02b632fd870459534e046 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 5 May 2026 10:08:58 +0200 Subject: [PATCH 38/72] [O2B-1564] Process invalid GAQ summaries concurrently in batches Replace iterative invalidation pop with a single query that fetches up to batchSize invalidated GAQ summaries and processes them concurrently with a Promise.all. --- lib/server/services/gaq/GaqService.js | 29 ++++++++++----------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index b649141dad..cee14a5c19 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -32,7 +32,6 @@ const { QcFlagRepository, GaqSummaryRepository } = require('../../../database/re const { qcFlagAdapter } = require('../../../database/adapters/index.js'); const { Op } = require('sequelize'); const { QcSummarProperties } = require('../../../domain/enums/QcSummaryProperties.js'); -const { dataSource } = require('../../../database/DataSource.js'); const { LogManager } = require('@aliceo2/web-ui'); /** @@ -171,24 +170,18 @@ class GaqService { * @return {Promise} promise */ async popNInvalidSummaryAndRecalculate(batchSize = 1) { - const invalidCount = await GaqSummaryRepository.count({ where: { invalidatedAt: { [Op.not]: null } } }); - const remaining = Math.min(batchSize, invalidCount); - if (remaining === 0) { - return; - } - await dataSource.transaction(async () => { - for (let i = 0; i < remaining; i++) { - const invalidation = await GaqSummaryRepository.findOne({ - where: { invalidatedAt: { [Op.not]: null } }, - order: [['invalidatedAt', 'ASC']], - }); - if (!invalidation) { - return; - } - const { dataPassId, runNumber } = invalidation; - await this.calculateAndStoreGaqSummary(dataPassId, runNumber); - } + const rows = await GaqSummaryRepository.findAll({ + where: { invalidatedAt: { [Op.not]: null } }, + order: [['invalidatedAt', 'ASC']], + limit: batchSize, }); + + if (rows.length > 0) { + this._logger.infoMessage(`Processing ${rows.length} invalidated GAQ summaries`); + } + + await Promise.all(rows.map(({ dataPassId, runNumber }) => + this.calculateAndStoreGaqSummary(dataPassId, runNumber))); } } From 772a96c179e35f1f705a6a04e6f41a518c627856 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 5 May 2026 10:23:03 +0200 Subject: [PATCH 39/72] [O2B-1545] Add index on gaq_summaries.invalidated_at Create an index on invalidated_at column of gaq_summaries table to improve query performance, as it is commonly used to fetch the invalidated summary queue. --- .../v1/20260223120000-create-gaq-summary-tables.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js index 9366d050ce..639db07c5c 100644 --- a/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js +++ b/lib/database/migrations/v1/20260223120000-create-gaq-summary-tables.js @@ -58,6 +58,11 @@ module.exports = { defaultValue: Sequelize.literal('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), }, }, { transaction }); + + await queryInterface.addIndex('gaq_summaries', { + name: 'gaq_summaries_invalidated_at_idx', + fields: ['invalidated_at'], + }, { transaction }); }), down: async (queryInterface) => queryInterface.sequelize.transaction(async (transaction) => { From ebef7a9ff6678f149ddeafa414bf1d76c72c1d12 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 5 May 2026 11:27:13 +0200 Subject: [PATCH 40/72] [O2B-1567] Fixes inconsistent width and height of GAQ button components Remove .items-center to restore btn-group's align-items: stretch so buttons match height, and add w-100 so the container fills the column width consistently regardless of button content --- lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js b/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js index d7f1fa6d9b..9d9c4a71b8 100644 --- a/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js +++ b/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js @@ -126,7 +126,7 @@ export const getGAQSummaryDisplay = (remoteSummary, dataPassId, runNumber, onRec return remoteSummary.match({ Success: (gaqSummary) => h( - `.flex-row.items-center${hasRecalculateAccess ? '.btn-group' : ''}`, + `.flex-row.w-100${hasRecalculateAccess ? '.btn-group' : ''}`, [ gaqFlagLink(getSuccessGaqDisplay(gaqSummary, hasRecalculateAccess)), hasRecalculateAccess ? recalculateButton(onRecalculate) : null, From 1b7baa1cef6fb010b2fb9c9653b2a87b86e5d551 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 5 May 2026 11:52:21 +0200 Subject: [PATCH 41/72] [O2B-1565] Disabling of fetching summaries for a whole dataPass reverted Given GAQ summaries are now stored, precomputed in a table, there is no reason to restrict access to fetch the whole datapass' summaries. There was before when they were calculated on the fly. --- lib/server/controllers/qcFlag.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/server/controllers/qcFlag.controller.js b/lib/server/controllers/qcFlag.controller.js index b7e4cf15f3..a7bd5ba6d0 100644 --- a/lib/server/controllers/qcFlag.controller.js +++ b/lib/server/controllers/qcFlag.controller.js @@ -384,7 +384,7 @@ const getGaqSummaryHandler = async (request, response) => { DtoFactory.queryOnly(Joi.object({ dataPassId: Joi.number().positive().required(), mcReproducibleAsNotBad: Joi.boolean().optional(), - runNumber: Joi.number().positive().required(), + runNumber: Joi.number().positive().optional(), })), request, response, From 92e3c54af043f2eeb6a00c09dba982f05e8e4698 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 5 May 2026 12:32:20 +0200 Subject: [PATCH 42/72] [O2B-1563] Stealth fix: We do not need to soft delete flags that are already soft deleted The simplest way of invalidating GAQ summaries affected by the deletion of a dataPass' Flags is to just take the list of deleted flags. But this is inefficient if some flags were already deleted and thus had already had their summaries at the time. --- lib/server/services/qualityControlFlag/QcFlagService.js | 1 + test/api/qcFlags.test.js | 2 +- test/lib/server/services/gaq/GaqSummary.test.js | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/server/services/qualityControlFlag/QcFlagService.js b/lib/server/services/qualityControlFlag/QcFlagService.js index e3dd1a9dda..b1484df9b8 100644 --- a/lib/server/services/qualityControlFlag/QcFlagService.js +++ b/lib/server/services/qualityControlFlag/QcFlagService.js @@ -368,6 +368,7 @@ class QcFlagService { id: dataPassId, }, }, + where: { deleted: false }, raw: true, })).map(({ id, runNumber }) => ({ id, runNumber })); diff --git a/test/api/qcFlags.test.js b/test/api/qcFlags.test.js index ee67961439..092df4d883 100644 --- a/test/api/qcFlags.test.js +++ b/test/api/qcFlags.test.js @@ -1110,7 +1110,7 @@ module.exports = () => { .delete(`/api/qcFlags/perDataPass?dataPassId=${dataPassId}&token=${BkpRoles.DPG_ASYNC_QC_ADMIN}`); expect(response.status).to.be.equal(200); - expect(response.body.data.deletedCount).to.equal(11); // 9 from seeders, 2 created in POST requests previously in this test + expect(response.body.data.deletedCount).to.equal(10); // 9 from seeders, 2 created in POST requests previously in this test, 1 already soft-deleted }); }); diff --git a/test/lib/server/services/gaq/GaqSummary.test.js b/test/lib/server/services/gaq/GaqSummary.test.js index b4f6ae920d..62ad2a980e 100644 --- a/test/lib/server/services/gaq/GaqSummary.test.js +++ b/test/lib/server/services/gaq/GaqSummary.test.js @@ -89,7 +89,6 @@ module.exports = () => { await qcFlagService.deleteAllForDataPass(dataPassId); await expectInvalidation(dataPassId, 100); - await expectInvalidation(dataPassId, 105); }); it('should invalidate GAQ summary when GAQ detectors are explicitly set for a data pass and run', async () => { From 49430910ffa16cf397c9a9e912555ba65485b960 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 5 May 2026 12:46:12 +0200 Subject: [PATCH 43/72] [O2B-1564] Improve logging message for GAQWorker More details logged to help debugging the state of the GAQ worker. --- lib/server/services/gaq/GaqService.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index cee14a5c19..a2e33e6be9 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -170,14 +170,14 @@ class GaqService { * @return {Promise} promise */ async popNInvalidSummaryAndRecalculate(batchSize = 1) { - const rows = await GaqSummaryRepository.findAll({ + const { rows, count } = await GaqSummaryRepository.findAndCountAll({ where: { invalidatedAt: { [Op.not]: null } }, order: [['invalidatedAt', 'ASC']], limit: batchSize, }); if (rows.length > 0) { - this._logger.infoMessage(`Processing ${rows.length} invalidated GAQ summaries`); + this._logger.infoMessage(`Processing ${rows.length} out of ${count} invalidated GAQ summaries (batch size: ${batchSize})`); } await Promise.all(rows.map(({ dataPassId, runNumber }) => From 86ff3a613805bd0da651772b75e4c47556ae0c22 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 5 May 2026 16:29:14 +0200 Subject: [PATCH 44/72] [O2B-1567] Stealth fix: Make clicking outside the DropdownSubMenu close it Note: e.stopPropagation is used on the .navbar-dropdown container so that clicks inside don't bubble up to the document listener and immediately re-close the menu. --- lib/public/Model.js | 1 + lib/public/components/NavBar/index.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/public/Model.js b/lib/public/Model.js index 6818118c81..3a49a0af05 100644 --- a/lib/public/Model.js +++ b/lib/public/Model.js @@ -190,6 +190,7 @@ export default class Model extends Observable { // Navbar dropdown menus this.dropdownMenu = false; + document.addEventListener('click', () => this.clearDropdownMenu()); } /** diff --git a/lib/public/components/NavBar/index.js b/lib/public/components/NavBar/index.js index d6d873ef48..e94e8f3b3e 100644 --- a/lib/public/components/NavBar/index.js +++ b/lib/public/components/NavBar/index.js @@ -102,7 +102,7 @@ const btnGroupsDelimiter = h('.mh1', { style: { 'border-left': '1px solid var(-- * @return {Component} dropdown menu */ const dropdownSubMenu = (dropdownButtonContent, isOpened, onclick, entries, { menuAttributes, isSelected, dropdownAttributes } = {}) => - h(`.navbar-dropdown ${isOpened ? '.dropdown-open' : ''}`, dropdownAttributes, [ + h(`.navbar-dropdown ${isOpened ? '.dropdown-open' : ''}`, { ...dropdownAttributes, onclick: (e) => e.stopPropagation() }, [ h('button', { class: `btn btn-tab ${isSelected ? 'selected' : ''}`, onclick, From e9ca95db42117af623f640209d5dfd97cf79c085 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 5 May 2026 21:49:44 +0200 Subject: [PATCH 45/72] [O2B-1567] Stealth fixes fixes Navbar fix had a bug missed so reverting this change for another time. Removing the test case for the optional runNumber change that is now failing. --- lib/public/Model.js | 1 - lib/public/components/NavBar/index.js | 2 +- test/api/gaqSummary.test.js | 9 --------- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/lib/public/Model.js b/lib/public/Model.js index 3a49a0af05..6818118c81 100644 --- a/lib/public/Model.js +++ b/lib/public/Model.js @@ -190,7 +190,6 @@ export default class Model extends Observable { // Navbar dropdown menus this.dropdownMenu = false; - document.addEventListener('click', () => this.clearDropdownMenu()); } /** diff --git a/lib/public/components/NavBar/index.js b/lib/public/components/NavBar/index.js index e94e8f3b3e..d6d873ef48 100644 --- a/lib/public/components/NavBar/index.js +++ b/lib/public/components/NavBar/index.js @@ -102,7 +102,7 @@ const btnGroupsDelimiter = h('.mh1', { style: { 'border-left': '1px solid var(-- * @return {Component} dropdown menu */ const dropdownSubMenu = (dropdownButtonContent, isOpened, onclick, entries, { menuAttributes, isSelected, dropdownAttributes } = {}) => - h(`.navbar-dropdown ${isOpened ? '.dropdown-open' : ''}`, { ...dropdownAttributes, onclick: (e) => e.stopPropagation() }, [ + h(`.navbar-dropdown ${isOpened ? '.dropdown-open' : ''}`, dropdownAttributes, [ h('button', { class: `btn btn-tab ${isSelected ? 'selected' : ''}`, onclick, diff --git a/test/api/gaqSummary.test.js b/test/api/gaqSummary.test.js index ff8a764654..3a946bdb92 100644 --- a/test/api/gaqSummary.test.js +++ b/test/api/gaqSummary.test.js @@ -227,15 +227,6 @@ module.exports = () => { expect(response.body.errors[0].detail).to.equal('"query.runNumber" must be a number'); }); - it('should return 400 when runNumber parameter is missing', async () => { - const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1'); - - expect(response.status).to.equal(400); - const { errors } = response.body; - const runNumberError = errors.find((error) => error.source.pointer === '/data/attributes/query/runNumber'); - expect(runNumberError.detail).to.equal('"query.runNumber" is required'); - }); - it('should return 400 when dataPassId parameter is missing', async () => { const response = await request(server).get('/api/qcFlags/summary/gaq?runNumber=107'); From 9f1e8a7a6d8ec9da0889a665323bcc13b13745fd Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 5 May 2026 22:04:25 +0200 Subject: [PATCH 46/72] [O2B-1567] - Changed for loop upsert to a bulk insert which is more efficient Changed the insertAll repository method to include options variable that allows us to specify the updateOnDuplicate argument required for the invalidation bulkInsert. --- lib/database/repositories/Repository.js | 5 +++-- lib/server/services/gaq/GaqService.js | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/database/repositories/Repository.js b/lib/database/repositories/Repository.js index 05a8e6c39c..45f3a3f42a 100644 --- a/lib/database/repositories/Repository.js +++ b/lib/database/repositories/Repository.js @@ -118,10 +118,11 @@ class Repository { * Insert multiple entities * * @param {*[]} entities list of entities to be inserted + * @param {Object} [options] options to be passed to bulkCreate * @return {Promise<*[]>} promise */ - async insertAll(entities) { - return this.model.bulkCreate(entities); + async insertAll(entities, options = {}) { + return this.model.bulkCreate(entities, options); } /** diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index 24307e0f5d..730a63edd5 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -214,9 +214,11 @@ class GaqService { attributes: ['dataPassId', 'runNumber'], }); - for (const summary of summariesToRecalculate) { - await GaqSummaryRepository.upsert({ dataPassId: summary.dataPassId, runNumber: summary.runNumber, invalidatedAt: new Date() }); - } + const now = new Date(); + await GaqSummaryRepository.insertAll( + summariesToRecalculate.map(({ dataPassId, runNumber }) => ({ dataPassId, runNumber, invalidatedAt: now })), + { updateOnDuplicate: ['invalidatedAt'] }, + ); return { summariesToRecalculate: summariesToRecalculate.length }; } From 0586b3ef55e67f537cf99a67f8bb9b820d11225e Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Tue, 5 May 2026 22:44:28 +0200 Subject: [PATCH 47/72] [O2B-1564] Add warning message when the num of invalidated summaries consistently exceeds batch size Also move any GAQ worker related logging to within the GAQ worker itself (separation of concerns). --- lib/server/services/gaq/GaqService.js | 6 ++---- lib/server/services/gaq/GaqWorker.js | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index a2e33e6be9..74bb1360d7 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -176,12 +176,10 @@ class GaqService { limit: batchSize, }); - if (rows.length > 0) { - this._logger.infoMessage(`Processing ${rows.length} out of ${count} invalidated GAQ summaries (batch size: ${batchSize})`); - } - await Promise.all(rows.map(({ dataPassId, runNumber }) => this.calculateAndStoreGaqSummary(dataPassId, runNumber))); + + return { processedCount: rows.length, totalInvalidCount: count }; } } diff --git a/lib/server/services/gaq/GaqWorker.js b/lib/server/services/gaq/GaqWorker.js index a89671aba6..2393adb258 100644 --- a/lib/server/services/gaq/GaqWorker.js +++ b/lib/server/services/gaq/GaqWorker.js @@ -12,6 +12,8 @@ class GaqWorker { this._logger = LogManager.getLogger(GaqWorker.name); this._isPaused = false; this._isSynchronizing = false; + + this.batchSmallerThanInvalidCountWarningTimesOccurring = 0; } /** @@ -41,7 +43,23 @@ class GaqWorker { } this._isSynchronizing = true; try { - await gaqService.popNInvalidSummaryAndRecalculate(batchSize); + const { processedCount, totalInvalidCount } = await gaqService.popNInvalidSummaryAndRecalculate(batchSize); + + if (processedCount > 0) { + this._logger.infoMessage(`Processed ${processedCount} out of ${totalInvalidCount} ` + + `invalidated GAQ summaries (batch size: ${batchSize})`); + } + + if (totalInvalidCount > batchSize) { + this.batchSmallerThanInvalidCountWarningTimesOccurring += 1; + + if (this.batchSmallerThanInvalidCountWarningTimesOccurring >= 5) { + this._logger.warnMessage('For 5 iterations, there have been more invalidated GAQ summaries than the batch size ' + + `(${batchSize}). Consider increasing the batch size.`); + } + } else { + this.batchSmallerThanInvalidCountWarningTimesOccurring = 0; + } } catch (error) { this._logger.errorMessage(`Error recalculating GAQ summaries: ${error.message}\n${error.stack}`); } finally { From f5f686c906213b99e563f1ceb81882c53bb2d345 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:15:32 +0200 Subject: [PATCH 48/72] [O2B-1545] Add a composite primary key on GAQSummary table Sequelize model omitted the primaryKey that migration file defines. --- lib/database/models/gaqSummary.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/database/models/gaqSummary.js b/lib/database/models/gaqSummary.js index efa76e04fd..3a12f62f33 100644 --- a/lib/database/models/gaqSummary.js +++ b/lib/database/models/gaqSummary.js @@ -17,9 +17,11 @@ module.exports = (sequelize) => { const GaqSummary = sequelize.define('GaqSummary', { dataPassId: { type: Sequelize.INTEGER, + primaryKey: true, }, runNumber: { type: Sequelize.INTEGER, + primaryKey: true, }, badRunCoverage: { type: Sequelize.FLOAT, From 9c1116b0944266fac0fe6b769d7ff4bce5230105 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:18:25 +0200 Subject: [PATCH 49/72] [O2B-1563] Centralise GAQ summary invalidation and skip redundant work Extract invalidation into helpers. Guard GAQ updateRun reload so don't run reload unnecessarily. Add to notComputable field in model allowNull default params. Add more test coverage for invalidation on run patch. --- lib/database/models/gaqSummary.js | 2 + .../repositories/GaqSummaryRepository.js | 22 +++++++++++ .../services/gaq/GaqDetectorsService.js | 14 +------ .../qualityControlFlag/QcFlagService.js | 36 ++++++------------ lib/server/services/run/updateRun.js | 37 ++++++++++++------- .../server/services/gaq/GaqSummary.test.js | 20 ++++++++++ 6 files changed, 81 insertions(+), 50 deletions(-) diff --git a/lib/database/models/gaqSummary.js b/lib/database/models/gaqSummary.js index 3a12f62f33..ca2a1f69b8 100644 --- a/lib/database/models/gaqSummary.js +++ b/lib/database/models/gaqSummary.js @@ -40,6 +40,8 @@ module.exports = (sequelize) => { }, notComputable: { type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, }, invalidatedAt: { type: Sequelize.DATE(3), diff --git a/lib/database/repositories/GaqSummaryRepository.js b/lib/database/repositories/GaqSummaryRepository.js index 98986de513..81463d31c3 100644 --- a/lib/database/repositories/GaqSummaryRepository.js +++ b/lib/database/repositories/GaqSummaryRepository.js @@ -28,6 +28,28 @@ class GaqSummaryRepository extends Repository { constructor() { super(GaqSummary); } + + /** + * Mark the summary for a given (dataPassId, runNumber) as invalidated, creating the row if it does not yet exist + * + * @param {number} dataPassId data pass id + * @param {number} runNumber run number + * @return {Promise} resolves once the summary is invalidated + */ + async invalidate(dataPassId, runNumber) { + await this.upsert({ dataPassId, runNumber, invalidatedAt: new Date() }); + } + + /** + * Mark a list of summaries as invalidated in parallel + * + * @param {{ dataPassId: number, runNumber: number }[]} pairs the (dataPassId, runNumber) pairs to invalidate + * @return {Promise} resolves once all summaries are invalidated + */ + async invalidateMany(pairs) { + const invalidatedAt = new Date(); + await Promise.all(pairs.map(({ dataPassId, runNumber }) => this.upsert({ dataPassId, runNumber, invalidatedAt }))); + } } module.exports = new GaqSummaryRepository(); diff --git a/lib/server/services/gaq/GaqDetectorsService.js b/lib/server/services/gaq/GaqDetectorsService.js index 4411acdefa..4f6d466503 100644 --- a/lib/server/services/gaq/GaqDetectorsService.js +++ b/lib/server/services/gaq/GaqDetectorsService.js @@ -58,12 +58,7 @@ class GaqDetectorService { .map((detectorId) => ({ dataPassId, runNumber, detectorId }))); const createdEntries = await GaqDetectorRepository.insertAll(gaqEntries); - // Invalidate GAQ summaries for all affected runs - await Promise.all(runNumbers.map((runNumber) => GaqSummaryRepository.upsert({ - dataPassId, - runNumber, - invalidatedAt: new Date(), - }))); + await GaqSummaryRepository.invalidateMany(runNumbers.map((runNumber) => ({ dataPassId, runNumber }))); return createdEntries.map(gaqDetectorAdapter.toEntity); }); @@ -110,12 +105,7 @@ class GaqDetectorService { .map(({ id: detectorId }) => ({ dataPassId, runNumber, detectorId }))); const createdEntries = await GaqDetectorRepository.insertAll(gaqEntries); - // Invalidate GAQ summaries for all affected runs - await Promise.all(runNumbers.map((runNumber) => GaqSummaryRepository.upsert({ - dataPassId, - runNumber, - invalidatedAt: new Date(), - }))); + await GaqSummaryRepository.invalidateMany(runNumbers.map((runNumber) => ({ dataPassId, runNumber }))); return createdEntries.map(gaqDetectorAdapter.toEntity); }, { transaction }); diff --git a/lib/server/services/qualityControlFlag/QcFlagService.js b/lib/server/services/qualityControlFlag/QcFlagService.js index b1484df9b8..b6a647290a 100644 --- a/lib/server/services/qualityControlFlag/QcFlagService.js +++ b/lib/server/services/qualityControlFlag/QcFlagService.js @@ -210,13 +210,12 @@ class QcFlagService { ], }); - if (dataPass) { - // Invalidate GAQ summary for the dataPass and runNumber of the created flag - await GaqSummaryRepository.upsert({ - dataPassId: dataPass.id, - runNumber, - invalidatedAt: new Date(), - }); + /** + * Invalidate GAQ summary for the dataPass and runNumber of the created flag. + * Skip when `verify` is true: verifyFlag() above already invalidated the same (dataPassId, runNumber). + */ + if (dataPass && !verify) { + await GaqSummaryRepository.invalidate(dataPass.id, runNumber); } createdFlags.push(qcFlagAdapter.toEntity(createdFlag)); @@ -296,12 +295,8 @@ class QcFlagService { const { id, from, to, origin, createdById, runNumber, dplDetectorId, flagTypeId, createdAt } = qcFlag; if (dataPassId) { - // Invalidate GAQ summary for the dataPass and runNumber of the created flag - await GaqSummaryRepository.upsert({ - dataPassId: dataPassId, - runNumber, - invalidatedAt: new Date(), - }); + // Invalidate GAQ summary for the dataPass and runNumber of the deleted flag + await GaqSummaryRepository.invalidate(dataPassId, runNumber); } const qcFlagPropertiesToLog = { @@ -373,8 +368,7 @@ class QcFlagService { })).map(({ id, runNumber }) => ({ id, runNumber })); const qcFlagIds = qcFlagIdsToRunNumbers.map(({ id }) => id); - const runNumbers = new Set(); - qcFlagIdsToRunNumbers.map(({ runNumber }) => runNumbers.add(runNumber)); + const runNumbers = new Set(qcFlagIdsToRunNumbers.map(({ runNumber }) => runNumber)); await QcFlagRepository.updateAll( { deleted: true }, @@ -382,11 +376,7 @@ class QcFlagService { ); // Invalidate GAQ summary for the dataPass and all runNumbers of the deleted flags - await Promise.all(Array.from(runNumbers).map((runNumber) => GaqSummaryRepository.upsert({ - dataPassId, - runNumber, - invalidatedAt: new Date(), - }))); + await GaqSummaryRepository.invalidateMany(Array.from(runNumbers, (runNumber) => ({ dataPassId, runNumber }))); return qcFlagIds.length; }); @@ -429,11 +419,7 @@ class QcFlagService { // Invalidate GAQ summary if it's the first verification if (dataPassId && (!qcFlag.verifications || qcFlag.verifications.length === 0)) { - await GaqSummaryRepository.upsert({ - dataPassId, - runNumber: updatedQcFlag.runNumber, - invalidatedAt: new Date(), - }); + await GaqSummaryRepository.invalidate(dataPassId, updatedQcFlag.runNumber); } return updatedQcFlag; diff --git a/lib/server/services/run/updateRun.js b/lib/server/services/run/updateRun.js index a7833ed107..f4b9f0e69a 100644 --- a/lib/server/services/run/updateRun.js +++ b/lib/server/services/run/updateRun.js @@ -37,6 +37,20 @@ const { logSpecificRunTag } = require('./logEntriesCreation/logSpecificRunTag.js */ const TAGS_TO_LOG = ['Not for physics']; +/** + * Run patch fields whose values feed the qc_time_start / qc_time_end virtual columns + * + * @type {string[]} + */ +const QC_TIME_SOURCE_FIELDS = [ + 'firstTfTimestamp', + 'lastTfTimestamp', + 'timeTrgStart', + 'timeTrgEnd', + 'timeO2Start', + 'timeO2End', +]; + /** * Update the given run * @@ -211,20 +225,17 @@ exports.updateRun = async (identifier, payload, transaction) => { } } - // Need to reload the runModel as qcTimeStart and qcTimeEnd are virtual columns not in the patch so will stay stale after update - await runModel.reload(); - if (previousRun.qcTimeStart?.getTime() !== runModel.qcTimeStart?.getTime() - || previousRun.qcTimeEnd?.getTime() !== runModel.qcTimeEnd?.getTime()) { - const dataPassRuns = await DataPassRunRepository.findAll({ - attributes: ['dataPassId'], - where: { runNumber: runModel.runNumber }, - }); - for (const { dataPassId } of dataPassRuns) { - await GaqSummaryRepository.upsert({ - dataPassId, - runNumber: runModel.runNumber, - invalidatedAt: new Date(), + // Only check for invalidation when the patch could have changed them + if (QC_TIME_SOURCE_FIELDS.some((field) => field in runPatch)) { + // Reload because qcTimeStart/qcTimeEnd are virtual columns not in the patch and stay stale after update + await runModel.reload(); + if (previousRun.qcTimeStart?.getTime() !== runModel.qcTimeStart?.getTime() + || previousRun.qcTimeEnd?.getTime() !== runModel.qcTimeEnd?.getTime()) { + const dataPassRuns = await DataPassRunRepository.findAll({ + attributes: ['dataPassId'], + where: { runNumber: runModel.runNumber }, }); + await GaqSummaryRepository.invalidateMany(dataPassRuns.map(({ dataPassId }) => ({ dataPassId, runNumber: runModel.runNumber }))); } } diff --git a/test/lib/server/services/gaq/GaqSummary.test.js b/test/lib/server/services/gaq/GaqSummary.test.js index 62ad2a980e..c681b62b93 100644 --- a/test/lib/server/services/gaq/GaqSummary.test.js +++ b/test/lib/server/services/gaq/GaqSummary.test.js @@ -116,5 +116,25 @@ module.exports = () => { await expectInvalidation(dataPassId, runNumber); }); + + it('should NOT invalidate GAQ summary when updateRun patch does not touch QC time source fields', async () => { + await updateRun( + { runNumber }, + { runPatch: { definition: 'PHYSICS' } }, + ); + + await expectInvalidation(dataPassId, runNumber, true); + }); + + it('should NOT invalidate GAQ summary when a QC time source field is patched but qcTimeStart/qcTimeEnd do not change', async () => { + // Run 56 has a non-null time_trg_start, so qc_time_start resolves to time_trg_start regardless of time_o2_start. + // Patching only time_o2_start moves a source field but does not change the virtual qcTimeStart / qcTimeEnd, so no invalidation should fire. + await updateRun( + { runNumber }, + { runPatch: { timeO2Start: new Date('2019-08-08 18:00:00') } }, + ); + + await expectInvalidation(dataPassId, runNumber, true); + }); }); }; From 8992231f5214480046c01875d9765f23277f3617 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:51:34 +0200 Subject: [PATCH 50/72] [O2B-1564] Overwrite summary values if couldn't be calculated --- lib/server/services/gaq/GaqService.js | 12 ++++++- .../server/services/gaq/GaqService.test.js | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index 74bb1360d7..6ab62d9673 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -161,7 +161,17 @@ class GaqService { */ async calculateAndStoreGaqSummary(dataPassId, runNumber) { const summary = await this._computeSummary(dataPassId, runNumber); - await GaqSummaryRepository.upsert({ dataPassId, runNumber, ...summary, notComputable: summary === null, invalidatedAt: null }); + await GaqSummaryRepository.upsert({ + dataPassId, + runNumber, + badRunCoverage: summary?.badRunCoverage ?? null, + explicitlyNotBadRunCoverage: summary?.explicitlyNotBadRunCoverage ?? null, + mcReproducibleCoverage: summary?.mcReproducibleCoverage ?? null, + missingVerificationsCount: summary?.missingVerificationsCount ?? null, + undefinedQualityPeriodsCount: summary?.undefinedQualityPeriodsCount ?? null, + notComputable: summary === null, + invalidatedAt: null, + }); } /** diff --git a/test/lib/server/services/gaq/GaqService.test.js b/test/lib/server/services/gaq/GaqService.test.js index baf6cbfb0b..709f0e13ee 100644 --- a/test/lib/server/services/gaq/GaqService.test.js +++ b/test/lib/server/services/gaq/GaqService.test.js @@ -80,6 +80,37 @@ module.exports = () => { const summary = await findSummary(dataPassId, 49); expect(summary).to.not.be.null; expect(summary.notComputable).to.be.true; + + await GaqSummaryRepository.removeAll({ where: { dataPassId, runNumber: 49 } }); + }); + + it('should clear stale coverage fields when a previously-computable row becomes notComputable', async () => { + // Seed a row that has values but will become notComputable after recalculation due to missing QC flags + const staleRunNumber = 49; + await GaqSummaryRepository.upsert({ + dataPassId, + runNumber: staleRunNumber, + badRunCoverage: 0.5, + explicitlyNotBadRunCoverage: 0.4, + mcReproducibleCoverage: 0.1, + missingVerificationsCount: 2, + undefinedQualityPeriodsCount: 1, + notComputable: false, + }); + + // Run 49 has no QC flags seeded for data pass 1, so _computeSummary returns null + await gaqService.calculateAndStoreGaqSummary(dataPassId, staleRunNumber); + + const summary = await findSummary(dataPassId, staleRunNumber); + expect(summary).to.not.be.null; + expect(summary.notComputable).to.be.true; + expect(summary.badRunCoverage).to.be.null; + expect(summary.explicitlyNotBadRunCoverage).to.be.null; + expect(summary.mcReproducibleCoverage).to.be.null; + expect(summary.missingVerificationsCount).to.be.null; + expect(summary.undefinedQualityPeriodsCount).to.be.null; + + await GaqSummaryRepository.removeAll({ where: { dataPassId, runNumber: staleRunNumber } }); }); }); From 7481ec1bf9b9bdf57cd6ff5227101a0fe60da1af Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:50:25 +0200 Subject: [PATCH 51/72] [O2B-1564] Protect invalidatedAt from race condition Protects invalidatedAt from being set to null if the datapass and runNumber becomes invalid for whatever reason in the middle of computeSummary. ComputeSummary would overwrite the invalidatedAt column with null at the end. Now it performs a check to prevent this. --- lib/server/services/gaq/GaqService.js | 34 ++++++++++++++---- .../server/services/gaq/GaqService.test.js | 35 +++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index 6ab62d9673..a2b145ce3b 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -157,11 +157,14 @@ class GaqService { * Calculate and store GAQ summary for given data pass and run * @param {number} dataPassId id of data pass * @param {number} runNumber run number + * @param {object} [options] additional options + * @param {Date} [options.expectedInvalidatedAt] if provided, invalidatedAt will only be cleared if it is equal to the provided value * @return {Promise} promise */ - async calculateAndStoreGaqSummary(dataPassId, runNumber) { + async calculateAndStoreGaqSummary(dataPassId, runNumber, { expectedInvalidatedAt } = {}) { const summary = await this._computeSummary(dataPassId, runNumber); - await GaqSummaryRepository.upsert({ + + const fields = { dataPassId, runNumber, badRunCoverage: summary?.badRunCoverage ?? null, @@ -170,8 +173,27 @@ class GaqService { missingVerificationsCount: summary?.missingVerificationsCount ?? null, undefinedQualityPeriodsCount: summary?.undefinedQualityPeriodsCount ?? null, notComputable: summary === null, - invalidatedAt: null, - }); + }; + + if (expectedInvalidatedAt === undefined) { + // No expected invalidation time provided, just upsert the summary + await GaqSummaryRepository.upsert({ dataPassId, runNumber, ...fields, invalidatedAt: null }); + return; + }; + + // Only clear invalidatedAt if it hasn't been changed during compute + const [rows] = await GaqSummaryRepository.updateAll( + { ...fields, invalidatedAt: null }, + { where: { dataPassId, runNumber, invalidatedAt: expectedInvalidatedAt } }, + ); + + if (rows === 0) { + // Write fresh summary fields but leave invalidatedAt unchanged + await GaqSummaryRepository.updateAll( + { ...fields }, + { where: { dataPassId, runNumber } }, + ); + } } /** @@ -186,8 +208,8 @@ class GaqService { limit: batchSize, }); - await Promise.all(rows.map(({ dataPassId, runNumber }) => - this.calculateAndStoreGaqSummary(dataPassId, runNumber))); + await Promise.all(rows.map(({ dataPassId, runNumber, invalidatedAt }) => + this.calculateAndStoreGaqSummary(dataPassId, runNumber, { expectedInvalidatedAt: invalidatedAt }))); return { processedCount: rows.length, totalInvalidCount: count }; } diff --git a/test/lib/server/services/gaq/GaqService.test.js b/test/lib/server/services/gaq/GaqService.test.js index 709f0e13ee..b2a96c49e9 100644 --- a/test/lib/server/services/gaq/GaqService.test.js +++ b/test/lib/server/services/gaq/GaqService.test.js @@ -12,6 +12,7 @@ */ const { expect } = require('chai'); +const sinon = require('sinon'); const { resetDatabaseContent } = require('../../../../utilities/resetDatabaseContent.js'); const { repositories: { GaqSummaryRepository} } = require('../../../../../lib/database'); const { gaqService } = require('../../../../../lib/server/services/gaq/GaqService.js'); @@ -112,6 +113,40 @@ module.exports = () => { await GaqSummaryRepository.removeAll({ where: { dataPassId, runNumber: staleRunNumber } }); }); + + it('should not clear invalidatedAt if the row was re-invalidated during compute', async () => { + // Seed an initial invalidation so the row has a known invalidatedAt + await GaqSummaryRepository.invalidate(dataPassId, runNumber); + const initial = await findSummary(dataPassId, runNumber); + expect(initial.invalidatedAt).to.not.be.null; + + // Simulate a concurrent invalidate by mocking _computeSummary to invalidate the row again but still return a valid summary + sinon.stub(gaqService, '_computeSummary').callsFake(async () => { + await GaqSummaryRepository.invalidate(dataPassId, runNumber); + return { + badRunCoverage: 0, + explicitlyNotBadRunCoverage: 1, + mcReproducibleCoverage: 0, + missingVerificationsCount: 0, + undefinedQualityPeriodsCount: 0, + }; + }); + + try { + await gaqService.calculateAndStoreGaqSummary( + dataPassId, + runNumber, + { expectedInvalidatedAt: initial.invalidatedAt }, + ); + } finally { + sinon.restore(); + } + + const after = await findSummary(dataPassId, runNumber); + // InvalidatedAt should not have been cleared because the row was re-invalidated during compute + expect(after.invalidatedAt).to.not.be.null; + expect(after.invalidatedAt).to.be.greaterThan(initial.invalidatedAt); + }); }); describe('popNInvalidSummaryAndRecalculate', () => { From fe8062c531e82d74b583148bcab4bb35e7652a07 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:15:33 +0200 Subject: [PATCH 52/72] [O2B-1564] Fixed silent error in test --- test/lib/server/services/gaq/GaqSummary.test.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/lib/server/services/gaq/GaqSummary.test.js b/test/lib/server/services/gaq/GaqSummary.test.js index 5bcde3853e..21d10b7fa7 100644 --- a/test/lib/server/services/gaq/GaqSummary.test.js +++ b/test/lib/server/services/gaq/GaqSummary.test.js @@ -242,14 +242,13 @@ module.exports = () => { // First call — will be held open by the slow stub const firstCall = gaqWorker.recalculateGaqSummaries(1); - // Second call — should be skipped because _isSynchronizing is true await gaqWorker.recalculateGaqSummaries(1); // Stub should only have been called once expect(stub.callCount).to.equal(1); - // Release the first call - resolveFirst(); + // Release the first call with the shape popNInvalidSummaryAndRecalculate normally returns + resolveFirst({ processedCount: 0, totalInvalidCount: 0 }); await firstCall; } finally { sinon.restore(); From 9851455b67181978d35b6d2802151386707477c1 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:16:58 +0200 Subject: [PATCH 53/72] [O2B-1564] Modify pause behaviour to account for in-flight recalculation --- lib/server/services/gaq/GaqWorker.js | 35 ++++++++++++++----- .../server/services/gaq/GaqSummary.test.js | 1 + test/utilities/resetDatabaseContent.js | 6 ++-- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/lib/server/services/gaq/GaqWorker.js b/lib/server/services/gaq/GaqWorker.js index 2393adb258..0a5cfb466d 100644 --- a/lib/server/services/gaq/GaqWorker.js +++ b/lib/server/services/gaq/GaqWorker.js @@ -11,17 +11,25 @@ class GaqWorker { constructor() { this._logger = LogManager.getLogger(GaqWorker.name); this._isPaused = false; - this._isSynchronizing = false; + this._currentRun = null; this.batchSmallerThanInvalidCountWarningTimesOccurring = 0; } /** - * Pause the worker so it skips processing on the next scheduled calls - * @return {void} + * Pause the worker so it skips future scheduled calls, and await any in-flight call to finish + * so callers can safely mutate shared state (e.g. drop tables in tests) once this resolves + * @return {Promise} resolves once the worker is idle and paused */ - pause() { + async pause() { this._isPaused = true; + if (this._currentRun) { + try { + await this._currentRun; + } catch { + // Already logged inside _doRecalculate + } + } } /** @@ -38,10 +46,23 @@ class GaqWorker { * @return {Promise} promise */ async recalculateGaqSummaries(batchSize) { - if (this._isSynchronizing || this._isPaused) { + if (this._isPaused || this._currentRun) { return; } - this._isSynchronizing = true; + this._currentRun = this._doRecalculate(batchSize); + try { + await this._currentRun; + } finally { + this._currentRun = null; + } + } + + /** + * Run a single recalculation pass; errors are logged but not rethrown + * @param {number} batchSize number of invalid summaries to recalculate + * @return {Promise} promise + */ + async _doRecalculate(batchSize) { try { const { processedCount, totalInvalidCount } = await gaqService.popNInvalidSummaryAndRecalculate(batchSize); @@ -62,8 +83,6 @@ class GaqWorker { } } catch (error) { this._logger.errorMessage(`Error recalculating GAQ summaries: ${error.message}\n${error.stack}`); - } finally { - this._isSynchronizing = false; } } } diff --git a/test/lib/server/services/gaq/GaqSummary.test.js b/test/lib/server/services/gaq/GaqSummary.test.js index 21d10b7fa7..f613dc4d5c 100644 --- a/test/lib/server/services/gaq/GaqSummary.test.js +++ b/test/lib/server/services/gaq/GaqSummary.test.js @@ -242,6 +242,7 @@ module.exports = () => { // First call — will be held open by the slow stub const firstCall = gaqWorker.recalculateGaqSummaries(1); + // Second call — should be skipped because a previous run is still in flight await gaqWorker.recalculateGaqSummaries(1); // Stub should only have been called once diff --git a/test/utilities/resetDatabaseContent.js b/test/utilities/resetDatabaseContent.js index 704a986219..79d9996f7d 100644 --- a/test/utilities/resetDatabaseContent.js +++ b/test/utilities/resetDatabaseContent.js @@ -15,9 +15,9 @@ const { database } = require('../../lib/application.js'); const { gaqWorker } = require('../../lib/server/services/gaq/GaqWorker.js'); exports.resetDatabaseContent = async () => { - // Pause GAQ worker when resetDatabaseContent() runs in between tests in the different test suites - // Otherwise, worker fails as the invalidation table is DROPPED. This avoids an ERROR message appearing in test logs even if the suite passed - gaqWorker.pause(); + // Pause GAQ worker and await any in-flight call before dropping tables, otherwise a tick + // already past the guard would hit dropped tables and log a spurious ERROR + await gaqWorker.pause(); await database.dropAllTables(); await database.migrate(); await database.seed(); From d7dc453b6e7f3ed7f90d71b249c8a61c17bcaaf4 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:27:25 +0200 Subject: [PATCH 54/72] [O2B-1564] Remove extra where option --- lib/server/services/qualityControlFlag/QcFlagService.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/server/services/qualityControlFlag/QcFlagService.js b/lib/server/services/qualityControlFlag/QcFlagService.js index 70b4f92194..e389375453 100644 --- a/lib/server/services/qualityControlFlag/QcFlagService.js +++ b/lib/server/services/qualityControlFlag/QcFlagService.js @@ -364,7 +364,6 @@ class QcFlagService { id: dataPassId, }, }, - where: { deleted: false }, raw: true, })).map(({ id, runNumber }) => ({ id, runNumber })); From 35272c745f167fe9e80956984fc57722c50c0059 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:04:23 +0200 Subject: [PATCH 55/72] [O2B-1564] Make batchSize dynamic between a min and max Add a helper that logs a warning when the env var is set to an invalid value instead of silently falling back to the default. If batchSize is not enough log the first warning after 5 consecutive overflow ticks, then every 30 ticks while it persists and recovery line when the streak ends so operators can grep for "backlog recovered". Log at debug level when the worker is paused or resumed. --- docker-compose.dev.yml | 3 +- docker-compose.test.yml | 3 +- lib/application.js | 2 +- lib/config/services.js | 24 +++++++- lib/server/services/gaq/GaqWorker.js | 59 +++++++++++++++---- .../server/services/gaq/GaqSummary.test.js | 8 +-- 6 files changed, 78 insertions(+), 21 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f8b026a81a..c188aa5b70 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -12,7 +12,8 @@ services: GRPC_AUTHENTICATED_ORIGIN: '[::]:4002' GAQ_ENABLE_RECALCULATION: "True" GAQ_RECALCULATION_PERIOD: 10000 - GAQ_RECALCULATION_BATCH_SIZE: 20 + GAQ_RECALCULATION_MIN_BATCH_SIZE: 5 + GAQ_RECALCULATION_MAX_BATCH_SIZE: 50 ports: - "4000:4000" - "4001:4001" diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 3d4bce9081..a505b1022e 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -12,7 +12,8 @@ services: CCDB_SYNCHRONIZATION_PERIOD: 3153600000000 # 100y in milliseconds, to be sure all the runs are included when testing sync GAQ_ENABLE_RECALCULATION: "True" GAQ_RECALCULATION_PERIOD: 1000 - GAQ_RECALCULATION_BATCH_SIZE: 1 + GAQ_RECALCULATION_MIN_BATCH_SIZE: 1 + GAQ_RECALCULATION_MAX_BATCH_SIZE: 1 restart: "no" database: diff --git a/lib/application.js b/lib/application.js index f7d64dce01..ff39c2ed25 100644 --- a/lib/application.js +++ b/lib/application.js @@ -135,7 +135,7 @@ class BookkeepingApplication { if (gaqConfig.enableRecalculation) { this.scheduledProcessesManager.schedule( - () => gaqWorker.recalculateGaqSummaries(gaqConfig.batchSize), + () => gaqWorker.recalculateGaqSummaries(gaqConfig.minBatchSize, gaqConfig.maxBatchSize), { wait: 10 * 1000, every: gaqConfig.recalculationPeriod, diff --git a/lib/config/services.js b/lib/config/services.js index e2a548565b..599cc1ad13 100644 --- a/lib/config/services.js +++ b/lib/config/services.js @@ -27,6 +27,25 @@ const { CCDB_RUN_INFO_URL, } = process.env ?? {}; +/** + * Parse a positive integer env var, falling back to the default and warning if the value is invalid + * + * @param {string|undefined} raw the raw env var value + * @param {number} defaultValue value to use when raw is unset or invalid + * @param {string} name env var name (for the warning message) + * @return {number} the parsed value or the default + */ +const parsePositiveInt = (raw, defaultValue, name) => { + if (raw === undefined) return defaultValue; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0) { + // eslint-disable-next-line no-console + console.warn(`Invalid ${name}=${JSON.stringify(raw)}; falling back to ${defaultValue}`); + return defaultValue; + } + return parsed; +}; + exports.services = { enableHousekeeping: process.env?.ENABLE_HOUSEKEEPING?.toLowerCase() === 'true', userCertificate: { @@ -71,7 +90,8 @@ exports.services = { gaq: { enableRecalculation: process.env?.GAQ_ENABLE_RECALCULATION?.toLowerCase() === 'true', - recalculationPeriod: Number(process.env?.GAQ_RECALCULATION_PERIOD) || 30 * 1000, // 30s in milliseconds - batchSize: Number(process.env?.GAQ_RECALCULATION_BATCH_SIZE) || 10, + recalculationPeriod: parsePositiveInt(process.env?.GAQ_RECALCULATION_PERIOD, 30 * 1000, 'GAQ_RECALCULATION_PERIOD'), // 30s default + minBatchSize: parsePositiveInt(process.env?.GAQ_RECALCULATION_MIN_BATCH_SIZE, 1, 'GAQ_RECALCULATION_MIN_BATCH_SIZE'), + maxBatchSize: parsePositiveInt(process.env?.GAQ_RECALCULATION_MAX_BATCH_SIZE, 100, 'GAQ_RECALCULATION_MAX_BATCH_SIZE'), }, }; diff --git a/lib/server/services/gaq/GaqWorker.js b/lib/server/services/gaq/GaqWorker.js index 0a5cfb466d..5e4cbccf7c 100644 --- a/lib/server/services/gaq/GaqWorker.js +++ b/lib/server/services/gaq/GaqWorker.js @@ -1,6 +1,12 @@ const { gaqService } = require('../../services/gaq/GaqService.js'); const { LogManager } = require('@aliceo2/web-ui'); +// Tolerate brief blips before warning so transient overshoots don't spam logs +const OVERFLOW_THRESHOLD_TICKS = 5; + +// While the overflow persists, re-warn at this cadence so operators see "still bad" without log floods +const OVERFLOW_REMINDER_EVERY_TICKS = 30; + /** * Worker responsible for processing pending GAQ summary invalidations */ @@ -13,7 +19,10 @@ class GaqWorker { this._isPaused = false; this._currentRun = null; - this.batchSmallerThanInvalidCountWarningTimesOccurring = 0; + // Adaptive batch size for the next tick. Null on first tick → falls back to the passed-in min. + this._nextBatchSize = null; + + this._overflowConsecutiveTicks = 0; } /** @@ -22,6 +31,9 @@ class GaqWorker { * @return {Promise} resolves once the worker is idle and paused */ async pause() { + if (!this._isPaused) { + this._logger.infoMessage('Worker paused'); + } this._isPaused = true; if (this._currentRun) { try { @@ -37,19 +49,24 @@ class GaqWorker { * @return {void} */ resume() { + if (this._isPaused) { + this._logger.infoMessage('Worker resumed'); + } this._isPaused = false; } /** * Process pending GAQ summary invalidations. Skips if a previous call is still in progress or if paused. - * @param {number} batchSize number of invalid summaries to recalculate + * The batch size for this tick is clamped between min and max and adapts to the observed backlog. + * @param {number} minBatchSize lower bound on rows to fetch per tick + * @param {number} maxBatchSize upper bound on rows to fetch per tick * @return {Promise} promise */ - async recalculateGaqSummaries(batchSize) { + async recalculateGaqSummaries(minBatchSize, maxBatchSize) { if (this._isPaused || this._currentRun) { return; } - this._currentRun = this._doRecalculate(batchSize); + this._currentRun = this._doRecalculate(minBatchSize, maxBatchSize); try { await this._currentRun; } finally { @@ -59,27 +76,45 @@ class GaqWorker { /** * Run a single recalculation pass; errors are logged but not rethrown - * @param {number} batchSize number of invalid summaries to recalculate + * @param {number} minBatchSize lower bound on rows to fetch per tick + * @param {number} maxBatchSize upper bound on rows to fetch per tick * @return {Promise} promise */ - async _doRecalculate(batchSize) { + async _doRecalculate(minBatchSize, maxBatchSize) { + const clamp = (n) => Math.min(maxBatchSize, Math.max(minBatchSize, n)); + const batchSize = clamp(this._nextBatchSize ?? minBatchSize); + try { const { processedCount, totalInvalidCount } = await gaqService.popNInvalidSummaryAndRecalculate(batchSize); + // Adapt next tick's batch size to the observed backlog (clamped to the bounds) + this._nextBatchSize = clamp(totalInvalidCount); + if (processedCount > 0) { this._logger.infoMessage(`Processed ${processedCount} out of ${totalInvalidCount} ` + `invalidated GAQ summaries (batch size: ${batchSize})`); } - if (totalInvalidCount > batchSize) { - this.batchSmallerThanInvalidCountWarningTimesOccurring += 1; + // Overflow: backlog still exceeds the max even at the largest batch we'll fetch + if (totalInvalidCount > maxBatchSize) { + this._overflowConsecutiveTicks += 1; - if (this.batchSmallerThanInvalidCountWarningTimesOccurring >= 5) { - this._logger.warnMessage('For 5 iterations, there have been more invalidated GAQ summaries than the batch size ' - + `(${batchSize}). Consider increasing the batch size.`); + const ticks = this._overflowConsecutiveTicks; + const firstWarning = ticks === OVERFLOW_THRESHOLD_TICKS; + const reminderDue = ticks > OVERFLOW_THRESHOLD_TICKS + && (ticks - OVERFLOW_THRESHOLD_TICKS) % OVERFLOW_REMINDER_EVERY_TICKS === 0; + + if (firstWarning || reminderDue) { + this._logger.warnMessage(`Invalidated GAQ summary backlog (${totalInvalidCount}) has exceeded ` + + `the max batch size (${maxBatchSize}) for ${ticks} consecutive ticks. ` + + 'Consider raising GAQ_RECALCULATION_MAX_BATCH_SIZE or shortening GAQ_RECALCULATION_PERIOD.'); } } else { - this.batchSmallerThanInvalidCountWarningTimesOccurring = 0; + if (this._overflowConsecutiveTicks >= OVERFLOW_THRESHOLD_TICKS) { + this._logger.infoMessage(`GAQ summary backlog recovered after ${this._overflowConsecutiveTicks} ` + + `consecutive overflow ticks (current backlog: ${totalInvalidCount})`); + } + this._overflowConsecutiveTicks = 0; } } catch (error) { this._logger.errorMessage(`Error recalculating GAQ summaries: ${error.message}\n${error.stack}`); diff --git a/test/lib/server/services/gaq/GaqSummary.test.js b/test/lib/server/services/gaq/GaqSummary.test.js index f613dc4d5c..fc15274711 100644 --- a/test/lib/server/services/gaq/GaqSummary.test.js +++ b/test/lib/server/services/gaq/GaqSummary.test.js @@ -220,8 +220,8 @@ module.exports = () => { await expectInvalidation(1, 106); await expectInvalidation(1, 107); - // Manually call the worker with batchSize=2 to process both in one go - await gaqWorker.recalculateGaqSummaries(2); + // Manually call the worker with min/max batchSize=2 to process both in one go + await gaqWorker.recalculateGaqSummaries(2, 2); await expectInvalidation(1, 106, true); await expectInvalidation(1, 107, true); @@ -240,10 +240,10 @@ module.exports = () => { try { // First call — will be held open by the slow stub - const firstCall = gaqWorker.recalculateGaqSummaries(1); + const firstCall = gaqWorker.recalculateGaqSummaries(1, 1); // Second call — should be skipped because a previous run is still in flight - await gaqWorker.recalculateGaqSummaries(1); + await gaqWorker.recalculateGaqSummaries(1, 1); // Stub should only have been called once expect(stub.callCount).to.equal(1); From 1f640c8bfefa44d2448a1baeec929c8db50dd647 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:27:14 +0200 Subject: [PATCH 56/72] [O2B-1567] Refactoring of handling runNumber and no summary found --- lib/server/services/gaq/GaqService.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index c9937a3d56..3957f7f29f 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -53,20 +53,20 @@ class GaqService { * @param {boolean} [options.mcReproducibleAsNotBad = false] if set to true, * `Limited Acceptance MC Reproducible` flag type is treated as good one * @param {number} [options.runNumber] Optional run number to filter by - * @return {Promise} Resolves with the GAQ Summary + * @return {Promise|RunGaqSummary|null>} when runNumber is given, the run's summary or null if no row exists yet; otherwise a map of runNumber to summary */ async getSummary(dataPassId, { mcReproducibleAsNotBad = false, runNumber } = {}) { await getOneDataPassOrFail({ id: dataPassId }); const where = { dataPassId }; - if (runNumber) { + if (runNumber !== undefined) { where.runNumber = runNumber; } const summaries = await GaqSummaryRepository.findAll({ where }); const summaryByRun = Object.fromEntries(summaries.map((s) => [s.runNumber, this._formatSummary(s, mcReproducibleAsNotBad)])); - return runNumber ? summaryByRun[runNumber] ?? {} : summaryByRun; + return runNumber !== undefined ? summaryByRun[runNumber] ?? null : summaryByRun; } /** From 23f6e0442e16511db656ed942338df95e424f9fc Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:08:09 +0200 Subject: [PATCH 57/72] Revert "Merge branch 'main' into feature/O2B-1563/Create-GAQ-summary-invalidation-mechanism" This reverts commit ce0ac8cc3179e92f0e7b317d330ac445e999c903, reversing changes made to 9c1116b0944266fac0fe6b769d7ff4bce5230105. --- .github/CODEOWNERS | 1 - .github/workflows/bookkeeping.yml | 8 +- .github/workflows/docker.yml | 4 - Dockerfile | 2 +- ...260305110000-add-qcf-run-detector-index.js | 28 - .../seeders/20240404100811-qc-flags.js | 21 - lib/domain/dtos/GetAllLogsDto.js | 19 +- .../dtos/filters/EnvironmentsFilterDto.js | 14 +- lib/domain/dtos/filters/RunFilterDto.js | 25 +- .../enums/NonPhysicsProductionsNamesWords.js | 2 - lib/public/Model.js | 18 +- lib/public/app.css | 68 - .../LhcFillsFilter/BeamTypeFilterModel.js | 6 +- .../LhcFillsFilter/StableBeamFilterModel.js | 76 + .../Filters/LhcFillsFilter/beamTypeFilter.js | 6 +- .../LhcFillsFilter/fillNumberFilter.js | 25 + .../LhcFillsFilter/schemeNameFilter.js | 25 + .../LhcFillsFilter/stableBeamFilter.js | 49 + .../LogsFilter/author/AuthorFilterModel.js | 21 +- .../Filters/LogsFilter/author/authorFilter.js | 33 +- .../components/Filters/LogsFilter/created.js | 53 + .../Filters/LogsFilter/environments.js | 28 + .../components/Filters/LogsFilter/runs.js | 28 + .../Filters/RunsFilter/BeamModeFilterModel.js | 37 +- .../RunsFilter/DetectorsFilterModel.js | 10 +- .../RunsFilter/EorReasonFilterModel.js | 9 - .../Filters/RunsFilter/GaqFilterModel.js | 97 - .../RunsFilter/MagnetsFilteringModel.js | 79 +- .../RunsFilter/MultiCompositionFilterModel.js | 110 - .../RunsFilter/RunDefinitionFilterModel.js | 11 +- .../Filters/RunsFilter/TimeRangeFilter.js | 7 - .../components/Filters/RunsFilter/dcs.js | 50 + .../components/Filters/RunsFilter/ddflp.js | 50 + .../components/Filters/RunsFilter/epn.js | 50 + .../Filters/RunsFilter/runDefinitionFilter.js | 5 +- .../Filters/RunsFilter/runNumbersFilter.js | 25 + .../Filters/RunsFilter/triggerValueFilter.js | 21 + .../components/Filters/common/FilterModel.js | 20 - .../Filters/common/FilteringModel.js | 183 +- .../Filters/common/RadioButtonFilterModel.js | 48 - .../Filters/common/TagFilterModel.js | 12 +- .../common/filters/FilterInputModel.js | 119 ++ .../filters/NumericalComparisonFilterModel.js | 19 +- .../common/filters/ProcessedTextInputModel.js | 21 - .../common/filters/RawTextFilterModel.js | 7 - .../common/filters/SelectionFilterModel.js | 63 + .../filters/TextComparisonFilterModel.js | 12 +- .../common/filters/TextTokensFilterModel.js | 7 - .../common/filters/TimeRangeInputModel.js | 8 - .../common/filters/ToggleFilterModel.js | 74 - .../Filters/common/filters/checkboxFilter.js | 26 + .../common/filters/radioButtonFilter.js | 38 - .../Filters/common/filters/textFilter.js | 8 +- .../Filters/common/filters/textInputFilter.js | 26 - .../Filters/common/filters/toggleFilter.js | 45 - .../Filters/common/filtersPanelPopover.js | 131 +- .../common/form/inputs/DateTimeInputModel.js | 10 +- .../components/common/form/switchInput.js | 4 +- .../common/messages/warningComponent.js | 35 - .../common/selection/SelectionModel.js | 69 +- .../runEorReasons/runEorReasonSelection.js | 5 +- .../runTypes/RunTypesFilterModel.js | 37 +- lib/public/domain/enums/DetectorOrders.js | 43 - .../models/FilterableOverviewPageModel.js | 132 -- lib/public/models/OverviewModel.js | 19 +- .../services/detectors/detectorsProvider.js | 18 +- .../ActiveColumns/dataPassesActiveColumns.js | 9 +- .../views/DataPasses/DataPassesModel.js | 9 +- .../DataPasses/DataPassesOverviewModel.js | 62 +- .../DataPassesPerLhcPeriodOverviewModel.js | 6 +- .../DataPassesPerLhcPeriodOverviewPage.js | 21 +- ...ataPassesPerSimulationPassOverviewModel.js | 6 +- ...DataPassesPerSimulationPassOverviewPage.js | 19 +- .../environmentsActiveColumns.js | 25 +- .../views/Environments/EnvironmentModel.js | 3 +- .../Overview/EnvironmentOverviewModel.js | 109 +- .../Overview/environmentOverviewComponent.js | 7 +- lib/public/views/Home/Overview/HomePage.js | 2 +- .../views/Home/Overview/HomePageModel.js | 8 +- .../ActiveColumns/lhcFillsActiveColumns.js | 26 +- lib/public/views/LhcFills/LhcFills.js | 3 +- .../Overview/LhcFillsOverviewModel.js | 102 +- lib/public/views/LhcFills/Overview/index.js | 17 +- .../Logs/ActiveColumns/logsActiveColumns.js | 87 +- lib/public/views/Logs/LogsModel.js | 5 +- .../views/Logs/Overview/LogsOverviewModel.js | 409 +++- lib/public/views/Logs/Overview/index.js | 15 +- .../ActiveColumns/qcFlagTypesActiveColumns.js | 17 +- .../Overview/QcFlagTypesOverviewModel.js | 105 +- .../Overview/QcFlagTypesOverviewPage.js | 19 +- .../views/QcFlagTypes/QcFlagTypesModel.js | 3 +- .../synchronousQcFlagsActiveColumns.js | 61 - .../SynchronousQcFlagsOverviewPage.js | 16 +- .../views/QcFlags/format/formatQcFlagEnd.js | 5 +- .../views/QcFlags/format/formatQcFlagStart.js | 5 +- .../runDetectorsAsyncQcActiveColumns.js | 2 +- .../Runs/ActiveColumns/runsActiveColumns.js | 73 +- lib/public/views/Runs/Details/RunPatch.js | 11 +- .../views/Runs/Details/runDetailsComponent.js | 7 +- .../FixedPdpBeamTypeRunsOverviewModel.js | 5 +- .../views/Runs/Overview/RunsOverviewModel.js | 316 ++- .../views/Runs/Overview/RunsOverviewPage.js | 20 +- .../views/Runs/Overview/RunsWithQcModel.js | 136 +- .../RunsPerDataPassOverviewModel.js | 43 +- .../RunsPerDataPassOverviewPage.js | 350 ++-- .../RunsPerLhcPeriodOverviewModel.js | 38 +- .../RunsPerLhcPeriodOverviewPage.js | 69 +- lib/public/views/Runs/RunsModel.js | 30 +- .../RunsPerSimulationPassOverviewModel.js | 38 +- .../RunsPerSimulationPassOverviewPage.js | 129 +- .../views/Runs/format/editRunEorReasons.js | 15 +- .../views/Runs/format/formatRunEorReason.js | 36 - .../Runs/mcReproducibleAsNotBadToggle.js | 28 + .../simulationPassesActiveColumns.js | 4 +- .../AnchoredSimulationPassesOverviewModel.js | 65 +- .../AnchoredSimulationPassesOverviewPage.js | 12 +- ...mulationPassesPerLhcPeriodOverviewModel.js | 65 +- ...imulationPassesPerLhcPeriodOverviewPage.js | 19 +- .../SimulationPasses/SimulationPassesModel.js | 9 +- .../ActiveColumns/lhcPeriodsActiveColumns.js | 12 +- .../views/lhcPeriods/LhcPeriodsModel.js | 6 +- .../Overview/LhcPeriodsOverviewModel.js | 96 +- .../Overview/LhcPeriodsOverviewPage.js | 24 +- lib/server/Loggers/FilterLogger.js | 60 - .../controllers/dataPasses.controller.js | 16 +- .../lhcPeriodStatistics.controller.js | 2 +- .../InfoLoggerListener.middleware.js | 23 - lib/server/routers/dataPasses.router.js | 4 +- lib/server/routers/environments.router.js | 4 +- lib/server/routers/lhcFills.router.js | 4 +- .../routers/lhcPeriodsStatistics.router.js | 4 +- lib/server/routers/logs.router.js | 4 +- lib/server/routers/qcFlag.router.js | 4 +- lib/server/routers/runs.router.js | 4 +- lib/server/routers/simulationPasses.router.js | 4 +- .../services/dataPasses/DataPassService.js | 55 +- .../lhcPeriod/LhcPeriodStatisticsService.js | 21 +- .../environment/GetAllEnvironmentsUseCase.js | 50 +- lib/usecases/lhcFill/GetAllLhcFillsUseCase.js | 9 +- lib/usecases/log/GetAllLogsUseCase.js | 74 +- lib/usecases/run/GetAllRunsUseCase.js | 57 +- lib/utilities/setTimeRangeQuery.js | 25 - package-lock.json | 1833 ++++++++++++----- package.json | 32 +- test/api/dataPasses.test.js | 4 +- test/api/logs.test.js | 149 +- test/api/qcFlags.test.js | 10 +- test/api/runs.test.js | 28 +- .../qualityControlFlag/QcFlagService.test.js | 16 +- .../GetAllEnvironmentsUseCase.test.js | 36 - .../usecases/log/GetAllLogsUseCase.test.js | 52 +- .../usecases/run/GetAllRunsUseCase.test.js | 13 +- test/public/Filters/FilteringModel.test.js | 157 -- test/public/Filters/filtersToUrl.test.js | 529 ----- test/public/Filters/urlToFilter.test.js | 372 ---- .../components/filtersPopoverPanel.test.js | 70 - test/public/components/index.js | 4 - test/public/components/warnings.test.js | 85 - .../dataPasses/overviewPerLhcPeriod.test.js | 2 +- .../overviewPerSimulationPass.test.js | 2 +- test/public/defaults.js | 60 +- test/public/index.js | 2 - test/public/logs/overview.test.js | 333 +-- test/public/qcFlagTypes/overview.test.js | 2 +- .../qcFlags/synchronousOverview.test.js | 50 +- test/public/runs/detail.test.js | 19 +- test/public/runs/overview.test.js | 16 +- .../runs/runsPerDataPass.overview.test.js | 43 +- .../runs/runsPerLhcPeriod.overview.test.js | 33 +- .../runsPerSimulationPass.overview.test.js | 22 +- 170 files changed, 4641 insertions(+), 4956 deletions(-) delete mode 100644 lib/database/migrations/v1/20260305110000-add-qcf-run-detector-index.js rename test/public/Filters/index.js => lib/domain/dtos/filters/EnvironmentsFilterDto.js (58%) create mode 100644 lib/public/components/Filters/LhcFillsFilter/StableBeamFilterModel.js create mode 100644 lib/public/components/Filters/LhcFillsFilter/fillNumberFilter.js create mode 100644 lib/public/components/Filters/LhcFillsFilter/schemeNameFilter.js create mode 100644 lib/public/components/Filters/LhcFillsFilter/stableBeamFilter.js create mode 100644 lib/public/components/Filters/LogsFilter/created.js create mode 100644 lib/public/components/Filters/LogsFilter/environments.js create mode 100644 lib/public/components/Filters/LogsFilter/runs.js delete mode 100644 lib/public/components/Filters/RunsFilter/GaqFilterModel.js delete mode 100644 lib/public/components/Filters/RunsFilter/MultiCompositionFilterModel.js create mode 100644 lib/public/components/Filters/RunsFilter/dcs.js create mode 100644 lib/public/components/Filters/RunsFilter/ddflp.js create mode 100644 lib/public/components/Filters/RunsFilter/epn.js create mode 100644 lib/public/components/Filters/RunsFilter/runNumbersFilter.js create mode 100644 lib/public/components/Filters/RunsFilter/triggerValueFilter.js delete mode 100644 lib/public/components/Filters/common/RadioButtonFilterModel.js create mode 100644 lib/public/components/Filters/common/filters/FilterInputModel.js create mode 100644 lib/public/components/Filters/common/filters/SelectionFilterModel.js delete mode 100644 lib/public/components/Filters/common/filters/ToggleFilterModel.js delete mode 100644 lib/public/components/Filters/common/filters/radioButtonFilter.js delete mode 100644 lib/public/components/Filters/common/filters/textInputFilter.js delete mode 100644 lib/public/components/Filters/common/filters/toggleFilter.js delete mode 100644 lib/public/components/common/messages/warningComponent.js delete mode 100644 lib/public/domain/enums/DetectorOrders.js delete mode 100644 lib/public/models/FilterableOverviewPageModel.js delete mode 100644 lib/public/views/QcFlags/ActiveColumns/synchronousQcFlagsActiveColumns.js delete mode 100644 lib/public/views/Runs/format/formatRunEorReason.js create mode 100644 lib/public/views/Runs/mcReproducibleAsNotBadToggle.js delete mode 100644 lib/server/Loggers/FilterLogger.js delete mode 100644 lib/server/middleware/InfoLoggerListener.middleware.js delete mode 100644 lib/utilities/setTimeRangeQuery.js delete mode 100644 test/public/Filters/FilteringModel.test.js delete mode 100644 test/public/Filters/filtersToUrl.test.js delete mode 100644 test/public/Filters/urlToFilter.test.js delete mode 100644 test/public/components/filtersPopoverPanel.test.js delete mode 100644 test/public/components/warnings.test.js diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3be7e511ff..b806224b85 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1 @@ * @graduta -* @isaachilly diff --git a/.github/workflows/bookkeeping.yml b/.github/workflows/bookkeeping.yml index d4ee530717..c3c2358c27 100644 --- a/.github/workflows/bookkeeping.yml +++ b/.github/workflows/bookkeeping.yml @@ -10,10 +10,6 @@ on: branches: - main -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: parallel_tests: name: ${{ matrix.test_type }} @@ -43,7 +39,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Set up Docker - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@v3 - name: Create Coverage Directory run: mkdir -p ${{ github.workspace }}/coverage @@ -75,7 +71,7 @@ jobs: env: TEST_TYPE: ${{ matrix.test_type }} - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v5 with: files: ./coverage/lcov.info env: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0521614f2a..69dd060eb0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -9,10 +9,6 @@ on: permissions: contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: linter: runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index d4f9caed24..b0373db06a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,7 @@ RUN apk add --no-cache \ freetype=2.13.2-r0 \ freetype-dev=2.13.2-r0 \ harfbuzz=8.5.0-r0 \ - ca-certificates=20260413-r0 + ca-certificates=20250911-r0 # Tell Puppeteer to skip installing Chrome. We'll be using the installed package. ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true diff --git a/lib/database/migrations/v1/20260305110000-add-qcf-run-detector-index.js b/lib/database/migrations/v1/20260305110000-add-qcf-run-detector-index.js deleted file mode 100644 index 4c04e5920e..0000000000 --- a/lib/database/migrations/v1/20260305110000-add-qcf-run-detector-index.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * @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. - */ - -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - up: async (queryInterface) => queryInterface.sequelize.transaction(async (transaction) => { - await queryInterface.addIndex('quality_control_flags', { - name: 'quality_control_flags_run_detector_idx', - fields: ['run_number', 'detector_id'], - }, { transaction }); - }), - - down: async (queryInterface) => queryInterface.sequelize.transaction(async (transaction) => { - await queryInterface.removeIndex('quality_control_flags', 'quality_control_flags_run_detector_idx', { transaction }); - }), -}; diff --git a/lib/database/seeders/20240404100811-qc-flags.js b/lib/database/seeders/20240404100811-qc-flags.js index 560cb644bc..b66ca15bce 100644 --- a/lib/database/seeders/20240404100811-qc-flags.js +++ b/lib/database/seeders/20240404100811-qc-flags.js @@ -281,21 +281,6 @@ module.exports = { created_at: '2024-08-12 12:00:10', updated_at: '2024-08-12 12:00:10', }, - { - id: 103, - deleted: true, - from: null, - to: '2019-08-08 20:50:00', - comment: 'deleted flag', - - run_number: 56, - flag_type_id: 13, // Bad - created_by_id: 2, - detector_id: 7, // FT0 - - created_at: '2024-08-12 12:00:15', - updated_at: '2024-08-12 12:00:15', - }, // Run : 56, ITS { @@ -409,12 +394,6 @@ module.exports = { from: '2019-08-08 20:50:00', to: null, }, - { - id: 103, - flag_id: 103, - from: null, - to: '2019-08-08 20:50:00', - }, // Run : 56, ITS { diff --git a/lib/domain/dtos/GetAllLogsDto.js b/lib/domain/dtos/GetAllLogsDto.js index 7a0ef08306..8f6be452d7 100644 --- a/lib/domain/dtos/GetAllLogsDto.js +++ b/lib/domain/dtos/GetAllLogsDto.js @@ -17,10 +17,17 @@ const PaginationDto = require('./PaginationDto'); const { CustomJoi } = require('./CustomJoi.js'); const { TagsFilterDto } = require('./filters/TagsFilterDto.js'); const { FromToFilterDto } = require('./filters/FromToFilterDto.js'); +const { EnvironmentsFilterDto } = require('./filters/EnvironmentsFilterDto'); -const RunFilterDto = CustomJoi.stringArray().items(EntityIdDto).single(); -const EnvironmentsFilterDto = CustomJoi.stringArray().items(Joi.string()).single(); -const LhcFillFilterDto = CustomJoi.stringArray().items(EntityIdDto).single(); +const RunFilterDto = Joi.object({ + values: CustomJoi.stringArray().items(EntityIdDto).single().required(), + operation: Joi.string().valid('and', 'or').required(), +}); + +const LhcFillFilterDto = Joi.object({ + values: CustomJoi.stringArray().items(EntityIdDto).single().required(), + operation: Joi.string().valid('and', 'or').required(), +}); const FilterDto = Joi.object({ title: Joi.string().trim(), @@ -28,14 +35,14 @@ const FilterDto = Joi.object({ author: Joi.string().trim(), created: FromToFilterDto, tags: TagsFilterDto, - fillNumbers: LhcFillFilterDto, - runNumbers: RunFilterDto, + lhcFills: LhcFillFilterDto, + run: RunFilterDto, origin: Joi.string() .valid('human', 'process'), parentLog: EntityIdDto, rootLog: EntityIdDto, rootOnly: Joi.boolean(), - environmentIds: EnvironmentsFilterDto, + environments: EnvironmentsFilterDto, }); const SortDto = Joi.object({ diff --git a/test/public/Filters/index.js b/lib/domain/dtos/filters/EnvironmentsFilterDto.js similarity index 58% rename from test/public/Filters/index.js rename to lib/domain/dtos/filters/EnvironmentsFilterDto.js index 1023d11de8..3baa97a747 100644 --- a/test/public/Filters/index.js +++ b/lib/domain/dtos/filters/EnvironmentsFilterDto.js @@ -11,12 +11,10 @@ * or submit itself to any jurisdiction. */ -const ToUrlSuite = require('./filtersToUrl.test.js'); -const ToFilterSuite = require('./urlToFilter.test.js'); -const FilteringModelSuite = require('./filteringModel.test.js'); +const Joi = require('joi'); +const { CustomJoi } = require('../CustomJoi.js'); -module.exports = () => { - describe('Filters to URL', ToUrlSuite); - describe('URL to Filters', ToFilterSuite); - describe('FilteringModel', FilteringModelSuite); -}; +exports.EnvironmentsFilterDto = Joi.object({ + values: CustomJoi.stringArray().items(Joi.string()).single().required(), + operation: Joi.string().valid('and', 'or').required(), +}); diff --git a/lib/domain/dtos/filters/RunFilterDto.js b/lib/domain/dtos/filters/RunFilterDto.js index e67106d9f0..c66a194778 100644 --- a/lib/domain/dtos/filters/RunFilterDto.js +++ b/lib/domain/dtos/filters/RunFilterDto.js @@ -88,27 +88,26 @@ exports.RunFilterDto = Joi.object({ inelasticInteractionRateAtEnd: FloatComparisonDto, gaq: Joi.object({ - notBadFraction: FloatComparisonDto.custom((value, helpers) => { - const [, { dataPassIds }] = helpers.state.ancestors; - - if (!dataPassIds || dataPassIds.length !== 1) { - return helpers.message('Filtering by GAQ is enabled only when filtering with one dataPassId'); - } - - return value; - }), + notBadFraction: FloatComparisonDto.when( + 'dataPassIds', + { + is: Joi.array().length(1), + then: FloatComparisonDto, + otherwise: Joi.forbidden().error(new Error('Filtering by GAQ is enabled only when filtering with one dataPassId')), + }, + ), mcReproducibleAsNotBad: Joi.boolean().optional(), }), - detectorsQcNotBadFraction: Joi.object() + detectorsQc: Joi.object() .pattern( Joi.string().regex(/^_\d+$/), // Detector id with '_' prefix - FloatComparisonDto, + Joi.object({ notBadFraction: FloatComparisonDto }), ) .keys({ mcReproducibleAsNotBad: Joi.boolean().optional(), }) - .custom((detectorsQcNotBadFractionObj, helpers) => { + .custom((detectorsQcObj, helpers) => { const [{ dataPassIds, simulationPassIds, lhcPeriodIds }] = helpers.state.ancestors; singleRunsCollectionCustomCheck( @@ -118,6 +117,6 @@ exports.RunFilterDto = Joi.object({ 'the dataPassIds, simulationPassIds and lhcPeriodIds filters collectively contain exactly one ID', ); - return detectorsQcNotBadFractionObj; + return detectorsQcObj; }), }); diff --git a/lib/domain/enums/NonPhysicsProductionsNamesWords.js b/lib/domain/enums/NonPhysicsProductionsNamesWords.js index 0d76a56d09..fb3d55cc07 100644 --- a/lib/domain/enums/NonPhysicsProductionsNamesWords.js +++ b/lib/domain/enums/NonPhysicsProductionsNamesWords.js @@ -23,5 +23,3 @@ const NonPhysicsProductionsNamesWords = Object.freeze({ module.exports.NonPhysicsProductionsNamesWords = NonPhysicsProductionsNamesWords; module.exports.NON_PHYSICS_PRODUCTIONS_NAMES_WORDS = Object.values(NonPhysicsProductionsNamesWords); - -module.exports.NON_PHYSICS_PRODUCTIONS_NAMES_TOTAL_LENGTH = Object.values(NonPhysicsProductionsNamesWords).join(',').length; diff --git a/lib/public/Model.js b/lib/public/Model.js index 0d0ae222f3..6818118c81 100644 --- a/lib/public/Model.js +++ b/lib/public/Model.js @@ -95,27 +95,21 @@ export default class Model extends Observable { this._appConfiguration$ = new Observable(); this._inputDebounceTime = INPUT_DEBOUNCE_TIME; - // Setup router - this.router = new QueryRouter(); - this.router.observe(this.handleLocationChange.bind(this)); - this.router.bubbleTo(this); - registerFrontLinkListener((e) => this.router.handleLinkEvent(e)); - // Models this.home = new HomePageModel(this); this.home.bubbleTo(this); - this.lhcPeriods = new LhcPeriodsModel(this.router); + this.lhcPeriods = new LhcPeriodsModel(this); this.lhcPeriods.bubbleTo(this); - this.dataPasses = new DataPassesModel(this.router); + this.dataPasses = new DataPassesModel(this); this.dataPasses.bubbleTo(this); this.qcFlags = new QcFlagsModel(this); this.qcFlags.bubbleTo(this); - this.simulationPasses = new SimulationPassesModel(this.router); + this.simulationPasses = new SimulationPassesModel(this); this.simulationPasses.bubbleTo(this); this.qcFlagTypes = new QcFlagTypesModel(this); @@ -184,6 +178,12 @@ export default class Model extends Observable { this.errorModel = new ErrorModel(); this.errorModel.bubbleTo(this); + // Setup router + this.router = new QueryRouter(); + this.router.observe(this.handleLocationChange.bind(this)); + this.router.bubbleTo(this); + registerFrontLinkListener((e) => this.router.handleLinkEvent(e)); + // Init pages this.handleLocationChange(); this.window.addEventListener('resize', debounce(() => this.notify(), 100)); diff --git a/lib/public/app.css b/lib/public/app.css index 0e88f93174..ec66c3717c 100644 --- a/lib/public/app.css +++ b/lib/public/app.css @@ -266,12 +266,6 @@ th.text-center, td.text-center { border-color: #f5c6cb; } -.alert-warning { - color: var(--color-warning); - background-color: #ffe8c8; - border-color: #fdd69f; -} - .alert-danger hr { border-top-color: #f1b0b7; } @@ -718,68 +712,6 @@ label { opacity: 0.5; } -.active-filters-indicator { - position: relative; - z-index: 10; - background-color: white; - border-radius: .25rem; - padding: var(--space-xs) var(--space-s) var(--space-xs) var(--space-s); - margin: 0 0 0 var(--space-s); -} - -.active-filters-indicator:has(+ .clear-filter-icon-container) { - border-right: 0; - border-radius: .25rem 0 0 .25rem -} - -.clear-filter-icon-container { - background-color: white; - border-radius: 0 .25rem .25rem 0; - font-weight: 700; - cursor: pointer; -} - -.clear-filter-icon { - padding: var(--space-xs); - background-color: white; - color: var(--color-danger); - position: relative; - border-radius: 0 .25rem .25rem 0; - z-index: 10; -} - -.clear-filter-icon:hover { - background-color: var(--color-danger); - color: white; -} - -.inactive { - opacity: 0.5; - pointer-events: none; -} - -.pulse-green { - --pulse-color: 102, 255, 7; - animation: pulse 2s infinite; -} - -.pulse-red { - --pulse-color: 206, 42, 42; - animation: pulse 2s infinite; -} - -@keyframes pulse { - 0% { - box-shadow: 0 0 0px rgba(var(--pulse-color), 0.6); - } - 50% { - box-shadow: 0 0 10px rgba(var(--pulse-color), 0.9); - } - 100% { - box-shadow: 0 0 0px rgba(var(--pulse-color), 0.6); - } -} - /** * Breakpoints : * small : x < 600 (default styles) diff --git a/lib/public/components/Filters/LhcFillsFilter/BeamTypeFilterModel.js b/lib/public/components/Filters/LhcFillsFilter/BeamTypeFilterModel.js index fc0964da04..18be7af40d 100644 --- a/lib/public/components/Filters/LhcFillsFilter/BeamTypeFilterModel.js +++ b/lib/public/components/Filters/LhcFillsFilter/BeamTypeFilterModel.js @@ -12,12 +12,12 @@ */ import { beamTypesProvider } from '../../../services/beamTypes/beamTypesProvider.js'; -import { SelectionModel } from '../../common/selection/SelectionModel.js'; +import { SelectionFilterModel } from '../common/filters/SelectionFilterModel.js'; /** * Beam type filter model */ -export class BeamTypeFilterModel extends SelectionModel { +export class BeamTypeFilterModel extends SelectionFilterModel { /** * Constructor */ @@ -28,7 +28,7 @@ export class BeamTypeFilterModel extends SelectionModel { beamTypesProvider.items$.getCurrent().apply({ Success: (types) => { const beamTypes = types.map((type) => ({ value: type.beam_type })); - this.setAvailableOptions(beamTypes); + this._selectionModel.setAvailableOptions(beamTypes); }, }); }); diff --git a/lib/public/components/Filters/LhcFillsFilter/StableBeamFilterModel.js b/lib/public/components/Filters/LhcFillsFilter/StableBeamFilterModel.js new file mode 100644 index 0000000000..1bc3f8aed2 --- /dev/null +++ b/lib/public/components/Filters/LhcFillsFilter/StableBeamFilterModel.js @@ -0,0 +1,76 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { SelectionModel } from '../../common/selection/SelectionModel.js'; + +/** + * Stable beam filter model + * Holds true or false value + */ +export class StableBeamFilterModel extends SelectionModel { + /** + * Constructor + */ + constructor() { + super({ availableOptions: [{ value: true }, { value: false }], + defaultSelection: [{ value: false }], + multiple: false, + allowEmpty: false }); + } + + /** + * Returns true if the current filter is stable beams only + * + * @return {boolean} true if filter is stable beams only + */ + isStableBeamsOnly() { + return this.current; + } + + /** + * Sets the current filter to stable beams only + * + * @param {boolean} value value to set this stable beams only filter with + * @return {void} + */ + setStableBeamsOnly(value) { + this.select({ value }); + } + + /** + * Get normalized selected option + */ + get normalized() { + return this.current; + } + + /** + * Overrides SelectionModel.isEmpty to respect the fact that stable beam filter cannot be empty. + * @returns {boolean} true if the current value of the filter is false. + */ + get isEmpty() { + return this.current === false; + } + + /** + * Reset the filter to default values + * + * @return {void} + */ + resetDefaults() { + if (!this.isEmpty) { + this.reset(); + this.notify(); + } + } +} diff --git a/lib/public/components/Filters/LhcFillsFilter/beamTypeFilter.js b/lib/public/components/Filters/LhcFillsFilter/beamTypeFilter.js index 83f1487922..7872734704 100644 --- a/lib/public/components/Filters/LhcFillsFilter/beamTypeFilter.js +++ b/lib/public/components/Filters/LhcFillsFilter/beamTypeFilter.js @@ -19,4 +19,8 @@ import { checkboxes } from '../common/filters/checkboxFilter.js'; * @param {BeamTypeFilterModel} beamTypeFilterModel beamTypeFilterModel * @return {Component} the filter */ -export const beamTypeFilter = (beamTypeFilterModel) => checkboxes(beamTypeFilterModel, { selector: 'beam-types' }); +export const beamTypeFilter = (beamTypeFilterModel) => + checkboxes( + beamTypeFilterModel.selectionModel, + { selector: 'beam-types' }, + ); diff --git a/lib/public/components/Filters/LhcFillsFilter/fillNumberFilter.js b/lib/public/components/Filters/LhcFillsFilter/fillNumberFilter.js new file mode 100644 index 0000000000..de13af7586 --- /dev/null +++ b/lib/public/components/Filters/LhcFillsFilter/fillNumberFilter.js @@ -0,0 +1,25 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { rawTextFilter } from '../common/filters/rawTextFilter.js'; + +/** + * Component to filter LHC-fills by fill number + * + * @param {RawTextFilterModel} filterModel the filter model + * @returns {Component} the text field + */ +export const fillNumberFilter = (filterModel) => rawTextFilter( + filterModel, + { classes: ['w-100', 'fill-numbers-filter'], placeholder: 'e.g. 11392, 11383, 7625' }, +); diff --git a/lib/public/components/Filters/LhcFillsFilter/schemeNameFilter.js b/lib/public/components/Filters/LhcFillsFilter/schemeNameFilter.js new file mode 100644 index 0000000000..7b644f382a --- /dev/null +++ b/lib/public/components/Filters/LhcFillsFilter/schemeNameFilter.js @@ -0,0 +1,25 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { rawTextFilter } from '../common/filters/rawTextFilter.js'; + +/** + * Component to filter LHC-fills by scheme name + * + * @param {RawTextFilterModel} filterModel the filter model + * @returns {Component} the text field + */ +export const schemeNameFilter = (filterModel) => rawTextFilter( + filterModel, + { classes: ['w-100'], placeholder: 'e.g. Single_12b_8_1024_8_2018' }, +); diff --git a/lib/public/components/Filters/LhcFillsFilter/stableBeamFilter.js b/lib/public/components/Filters/LhcFillsFilter/stableBeamFilter.js new file mode 100644 index 0000000000..b4429c002c --- /dev/null +++ b/lib/public/components/Filters/LhcFillsFilter/stableBeamFilter.js @@ -0,0 +1,49 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { h } from '/js/src/index.js'; +import { switchInput } from '../../common/form/switchInput.js'; +import { radioButton } from '../../common/form/inputs/radioButton.js'; + +/** + * Display a toggle switch or radio buttons to filter stable beams only + * + * @param {StableBeamFilterModel} stableBeamFilterModel the stableBeamFilterModel + * @param {boolean} radioButtonMode define whether or not to return radio buttons or a switch. + * @returns {Component} the toggle switch + */ +export const toggleStableBeamOnlyFilter = (stableBeamFilterModel, radioButtonMode = false) => { + const name = 'stableBeamsOnlyRadio'; + const labelOff = 'OFF'; + const labelOn = 'ON'; + if (radioButtonMode) { + return h('.form-group-header.flex-row.w-100', [ + radioButton({ + label: labelOff, + isChecked: !stableBeamFilterModel.isStableBeamsOnly(), + action: () => stableBeamFilterModel.setStableBeamsOnly(false), + name: name, + }), + radioButton({ + label: labelOn, + isChecked: stableBeamFilterModel.isStableBeamsOnly(), + action: () => stableBeamFilterModel.setStableBeamsOnly(true), + name: name, + }), + ]); + } else { + return switchInput(stableBeamFilterModel.isStableBeamsOnly(), (newState) => { + stableBeamFilterModel.setStableBeamsOnly(newState); + }, { labelAfter: 'STABLE BEAM ONLY' }); + } +}; diff --git a/lib/public/components/Filters/LogsFilter/author/AuthorFilterModel.js b/lib/public/components/Filters/LogsFilter/author/AuthorFilterModel.js index f41a4458e2..1b7a133916 100644 --- a/lib/public/components/Filters/LogsFilter/author/AuthorFilterModel.js +++ b/lib/public/components/Filters/LogsFilter/author/AuthorFilterModel.js @@ -11,12 +11,12 @@ * or submit itself to any jurisdiction. */ -import { RawTextFilterModel } from '../../common/filters/RawTextFilterModel.js'; +import { FilterInputModel } from '../../common/filters/FilterInputModel.js'; /** * Model to handle the state of the Author Filter */ -export class AuthorFilterModel extends RawTextFilterModel { +export class AuthorFilterModel extends FilterInputModel { /** * Constructor * @@ -32,7 +32,7 @@ export class AuthorFilterModel extends RawTextFilterModel { * @return {boolean} true if '!Anonymous' is included in the raw filter string, false otherwise. */ isAnonymousExcluded() { - return this._value.includes('!Anonymous'); + return this._raw.includes('!Anonymous'); } /** @@ -42,25 +42,28 @@ export class AuthorFilterModel extends RawTextFilterModel { */ toggleAnonymousFilter() { if (this.isAnonymousExcluded()) { - this._value = this._value.split(',') + this._raw = this._raw.split(',') .filter((author) => author.trim() !== '!Anonymous') .join(','); } else { - this._value += super.isEmpty ? '!Anonymous' : ', !Anonymous'; + this._raw += super.isEmpty ? '!Anonymous' : ', !Anonymous'; } + this._value = this.valueFromRaw(this._raw); this.notify(); } /** - * Reset the filter to its default value and notify the observers if the reset changed anything. + * Reset the filter to its default value and notify the observers. * * @return {void} */ clear() { - if (!this.isEmpty) { - super.reset(); - this.notify(); + if (this.isEmpty) { + return; } + + super.reset(); + this.notify(); } } diff --git a/lib/public/components/Filters/LogsFilter/author/authorFilter.js b/lib/public/components/Filters/LogsFilter/author/authorFilter.js index f40d2c160d..d5fe5a7a45 100644 --- a/lib/public/components/Filters/LogsFilter/author/authorFilter.js +++ b/lib/public/components/Filters/LogsFilter/author/authorFilter.js @@ -14,7 +14,19 @@ import { h } from '/js/src/index.js'; import { iconX } from '/js/src/icons.js'; import { switchInput } from '../../../common/form/switchInput.js'; -import { rawTextFilter } from '../../common/filters/rawTextFilter.js'; + +/** + * Returns a text input field that can be used to filter logs by author + * + * @param {AuthorFilterModel} authorFilterModel The author filter model object + * @returns {Component} A text box that allows the user to enter an author substring to match against all logs + */ +const authorFilterTextInput = (authorFilterModel) => h('input.w-40', { + type: 'text', + id: 'authorFilterText', + value: authorFilterModel.raw, + oninput: (e) => authorFilterModel.update(e.target.value), +}); /** * Returns a button that can be used to reset the author filter. @@ -22,8 +34,11 @@ import { rawTextFilter } from '../../common/filters/rawTextFilter.js'; * @param {AuthorFilterModel} authorFilterModel The author filter model object * @return {Component} A button that can be used to reset the author filter */ -const resetAuthorFilterButton = (authorFilterModel) => - h('.btn.btn-pill.f7', { disabled: authorFilterModel.isEmpty, onclick: () => authorFilterModel.clear() }, iconX()); +const resetAuthorFilterButton = (authorFilterModel) => h( + '.btn.btn-pill.f7', + { disabled: authorFilterModel.isEmpty, onclick: () => authorFilterModel.clear() }, + iconX(), +); /** * Returns a toggle that can be used to exclude anonymous authors @@ -40,11 +55,11 @@ export const excludeAnonymousLogAuthorToggle = (authorFilterModel) => switchInpu /** * Returns a authorFilter component with text input, reset button, and anonymous exclusion button. * - * @param {AuthorFilterModel} authorFilterModel the authorFilterModel - * @return {Component} the author filter component + * @param {LogModel} logModel the log model object + * @returns {Component} the author filter component */ -export const authorFilter = (authorFilterModel) => h('.flex-row.items-center.g3', [ - rawTextFilter(authorFilterModel, { classes: ['w-50'], id: 'authorFilterText', value: authorFilterModel.raw, placeholder: 'e.g. John Doe' }), - resetAuthorFilterButton(authorFilterModel), - excludeAnonymousLogAuthorToggle(authorFilterModel), +export const authorFilter = ({ authorFilter }) => h('.flex-row.items-center.g3', [ + authorFilterTextInput(authorFilter), + resetAuthorFilterButton(authorFilter), + excludeAnonymousLogAuthorToggle(authorFilter), ]); diff --git a/lib/public/components/Filters/LogsFilter/created.js b/lib/public/components/Filters/LogsFilter/created.js new file mode 100644 index 0000000000..3a86526c85 --- /dev/null +++ b/lib/public/components/Filters/LogsFilter/created.js @@ -0,0 +1,53 @@ +/** + * @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 { h } from '/js/src/index.js'; + +const DATE_FORMAT = 'YYYY-MM-DD'; + +let today = new Date(); +today.setMinutes(today.getMinutes() - today.getTimezoneOffset()); +[today] = today.toISOString().split('T'); + +/** + * Returns the creation date filter components + * @param {LogModel} logModel the log model object + * @return {vnode} Two date selection boxes to control the minimum and maximum creation dates for the log filters + */ +const createdFilter = (logModel) => { + const createdFrom = logModel.getCreatedFilterFrom(); + const createdTo = logModel.getCreatedFilterTo(); + return h('', [ + h('.f6', 'From:'), + h('input.w-75.mv1', { + type: 'date', + id: 'createdFilterFrom', + placeholder: DATE_FORMAT, + max: createdTo || today, + value: createdFrom, + oninput: (e) => logModel.setCreatedFilter('From', e.target.value, e.target.validity.valid), + }, ''), + h('.f6', 'To:'), + h('input.w-75.mv1', { + type: 'date', + id: 'createdFilterTo', + placeholder: DATE_FORMAT, + min: createdFrom, + max: today, + value: createdTo, + oninput: (e) => logModel.setCreatedFilter('To', e.target.value, e.target.validity.valid), + }, ''), + ]); +}; + +export default createdFilter; diff --git a/lib/public/components/Filters/LogsFilter/environments.js b/lib/public/components/Filters/LogsFilter/environments.js new file mode 100644 index 0000000000..665ae9eb44 --- /dev/null +++ b/lib/public/components/Filters/LogsFilter/environments.js @@ -0,0 +1,28 @@ +/** + * @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 { h } from '/js/src/index.js'; + +/** + * Returns a filter component to filter on environment Ids, either a coma separated list of specific ids or a substring + * search + * @param {LogsOverviewModel} logModel The global model object + * @return {vnode} A text box that allows the user to enter an environment substring to match against all runs or a + * list of environment ids + */ +export const environmentFilter = (logModel) => h('input.w-75.mt1', { + type: 'text', + value: logModel.getEnvFilterRaw(), + placeholder: 'e.g. Dxi029djX, TDI59So3d...', + oninput: (e) => logModel.setEnvFilter(e.target.value), +}, ''); diff --git a/lib/public/components/Filters/LogsFilter/runs.js b/lib/public/components/Filters/LogsFilter/runs.js new file mode 100644 index 0000000000..659d04a401 --- /dev/null +++ b/lib/public/components/Filters/LogsFilter/runs.js @@ -0,0 +1,28 @@ +/** + * @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 { h } from '/js/src/index.js'; + +/** + * Returns the runs filter component + * @param {LogModel} logsModel the log model object + * @return {vnode} A text box that allows the user to enter runNumbers to filter the logs + */ +const runsFilter = (logsModel) => h('input.w-75.mt1', { + type: 'text', + id: 'runsFilterText', + value: logsModel.getRunsFilterRaw(), + oninput: (e) => logsModel.setRunsFilter(e.target.value), +}, ''); + +export default runsFilter; diff --git a/lib/public/components/Filters/RunsFilter/BeamModeFilterModel.js b/lib/public/components/Filters/RunsFilter/BeamModeFilterModel.js index 626644ae88..0704fc684d 100644 --- a/lib/public/components/Filters/RunsFilter/BeamModeFilterModel.js +++ b/lib/public/components/Filters/RunsFilter/BeamModeFilterModel.js @@ -12,17 +12,50 @@ */ import { ObservableBasedSelectionDropdownModel } from '../../detector/ObservableBasedSelectionDropdownModel.js'; +import { FilterModel } from '../common/FilterModel.js'; /** * Beam mode filter model */ -export class BeamModeFilterModel extends ObservableBasedSelectionDropdownModel { +export class BeamModeFilterModel extends FilterModel { /** * Constructor * * @param {ObservableData>} beamModes$ observable remote data of objects representing beam modes */ constructor(beamModes$) { - super(beamModes$, ({ name }) => ({ value: name })); + super(); + this._selectionDropdownModel = new ObservableBasedSelectionDropdownModel(beamModes$, ({ name }) => ({ value: name })); + this._addSubmodel(this._selectionDropdownModel); + } + + /** + * @inheritDoc + */ + reset() { + this._selectionDropdownModel.reset(); + } + + /** + * @inheritDoc + */ + get isEmpty() { + return this._selectionDropdownModel.isEmpty; + } + + /** + * Return the underlying dropdown model + * + * @return {ObservableDropDownModel} the underlying dropdown model + */ + get selectionDropdownModel() { + return this._selectionDropdownModel; + } + + /** + * @inheritDoc + */ + get normalized() { + return this._selectionDropdownModel.selected; } } diff --git a/lib/public/components/Filters/RunsFilter/DetectorsFilterModel.js b/lib/public/components/Filters/RunsFilter/DetectorsFilterModel.js index 7d75c417c8..432ecc58df 100644 --- a/lib/public/components/Filters/RunsFilter/DetectorsFilterModel.js +++ b/lib/public/components/Filters/RunsFilter/DetectorsFilterModel.js @@ -62,19 +62,11 @@ export class DetectorsFilterModel extends FilterModel { operator: this._combinationOperatorModel.current, }; if (!this.isNone()) { - normalized.values = this._dropdownModel.normalized; + normalized.values = this._dropdownModel.selected.join(); } return normalized; } - /** - * @inheritDoc - */ - set normalized({ operator, values }) { - this._combinationOperatorModel.normalized = operator; - this._dropdownModel.normalized = values; - } - /** * Return true if the current combination operator is none * diff --git a/lib/public/components/Filters/RunsFilter/EorReasonFilterModel.js b/lib/public/components/Filters/RunsFilter/EorReasonFilterModel.js index b3b1e649bf..f57c810cce 100644 --- a/lib/public/components/Filters/RunsFilter/EorReasonFilterModel.js +++ b/lib/public/components/Filters/RunsFilter/EorReasonFilterModel.js @@ -66,15 +66,6 @@ export class EorReasonFilterModel extends FilterModel { return ret; } - /** - * @inheritDoc - */ - set normalized({ category, title, description }) { - this._category = category; - this._title = title; - this._description = description; - } - /** * Returns the EOR reason filter category * diff --git a/lib/public/components/Filters/RunsFilter/GaqFilterModel.js b/lib/public/components/Filters/RunsFilter/GaqFilterModel.js deleted file mode 100644 index fe80fb8745..0000000000 --- a/lib/public/components/Filters/RunsFilter/GaqFilterModel.js +++ /dev/null @@ -1,97 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { FilterModel } from '../common/FilterModel.js'; -import { NumericalComparisonFilterModel } from '../common/filters/NumericalComparisonFilterModel.js'; - -/** - * FilterModel that filters by the fraction of gaq that was not bad - */ -export class GaqFilterModel extends FilterModel { - /** - * Constructor - * @param {ToggleFilterModel} mcReproducibleAsNotBad model that determines if a 'not bad' status was reproduceable for a Monte Carlo. - * This param is required as multiple other filters models need to make use of the same ToggleFilterModel instance - */ - constructor(mcReproducibleAsNotBad) { - super(); - - this._notBadFraction = new NumericalComparisonFilterModel({ scale: 0.01, integer: false }); - this._addSubmodel(this._notBadFraction); - this._mcReproducibleAsNotBad = mcReproducibleAsNotBad; - - /** - * _mcReproducableAsNotBad will only be added to the normalize call notBadFraction is not empty - * So, notifying when it is empty will just send an unneeded request. - */ - this._mcReproducibleAsNotBad.visualChange$.bubbleTo(this._visualChange$); - this._mcReproducibleAsNotBad.observe(() => { - if (!this.notBadFraction.isEmpty) { - this.notify(); - } - }); - } - - /** - * @inheritDoc - */ - reset() { - this._notBadFraction.reset(); - } - - /** - * @inheritDoc - */ - get isEmpty() { - return this._notBadFraction.isEmpty; - } - - /** - * @inheritDoc - */ - get normalized() { - const normalized = { notBadFraction: this._notBadFraction.normalized }; - - if (!this.isEmpty) { - normalized.mcReproducibleAsNotBad = this._mcReproducibleAsNotBad.isToggled; - } - - return normalized; - } - - /** - * @inheritDoc - */ - set normalized({ notBadFraction, mcReproducibleAsNotBad }) { - this._notBadFraction.normalized = notBadFraction; - this._mcReproducibleAsNotBad.normalized = mcReproducibleAsNotBad; - } - - /** - * Return the underlying notBadFraction model - * - * @return {NumericalComparisonFilterModel} the filter model - */ - get notBadFraction() { - return this._notBadFraction; - } - - /** - * Return the underlying mcReproducibleAsNotBad model - * - * @return {ToggleFilterModel} the filter model - */ - get mcReproducibleAsNotBad() { - return this._mcReproducibleAsNotBad; - } -} diff --git a/lib/public/components/Filters/RunsFilter/MagnetsFilteringModel.js b/lib/public/components/Filters/RunsFilter/MagnetsFilteringModel.js index 9e38dfbbf3..015f991286 100644 --- a/lib/public/components/Filters/RunsFilter/MagnetsFilteringModel.js +++ b/lib/public/components/Filters/RunsFilter/MagnetsFilteringModel.js @@ -11,31 +11,21 @@ * or submit itself to any jurisdiction. */ +import { FilterModel } from '../common/FilterModel.js'; import { ObservableBasedSelectionDropdownModel } from '../../detector/ObservableBasedSelectionDropdownModel.js'; /** * Return the option value corresponding to a given magnets current level * * @param {MagnetsCurrentLevels} currentLevels the current levels - * @return {object} the option's value + * @return {string} the option's value */ -const magnetsCurrentLevelsToKey = ({ l3, dipole }) => ({ value: `${l3}kA/${dipole}kA` }); - -/** - * Return the magnets current lever based on a key string - * - * @param {object} value string containing the current levels - * @return {MagnetsCurrentLevels} - */ -const keyToMagnetsCurrentLevels = (value) => { - const [l3, dipole] = value.split('/').map((str) => parseFloat(str.slice(0, -2))); - return { l3, dipole }; -}; +const magnetsCurrentLevelsToOptionValue = ({ l3, dipole }) => `${l3}kA/${dipole}kA`; /** * AliceL3AndDipoleFilteringModel */ -export class MagnetsFilteringModel extends ObservableBasedSelectionDropdownModel { +export class MagnetsFilteringModel extends FilterModel { /** * Constructor * @@ -43,31 +33,64 @@ export class MagnetsFilteringModel extends ObservableBasedSelectionDropdownModel * levels */ constructor(magnetsCurrentLevels$) { - super(magnetsCurrentLevels$, magnetsCurrentLevelsToKey, { multiple: false }); + super(); + this._selectionDropdownModel = new ObservableBasedSelectionDropdownModel( + magnetsCurrentLevels$, + (magnetsCurrentLevels) => ({ value: magnetsCurrentLevelsToOptionValue(magnetsCurrentLevels) }), + { multiple: false }, + ); + this._addSubmodel(this._selectionDropdownModel); + + this._valueToFilteringParamsMap = new Map(); + magnetsCurrentLevels$.observe(() => { + magnetsCurrentLevels$.getCurrent().match({ + + /** + * Fill map indexing current level by their corresponding value + * + * @param {MagnetsCurrentLevels[]} currentLevels the current levels to map + * @return {void} + */ + Success: (currentLevels) => { + this._valueToFilteringParamsMap = new Map(currentLevels.map(({ l3, dipole }) => [ + magnetsCurrentLevelsToOptionValue({ l3, dipole }), + { l3, dipole }, + ])); + }, + Other: () => { + this._valueToFilteringParamsMap = new Map(); + }, + }); + }); } /** * @inheritDoc */ - get normalized() { - const [selectedOption] = this.selected; + reset() { + this._selectionDropdownModel.reset(); + } - if (selectedOption === undefined) { - return null; - } + /** + * @inheritDoc + */ + get isEmpty() { + return this._selectionDropdownModel.isEmpty; + } - return keyToMagnetsCurrentLevels(selectedOption); + /** + * @inheritDoc + */ + get normalized() { + return this._valueToFilteringParamsMap.get(this._selectionDropdownModel.selected[0]) ?? null; } /** - * Sets selected options based on an object containing l3 and dipole fields. - * Accounts for the options being either RemoteData or an array. + * Return the underlying selection dropdown model * - * @param {MagnetsCurrentLevels} value the magnets current levels - * @param {number} value.l3 the L3 current level in kA - * @param {number} value.dipole the dipole current level in kA + * @return {SelectionDropdownModel} the dropdown model */ - set normalized(value) { - super.normalized = magnetsCurrentLevelsToKey(value).value; + get selectionDropdownModel() { + return this._selectionDropdownModel; } } diff --git a/lib/public/components/Filters/RunsFilter/MultiCompositionFilterModel.js b/lib/public/components/Filters/RunsFilter/MultiCompositionFilterModel.js deleted file mode 100644 index 80aafc8644..0000000000 --- a/lib/public/components/Filters/RunsFilter/MultiCompositionFilterModel.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { FilterModel } from '../common/FilterModel.js'; - -/** - * FilterModel that allows devs to create custom filters from multiple other filters during instantiation, or using putFilter - */ -export class MultiCompositionFilterModel extends FilterModel { - /** - * Constructor - * @param {Object} filters the filters that will make up the composite filter - */ - constructor(filters = {}) { - super(); - - /** - * @type {Object} - */ - this._filters = {}; - - Object.entries(filters).forEach(([key, filter]) => this.putFilter(key, filter)); - } - - /** - * Return a subfilter by key - * - * @param {string} key the key of the subfilter - * @return {FilterModel} the subfilter - */ - putFilter(key, filterModel) { - if (key in this._filters) { - return; - } - - this._filters[key] = filterModel; - this._addSubmodel(filterModel); - } - - /** - * Add new subfilter - * - * @param {string} key key of the subfilter - * @param {FilterModel} filter the the subfilter - */ - getFilter(key) { - if (!(key in this._filters)) { - throw new Error(`No filter found with key ${key}`); - } - - return this._filters[key]; - } - - /** - * @inheritDoc - */ - reset() { - Object.values(this._filters).forEach((filter) => filter.reset()); - } - - /** - * @inheritDoc - */ - get isEmpty() { - return Object.values(this._filters).every((filter) => filter.isEmpty); - } - - /** - * @inheritDoc - */ - get isInactive() { - return Object.values(this._filters).every((filter) => filter.isInactive); - } - - /** - * @inheritDoc - */ - get normalized() { - const normalized = {}; - - for (const [id, filter] of Object.entries(this._filters)) { - if (!filter.isEmpty) { - normalized[id] = filter.normalized; - } - } - - return normalized; - } - - /** - * @inheritDoc - */ - set normalized(filters) { - for (const [key, value] of Object.entries(filters)) { - if (key in this._filters) { - this._filters[key].normalized = value; - } - } - } -} diff --git a/lib/public/components/Filters/RunsFilter/RunDefinitionFilterModel.js b/lib/public/components/Filters/RunsFilter/RunDefinitionFilterModel.js index ac41defd53..8fb9347735 100644 --- a/lib/public/components/Filters/RunsFilter/RunDefinitionFilterModel.js +++ b/lib/public/components/Filters/RunsFilter/RunDefinitionFilterModel.js @@ -1,10 +1,10 @@ import { RUN_DEFINITIONS, RunDefinition } from '../../../domain/enums/RunDefinition.js'; -import { SelectionModel } from '../../common/selection/SelectionModel.js'; +import { SelectionFilterModel } from '../common/filters/SelectionFilterModel.js'; /** * Run definition filter model */ -export class RunDefinitionFilterModel extends SelectionModel { +export class RunDefinitionFilterModel extends SelectionFilterModel { /** * Constructor */ @@ -18,7 +18,7 @@ export class RunDefinitionFilterModel extends SelectionModel { * @return {boolean} true if filter is physics only */ isPhysicsOnly() { - const selectedOptions = this.selected; + const selectedOptions = this._selectionModel.selected; return selectedOptions.length === 1 && selectedOptions[0] === RunDefinition.Physics; } @@ -29,8 +29,9 @@ export class RunDefinitionFilterModel extends SelectionModel { */ setPhysicsOnly() { if (!this.isPhysicsOnly()) { - this.selectedOptions = []; - this.select(RunDefinition.Physics); + this._selectionModel.selectedOptions = []; + this._selectionModel.select(RunDefinition.Physics); + this.notify(); } } diff --git a/lib/public/components/Filters/RunsFilter/TimeRangeFilter.js b/lib/public/components/Filters/RunsFilter/TimeRangeFilter.js index 296e4f4753..e765137afa 100644 --- a/lib/public/components/Filters/RunsFilter/TimeRangeFilter.js +++ b/lib/public/components/Filters/RunsFilter/TimeRangeFilter.js @@ -45,13 +45,6 @@ export class TimeRangeFilterModel extends FilterModel { return normalized; } - /** - * @inheritDoc - */ - set normalized({ from, to }) { - this._timeRangeInputModel.normalized = { from, to }; - } - /** * Return the underlying time range input model * diff --git a/lib/public/components/Filters/RunsFilter/dcs.js b/lib/public/components/Filters/RunsFilter/dcs.js new file mode 100644 index 0000000000..590eb81b78 --- /dev/null +++ b/lib/public/components/Filters/RunsFilter/dcs.js @@ -0,0 +1,50 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { radioButton } from '../../common/form/inputs/radioButton.js'; +import { h } from '/js/src/index.js'; + +/** + * Filter panel for DCS toggle; ON/OFF/ANY + * @param {RunsOverviewModel} runModel the run model object + * @return {vnode} Three radio buttons inline + */ +const dcsOperationRadioButtons = (runModel) => { + const state = runModel.getDcsFilterOperation(); + const name = 'dcsFilterRadio'; + const labelAny = 'ANY'; + const labelOff = 'OFF'; + const labelOn = 'ON'; + return h('.form-group-header.flex-row.w-100', [ + radioButton({ + label: labelAny, + isChecked: state === '', + action: () => runModel.removeDcs(), + name, + }), + radioButton({ + label: labelOff, + isChecked: state === false, + action: () => runModel.setDcsFilterOperation(false), + name, + }), + radioButton({ + label: labelOn, + isChecked: state === true, + action: () => runModel.setDcsFilterOperation(true), + name, + }), + ]); +}; + +export default dcsOperationRadioButtons; diff --git a/lib/public/components/Filters/RunsFilter/ddflp.js b/lib/public/components/Filters/RunsFilter/ddflp.js new file mode 100644 index 0000000000..74bf28f4ba --- /dev/null +++ b/lib/public/components/Filters/RunsFilter/ddflp.js @@ -0,0 +1,50 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { radioButton } from '../../common/form/inputs/radioButton.js'; +import { h } from '/js/src/index.js'; + +/** + * Filter panel for Data Distribution toggle; ON/OFF/ANY + * @param {RunsOverviewModel} runModel the run model object + * @return {vnode} Three radio buttons inline + */ +const ddflpOperationRadioButtons = (runModel) => { + const state = runModel.getDdflpFilterOperation(); + const name = 'ddFlpFilterRadio'; + const labelAny = 'ANY'; + const labelOff = 'OFF'; + const labelOn = 'ON'; + return h('.form-group-header.flex-row.w-100', [ + radioButton({ + label: labelAny, + isChecked: state === '', + action: () => runModel.removeDdflp(), + name, + }), + radioButton({ + label: labelOff, + isChecked: state === false, + action: () => runModel.setDdflpFilterOperation(false), + name, + }), + radioButton({ + label: labelOn, + isChecked: state === true, + action: () => runModel.setDdflpFilterOperation(true), + name, + }), + ]); +}; + +export default ddflpOperationRadioButtons; diff --git a/lib/public/components/Filters/RunsFilter/epn.js b/lib/public/components/Filters/RunsFilter/epn.js new file mode 100644 index 0000000000..5e639d8afb --- /dev/null +++ b/lib/public/components/Filters/RunsFilter/epn.js @@ -0,0 +1,50 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { radioButton } from '../../common/form/inputs/radioButton.js'; +import { h } from '/js/src/index.js'; + +/** + * Filter panel for EPN toggle; ON/OFF/ANY + * @param {RunsOverviewModel} runModel the run model object + * @return {vnode} Three radio buttons inline + */ +const epnOperationRadioButtons = (runModel) => { + const state = runModel.getEpnFilterOperation(); + const name = 'epnFilterRadio'; + const labelAny = 'ANY'; + const labelOff = 'OFF'; + const labelOn = 'ON'; + return h('.form-group-header.flex-row.w-100', [ + radioButton({ + label: labelAny, + isChecked: state === '', + action: () => runModel.removeEpn(), + name, + }), + radioButton({ + label: labelOff, + isChecked: state === false, + action: () => runModel.setEpnFilterOperation(false), + name, + }), + radioButton({ + label: labelOn, + isChecked: state === true, + action: () => runModel.setEpnFilterOperation(true), + name, + }), + ]); +}; + +export default epnOperationRadioButtons; diff --git a/lib/public/components/Filters/RunsFilter/runDefinitionFilter.js b/lib/public/components/Filters/RunsFilter/runDefinitionFilter.js index 2a799ff675..d53ba62428 100644 --- a/lib/public/components/Filters/RunsFilter/runDefinitionFilter.js +++ b/lib/public/components/Filters/RunsFilter/runDefinitionFilter.js @@ -19,4 +19,7 @@ import { checkboxes } from '../common/filters/checkboxFilter.js'; * @param {RunDefinitionFilterModel} runDefinitionFilterModel run definition filter model * @return {Component} the filter */ -export const runDefinitionFilter = (runDefinitionFilterModel) => checkboxes(runDefinitionFilterModel, { selector: 'run-definition' }); +export const runDefinitionFilter = (runDefinitionFilterModel) => checkboxes( + runDefinitionFilterModel.selectionModel, + { selector: 'run-definition' }, +); diff --git a/lib/public/components/Filters/RunsFilter/runNumbersFilter.js b/lib/public/components/Filters/RunsFilter/runNumbersFilter.js new file mode 100644 index 0000000000..1beeadee0a --- /dev/null +++ b/lib/public/components/Filters/RunsFilter/runNumbersFilter.js @@ -0,0 +1,25 @@ +/** + * @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 { rawTextFilter } from '../common/filters/rawTextFilter.js'; + +/** + * Component to filter runs on run number + * + * @param {RawTextFilterModel} filterModel the filter model + * @return {Component} the filter + */ +export const runNumbersFilter = (filterModel) => rawTextFilter( + filterModel, + { classes: ['w-100', 'run-numbers-filter'], placeholder: 'e.g. 534454, 534455...' }, +); diff --git a/lib/public/components/Filters/RunsFilter/triggerValueFilter.js b/lib/public/components/Filters/RunsFilter/triggerValueFilter.js new file mode 100644 index 0000000000..5addab02fe --- /dev/null +++ b/lib/public/components/Filters/RunsFilter/triggerValueFilter.js @@ -0,0 +1,21 @@ +import { checkboxFilter } from '../common/filters/checkboxFilter.js'; +import { TRIGGER_VALUES } from '../../../domain/enums/TriggerValue.js'; + +/** + * Returns a panel to be used by user to filter runs by trigger value + * @param {RunsOverviewModel} runModel The global model object + * @return {vnode} Multiple checkboxes for a user to select the values to be filtered. + */ +export const triggerValueFilter = (runModel) => checkboxFilter( + 'triggerValue', + TRIGGER_VALUES, + (value) => runModel.triggerValuesFilters.has(value), + (e, value) => { + if (e.target.checked) { + runModel.triggerValuesFilters.add(value); + } else { + runModel.triggerValuesFilters.delete(value); + } + runModel.triggerValuesFilters = Array.from(runModel.triggerValuesFilters); + }, +); diff --git a/lib/public/components/Filters/common/FilterModel.js b/lib/public/components/Filters/common/FilterModel.js index d16f1226f7..cc7badb53c 100644 --- a/lib/public/components/Filters/common/FilterModel.js +++ b/lib/public/components/Filters/common/FilterModel.js @@ -57,17 +57,6 @@ export class FilterModel extends Observable { throw new Error('Abstract function call'); } - /** - * Sets filters from normalised values to submodels in needed. - * - * @param {string|number|object|string[]|number[]|null} _value The value used to set filters - * @return {void} the normalized value - * @abstract - */ - set normalized(_value) { - throw new Error('Abstract function call'); - } - /** * Returns the observable notified any time there is a visual change which has no impact on the actual filter value * @@ -77,15 +66,6 @@ export class FilterModel extends Observable { return this._visualChange$; } - /** - * States if the filter is active. By default this is equivalent to isEmpty - * - * @return {boolean} true if the filter is active - */ - get isInactive() { - return this.isEmpty; - } - /** * Utility function to register a filter model as sub-filter model * diff --git a/lib/public/components/Filters/common/FilteringModel.js b/lib/public/components/Filters/common/FilteringModel.js index 2f196d4f7c..e937786456 100644 --- a/lib/public/components/Filters/common/FilteringModel.js +++ b/lib/public/components/Filters/common/FilteringModel.js @@ -12,16 +12,7 @@ */ import { expandQueryLikeNestedKey } from '../../../utilities/expandNestedKey.js'; -import { SelectionModel } from '../../common/selection/SelectionModel.js'; -import { FilterModel } from './FilterModel.js'; -import { buildUrl, Observable, parseUrlParameters } from '/js/src/index.js'; - -const WARNING_TYPES = Object.freeze({ - PAGE_MISMATCH: 'Page-Filter mismatch', - UNKNOWN_FILTERS: 'Unknown Filters', - UNPARSABLE_URL: 'Unparseable URL', - UNPARSABLE_FILTERS: 'Unparsable Filters', -}); +import { Observable } from '/js/src/index.js'; /** * Model representing a filtering system, including filter inputs visibility, filters values and so on @@ -30,45 +21,28 @@ export class FilteringModel extends Observable { /** * Constructor * - * @param {QueryRouter} router router that controls the application's page navigation * @param {Object} filters the filters with their label and model - * @param {Map} warnings object reference used to define warnings. */ - constructor(router, filters, warnings) { + constructor(filters) { super(); - this._visualChange$ = new Observable(); - this._pageIdentifier = null; - this._warnings = warnings; - this._router = router; - this._filters = {}; - this._filterModels = []; - Object.entries(filters).forEach(([key, model]) => this.put(key, model)); - } + this._visualChange$ = new Observable(); - /** - * Sets the page identifiers - * - * @param {string} identifier a string identifies a page from the router params. - * Used to prevent unneeded reads/writes from/to the url - * @returns {void} - */ - set pageIdentifier(identifier) { - this._pageIdentifier = identifier; + this._filters = filters; + this._filterModels = Object.values(filters); + for (const model of this._filterModels) { + model.bubbleTo(this); + model.visualChange$?.bubbleTo(this._visualChange$); + } } /** * Reset the filters * * @param {boolean} [notify=false] if true the model notifies its observers - * @param {boolean} [clearUrl=false] if true filters will be removed from the url * @return {void} */ - reset(notify = false, clearUrl = false) { - if (!this.isAnyFilterActive()) { - return; - } - + reset(notify = false) { for (const model of this._filterModels) { model.reset(); } @@ -76,13 +50,6 @@ export class FilteringModel extends Observable { if (notify) { this.notify(); } - - if (clearUrl) { - this._clearWarnings(); - const { params } = this._router; - params.filter = this.normalized; - this._router.go(buildUrl('?', params), false, true); - } } /** @@ -107,7 +74,12 @@ export class FilteringModel extends Observable { * @return {boolean} true if at least one filter is active */ isAnyFilterActive() { - return !this._filterModels.every((model) => model.isInactive); + for (const model of this._filterModels) { + if (!model.isEmpty) { + return true; + } + } + return false; } /** @@ -133,123 +105,6 @@ export class FilteringModel extends Observable { return this._filters[key]; } - /** - * When the user updates the displayed Objects, the filters should be placed in the URL as well - * @returns {undefined} - */ - setFilterToURL() { - const { params } = this._router; - const newParams = { ...params }; - newParams.filter = this.normalized; - - if (this._pageIdentifier === params.page) { - this._router.go(buildUrl('?', newParams), false, true); - } - - this.notify(); - } - - /** - * Compute seach parameters based a url or router - * - * @param {string} url the url that is to be parsed - * @returns {object} the serach parameters object - */ - _computeParameters(url) { - try { - return parseUrlParameters(new URL(url).searchParams); - } catch { - this._warnings.set(WARNING_TYPES.UNPARSABLE_URL, `URL could not be parsed. URL: ${url}`); - this.notify(); - return {}; - } - } - - /** - * Look for parameters used for filtering in URL and apply them in the layout if it exists - * - * @param {boolean} notify if observers should be notified after setting the filters - * @param {string|null} [url=null] the url that is to be parsed into active filters - * @returns {undefined} - */ - setFilterFromURL(notify = false, url = null) { - this._clearWarnings(); - - const params = url ? this._computeParameters(url) : this._router.params; - const { page, filter } = params; - - if (this._pageIdentifier !== page) { - if (url && page) { // 'page' might be undefined if the url is unparsable - this._warnings.set(WARNING_TYPES.PAGE_MISMATCH, `The filters provided were meant for ${page}`); - } - } else { - if (!filter) { - this.reset(); - return; - } - - const { setFilterErrors, unknownFilters } = this._setFilters(filter); - - if (setFilterErrors.length > 0) { - this._warnings.set( - WARNING_TYPES.UNPARSABLE_FILTERS, - `The following filter-value pairs could not be parsed: [${setFilterErrors.join(', ')}]`, - ); - } - - if (unknownFilters.length > 0) { - this._warnings.set( - WARNING_TYPES.UNKNOWN_FILTERS, - `The filters: [${unknownFilters.join(', ')}]; are not reccognised. Check if they are spelled correctly.`, - ); - } - - if (url) { - this._router.go(buildUrl('?', params), false, true); - } - } - - if (notify) { - this.notify(); - } - } - - /** - * Clear all filter-related warnings from the warnings map - * - * @returns {undefined} - */ - _clearWarnings() { - for (const key in Object.keys(WARNING_TYPES)) { - this._warnings.delete(key); - } - } - - /** - * Sets all filters using their normalized setters - * - * @param {object} an object containging the uknown filters and the filters that failed to parse - */ - _setFilters(filters) { - const unknownFilters = []; - const setFilterErrors = []; - - for (const [key, value] of Object.entries(filters)) { - if (key in this._filters) { - try { - this._filters[key].normalized = value; - } catch { - setFilterErrors.push(`${buildUrl('', { [key]: value }).slice(1)}`); - } - } else { - unknownFilters.push(`'${key}'`); - } - } - - return { unknownFilters, setFilterErrors }; - } - /** * Add new filter * @@ -263,13 +118,9 @@ export class FilteringModel extends Observable { return; } - if (!(filter instanceof FilterModel || filter instanceof SelectionModel)) { - throw new Error('Filter must extend FilterModel or SelectionModel'); - } - this._filters[key] = filter; this._filterModels.push(filter); - filter.observe(() => this.setFilterToURL()); + filter.bubbleTo(this); filter.visualChange$?.bubbleTo(this._visualChange$); } } diff --git a/lib/public/components/Filters/common/RadioButtonFilterModel.js b/lib/public/components/Filters/common/RadioButtonFilterModel.js deleted file mode 100644 index 0aaa6e70af..0000000000 --- a/lib/public/components/Filters/common/RadioButtonFilterModel.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @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 { SelectionModel } from '../../common/selection/SelectionModel.js'; - -/** - * Model for managing a radiobutton view and state - */ -export class RadioButtonFilterModel extends SelectionModel { - /** - * Constructor - * - * @param {SelectionOption[]} [availableOptions] the list of possible operators - * @param {function} [setDefault] function that selects the default from the list of available options. Selects first entry by default - * @param {boolean} [defaultIsEmpty] if true, the default selection will be treated as empty - */ - constructor(availableOptions, setDefault = (options) => [options[0]], defaultIsEmpty = true) { - super({ - availableOptions, - defaultSelection: setDefault(availableOptions), - multiple: false, - allowEmpty: false, - }); - - this._defaultIsEmpty = defaultIsEmpty; - } - - /** - * @inheritdoc - */ - get isEmpty() { - if (this._defaultIsEmpty) { - return this.hasOnlyDefaultSelection(); - } - - return false; - } -} diff --git a/lib/public/components/Filters/common/TagFilterModel.js b/lib/public/components/Filters/common/TagFilterModel.js index e92d129eed..c3ce81e09f 100644 --- a/lib/public/components/Filters/common/TagFilterModel.js +++ b/lib/public/components/Filters/common/TagFilterModel.js @@ -58,19 +58,11 @@ export class TagFilterModel extends FilterModel { */ get normalized() { return { - values: this._selectionModel.normalized, - operation: this._combinationOperatorModel.normalized, + values: this.selected.join(), + operation: this.combinationOperator, }; } - /** - * @inheritDoc - */ - set normalized({ values, operation }) { - this._selectionModel.normalized = values; - this._combinationOperatorModel.normalized = operation; - } - /** * Return the model handling tag selection state * diff --git a/lib/public/components/Filters/common/filters/FilterInputModel.js b/lib/public/components/Filters/common/filters/FilterInputModel.js new file mode 100644 index 0000000000..8860edf61d --- /dev/null +++ b/lib/public/components/Filters/common/filters/FilterInputModel.js @@ -0,0 +1,119 @@ +/** + * @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 { Observable } from '/js/src/index.js'; + +/** + * Model for a generic filter input + */ +export class FilterInputModel extends Observable { + /** + * Constructor + */ + constructor() { + super(); + + this._value = null; + this._raw = ''; + + this._visualChange$ = new Observable(); + } + + /** + * Define the current value of the filter + * + * @param {string} raw the raw value of the filter + * @return {void} + */ + update(raw) { + const previousValues = this.value; + + this._value = this.valueFromRaw(raw); + this._raw = raw; + + if (this.areValuesEquals(this.value, previousValues)) { + // Only raw value changed + this._visualChange$.notify(); + } else { + this.notify(); + } + } + + /** + * Reset the filter to its default value + * + * @return {void} + */ + reset() { + this._value = null; + this._raw = ''; + } + + /** + * Returns the raw value of the filter (the user input) + * + * @return {string} the raw value + */ + get raw() { + return this._raw; + } + + /** + * Return the parsed values of the filter + * + * @return {*} the parsed values + */ + get value() { + return this._value; + } + + /** + * States if the filter has been filled + * + * @return {boolean} true if the filter has been filled + */ + get isEmpty() { + return !this.value; + } + + /** + * Returns the observable notified any time there is a visual change which has no impact on the actual filter value + * + * @return {Observable} the observable + */ + get visualChange$() { + return this._visualChange$; + } + + /** + * Returns the processed value from raw input + * + * @param {string} raw the raw input value + * @return {*} the processed value + * @protected + */ + valueFromRaw(raw) { + return raw.trim(); + } + + /** + * Compares two values + * + * @param {*} first the first value + * @param {*} second the second value + * @return {boolean} true if the values are equals + * @protected + */ + areValuesEquals(first, second) { + return first === second; + } +} diff --git a/lib/public/components/Filters/common/filters/NumericalComparisonFilterModel.js b/lib/public/components/Filters/common/filters/NumericalComparisonFilterModel.js index 843500ad1f..ee00126389 100644 --- a/lib/public/components/Filters/common/filters/NumericalComparisonFilterModel.js +++ b/lib/public/components/Filters/common/filters/NumericalComparisonFilterModel.js @@ -27,7 +27,6 @@ export class NumericalComparisonFilterModel extends FilterModel { constructor(options) { super(); const { scale = 1, integer = false } = options || {}; - this._scale = scale; this._operatorSelectionModel = new ComparisonSelectionModel(); this._operatorSelectionModel.visualChange$.bubbleTo(this._visualChange$); @@ -83,25 +82,11 @@ export class NumericalComparisonFilterModel extends FilterModel { */ get normalized() { return { - operator: this._operatorSelectionModel.normalized, - limit: this._operandInputModel.normalized, + operator: this._operatorSelectionModel.current, + limit: this._operandInputModel.value, }; } - /** - * @inheritDoc - */ - set normalized({ operator, limit }) { - const numericLimit = parseFloat(limit); - const scaledLimit = numericLimit / this._scale; - - if (!isNaN(numericLimit) || !isNaN(scaledLimit)) { - this._operandInputModel.normalized = { value: numericLimit, raw: scaledLimit }; - } - - this._operatorSelectionModel.normalized = operator; - } - /** * @inheritDoc */ diff --git a/lib/public/components/Filters/common/filters/ProcessedTextInputModel.js b/lib/public/components/Filters/common/filters/ProcessedTextInputModel.js index d9488cd8f1..9e46fe95b5 100644 --- a/lib/public/components/Filters/common/filters/ProcessedTextInputModel.js +++ b/lib/public/components/Filters/common/filters/ProcessedTextInputModel.js @@ -98,27 +98,6 @@ export class ProcessedTextInputModel extends Observable { this._value = null; } - /** - * Returns the normalized value of the filter, that can be used as URL parameter - * @returns {string} - */ - get normalized() { - return this._value; - } - - /** - * Sets filters from normalised values. - * - * @param {string} value The value used to set the parsed value - * @param {string} raw The value used to set the raw value - * @return {void} - * @abstract - */ - set normalized({ value, raw }) { - this._value = value; - this._raw = raw; - } - /** * Return the visual change observable * diff --git a/lib/public/components/Filters/common/filters/RawTextFilterModel.js b/lib/public/components/Filters/common/filters/RawTextFilterModel.js index d156c86e10..f996b7b976 100644 --- a/lib/public/components/Filters/common/filters/RawTextFilterModel.js +++ b/lib/public/components/Filters/common/filters/RawTextFilterModel.js @@ -35,13 +35,6 @@ export class RawTextFilterModel extends FilterModel { return this._value; } - /** - * @inheritDoc - */ - set normalized(value) { - this._value = value; - } - /** * Return the filter current value * diff --git a/lib/public/components/Filters/common/filters/SelectionFilterModel.js b/lib/public/components/Filters/common/filters/SelectionFilterModel.js new file mode 100644 index 0000000000..4bb602d7aa --- /dev/null +++ b/lib/public/components/Filters/common/filters/SelectionFilterModel.js @@ -0,0 +1,63 @@ +/** + * @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 { FilterModel } from '../FilterModel.js'; +import { SelectionModel } from '../../../common/selection/SelectionModel.js'; + +/** + * Filter model based on a selection model + */ +export class SelectionFilterModel extends FilterModel { + /** + * Constructor + * + * @param {object} [configuration] the selection filter configuration + * @param {SelectionOption[]} [configuration.availableOptions=[]] the list of available options + */ + constructor(configuration) { + super(); + + this._selectionModel = new SelectionModel({ availableOptions: configuration.availableOptions }); + this._selectionModel.bubbleTo(this); + } + + /** + * @inheritDoc + */ + reset() { + this._selectionModel.reset(); + } + + /** + * @inheritDoc + */ + get isEmpty() { + return this._selectionModel.isEmpty; + } + + /** + * @inheritDoc + */ + get normalized() { + return this._selectionModel.selected.join(','); + } + + /** + * Return the underlying selection model + * + * @return {SelectionModel} the underlying selection model + */ + get selectionModel() { + return this._selectionModel; + } +} diff --git a/lib/public/components/Filters/common/filters/TextComparisonFilterModel.js b/lib/public/components/Filters/common/filters/TextComparisonFilterModel.js index 7f843d6295..b6510f8fae 100644 --- a/lib/public/components/Filters/common/filters/TextComparisonFilterModel.js +++ b/lib/public/components/Filters/common/filters/TextComparisonFilterModel.js @@ -64,19 +64,11 @@ export class TextComparisonFilterModel extends FilterModel { */ get normalized() { return { - operator: this._operatorSelectionModel.normalized, - limit: this._operandInputModel.normalized, + operator: this._operatorSelectionModel.current, + limit: this._operandInputModel.value, }; } - /** - * @inheritDoc - */ - set normalized({ operator, limit }) { - this._operatorSelectionModel.normalized = operator; - this._operandInputModel.normalized = limit; - } - /** * @inheritDoc */ diff --git a/lib/public/components/Filters/common/filters/TextTokensFilterModel.js b/lib/public/components/Filters/common/filters/TextTokensFilterModel.js index 8c838e5abf..60e192febe 100644 --- a/lib/public/components/Filters/common/filters/TextTokensFilterModel.js +++ b/lib/public/components/Filters/common/filters/TextTokensFilterModel.js @@ -78,13 +78,6 @@ export class TextTokensFilterModel extends FilterModel { .filter((token) => token.length > 0); } - /** - * @inheritDoc - */ - set normalized(value) { - this._raw = value.join(TOKENS_DELIMITER); - } - /** * Returns the observable notified any time there is a visual change which has no impact on the actual filter value * @return {Observable} the observable diff --git a/lib/public/components/Filters/common/filters/TimeRangeInputModel.js b/lib/public/components/Filters/common/filters/TimeRangeInputModel.js index 66a4481847..54ee3fe7b0 100644 --- a/lib/public/components/Filters/common/filters/TimeRangeInputModel.js +++ b/lib/public/components/Filters/common/filters/TimeRangeInputModel.js @@ -142,14 +142,6 @@ export class TimeRangeInputModel extends FilterModel { }; } - /** - * @inheritDoc - */ - set normalized({ from, to }) { - this._fromTimeInputModel.setValue(parseInt(from, 10), true); - this._toTimeInputModel.setValue(parseInt(to, 10), true); - } - /** * States if the filter value is valid * diff --git a/lib/public/components/Filters/common/filters/ToggleFilterModel.js b/lib/public/components/Filters/common/filters/ToggleFilterModel.js deleted file mode 100644 index ee22703852..0000000000 --- a/lib/public/components/Filters/common/filters/ToggleFilterModel.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @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 { SelectionModel } from '../../../common/selection/SelectionModel.js'; - -/** - * SelectionModel that restricts the selection to a boolean toggle (true/false). - */ -export class ToggleFilterModel extends SelectionModel { - /** - * Constructor - * @param {boolean} toggledByDefault If the filter should be toggled by default - * @param {boolean} defaultIsInactive if true, will treat the untoggled state (false) as empty. - */ - constructor(toggledByDefault = false, defaultIsInactive = false) { - super({ - availableOptions: [{ value: true }, { value: false }], - defaultSelection: [{ value: toggledByDefault }], - multiple: false, - allowEmpty: false, - }); - - this._defaultIsInactive = defaultIsInactive; - } - - /** - * Returns true if the current value is set to true - * - * @return {boolean} true if filter is stable beams only - */ - get isToggled() { - return this.current; - } - - /** - * Toggles the filter state - * - * @return {void} - */ - toggle() { - this.select({ value: !this.current }); - } - - /** - * Toggles are always filled, as 'false' / untoggled is also considered a value - * - * @return {boolean} `false` - */ - get isEmpty() { - return false; - } - - /** - * Returns if the toggle filter is considered 'inactive' - * - * @return {boolean} - */ - get isInactive() { - if (this._defaultIsInactive) { - return this.hasOnlyDefaultSelection(); - } - - return false; - } -} diff --git a/lib/public/components/Filters/common/filters/checkboxFilter.js b/lib/public/components/Filters/common/filters/checkboxFilter.js index 2cf550c091..dcfcb4a95b 100644 --- a/lib/public/components/Filters/common/filters/checkboxFilter.js +++ b/lib/public/components/Filters/common/filters/checkboxFilter.js @@ -14,6 +14,32 @@ import { h } from '/js/src/index.js'; +/** + * A general component for generating checkboxes. + * + * @param {string} name The general name of the element. + * @param {Array} values the list of options to display + * @param {function} isChecked true if the checkbox is checked, else false + * @param {function} onChange the handler called once the checkbox state changes (change event is passed as first parameter, value as second) + * @param {Object} [additionalProperties] Additional options that can be given to the class. + * @returns {vnode} An object that has one or multiple checkboxes. + * @deprecated use checkboxes + */ +export const checkboxFilter = (name, values, isChecked, onChange, additionalProperties) => + h('.flex-row.flex-wrap', values.map((value) => h('.form-check.flex-grow', [ + h('input.form-check-input', { + id: `${name}Checkbox${value}`, + class: name, + type: 'checkbox', + checked: isChecked(value), + onchange: (e) => onChange(e, value), + ...additionalProperties || {}, + }), + h('label.form-check-label', { + for: `${name}Checkbox${value}`, + }, value.toUpperCase()), + ]))); + /** * Display a filter composed of checkbox listing pre-defined options * @param {SelectionModel} selectionModel filter model diff --git a/lib/public/components/Filters/common/filters/radioButtonFilter.js b/lib/public/components/Filters/common/filters/radioButtonFilter.js deleted file mode 100644 index 88f42c610a..0000000000 --- a/lib/public/components/Filters/common/filters/radioButtonFilter.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { radioButton } from '../../../common/form/inputs/radioButton.js'; -import { h } from '/js/src/index.js'; - -/** - * Radio button filter component - * - * @param {RadioSelectionModel} selectionModel the a selectionmodel - * @param {string} filterName the name of the filter - * @return {vnode} A number of radio buttons corresponding with the selection options - */ -const radioButtonFilter = (selectionModel, filterName) => { - const name = `${filterName}FilterRadio`; - return h( - '.flex-row.w-100', - selectionModel.options.map((option) => { - const { label } = option; - const action = () => selectionModel.select(option); - const isChecked = selectionModel.isSelected(option); - - return radioButton({ label, isChecked, action, name }); - }), - ); -}; - -export default radioButtonFilter; diff --git a/lib/public/components/Filters/common/filters/textFilter.js b/lib/public/components/Filters/common/filters/textFilter.js index d6ae0cdfa4..6b288d54ac 100644 --- a/lib/public/components/Filters/common/filters/textFilter.js +++ b/lib/public/components/Filters/common/filters/textFilter.js @@ -16,13 +16,13 @@ import { h } from '/js/src/index.js'; /** * Returns a text filter component * - * @param {TextTokensFilterModel} textTokensFilterModel the model of the text filter + * @param {FilterInputModel|TextTokensFilterModel} filterInputModel the model of the text filter * @param {Object} attributes the additional attributes to pass to the component, such as id and classes * @return {Component} the filter component */ -export const textFilter = (textTokensFilterModel, attributes) => h('input', { +export const textFilter = (filterInputModel, attributes) => h('input', { ...attributes, type: 'text', - value: textTokensFilterModel.raw, - oninput: (e) => textTokensFilterModel.update(e.target.value), + value: filterInputModel.raw, + oninput: (e) => filterInputModel.update(e.target.value), }, ''); diff --git a/lib/public/components/Filters/common/filters/textInputFilter.js b/lib/public/components/Filters/common/filters/textInputFilter.js deleted file mode 100644 index 27a36f112d..0000000000 --- a/lib/public/components/Filters/common/filters/textInputFilter.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @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 { rawTextFilter } from './rawTextFilter.js'; - -/** - * Standardised component for a rawTextFilter that span the width of their container - * - * @param {FilteringModel} filteringModel the page's filteringModel - * @param {string} key the identifier to serve as css selector and to fetch the correct filter from the filteringModel - * @param {string} placeholder placeholder text for the input element - * @param {string} width class that determines the width of the input - * @return {Component} the filter - */ -export const textInputFilter = (filteringModel, key, placeholder, widthClass = 'w-100') => - rawTextFilter(filteringModel.get(key), { classes: [widthClass, `${key}-textFilter`], placeholder }); diff --git a/lib/public/components/Filters/common/filters/toggleFilter.js b/lib/public/components/Filters/common/filters/toggleFilter.js deleted file mode 100644 index ac37063779..0000000000 --- a/lib/public/components/Filters/common/filters/toggleFilter.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { h } from '/js/src/index.js'; -import { switchInput } from '../../../common/form/switchInput.js'; -import { radioButton } from '../../../common/form/inputs/radioButton.js'; - -/** - * Display a toggle switch or radio buttons for toggle filters - * - * @param {ToggleFilterModel} toggleFilterModel a ToggleFilterModel - * @param {name} toggleFilterModel the name used to identify and label the filter - * @param {boolean} radioButtonMode define whether or not to return radio buttons or a switch. - * @returns {Component} the toggle switch - */ -export const toggleFilter = (toggleFilterModel, name, id, radioButtonMode = false) => { - if (radioButtonMode) { - return h('.flex-row.w-100', [ - radioButton({ - label: 'OFF', - isChecked: !toggleFilterModel.isToggled, - action: () => toggleFilterModel.toggle(), - name, - }), - radioButton({ - label: 'ON', - isChecked: toggleFilterModel.isToggled, - action: () => toggleFilterModel.toggle(), - name, - }), - ]); - } - - return h('', switchInput(toggleFilterModel.isToggled, () => toggleFilterModel.toggle(), { labelAfter: name, id })); -}; diff --git a/lib/public/components/Filters/common/filtersPanelPopover.js b/lib/public/components/Filters/common/filtersPanelPopover.js index 648427db34..e0c0a7490c 100644 --- a/lib/public/components/Filters/common/filtersPanelPopover.js +++ b/lib/public/components/Filters/common/filtersPanelPopover.js @@ -10,8 +10,7 @@ * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. */ -import { h, info, popover, PopoverAnchors, PopoverTriggerPreConfiguration, DropdownComponent, CopyToClipboardComponent } from '/js/src/index.js'; -import { iconCaretBottom } from '/js/src/icons.js'; +import { h, info, popover, PopoverAnchors, PopoverTriggerPreConfiguration } from '/js/src/index.js'; import { profiles } from '../../common/table/profiles.js'; import { applyProfile } from '../../../utilities/applyProfile.js'; import { tooltip } from '../../common/popover/tooltip.js'; @@ -36,27 +35,7 @@ import { tooltip } from '../../common/popover/tooltip.js'; * * @return {Component} the button component */ -const filtersToggleTrigger = () => h('button#openFilterToggle.btn.btn.btn-primary.first-item', 'Filters'); - -/** - * Button component that resets all filters upon click - * - * @param {FilteringModel|OverviewPageModel} filteringModel the FilteringModel - * @param {bool} [isIcon=false] if the component is rendered as a regular button with text or as a component with an 'X' icon - * @returns {Component} the reset button component - */ -const resetFiltersButton = (filteringModel, isIcon = false) => { - const attributes = { - disabled: !filteringModel.isAnyFilterActive(), - onclick: () => filteringModel.resetFiltering - ? filteringModel.resetFiltering(true, true) - : filteringModel.reset(true, true), - }; - - return isIcon - ? h('.clear-filter-icon-container.btn-group-item.last-item.pulse-red', attributes, h('.clear-filter-icon.b1.b-danger', 'X')) - : h('button#reset-filters.btn.btn-danger', attributes, 'Reset all filters'); -}; +const filtersToggleTrigger = () => h('button#openFilterToggle.btn.btn.btn-primary', 'Filters'); /** * Create main header of the filters panel @@ -65,7 +44,16 @@ const resetFiltersButton = (filteringModel, isIcon = false) => { */ const filtersToggleContentHeader = (filteringModel) => h('.flex-row.justify-between', [ h('.f4', 'Filters'), - resetFiltersButton(filteringModel), + h( + 'button#reset-filters.btn.btn-danger', + { + onclick: () => filteringModel.resetFiltering + ? filteringModel.resetFiltering() + : filteringModel.reset(true), + disabled: !filteringModel.isAnyFilterActive(), + }, + 'Reset all filters', + ), ]); /** @@ -126,9 +114,9 @@ const filtersToggleContent = ( * @param {FiltersConfiguration} filtersConfiguration filters configuration * @param {object} [configuration] optional configuration * @param {string} [configuration.profile] specify for which profile filtering should be enabled - * @return {Component} the filter button component + * @return {Component} the filter component */ -const filtersPanelButton = (filteringModel, filtersConfiguration, configuration) => popover( +export const filtersPanelPopover = (filteringModel, filtersConfiguration, configuration) => popover( filtersToggleTrigger(), filtersToggleContent(filteringModel, filtersConfiguration, configuration), { @@ -136,94 +124,3 @@ const filtersPanelButton = (filteringModel, filtersConfiguration, configuration) anchor: PopoverAnchors.RIGHT_START, }, ); - -/** - * A button component that lets the user copy the url if there are active filters. - * - * @param {boolean} activeFilters if false, will disable the button - * @returns {Component} the copy button component - */ -const copyButtonOption = (activeFilters) => h( - '', - { style: activeFilters ? {} : { opacity: 0.5, pointerEvents: 'none' } }, - h(CopyToClipboardComponent, { value: location.href, id: 'filters' }, 'Copy Active Filters'), -); - -/** - * A button component that lets the user paste the first entry of their clipboard as a filter url. - * - * @param {FilteringModel|OverviewPageModel} model the FilteringModel - * @returns {Component} the paste button component - */ -const pasteButtonOption = (model) => { - const clipboardSupported = navigator?.clipboard && window.isSecureContext; - - // Sometimes, the overview model is passed to filterPanelPopover instead of the filteringmodel (e.g. envirionments) - const { filteringModel = model } = model; - - return h('button.btn.btn-primary', { - onclick: async () => { - const url = await navigator.clipboard.readText(); - filteringModel.setFilterFromURL(true, url); - }, - disabled: !clipboardSupported, - id: 'paste-filters', - }, 'Paste filters'); -}; - -/** - * A indicates if any filters are currently active on the page - * - * @param {FilteringModel} model the filtering model - * @returns {Component} the active filters indicator - */ -const activeFilterIndicator = (model) => { - // Sometimes, the overview model is passed to filterPanelPopover instead of the filteringmodel (e.g. envirionments) - const { filteringModel = model } = model; - - const hasActiveFilters = filteringModel.isAnyFilterActive(); - const innerText = `Filters ${hasActiveFilters ? 'Active' : 'Inactive'}`; - - let indicator = '.active-filters-indicator.b1'; - indicator += hasActiveFilters ? '.b-success.success.pulse-green' : '.inactive'; - - const children = [h(indicator, innerText)]; - - if (hasActiveFilters) { - children.push(resetFiltersButton(filteringModel, true)); - } - - return h('.flex-row.items-center', children); -}; - -/** - * Return component composed of the filter popover button and a dropdown trigger - * - * @param {FilteringModel} filteringModel the filtering model - * @param {FiltersConfiguration} filtersConfiguration filters configuration - * @param {object} [configuration] optional configuration - * @param {string} [configuration.profile] specify for which profile filtering should be enabled - * @return {Component} the filter component - */ -export const filtersPanelPopover = (filteringModel, filtersConfiguration, configuration) => { - const hasActiveFilters = filteringModel.isAnyFilterActive(); - - return h( - '.flex-row.items-center.btn-group', - [ - filtersPanelButton(filteringModel, filtersConfiguration, configuration), - DropdownComponent( - h('.btn.btn-group-item.last-item', iconCaretBottom()), - h( - '.flex-column.p2.g2', - [ - copyButtonOption(hasActiveFilters), - pasteButtonOption(filteringModel), - resetFiltersButton(filteringModel), - ], - ), - ), - activeFilterIndicator(filteringModel), - ], - ); -}; diff --git a/lib/public/components/common/form/inputs/DateTimeInputModel.js b/lib/public/components/common/form/inputs/DateTimeInputModel.js index 69456fd95d..2aec85f59f 100644 --- a/lib/public/components/common/form/inputs/DateTimeInputModel.js +++ b/lib/public/components/common/form/inputs/DateTimeInputModel.js @@ -65,15 +65,13 @@ export class DateTimeInputModel extends Observable { */ update(raw) { this._raw = raw; - const hasDateAndTime = raw.date && raw.time; - try { - this._value = hasDateAndTime ? extractTimestampFromDateTimeInput(raw) : null; + this._value = raw.date && raw.time ? extractTimestampFromDateTimeInput(raw, { seconds: this._seconds }) : null; } catch { this._value = null; } - hasDateAndTime && this.notify(); + this.notify(); } /** @@ -123,10 +121,6 @@ export class DateTimeInputModel extends Observable { return; } - if (isNaN(value)) { - return; - } - this._value = value; this._raw = value !== null ? formatTimestampForDateTimeInput(value, this._seconds) diff --git a/lib/public/components/common/form/switchInput.js b/lib/public/components/common/form/switchInput.js index f06cb5154a..ad7f7f8135 100644 --- a/lib/public/components/common/form/switchInput.js +++ b/lib/public/components/common/form/switchInput.js @@ -32,7 +32,7 @@ import { h } from '/js/src/index.js'; * @return {Component} the switch component */ export const switchInput = (value, onChange, options) => { - const { key, labelAfter, labelBefore, color, id } = options || {}; + const { key, labelAfter, labelBefore, color } = options || {}; const attributes = { ...key ? { key } : {} }; return h( @@ -40,7 +40,7 @@ export const switchInput = (value, onChange, options) => { attributes, [ labelBefore, - h('.switch', { id }, [ + h('.switch', [ h('input', { onchange: (e) => onChange(e.target.checked), type: 'checkbox', diff --git a/lib/public/components/common/messages/warningComponent.js b/lib/public/components/common/messages/warningComponent.js deleted file mode 100644 index 1c37ddf5d7..0000000000 --- a/lib/public/components/common/messages/warningComponent.js +++ /dev/null @@ -1,35 +0,0 @@ -import { h } from '/js/src/index.js'; -import { iconX } from '/js/src/icons.js'; - -/** - * Component to display whenever a page has warnings. - * - * @param {OverviewPageModel} overviewModel model that controlls an overview page - * @returns {Component} the warning componen - */ -export const warningComponent = (overviewModel) => { - const { warnings } = overviewModel; - - if (!warnings.size) { - return null; - } - - return h('details.alert.alert-warning', { open: true }, [ - h('summary', 'Warnings'), - h('ul', warnings.entries().toArray().map(([key, message]) => - h('li.flex-row.items-center', [ - h( - '.btn.btn-pill.alert-warning.mh1', - { - onclick: () => { - warnings.delete(key); - overviewModel.notify(); - }, - }, - iconX(), - ), - h('strong.mh1', `${key}:`), - h('span', message), - ]))), - ]); -}; diff --git a/lib/public/components/common/selection/SelectionModel.js b/lib/public/components/common/selection/SelectionModel.js index b9926b4f32..8b28aa28d1 100644 --- a/lib/public/components/common/selection/SelectionModel.js +++ b/lib/public/components/common/selection/SelectionModel.js @@ -42,12 +42,6 @@ export class SelectionModel extends Observable { super(); const { availableOptions = [], defaultSelection = [], multiple = true, allowEmpty = true } = configuration || {}; - /** - * @type {SelectionOption[]} - * @protected - */ - this._selectionBacklog = []; - /** * @type {RemoteData|SelectionOption[]} * @protected @@ -113,15 +107,6 @@ export class SelectionModel extends Observable { return selected.length === defaultSelection.length && selected.every((item) => defaultSelection.includes(item)); } - /** - * States if the filter is active. By default this is equivalent to isEmpty - * - * @return {boolean} true if the filter is active - */ - get isInactive() { - return this.isEmpty; - } - /** * Reset the selection to the default * @@ -258,7 +243,7 @@ export class SelectionModel extends Observable { } /** - * Defines the list of available options and if there is a selection backlog, these will be applied + * Defines the list of available options * * @param {RemoteData|SelectionOption[]} availableOptions the new available options * @return {void} @@ -266,11 +251,6 @@ export class SelectionModel extends Observable { setAvailableOptions(availableOptions) { this._availableOptions = availableOptions; this.visualChange$.notify(); - - if (this._selectionBacklog.length) { - this.selectedOptions = this._selectionBacklog; - this.notify(); - } } /** @@ -335,19 +315,12 @@ export class SelectionModel extends Observable { } /** - * Define (overrides) the list of currently selected options. - * Invalid selection options are excluded + * Define (overrides) the list of currently selected options * * @param {SelectionOption[]} selected the list of selected options */ set selectedOptions(selected) { - let { options } = this; - - if (this.options instanceof RemoteData) { - options = options.isSuccess() ? options.payload : []; - } - - this._selectedOptions = options.filter((option) => selected.some(({ value }) => String(value) === String(option.value)));; + this._selectedOptions = selected; } /** @@ -358,40 +331,4 @@ export class SelectionModel extends Observable { get optionsSelectedByDefault() { return this._defaultSelection; } - - /** - * Sets selected options based on a comma-seperated string. - * Accounts for the options being either RemoteData or an array. - * - * @param {string} value the value that is to be set. - */ - set normalized(value) { - const options = value.split(',').map((option) => ({ value: option.trim() })); - const isRemoteData = this.options instanceof RemoteData; - const noOptions = !this.options?.length; - - if (isRemoteData) { - this._availableOptions.match({ - Success: (_) => { - this.selectedOptions = options; - }, - Other: () => { - this._selectionBacklog = options; - }, - }); - } else if (noOptions) { - this._selectionBacklog = options; - } else { - this.selectedOptions = options; - } - } - - /** - * Returns the normalized value of the selection - * - * @return {string|string[]|boolean|boolean[]|number|number[]|SelectionOption|SelectionOption[]} the normalized value - */ - get normalized() { - return (this._allowEmpty || this._multiple) ? this.selected.join(',') : this.current; - } } diff --git a/lib/public/components/runEorReasons/runEorReasonSelection.js b/lib/public/components/runEorReasons/runEorReasonSelection.js index c7a3ad14a3..dbe86cde87 100644 --- a/lib/public/components/runEorReasons/runEorReasonSelection.js +++ b/lib/public/components/runEorReasons/runEorReasonSelection.js @@ -22,7 +22,6 @@ import { h } from '/js/src/index.js'; */ export const eorReasonFilterComponent = (eorReasonFilterModel, eorReasonTypes) => { const eorReasonsCategories = [...new Set(eorReasonTypes.map(({ category }) => category))]; - const { category: currentCategory, title: currentTitle } = eorReasonFilterModel; return [ h('.flex-row', [ @@ -37,7 +36,7 @@ export const eorReasonFilterComponent = (eorReasonFilterModel, eorReasonTypes) = h('option', { selected: eorReasonFilterModel.category === '', value: '' }, '-'), eorReasonsCategories.map((category, index) => h( `option#eorCategory${index}`, - { key: category, value: category, selected: category === currentCategory }, + { key: category, value: category }, category, )), ], @@ -55,7 +54,7 @@ export const eorReasonFilterComponent = (eorReasonFilterModel, eorReasonTypes) = .filter((reason) => reason.category === eorReasonFilterModel.category) .map(({ title }, index) => h( `option#eorTitle${index}`, - { key: title, value: title, selected: title === currentTitle }, + { key: title, value: title }, title || '(empty)', )), ], diff --git a/lib/public/components/runTypes/RunTypesFilterModel.js b/lib/public/components/runTypes/RunTypesFilterModel.js index 9767fb0e08..60a923cbc6 100644 --- a/lib/public/components/runTypes/RunTypesFilterModel.js +++ b/lib/public/components/runTypes/RunTypesFilterModel.js @@ -12,18 +12,51 @@ */ import { runTypeToOption } from './runTypeToOption.js'; +import { FilterModel } from '../Filters/common/FilterModel.js'; import { ObservableBasedSelectionDropdownModel } from '../detector/ObservableBasedSelectionDropdownModel.js'; /** * Model storing state of a selection of run types picked from the list of all the existing run types */ -export class RunTypesFilterModel extends ObservableBasedSelectionDropdownModel { +export class RunTypesFilterModel extends FilterModel { /** * Constructor * * @param {ObservableData>} runTypes$ observable remote data of run types list */ constructor(runTypes$) { - super(runTypes$, runTypeToOption); + super(); + this._selectionDropdownModel = new ObservableBasedSelectionDropdownModel(runTypes$, runTypeToOption); + this._addSubmodel(this._selectionDropdownModel); + } + + /** + * @inheritDoc + */ + reset() { + this._selectionDropdownModel.reset(); + } + + /** + * @inheritDoc + */ + get isEmpty() { + return this._selectionDropdownModel.isEmpty; + } + + /** + * @inheritDoc + */ + get normalized() { + return this._selectionDropdownModel.selected; + } + + /** + * Return the underlying selection dropdown model + * + * @return {SelectionDropdownModel} the selection dropdown model + */ + get selectionDropdownModel() { + return this._selectionDropdownModel; } } diff --git a/lib/public/domain/enums/DetectorOrders.js b/lib/public/domain/enums/DetectorOrders.js deleted file mode 100644 index 90094c7f21..0000000000 --- a/lib/public/domain/enums/DetectorOrders.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @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 { DetectorType } from './DetectorTypes.js'; - -/** - * Defines priority mappings for detector types. - * Each key is a mapping between {@link DetectorType} values and their numeric priority - * (larger values will appear first - see detectorsProvider LN88). - * - * - **DEFAULT**: Standard ordering used across most views. - * - **RCT**: Ordering used in the Run Condition Table, which prioritizes PHYSICAL detectors. - */ -export const DetectorOrders = Object.freeze({ - DEFAULT: { - [DetectorType.OTHER]: 0, - [DetectorType.VIRTUAL]: 1, - [DetectorType.PHYSICAL]: 2, - [DetectorType.AOT_GLO]: 3, - [DetectorType.AOT_EVENT]: 4, - [DetectorType.MUON_GLO]: 5, - [DetectorType.QC_ONLY]: 6, - }, - RCT: { - [DetectorType.OTHER]: 0, - [DetectorType.AOT_GLO]: 1, - [DetectorType.AOT_EVENT]: 2, - [DetectorType.MUON_GLO]: 3, - [DetectorType.VIRTUAL]: 4, - [DetectorType.PHYSICAL]: 5, - [DetectorType.QC_ONLY]: 6, - }, -}); diff --git a/lib/public/models/FilterableOverviewPageModel.js b/lib/public/models/FilterableOverviewPageModel.js deleted file mode 100644 index b3d954e05d..0000000000 --- a/lib/public/models/FilterableOverviewPageModel.js +++ /dev/null @@ -1,132 +0,0 @@ -/** - * @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 { buildUrl } from '/js/src/index.js'; -import { OverviewPageModel } from './OverviewModel.js'; -import { FilteringModel } from '../components/Filters/common/FilteringModel.js'; - -/** - * Base model for a filterable overview page - * - * @template T the type of data displayed in the overview page - */ -export class FilterableOverviewPageModel extends OverviewPageModel { - /** - * Constructor - * @param {QueryRouter} router router that controls the application's page navigation - * @param {string} pageIdentifier string that indicates what page this model represents - * @param {Object} filters the filters with their label and model - */ - constructor(router, pageIdentifier, filters) { - super(); - this._filteringModel = new FilteringModel(router, filters, this._warnings); - - this._filteringModel.pageIdentifier = pageIdentifier; - this._filteringModel.visualChange$.bubbleTo(this); - this._filteringModel.observe(() => this._applyFilters()); - this._sortModel.unobserve(this._sortModelCallback); - this._sortModel.observe(() => this._applyFilters()); - this._debouncedLoad = (_time) => {}; // Abstract, does nothing on purpose - this._fetchInstantly = true; - } - - /** - * Builds a url string from filters and a base string - * - * @param {string} base the base string from which the endpoint will be built - * @return {string} - */ - buildRootEndpoint(base) { - return buildUrl(base, { filter: this.getFilterParams() }); - } - - /** - * Sets the fetchInstantly boolean - * @param {boolean} bool the value to set - * @return {void} - */ - set fetchInstantly(bool) { - this._fetchInstantly = bool; - } - - /** - * Returns all filtering, sorting and pagination settings to their default values - * @param {boolean} [fetch = true] whether to refetch all data after filters have been reset - * @return {void} - */ - reset(fetch = true) { - super.reset(); - this.resetFiltering(fetch); - } - - /** - * Reset all filtering models - * @param {boolean} fetch Whether to refetch all data after filters have been reset - * @param {boolean} [clearUrl=false] if true filters will be removed from the url - * @return {void} - */ - resetFiltering(fetch = true, clearUrl = false) { - this._filteringModel.reset(false, clearUrl); - - if (fetch) { - this._applyFilters(true); - } - } - - /** - * Checks if any filter value has been modified from their default (empty) - * @return {Boolean} If any filter is active - */ - isAnyFilterActive() { - return this._filteringModel.isAnyFilterActive(); - } - - /** - * Apply the current filtering and update the remote data list - * - * @param {boolean} now if true, filtering will be applied now without debouncing - * - * @return {void} - */ - _applyFilters() { - this._pagination.silentlySetCurrentPage(1); - this._fetchInstantly ? this.load() : this._debouncedLoad(); - } - - /** - * Set underlying FilteringModel's filters from the query parameters in the URL - * - * @param {boolean} notify if the FilteringModel should notify it's observers after finishing setting the filters - */ - setFilterFromURL(notify) { - this._filteringModel.setFilterFromURL(notify); - } - - /** - * Return the filtering model - * - * @return {FilteringModel} the filtering model - */ - get filteringModel() { - return this._filteringModel; - } - - /** - * Return filter params of base model - * - * @return {object} filter - */ - getFilterParams() { - return this._filteringModel.normalized; - } -} diff --git a/lib/public/models/OverviewModel.js b/lib/public/models/OverviewModel.js index 73334c204b..69ae0c3df3 100644 --- a/lib/public/models/OverviewModel.js +++ b/lib/public/models/OverviewModel.js @@ -38,15 +38,12 @@ export class OverviewPageModel extends Observable { */ constructor() { super(); - this._warnings = new Map(); - this._sortModel = new SortModel(); - this._sortModelCallback = () => { + this._sortModel = new SortModel(); + this._sortModel.observe(() => { this._pagination.silentlySetCurrentPage(1); this.load(); - }; - - this._sortModel.observe(this._sortModelCallback); + }); this._sortModel.visualChange$.bubbleTo(this); // Single page data handling @@ -100,7 +97,6 @@ export class OverviewPageModel extends Observable { reset() { this._item$.setCurrent(RemoteData.notAsked()); this._pagination.reset(); - this._warnings.clear(); } /** @@ -253,13 +249,4 @@ export class OverviewPageModel extends Observable { hasAnyData() { return this._item$.getCurrent().match({ Success: ({ length = 0 } = {}) => length > 0, Other: () => false }); } - - /** - * Returns the warnings object - * - * @return {object} the warning model - */ - get warnings() { - return this._warnings; - } } diff --git a/lib/public/services/detectors/detectorsProvider.js b/lib/public/services/detectors/detectorsProvider.js index 2370f19942..3825835d66 100644 --- a/lib/public/services/detectors/detectorsProvider.js +++ b/lib/public/services/detectors/detectorsProvider.js @@ -15,7 +15,6 @@ import { switchCase } from '/js/src/index.js'; import { getRemoteData } from '../../utilities/fetch/getRemoteData.js'; import { ObservableData } from '../../utilities/ObservableData.js'; import { DetectorType, DATA_TAKING_DETECTOR_TYPES, QC_DETECTORS } from '../../domain/enums/DetectorTypes.js'; -import { DetectorOrders } from '../../domain/enums/DetectorOrders.js'; import { NonPhysicalDetector } from '../../domain/enums/detectorsNames.mjs'; @@ -45,12 +44,9 @@ const getQcDetectorsFromAllDetectors = (allDetectors) => allDetectors export class DetectorsProvider extends RemoteDataProvider { /** * Constructor - * - * @param {DetectorOrders} detectorOrder the order to base sorting on, default is DetectorOrders.DEFAULT */ - constructor(detectorOrder = DetectorOrders.DEFAULT) { + constructor() { super(); - this._detectorOrder = detectorOrder; this._physical$ = ObservableData.builder() .source(this._items$) .apply((remoteDetectors) => remoteDetectors.apply({ @@ -78,14 +74,21 @@ export class DetectorsProvider extends RemoteDataProvider { */ async getRemoteData() { const { data: detectors } = await getRemoteData('/api/detectors'); - const typeToOrderingKey = (type) => switchCase(type, this._detectorOrder); + const typeToOrderingKey = (type) => switchCase(type, { + [DetectorType.OTHER]: 0, + [DetectorType.VIRTUAL]: 1, + [DetectorType.PHYSICAL]: 2, + [DetectorType.AOT_GLO]: 3, + [DetectorType.AOT_EVENT]: 4, + [DetectorType.MUON_GLO]: 5, + [DetectorType.QC_ONLY]: 6, + }); const orderingKey = (detector1, detector2) => { const specialPair = ['ZDC', 'TST']; if (specialPair.includes(detector1.name) && specialPair.includes(detector2.name)) { return detector1.name === 'ZDC' ? 1 : -1; } - // Note the negative sign to have larger priority types appear first return -(typeToOrderingKey(detector1.type) - typeToOrderingKey(detector2.type)) * 10 + detector1.name.localeCompare(detector2.name); }; @@ -158,4 +161,3 @@ export class DetectorsProvider extends RemoteDataProvider { } export const detectorsProvider = new DetectorsProvider(); -export const rctDetectorsProvider = new DetectorsProvider(DetectorOrders.RCT); diff --git a/lib/public/views/DataPasses/ActiveColumns/dataPassesActiveColumns.js b/lib/public/views/DataPasses/ActiveColumns/dataPassesActiveColumns.js index e0b6d87316..45d55bf6c6 100644 --- a/lib/public/views/DataPasses/ActiveColumns/dataPassesActiveColumns.js +++ b/lib/public/views/DataPasses/ActiveColumns/dataPassesActiveColumns.js @@ -20,7 +20,7 @@ import { h } from '/js/src/index.js'; import { formatDataPassName } from '../format/formatDataPassName.js'; import { formatDataPassStatusHistory } from '../format/formatStatusHistory.js'; import { checkboxes } from '../../../components/Filters/common/filters/checkboxFilter.js'; -import { textFilter } from '../../../components/Filters/common/filters/textFilter.js'; +import { rawTextFilter } from '../../../components/Filters/common/filters/rawTextFilter.js'; /** * List of active columns for a generic data passes table @@ -35,7 +35,10 @@ export const dataPassesActiveColumns = { visible: true, sortable: true, format: (_, dataPass) => formatDataPassName(dataPass), - filter: (filteringModel) => textFilter(filteringModel.get('names'), { class: 'w-75 mt1', placeholder: 'e.g. LHC22a, lhc23b, ...' }), + filter: (filteringModel) => rawTextFilter( + filteringModel.get('names'), + { classes: ['w-75', 'mt1'], placeholder: 'e.g. LHC22a_apass1, ...' }, + ), balloon: true, classes: 'w-20', }, @@ -102,7 +105,7 @@ export const dataPassesActiveColumns = { nonPhysicsProductions: { name: 'Include nonphysics productions', - filter: (filteringModel) => checkboxes(filteringModel.get('permittedNonPhysicsNames')), + filter: (filteringModel) => checkboxes(filteringModel.get('include[byName]').selectionModel), visible: false, }, }; diff --git a/lib/public/views/DataPasses/DataPassesModel.js b/lib/public/views/DataPasses/DataPassesModel.js index 42fed10c3a..5d987b31d7 100644 --- a/lib/public/views/DataPasses/DataPassesModel.js +++ b/lib/public/views/DataPasses/DataPassesModel.js @@ -21,15 +21,14 @@ import { DataPassesPerSimulationPassOverviewModel } from './PerSimulationPassOve export class DataPassesModel extends Observable { /** * The constructor of the model - * @param {QueryRouter} router router that controls the application's page navigation */ - constructor(router) { + constructor() { super(); - this._perLhcPeriodOverviewModel = new DataPassesPerLhcPeriodOverviewModel(router, 'data-passes-per-lhc-period-overview'); + this._perLhcPeriodOverviewModel = new DataPassesPerLhcPeriodOverviewModel(); this._perLhcPeriodOverviewModel.bubbleTo(this); - this._perSimulationPassOverviewModel = new DataPassesPerSimulationPassOverviewModel(router, 'data-passes-per-simulation-pass-overview'); + this._perSimulationPassOverviewModel = new DataPassesPerSimulationPassOverviewModel(); this._perSimulationPassOverviewModel.bubbleTo(this); } @@ -40,7 +39,6 @@ export class DataPassesModel extends Observable { * @returns {void} */ loadPerLhcPeriodOverview({ lhcPeriodId }) { - this._perLhcPeriodOverviewModel.setFilterFromURL(false); this._perLhcPeriodOverviewModel.load({ lhcPeriodId }); } @@ -69,7 +67,6 @@ export class DataPassesModel extends Observable { */ loadPerSimulationPassOverview({ simulationPassId }) { this._perSimulationPassOverviewModel.simulationPassId = parseInt(simulationPassId, 10); - this._perSimulationPassOverviewModel.setFilterFromURL(false); this._perSimulationPassOverviewModel.load(); } diff --git a/lib/public/views/DataPasses/DataPassesOverviewModel.js b/lib/public/views/DataPasses/DataPassesOverviewModel.js index 4d07c34e51..b85cc052d7 100644 --- a/lib/public/views/DataPasses/DataPassesOverviewModel.js +++ b/lib/public/views/DataPasses/DataPassesOverviewModel.js @@ -10,30 +10,60 @@ * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. */ -import { SelectionModel } from '../../components/common/selection/SelectionModel.js'; +import { FilteringModel } from '../../components/Filters/common/FilteringModel.js'; +import { SelectionFilterModel } from '../../components/Filters/common/filters/SelectionFilterModel.js'; import { TextTokensFilterModel } from '../../components/Filters/common/filters/TextTokensFilterModel.js'; import { NON_PHYSICS_PRODUCTIONS_NAMES_WORDS } from '../../domain/enums/NonPhysicsProductionsNamesWords.js'; -import { FilterableOverviewPageModel } from '../../models/FilterableOverviewPageModel.js'; +import { OverviewPageModel } from '../../models/OverviewModel.js'; /** * Data Passes overview model */ -export class DataPassesOverviewModel extends FilterableOverviewPageModel { +export class DataPassesOverviewModel extends OverviewPageModel { /** * Constructor - * @param {QueryRouter} router router that controls the application's page navigation - * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(router, pageIdentifier) { - super( - router, - pageIdentifier, - { - names: new TextTokensFilterModel(), - permittedNonPhysicsNames: new SelectionModel({ - availableOptions: NON_PHYSICS_PRODUCTIONS_NAMES_WORDS.map((word) => ({ label: word.toUpperCase(), value: word })), - }), - }, - ); + constructor() { + super(); + this._filteringModel = new FilteringModel({ + names: new TextTokensFilterModel(), + 'include[byName]': new SelectionFilterModel({ + availableOptions: NON_PHYSICS_PRODUCTIONS_NAMES_WORDS.map((word) => ({ label: word.toUpperCase(), value: word })), + }), + }); + + this._filteringModel.visualChange$.bubbleTo(this); + this._filteringModel.observe(() => { + this._pagination.currentPage = 1; + this.load(); + }); + } + + /** + * Return filter params of base model + * + * @return {object} filter + */ + getFilterParams() { + return this._filteringModel.normalized; + } + + /** + * Reset this model to its default + * + * @returns {void} + */ + reset() { + this._filteringModel.reset(); + super.reset(); + } + + /** + * Return the filtering model + * + * @return {FilteringModel} the filtering model + */ + get filteringModel() { + return this._filteringModel; } } diff --git a/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewModel.js b/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewModel.js index 6da2205751..dc125e1a94 100644 --- a/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewModel.js +++ b/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewModel.js @@ -19,11 +19,9 @@ import { buildUrl } from '/js/src/index.js'; export class DataPassesPerLhcPeriodOverviewModel extends DataPassesOverviewModel { /** * Constructor - * @param {QueryRouter} router router that controls the application's page navigation - * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(router, pageIdentifier) { - super(router, pageIdentifier); + constructor() { + super(); this._lhcPeriodId = null; } diff --git a/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewPage.js b/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewPage.js index 7cb3e5fb65..e97dca2170 100644 --- a/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewPage.js +++ b/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewPage.js @@ -19,7 +19,6 @@ import { filtersPanelPopover } from '../../../components/Filters/common/filtersP import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; import { dataPassesActiveColumns } from '../ActiveColumns/dataPassesActiveColumns.js'; import { DataPassVersionStatus } from '../../../domain/enums/DataPassVersionStatus.js'; -import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 42; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -43,18 +42,24 @@ const getRowClasses = ({ versions }) => { * @returns {Component} The overview screen */ export const DataPassesPerLhcPeriodOverviewPage = ({ dataPasses: { perLhcPeriodOverviewModel: dataPassesPerLhcPeriodOverviewModel } }) => { - const { filteringModel, sortModel, pagination, items } = dataPassesPerLhcPeriodOverviewModel; - - pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); + dataPassesPerLhcPeriodOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( + TABLEROW_HEIGHT, + PAGE_USED_HEIGHT, + )); return h('', { onremove: () => dataPassesPerLhcPeriodOverviewModel.reset(), }, [ - h('.flex-row.header-container.pv2', filtersPanelPopover(filteringModel, dataPassesActiveColumns)), - warningComponent(dataPassesPerLhcPeriodOverviewModel), + h('.flex-row.header-container.pv2', filtersPanelPopover(dataPassesPerLhcPeriodOverviewModel.filteringModel, dataPassesActiveColumns)), h('.w-100.flex-column', [ - table(items, dataPassesActiveColumns, { classes: getRowClasses }, null, { sort: sortModel }), - paginationComponent(pagination), + table( + dataPassesPerLhcPeriodOverviewModel.items, + dataPassesActiveColumns, + { classes: getRowClasses }, + null, + { sort: dataPassesPerLhcPeriodOverviewModel.sortModel }, + ), + paginationComponent(dataPassesPerLhcPeriodOverviewModel.pagination), ]), ]); }; diff --git a/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewModel.js b/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewModel.js index d9b1008552..30fd3c616c 100644 --- a/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewModel.js +++ b/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewModel.js @@ -21,11 +21,9 @@ import { DataPassesOverviewModel } from '../DataPassesOverviewModel.js'; export class DataPassesPerSimulationPassOverviewModel extends DataPassesOverviewModel { /** * Constructor - * @param {QueryRouter} router router that controls the application's page navigation - * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(router, pageIdentifier) { - super(router, pageIdentifier); + constructor() { + super(); this._simulationPass = new ObservableData(RemoteData.notAsked()); this._simulationPass.bubbleTo(this); } diff --git a/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewPage.js b/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewPage.js index 6e11d594a8..2473f3383d 100644 --- a/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewPage.js +++ b/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewPage.js @@ -22,7 +22,6 @@ import { breadcrumbs } from '../../../components/common/navigation/breadcrumbs.j import spinner from '../../../components/common/spinner.js'; import { tooltip } from '../../../components/common/popover/tooltip.js'; import { DataPassVersionStatus } from '../../../domain/enums/DataPassVersionStatus.js'; -import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 42; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -47,9 +46,12 @@ const getRowClasses = ({ versions }) => { */ export const DataPassesPerSimulationPassOverviewPage = ({ dataPasses: { perSimulationPassOverviewModel: dataPassesPerSimulationPassOverviewModel } }) => { - const { items, simulationPass, pagination, filteringModel, sortModel } = dataPassesPerSimulationPassOverviewModel; + dataPassesPerSimulationPassOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( + TABLEROW_HEIGHT, + PAGE_USED_HEIGHT, + )); - pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); + const { items, simulationPass, pagination } = dataPassesPerSimulationPassOverviewModel; const commonTitle = h('h2#breadcrumb-header', 'Data Passes per MC'); @@ -57,7 +59,7 @@ export const DataPassesPerSimulationPassOverviewPage = ({ dataPasses: { onremove: () => dataPassesPerSimulationPassOverviewModel.reset(), }, [ h('.flex-row.items-center.g2', [ - filtersPanelPopover(filteringModel, dataPassesActiveColumns), + filtersPanelPopover(dataPassesPerSimulationPassOverviewModel.filteringModel, dataPassesActiveColumns), h( '.flex-row.g1.items-center', simulationPass.match({ @@ -68,9 +70,14 @@ export const DataPassesPerSimulationPassOverviewPage = ({ dataPasses: { }), ), ]), - warningComponent(dataPassesPerSimulationPassOverviewModel), h('.w-100.flex-column', [ - table(items, dataPassesActiveColumns, { classes: getRowClasses }, null, { sort: sortModel }), + table( + items, + dataPassesActiveColumns, + { classes: getRowClasses }, + null, + { sort: dataPassesPerSimulationPassOverviewModel.sortModel }, + ), paginationComponent(pagination), ]), ]); diff --git a/lib/public/views/Environments/ActiveColumns/environmentsActiveColumns.js b/lib/public/views/Environments/ActiveColumns/environmentsActiveColumns.js index 1226d7f7cb..d392ffb53a 100644 --- a/lib/public/views/Environments/ActiveColumns/environmentsActiveColumns.js +++ b/lib/public/views/Environments/ActiveColumns/environmentsActiveColumns.js @@ -26,7 +26,7 @@ import { aliEcsEnvironmentLinkComponent } from '../../../components/common/exter import { StatusAcronym } from '../../../domain/enums/statusAcronym.mjs'; import { timeRangeFilter } from '../../../components/Filters/common/filters/timeRangeFilter.js'; import { checkboxes } from '../../../components/Filters/common/filters/checkboxFilter.js'; -import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; +import { rawTextFilter } from '../../../components/Filters/common/filters/rawTextFilter.js'; /** * List of active columns for a generic Environments component @@ -60,10 +60,13 @@ export const environmentsActiveColumns = { /** * Environment IDs filter component * - * @param {EnvironmentOverviewModel} environmentOverviewModel.filteringModel the filtering model + * @param {EnvironmentOverviewModel} environmentOverviewModel the environment overview model * @return {Component} the filter component */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'ids', 'e.g. CmCvjNbg, TDI59So3d...'), + filter: (environmentOverviewModel) => rawTextFilter( + environmentOverviewModel.filteringModel.get('ids'), + { classes: ['w-100'], placeholder: 'e.g. CmCvjNbg, TDI59So3d...' }, + ), }, runs: { name: 'Runs', @@ -76,10 +79,13 @@ export const environmentsActiveColumns = { /** * Run numbers filter component * - * @param {EnvironmentOverviewModel} environmentOverviewModel.filteringModel the filtering model + * @param {EnvironmentOverviewModel} environmentOverviewModel the environment overview model * @return {Component} the filter component */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'runNumbers', 'e.g. 553203, 553221, ...'), + filter: (environmentOverviewModel) => rawTextFilter( + environmentOverviewModel.filteringModel.get('runNumbers'), + { classes: ['w-100'], placeholder: 'e.g. 553203, 553221, ...' }, + ), }, updatedAt: { name: 'Last Update', @@ -117,7 +123,7 @@ export const environmentsActiveColumns = { * @param {EnvironmentOverviewModel} environmentOverviewModel the environment overview model * @return {Component} the filter component */ - filter: (environmentOverviewModel) => checkboxes(environmentOverviewModel.filteringModel.get('currentStatus')), + filter: (environmentOverviewModel) => checkboxes(environmentOverviewModel.filteringModel.get('currentStatus').selectionModel), }, historyItems: { name: h('.flex-row.g2.items-center', ['Status History', infoTooltip(environmentStatusHistoryLegendComponent())]), @@ -134,9 +140,12 @@ export const environmentsActiveColumns = { /** * Status history filter component * - * @param {EnvironmentOverviewModel} environmentOverviewModel.filteringModel the filtering model + * @param {EnvironmentOverviewModel} environmentOverviewModel the environment overview model * @return {Component} the filter component */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'statusHistory', 'e.g. D-R-X'), + filter: (environmentOverviewModel) => rawTextFilter( + environmentOverviewModel.filteringModel.get('statusHistory'), + { classes: ['w-100'], placeholder: 'e.g. D-R-X' }, + ), }, }; diff --git a/lib/public/views/Environments/EnvironmentModel.js b/lib/public/views/Environments/EnvironmentModel.js index 1cc7fa484d..ba4b1e86bf 100644 --- a/lib/public/views/Environments/EnvironmentModel.js +++ b/lib/public/views/Environments/EnvironmentModel.js @@ -29,7 +29,7 @@ export class EnvironmentModel extends Observable { super(); // Sub-models - this._overviewModel = new EnvironmentOverviewModel(model, 'env-overview'); + this._overviewModel = new EnvironmentOverviewModel(model); this._overviewModel.bubbleTo(this); this._detailsModel = new EnvironmentDetailsModel(); @@ -42,7 +42,6 @@ export class EnvironmentModel extends Observable { */ loadOverview() { if (!this._overviewModel.pagination.isInfiniteScrollEnabled) { - this._overviewModel.setFilterFromURL(false); this._overviewModel.load(); } } diff --git a/lib/public/views/Environments/Overview/EnvironmentOverviewModel.js b/lib/public/views/Environments/Overview/EnvironmentOverviewModel.js index 9621e4df33..8498a02d79 100644 --- a/lib/public/views/Environments/Overview/EnvironmentOverviewModel.js +++ b/lib/public/views/Environments/Overview/EnvironmentOverviewModel.js @@ -11,47 +11,58 @@ * or submit itself to any jurisdiction. */ +import { buildUrl } from '/js/src/index.js'; +import { FilteringModel } from '../../../components/Filters/common/FilteringModel.js'; +import { OverviewPageModel } from '../../../models/OverviewModel.js'; import { TimeRangeInputModel } from '../../../components/Filters/common/filters/TimeRangeInputModel.js'; +import { SelectionFilterModel } from '../../../components/Filters/common/filters/SelectionFilterModel.js'; import { RawTextFilterModel } from '../../../components/Filters/common/filters/RawTextFilterModel.js'; +import { debounce } from '../../../utilities/debounce.js'; import { coloredEnvironmentStatusComponent } from '../ColoredEnvironmentStatusComponent.js'; import { StatusAcronym } from '../../../domain/enums/statusAcronym.mjs'; -import { SelectionModel } from '../../../components/common/selection/SelectionModel.js'; -import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; /** * Environment overview page model */ -export class EnvironmentOverviewModel extends FilterableOverviewPageModel { +export class EnvironmentOverviewModel extends OverviewPageModel { /** * Constructor * @param {Model} model global model - * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(model, pageIdentifier) { - super( - model.router, - pageIdentifier, - { - created: new TimeRangeInputModel(), - runNumbers: new RawTextFilterModel(), - statusHistory: new RawTextFilterModel(), - currentStatus: new SelectionModel({ - availableOptions: Object.keys(StatusAcronym).map((status) => ({ - value: status, - label: coloredEnvironmentStatusComponent(status), - rawLabel: status, - })), - }), - ids: new RawTextFilterModel(), - }, - ); + constructor(model) { + super(); + + this._filteringModel = new FilteringModel({ + created: new TimeRangeInputModel(), + runNumbers: new RawTextFilterModel(), + statusHistory: new RawTextFilterModel(), + currentStatus: new SelectionFilterModel({ + availableOptions: Object.keys(StatusAcronym).map((status) => ({ + value: status, + label: coloredEnvironmentStatusComponent(status), + rawLabel: status, + })), + }), + ids: new RawTextFilterModel(), + }); + + this._filteringModel.observe(() => this._applyFilters(true)); + this._filteringModel.visualChange$?.bubbleTo(this); + + this.reset(false); + const updateDebounceTime = () => { + this._debouncedLoad = debounce(this.load.bind(this), model.inputDebounceTime); + }; + + model.appConfiguration$.observe(() => updateDebounceTime()); + updateDebounceTime(); } /** * @inheritDoc */ getRootEndpoint() { - return this.buildRootEndpoint('/api/environments'); + return buildUrl('/api/environments', { filter: this.filteringModel.normalized }); } /** @@ -62,4 +73,56 @@ export class EnvironmentOverviewModel extends FilterableOverviewPageModel { get environments() { return this.items; } + + /** + * Returns all filtering, sorting and pagination settings to their default values + * @param {boolean} [fetch = true] whether to refetch all data after filters have been reset + * @return {void} + */ + reset(fetch = true) { + super.reset(); + this.resetFiltering(fetch); + } + + /** + * Reset all filtering models + * @param {boolean} fetch Whether to refetch all data after filters have been reset + * @return {void} + */ + resetFiltering(fetch = true) { + this._filteringModel.reset(); + + if (fetch) { + this._applyFilters(true); + } + } + + /** + * Checks if any filter value has been modified from their default (empty) + * @return {Boolean} If any filter is active + */ + isAnyFilterActive() { + return this._filteringModel.isAnyFilterActive(); + } + + /** + * Return the filtering model + * + * @return {FilteringModel} the filtering model + */ + get filteringModel() { + return this._filteringModel; + } + + /** + * Apply the current filtering and update the remote data list + * + * @param {boolean} now if true, filtering will be applied now without debouncing + * + * @return {void} + */ + _applyFilters(now = false) { + this._pagination.currentPage = 1; + now ? this.load() : this._debouncedLoad(true); + } } diff --git a/lib/public/views/Environments/Overview/environmentOverviewComponent.js b/lib/public/views/Environments/Overview/environmentOverviewComponent.js index df8f5a332d..7cc60ecd22 100644 --- a/lib/public/views/Environments/Overview/environmentOverviewComponent.js +++ b/lib/public/views/Environments/Overview/environmentOverviewComponent.js @@ -17,7 +17,6 @@ import { environmentsActiveColumns } from '../ActiveColumns/environmentsActiveCo import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; -import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 58; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -31,14 +30,16 @@ const PAGE_USED_HEIGHT = 181; export const environmentOverviewComponent = (envsOverviewModel) => { const { pagination, environments } = envsOverviewModel; - pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); + pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( + TABLEROW_HEIGHT, + PAGE_USED_HEIGHT, + )); return h('', [ h( '.flex-row.header-container.g2.pv2', filtersPanelPopover(envsOverviewModel, environmentsActiveColumns), ), - warningComponent(envsOverviewModel), h('.w-100.flex-column', [ h('.header-container.pv2'), table(environments, environmentsActiveColumns, { classes: 'table-sm' }), diff --git a/lib/public/views/Home/Overview/HomePage.js b/lib/public/views/Home/Overview/HomePage.js index 3768f6cf6d..92705e6973 100644 --- a/lib/public/views/Home/Overview/HomePage.js +++ b/lib/public/views/Home/Overview/HomePage.js @@ -46,7 +46,7 @@ export const HomePage = ({ home: { logsOverviewModel, runsOverviewModel, lhcFill h('.flex-row.g2', [ h('.flex-column', [ h('h3', 'Log Entries'), - h('.f6#logs-panel', table(logsOverviewModel.items, logsActiveColumns, null, { profile: 'home' })), + h('.f6#logs-panel', table(logsOverviewModel.logs, logsActiveColumns, null, { profile: 'home' })), ]), h('.flex-column', [ h('h3', 'LHC Fills'), diff --git a/lib/public/views/Home/Overview/HomePageModel.js b/lib/public/views/Home/Overview/HomePageModel.js index e40fe38952..40b6cfac85 100644 --- a/lib/public/views/Home/Overview/HomePageModel.js +++ b/lib/public/views/Home/Overview/HomePageModel.js @@ -26,13 +26,13 @@ export class HomePageModel extends Observable { */ constructor(model) { super(); - this._runsOverviewModel = new RunsOverviewModel(model, 'home'); + this._runsOverviewModel = new RunsOverviewModel(model); this._runsOverviewModel.bubbleTo(this); - this._logsOverviewModel = new LogsOverviewModel(model, true, 'home'); + this._logsOverviewModel = new LogsOverviewModel(model, true); this._logsOverviewModel.bubbleTo(this); - this._lhcFillsOverviewModel = new LhcFillsOverviewModel(model.router, true, 'home'); + this._lhcFillsOverviewModel = new LhcFillsOverviewModel(true); this._lhcFillsOverviewModel.bubbleTo(this); } @@ -42,7 +42,7 @@ export class HomePageModel extends Observable { */ loadOverview() { this._runsOverviewModel.load(); - this._logsOverviewModel.load(true); + this._logsOverviewModel.fetchLogs(true); this._lhcFillsOverviewModel.load(); } diff --git a/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js b/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js index be4311d7e4..b2657c8cfd 100644 --- a/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js +++ b/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js @@ -23,11 +23,12 @@ import { buttonLinkWithDropdown } from '../../../components/common/selection/inf import { infologgerLinksComponents } from '../../../components/common/externalLinks/infologgerLinksComponents.js'; import { formatBeamType } from '../../../utilities/formatting/formatBeamType.js'; import { frontLink } from '../../../components/common/navigation/frontLink.js'; -import { toggleFilter } from '../../../components/Filters/common/filters/toggleFilter.js'; +import { toggleStableBeamOnlyFilter } from '../../../components/Filters/LhcFillsFilter/stableBeamFilter.js'; +import { fillNumberFilter } from '../../../components/Filters/LhcFillsFilter/fillNumberFilter.js'; import { durationFilter } from '../../../components/Filters/LhcFillsFilter/durationFilter.js'; import { beamTypeFilter } from '../../../components/Filters/LhcFillsFilter/beamTypeFilter.js'; +import { schemeNameFilter } from '../../../components/Filters/LhcFillsFilter/schemeNameFilter.js'; import { timeRangeFilter } from '../../../components/Filters/common/filters/timeRangeFilter.js'; -import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; /** * List of active columns for a lhc fills table @@ -53,14 +54,7 @@ export const lhcFillsActiveColumns = { ), ], ), - - /** - * FillNumber filter component - * - * @param {FilteringModel} LhcFillsOverviewModel.filteringModel the filtering model - * @return {Component} the filter component - */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'fillNumbers', 'e.g. 7966, 7954, 7948...'), + filter: (lhcFillModel) => fillNumberFilter(lhcFillModel.filteringModel.get('fillNumbers')), profiles: { lhcFill: true, environment: true, @@ -117,8 +111,7 @@ export const lhcFillsActiveColumns = { name: 'Stable Beams Only', visible: false, format: (boolean) => boolean ? 'On' : 'Off', - filter: (lhcFillModel) => - toggleFilter(lhcFillModel.filteringModel.get('hasStableBeams'), 'stableBeamsOnlyRadio', 'stableBeamsOnlyRadio', true), + filter: (lhcFillModel) => toggleStableBeamOnlyFilter(lhcFillModel.filteringModel.get('hasStableBeams'), true), }, stableBeamsDuration: { name: 'SB Duration', @@ -200,14 +193,7 @@ export const lhcFillsActiveColumns = { visible: true, size: 'w-10', format: (value) => value ? value : '-', - - /** - * Schema filter component - * - * @param {FilteringModel} LhcFillsOverviewModel.filteringModel the filtering model - * @return {Component} the filter component - */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'schemeName', 'e.g. Single_12b_8_1024_8_2018'), + filter: (lhcFillModel) => schemeNameFilter(lhcFillModel.filteringModel.get('schemeName')), balloon: true, }, runs: { diff --git a/lib/public/views/LhcFills/LhcFills.js b/lib/public/views/LhcFills/LhcFills.js index a4343be26a..70b6c5eb3d 100644 --- a/lib/public/views/LhcFills/LhcFills.js +++ b/lib/public/views/LhcFills/LhcFills.js @@ -29,7 +29,7 @@ export default class LhcFills extends Observable { this.model = model; // Sub-models - this._overviewModel = new LhcFillsOverviewModel(model.router, true, 'lhc-fill-overview'); + this._overviewModel = new LhcFillsOverviewModel(true); this._overviewModel.bubbleTo(this); this._detailsModel = new LhcFillDetailsModel(); @@ -42,7 +42,6 @@ export default class LhcFills extends Observable { * @returns {void} */ loadOverview() { - this._overviewModel.setFilterFromURL(false); this._overviewModel.load(); } diff --git a/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js b/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js index 3e73c2fa0f..c57ae69c25 100644 --- a/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js +++ b/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js @@ -11,42 +11,49 @@ * or submit itself to any jurisdiction. */ +import { buildUrl } from '/js/src/index.js'; +import { FilteringModel } from '../../../components/Filters/common/FilteringModel.js'; +import { StableBeamFilterModel } from '../../../components/Filters/LhcFillsFilter/StableBeamFilterModel.js'; import { RawTextFilterModel } from '../../../components/Filters/common/filters/RawTextFilterModel.js'; +import { OverviewPageModel } from '../../../models/OverviewModel.js'; import { addStatisticsToLhcFill } from '../../../services/lhcFill/addStatisticsToLhcFill.js'; import { BeamTypeFilterModel } from '../../../components/Filters/LhcFillsFilter/BeamTypeFilterModel.js'; import { TextComparisonFilterModel } from '../../../components/Filters/common/filters/TextComparisonFilterModel.js'; import { TimeRangeFilterModel } from '../../../components/Filters/RunsFilter/TimeRangeFilter.js'; -import { ToggleFilterModel } from '../../../components/Filters/common/filters/ToggleFilterModel.js'; -import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; /** * Model for the LHC fills overview page * * @implements {OverviewModel} */ -export class LhcFillsOverviewModel extends FilterableOverviewPageModel { +export class LhcFillsOverviewModel extends OverviewPageModel { /** * Constructor * - * @param {QueryRouter} router router that controls the application's page navigation * @param {boolean} [stableBeamsOnly=false] if true, overview will load stable beam only - * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(router, stableBeamsOnly = false, pageIdentifier) { - super( - router, - pageIdentifier, - { - fillNumbers: new RawTextFilterModel(), - beamDuration: new TextComparisonFilterModel(), - runDuration: new TextComparisonFilterModel(), - hasStableBeams: new ToggleFilterModel(stableBeamsOnly, true), - stableBeamsStart: new TimeRangeFilterModel(), - stableBeamsEnd: new TimeRangeFilterModel(), - beamTypes: new BeamTypeFilterModel(), - schemeName: new RawTextFilterModel(), - }, - ); + constructor(stableBeamsOnly = false) { + super(); + + this._filteringModel = new FilteringModel({ + fillNumbers: new RawTextFilterModel(), + beamDuration: new TextComparisonFilterModel(), + runDuration: new TextComparisonFilterModel(), + hasStableBeams: new StableBeamFilterModel(), + stableBeamsStart: new TimeRangeFilterModel(), + stableBeamsEnd: new TimeRangeFilterModel(), + beamTypes: new BeamTypeFilterModel(), + schemeName: new RawTextFilterModel(), + }); + + this._filteringModel.observe(() => this._applyFilters()); + this._filteringModel.visualChange$.bubbleTo(this); + + this.reset(false); + + if (stableBeamsOnly) { + this._filteringModel.get('hasStableBeams').setStableBeamsOnly(true); + } } /** @@ -63,6 +70,59 @@ export class LhcFillsOverviewModel extends FilterableOverviewPageModel { * @inheritDoc */ getRootEndpoint() { - return this.buildRootEndpoint('/api/lhcFills'); + const params = { + filter: this.filteringModel.normalized, + }; + return buildUrl('/api/lhcFills', params); + } + + /** + * Returns all filtering, sorting and pagination settings to their default values + * @param {boolean} [fetch = true] whether to refetch all data after filters have been reset + * @return {void} + */ + reset(fetch = true) { + super.reset(); + this.resetFiltering(fetch); + } + + /** + * Reset all filtering models + * @param {boolean} fetch Whether to refetch all data after filters have been reset + * @return {void} + */ + resetFiltering(fetch = true) { + this._filteringModel.reset(); + + if (fetch) { + this._applyFilters(); + } + } + + /** + * Checks if any filter value has been modified from their default (empty) + * @return {Boolean} If any filter is active + */ + isAnyFilterActive() { + return this._filteringModel.isAnyFilterActive(); + } + + /** + * Return the filtering model + * + * @return {FilteringModel} the filtering model + */ + get filteringModel() { + return this._filteringModel; + } + + /** + * Apply the current filtering and update the remote data list + * + * @return {void} + */ + _applyFilters() { + this._pagination.currentPage = 1; + this.load(); } } diff --git a/lib/public/views/LhcFills/Overview/index.js b/lib/public/views/LhcFills/Overview/index.js index f790bb9957..e81409f06c 100644 --- a/lib/public/views/LhcFills/Overview/index.js +++ b/lib/public/views/LhcFills/Overview/index.js @@ -18,8 +18,7 @@ import { lhcFillsActiveColumns } from '../ActiveColumns/lhcFillsActiveColumns.js import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; -import { toggleFilter } from '../../../components/Filters/common/filters/toggleFilter.js'; -import { warningComponent } from '../../../components/common/messages/warningComponent.js'; +import { toggleStableBeamOnlyFilter } from '../../../components/Filters/LhcFillsFilter/stableBeamFilter.js'; const TABLEROW_HEIGHT = 53.3; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -42,18 +41,20 @@ export const Index = (model) => h('', { * @returns {Object} Html page */ const showLhcFillsTable = (lhcFillsOverviewModel) => { - const { items, pagination, filteringModel } = lhcFillsOverviewModel; - pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT, 1)); + lhcFillsOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( + TABLEROW_HEIGHT, + PAGE_USED_HEIGHT, + 1, + )); return [ h('.flex-row.header-container.g2.pv2', [ filtersPanelPopover(lhcFillsOverviewModel, lhcFillsActiveColumns), - toggleFilter(filteringModel.get('hasStableBeams'), 'STABLE BEAM ONLY'), + toggleStableBeamOnlyFilter(lhcFillsOverviewModel.filteringModel.get('hasStableBeams')), ]), - warningComponent(lhcFillsOverviewModel), h('.w-100.flex-column', [ - table(items, lhcFillsActiveColumns, null, { tableClasses: '.table-sm' }), - paginationComponent(pagination), + table(lhcFillsOverviewModel.items, lhcFillsActiveColumns, null, { tableClasses: '.table-sm' }), + paginationComponent(lhcFillsOverviewModel.pagination), ]), ]; }; diff --git a/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js b/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js index 6d99d48fd5..c43b04b917 100644 --- a/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js +++ b/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js @@ -15,16 +15,19 @@ import { h } from '/js/src/index.js'; import { iconCommentSquare, iconPaperclip } from '/js/src/icons.js'; import { authorFilter } from '../../../components/Filters/LogsFilter/author/authorFilter.js'; +import createdFilter from '../../../components/Filters/LogsFilter/created.js'; +import runsFilter from '../../../components/Filters/LogsFilter/runs.js'; import { formatTimestamp } from '../../../utilities/formatting/formatTimestamp.js'; import { frontLink } from '../../../components/common/navigation/frontLink.js'; import { frontLinks } from '../../../components/common/navigation/frontLinks.js'; import { tagFilter } from '../../../components/Filters/common/filters/tagFilter.js'; import { formatRunsList } from '../../Runs/format/formatRunsList.js'; import { profiles } from '../../../components/common/table/profiles.js'; +import { textFilter } from '../../../components/Filters/common/filters/textFilter.js'; +import { environmentFilter } from '../../../components/Filters/LogsFilter/environments.js'; import { formatLhcFillsList } from '../../LhcFills/format/formatLhcFillsList.js'; +import { lhcFillsFilter } from '../../../components/Filters/LogsFilter/lhcFill.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'; /** * A method to display a small and simple number/icon collection as a column @@ -68,14 +71,13 @@ export const logsActiveColumns = { visible: true, sortable: true, size: 'w-30', - - /** - * Title filter component - * - * @param {FilteringModel} logOverviewModel.filteringModel filtering model - * @return {Component} the filter component - */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'title', 'e.g. Report on runs: ...'), + filter: ({ titleFilter }) => textFilter( + titleFilter, + { + id: 'titleFilterText', + class: 'w-75 mt1', + }, + ), balloon: true, profiles: { embeded: true, @@ -90,14 +92,13 @@ export const logsActiveColumns = { name: 'Content', visible: false, size: 'w-10', - - /** - * Content filter component - * - * @param {FilteringModel} logOverviewModel.filteringModel the filtering model - * @return {Component} the filter component - */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'content', 'e.g. Quality of run 52...'), + filter: ({ contentFilter }) => textFilter( + contentFilter, + { + id: 'contentFilterText', + class: 'w-75 mt1', + }, + ), }, author: { name: 'Author', @@ -105,14 +106,7 @@ export const logsActiveColumns = { sortable: true, size: 'w-15', format: (author) => author.name, - - /** - * Author filter component - * - * @param {FilteringModel} logOverviewModel.filteringModel filtering model - * @return {Component} the filter component - */ - filter: ({ filteringModel }) => authorFilter(filteringModel.get('author')), + filter: authorFilter, profiles: [profiles.none, 'embeded'], }, createdAt: { @@ -121,14 +115,7 @@ export const logsActiveColumns = { sortable: true, size: 'w-10', format: (timestamp) => formatTimestamp(timestamp, false), - - /** - * Created filter component - * - * @param {FilteringModel} logOverviewModel.filteringModel filtering model - * @return {Component} the filter component - */ - filter: ({ filteringModel }) => timeRangeFilter(filteringModel.get('created')), + filter: createdFilter, profiles: { embeded: { format: (timestamp) => formatTimestamp(timestamp), @@ -150,11 +137,10 @@ export const logsActiveColumns = { /** * Tag filter component - * - * @param {FilteringModel} logOverviewModel.filteringModel filtering model + * @param {LogsOverviewModel} logsModel the log model * @return {Component} the filter component */ - filter: ({ filteringModel }) => tagFilter(filteringModel.get('tags')), + filter: (logsModel) => tagFilter(logsModel.listingTagsFilterModel), balloon: true, profiles: [profiles.none, 'embeded'], }, @@ -164,14 +150,7 @@ export const logsActiveColumns = { sortable: true, size: 'w-15', format: formatRunsList, - - /** - * Runs filter component - * - * @param {FilteringModel} logOverviewModel.filteringModel filtering model - * @return {Component} the filter component - */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'runNumbers', 'e.g. 553203, 553221, ...'), + filter: runsFilter, balloon: true, profiles: [profiles.none, 'embeded'], }, @@ -188,14 +167,7 @@ export const logsActiveColumns = { parameters: { environmentId: id }, }), ), - - /** - * Environment filter component - * - * @param {FilteringModel} logOverviewModel.filteringModel filtering model - * @return {Component} the filter component - */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'environmentIds', 'e.g. Dxi029djX, TDI59So3d...'), + filter: environmentFilter, balloon: true, profiles: [profiles.none, 'embeded'], }, @@ -205,14 +177,7 @@ export const logsActiveColumns = { sortable: false, size: 'w-10', format: formatLhcFillsList, - - /** - * LhcFills filter component - * - * @param {FilteringModel} logOverviewModel.filteringModel filtering model - * @return {Component} the filter component - */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'fillNumbers', 'e.g. 11392, 11383, 7625'), + filter: lhcFillsFilter, balloon: true, profiles: [profiles.none, 'embeded'], }, diff --git a/lib/public/views/Logs/LogsModel.js b/lib/public/views/Logs/LogsModel.js index 1c894620a4..b4f9342d42 100644 --- a/lib/public/views/Logs/LogsModel.js +++ b/lib/public/views/Logs/LogsModel.js @@ -30,7 +30,7 @@ export class LogsModel extends Observable { super(); this.model = model; - this._overviewModel = new LogsOverviewModel(model, false, 'log-overview'); + this._overviewModel = new LogsOverviewModel(model); this._overviewModel.bubbleTo(this); this._treeViewModel = new LogTreeViewModel(); @@ -55,8 +55,7 @@ export class LogsModel extends Observable { */ loadOverview() { if (!this._overviewModel.pagination.isInfiniteScrollEnabled) { - this._overviewModel.setFilterFromURL(false); - this._overviewModel.load(); + this._overviewModel.fetchLogs(); } } diff --git a/lib/public/views/Logs/Overview/LogsOverviewModel.js b/lib/public/views/Logs/Overview/LogsOverviewModel.js index f8244d42a8..cce376438b 100644 --- a/lib/public/views/Logs/Overview/LogsOverviewModel.js +++ b/lib/public/views/Logs/Overview/LogsOverviewModel.js @@ -11,49 +11,410 @@ * or submit itself to any jurisdiction. */ +import { buildUrl, Observable, RemoteData } from '/js/src/index.js'; import { TagFilterModel } from '../../../components/Filters/common/TagFilterModel.js'; +import { SortModel } from '../../../components/common/table/SortModel.js'; +import { debounce } from '../../../utilities/debounce.js'; +import { FilterInputModel } from '../../../components/Filters/common/filters/FilterInputModel.js'; import { AuthorFilterModel } from '../../../components/Filters/LogsFilter/author/AuthorFilterModel.js'; +import { PaginationModel } from '../../../components/Pagination/PaginationModel.js'; +import { getRemoteDataSlice } from '../../../utilities/fetch/getRemoteDataSlice.js'; import { tagsProvider } from '../../../services/tag/tagsProvider.js'; -import { RawTextFilterModel } from '../../../components/Filters/common/filters/RawTextFilterModel.js'; -import { TimeRangeInputModel } from '../../../components/Filters/common/filters/TimeRangeInputModel.js'; -import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; /** * Model representing handlers for log entries page * * @implements {OverviewModel} */ -export class LogsOverviewModel extends FilterableOverviewPageModel { +export class LogsOverviewModel extends Observable { /** * The constructor of the Overview model object * * @param {Model} model global model * @param {boolean} excludeAnonymous Whether to exclude anonymous logs - * @param {string} pageIdentifier string that indicates what page this model represents - */ - constructor(model, excludeAnonymous = false, pageIdentifier) { - super( - model.router, - pageIdentifier, - { - author: new AuthorFilterModel(), - title: new RawTextFilterModel(), - content: new RawTextFilterModel(), - tags: new TagFilterModel(tagsProvider.items$), - runNumbers: new RawTextFilterModel(), - environmentIds: new RawTextFilterModel(), - fillNumbers: new RawTextFilterModel(), - created: new TimeRangeInputModel(), - }, + */ + constructor(model, excludeAnonymous = false) { + super(); + + this.model = model; + + // Sub-models + this._listingTagsFilterModel = new TagFilterModel(tagsProvider.items$); + this._listingTagsFilterModel.observe(() => this._applyFilters()); + this._listingTagsFilterModel.visualChange$.bubbleTo(this); + + this._overviewSortModel = new SortModel(); + this._overviewSortModel.observe(() => this._applyFilters(true)); + this._overviewSortModel.visualChange$.bubbleTo(this); + + this._pagination = new PaginationModel(); + this._pagination.observe(() => this.fetchLogs()); + this._pagination.itemsPerPageSelector$.observe(() => this.notify()); + + // Filtering models + this._authorFilter = new AuthorFilterModel(); + this._registerFilter(this._authorFilter); + + this._titleFilter = new FilterInputModel(); + this._registerFilter(this._titleFilter); + + this._contentFilter = new FilterInputModel(); + this._registerFilter(this._contentFilter); + + this._logs = RemoteData.NotAsked(); + + const updateDebounceTime = () => { + this._debouncedFetchAllLogs = debounce(this.fetchLogs.bind(this), model.inputDebounceTime); + }; + model.appConfiguration$.observe(() => updateDebounceTime()); + updateDebounceTime(); + + excludeAnonymous && this._authorFilter.update('!Anonymous'); + + this.reset(false); + } + + /** + * Retrieve every relevant log from the API + * @returns {Promise} Injects the data object with the response data + */ + async fetchLogs() { + const keepExisting = this._pagination.currentPage > 1 && this._pagination.isInfiniteScrollEnabled; + + if (!keepExisting) { + this._logs = RemoteData.loading(); + this.notify(); + } + + const params = { + ...this._getFilterQueryParams(), + 'page[offset]': this._pagination.firstItemOffset, + 'page[limit]': this._pagination.itemsPerPage, + }; + + const endpoint = buildUrl('/api/logs', params); + + try { + const { items, totalCount } = await getRemoteDataSlice(endpoint); + const concatenateWith = keepExisting ? this._logs.payload ?? [] : []; + this._logs = RemoteData.success([...concatenateWith, ...items]); + this._pagination.itemsCount = totalCount; + } catch (errors) { + this._logs = RemoteData.failure(errors); + } + + this.notify(); + } + + /** + * Return current logs + * @return {RemoteData<*[]>} current data + */ + get logs() { + return this._logs; + } + + /** + * Reset all filtering, sorting and pagination settings to their default values + * + * @param {boolean} fetch Whether to refetch all logs after filters have been reset + * @return {undefined} + */ + reset(fetch = true) { + this.titleFilter.reset(); + this.contentFilter.reset(); + this.authorFilter.reset(); + + this.createdFilterFrom = ''; + this.createdFilterTo = ''; + + this.listingTagsFilterModel.reset(); + + this.runFilterOperation = 'AND'; + this.runFilterValues = []; + this._runFilterRawValue = ''; + + this.environmentFilterOperation = 'AND'; + this.environmentFilterValues = []; + this._environmentFilterRawValue = ''; + + this.lhcFillFilterOperation = 'AND'; + this.lhcFillFilterValues = []; + this._lhcFillFilterRawValue = ''; + + this._pagination.reset(); + + if (fetch) { + this._applyFilters(true); + } + } + + /** + * Checks if any filter value has been modified from their default (empty) + * @returns {boolean} If any filter is active + */ + isAnyFilterActive() { + return ( + !this._titleFilter.isEmpty + || !this._contentFilter.isEmpty + || !this._authorFilter.isEmpty + || this.createdFilterFrom !== '' + || this.createdFilterTo !== '' + || !this.listingTagsFilterModel.isEmpty + || this.runFilterValues.length !== 0 + || this.environmentFilterValues.length !== 0 + || this.lhcFillFilterValues.length !== 0 ); + } + + /** + * Returns the current title substring filter + * @returns {string} The current title substring filter + */ + getRunsFilterRaw() { + return this._runFilterRawValue; + } + + /** + * Add a run to the filter + * @param {string} rawRuns The runs to be added to the filter criteria + * @returns {undefined} + */ + setRunsFilter(rawRuns) { + this._runFilterRawValue = rawRuns; + const runs = []; + const valuesRegex = /([0-9]+),?/g; + + let match = valuesRegex.exec(rawRuns); + while (match) { + runs.push(parseInt(match[1], 10)); + match = valuesRegex.exec(rawRuns); + } + + // Allow empty runs only if raw runs is an empty string + if (runs.length > 0 || rawRuns.length === 0) { + this.runFilterValues = runs; + this._applyFilters(); + } + } + + /** + * Returns the raw current environment filter + * @returns {string} the raw current environment filter + */ + getEnvFilterRaw() { + return this._environmentFilterRawValue; + } + + /** + * Returns the current environment filter + * @returns {string[]} The current environment filter + */ + getEnvFilter() { + return this.environmentFilterValues; + } + + /** + * Sets the environment filter + * @param {string} rawEnvironments The environments to apply to the filter + * @returns {undefined} + */ + setEnvFilter(rawEnvironments) { + this._environmentFilterRawValue = rawEnvironments; + const envs = rawEnvironments + .split(/[ ,]+/) + .filter(Boolean) + .map((id) => id.trim()); + + if (envs.length > 0 || rawEnvironments.length === 0) { + this.environmentFilterValues = envs; + this._applyFilters(); + } + } + + /** + * Returns the current title substring filter + * @returns {string} The current title substring filter + */ + getLhcFillsFilterRaw() { + return this._lhcFillFilterRawValue; + } + + /** + * Add a lhcFill to the filter + * @param {string} rawLhcFills The LHC fills to be added to the filter criteria + * @returns {void} + */ + setLhcFillsFilter(rawLhcFills) { + this._lhcFillFilterRawValue = rawLhcFills; + + // Split the lhc fills string by comma or whitespace, remove falsy values like empty strings, and convert to int + const lhcFills = rawLhcFills + .split(/[ ,]+/) + .filter(Boolean) + .map((fillNumberStr) => parseInt(fillNumberStr.trim(), 10)); + + // Allow empty lhcFills only if raw lhcFills is an empty string + if (lhcFills.length > 0 || rawLhcFills.length === 0) { + this.lhcFillFilterValues = lhcFills; + this._applyFilters(); + } + } + + /** + * Returns the current minimum creation datetime + * @returns {Integer} The current minimum creation datetime + */ + getCreatedFilterFrom() { + return this.createdFilterFrom; + } - excludeAnonymous && this._filteringModel.get('author').update('!Anonymous'); + /** + * Returns the current maximum creation datetime + * @returns {Integer} The current maximum creation datetime + */ + getCreatedFilterTo() { + return this.createdFilterTo; } /** - * @inheritdoc + * Set a datetime for the creation datetime filter + * @param {string} key The filter value to apply the datetime to + * @param {Object} date The datetime to be applied to the creation datetime filter + * @param {boolean} valid Whether the inserted date passes validity check + * @returns {undefined} + */ + setCreatedFilter(key, date, valid) { + if (valid) { + this[`createdFilter${key}`] = date; + this._applyFilters(); + } + } + + /** + * Return the model handling the filtering on tags + * + * @return {TagFilterModel} the filtering model */ - getRootEndpoint() { - return this.buildRootEndpoint('/api/logs'); + get listingTagsFilterModel() { + return this._listingTagsFilterModel; + } + + /** + * Returns the model handling the overview page table sort + * + * @return {SortModel} the sort model + */ + get overviewSortModel() { + return this._overviewSortModel; + } + + /** + * Returns the filter model for author filter + * + * @return {FilterInputModel} the filter model + */ + get authorFilter() { + return this._authorFilter; + } + + /** + * Returns the filter model for title filter + * + * @return {FilterInputModel} the filter model + */ + get titleFilter() { + return this._titleFilter; + } + + /** + * Returns the model for body filter + * @return {FilterInputModel} the filter model + */ + get contentFilter() { + return this._contentFilter; + } + + /** + * Returns the pagination model + * + * @return {PaginationModel} the pagination model + */ + get pagination() { + return this._pagination; + } + + /** + * Apply the current filtering and update the remote data list + * + * @param {boolean} now if true, filtering will be applied now without debouncing + * + * @return {void} + */ + _applyFilters(now = false) { + this._pagination.silentlySetCurrentPage(1); + now ? this.fetchLogs() : this._debouncedFetchAllLogs(); + } + + /** + * Register a new filter model + * @param {FilterInputModel} filter the filter to register + * @return {void} + * @private + */ + _registerFilter(filter) { + filter.visualChange$.bubbleTo(this); + filter.observe(() => this._applyFilters()); + } + + /** + * Returns the list of URL params corresponding to the currently applied filter + * + * @return {Object} the URL params + * + * @private + */ + _getFilterQueryParams() { + const sortOn = this._overviewSortModel.appliedOn; + const sortDirection = this._overviewSortModel.appliedDirection; + + return { + ...!this._titleFilter.isEmpty && { + 'filter[title]': this._titleFilter.value, + }, + ...!this._contentFilter.isEmpty && { + 'filter[content]': this._contentFilter.value, + }, + ...!this._authorFilter.isEmpty && { + 'filter[author]': this._authorFilter.value, + }, + ...this.createdFilterFrom && { + 'filter[created][from]': + new Date(`${this.createdFilterFrom.replace(/\//g, '-')}T00:00:00.000`).getTime(), + }, + ...this.createdFilterTo && { + 'filter[created][to]': + new Date(`${this.createdFilterTo.replace(/\//g, '-')}T23:59:59.999`).getTime(), + }, + ...!this.listingTagsFilterModel.isEmpty && { + 'filter[tags][values]': this.listingTagsFilterModel.selected.join(), + 'filter[tags][operation]': this.listingTagsFilterModel.combinationOperator, + }, + ...this.runFilterValues.length > 0 && { + 'filter[run][values]': this.runFilterValues.join(), + 'filter[run][operation]': this.runFilterOperation.toLowerCase(), + }, + ...this.environmentFilterValues.length > 0 && { + 'filter[environments][values]': this.environmentFilterValues, + 'filter[environments][operation]': this.environmentFilterOperation.toLowerCase(), + }, + ...this.lhcFillFilterValues.length > 0 && { + 'filter[lhcFills][values]': this.lhcFillFilterValues.join(), + 'filter[lhcFills][operation]': this.lhcFillFilterOperation.toLowerCase(), + }, + ...sortOn && sortDirection && { + [`sort[${sortOn}]`]: sortDirection, + }, + }; } } diff --git a/lib/public/views/Logs/Overview/index.js b/lib/public/views/Logs/Overview/index.js index bf72f81c3a..012f6e7bfe 100644 --- a/lib/public/views/Logs/Overview/index.js +++ b/lib/public/views/Logs/Overview/index.js @@ -19,7 +19,6 @@ import { paginationComponent } from '../../../components/Pagination/paginationCo import { frontLink } from '../../../components/common/navigation/frontLink.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; import { excludeAnonymousLogAuthorToggle } from '../../../components/Filters/LogsFilter/author/authorFilter.js'; -import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 69; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -31,22 +30,22 @@ const PAGE_USED_HEIGHT = 215; * @return {Component} Returns a vnode with the table containing the logs */ const logOverviewScreen = ({ logs: { overviewModel: logsOverviewModel } }) => { - const { pagination, filteringModel, items, sortModel } = logsOverviewModel; - - pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); + logsOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( + TABLEROW_HEIGHT, + PAGE_USED_HEIGHT, + )); return h('', [ h('#main-action-bar.flex-row.justify-between.header-container.pv2', [ h('.flex-row.g3', [ filtersPanelPopover(logsOverviewModel, logsActiveColumns), - excludeAnonymousLogAuthorToggle(filteringModel.get('author')), + excludeAnonymousLogAuthorToggle(logsOverviewModel.authorFilter), ]), actionButtons(), ]), - warningComponent(logsOverviewModel), h('.w-100.flex-column', [ - table(items, logsActiveColumns, null, null, { sort: sortModel }), - paginationComponent(pagination), + table(logsOverviewModel.logs, logsActiveColumns, null, null, { sort: logsOverviewModel.overviewSortModel }), + paginationComponent(logsOverviewModel.pagination), ]), ]); }; diff --git a/lib/public/views/QcFlagTypes/ActiveColumns/qcFlagTypesActiveColumns.js b/lib/public/views/QcFlagTypes/ActiveColumns/qcFlagTypesActiveColumns.js index 6f1ae72b84..7f4ae8aa69 100644 --- a/lib/public/views/QcFlagTypes/ActiveColumns/qcFlagTypesActiveColumns.js +++ b/lib/public/views/QcFlagTypes/ActiveColumns/qcFlagTypesActiveColumns.js @@ -14,8 +14,8 @@ import { h } from '/js/src/index.js'; import { formatTimestamp } from '../../../utilities/formatting/formatTimestamp.js'; import { textFilter } from '../../../components/Filters/common/filters/textFilter.js'; +import { checkboxes } from '../../../components/Filters/common/filters/checkboxFilter.js'; import { qcFlagTypeColoredBadge } from '../../../components/qcFlags/qcFlagTypeColoredBadge.js'; -import radioButtonFilter from '../../../components/Filters/common/filters/radioButtonFilter.js'; /** * List of active columns for a QC Flag Types table @@ -30,7 +30,10 @@ export const qcFlagTypesActiveColumns = { name: { name: 'Name', visible: true, - filter: ({ filteringModel }) => textFilter(filteringModel.get('names'), { class: 'w-75 mt1', placeholder: 'e.g. BadPID, ...' }), + filter: ({ namesFilterModel }) => textFilter( + namesFilterModel, + { class: 'w-75 mt1', placeholder: 'e.g. BadPID, ...' }, + ), classes: 'f6', sortable: true, format: (_, qcFlagType) => qcFlagTypeColoredBadge(qcFlagType), @@ -40,7 +43,10 @@ export const qcFlagTypesActiveColumns = { name: 'Method', visible: true, sortable: true, - filter: ({ filteringModel }) => textFilter(filteringModel.get('methods'), { class: 'w-75 mt1', placeholder: 'e.g. Bad PID, ...' }), + filter: ({ methodsFilterModel }) => textFilter( + methodsFilterModel, + { class: 'w-75 mt1', placeholder: 'e.g. Bad PID, ...' }, + ), classes: 'f6', }, @@ -48,7 +54,10 @@ export const qcFlagTypesActiveColumns = { name: 'Bad', visible: true, sortable: true, - filter: ({ filteringModel }) => radioButtonFilter(filteringModel.get('bad'), 'bad'), + filter: ({ isBadFilterModel }) => checkboxes( + isBadFilterModel, + { class: 'w-75 mt1', selector: 'qc-flag-type-bad-filter' }, + ), classes: 'f6 w-5', format: (bad) => bad ? h('.danger', 'Yes') : h('.success', 'No'), }, diff --git a/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewModel.js b/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewModel.js index 8f12861e8f..6c80ada996 100644 --- a/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewModel.js +++ b/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewModel.js @@ -12,34 +12,107 @@ */ import { TextTokensFilterModel } from '../../../components/Filters/common/filters/TextTokensFilterModel.js'; -import { RadioButtonFilterModel } from '../../../components/Filters/common/RadioButtonFilterModel.js'; -import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; +import { OverviewPageModel } from '../../../models/OverviewModel.js'; +import { SelectionModel } from '../../../components/common/selection/SelectionModel.js'; +import { buildUrl } from '/js/src/index.js'; /** * QcFlagTypesOverviewModel */ -export class QcFlagTypesOverviewModel extends FilterableOverviewPageModel { +export class QcFlagTypesOverviewModel extends OverviewPageModel { /** * Constructor - * @param {QueryRouter} router router that controls the application's page navigation - * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(router, pageIdentifier) { - super( - router, - pageIdentifier, - { - names: new TextTokensFilterModel(), - methods: new TextTokensFilterModel(), - bad: new RadioButtonFilterModel([{ label: 'Any' }, { label: 'Bad', value: true }, { label: 'Not Bad', value: false }]), - }, - ); + constructor() { + super(); + + this._namesFilterModel = new TextTokensFilterModel(); + this._registerFilter(this._namesFilterModel); + this._methodsFilterModel = new TextTokensFilterModel(); + this._registerFilter(this._methodsFilterModel); + this._isBadFilterModel = + new SelectionModel({ availableOptions: [{ label: 'Bad', value: true }, { label: 'Not Bad', value: false }] }); + this._registerFilter(this._isBadFilterModel); } /** * @inheritdoc */ getRootEndpoint() { - return this.buildRootEndpoint('/api/qcFlagTypes'); + const params = {}; + if (this.isAnyFilterActive()) { + params.filter = { + names: this._namesFilterModel.normalized, + methods: this._methodsFilterModel.normalized, + bad: this._isBadFilterModel.selected.length === 2 + ? undefined + : this._isBadFilterModel.selected[0], + }; + } + + return buildUrl('/api/qcFlagTypes', params); + } + + /** + * Get names filter model + * + * @return {TextTokensFilterModel} names filter model + */ + get namesFilterModel() { + return this._namesFilterModel; + } + + /** + * Get methods filter model + * + * @return {TextTokensFilterModel} methods filter model + */ + get methodsFilterModel() { + return this._methodsFilterModel; + } + + /** + * Returns filter model for filtering bad and not bad flags + * + * @return {TextTokensFilterModel} filter model for filtering bad and not bad flags + */ + get isBadFilterModel() { + return this._isBadFilterModel; + } + + /** + * Register a new filter model + * + * @param {FilterModel} filterModel the filter model to register + * @return {void} + * @private + */ + _registerFilter(filterModel) { + filterModel.visualChange$.bubbleTo(this); + filterModel.observe(() => { + this._pagination.silentlySetCurrentPage(1); + this.load(); + }); + } + + /** + * States whether any filter is active + * + * @return {boolean} true if any filter is active + */ + isAnyFilterActive() { + return !this._namesFilterModel.isEmpty || !this._methodsFilterModel.isEmpty || this._isBadFilterModel.selected.length; + } + + /** + * Reset this model to its default + * + * @returns {void} + */ + reset() { + this._methodsFilterModel.reset(); + this._namesFilterModel.reset(); + this._isBadFilterModel.reset(); + super.reset(); } } diff --git a/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewPage.js b/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewPage.js index 0c3fb2a71e..6b2a818527 100644 --- a/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewPage.js +++ b/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewPage.js @@ -19,7 +19,6 @@ import { qcFlagTypesActiveColumns } from '../ActiveColumns/qcFlagTypesActiveColu import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; import { frontLink } from '../../../components/common/navigation/frontLink.js'; import { BkpRoles } from '../../../domain/enums/BkpRoles.js'; -import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 30; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -31,9 +30,12 @@ const PAGE_USED_HEIGHT = 215; * @return {Component} The overview page */ export const QcFlagTypesOverviewPage = ({ qcFlagTypes: { overviewModel } }) => { - const { items: qcFlagTypes, pagination, sortModel } = overviewModel; + overviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( + TABLEROW_HEIGHT, + PAGE_USED_HEIGHT, + )); - pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); + const { items: qcFlagTypes } = overviewModel; return h('', [ h('.flex-row.justify-between.items-center.g2', [ @@ -48,10 +50,15 @@ export const QcFlagTypesOverviewPage = ({ qcFlagTypes: { overviewModel } }) => { }), ], ]), - warningComponent(overviewModel), h('.flex-column.w-100', [ - table(qcFlagTypes, qcFlagTypesActiveColumns, { classes: '.table-sm' }, null, { sort: sortModel }), - paginationComponent(pagination), + table( + qcFlagTypes, + qcFlagTypesActiveColumns, + { classes: '.table-sm' }, + null, + { sort: overviewModel.sortModel }, + ), + paginationComponent(overviewModel.pagination), ]), ]); }; diff --git a/lib/public/views/QcFlagTypes/QcFlagTypesModel.js b/lib/public/views/QcFlagTypes/QcFlagTypesModel.js index 9fe8118a76..43468d3e34 100644 --- a/lib/public/views/QcFlagTypes/QcFlagTypesModel.js +++ b/lib/public/views/QcFlagTypes/QcFlagTypesModel.js @@ -29,7 +29,7 @@ export class QcFlagTypesModel extends Observable { this.model = model; // Overview - this._overviewModel = new QcFlagTypesOverviewModel(model.router, 'qc-flag-types-overview'); + this._overviewModel = new QcFlagTypesOverviewModel(); this._overviewModel.bubbleTo(this); } @@ -38,7 +38,6 @@ export class QcFlagTypesModel extends Observable { * @return {void} */ loadOverview() { - this._overviewModel.setFilterFromURL(false); this._overviewModel.load(); } diff --git a/lib/public/views/QcFlags/ActiveColumns/synchronousQcFlagsActiveColumns.js b/lib/public/views/QcFlags/ActiveColumns/synchronousQcFlagsActiveColumns.js deleted file mode 100644 index e087e2b780..0000000000 --- a/lib/public/views/QcFlags/ActiveColumns/synchronousQcFlagsActiveColumns.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @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 { h } from '/js/src/index.js'; -import { qcFlagsActiveColumns } from './qcFlagsActiveColumns.js'; -import { formatQcFlagStart } from '../format/formatQcFlagStart.js'; -import { formatQcFlagEnd } from '../format/formatQcFlagEnd.js'; -import { formatQcFlagCreatedBy } from '../format/formatQcFlagCreatedBy.js'; -import { formatTimestamp } from '../../../utilities/formatting/formatTimestamp.js'; - -/** - * Active columns configuration for synchronous QC flags table - */ -export const synchronousQcFlagsActiveColumns = { - id: { - name: 'Id', - visible: false, - }, - flagType: { - ...qcFlagsActiveColumns.flagType, - classes: 'w-15', - }, - from: { - name: 'From/To', - visible: true, - format: (_, qcFlag) => h('', [ - h('.flex-row', ['From: ', formatQcFlagStart(qcFlag, true)]), - h('.flex-row', ['To: ', formatQcFlagEnd(qcFlag, true)]), - ]), - classes: 'w-15', - }, - comment: { - ...qcFlagsActiveColumns.comment, - balloon: true, - }, - deleted: { - name: 'Deleted', - visible: true, - classes: 'w-5', - format: (deleted) => deleted ? h('.danger', 'Yes') : 'No', - }, - createdBy: { - name: 'Created', - visible: true, - balloon: true, - format: (_, qcFlag) => h('', [ - h('.flex-row', ['By: ', formatQcFlagCreatedBy(qcFlag)]), - h('.flex-row', ['At: ', formatTimestamp(qcFlag.createdAt)]), - ]), - }, -}; diff --git a/lib/public/views/QcFlags/Synchronous/SynchronousQcFlagsOverviewPage.js b/lib/public/views/QcFlags/Synchronous/SynchronousQcFlagsOverviewPage.js index 8fa5058b25..b8937c51ba 100644 --- a/lib/public/views/QcFlags/Synchronous/SynchronousQcFlagsOverviewPage.js +++ b/lib/public/views/QcFlags/Synchronous/SynchronousQcFlagsOverviewPage.js @@ -16,7 +16,7 @@ import { h } from '/js/src/index.js'; import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; import { table } from '../../../components/common/table/table.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; -import { synchronousQcFlagsActiveColumns } from '../ActiveColumns/synchronousQcFlagsActiveColumns.js'; +import { qcFlagsActiveColumns } from '../ActiveColumns/qcFlagsActiveColumns.js'; import { qcFlagsBreadcrumbs } from '../../../components/qcFlags/qcFlagsBreadcrumbs.js'; import { mergeRemoteData } from '../../../utilities/mergeRemoteData.js'; import errorAlert from '../../../components/common/errorAlert.js'; @@ -46,6 +46,16 @@ export const SynchronousQcFlagsOverviewPage = ({ qcFlags: { synchronousOverviewM PAGE_USED_HEIGHT, )); + const activeColumns = { + qcFlagId: { + name: 'Id', + visible: false, + classes: 'w-5', + }, + ...qcFlagsActiveColumns, + }; + delete activeColumns.verified; + return h( '', { onremove: () => synchronousOverviewModel.reset() }, @@ -60,8 +70,8 @@ export const SynchronousQcFlagsOverviewPage = ({ qcFlags: { synchronousOverviewM h('.w-100.flex-column', [ table( qcFlags, - synchronousQcFlagsActiveColumns, - { classes: '.table-sm.f6' }, + activeColumns, + { classes: '.table-sm' }, null, { sort: sortModel }, ), diff --git a/lib/public/views/QcFlags/format/formatQcFlagEnd.js b/lib/public/views/QcFlags/format/formatQcFlagEnd.js index 9cb2a7857d..dac1426802 100644 --- a/lib/public/views/QcFlags/format/formatQcFlagEnd.js +++ b/lib/public/views/QcFlags/format/formatQcFlagEnd.js @@ -17,12 +17,11 @@ import { formatTimestamp } from '../../../utilities/formatting/formatTimestamp.j * Format QC flag `to` timestamp * * @param {QcFlag} qcFlag QC flag - * @param {boolean} inline if true, date and time are on a single line * @return {Component} formatted `to` timestamp */ -export const formatQcFlagEnd = ({ from, to }, inline = false) => { +export const formatQcFlagEnd = ({ from, to }) => { if (to) { - return formatTimestamp(to, inline); + return formatTimestamp(to, false); } else { return from ? 'Until run end' diff --git a/lib/public/views/QcFlags/format/formatQcFlagStart.js b/lib/public/views/QcFlags/format/formatQcFlagStart.js index bf9e8ccae5..b5a11b9b6d 100644 --- a/lib/public/views/QcFlags/format/formatQcFlagStart.js +++ b/lib/public/views/QcFlags/format/formatQcFlagStart.js @@ -17,12 +17,11 @@ import { formatTimestamp } from '../../../utilities/formatting/formatTimestamp.j * Format QC flag `from` timestamp * * @param {QcFlag} qcFlag QC flag - * @param {boolean} inline if true, date and time are on a single line * @return {Component} formatted `from` timestamp */ -export const formatQcFlagStart = ({ from, to }, inline = false) => { +export const formatQcFlagStart = ({ from, to }) => { if (from) { - return formatTimestamp(from, inline); + return formatTimestamp(from, false); } else { return to ? 'Since run start' diff --git a/lib/public/views/Runs/ActiveColumns/runDetectorsAsyncQcActiveColumns.js b/lib/public/views/Runs/ActiveColumns/runDetectorsAsyncQcActiveColumns.js index 2647f8589a..f4497010c4 100644 --- a/lib/public/views/Runs/ActiveColumns/runDetectorsAsyncQcActiveColumns.js +++ b/lib/public/views/Runs/ActiveColumns/runDetectorsAsyncQcActiveColumns.js @@ -161,7 +161,7 @@ export const createRunDetectorsAsyncQcActiveColumns = ( visible: false, profiles: profile, filter: (filteringModel) => { - const filterModel = filteringModel.get('detectorsQcNotBadFraction').getFilter(`_${dplDetectorId}`); + const filterModel = filteringModel.get(`detectorsQc[_${dplDetectorId}][notBadFraction]`); return filterModel ? numericalComparisonFilter(filterModel, { step: 0.1, selectorPrefix: `detectorsQc-for-${dplDetectorId}-notBadFraction` }) : null; diff --git a/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js b/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js index 9992a93407..eefe0f006f 100644 --- a/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js +++ b/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js @@ -12,7 +12,11 @@ */ import { CopyToClipboardComponent, h } from '/js/src/index.js'; +import { runNumbersFilter } from '../../../components/Filters/RunsFilter/runNumbersFilter.js'; import { displayRunEorReasonsOverview } from '../format/displayRunEorReasonOverview.js'; +import ddflpFilter from '../../../components/Filters/RunsFilter/ddflp.js'; +import dcsFilter from '../../../components/Filters/RunsFilter/dcs.js'; +import epnFilter from '../../../components/Filters/RunsFilter/epn.js'; import { formatTimestamp } from '../../../utilities/formatting/formatTimestamp.js'; import { displayRunDuration } from '../format/displayRunDuration.js'; import { frontLink } from '../../../components/common/navigation/frontLink.js'; @@ -40,10 +44,10 @@ import { isRunConsideredRunning } from '../../../services/run/isRunConsideredRun import { aliEcsEnvironmentLinkComponent } from '../../../components/common/externalLinks/aliEcsEnvironmentLinkComponent.js'; import { detectorsFilterComponent } from '../../../components/Filters/RunsFilter/detectorsFilterComponent.js'; import { timeRangeFilter } from '../../../components/Filters/common/filters/timeRangeFilter.js'; +import { rawTextFilter } from '../../../components/Filters/common/filters/rawTextFilter.js'; import { numericalComparisonFilter } from '../../../components/Filters/common/filters/numericalComparisonFilter.js'; import { checkboxes } from '../../../components/Filters/common/filters/checkboxFilter.js'; -import radioButtonFilter from '../../../components/Filters/common/filters/radioButtonFilter.js'; -import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; +import { triggerValueFilter } from '../../../components/Filters/RunsFilter/triggerValueFilter.js'; /** * List of active columns for a generic runs table @@ -63,10 +67,10 @@ export const runsActiveColumns = { /** * Run numbers filter component * - * @param {FilteringModel} runsOverviewModel.filteringModel the filtering model + * @param {RunsOverviewModel} runsOverviewModel the runs overview model * @return {Component} the filter component */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'runNumbers', 'e.g. 534454, 534455...'), + filter: (runsOverviewModel) => runNumbersFilter(runsOverviewModel.filteringModel.get('runNumbers')), format: (runNumber, run) => buttonLinkWithDropdown( runNumber, 'run-detail', @@ -158,7 +162,8 @@ export const runsActiveColumns = { * @param {RunsOverviewModel} runsOverviewModel the runs overview model * @return {Component} the beam modes filter component */ - filter: (runsOverviewModel) => selectionDropdown(runsOverviewModel.filteringModel.get('beamModes'), { selectorPrefix: 'beam-mode' }), + filter: (runsOverviewModel) => + selectionDropdown(runsOverviewModel.filteringModel.get('beamModes').selectionDropdownModel, { selectorPrefix: 'beam-mode' }), }, fillNumber: { name: 'Fill No.', @@ -184,10 +189,13 @@ export const runsActiveColumns = { /** * Fill number filter component * - * @param {FilteringModel} RunsOverviewModel.filteringModel the filtering model + * @param {RunsOverviewModel} runsOverviewModel the runs overview model * @return {Component} the filter component */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'fillNumbers', 'e.g. 7966, 7954, 7948...'), + filter: (runsOverviewModel) => rawTextFilter( + runsOverviewModel.filteringModel.get('fillNumbers'), + { classes: ['w-100', 'fill-numbers-filter'], placeholder: 'e.g. 7966, 7954, 7948...' }, + ), }, lhcPeriod: { name: 'LHC Period', @@ -199,10 +207,13 @@ export const runsActiveColumns = { /** * LHC Periods filter * - * @param {FilteringModel} RunsOverviewModel.filteringModel the filtering model + * @param {RunsOverviewModel} runsOverviewModel the runs overview model * @return {Component} the filter component */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'lhcPeriods', 'e.g. LHC22b, LHC22a...'), + filter: (runsOverviewModel) => rawTextFilter( + runsOverviewModel.filteringModel.get('lhcPeriods'), + { classes: ['w-100'], placeholder: 'e.g. LHC22b, LHC22a...' }, + ), }, timeO2Start: { name: 'Start', @@ -388,10 +399,13 @@ export const runsActiveColumns = { /** * Environment ids filter component * - * @param {FilteringModel} RunsOverviewModel.filteringModel the filtering model - * @return {Component} the filter component + * @param {RunsOverviewModel} runsOverviewModel the runs overview model + * @return {Component} the environment ids filter component */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'environmentIds', 'e.g. Dxi029djX, TDI59So3d...'), + filter: (runsOverviewModel) => rawTextFilter( + runsOverviewModel.filteringModel.get('environmentIds'), + { classes: ['environment-ids-filter', 'w-100'], placeholder: 'e.g. Dxi029djX, TDI59So3d...' }, + ), format: (id) => id ? frontLink(id, 'env-details', { environmentId: id }) : '-', }, runType: { @@ -406,7 +420,10 @@ export const runsActiveColumns = { * @param {RunsOverviewModel} runsOverviewModel the runs overview model * @return {Component} the run types filter component */ - filter: (runsOverviewModel) => selectionDropdown(runsOverviewModel.filteringModel.get('runTypes'), { selectorPrefix: 'run-types' }), + filter: (runsOverviewModel) => selectionDropdown( + runsOverviewModel.filteringModel.get('runTypes').selectionDropdownModel, + { selectorPrefix: 'run-types' }, + ), }, runQuality: { name: 'Quality', @@ -437,7 +454,7 @@ export const runsActiveColumns = { * @param {RunsOverviewModel} runsOverviewModel the runs overview model * @return {Component} the run quality filter component */ - filter: (runsOverviewModel) => checkboxes(runsOverviewModel.filteringModel.get('runQualities')), + filter: (runsOverviewModel) => checkboxes(runsOverviewModel.filteringModel.get('runQualities').selectionModel), }, nDetectors: { name: 'DETs #', @@ -508,7 +525,7 @@ export const runsActiveColumns = { classes: 'w-2 f6 w-wrapped', format: (boolean) => boolean ? 'On' : 'Off', exportFormat: (boolean) => boolean ? 'On' : 'Off', - filter: ({ filteringModel }) => radioButtonFilter(filteringModel.get('ddflp'), 'ddFlp'), + filter: ddflpFilter, }, dcs: { name: 'DCS', @@ -517,21 +534,14 @@ export const runsActiveColumns = { classes: 'w-2 f6 w-wrapped', format: (boolean) => boolean ? 'On' : 'Off', exportFormat: (boolean) => boolean ? 'On' : 'Off', - filter: ({ filteringModel }) => radioButtonFilter(filteringModel.get('dcs'), 'dcs'), + filter: dcsFilter, }, triggerValue: { name: 'TRG', visible: true, profiles: [profiles.none, 'lhcFill', 'environment'], classes: 'w-5 f6 w-wrapped', - - /** - * TriggerValue filter component - * - * @param {RunsOverviewModel} runsOverviewModel the runs overview model - * @return {Component} the trigger value filter component - */ - filter: ({ filteringModel }) => checkboxes(filteringModel.get('triggerValues'), { selector: 'triggerValue' }), + filter: triggerValueFilter, format: (trgValue) => trgValue ? trgValue : '-', }, epn: { @@ -541,7 +551,7 @@ export const runsActiveColumns = { classes: 'w-2 f6 w-wrapped', format: (boolean) => boolean ? 'On' : 'Off', exportFormat: (boolean) => boolean ? 'On' : 'Off', - filter: ({ filteringModel }) => radioButtonFilter(filteringModel.get('epn'), 'epn'), + filter: epnFilter, }, epnTopology: { name: 'EPN Topology', @@ -558,10 +568,13 @@ export const runsActiveColumns = { /** * ODC topology full name filter component * - * @param {FilteringModel} RunsOverviewModel.filteringModel the filtering model + * @param {RunsOverviewModel} runsOverviewModel the runs overview model * @return {Component} the filter component */ - filter: ({ filteringModel }) => textInputFilter(filteringModel, 'odcTopologyFullName'), + filter: (runsOverviewModel) => rawTextFilter( + runsOverviewModel.filteringModel.get('odcTopologyFullName'), + { classes: ['w-100'] }, + ), balloon: true, }, eorReasons: { @@ -663,8 +676,10 @@ export const runsActiveColumns = { * @param {RunsOverviewModel} runsOverviewModel the runs overview model * @return {Component} the run types filter component */ - filter: (runsOverviewModel) => - selectionDropdown(runsOverviewModel.filteringModel.get('magnets'), { selectorPrefix: 'l3-dipole-current' }), + filter: (runsOverviewModel) => selectionDropdown( + runsOverviewModel.filteringModel.get('magnets').selectionDropdownModel, + { selectorPrefix: 'l3-dipole-current' }, + ), profiles: ['runsPerLhcPeriod', 'runsPerDataPass', 'runsPerSimulationPass', profiles.none], }, diff --git a/lib/public/views/Runs/Details/RunPatch.js b/lib/public/views/Runs/Details/RunPatch.js index 54f7e347c6..3b000b8223 100644 --- a/lib/public/views/Runs/Details/RunPatch.js +++ b/lib/public/views/Runs/Details/RunPatch.js @@ -9,7 +9,6 @@ import { RunQualities } from '../../../domain/enums/RunQualities.js'; * @property {string} category * @property {string} title * @property {string} description - * @property {string|null} [lastEditedName] */ /** @@ -76,8 +75,7 @@ export class RunPatch extends Observable { } if (this._eorReasons.length !== this._run.eorReasons.length || this._eorReasons.some(({ id }) => id === undefined)) { - // Strip lastEditedName — the server's EorReasonDto only accepts id, reasonTypeId, and description - ret.eorReasons = this._eorReasons.map(({ id, reasonTypeId, description }) => ({ id, reasonTypeId, description })); + ret.eorReasons = this._eorReasons; } if (this._hasRunQualityChange()) { @@ -128,12 +126,7 @@ export class RunPatch extends Observable { } = this._run || {}; this._runQuality = runQuality; - this._eorReasons = eorReasons.map(({ id, description, reasonTypeId, lastEditedName }) => ({ - id, - description, - reasonTypeId, - lastEditedName, - })); + this._eorReasons = eorReasons.map(({ id, description, reasonTypeId }) => ({ id, description, reasonTypeId })); this._tags = tags.map(({ text }) => text); this.formData = { diff --git a/lib/public/views/Runs/Details/runDetailsComponent.js b/lib/public/views/Runs/Details/runDetailsComponent.js index fe370ced70..ebb7ae4fe7 100644 --- a/lib/public/views/Runs/Details/runDetailsComponent.js +++ b/lib/public/views/Runs/Details/runDetailsComponent.js @@ -40,7 +40,7 @@ import { RunDefinition } from '../../../domain/enums/RunDefinition.js'; import { formatFloat } from '../../../utilities/formatting/formatFloat.js'; import { formatEditableNumber } from '../format/formatEditableNumber.js'; import { editRunEorReasons } from '../format/editRunEorReasons.js'; -import { formatRunEorReason } from '../format/formatRunEorReason.js'; +import { formatEorReason } from '../format/formatEorReason.mjs'; import { selectionDropdown } from '../../../components/common/selection/dropdown/selectionDropdown.js'; import { formatRunCalibrationStatus } from '../format/formatRunCalibrationStatus.js'; import { BeamModes } from '../../../domain/enums/BeamModes.js'; @@ -533,10 +533,7 @@ export const runDetailsComponent = (runDetailsModel, router) => runDetailsModel. h('#eor-reasons.flex-row', [ runDetailsModel.isEditModeEnabled ? editRunEorReasons(runDetailsModel) - : h( - '.flex-column.g2.w-100', - run.eorReasons.map((eorReason) => h('.eor-reason', formatRunEorReason(eorReason))), - ), + : h('.flex-column.g2', run.eorReasons.map((eorReason) => h('.eor-reason', formatEorReason(eorReason)))), ]), ]), ]), diff --git a/lib/public/views/Runs/Overview/FixedPdpBeamTypeRunsOverviewModel.js b/lib/public/views/Runs/Overview/FixedPdpBeamTypeRunsOverviewModel.js index 5edce4c14e..82eaf9e819 100644 --- a/lib/public/views/Runs/Overview/FixedPdpBeamTypeRunsOverviewModel.js +++ b/lib/public/views/Runs/Overview/FixedPdpBeamTypeRunsOverviewModel.js @@ -23,10 +23,9 @@ export class FixedPdpBeamTypeRunsOverviewModel extends RunsWithQcModel { /** * Constructor * @param {Model} model global model - * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(model, pageIdentifier) { - super(model, pageIdentifier); + constructor(model) { + super(model); this._pdpBeamTypes = []; } diff --git a/lib/public/views/Runs/Overview/RunsOverviewModel.js b/lib/public/views/Runs/Overview/RunsOverviewModel.js index c6912513b6..0249c66085 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewModel.js +++ b/lib/public/views/Runs/Overview/RunsOverviewModel.js @@ -11,15 +11,18 @@ * or submit itself to any jurisdiction. */ +import { buildUrl } from '/js/src/index.js'; import { TagFilterModel } from '../../../components/Filters/common/TagFilterModel.js'; +import { debounce } from '../../../utilities/debounce.js'; import { DetectorsFilterModel } from '../../../components/Filters/RunsFilter/DetectorsFilterModel.js'; import { RunTypesFilterModel } from '../../../components/runTypes/RunTypesFilterModel.js'; import { EorReasonFilterModel } from '../../../components/Filters/RunsFilter/EorReasonFilterModel.js'; -import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; +import { OverviewPageModel } from '../../../models/OverviewModel.js'; import { CombinationOperator } from '../../../components/Filters/common/CombinationOperatorChoiceModel.js'; import { NumericalComparisonFilterModel } from '../../../components/Filters/common/filters/NumericalComparisonFilterModel.js'; import { detectorsProvider } from '../../../services/detectors/detectorsProvider.js'; import { MagnetsFilteringModel } from '../../../components/Filters/RunsFilter/MagnetsFilteringModel.js'; +import { FilteringModel } from '../../../components/Filters/common/FilteringModel.js'; import { tagsProvider } from '../../../services/tag/tagsProvider.js'; import { eorReasonTypeProvider } from '../../../services/eorReason/eorReasonTypeProvider.js'; import { runTypesProvider } from '../../../services/runTypes/runTypesProvider.js'; @@ -28,75 +31,74 @@ import { magnetsCurrentLevelsProvider } from '../../../services/magnets/magnetsC import { RawTextFilterModel } from '../../../components/Filters/common/filters/RawTextFilterModel.js'; import { RunDefinitionFilterModel } from '../../../components/Filters/RunsFilter/RunDefinitionFilterModel.js'; import { RUN_QUALITIES } from '../../../domain/enums/RunQualities.js'; +import { SelectionFilterModel } from '../../../components/Filters/common/filters/SelectionFilterModel.js'; import { DataExportModel } from '../../../models/DataExportModel.js'; import { runsActiveColumns as dataExportConfiguration } from '../ActiveColumns/runsActiveColumns.js'; import { BeamModeFilterModel } from '../../../components/Filters/RunsFilter/BeamModeFilterModel.js'; import { beamModesProvider } from '../../../services/beamModes/beamModesProvider.js'; -import { RadioButtonFilterModel } from '../../../components/Filters/common/RadioButtonFilterModel.js'; -import { SelectionModel } from '../../../components/common/selection/SelectionModel.js'; -import { TRIGGER_VALUES } from '../../../domain/enums/TriggerValue.js'; /** * Model representing handlers for runs page * * @implements {OverviewModel} */ -export class RunsOverviewModel extends FilterableOverviewPageModel { +export class RunsOverviewModel extends OverviewPageModel { /** * The constructor of the Overview model object * @param {Model} model global model - * @param {string} pageIdentifier string that indicates what page this model represents - */ - constructor(model, pageIdentifier) { - super( - model.router, - pageIdentifier, - { - runNumbers: new RawTextFilterModel(), - detectors: new DetectorsFilterModel(detectorsProvider.dataTaking$), - tags: new TagFilterModel( - tagsProvider.items$, - [ - CombinationOperator.AND, - CombinationOperator.OR, - CombinationOperator.NONE_OF, - ], - ), - fillNumbers: new RawTextFilterModel(), - lhcPeriods: new RawTextFilterModel(), - o2start: new TimeRangeFilterModel(), - o2end: new TimeRangeFilterModel(), - definitions: new RunDefinitionFilterModel(), - runDuration: new NumericalComparisonFilterModel({ scale: 60 * 1000 }), - environmentIds: new RawTextFilterModel(), - runTypes: new RunTypesFilterModel(runTypesProvider.items$), - beamModes: new BeamModeFilterModel(beamModesProvider.items$), - runQualities: new SelectionModel({ - availableOptions: RUN_QUALITIES.map((quality) => ({ - label: quality.toUpperCase(), - value: quality, - })), - }), - nDetectors: new NumericalComparisonFilterModel({ integer: true }), - nEpns: new NumericalComparisonFilterModel({ integer: true }), - nFlps: new NumericalComparisonFilterModel({ integer: true }), - ctfFileCount: new NumericalComparisonFilterModel({ integer: true }), - tfFileCount: new NumericalComparisonFilterModel({ integer: true }), - otherFileCount: new NumericalComparisonFilterModel({ integer: true }), - odcTopologyFullName: new RawTextFilterModel(), - eorReason: new EorReasonFilterModel(eorReasonTypeProvider.items$), - magnets: new MagnetsFilteringModel(magnetsCurrentLevelsProvider.items$), - muInelasticInteractionRate: new NumericalComparisonFilterModel(), - inelasticInteractionRateAvg: new NumericalComparisonFilterModel(), - inelasticInteractionRateAtStart: new NumericalComparisonFilterModel(), - inelasticInteractionRateAtMid: new NumericalComparisonFilterModel(), - inelasticInteractionRateAtEnd: new NumericalComparisonFilterModel(), - ddflp: new RadioButtonFilterModel([{ label: 'ANY' }, { label: 'ON', value: true }, { label: 'OFF', value: false }]), - dcs: new RadioButtonFilterModel([{ label: 'ANY' }, { label: 'ON', value: true }, { label: 'OFF', value: false }]), - epn: new RadioButtonFilterModel([{ label: 'ANY' }, { label: 'ON', value: true }, { label: 'OFF', value: false }]), - triggerValues: new SelectionModel({ availableOptions: TRIGGER_VALUES.map((value) => ({ label: value, value })) }), - }, - ); + */ + constructor(model) { + super(); + + this._filteringModel = new FilteringModel({ + runNumbers: new RawTextFilterModel(), + detectors: new DetectorsFilterModel(detectorsProvider.dataTaking$), + tags: new TagFilterModel( + tagsProvider.items$, + [ + CombinationOperator.AND, + CombinationOperator.OR, + CombinationOperator.NONE_OF, + ], + ), + fillNumbers: new RawTextFilterModel(), + lhcPeriods: new RawTextFilterModel(), + o2start: new TimeRangeFilterModel(), + o2end: new TimeRangeFilterModel(), + definitions: new RunDefinitionFilterModel(), + runDuration: new NumericalComparisonFilterModel({ scale: 60 * 1000 }), + environmentIds: new RawTextFilterModel(), + runTypes: new RunTypesFilterModel(runTypesProvider.items$), + beamModes: new BeamModeFilterModel(beamModesProvider.items$), + runQualities: new SelectionFilterModel({ + availableOptions: RUN_QUALITIES.map((quality) => ({ + label: quality.toUpperCase(), + value: quality, + })), + }), + nDetectors: new NumericalComparisonFilterModel({ integer: true }), + nEpns: new NumericalComparisonFilterModel({ integer: true }), + nFlps: new NumericalComparisonFilterModel({ integer: true }), + ctfFileCount: new NumericalComparisonFilterModel({ integer: true }), + tfFileCount: new NumericalComparisonFilterModel({ integer: true }), + otherFileCount: new NumericalComparisonFilterModel({ integer: true }), + odcTopologyFullName: new RawTextFilterModel(), + eorReason: new EorReasonFilterModel(eorReasonTypeProvider.items$), + magnets: new MagnetsFilteringModel(magnetsCurrentLevelsProvider.items$), + muInelasticInteractionRate: new NumericalComparisonFilterModel(), + inelasticInteractionRateAvg: new NumericalComparisonFilterModel(), + inelasticInteractionRateAtStart: new NumericalComparisonFilterModel(), + inelasticInteractionRateAtMid: new NumericalComparisonFilterModel(), + inelasticInteractionRateAtEnd: new NumericalComparisonFilterModel(), + }); + + this._filteringModel.observe(() => this._applyFilters(true)); + this._filteringModel.visualChange$.bubbleTo(this); + + this.reset(false); + const updateDebounceTime = () => { + this._debouncedLoad = debounce(this.load.bind(this), model.inputDebounceTime); + }; this._exportModel = new DataExportModel(this._allItems$, dataExportConfiguration, () => this.loadAll()); this._exportModel.bubbleTo(this); @@ -104,14 +106,9 @@ export class RunsOverviewModel extends FilterableOverviewPageModel { this._exportModel.setDisabled(!this.hasAnyData()); this._exportModel.setTotalExistingItemsCount(this._pagination.itemsCount); }); - } - /** - * @inheritdoc - */ - reset(fetch = true) { - this._exportModel?.reset(); - super.reset(fetch); + model.appConfiguration$.observe(() => updateDebounceTime()); + updateDebounceTime(); } /** @@ -126,6 +123,195 @@ export class RunsOverviewModel extends FilterableOverviewPageModel { * @inheritdoc */ getRootEndpoint() { - return this.buildRootEndpoint('/api/runs'); + return buildUrl('/api/runs', { ...this._getFilterQueryParams(), ...{ filter: this.filteringModel.normalized } }); + } + + /** + * Returns all filtering, sorting and pagination settings to their default values + * @param {boolean} [fetch = true] whether to refetch all data after filters have been reset + * @return {void} + */ + reset(fetch = true) { + super.reset(); + this._exportModel?.reset(); + this.resetFiltering(fetch); + } + + /** + * Reset all filtering models + * @param {boolean} fetch Whether to refetch all data after filters have been reset + * @return {void} + */ + resetFiltering(fetch = true) { + this._filteringModel.reset(); + + this._triggerValuesFilters = new Set(); + + this.ddflpFilter = ''; + + this.dcsFilter = ''; + + this.epnFilter = ''; + + if (fetch) { + this._applyFilters(true); + } + } + + /** + * Checks if any filter value has been modified from their default (empty) + * @return {Boolean} If any filter is active + */ + isAnyFilterActive() { + return this._filteringModel.isAnyFilterActive() + || this._triggerValuesFilters.size !== 0 + || this.ddflpFilter !== '' + || this.dcsFilter !== '' + || this.epnFilter !== ''; + } + + /** + * Return the filtering model + * + * @return {FilteringModel} the filtering model + */ + get filteringModel() { + return this._filteringModel; + } + + /** + * Getter for the trigger values filter Set + * @return {Set} set of trigger filter values + */ + get triggerValuesFilters() { + return this._triggerValuesFilters; + } + + /** + * Setter for trigger values filter, this replaces the current set + * @param {Array} newTriggerValues new Set of values + * @return {undefined} + */ + set triggerValuesFilters(newTriggerValues) { + this._triggerValuesFilters = new Set(newTriggerValues); + this._applyFilters(); + } + + /** + * Returns the boolean of ddflp + * @return {Boolean} if ddflp is on + */ + getDdflpFilterOperation() { + return this.ddflpFilter; + } + + /** + * Sets the boolean of the filter if no new inputs were detected for 200 milliseconds + * @param {boolean} operation if the ddflp is on + * @return {undefined} + */ + setDdflpFilterOperation(operation) { + this.ddflpFilter = operation; + this._applyFilters(); + } + + /** + * Unchecks the ddflp checkbox and fetches all the runs. + * @return {undefined} + * + */ + removeDdflp() { + this.ddflpFilter = ''; + this._applyFilters(); + } + + /** + * Returns the boolean of dcs + * @return {Boolean} if dcs is on + */ + getDcsFilterOperation() { + return this.dcsFilter; + } + + /** + * Sets the boolean of the filter if no new inputs were detected for 200 milliseconds + * @param {boolean} operation if the dcs is on + * @return {undefined} + */ + setDcsFilterOperation(operation) { + this.dcsFilter = operation; + this._applyFilters(); + } + + /** + * Unchecks the dcs checkbox and fetches all the runs. + * @return {undefined} + */ + removeDcs() { + this.dcsFilter = ''; + this._applyFilters(); + } + + /** + * Returns the boolean of epn + * @return {Boolean} if epn is on + */ + getEpnFilterOperation() { + return this.epnFilter; + } + + /** + * Sets the boolean of the filter if no new inputs were detected for 200 milliseconds + * @param {boolean} operation if the epn is on + * @return {undefined} + */ + setEpnFilterOperation(operation) { + this.epnFilter = operation; + this._applyFilters(); + } + + /** + * Unchecks the epn checkbox and fetches all the runs. + * @return {undefined} + */ + removeEpn() { + this.epnFilter = ''; + this._applyFilters(); + } + + /** + * Returns the list of URL params corresponding to the currently applied filter + * + * @return {Object} the URL params + * + * @private + */ + _getFilterQueryParams() { + return { + ...this._triggerValuesFilters.size !== 0 && { + 'filter[triggerValues]': Array.from(this._triggerValuesFilters).join(), + }, + ...(this.ddflpFilter === true || this.ddflpFilter === false) && { + 'filter[ddflp]': this.ddflpFilter, + }, + ...(this.dcsFilter === true || this.dcsFilter === false) && { + 'filter[dcs]': this.dcsFilter, + }, + ...(this.epnFilter === true || this.epnFilter === false) && { + 'filter[epn]': this.epnFilter, + }, + }; + } + + /** + * Apply the current filtering and update the remote data list + * + * @param {boolean} now if true, filtering will be applied now without debouncing + * + * @return {void} + */ + _applyFilters(now = false) { + this._pagination.currentPage = 1; + now ? this.load() : this._debouncedLoad(true); } } diff --git a/lib/public/views/Runs/Overview/RunsOverviewPage.js b/lib/public/views/Runs/Overview/RunsOverviewPage.js index 4f76d417d9..ab43fcbfe9 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewPage.js +++ b/lib/public/views/Runs/Overview/RunsOverviewPage.js @@ -17,10 +17,9 @@ import { filtersPanelPopover } from '../../../components/Filters/common/filtersP import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { runsActiveColumns } from '../ActiveColumns/runsActiveColumns.js'; import { table } from '../../../components/common/table/table.js'; +import { runNumbersFilter } from '../../../components/Filters/RunsFilter/runNumbersFilter.js'; import { switchInput } from '../../../components/common/form/switchInput.js'; import { exportTriggerAndModal } from '../../../components/common/dataExport/exportTriggerAndModal.js'; -import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; -import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 59; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -47,20 +46,21 @@ export const togglePhysicsOnlyFilter = (runDefinitionFilterModel) => { * @return {Component} Returns a vnode with the table containing the runs */ export const RunsOverviewPage = ({ runs: { overviewModel: runsOverviewModel }, modalModel }) => { - const { pagination, items, exportModel, filteringModel } = runsOverviewModel; - pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); + runsOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( + TABLEROW_HEIGHT, + PAGE_USED_HEIGHT, + )); return h('', [ h('.flex-row.header-container.g2.pv2', [ filtersPanelPopover(runsOverviewModel, runsActiveColumns), - h('.pl2#runOverviewFilter', textInputFilter(runsOverviewModel.filteringModel, 'runNumbers', 'e.g. 534454, 534455...')), - togglePhysicsOnlyFilter(filteringModel.get('definitions')), - exportTriggerAndModal(exportModel, modalModel), + h('.pl2#runOverviewFilter', runNumbersFilter(runsOverviewModel.filteringModel.get('runNumbers'))), + togglePhysicsOnlyFilter(runsOverviewModel.filteringModel.get('definitions')), + exportTriggerAndModal(runsOverviewModel.exportModel, modalModel), ]), - warningComponent(runsOverviewModel), h('.flex-column.w-100', [ - table(items, runsActiveColumns), - paginationComponent(pagination), + table(runsOverviewModel.items, runsActiveColumns), + paginationComponent(runsOverviewModel.pagination), ]), ]); }; diff --git a/lib/public/views/Runs/Overview/RunsWithQcModel.js b/lib/public/views/Runs/Overview/RunsWithQcModel.js index e9ec4f36c5..ad09ea4718 100644 --- a/lib/public/views/Runs/Overview/RunsWithQcModel.js +++ b/lib/public/views/Runs/Overview/RunsWithQcModel.js @@ -43,8 +43,6 @@ const qcFlagsExportConfigurationFactory = (detectors) => Object.fromEntries(dete import { ObservableData } from '../../../utilities/ObservableData.js'; import { DetectorType } from '../../../domain/enums/DetectorTypes.js'; import { mergeRemoteData } from '../../../utilities/mergeRemoteData.js'; -import { ToggleFilterModel } from '../../../components/Filters/common/filters/ToggleFilterModel.js'; -import { MultiCompositionFilterModel } from '../../../components/Filters/RunsFilter/MultiCompositionFilterModel.js'; /** * Merge QC summaries @@ -68,17 +66,11 @@ export class RunsWithQcModel extends RunsOverviewModel { /** * Constructor * @param {Model} model global model - * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(model, pageIdentifier) { - super(model, pageIdentifier); + constructor(model) { + super(model); - this._detectorsNotBadFractionRegistered = false; - this._detectorsForQcFlagRegistered = false; - - this._observablesQcFlagsSummaryDependsOn$ = null; - // This filter instance will be added as a sub-filter for a MultiCompositionFilter and a GaqFilter later. - this._mcReproducibleAsNotBad = new ToggleFilterModel(false, true); + this._mcReproducibleAsNotBad = false; this._runDetectorsSelectionModel = new RunDetectorsSelectionModel(); this._runDetectorsSelectionModel.bubbleTo(this); @@ -91,22 +83,35 @@ export class RunsWithQcModel extends RunsOverviewModel { verticalScrollEnabled: true, freezeFirstColumn: true, }); - - this._filteringModel - .put('detectorsQcNotBadFraction', new MultiCompositionFilterModel({ mcReproducibleAsNotBad: this._mcReproducibleAsNotBad })); } /** * @inheritdoc */ getRootEndpoint() { - return buildUrl(super.getRootEndpoint(), { include: { effectiveQcFlags: true } }); + const filter = {}; + filter.detectorsQc = { + mcReproducibleAsNotBad: this._mcReproducibleAsNotBad, + }; + + return buildUrl(super.getRootEndpoint(), { filter, include: { effectiveQcFlags: true } }); + } + + /** + * Set mcReproducibleAsNotBad + * + * @param {boolean} mcReproducibleAsNotBad new value + * @return {void} + */ + setMcReproducibleAsNotBad(mcReproducibleAsNotBad) { + this._mcReproducibleAsNotBad = mcReproducibleAsNotBad; + this.load(); } /** * Get mcReproducibleAsNotBad * - * @return {ToggleFilterModel} mcReproducibleAsNotBad + * @return {boolean} mcReproducibleAsNotBad */ get mcReproducibleAsNotBad() { return this._mcReproducibleAsNotBad; @@ -126,86 +131,57 @@ export class RunsWithQcModel extends RunsOverviewModel { */ async load() { this._runDetectorsSelectionModel.reset(); - // Only fetch QC summary manually if no observer is registered - if (!this._observablesQcFlagsSummaryDependsOn$) { - this._fetchQcSummary(); - } + this._fetchQcSummary(); super.load(); } /** - * Register not-bad fraction detectors filtering model and update it when detectors are loaded - * Also, trigger an immediate update if detectors are already loaded at the moment of registration + * Register not-bad fraction detectors filtering model * * @param {ObservableData>} detectors$ detectors remote data observable */ registerDetectorsNotBadFractionFilterModels(detectors$) { - const detectorsQcNotBadFraction = this._filteringModel.get('detectorsQcNotBadFraction'); - - const callback = (observableData) => { - const current = observableData.getCurrent(); - current?.apply({ - Success: (detectors) => detectors.forEach(({ id }) => - detectorsQcNotBadFraction.putFilter(`_${id}`, new NumericalComparisonFilterModel({ scale: 0.01, integer: false }))), - }); - - if (current?.isSuccess() && !this._detectorsNotBadFractionRegistered) { - this.filteringModel.setFilterFromURL(); - this._detectorsNotBadFractionRegistered = true; - } - }; - - if (!this._detectorsNotBadFractionRegistered) { - detectors$.observe(callback.bind(this)); - callback(detectors$); - } + detectors$.observe((observableData) => observableData.getCurrent().apply({ + Success: (detectors) => detectors.forEach(({ id }) => { + this._filteringModel.put(`detectorsQc[_${id}][notBadFraction]`, new NumericalComparisonFilterModel({ + scale: 0.01, + integer: false, + })); + }), + })); } /** - * Register detectors for QC flags data export and update export configuration when detectors are loaded - * Also, trigger an immediate update if detectors are already loaded at the moment of registration + * Register detectors for QC flags data export * * @param {ObservableData>} detectors$ detectors remote data observable */ registerDetectorsForQcFlagsDataExport(detectors$) { - const callback = (observableData) => { - const current = observableData.getCurrent(); - current?.apply({ - Success: (detectors) => { - this._detectorsForQcFlagRegistered = true; - this._exportModel.setDataExportConfiguration({ - ...baseDataExportConfiguration, - ...qcFlagsExportConfigurationFactory(detectors), - }); - }, - Other: () => null, - }); - }; - - if (!this._detectorsForQcFlagRegistered) { - detectors$.observe(callback.bind(this)); - callback(detectors$); - } + detectors$.observe((observableData) => observableData.getCurrent().apply({ + Success: (detectors) => { + this._exportModel.setDataExportConfiguration({ + ...baseDataExportConfiguration, + ...qcFlagsExportConfigurationFactory(detectors), + }); + }, + Other: () => null, + })); } /** - * Register observables data, which QC flags fetching operation success depends on + * Register obervables data, which QC flags fetching operation success dependes on * - * @param {ObservableData>} detectors$ observable data which QC flags fetching operation success depends on + * @param {ObservableData[]} observables obervable data list */ - registerObservablesQcSummaryDependsOn(detectors$) { - if (detectors$ === this._observablesQcFlagsSummaryDependsOn$) { - return; - } - - this._observablesQcFlagsSummaryDependsOn$ = detectors$; - const callback = (observableData) => { - const current = observableData.getCurrent(); - current?.apply({ Success: () => this._fetchQcSummary() }); - }; - this._observablesQcFlagsSummaryDependsOn$.observe(callback); - // Also trigger immediately if detectors are already loaded - callback(this._observablesQcFlagsSummaryDependsOn$); + registerObervablesQcSummaryDependesOn(observables) { + this._observablesQcFlagsSummaryDepndsOn$ = ObservableData + .builder() + .sources(observables) + .apply((remoteDataList) => mergeRemoteData(remoteDataList)) + .build(); + + this._observablesQcFlagsSummaryDepndsOn$ + .observe((observableData) => observableData.getCurrent().apply({ Success: () => this._fetchQcSummary() })); } /** @@ -232,8 +208,8 @@ export class RunsWithQcModel extends RunsOverviewModel { async _fetchQcSummary() { const qcSummaryScopeValid = Object.entries(this.qcSummaryScope).filter(([, id]) => id).length == 1; - if (qcSummaryScopeValid && this.detectors && this._observablesQcFlagsSummaryDependsOn$?.getCurrent()) { - mergeRemoteData([this.detectors, this._observablesQcFlagsSummaryDependsOn$.getCurrent()]).match({ + if (qcSummaryScopeValid && this.detectors && this._observablesQcFlagsSummaryDepndsOn$.getCurrent()) { + mergeRemoteData([this.detectors, this._observablesQcFlagsSummaryDepndsOn$.getCurrent()]).match({ Success: async ([detectors]) => { this._qcSummary$.setCurrent(RemoteData.loading()); try { @@ -242,7 +218,7 @@ export class RunsWithQcModel extends RunsOverviewModel { detectorIds: detectors .filter(({ type }) => type === DetectorType.PHYSICAL) .map(({ id }) => id).join(','), - mcReproducibleAsNotBad: this._mcReproducibleAsNotBad.isToggled, + mcReproducibleAsNotBad: this._mcReproducibleAsNotBad, })); const { data: qcSummary2 } = await getRemoteData(buildUrl('/api/qcFlags/summary', { @@ -256,7 +232,7 @@ export class RunsWithQcModel extends RunsOverviewModel { operator: 'none', }, }, - mcReproducibleAsNotBad: this._mcReproducibleAsNotBad.isToggled, + mcReproducibleAsNotBad: this._mcReproducibleAsNotBad, })); this._qcSummary$.setCurrent(RemoteData.success(mergeQcSummaries([qcSummary1, qcSummary2]))); } catch (error) { diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js index 5bceeeeeb4..0aca49d627 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js @@ -14,16 +14,16 @@ import { buildUrl, RemoteData } from '/js/src/index.js'; import { ObservableData } from '../../../utilities/ObservableData.js'; import { getRemoteDataSlice } from '../../../utilities/fetch/getRemoteDataSlice.js'; import { getRemoteData } from '../../../utilities/fetch/getRemoteData.js'; -import { rctDetectorsProvider } from '../../../services/detectors/detectorsProvider.js'; +import { detectorsProvider } from '../../../services/detectors/detectorsProvider.js'; import { FixedPdpBeamTypeRunsOverviewModel } from '../Overview/FixedPdpBeamTypeRunsOverviewModel.js'; import { jsonPatch } from '../../../utilities/fetch/jsonPatch.js'; import { jsonPut } from '../../../utilities/fetch/jsonPut.js'; import { SkimmingStage } from '../../../domain/enums/SkimmingStage.js'; +import { NumericalComparisonFilterModel } from '../../../components/Filters/common/filters/NumericalComparisonFilterModel.js'; import { jsonFetch } from '../../../utilities/fetch/jsonFetch.js'; import { mergeRemoteData } from '../../../utilities/mergeRemoteData.js'; import { RemoteDataSource } from '../../../utilities/fetch/RemoteDataSource.js'; import { DetectorType } from '../../../domain/enums/DetectorTypes.js'; -import { GaqFilterModel } from '../../../components/Filters/RunsFilter/GaqFilterModel.js'; const ALL_CPASS_PRODUCTIONS_REGEX = /cpass\d+/; const DETECTOR_NAMES_NOT_IN_CPASSES = ['EVS']; @@ -35,16 +35,15 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo /** * Constructor * @param {Model} model global model - * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(model, pageIdentifier) { - super(model, pageIdentifier); + constructor(model) { + super(model); this._dataPass$ = new ObservableData(RemoteData.notAsked()); this._dataPass$.bubbleTo(this); this._detectors$ = ObservableData .builder() - .sources([rctDetectorsProvider.qc$, this._dataPass$]) + .sources([detectorsProvider.qc$, this._dataPass$]) .apply((remoteDataList) => mergeRemoteData(remoteDataList) .apply({ Success: ([detectors, dataPass]) => ALL_CPASS_PRODUCTIONS_REGEX.test(dataPass.name) ? detectors.filter(({ name, type }) => type !== DetectorType.AOT_GLO || DETECTOR_NAMES_NOT_IN_CPASSES.includes(name)) @@ -52,9 +51,10 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo })) .build(); - this._filteringModel.put('gaq', new GaqFilterModel(this._mcReproducibleAsNotBad)); - this._detectors$.bubbleTo(this); + this.registerDetectorsNotBadFractionFilterModels(this._detectors$); + this.registerDetectorsForQcFlagsDataExport(this._detectors$); + this.registerObervablesQcSummaryDependesOn([this._detectors$]); this._markAsSkimmableRequestResult$ = new ObservableData(RemoteData.notAsked()); this._markAsSkimmableRequestResult$.bubbleTo(this); @@ -68,6 +68,11 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo this._skimmableRuns$ = new ObservableData(RemoteData.notAsked()); this._skimmableRuns$.bubbleTo(this); + this._filteringModel.put('gaq[notBadFraction]', new NumericalComparisonFilterModel({ + scale: 0.01, + integer: false, + })); + this._freezeOrUnfreezeActionState$ = new ObservableData(RemoteData.notAsked()); this._freezeOrUnfreezeActionState$.bubbleTo(this); @@ -130,11 +135,6 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo }, Other: () => null, })); - - this.registerDetectorsNotBadFractionFilterModels(this._detectors$); - this.registerDetectorsForQcFlagsDataExport(this._detectors$); - this.registerObservablesQcSummaryDependsOn(this._detectors$); - super.load(); } @@ -142,15 +142,22 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo * @inheritdoc */ getRootEndpoint() { - const filter = { ...this._filteringModel.normalized, dataPassIds: [this._dataPassId] }; + const gaqNotBadFilter = this._filteringModel.get('gaq[notBadFraction]'); + const filter = { dataPassIds: [this._dataPassId] }; + if (!gaqNotBadFilter.isEmpty) { + filter.gaq = { + mcReproducibleAsNotBad: this._mcReproducibleAsNotBad, + }; + } + return buildUrl(super.getRootEndpoint(), { filter }); } /** * @inheritdoc */ - resetFiltering(fetch = true, clearUrl = false) { - super.resetFiltering(fetch, clearUrl); + resetFiltering(fetch = true) { + super.resetFiltering(fetch); } /** @@ -272,7 +279,7 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo * @param {number} dataPassId id of Data Pass */ set dataPassId(dataPassId) { - if (this._dataPassId && dataPassId !== this._dataPassId) { + if (dataPassId !== this._dataPassId) { this.reset(false); } this._dataPassId = dataPassId; @@ -356,7 +363,7 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo }); const url = buildUrl('/api/qcFlags/summary/gaq', { dataPassId: this._dataPassId, - mcReproducibleAsNotBad: this._mcReproducibleAsNotBad.isToggled, + mcReproducibleAsNotBad: this._mcReproducibleAsNotBad, runNumber: runNumber, }); await this._gaqSummarySources[runNumber].fetch(url); diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js index fd847389f5..8f63fb608b 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js @@ -21,6 +21,7 @@ import { tooltip } from '../../../components/common/popover/tooltip.js'; import { breadcrumbs } from '../../../components/common/navigation/breadcrumbs.js'; import { createRunDetectorsAsyncQcActiveColumns } from '../ActiveColumns/runDetectorsAsyncQcActiveColumns.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; +import { runNumbersFilter } from '../../../components/Filters/RunsFilter/runNumbersFilter.js'; import { qcSummaryLegendTooltip } from '../../../components/qcFlags/qcSummaryLegendTooltip.js'; import { isRunNotSubjectToQc } from '../../../components/qcFlags/isRunNotSubjectToQc.js'; import { frontLink } from '../../../components/common/navigation/frontLink.js'; @@ -37,9 +38,7 @@ import { iconCaretBottom } from '/js/src/icons.js'; import { BkpRoles } from '../../../domain/enums/BkpRoles.js'; import { getInelasticInteractionRateColumns } from '../ActiveColumns/getInelasticInteractionRateActiveColumns.js'; import { exportTriggerAndModal } from '../../../components/common/dataExport/exportTriggerAndModal.js'; -import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; -import { toggleFilter } from '../../../components/Filters/common/filters/toggleFilter.js'; -import { warningComponent } from '../../../components/common/messages/warningComponent.js'; +import { mcReproducibleAsNotBadToggle } from '../mcReproducibleAsNotBadToggle.js'; const TABLEROW_HEIGHT = 59; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -118,194 +117,177 @@ export const RunsPerDataPassOverviewPage = ({ const commonTitle = h('h2#breadcrumb-header', { style: 'white-space: nowrap;' }, 'Physics Runs'); const runDetectorsSelectionIsEmpty = perDataPassOverviewModel.runDetectorsSelectionModel.selectedQueryString.length === 0; - const dataPass = remoteDataPass.match({ Other: () => null, Success: (data) => data }); - const detectors = remoteDetectors.match({ Other: () => null, Success: (data) => data }); - const qcSummary = remoteQcSummary.match({ Other: () => null, Success: (data) => data }); - /* - * The table drawing can be done without using mergeRemoteData, but that will redraw it - * each independent update to dataPass, detectors, or qcSummary. - * While this wouldn't necessarily be noticeable for users, it would detach nodes from - * the document, which would make writing integration test difficult and unreliable. - */ - const fullPageData = mergeRemoteData([remoteRuns, remoteDataPass, remoteDetectors, remoteQcSummary]); - - const activeColumns = { - ...runsActiveColumns, - ...getInelasticInteractionRateColumns(pdpBeamTypes), - - globalAggregatedQuality: { - name: 'GAQ', - information: h( - '', - h('', 'Global aggregated flag based on critical detectors.'), - h('', 'Default detectors: FT0, ITS, TPC (and ZDC for heavy-ion runs)'), - ), - visible: true, - - format: (_, { runNumber }) => { - const runGaqSummary = remoteGaqSummary[runNumber]; - const spinnerEl = h('.flex-row.items-center.justify-center.black', spinner({ size: 2, absolute: false })); - - return runGaqSummary.match({ - Success: (gaqSummary) => { - const gaqDisplay = - gaqSummary?.undefinedQualityPeriodsCount === 0 - ? getQcSummaryDisplay(gaqSummary) - : h('button.btn.btn-primary.w-100', 'GAQ'); - - return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, runNumber }); - }, - Loading: () => tooltip(spinnerEl), - NotAsked: () => tooltip(spinnerEl), - Failure: () => - frontLink( - h('button.btn.btn-primary.w-100', [ - 'GAQ', - h( - '.d-inline-block.va-t-bottom', - tooltip( - h('.f7', iconWarning()), - 'GAQ Summary failed, please click to view GAQ flags', + return h( + '.intermediate-flex-column', + { onremove: () => { + perDataPassOverviewModel._abortGaqFetches(); + } }, + mergeRemoteData([remoteDataPass, remoteRuns, remoteDetectors, remoteQcSummary]).match({ + NotAsked: () => null, + Failure: (errors) => errorAlert(errors), + Success: ([dataPass, runs, detectors, qcSummary]) => { + const activeColumns = { + ...runsActiveColumns, + ...getInelasticInteractionRateColumns(pdpBeamTypes), + ...dataPass.skimmingStage === SkimmingStage.SKIMMABLE + ? { + readyForSkimming: { + name: 'Ready for skimming', + visible: true, + format: (_, { runNumber }) => remoteSkimmableRuns.match({ + Success: (skimmableRuns) => switchInput( + skimmableRuns[runNumber], + () => perDataPassOverviewModel.changeReadyForSkimmingFlagForRun({ + runNumber, + readyForSkimming: !skimmableRuns[runNumber], + }), + { + labelAfter: skimmableRuns[runNumber] + ? badge('ready', { color: Color.GREEN }) + : badge('not ready', { color: Color.WARNING_DARKER }), + }, ), - ), - ]), - 'gaq-flags', - { dataPassId, runNumber }, + Loading: () => h('.mh3.ph4', '... ...'), + Failure: () => tooltip(iconWarning(), 'Error occurred'), + NotAsked: () => tooltip(iconWarning(), 'Not asked for data'), + }), + profiles: ['runsPerDataPass'], + }, + } + : {}, + globalAggregatedQuality: { + name: 'GAQ', + information: h( + '', + h('', 'Global aggregated flag based on critical detectors.'), + h('', 'Default detectors: FT0, ITS, TPC (and ZDC for heavy-ion runs)'), ), - }); - }, - filter: ({ filteringModel }) => - numericalComparisonFilter(filteringModel.get('gaq').notBadFraction, { step: 0.1, selectorPrefix: 'gaqNotBadFraction' }), - filterTooltip: 'not-bad fraction expressed as a percentage', - profiles: ['runsPerDataPass'], - }, - ...dataPass?.skimmingStage === SkimmingStage.SKIMMABLE && { - readyForSkimming: { - name: 'Ready for skimming', - visible: true, + visible: true, + format: (_, { runNumber }) => { + const gaqLoadingSpinner = h('.flex-row.items-center.justify-center.black', spinner({ size: 2, absolute: false })); + const runGaqSummary = remoteGaqSummary[runNumber]; - format: (_, { runNumber }) => - remoteSkimmableRuns.match({ - Success: (skimmableRuns) => - switchInput( - skimmableRuns[runNumber], - () => - perDataPassOverviewModel.changeReadyForSkimmingFlagForRun({ - runNumber, - readyForSkimming: !skimmableRuns[runNumber], - }), - { - labelAfter: skimmableRuns[runNumber] - ? badge('ready', { color: Color.GREEN }) - : badge('not ready', { color: Color.WARNING_DARKER }), - }, - ), - - Loading: () => h('.mh3.ph4', '... ...'), - Failure: () => tooltip(iconWarning(), 'Error occurred'), - NotAsked: () => tooltip(iconWarning(), 'Not asked for data'), - }), + return runGaqSummary.match({ + Success: (gaqSummary) => { + const gaqDisplay = gaqSummary?.undefinedQualityPeriodsCount === 0 + ? getQcSummaryDisplay(gaqSummary) + : h('button.btn.btn-primary.w-100', 'GAQ'); - profiles: ['runsPerDataPass'], - }, - }, - ...detectors && dataPass && createRunDetectorsAsyncQcActiveColumns( - perDataPassOverviewModel.runDetectorsSelectionModel, - detectors, - remoteDplDetectorsUserHasAccessTo, - { dataPass }, - { - profile: 'runsPerDataPass', - qcSummary, - mcReproducibleAsNotBad, - }, - ), - }; - - return [ - h('.flex-row.justify-between.items-center.g2', [ - filtersPanelPopover(perDataPassOverviewModel, activeColumns, { profile: 'runsPerDataPass' }), - h('.pl2#runOverviewFilter', textInputFilter(perDataPassOverviewModel.filteringModel, 'runNumbers', 'e.g. 534454, 534455...')), - h( - '.flex-row.g1.items-center', - h('.flex-row.items-center.g1', [ - breadcrumbs([commonTitle, h('h2#breadcrumb-data-pass-name', dataPass?.name ?? spinner({ size: 1, absolute: false }))]), - h('#skimmableControl', dataPass && skimmableControl( - dataPass, - () => { - if (confirm('The data pass is going to be set as skimmable. Do you want to continue?')) { - perDataPassOverviewModel.markDataPassAsSkimmable(); - } + return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, runNumber }); + }, + Loading: () => tooltip(gaqLoadingSpinner, 'Loading GAQ summary...'), + NotAsked: () => tooltip(gaqLoadingSpinner, 'Loading GAQ summary...'), + Failure: () => { + const gaqDisplay = h('button.btn.btn-primary.w-100', [ + 'GAQ', + h( + '.d-inline-block.va-t-bottom', + tooltip(h('.f7', iconWarning()), 'GAQ Summary failed, please click to view GAQ flags'), + ), + ]); + return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, runNumber }); + }, + }); }, - markAsSkimmableRequestResult, - )), - ]), - ), - toggleFilter(mcReproducibleAsNotBad, h('em', 'MC.R as not-bad'), 'mcReproducibleAsNotBadToggle'), - h('.mlauto', qcSummaryLegendTooltip()), - h('#actions-dropdown-button', DropdownComponent( - h('button.btn.btn-primary', h('.flex-row.g2', ['Actions', iconCaretBottom()])), - h('.flex-column.p2.g2', [ - exportTriggerAndModal(perDataPassOverviewModel.exportModel, modalModel, { autoMarginLeft: false }), - frontLink( - h('button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', { - disabled: runDetectorsSelectionIsEmpty, - }, 'Set QC Flags'), - 'qc-flag-creation-for-data-pass', + filter: ({ filteringModel }) => numericalComparisonFilter( + filteringModel.get('gaq[notBadFraction]'), + { step: 0.1, selectorPrefix: 'gaqNotBadFraction' }, + ), + filterTooltip: 'not-bad fraction expressed as a percentage', + profiles: ['runsPerDataPass'], + }, + ...createRunDetectorsAsyncQcActiveColumns( + perDataPassOverviewModel.runDetectorsSelectionModel, + detectors, + remoteDplDetectorsUserHasAccessTo, + { dataPass }, { - runNumberDetectorsMap: perDataPassOverviewModel.runDetectorsSelectionModel.selectedQueryString, - dataPassId, + profile: 'runsPerDataPass', + qcSummary, + mcReproducibleAsNotBad, }, ), - sessionService.hasAccess([BkpRoles.DPG_ASYNC_QC_ADMIN]) && [ + }; + + return [ + h('.flex-row.justify-between.items-center.g2', [ + filtersPanelPopover(perDataPassOverviewModel, activeColumns, { profile: 'runsPerDataPass' }), + h('.pl2#runOverviewFilter', runNumbersFilter(perDataPassOverviewModel.filteringModel.get('runNumbers'))), h( - 'button.btn.btn-danger', - { - ...freezeOrUnfreezeActionState.match({ - Loading: () => ({ - disabled: true, - title: 'Loading', - }), - Other: () => ({}), - }), - onclick: () => dataPass?.isFrozen - ? perDataPassOverviewModel.unfreezeDataPass() - : perDataPassOverviewModel.freezeDataPass(), - }, - `${dataPass?.isFrozen ? 'Unfreeze' : 'Freeze'} the data pass`, + '.flex-row.g1.items-center', + h('.flex-row.items-center.g1', [ + breadcrumbs([commonTitle, h('h2#breadcrumb-data-pass-name', dataPass.name)]), + h('#skimmableControl', skimmableControl( + dataPass, + () => { + if (confirm('The data pass is going to be set as skimmable. Do you want to continue?')) { + perDataPassOverviewModel.markDataPassAsSkimmable(); + } + }, + markAsSkimmableRequestResult, + )), + ]), ), - h( - 'button.btn.btn-danger', - { - ...discardAllQcFlagsActionState.match({ - Loading: () => ({ - disabled: true, - title: 'Loading', - }), - Other: () => ({}), - }), - onclick: () => { - if (confirm('Are you sure you want to delete ALL the QC flags for this data pass?')) { - perDataPassOverviewModel.discardAllQcFlags(); - } - }, - }, - 'Delete ALL QC flags', + mcReproducibleAsNotBadToggle( + mcReproducibleAsNotBad, + () => perDataPassOverviewModel.setMcReproducibleAsNotBad(!mcReproducibleAsNotBad), ), - ], - ]), - { alignment: 'right' }, - )), - ]), - warningComponent(perDataPassOverviewModel), - h( - '.intermediate-flex-column', - { onremove: () => perDataPassOverviewModel._abortGaqFetches() }, - fullPageData.match({ - NotAsked: () => null, - Failure: (errors) => errorAlert(errors), - Success: ([runs]) => [ + h('.mlauto', qcSummaryLegendTooltip()), + h('#actions-dropdown-button', DropdownComponent( + h('button.btn.btn-primary', h('.flex-row.g2', ['Actions', iconCaretBottom()])), + h('.flex-column.p2.g2', [ + exportTriggerAndModal(perDataPassOverviewModel.exportModel, modalModel, { autoMarginLeft: false }), + frontLink( + h('button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', { + disabled: runDetectorsSelectionIsEmpty, + }, 'Set QC Flags'), + 'qc-flag-creation-for-data-pass', + { + runNumberDetectorsMap: perDataPassOverviewModel.runDetectorsSelectionModel.selectedQueryString, + dataPassId, + }, + ), + sessionService.hasAccess([BkpRoles.DPG_ASYNC_QC_ADMIN]) && [ + h( + 'button.btn.btn-danger', + { + ...freezeOrUnfreezeActionState.match({ + Loading: () => ({ + disabled: true, + title: 'Loading', + }), + Other: () => ({}), + }), + onclick: () => dataPass.isFrozen + ? perDataPassOverviewModel.unfreezeDataPass() + : perDataPassOverviewModel.freezeDataPass(), + }, + `${dataPass.isFrozen ? 'Unfreeze' : 'Freeze'} the data pass`, + ), + h( + 'button.btn.btn-danger', + { + ...discardAllQcFlagsActionState.match({ + Loading: () => ({ + disabled: true, + title: 'Loading', + }), + Other: () => ({}), + }), + onclick: () => { + if (confirm('Are you sure you want to delete ALL the QC flags for this data pass?')) { + perDataPassOverviewModel.discardAllQcFlags(); + } + }, + }, + 'Delete ALL QC flags', + ), + ], + ]), + { alignment: 'right' }, + )), + ]), markAsSkimmableRequestResult.match({ Failure: (errors) => errorAlert(errors), Other: () => null, @@ -329,9 +311,9 @@ export const RunsPerDataPassOverviewPage = ({ { sort: sortModel }, ), paginationComponent(perDataPassOverviewModel.pagination), - ], - Loading: () => spinner(), - }), - ), - ]; + ]; + }, + Loading: () => spinner(), + }), + ); }; diff --git a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewModel.js b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewModel.js index 2ae78a395c..b361522b8b 100644 --- a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewModel.js +++ b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewModel.js @@ -12,7 +12,7 @@ */ import { buildUrl, RemoteData } from '/js/src/index.js'; import { TabbedPanelModel } from '../../../components/TabbedPanel/TabbedPanelModel.js'; -import { rctDetectorsProvider } from '../../../services/detectors/detectorsProvider.js'; +import { detectorsProvider } from '../../../services/detectors/detectorsProvider.js'; import { jsonFetch } from '../../../utilities/fetch/jsonFetch.js'; import { DetectorType } from '../../../domain/enums/DetectorTypes.js'; import { ObservableData } from '../../../utilities/ObservableData.js'; @@ -31,25 +31,27 @@ export class RunsPerLhcPeriodOverviewModel extends FixedPdpBeamTypeRunsOverviewM * Constructor * * @param {Model} model global model - * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(model, pageIdentifier) { - super(model, pageIdentifier); + constructor(model) { + super(model); this._lhcPeriodId = null; this._lhcPeriodStatistics$ = new ObservableData(RemoteData.notAsked()); - this._onlineDetectors$ = rctDetectorsProvider.physical$; + this._onlineDetectors$ = detectorsProvider.physical$; this._syncDetectors$ = ObservableData .builder() - .source(rctDetectorsProvider.qc$) + .source(detectorsProvider.qc$) .apply((remoteDetectors) => remoteDetectors.apply({ Success: (detectors) => detectors.filter(({ type }) => [DetectorType.PHYSICAL, DetectorType.MUON_GLO].includes(type)), })) .build(); + this.registerDetectorsForQcFlagsDataExport(this._syncDetectors$); + this.registerObervablesQcSummaryDependesOn([this._syncDetectors$]); + this._syncDetectors$.bubbleTo(this); this._onlineDetectors$.bubbleTo(this); this._lhcPeriodStatistics$.bubbleTo(this); @@ -80,15 +82,12 @@ export class RunsPerLhcPeriodOverviewModel extends FixedPdpBeamTypeRunsOverviewM return; } - await this._fetchLhcPeriod(); - this._lhcPeriodStatistics$.getCurrent().match({ - Success: ({ pdpBeamTypes }) => this.setPdpBeamTypes(pdpBeamTypes), - Other: () => null, + await this._fetchLhcPeriod().then(() => { + this._lhcPeriodStatistics$.getCurrent().match({ + Success: ({ pdpBeamTypes }) => this.setPdpBeamTypes(pdpBeamTypes), + Other: () => null, + }); }); - - this.registerDetectorsForQcFlagsDataExport(this._syncDetectors$); - this.registerObservablesQcSummaryDependsOn(this._syncDetectors$); - super.load(); } @@ -96,8 +95,13 @@ export class RunsPerLhcPeriodOverviewModel extends FixedPdpBeamTypeRunsOverviewM * @inheritdoc */ getRootEndpoint() { - const filter = { lhcPeriodIds: [this._lhcPeriodId], runQualities: 'good', definitions: 'PHYSICS' }; - return buildUrl(super.getRootEndpoint(), { filter }); + return buildUrl(super.getRootEndpoint(), { + filter: { + lhcPeriodIds: [this._lhcPeriodId], + runQualities: 'good', + definitions: 'PHYSICS', + }, + }); } /** @@ -147,7 +151,7 @@ export class RunsPerLhcPeriodOverviewModel extends FixedPdpBeamTypeRunsOverviewM * @param {string} lhcPeriodId id of a LHC period */ set lhcPeriodId(lhcPeriodId) { - if (this._lhcPeriodId && lhcPeriodId !== this._lhcPeriodId) { + if (lhcPeriodId !== this._lhcPeriodId) { this.reset(false); } this._lhcPeriodId = lhcPeriodId; diff --git a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js index 4a08a95565..7526324b35 100644 --- a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js +++ b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js @@ -26,10 +26,9 @@ import errorAlert from '../../../components/common/errorAlert.js'; import spinner from '../../../components/common/spinner.js'; import { getInelasticInteractionRateColumns } from '../ActiveColumns/getInelasticInteractionRateActiveColumns.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; +import { runNumbersFilter } from '../../../components/Filters/RunsFilter/runNumbersFilter.js'; +import { mcReproducibleAsNotBadToggle } from '../mcReproducibleAsNotBadToggle.js'; import { exportTriggerAndModal } from '../../../components/common/dataExport/exportTriggerAndModal.js'; -import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; -import { toggleFilter } from '../../../components/Filters/common/filters/toggleFilter.js'; -import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 62; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -51,6 +50,11 @@ const getRowClasses = (run) => isRunNotSubjectToQc(run) ? '.danger' : null; * @return {Component} The overview page */ export const RunsPerLhcPeriodOverviewPage = ({ runs: { perLhcPeriodOverviewModel }, modalModel }) => { + perLhcPeriodOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( + TABLEROW_HEIGHT, + PAGE_USED_HEIGHT, + )); + const { items: remoteRuns, lhcPeriodStatistics: remoteLhcPeriodStatistics, @@ -62,11 +66,8 @@ export const RunsPerLhcPeriodOverviewPage = ({ runs: { perLhcPeriodOverviewModel mcReproducibleAsNotBad, qcSummary: remoteQcSummary, pdpBeamTypes, - pagination, } = perLhcPeriodOverviewModel; - pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); - /** * Render runs table with given detectors' active columns configuration * @@ -94,32 +95,30 @@ export const RunsPerLhcPeriodOverviewPage = ({ runs: { perLhcPeriodOverviewModel { sort: sortModel }, ); - const activeColumns = { - ...runsActiveColumns, - ...getInelasticInteractionRateColumns(pdpBeamTypes), - }; + return h( + '.intermediate-flex-column', + mergeRemoteData([remoteLhcPeriodStatistics, remoteRuns]).match({ + NotAsked: () => null, + Failure: (errors) => errorAlert(errors), + Loading: () => spinner(), + Success: ([lhcPeriodStatistics]) => { + const activeColumns = { + ...runsActiveColumns, + ...getInelasticInteractionRateColumns(pdpBeamTypes), - const lhcPeriodName = remoteLhcPeriodStatistics?.match({ - Success: (lhcPeriodStatistics) => lhcPeriodStatistics.lhcPeriod.name, - Other: () => spinner({ size: 1, absolute: false }), - }); + }; - return [ - h('.flex-row.justify-between.items-center.g2', [ - filtersPanelPopover(perLhcPeriodOverviewModel, activeColumns, { profile: 'runsPerLhcPeriod' }), - h('.pl2#runOverviewFilter', textInputFilter(perLhcPeriodOverviewModel.filteringModel, 'runNumbers', 'e.g. 534454, 534455...')), - h('h2', ['Good, physics runs of ', lhcPeriodName]), - warningComponent(perLhcPeriodOverviewModel), - toggleFilter(mcReproducibleAsNotBad, h('em', 'MC.R as not-bad'), 'mcReproducibleAsNotBadToggle'), - exportTriggerAndModal(perLhcPeriodOverviewModel.exportModel, modalModel), - ]), - h( - '.intermediate-flex-column', - remoteRuns.match({ - NotAsked: () => null, - Failure: (errors) => errorAlert(errors), - Loading: () => spinner(), - Success: () => [ + return [ + h('.flex-row.justify-between.items-center.g2', [ + filtersPanelPopover(perLhcPeriodOverviewModel, activeColumns, { profile: 'runsPerLhcPeriod' }), + h('.pl2#runOverviewFilter', runNumbersFilter(perLhcPeriodOverviewModel.filteringModel.get('runNumbers'))), + h('h2', `Good, physics runs of ${lhcPeriodStatistics.lhcPeriod.name}`), + mcReproducibleAsNotBadToggle( + mcReproducibleAsNotBad, + () => perLhcPeriodOverviewModel.setMcReproducibleAsNotBad(!mcReproducibleAsNotBad), + ), + exportTriggerAndModal(perLhcPeriodOverviewModel.exportModel, modalModel), + ]), ...tabbedPanelComponent( tabbedPanelModel, { @@ -153,8 +152,10 @@ export const RunsPerLhcPeriodOverviewPage = ({ runs: { perLhcPeriodOverviewModel }, { panelClass: ['scroll-auto'] }, ), - paginationComponent(pagination), - ] }), - ), - ]; + paginationComponent(perLhcPeriodOverviewModel.pagination), + ]; + }, + }), + + ); }; diff --git a/lib/public/views/Runs/RunsModel.js b/lib/public/views/Runs/RunsModel.js index 007a456368..ba30c3519a 100644 --- a/lib/public/views/Runs/RunsModel.js +++ b/lib/public/views/Runs/RunsModel.js @@ -32,13 +32,13 @@ export class RunsModel extends Observable { super(); this._detailsModel = new RunDetailsModel(); this._detailsModel.bubbleTo(this); - this._overviewModel = new RunsOverviewModel(model, 'run-overview'); + this._overviewModel = new RunsOverviewModel(model); this._overviewModel.bubbleTo(this); - this._perLhcPeriodOverviewModel = new RunsPerLhcPeriodOverviewModel(model, 'runs-per-lhc-period'); + this._perLhcPeriodOverviewModel = new RunsPerLhcPeriodOverviewModel(model); this._perLhcPeriodOverviewModel.bubbleTo(this); - this._perDataPassOverviewModel = new RunsPerDataPassOverviewModel(model, 'runs-per-data-pass'); + this._perDataPassOverviewModel = new RunsPerDataPassOverviewModel(model); this._perDataPassOverviewModel.bubbleTo(this); - this._perSimulationPassOverviewModel = new RunsPerSimulationPassOverviewModel(model, 'runs-per-simulation-pass'); + this._perSimulationPassOverviewModel = new RunsPerSimulationPassOverviewModel(model); this._perSimulationPassOverviewModel.bubbleTo(this); } @@ -48,7 +48,6 @@ export class RunsModel extends Observable { */ loadOverview() { if (! this._overviewModel.pagination.isInfiniteScrollEnabled) { - this._overviewModel.setFilterFromURL(false); this._overviewModel.load(); } } @@ -94,7 +93,6 @@ export class RunsModel extends Observable { this._perLhcPeriodOverviewModel.tabbedPanelModel.currentPanelKey = panel; if (!this._perLhcPeriodOverviewModel.pagination.isInfiniteScrollEnabled) { this._perLhcPeriodOverviewModel.lhcPeriodId = lhcPeriodId; - this._perLhcPeriodOverviewModel.setFilterFromURL(false); this._perLhcPeriodOverviewModel.load(); } } @@ -116,15 +114,7 @@ export class RunsModel extends Observable { loadPerDataPassOverview({ dataPassId }) { if (!this._perDataPassOverviewModel.pagination.isInfiniteScrollEnabled) { this._perDataPassOverviewModel.dataPassId = parseInt(dataPassId, 10); - if (this._perDataPassOverviewModel.pagination._defaultItemsPerPage) { - /** - * If the default items per page is set, it means model has loaded already once, - * so the pagination trigger will not refresh the data. - * Thus, we need to trigger the load here. - */ - this._perDataPassOverviewModel.setFilterFromURL(false); - this._perDataPassOverviewModel.load(); - } + this._perDataPassOverviewModel.load(); } } @@ -145,15 +135,7 @@ export class RunsModel extends Observable { loadPerSimulationPassOverview({ simulationPassId }) { if (!this._perSimulationPassOverviewModel.pagination.isInfiniteScrollEnabled) { this._perSimulationPassOverviewModel.simulationPassId = parseInt(simulationPassId, 10); - if (this._perSimulationPassOverviewModel.pagination._defaultItemsPerPage) { - /** - * If the default items per page is set, it means model has loaded already once, - * so the pagination trigger will not refresh the data. - * Thus, we need to trigger the load here. - */ - this._perSimulationPassOverviewModel.setFilterFromURL(false); - this._perSimulationPassOverviewModel.load(); - } + this._perSimulationPassOverviewModel.load(); } } diff --git a/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewModel.js b/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewModel.js index 084b57d130..9b8b982d4b 100644 --- a/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewModel.js +++ b/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewModel.js @@ -13,7 +13,7 @@ import { buildUrl, RemoteData } from '/js/src/index.js'; import { ObservableData } from '../../../utilities/ObservableData.js'; import { getRemoteData } from '../../../utilities/fetch/getRemoteData.js'; -import { rctDetectorsProvider } from '../../../services/detectors/detectorsProvider.js'; +import { detectorsProvider } from '../../../services/detectors/detectorsProvider.js'; import { FixedPdpBeamTypeRunsOverviewModel } from '../Overview/FixedPdpBeamTypeRunsOverviewModel.js'; /** @@ -23,14 +23,17 @@ export class RunsPerSimulationPassOverviewModel extends FixedPdpBeamTypeRunsOver /** * Constructor * @param {Model} model global model - * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(model, pageIdentifier) { - super(model, pageIdentifier); + constructor(model) { + super(model); this._simulationPass$ = new ObservableData(RemoteData.notAsked()); - this._detectors$ = rctDetectorsProvider.qc$; + this._detectors$ = detectorsProvider.qc$; + + this.registerObervablesQcSummaryDependesOn([this._detectors$]); + this.registerDetectorsNotBadFractionFilterModels(this._detectors$); + this.registerDetectorsForQcFlagsDataExport(this._detectors$); this._detectors$.bubbleTo(this); this._simulationPass$.bubbleTo(this); @@ -58,16 +61,12 @@ export class RunsPerSimulationPassOverviewModel extends FixedPdpBeamTypeRunsOver return; } - await this._fetchSimulationPass(); - this._simulationPass$.getCurrent().match({ - Success: ({ pdpBeamTypes }) => this.setPdpBeamTypes(pdpBeamTypes), - Other: () => null, + this._fetchSimulationPass().then(() => { + this._simulationPass$.getCurrent().match({ + Success: ({ pdpBeamTypes }) => this.setPdpBeamTypes(pdpBeamTypes), + Other: () => null, + }); }); - - this.registerDetectorsNotBadFractionFilterModels(this._detectors$); - this.registerDetectorsForQcFlagsDataExport(this._detectors$); - this.registerObservablesQcSummaryDependsOn(this._detectors$); - super.load(); } @@ -75,8 +74,13 @@ export class RunsPerSimulationPassOverviewModel extends FixedPdpBeamTypeRunsOver * @inheritdoc */ getRootEndpoint() { - const filter = { simulationPassIds: [this._simulationPassId] }; - return buildUrl(super.getRootEndpoint(), { filter }); + const params = { + filter: { + simulationPassIds: [this._simulationPassId], + }, + }; + + return buildUrl(super.getRootEndpoint(), params); } /** @@ -84,7 +88,7 @@ export class RunsPerSimulationPassOverviewModel extends FixedPdpBeamTypeRunsOver * @param {number} simulationPassId simulation pass id */ set simulationPassId(simulationPassId) { - if (this._simulationPassId && simulationPassId !== this._simulationPassId) { + if (simulationPassId !== this._simulationPassId) { this.reset(false); } this._simulationPassId = simulationPassId; diff --git a/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js b/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js index c64fcbe6c8..55d4cdb988 100644 --- a/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js +++ b/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js @@ -27,9 +27,8 @@ import errorAlert from '../../../components/common/errorAlert.js'; import { getInelasticInteractionRateColumns } from '../ActiveColumns/getInelasticInteractionRateActiveColumns.js'; import { exportTriggerAndModal } from '../../../components/common/dataExport/exportTriggerAndModal.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; -import { toggleFilter } from '../../../components/Filters/common/filters/toggleFilter.js'; -import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; -import { warningComponent } from '../../../components/common/messages/warningComponent.js'; +import { runNumbersFilter } from '../../../components/Filters/RunsFilter/runNumbersFilter.js'; +import { mcReproducibleAsNotBadToggle } from '../mcReproducibleAsNotBadToggle.js'; const TABLEROW_HEIGHT = 59; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -53,6 +52,11 @@ export const RunsPerSimulationPassOverviewPage = ({ dplDetectorsUserHasAccessTo: remoteDplDetectorsUserHasAccessTo, modalModel, }) => { + perSimulationPassOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( + TABLEROW_HEIGHT, + PAGE_USED_HEIGHT, + )); + const { items: remoteRuns, detectors: remoteDetectors, @@ -63,69 +67,60 @@ export const RunsPerSimulationPassOverviewPage = ({ sortModel, pdpBeamTypes, mcReproducibleAsNotBad, - pagination, } = perSimulationPassOverviewModel; - pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); - const commonTitle = h('h2', 'Runs per MC'); - const fullPageData = mergeRemoteData([remoteRuns, remoteSimulationPass, remoteDetectors, remoteQcSummary]); - const simulationPass = remoteSimulationPass.match({ Other: () => null, Success: (data) => data }); - const detectors = remoteDetectors.match({ Other: () => null, Success: (data) => data }); - const qcSummary = remoteQcSummary.match({ Other: () => null, Success: (data) => data }); - - const activeColumns = { - ...runsActiveColumns, - ...getInelasticInteractionRateColumns(pdpBeamTypes), - ...detectors && qcSummary && createRunDetectorsAsyncQcActiveColumns( - perSimulationPassOverviewModel.runDetectorsSelectionModel, - detectors, - remoteDplDetectorsUserHasAccessTo, - { simulationPass }, - { - profile: 'runsPerSimulationPass', - qcSummary, - }, - ), - }; + return h( + '.intermediate-flex-column', + mergeRemoteData([remoteSimulationPass, remoteRuns, remoteDetectors, remoteQcSummary]).match({ + NotAsked: () => null, + Failure: (errors) => errorAlert(errors), + Success: ([simulationPass, runs, detectors, qcSummary]) => { + const activeColumns = { + ...runsActiveColumns, + ...getInelasticInteractionRateColumns(pdpBeamTypes), + ...createRunDetectorsAsyncQcActiveColumns( + perSimulationPassOverviewModel.runDetectorsSelectionModel, + detectors, + remoteDplDetectorsUserHasAccessTo, + { simulationPass }, + { + profile: 'runsPerSimulationPass', + qcSummary, + }, + ), + }; - return [ - h('.flex-row.justify-between.items-center.g2', [ - filtersPanelPopover(perSimulationPassOverviewModel, activeColumns, { profile: 'runsPerSimulationPass' }), - h('.pl2#runOverviewFilter', textInputFilter(perSimulationPassOverviewModel.filteringModel, 'runNumbers', 'e.g. 534454, 534455...')), - h( - '.flex-row.g1.items-center', - breadcrumbs([ - commonTitle, - h('h2#breadcrumb-simulation-pass-name', simulationPass?.name ?? spinner({ size: 1, absolute: false })), - ]), - ), - toggleFilter(mcReproducibleAsNotBad, h('em', 'MC.R as not-bad'), 'mcReproducibleAsNotBadToggle'), - h('.mlauto', qcSummaryLegendTooltip()), - exportTriggerAndModal(perSimulationPassOverviewModel.exportModel, modalModel, { autoMarginLeft: false }), - frontLink( - h( - 'button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', - { - disabled: perSimulationPassOverviewModel.runDetectorsSelectionModel.selectedQueryString.length < 1, - }, - 'Set QC Flags', - ), - 'qc-flag-creation-for-simulation-pass', - { - runNumberDetectorsMap: perSimulationPassOverviewModel.runDetectorsSelectionModel.selectedQueryString, - simulationPassId, - }, - ), - ]), - warningComponent(perSimulationPassOverviewModel), - h( - '.intermediate-flex-column', - fullPageData.match({ - NotAsked: () => null, - Failure: (errors) => errorAlert(errors), - Success: ([runs]) => [ + return [ + h('.flex-row.justify-between.items-center.g2', [ + filtersPanelPopover(perSimulationPassOverviewModel, activeColumns, { profile: 'runsPerSimulationPass' }), + h('.pl2#runOverviewFilter', runNumbersFilter(perSimulationPassOverviewModel.filteringModel.get('runNumbers'))), + h( + '.flex-row.g1.items-center', + breadcrumbs([commonTitle, h('h2#breadcrumb-simulation-pass-name', simulationPass.name)]), + ), + mcReproducibleAsNotBadToggle( + mcReproducibleAsNotBad, + () => perSimulationPassOverviewModel.setMcReproducibleAsNotBad(!mcReproducibleAsNotBad), + ), + h('.mlauto', qcSummaryLegendTooltip()), + exportTriggerAndModal(perSimulationPassOverviewModel.exportModel, modalModel, { autoMarginLeft: false }), + frontLink( + h( + 'button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', + { + disabled: perSimulationPassOverviewModel.runDetectorsSelectionModel.selectedQueryString.length < 1, + }, + 'Set QC Flags', + ), + 'qc-flag-creation-for-simulation-pass', + { + runNumberDetectorsMap: perSimulationPassOverviewModel.runDetectorsSelectionModel.selectedQueryString, + simulationPassId, + }, + ), + ]), table( runs, activeColumns, @@ -136,10 +131,10 @@ export const RunsPerSimulationPassOverviewPage = ({ }, { sort: sortModel }, ), - paginationComponent(pagination), - ], - Loading: () => spinner(), - }), - ), - ]; + paginationComponent(perSimulationPassOverviewModel.pagination), + ]; + }, + Loading: () => spinner(), + }), + ); }; diff --git a/lib/public/views/Runs/format/editRunEorReasons.js b/lib/public/views/Runs/format/editRunEorReasons.js index 6ba0d59e24..56c69e6f04 100644 --- a/lib/public/views/Runs/format/editRunEorReasons.js +++ b/lib/public/views/Runs/format/editRunEorReasons.js @@ -94,23 +94,20 @@ export const editRunEorReasons = (runDetailsModel) => { */ runDetailsModel.runPatch.eorReasons.length > 0 ? runDetailsModel.runPatch.eorReasons.map((eorReason) => { - const { reasonTypeId, description, lastEditedName } = eorReason; + const { reasonTypeId, description } = eorReason; const { category = '-', title } = eorReasonTypes.find((eorReasonType) => eorReasonType.id === reasonTypeId) || {}; const titleString = title ? ` - ${title}` : ''; const descriptionString = description ? ` - ${description}` : ''; return h( - '.flex-row.justify-between', + '.flex-row.items-center', { key: `${category} ${titleString} ${descriptionString}`, }, [ - h('.flex-row.items-center', [ - h('label.remove-eor-reason.danger.ph1.actionable-icon', { - onclick: () => runDetailsModel.runPatch.removeEorReason(eorReason), - }, iconTrash()), - h('.w-wrapped', `${category} ${titleString} ${descriptionString}`), - ]), - h('.w-wrapped', lastEditedName || null), + h('label.remove-eor-reason.danger.ph1.actionable-icon', { + onclick: () => runDetailsModel.runPatch.removeEorReason(eorReason), + }, iconTrash()), + h('.w-wrapped', `${category} ${titleString} ${descriptionString}`), ], ); }) diff --git a/lib/public/views/Runs/format/formatRunEorReason.js b/lib/public/views/Runs/format/formatRunEorReason.js deleted file mode 100644 index b97ab4a223..0000000000 --- a/lib/public/views/Runs/format/formatRunEorReason.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @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 { h } from '/js/src/index.js'; -import { tooltip } from '../../../../components/common/popover/tooltip.js'; -import { formatEorReason } from './formatEorReason.mjs'; - -/** - * Display the given EoR reason as a vnode component with lastEditedName tooltip - * - * @param {Partial<{ - * category: string, - * title: string, - * description: string, - * lastEditedName: string, - * }>} eorReason the EoR reason to display - * @return {VNode} the vnode component - */ -export const formatRunEorReason = (eorReason) => { - const { lastEditedName } = eorReason; - const reasonText = formatEorReason(eorReason); - return h('.w-100.flex-row.justify-between', [ - h('', reasonText), - lastEditedName ? tooltip(h('.w-wrapped', lastEditedName), 'Last edited by') : null, - ]); -}; diff --git a/lib/public/views/Runs/mcReproducibleAsNotBadToggle.js b/lib/public/views/Runs/mcReproducibleAsNotBadToggle.js new file mode 100644 index 0000000000..636ed0f245 --- /dev/null +++ b/lib/public/views/Runs/mcReproducibleAsNotBadToggle.js @@ -0,0 +1,28 @@ +/** + * @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 { switchInput } from '../../components/common/form/switchInput.js'; +import { h } from '/js/src/index.js'; + +/** + * Display a toggle switch to change interpretation of MC.Reproducible flag type from bad to not-bad + * + * @param {boolean} value current value + * @param {function} onChange to be called when switching + * @returns {Component} the toggle switch + */ +export const mcReproducibleAsNotBadToggle = (value, onChange) => h('#mcReproducibleAsNotBadToggle', switchInput( + value, + onChange, + { labelAfter: h('em', 'MC.R as not-bad') }, +)); diff --git a/lib/public/views/SimulationPasses/ActiveColumns/simulationPassesActiveColumns.js b/lib/public/views/SimulationPasses/ActiveColumns/simulationPassesActiveColumns.js index 95f9940c22..05b796bcf8 100644 --- a/lib/public/views/SimulationPasses/ActiveColumns/simulationPassesActiveColumns.js +++ b/lib/public/views/SimulationPasses/ActiveColumns/simulationPassesActiveColumns.js @@ -31,8 +31,8 @@ export const simulationPassesActiveColumns = { name: 'Name', visible: true, sortable: true, - filter: ({ filteringModel }) => textFilter( - filteringModel.get('names'), + filter: ({ namesFilterModel }) => textFilter( + namesFilterModel, { class: 'w-75 mt1', placeholder: 'e.g. LHC23k5, ...' }, ), classes: 'w-10 f6', diff --git a/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewModel.js b/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewModel.js index 3054251391..ed6b776215 100644 --- a/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewModel.js +++ b/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewModel.js @@ -10,23 +10,24 @@ * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. */ -import { RemoteData } from '/js/src/index.js'; +import { buildUrl, RemoteData } from '/js/src/index.js'; import { TextTokensFilterModel } from '../../../components/Filters/common/filters/TextTokensFilterModel.js'; +import { OverviewPageModel } from '../../../models/OverviewModel.js'; import { getRemoteData } from '../../../utilities/fetch/getRemoteData.js'; import { ObservableData } from '../../../utilities/ObservableData.js'; -import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; /** * Simulation Passes Per Data Pass overview model */ -export class AnchoredSimulationPassesOverviewModel extends FilterableOverviewPageModel { +export class AnchoredSimulationPassesOverviewModel extends OverviewPageModel { /** * Constructor - * @param {QueryRouter} router router that controls the application's page navigation - * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(router, pageIdentifier) { - super(router, pageIdentifier, { names: new TextTokensFilterModel() }); + constructor() { + super(); + this._namesFilterModel = new TextTokensFilterModel(); + this._registerFilter(this._namesFilterModel); + this._dataPass = new ObservableData(RemoteData.notAsked()); } @@ -55,15 +56,25 @@ export class AnchoredSimulationPassesOverviewModel extends FilterableOverviewPag /** * @inheritdoc */ - getFilterParams() { - return { ...super.getFilterParams(), dataPassIds: [this._dataPassId] }; + getRootEndpoint() { + const params = { + filter: { + names: this._namesFilterModel.normalized, + dataPassIds: [this._dataPassId], + }, + }; + + return buildUrl('/api/simulationPasses', params); } /** - * @inheritdoc + * Reset this model to its default + * + * @returns {void} */ - getRootEndpoint() { - return this.buildRootEndpoint('/api/simulationPasses'); + reset() { + this._namesFilterModel.reset(); + super.reset(); } /** @@ -80,4 +91,34 @@ export class AnchoredSimulationPassesOverviewModel extends FilterableOverviewPag get dataPass() { return this._dataPass.getCurrent(); } + + /** + * Returns data passes names filter model + * @return {TextTokensFilterModel} data passes names filter model + */ + get namesFilterModel() { + return this._namesFilterModel; + } + + /** + * Register a new filter model + * @param {FilterModel} filterModel the filter model to register + * @return {void} + * @private + */ + _registerFilter(filterModel) { + filterModel.visualChange$.bubbleTo(this); + filterModel.observe(() => { + this._pagination.silentlySetCurrentPage(1); + this.load(); + }); + } + + /** + * States whether any filter is active + * @return {boolean} true if any filter is active + */ + isAnyFilterActive() { + return !this._namesFilterModel.isEmpty; + } } diff --git a/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewPage.js b/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewPage.js index f9f752836c..5894ba1a05 100644 --- a/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewPage.js +++ b/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewPage.js @@ -21,7 +21,6 @@ import { simulationPassesActiveColumns } from '../ActiveColumns/simulationPasses import { breadcrumbs } from '../../../components/common/navigation/breadcrumbs.js'; import spinner from '../../../components/common/spinner.js'; import { tooltip } from '../../../components/common/popover/tooltip.js'; -import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 42; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -35,9 +34,13 @@ const PAGE_USED_HEIGHT = 215; export const AnchoredSimulationPassesOverviewPage = ({ simulationPasses: { anchoredOverviewModel: anchoredSimulationPassesOverviewModel }, }) => { - const { items, dataPass, pagination, sortModel } = anchoredSimulationPassesOverviewModel; + anchoredSimulationPassesOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( + TABLEROW_HEIGHT, + PAGE_USED_HEIGHT, + )); + + const { items, dataPass, pagination } = anchoredSimulationPassesOverviewModel; - pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); const commonTitle = h('h2#breadcrumb-header', { style: 'white-space: nowrap;' }, 'Anchored MC'); return h( @@ -58,14 +61,13 @@ export const AnchoredSimulationPassesOverviewPage = ({ }), ), ]), - warningComponent(anchoredSimulationPassesOverviewModel), h('.w-100.flex-column', [ table( items, simulationPassesActiveColumns, { classes: '.table-sm' }, null, - { sort: sortModel }, + { sort: anchoredSimulationPassesOverviewModel.sortModel }, ), paginationComponent(pagination), ]), diff --git a/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewModel.js b/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewModel.js index 0980a8c961..98e5d12059 100644 --- a/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewModel.js +++ b/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewModel.js @@ -11,22 +11,23 @@ * or submit itself to any jurisdiction. */ import { TextTokensFilterModel } from '../../../components/Filters/common/filters/TextTokensFilterModel.js'; -import { RemoteData } from '/js/src/index.js'; +import { OverviewPageModel } from '../../../models/OverviewModel.js'; +import { buildUrl, RemoteData } from '/js/src/index.js'; import { ObservableData } from '../../../utilities/ObservableData.js'; import { getRemoteData } from '../../../utilities/fetch/getRemoteData.js'; -import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; /** * Simulation Passes Per LHC Period overview model */ -export class SimulationPassesPerLhcPeriodOverviewModel extends FilterableOverviewPageModel { +export class SimulationPassesPerLhcPeriodOverviewModel extends OverviewPageModel { /** * Constructor - * @param {QueryRouter} router router that controls the application's page navigation - * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(router, pageIdentifier) { - super(router, pageIdentifier, { names: new TextTokensFilterModel() }); + constructor() { + super(); + + this._namesFilterModel = new TextTokensFilterModel(); + this._registerFilter(this._namesFilterModel); this._lhcPeriod = new ObservableData(RemoteData.notAsked()); this._lhcPeriod.bubbleTo(this); @@ -59,15 +60,25 @@ export class SimulationPassesPerLhcPeriodOverviewModel extends FilterableOvervie /** * @inheritdoc */ - getFilterParams() { - return { ...super.getFilterParams(), lhcPeriodIds: [this._lhcPeriodId] }; + getRootEndpoint() { + const params = { + filter: { + names: this._namesFilterModel.normalized, + lhcPeriodIds: [this._lhcPeriodId], + }, + }; + + return buildUrl('/api/simulationPasses', params); } /** - * @inheritdoc + * Reset this model to its default + * + * @returns {void} */ - getRootEndpoint() { - return this.buildRootEndpoint('/api/simulationPasses'); + reset() { + this._namesFilterModel.reset(); + super.reset(); } /** @@ -84,4 +95,34 @@ export class SimulationPassesPerLhcPeriodOverviewModel extends FilterableOvervie get lhcPeriod() { return this._lhcPeriod.getCurrent(); } + + /** + * Returns simulation passes names filter model + * @return {TextTokensFilterModel} simulation passes names filter model + */ + get namesFilterModel() { + return this._namesFilterModel; + } + + /** + * Register a new filter model + * @param {FilterModel} filterModel the filter model to register + * @return {void} + * @private + */ + _registerFilter(filterModel) { + filterModel.visualChange$.bubbleTo(this); + filterModel.observe(() => { + this._pagination.silentlySetCurrentPage(1); + this.load(); + }); + } + + /** + * States whether any filter is active + * @return {boolean} true if any filter is active + */ + isAnyFilterActive() { + return !this._namesFilterModel.isEmpty; + } } diff --git a/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewPage.js b/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewPage.js index 0d2961b5f3..3cc12756d0 100644 --- a/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewPage.js +++ b/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewPage.js @@ -21,7 +21,6 @@ import { simulationPassesActiveColumns } from '../ActiveColumns/simulationPasses import spinner from '../../../components/common/spinner.js'; import { tooltip } from '../../../components/common/popover/tooltip.js'; import { breadcrumbs } from '../../../components/common/navigation/breadcrumbs.js'; -import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 42; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -34,9 +33,12 @@ const PAGE_USED_HEIGHT = 215; */ export const SimulationPassesPerLhcPeriodOverviewPage = ({ simulationPasses: { perLhcPeriodOverviewModel: simulationPassesPerLhcPeriodOverviewModel } }) => { - const { items: simulationPasses, lhcPeriod, pagination, sortModel } = simulationPassesPerLhcPeriodOverviewModel; + simulationPassesPerLhcPeriodOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( + TABLEROW_HEIGHT, + PAGE_USED_HEIGHT, + )); - pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); + const { items: simulationPasses, lhcPeriod } = simulationPassesPerLhcPeriodOverviewModel; const commonTitle = h('h2#breadcrumb-header', { style: 'white-space: nowrap;' }, 'Monte Carlo'); @@ -55,10 +57,15 @@ export const SimulationPassesPerLhcPeriodOverviewPage = ({ simulationPasses: { }), ), ]), - warningComponent(simulationPassesPerLhcPeriodOverviewModel), h('.w-100.flex-column', [ - table(simulationPasses, simulationPassesActiveColumns, { classes: '.table-sm' }, null, { sort: sortModel }), - paginationComponent(pagination), + table( + simulationPasses, + simulationPassesActiveColumns, + { classes: '.table-sm' }, + null, + { sort: simulationPassesPerLhcPeriodOverviewModel.sortModel }, + ), + paginationComponent(simulationPassesPerLhcPeriodOverviewModel.pagination), ]), ]); }; diff --git a/lib/public/views/SimulationPasses/SimulationPassesModel.js b/lib/public/views/SimulationPasses/SimulationPassesModel.js index 8ba624efd8..8e8d6e7969 100644 --- a/lib/public/views/SimulationPasses/SimulationPassesModel.js +++ b/lib/public/views/SimulationPasses/SimulationPassesModel.js @@ -21,15 +21,14 @@ import { AnchoredSimulationPassesOverviewModel } from './AnchoredOverview/Anchor export class SimulationPassesModel extends Observable { /** * The constructor of the model - * @param {QueryRouter} router router that controls the application's page navigation */ - constructor(router) { + constructor() { super(); - this._perLhcPeriodOverviewModel = new SimulationPassesPerLhcPeriodOverviewModel(router, 'simulation-passes-per-lhc-period-overview'); + this._perLhcPeriodOverviewModel = new SimulationPassesPerLhcPeriodOverviewModel(); this._perLhcPeriodOverviewModel.bubbleTo(this); - this._anchoredOverviewModel = new AnchoredSimulationPassesOverviewModel(router, 'anchored-simulation-passes-overview'); + this._anchoredOverviewModel = new AnchoredSimulationPassesOverviewModel(); this._anchoredOverviewModel.bubbleTo(this); } @@ -42,7 +41,6 @@ export class SimulationPassesModel extends Observable { loadPerLhcPeriodOverview({ lhcPeriodId }) { if (!this._perLhcPeriodOverviewModel.pagination.isInfiniteScrollEnabled) { this._perLhcPeriodOverviewModel.lhcPeriodId = lhcPeriodId; - this._perLhcPeriodOverviewModel.setFilterFromURL(false); this._perLhcPeriodOverviewModel.load(); } } @@ -72,7 +70,6 @@ export class SimulationPassesModel extends Observable { */ loadAnchoredOverview({ dataPassId }) { this._anchoredOverviewModel.dataPassId = dataPassId; - this._anchoredOverviewModel.setFilterFromURL(false); this._anchoredOverviewModel.load(); } diff --git a/lib/public/views/lhcPeriods/ActiveColumns/lhcPeriodsActiveColumns.js b/lib/public/views/lhcPeriods/ActiveColumns/lhcPeriodsActiveColumns.js index 6c312fbecf..6f757e25d0 100644 --- a/lib/public/views/lhcPeriods/ActiveColumns/lhcPeriodsActiveColumns.js +++ b/lib/public/views/lhcPeriods/ActiveColumns/lhcPeriodsActiveColumns.js @@ -30,8 +30,8 @@ export const lhcPeriodsActiveColumns = { name: 'Name', visible: true, sortable: true, - filter: ({ filteringModel }) => textFilter( - filteringModel.get('names'), + filter: ({ namesFilterModel }) => textFilter( + namesFilterModel, { class: 'w-75 mt1', placeholder: 'e.g. LHC22a, lhc23b, ...' }, ), classes: 'w-15', @@ -92,8 +92,8 @@ export const lhcPeriodsActiveColumns = { visible: true, sortable: true, format: (_, lhcPeriod) => formatLhcPeriodYear(lhcPeriod.name), - filter: ({ filteringModel }) => textFilter( - filteringModel.get('years'), + filter: ({ yearsFilterModel }) => textFilter( + yearsFilterModel, { class: 'w-75 mt1', placeholder: 'e.g. 2022, 2023, ...' }, ), classes: 'w-7', @@ -104,8 +104,8 @@ export const lhcPeriodsActiveColumns = { visible: true, sortable: true, format: (pdpBeamTypes) => pdpBeamTypes.length > 0 ? pdpBeamTypes.join(',') : '-', - filter: ({ filteringModel }) => textFilter( - filteringModel.get('pdpBeamTypes'), + filter: ({ pdpBeamTypesFilterModel }) => textFilter( + pdpBeamTypesFilterModel, { class: 'w-75 mt1', placeholder: 'e.g. pp, PbPb' }, ), classes: 'w-7', diff --git a/lib/public/views/lhcPeriods/LhcPeriodsModel.js b/lib/public/views/lhcPeriods/LhcPeriodsModel.js index 74df7b9dc7..4f9d0ed185 100644 --- a/lib/public/views/lhcPeriods/LhcPeriodsModel.js +++ b/lib/public/views/lhcPeriods/LhcPeriodsModel.js @@ -20,12 +20,11 @@ import { LhcPeriodsOverviewModel } from './Overview/LhcPeriodsOverviewModel.js'; export class LhcPeriodsModel extends Observable { /** * The constructor of the model - * @param {QueryRouter} router router that controls the application's page navigation */ - constructor(router) { + constructor() { super(); - this._overviewModel = new LhcPeriodsOverviewModel(router, 'lhc-period-overview'); + this._overviewModel = new LhcPeriodsOverviewModel(); this._overviewModel.bubbleTo(this); } @@ -35,7 +34,6 @@ export class LhcPeriodsModel extends Observable { * @returns {void} */ loadOverview() { - this._overviewModel.setFilterFromURL(false); this._overviewModel.load(); } diff --git a/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewModel.js b/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewModel.js index 88bf797877..eb2d5e48cd 100644 --- a/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewModel.js +++ b/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewModel.js @@ -12,36 +12,42 @@ */ import { TextTokensFilterModel } from '../../../components/Filters/common/filters/TextTokensFilterModel.js'; -import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; +import { OverviewPageModel } from '../../../models/OverviewModel.js'; +import { buildUrl } from '/js/src/index.js'; /** * LHC Periods overview model * * @implements {OverviewModel} */ -export class LhcPeriodsOverviewModel extends FilterableOverviewPageModel { +export class LhcPeriodsOverviewModel extends OverviewPageModel { /** * The constructor of the Overview model object - * @param {QueryRouter} router router that controls the application's page navigation - * @param {string} pageIdentifier string that indicates what page this model represents - */ - constructor(router, pageIdentifier) { - super( - router, - pageIdentifier, - { - names: new TextTokensFilterModel(), - years: new TextTokensFilterModel(), - pdpBeamTypes: new TextTokensFilterModel(), - }, - ); + */ + constructor() { + super(); + + this._namesFilterModel = new TextTokensFilterModel(); + this._registerFilter(this._namesFilterModel); + this._yearsFilterModel = new TextTokensFilterModel(); + this._registerFilter(this._yearsFilterModel); + this._pdpBeamTypesFilterModel = new TextTokensFilterModel(); + this._registerFilter(this._pdpBeamTypesFilterModel); } /** * @inheritdoc */ getRootEndpoint() { - return this.buildRootEndpoint('/api/lhcPeriodsStatistics'); + const params = { + filter: { + names: this._namesFilterModel.normalized, + years: this._yearsFilterModel.normalized, + pdpBeamTypes: this._pdpBeamTypesFilterModel.normalized, + }, + }; + + return buildUrl('/api/lhcPeriodsStatistics', params); } /** @@ -59,4 +65,62 @@ export class LhcPeriodsOverviewModel extends FilterableOverviewPageModel { }; }); } + + /** + * Returns lhc periods names filter model + * @return {TextTokensFilterModel} lhc periods names filter model + */ + get namesFilterModel() { + return this._namesFilterModel; + } + + /** + * Returns lhc periods years filter model + * @return {TextTokensFilterModel} lhc periods years filter model + */ + get yearsFilterModel() { + return this._yearsFilterModel; + } + + /** + * Returns lhc periods beam type filter model + * @return {TextTokensFilterModel} lhc periods beam type filter model + */ + get pdpBeamTypesFilterModel() { + return this._pdpBeamTypesFilterModel; + } + + /** + * Reset this model to its default + * + * @returns {void} + */ + reset() { + super.reset(); + this._namesFilterModel.reset(); + this._yearsFilterModel.reset(); + this._pdpBeamTypesFilterModel.reset(); + } + + /** + * Register a new filter model + * @param {FilterModel} filterModel the filter model to register + * @return {void} + * @private + */ + _registerFilter(filterModel) { + filterModel.visualChange$.bubbleTo(this); + filterModel.observe(() => { + this._pagination.silentlySetCurrentPage(1); + this.load(); + }); + } + + /** + * States whether any filter is active + * @return {boolean} true if any filter is active + */ + isAnyFilterActive() { + return !this._namesFilterModel.isEmpty || !this._yearsFilterModel.isEmpty || !this._pdpBeamTypesFilterModel.isEmpty; + } } diff --git a/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewPage.js b/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewPage.js index 89c0def48c..b431c62d42 100644 --- a/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewPage.js +++ b/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewPage.js @@ -18,7 +18,6 @@ import { lhcPeriodsActiveColumns } from '../ActiveColumns/lhcPeriodsActiveColumn import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; -import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 35; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -30,19 +29,22 @@ const PAGE_USED_HEIGHT = 215; * @returns {Component} The overview screen */ export const LhcPeriodsOverviewPage = ({ lhcPeriods: { overviewModel: lhcPeriodsOverviewModel } }) => { - const { sortModel, pagination, items } = lhcPeriodsOverviewModel; - - pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); + lhcPeriodsOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( + TABLEROW_HEIGHT, + PAGE_USED_HEIGHT, + )); return h('', [ - h( - '.flex-row.header-container.pv2', - filtersPanelPopover(lhcPeriodsOverviewModel, lhcPeriodsActiveColumns), - ), - warningComponent(lhcPeriodsOverviewModel), + h('.flex-row.header-container.pv2', filtersPanelPopover(lhcPeriodsOverviewModel, lhcPeriodsActiveColumns)), h('.w-100.flex-column', [ - table(items, lhcPeriodsActiveColumns, { classes: '.table-sm' }, null, { sort: sortModel }), - paginationComponent(pagination), + table( + lhcPeriodsOverviewModel.items, + lhcPeriodsActiveColumns, + { classes: '.table-sm' }, + null, + { sort: lhcPeriodsOverviewModel.sortModel }, + ), + paginationComponent(lhcPeriodsOverviewModel.pagination), ]), ]); }; diff --git a/lib/server/Loggers/FilterLogger.js b/lib/server/Loggers/FilterLogger.js deleted file mode 100644 index 0ae19af9bf..0000000000 --- a/lib/server/Loggers/FilterLogger.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @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. - */ -const { LogManager, LogLevel } = require('@aliceo2/web-ui'); -const { isInTestMode } = require('../../utilities/env-utils'); - -/** - * Logger dedicated to filter-related endpoint access events. - */ -class FilterLogger { - /** - * Creates an instance of FilterLogger. - */ - constructor(silent = isInTestMode()) { - LogManager.configure({ infologger: true }); - this._logger = LogManager.getLogger('FILTERING'); - this._logLevel = LogLevel.OPERATIONS; - this._silent = silent; - } - - /** - * Logs an informational message about endpoint access and applied filters. - * - * @param {object} request the request received at any given endpoint. - * @param {string} endpoint the endpoint that was accessed. - * @param {string|number} id identifier of the user accessing the endpoint. - * @param {Object} [filters={}] filters applied to the request. - * @returns {void} - */ - infoMessage({ path, session: { id } = {}, query = {} }) { - if (this._silent) { - return; - } - - const filters = query.filter ?? {}; - - let message = `Endpoint ${path} was accessed by `; - message += id ? `user ${id} ` : 'an unauthenticated user '; - - if (!Object.keys(filters).length) { - message += 'without filters'; - } else { - message += 'with the following filters:\n'; - message += JSON.stringify(filters); - } - - this._logger.infoMessage(message, { level: this._logLevel }); - } -} - -module.exports = new FilterLogger(); diff --git a/lib/server/controllers/dataPasses.controller.js b/lib/server/controllers/dataPasses.controller.js index 116673beaf..81e2de5d6b 100644 --- a/lib/server/controllers/dataPasses.controller.js +++ b/lib/server/controllers/dataPasses.controller.js @@ -21,10 +21,7 @@ const { dtoValidator } = require('../utilities/dtoValidator.js'); const { countedItemsToHttpView } = require('../utilities/countedItemsToHttpView.js'); const { updateExpressResponseFromNativeError } = require('../express/updateExpressResponseFromNativeError'); const PaginationDto = require('../../domain/dtos/PaginationDto.js'); -const { - NON_PHYSICS_PRODUCTIONS_NAMES_WORDS, - NON_PHYSICS_PRODUCTIONS_NAMES_TOTAL_LENGTH, -} = require('../../domain/enums/NonPhysicsProductionsNamesWords.js'); +const { NON_PHYSICS_PRODUCTIONS_NAMES_WORDS } = require('../../domain/enums/NonPhysicsProductionsNamesWords.js'); /** * List All DataPasses with statistics @@ -37,14 +34,17 @@ const listDataPassesHandler = async (req, res) => { lhcPeriodIds: Joi.array().items(Joi.number()), ids: Joi.array().items(Joi.number()), names: Joi.array().items(Joi.string()), - permittedNonPhysicsNames: Joi.string().max(NON_PHYSICS_PRODUCTIONS_NAMES_TOTAL_LENGTH).custom((value, helper) => { - const nameTokens = value.split(','); + include: Joi.object({ byName: Joi.string().custom((value, helper) => { + if (value.length > 10) { + return helper.error('byName cannot have more than 10 characters'); + } + const nameTokens = value?.split(','); const allTokensCorrect = nameTokens.every((token) => NON_PHYSICS_PRODUCTIONS_NAMES_WORDS.includes(token)); if (!allTokensCorrect) { - return helper.error(`All permittedNonPhysicsNames must comma delimited list of ${NON_PHYSICS_PRODUCTIONS_NAMES_WORDS}`); + return helper.error(`All byName must comma delimited list of ${NON_PHYSICS_PRODUCTIONS_NAMES_WORDS}`); } return nameTokens; - }), + }) }), }, page: PaginationDto, sort: DtoFactory.order(['id', 'name']), diff --git a/lib/server/controllers/lhcPeriodStatistics.controller.js b/lib/server/controllers/lhcPeriodStatistics.controller.js index 8784e3871e..c70b04b67c 100644 --- a/lib/server/controllers/lhcPeriodStatistics.controller.js +++ b/lib/server/controllers/lhcPeriodStatistics.controller.js @@ -42,7 +42,7 @@ const listLhcPeriodStatisticsHandler = async (req, res) => { ); if (validatedDTO) { try { - const { filter, page: { limit = ApiConfig.pagination.limit, offset } = {}, sort = { id: 'DESC' } } = validatedDTO.query; + const { filter, page: { limit = ApiConfig.pagination.limit, offset } = {}, sort = { name: 'DESC' } } = validatedDTO.query; const { count, rows: items } = await lhcPeriodStatisticsService.getAllForPhysicsRuns({ filter, limit, diff --git a/lib/server/middleware/InfoLoggerListener.middleware.js b/lib/server/middleware/InfoLoggerListener.middleware.js deleted file mode 100644 index 858b8a805d..0000000000 --- a/lib/server/middleware/InfoLoggerListener.middleware.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @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. - */ - -/** - * Logger based middleware generator - * - * @param {Class} logger class that exposes an infoMessage function that recceives the request and then sends specific data to InfoLogger - * @return {(function(*, *, *): void)} the infoLoggerListener middleware - */ -exports.infoLoggerListenerMiddleware = (logger) => (request, _response, next) => { - logger.infoMessage(request); - next(); -}; diff --git a/lib/server/routers/dataPasses.router.js b/lib/server/routers/dataPasses.router.js index 89e0e7cd58..34f97d2547 100644 --- a/lib/server/routers/dataPasses.router.js +++ b/lib/server/routers/dataPasses.router.js @@ -14,13 +14,11 @@ const { DataPassesController } = require('../controllers/dataPasses.controller.js'); const { rbacMiddleware } = require('../middleware/rbac.middleware.js'); const { BkpRoles } = require('../../domain/enums/BkpRoles.js'); -const FilterLogger = require('../Loggers/FilterLogger.js'); -const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware.js'); exports.dataPassesRouter = { path: '/dataPasses', method: 'get', - controller: [infoLoggerListenerMiddleware(FilterLogger), DataPassesController.listDataPassesHandler], + controller: DataPassesController.listDataPassesHandler, children: [ { diff --git a/lib/server/routers/environments.router.js b/lib/server/routers/environments.router.js index 1c0769bc68..56d4066e73 100644 --- a/lib/server/routers/environments.router.js +++ b/lib/server/routers/environments.router.js @@ -12,13 +12,11 @@ */ const { EnvironmentsController } = require('../controllers'); -const FilterLogger = require('../Loggers/FilterLogger'); -const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware'); module.exports = { method: 'get', path: '/environments', - controller: [infoLoggerListenerMiddleware(FilterLogger), EnvironmentsController.getAllEnvironments], + controller: EnvironmentsController.getAllEnvironments, children: [ { method: 'post', diff --git a/lib/server/routers/lhcFills.router.js b/lib/server/routers/lhcFills.router.js index 73bc50433e..2b33cedb8c 100644 --- a/lib/server/routers/lhcFills.router.js +++ b/lib/server/routers/lhcFills.router.js @@ -12,15 +12,13 @@ */ const { LhcFillsController } = require('../controllers'); -const FilterLogger = require('../Loggers/FilterLogger'); -const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware'); module.exports = { path: '/lhcFills', children: [ { method: 'get', - controller: [infoLoggerListenerMiddleware(FilterLogger), LhcFillsController.listLhcFills], + controller: LhcFillsController.listLhcFills, }, { method: 'post', diff --git a/lib/server/routers/lhcPeriodsStatistics.router.js b/lib/server/routers/lhcPeriodsStatistics.router.js index feb0f4b058..073288903c 100644 --- a/lib/server/routers/lhcPeriodsStatistics.router.js +++ b/lib/server/routers/lhcPeriodsStatistics.router.js @@ -12,15 +12,13 @@ */ const { LhcPeriodStatisticsController } = require('../controllers/lhcPeriodStatistics.controller.js'); -const FilterLogger = require('../Loggers/FilterLogger.js'); -const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware.js'); exports.lhcPeriodsRouter = { path: '/lhcPeriodsStatistics', children: [ { method: 'get', - controller: [infoLoggerListenerMiddleware(FilterLogger), LhcPeriodStatisticsController.listLhcPeriodStatisticsHandler], + controller: LhcPeriodStatisticsController.listLhcPeriodStatisticsHandler, }, { method: 'get', diff --git a/lib/server/routers/logs.router.js b/lib/server/routers/logs.router.js index 9c115f855e..d4381dc170 100644 --- a/lib/server/routers/logs.router.js +++ b/lib/server/routers/logs.router.js @@ -12,14 +12,12 @@ */ const { LogsController } = require('../controllers'); -const FilterLogger = require('../Loggers/FilterLogger'); const { multerMiddleware: { attachmentMiddleware } } = require('../middleware'); -const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware'); module.exports = { method: 'get', path: '/logs', - controller: [infoLoggerListenerMiddleware(FilterLogger), LogsController.listLogs], + controller: LogsController.listLogs, children: [ { method: 'get', diff --git a/lib/server/routers/qcFlag.router.js b/lib/server/routers/qcFlag.router.js index f97a565c86..569a6802ec 100644 --- a/lib/server/routers/qcFlag.router.js +++ b/lib/server/routers/qcFlag.router.js @@ -13,8 +13,6 @@ const { BkpRoles } = require('../../domain/enums/BkpRoles.js'); const { QcFlagController } = require('../controllers/qcFlag.controller.js'); -const FilterLogger = require('../Loggers/FilterLogger.js'); -const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware.js'); const { rbacMiddleware } = require('../middleware/rbac.middleware.js'); exports.qcFlagsRouter = { @@ -23,7 +21,7 @@ exports.qcFlagsRouter = { { path: 'gaq', method: 'get', - controller: [infoLoggerListenerMiddleware(FilterLogger), QcFlagController.getGaqQcFlagsHandler], + controller: QcFlagController.getGaqQcFlagsHandler, }, { path: 'summary', diff --git a/lib/server/routers/runs.router.js b/lib/server/routers/runs.router.js index 59d453d9bc..cfff057864 100644 --- a/lib/server/routers/runs.router.js +++ b/lib/server/routers/runs.router.js @@ -12,8 +12,6 @@ */ const { RunsController } = require('../controllers'); -const FilterLogger = require('../Loggers/FilterLogger'); -const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware'); module.exports = { children: [ @@ -32,7 +30,7 @@ module.exports = { }, { method: 'get', - controller: [infoLoggerListenerMiddleware(FilterLogger), RunsController.listRuns], + controller: RunsController.listRuns, }, { method: 'get', diff --git a/lib/server/routers/simulationPasses.router.js b/lib/server/routers/simulationPasses.router.js index ccd7ba18c1..057a914fbc 100644 --- a/lib/server/routers/simulationPasses.router.js +++ b/lib/server/routers/simulationPasses.router.js @@ -12,8 +12,6 @@ */ const { SimulationPassesController } = require('../controllers/simulationPasses.controller.js'); -const FilterLogger = require('../Loggers/FilterLogger.js'); -const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware.js'); exports.simulationPassesRouter = { path: '/simulationPasses', @@ -25,7 +23,7 @@ exports.simulationPassesRouter = { }, { method: 'get', - controller: [infoLoggerListenerMiddleware(FilterLogger), SimulationPassesController.listSimulationPassesHandler], + controller: SimulationPassesController.listSimulationPassesHandler, }, ], }; diff --git a/lib/server/services/dataPasses/DataPassService.js b/lib/server/services/dataPasses/DataPassService.js index df29634c9c..617aa9c7e4 100644 --- a/lib/server/services/dataPasses/DataPassService.js +++ b/lib/server/services/dataPasses/DataPassService.js @@ -88,25 +88,13 @@ class DataPassService { * @returns {Promise>} result */ async getAll({ - filter = {}, + filter, limit, offset, sort, } = {}) { const queryBuilder = this.prepareQueryBuilder(); - /** - * @typedef - * @property {object} filter - * @property {number[]} [filter.lhcPeriodIds] lhcPeriod identifier to filter with - * @property {number[]} [filter.simulationPassIds] simulationPass identifier to filter with - * @property {number[]} [filter.ids] data passes identifier to filter with - * @property {string[]} [filter.names] data passes names to filter with - * @property {string[]} [filter.permittedNonPhysicsNames] list of tokens in data passes names which indicate - * a given data pass should not be excluded, possible tokens are 'test', 'debug'. - */ - const { ids, names, permittedNonPhysicsNames = [], lhcPeriodIds, simulationPassIds } = filter; - if (sort) { for (const property in sort) { queryBuilder.orderBy(property, sort[property]); @@ -120,24 +108,37 @@ class DataPassService { queryBuilder.offset(offset); } - if (lhcPeriodIds) { - queryBuilder.where('lhcPeriodId').oneOf(...lhcPeriodIds); - } - if (simulationPassIds) { - queryBuilder.whereAssociation('anchoredSimulationPasses', 'id').oneOf(...simulationPassIds); - } - if (ids) { - queryBuilder.where('id').oneOf(...ids); - } - if (names) { - queryBuilder.where('name').oneOf(...names); + if (filter) { + /** + * @typedef + * @property {object} filter + * @property {number[]} [filter.lhcPeriodIds] lhcPeriod identifier to filter with + * @property {number[]} [filter.simulationPassIds] simulationPass identifier to filter with + * @property {number[]} [filter.ids] data passes identifier to filter with + * @property {string[]} [filter.names] data passes names to filter with + * @property {boolean} [filter.include.byName] list of tokens in data passes names which indicate + * a given data pass should not be excluded, possible tokens are 'test', 'debug'. + */ + const { ids, names, lhcPeriodIds, simulationPassIds } = filter; + if (lhcPeriodIds) { + queryBuilder.where('lhcPeriodId').oneOf(...lhcPeriodIds); + } + if (simulationPassIds) { + queryBuilder.whereAssociation('anchoredSimulationPasses', 'id').oneOf(...simulationPassIds); + } + if (ids) { + queryBuilder.where('id').oneOf(...ids); + } + if (names) { + queryBuilder.where('name').oneOf(...names); + } } - if (!permittedNonPhysicsNames.includes(NonPhysicsProductionsNamesWords.TEST)) { + const byName = filter?.include?.byName ?? []; + if (!byName.includes(NonPhysicsProductionsNamesWords.TEST)) { queryBuilder.where('name').not().substring(`\\_${NonPhysicsProductionsNamesWords.TEST}`); } - - if (!permittedNonPhysicsNames.includes(NonPhysicsProductionsNamesWords.DEBUG)) { + if (!byName.includes(NonPhysicsProductionsNamesWords.DEBUG)) { queryBuilder.where('name').not().substring(`\\_${NonPhysicsProductionsNamesWords.DEBUG}`); } diff --git a/lib/server/services/lhcPeriod/LhcPeriodStatisticsService.js b/lib/server/services/lhcPeriod/LhcPeriodStatisticsService.js index a64c5566ee..4e92ae675e 100644 --- a/lib/server/services/lhcPeriod/LhcPeriodStatisticsService.js +++ b/lib/server/services/lhcPeriod/LhcPeriodStatisticsService.js @@ -21,12 +21,6 @@ const { NotFoundError } = require('../../errors/NotFoundError'); const { RunDefinition } = require('../../../domain/enums/RunDefinition.js'); const { NonPhysicsProductionsNamesWords } = require('../../../domain/enums/NonPhysicsProductionsNamesWords.js'); -const sortExpressionMap = { - name: (sequelize) => sequelize.col('`lhcPeriod`.`name`'), - year: (sequelize) => sequelize.literal('SUBSTRING(lhcPeriod.name, 4, 2)'), - pdpBeamTypes: (sequelize) => sequelize.literal('pdpBeamTypes'), -}; - /** * @typedef LhcPeriodIdentifier object to uniquely identify a lhc period * @property {string} [name] the lhc period name @@ -91,9 +85,22 @@ class LhcPeriodStatisticsService { sort, } = {}) { const queryBuilder = this.prepareQueryBuilder(); + if (sort) { for (const property in sort) { - const expression = sortExpressionMap[property]; + let expression; + switch (property) { + case 'name': + expression = (sequelize) => sequelize.col('`lhcPeriod`.`name`'); + break; + case 'year': + expression = (sequelize) => sequelize.literal('SUBSTRING(lhcPeriod.name, 4, 2)'); + break; + case 'pdpBeamTypes': + expression = (sequelize) => sequelize.literal('pdpBeamTypes'); + break; + } + queryBuilder.orderBy(expression ?? property, sort[property]); } } diff --git a/lib/usecases/environment/GetAllEnvironmentsUseCase.js b/lib/usecases/environment/GetAllEnvironmentsUseCase.js index 14923a63ca..c742c53b62 100644 --- a/lib/usecases/environment/GetAllEnvironmentsUseCase.js +++ b/lib/usecases/environment/GetAllEnvironmentsUseCase.js @@ -23,7 +23,6 @@ const { dataSource } = require('../../database/DataSource.js'); const { statusAcronyms } = require('../../domain/enums/StatusAcronyms.js'); const { unpackNumberRange } = require('../../utilities/rangeUtils.js'); const { splitStringToStringsTrimmed } = require('../../utilities/stringUtils.js'); -const { setTimeRangeQuery } = require('../../utilities/setTimeRangeQuery.js'); /** * Subquery to select the latest history item for each environment. @@ -70,11 +69,18 @@ class GetAllEnvironmentsUseCase { const { filter, page = {} } = query; const { limit = ApiConfig.pagination.limit, offset = 0 } = page; - const queryBuilder = dataSource.createQueryBuilder() + /** + * Prepare a query builder with ordering, limit and offset + * + * @return {QueryBuilder} the created query builder + */ + const prepareQueryBuilder = () => dataSource.createQueryBuilder() .orderBy('updatedAt', 'desc') .limit(limit) .offset(offset); + const fetchQueryBuilder = prepareQueryBuilder(); + if (filter) { const { ids: idsExpression, @@ -84,8 +90,12 @@ class GetAllEnvironmentsUseCase { created, } = filter; + const filterQueryBuilder = prepareQueryBuilder(); + if (created) { - setTimeRangeQuery(created, 'createdAt', queryBuilder); + const from = created.from !== undefined ? created.from : 0; + const to = created.to !== undefined ? created.to : Date.now(); + filterQueryBuilder.where('createdAt').between(from, to); } if (idsExpression) { @@ -93,12 +103,12 @@ class GetAllEnvironmentsUseCase { // Filter should be like with only one filter if (filters.length === 1) { - queryBuilder.where('id').substring(filters[0]); + filterQueryBuilder.where('id').substring(filters[0]); } // Filters should be exact with more than one filter if (filters.length > 1) { - queryBuilder.andWhere({ id: { [Op.in]: filters } }); + filterQueryBuilder.andWhere({ id: { [Op.in]: filters } }); } } @@ -106,12 +116,12 @@ class GetAllEnvironmentsUseCase { const filters = currentStatusExpression.split(',').map((status) => status.trim()); // Filter the environments by current status using the subquery - queryBuilder.literalWhere( + filterQueryBuilder.literalWhere( `${ENVIRONMENT_LATEST_HISTORY_ITEM_SUBQUERY} IN (:filters)`, { filters }, ); - queryBuilder.includeAttribute({ + filterQueryBuilder.includeAttribute({ query: ENVIRONMENT_LATEST_HISTORY_ITEM_SUBQUERY, alias: 'currentStatus', }); @@ -147,7 +157,7 @@ class GetAllEnvironmentsUseCase { * Use OR condition to match subsequences ending with either DESTROYED or DONE * Filter the environments by using LIKE for subsequence matching */ - queryBuilder.literalWhere( + filterQueryBuilder.literalWhere( `(${ENVIRONMENT_STATUS_HISTORY_SUBQUERY} LIKE :statusFiltersWithDestroyed OR ` + `${ENVIRONMENT_STATUS_HISTORY_SUBQUERY} LIKE :statusFiltersWithDone)`, { @@ -156,17 +166,17 @@ class GetAllEnvironmentsUseCase { }, ); - queryBuilder.includeAttribute({ + filterQueryBuilder.includeAttribute({ query: ENVIRONMENT_STATUS_HISTORY_SUBQUERY, alias: 'statusHistory', }); } else { - queryBuilder.literalWhere( + filterQueryBuilder.literalWhere( `${ENVIRONMENT_STATUS_HISTORY_SUBQUERY} LIKE :statusFilters`, { statusFilters: `%${statusFilters.join(',')}%` }, ); - queryBuilder.includeAttribute({ + filterQueryBuilder.includeAttribute({ query: ENVIRONMENT_STATUS_HISTORY_SUBQUERY, alias: 'statusHistory', }); @@ -180,7 +190,7 @@ class GetAllEnvironmentsUseCase { // Check that the final run numbers list contains at least one valid run number if (finalRunNumberList.length > 0) { - queryBuilder.include({ + filterQueryBuilder.include({ association: 'runs', where: { // Filter should be like with only one filter and exact with more than one filter @@ -188,12 +198,22 @@ class GetAllEnvironmentsUseCase { }, }); } + }; + + const filteredEnvironmentsIds = (await EnvironmentRepository.findAll(filterQueryBuilder)).map(({ id }) => id); + // If no environments match the filter, return an empty result + if (filteredEnvironmentsIds.length === 0) { + return { + count: 0, + environments: [], + }; } + fetchQueryBuilder.where('id').oneOf(filteredEnvironmentsIds); } - queryBuilder.include({ association: 'runs' }); - queryBuilder.include({ association: 'historyItems' }); - const { count, rows } = await EnvironmentRepository.findAndCountAll(queryBuilder); + fetchQueryBuilder.include({ association: 'runs' }); + fetchQueryBuilder.include({ association: 'historyItems' }); + const { count, rows } = await EnvironmentRepository.findAndCountAll(fetchQueryBuilder); return { count, environments: rows.map((environment) => environmentAdapter.toEntity(environment)), diff --git a/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js b/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js index f69ed2de34..4315cf9e1a 100644 --- a/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js +++ b/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js @@ -24,7 +24,6 @@ const { ApiConfig } = require('../../config/index.js'); const { RunDefinition } = require('../../domain/enums/RunDefinition.js'); const { unpackNumberRange } = require('../../utilities/rangeUtils.js'); const { splitStringToStringsTrimmed } = require('../../utilities/stringUtils.js'); -const { setTimeRangeQuery } = require('../../utilities/setTimeRangeQuery.js'); /** * GetAllLhcFillsUseCase @@ -55,11 +54,15 @@ class GetAllLhcFillsUseCase { } if (stableBeamsStart) { - setTimeRangeQuery(stableBeamsStart, 'stableBeamsStart', queryBuilder); + const from = stableBeamsStart.from !== undefined ? stableBeamsStart.from : 0; + const to = stableBeamsStart.to !== undefined ? stableBeamsStart.to : new Date().getTime(); + queryBuilder.where('stableBeamsStart').between(from, to); } if (stableBeamsEnd) { - setTimeRangeQuery(stableBeamsEnd, 'stableBeamsEnd', queryBuilder); + const from = stableBeamsEnd.from !== undefined ? stableBeamsEnd.from : 0; + const to = stableBeamsEnd.to !== undefined ? stableBeamsEnd.to : new Date().getTime(); + queryBuilder.where('stableBeamsEnd').between(from, to); } if (fillNumbers) { diff --git a/lib/usecases/log/GetAllLogsUseCase.js b/lib/usecases/log/GetAllLogsUseCase.js index c70fabfb31..b1f7ea72b5 100644 --- a/lib/usecases/log/GetAllLogsUseCase.js +++ b/lib/usecases/log/GetAllLogsUseCase.js @@ -29,7 +29,6 @@ const { ApiConfig } = require('../../config/index.js'); const { Op } = require('sequelize'); const { dataSource } = require('../../database/DataSource.js'); const { checkForFilterExclusion } = require('../common/checkForFilterExclusion.js'); -const { setTimeRangeQuery } = require('../../utilities/setTimeRangeQuery.js'); /** * Apply the given filter on the given query builder @@ -40,19 +39,7 @@ const { setTimeRangeQuery } = require('../../utilities/setTimeRangeQuery.js'); * @return {Promise} resolves once the filter has been applied */ const applyFilter = async (dataSource, queryBuilder, filter) => { - const { - title, - content, - author, - created, - origin, - parentLog, - rootLog, - rootOnly, - runNumbers, - environmentIds, - fillNumbers, - } = filter; + const { title, content, author, created, origin, parentLog, rootLog, rootOnly } = filter; if (title) { queryBuilder.where('title').substring(title); @@ -86,7 +73,9 @@ const applyFilter = async (dataSource, queryBuilder, filter) => { } if (created) { - setTimeRangeQuery(created, 'createdAt', queryBuilder); + const from = created.from !== undefined ? created.from : 0; + const to = created.to !== undefined ? created.to : new Date().getTime(); + queryBuilder.where('createdAt').between(from, to); } if (origin) { @@ -123,47 +112,74 @@ const applyFilter = async (dataSource, queryBuilder, filter) => { queryBuilder.where('id').oneOf(...logIds); } - if (runNumbers) { + if (filter.run?.values?.length > 0) { const runQueryBuilder = dataSource.createQueryBuilder(); runQueryBuilder.include({ association: 'run', - where: { runNumber: { [Op.in]: runNumbers } }, + where: { runNumber: { [Op.in]: filter.run.values } }, }).orderBy('logId', 'asc'); - let logRuns = await LogRunsRepository.findAllAndGroup(runQueryBuilder); - logRuns = logRuns.filter((logRun) => runNumbers.every((runNumber) => logRun.runNumbers.includes(runNumber))); + let logRuns; + switch (filter.run.operation) { + case 'and': + logRuns = await LogRunsRepository + .findAllAndGroup(runQueryBuilder); + logRuns = logRuns + .filter((logRun) => filter.run.values.every((runNumber) => logRun.runNumbers.includes(runNumber))); + break; + case 'or': + logRuns = await LogRunsRepository + .findAll(runQueryBuilder); + break; + } const logIds = logRuns.map((logRun) => logRun.logId); queryBuilder.where('id').oneOf(...logIds); } - if (fillNumbers) { + if (filter.lhcFills?.values?.length > 0) { const logLhcFillQueryBuilder = dataSource.createQueryBuilder(); logLhcFillQueryBuilder.include({ association: 'lhcFill', - where: { fill_number: { [Op.in]: fillNumbers } }, + where: { fill_number: { [Op.in]: filter.lhcFills.values } }, }).orderBy('logId', 'asc'); - let logLhcFills = await LogLhcFillsRepository.findAllAndGroup(logLhcFillQueryBuilder); - logLhcFills = logLhcFills.filter((logLhcFill) => - fillNumbers.every((fillNumber) => logLhcFill.fillNumbers.includes(fillNumber))); + let logLhcFills; + switch (filter.lhcFills.operation) { + case 'and': + logLhcFills = await LogLhcFillsRepository.findAllAndGroup(logLhcFillQueryBuilder); + logLhcFills = logLhcFills + .filter((logLhcFill) => filter.lhcFills.values.every((fillNumber) => logLhcFill.fillNumbers.includes(fillNumber))); + break; + case 'or': + logLhcFills = await LogLhcFillsRepository.findAll(logLhcFillQueryBuilder); + break; + } const logIds = logLhcFills.map((logLhcFill) => logLhcFill.logId); queryBuilder.where('id').oneOf(...logIds); } - if (environmentIds) { - const validEnvironments = await EnvironmentRepository.findAll({ where: { id: { [Op.in]: environmentIds } } }); + if (filter.environments?.values?.length > 0) { + const validEnvironments = await EnvironmentRepository.findAll({ where: { id: { [Op.in]: filter.environments.values } } }); const logEnvironmentQueryBuilder = dataSource.createQueryBuilder() .where('environmentId') .oneOf(...validEnvironments.map(({ id }) => id)) .orderBy('logId', 'asc'); - const logIds = groupByProperty(await LogEnvironmentsRepository.findAll(logEnvironmentQueryBuilder), 'logId') - .filter(({ values }) => validEnvironments.every((env) => values.some((item) => item.environmentId === env.id))) - .map(({ index }) => index); + let logIds; + switch (filter.environments.operation) { + case 'and': + logIds = groupByProperty(await LogEnvironmentsRepository.findAll(logEnvironmentQueryBuilder), 'logId') + .filter(({ values }) => validEnvironments.every((env) => values.some((item) => item.environmentId === env.id))) + .map(({ index }) => index); + break; + case 'or': + logIds = (await LogEnvironmentsRepository.findAll(logEnvironmentQueryBuilder)).map(({ logId }) => logId); + break; + } queryBuilder.where('id').oneOf(...logIds); } diff --git a/lib/usecases/run/GetAllRunsUseCase.js b/lib/usecases/run/GetAllRunsUseCase.js index ae0b14d071..df1b5f7f5b 100644 --- a/lib/usecases/run/GetAllRunsUseCase.js +++ b/lib/usecases/run/GetAllRunsUseCase.js @@ -25,7 +25,6 @@ const { qcFlagSummaryService } = require('../../server/services/qualityControlFl const { DetectorType } = require('../../domain/enums/DetectorTypes.js'); const { unpackNumberRange } = require('../../utilities/rangeUtils.js'); const { splitStringToStringsTrimmed } = require('../../utilities/stringUtils.js'); -const { setTimeRangeQuery } = require('../../utilities/setTimeRangeQuery.js'); /** * GetAllRunsUseCase @@ -82,7 +81,7 @@ class GetAllRunsUseCase { inelasticInteractionRateAtMid, inelasticInteractionRateAtEnd, gaq, - detectorsQcNotBadFraction, + detectorsQc, beamModes, } = filter; @@ -152,15 +151,21 @@ class GetAllRunsUseCase { } if (o2start) { - setTimeRangeQuery(o2start, 'timeO2Start', filteringQueryBuilder); + const from = o2start.from !== undefined ? o2start.from : 0; + const to = o2start.to !== undefined ? o2start.to : new Date().getTime(); + filteringQueryBuilder.where('timeO2Start').between(from, to); } if (o2end) { - setTimeRangeQuery(o2end, 'timeO2End', filteringQueryBuilder); + const from = o2end.from !== undefined ? o2end.from : 0; + const to = o2end.to !== undefined ? o2end.to : new Date().getTime(); + filteringQueryBuilder.where('timeO2End').between(from, to); } if (updatedAt) { - setTimeRangeQuery(updatedAt, 'updatedAt', filteringQueryBuilder); + const from = updatedAt.from ?? 0; + const to = updatedAt.to ?? new Date().getTime(); + filteringQueryBuilder.where('updatedAt').between(from, to); } if (triggerValues) { @@ -340,16 +345,13 @@ class GetAllRunsUseCase { } if (dataPassIds) { - const whereDataPassIds = dataPassIds.length === 1 - ? { id: { [Op.eq]: dataPassIds[0] } } - : { id: { [Op.in]: dataPassIds } }; const runNumbers = (await RunRepository.findAll({ attributes: ['runNumber'], raw: true, include: [ { association: 'dataPass', - where: whereDataPassIds, + where: { id: { [Op.in]: dataPassIds } }, }, ], })).map(({ runNumber }) => runNumber); @@ -389,21 +391,28 @@ class GetAllRunsUseCase { } } - if (detectorsQcNotBadFraction) { + if (detectorsQc) { const [dataPassId] = dataPassIds ?? []; const [simulationPassId] = simulationPassIds ?? []; const [lhcPeriodId] = lhcPeriodIds ?? []; - const { mcReproducibleAsNotBad } = detectorsQcNotBadFraction; - delete detectorsQcNotBadFraction.mcReproducibleAsNotBad; + const { mcReproducibleAsNotBad } = detectorsQc; + delete detectorsQc.mcReproducibleAsNotBad; - const dplDetectorIds = Object.keys(detectorsQcNotBadFraction).map((id) => parseInt(id.slice(1), 10)); + const dplDetectorIds = Object.keys(detectorsQc).map((id) => parseInt(id.slice(1), 10)); if (dplDetectorIds.length > 0) { - const scope = { dataPassId, simulationPassId, lhcPeriodId, dplDetectorIds }; - const qcSummary = await qcFlagSummaryService.getSummary(scope, { mcReproducibleAsNotBad }); + const qcSummary = await qcFlagSummaryService.getSummary( + { + dataPassId, + simulationPassId, + lhcPeriodId, + dplDetectorIds, + }, + { mcReproducibleAsNotBad }, + ); const runNumbers = Object.entries(qcSummary) .filter(([_, runSummary]) => { - const mask = Object.entries(detectorsQcNotBadFraction).map(([prefixedDetectorId, { operator, limit }]) => { + const mask = Object.entries(detectorsQc).map(([prefixedDetectorId, { notBadFraction: { operator, limit } }]) => { const dplDetectorId = parseInt(prefixedDetectorId.slice(1), 10); if (!(dplDetectorId in runSummary)) { return false; @@ -525,17 +534,15 @@ class GetAllRunsUseCase { const qcFlagsAssociationDef = { association: 'qcFlags', required: false, - separate: true, - order: [['from', 'ASC']], where: { [Op.and]: [ { deleted: false }, sequelize.literal(`( - \`detector\`.\`type\` not in (${detectorTypesOfNoneExportableAnonymousFlagsEscaped}) - OR \`createdBy\`.\`name\` != 'Anonymous' + \`qcFlags->detector\`.\`type\` not in (${detectorTypesOfNoneExportableAnonymousFlagsEscaped}) + OR \`qcFlags->createdBy\`.\`name\` != 'Anonymous' )`), ] }, include: [ - { association: 'effectivePeriods', required: true, separate: true }, + { association: 'effectivePeriods', required: true }, { association: 'flagType' }, { association: 'detector', required: true }, { association: 'createdBy' }, @@ -551,7 +558,13 @@ class GetAllRunsUseCase { } else { qcFlagsAssociationDef.include.push({ association: 'dataPasses', required: false }); qcFlagsAssociationDef.include.push({ association: 'simulationPasses', required: false }); - qcFlagsAssociationDef.where[Op.and].push(sequelize.literal('(`dataPasses`.`id` IS NULL AND `simulationPasses`.`id` IS NULL)')); + qcFlagsAssociationDef.where[Op.or] = [ + { '$qcFlags.id$': null }, + { + '$qcFlags.dataPasses.id$': null, + '$qcFlags.simulationPasses.id$': null, + }, + ]; fetchQueryBuilder.include(qcFlagsAssociationDef); } diff --git a/lib/utilities/setTimeRangeQuery.js b/lib/utilities/setTimeRangeQuery.js deleted file mode 100644 index ced721ce0f..0000000000 --- a/lib/utilities/setTimeRangeQuery.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @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. - */ - -/** - * Function that sets a time range in a QueryBuilder. - * - * @param {object} timerange an object that defines a time range to add to the query - * @param {number} timerange.from the lower bound of the time range - * @param {number} timerange.to the upper bound of the time range - * @param {string} attribute the model attribute for which the range will be set - * @param {QueryBuilder} queryBuilder queryBuider instance in which the time range will be set. - * @returns {void} - */ -exports.setTimeRangeQuery = ({ from = 0, to = Date.now() }, attribute, queryBuilder) => - queryBuilder.where(attribute).between(from, to); diff --git a/package-lock.json b/package-lock.json index ba5c5640fb..14d73124ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aliceo2/bookkeeping", - "version": "1.18.1", + "version": "1.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aliceo2/bookkeeping", - "version": "1.18.1", + "version": "1.17.1", "bundleDependencies": [ "@aliceo2/web-ui", "@grpc/grpc-js", @@ -19,43 +19,45 @@ "mariadb", "multer", "node-fetch", + "protobufjs", "sequelize", "umzug" ], "dependencies": { - "@aliceo2/web-ui": "2.11.0", - "@grpc/grpc-js": "1.14.4", + "@aliceo2/web-ui": "2.9.0", + "@grpc/grpc-js": "1.14.0", "@grpc/proto-loader": "0.8.0", "cls-hooked": "4.2.2", - "d3": "7.9.0", + "d3": "7.8.5", "deepmerge": "4.3.0", - "dotenv": "17.4.2", - "joi": "18.2.1", + "dotenv": "17.2.0", + "joi": "18.0.0", "kafkajs": "2.2.0", "mariadb": "3.0.0", "mkdirp": "3.0.1", - "multer": "2.2.0", + "multer": "2.0.2", "node-fetch": "3.3.1", - "sequelize": "6.37.8", + "protobufjs": "8.0.0", + "sequelize": "6.37.0", "umzug": "3.8.2" }, "devDependencies": { "@eslint/js": "^9.39.1", "@stylistic/eslint-plugin-js": "^4.4.1", - "@types/d3": "7.4.3", + "@types/d3": "7.4.0", "chai": "4.5.0", "date-and-time": "3.6.0", "eslint": "^9.37.0", - "eslint-plugin-jsdoc": "^62.9.0", - "globals": "^17.6.0", - "js-yaml": "4.2.0", + "eslint-plugin-jsdoc": "^62.5.0", + "globals": "^17.3.0", + "js-yaml": "4.1.1", "mocha": "11.7.0", "nodemon": "3.1.3", - "nyc": "18.0.0", - "puppeteer": "25.1.0", + "nyc": "17.1.0", + "puppeteer": "24.37.2", "puppeteer-to-istanbul": "1.4.0", "sequelize-cli": "6.6.0", - "sinon": "22.0.0", + "sinon": "21.0.0", "supertest": "7.2.2" }, "engines": { @@ -72,45 +74,47 @@ } }, "node_modules/@aliceo2/web-ui": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@aliceo2/web-ui/-/web-ui-2.11.0.tgz", - "integrity": "sha512-ISVPe8BqekVsNlIJFTsqrw1c2nhSxDDUVgvOsio2ZKsRRBlV5ndZkQFbAXSjpMabswKi+BCE466TG0Oagc3fuQ==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@aliceo2/web-ui/-/web-ui-2.9.0.tgz", + "integrity": "sha512-bPSpI/xXUPNShKF2muu5IIKgwZSfS37toneKBCCentw1seeTEsZBIo3kavdrb3v5SXOPkGeZiS6n7ixrkRKBEw==", "inBundle": true, "license": "GPL-3.0", "dependencies": { - "express": "4.22.2", - "helmet": "8.1.0", - "jsonwebtoken": "9.0.3", - "kafkajs": "2.2.4", + "express": "^4.22.1", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.0", + "kafkajs": "^2.2.0", "mithril": "1.1.7", - "openid-client": "5.6.5", - "protobufjs": "8.4.2", + "mysql": "^2.18.1", + "openid-client": "^5.6.0", + "protobufjs": "^7.5.0", "winston": "3.19.0", - "ws": "8.21.0" + "ws": "^8.19.0" }, "engines": { "node": ">= 22.x" } }, - "node_modules/@aliceo2/web-ui/node_modules/kafkajs": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-2.2.4.tgz", - "integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aliceo2/web-ui/node_modules/protobufjs": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.2.tgz", - "integrity": "sha512-64rfNzkWOZAIazXzpBFPWq6F9up6gMvTzjE2oWIzApx2N/dqVUEE7+bCn2+40780dFVtKOUab8QfxJ6KJDWbqA==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", "hasInstallScript": true, "inBundle": true, "license": "BSD-3-Clause", "dependencies": { - "long": "^5.3.2" + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" }, "engines": { "node": ">=12.0.0" @@ -546,17 +550,17 @@ } }, "node_modules/@es-joy/jsdoccomment": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.86.0.tgz", - "integrity": "sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==", + "version": "0.83.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.83.0.tgz", + "integrity": "sha512-e1MHSEPJ4m35zkBvNT6kcdeH1SvMaJDsPC3Xhfseg3hvF50FUE3f46Yn36jgbrPYYXezlWUQnevv23c+lx2MCA==", "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.8", - "@typescript-eslint/types": "^8.58.0", - "comment-parser": "1.4.6", + "@typescript-eslint/types": "^8.53.1", + "comment-parser": "1.4.5", "esquery": "^1.7.0", - "jsdoc-type-pratt-parser": "~7.2.0" + "jsdoc-type-pratt-parser": "~7.1.0" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -762,9 +766,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.0.tgz", + "integrity": "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==", "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -1383,28 +1387,25 @@ "inBundle": true }, "node_modules/@puppeteer/browsers": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.4.tgz", - "integrity": "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.12.0.tgz", + "integrity": "sha512-Xuq42yxcQJ54ti8ZHNzF5snFvtpgXzNToJ1bXUGQRaiO8t+B6UM8sTUJfvV+AJnqtkJU/7hdy6nbKyA12aHtRw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "modern-tar": "^0.7.6", + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { - "browsers": "lib/main-cli.js" + "browsers": "lib/cjs/main-cli.js" }, "engines": { - "node": ">=22.12.0" - }, - "peerDependencies": { - "proxy-agent": ">=8.0.1" - }, - "peerDependenciesMeta": { - "proxy-agent": { - "optional": true - } + "node": ">=18" } }, "node_modules/@puppeteer/browsers/node_modules/cliui": { @@ -1422,6 +1423,44 @@ "node": ">=12" } }, + "node_modules/@puppeteer/browsers/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@puppeteer/browsers/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@puppeteer/browsers/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@puppeteer/browsers/node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -1507,9 +1546,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "15.4.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", - "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1517,13 +1556,14 @@ } }, "node_modules/@sinonjs/samsam": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz", - "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", "type-detect": "^4.1.0" } }, @@ -1539,9 +1579,9 @@ } }, "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", "inBundle": true, "license": "MIT" }, @@ -1573,6 +1613,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/argparse": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", @@ -1586,11 +1633,10 @@ "dev": true }, "node_modules/@types/d3": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", - "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.0.tgz", + "integrity": "sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA==", "dev": true, - "license": "MIT", "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", @@ -1892,10 +1938,21 @@ "integrity": "sha512-aqayTNmeWrZcvnG2MG9eGYI6b7S5fl+yKgPs6bAjOTwPS316R5SxBGKvtSExfyoJU7pIeHJfsHI0Ji41RVMkvQ==", "inBundle": true }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/types": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", - "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", "dev": true, "license": "MIT", "engines": { @@ -1927,11 +1984,10 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -1948,12 +2004,21 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/aggregate-error": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz", + "integrity": "sha512-quoaXsZ9/BLNae5yiNoUz+Nhkwz83GhWwtYFglcjEQB2NDHCIpApbqXxIFnm4Pq/Nvhrsq5sYJFyohrrxnTGAA==", + "dev": true, "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" @@ -1963,11 +2028,10 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2022,9 +2086,8 @@ "node_modules/archy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", - "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", - "dev": true, - "license": "MIT" + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", + "dev": true }, "node_modules/are-docs-informative": { "version": "0.0.2", @@ -2067,6 +2130,19 @@ "node": "*" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2102,12 +2178,143 @@ "node": ">= 4.0.0" } }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.3.tgz", + "integrity": "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", + "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz", + "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==", + "inBundle": true, + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -2124,9 +2331,9 @@ "dev": true }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "inBundle": true, "license": "MIT", "dependencies": { @@ -2138,7 +2345,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.15.1", + "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -2209,12 +2416,21 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "inBundle": true, - "license": "BSD-3-Clause" + "inBundle": true }, "node_modules/buffer-from": { "version": "1.1.2", @@ -2459,18 +2675,15 @@ } }, "node_modules/chromium-bidi": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz", - "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-13.1.1.tgz", + "integrity": "sha512-zB9MpoPd7VJwjowQqiW3FKOvQwffFMjQ8Iejp5ZW+sJaKLRhZX1sTxzl3Zt22TDB4zP0OOqs8lRoY7eAW5geyQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, - "engines": { - "node": ">=20.19.0 <22.0.0 || >=22.12.0" - }, "peerDependencies": { "devtools-protocol": "*" } @@ -2480,7 +2693,6 @@ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -2614,9 +2826,9 @@ "dev": true }, "node_modules/comment-parser": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", - "integrity": "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz", + "integrity": "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==", "dev": true, "license": "MIT", "engines": { @@ -2760,6 +2972,38 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "inBundle": true + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2786,11 +3030,10 @@ } }, "node_modules/d3": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", - "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.8.5.tgz", + "integrity": "sha512-JgoahDG51ncUfJu6wX/1vWQEqOflgXyl4MaHqlcSruTez7yhaRKR9i8VjjcQGeS2en/jnFivXuaIMnseMMt0XA==", "inBundle": true, - "license": "ISC", "dependencies": { "d3-array": "3", "d3-axis": "3", @@ -3282,6 +3525,21 @@ "node": ">=8" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delaunator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.0.tgz", @@ -3332,9 +3590,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1624250", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz", - "integrity": "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==", + "version": "0.0.1566079", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", + "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", "dev": true, "license": "BSD-3-Clause" }, @@ -3360,9 +3618,9 @@ } }, "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.0.tgz", + "integrity": "sha512-Q4sgBT60gzd0BB0lSyYD3xM4YxrXA9y4uBDof1JNYGzOXrQdQ6yX+7XIAqoFOGQFOTK1D3Hts5OllpxMDZFONQ==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -3404,7 +3662,6 @@ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", "inBundle": true, - "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" } @@ -3497,6 +3754,40 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-ex/node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3618,6 +3909,28 @@ "inBundle": true, "license": "MIT" }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "9.39.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", @@ -3679,24 +3992,24 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "62.9.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.9.0.tgz", - "integrity": "sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==", + "version": "62.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.5.0.tgz", + "integrity": "sha512-D+1haMVDzW/ZMoPwOnsbXCK07rJtsq98Z1v+ApvDKxSzYTTcPgmFc/nyUDCGmxm2cP7g7hszyjYHO7Zodl/43w==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@es-joy/jsdoccomment": "~0.86.0", + "@es-joy/jsdoccomment": "~0.83.0", "@es-joy/resolve.exports": "1.2.0", "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.6", + "comment-parser": "1.4.5", "debug": "^4.4.3", "escape-string-regexp": "^4.0.0", - "espree": "^11.2.0", + "espree": "^11.1.0", "esquery": "^1.7.0", "html-entities": "^2.6.0", "object-deep-merge": "^2.0.0", "parse-imports-exports": "^0.2.4", - "semver": "^7.7.4", + "semver": "^7.7.3", "spdx-expression-parse": "^4.0.0", "to-valid-identifier": "^1.0.0" }, @@ -3704,7 +4017,7 @@ "node": "^20.19.0 || ^22.13.0 || >=24" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, "node_modules/eslint-plugin-jsdoc/node_modules/debug": { @@ -3738,9 +4051,9 @@ } }, "node_modules/eslint-plugin-jsdoc/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", + "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3751,15 +4064,15 @@ } }, "node_modules/eslint-plugin-jsdoc/node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.0.tgz", + "integrity": "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.16.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -3776,9 +4089,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-jsdoc/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -4016,16 +4329,26 @@ "es5-ext": "~0.10.14" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "inBundle": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.5", + "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -4044,7 +4367,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.15.1", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -4099,12 +4422,65 @@ "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==", "dev": true }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/extract-zip/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -4149,6 +4525,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -4278,11 +4664,10 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true }, "node_modules/fn.name": { "version": "1.1.0", @@ -4296,7 +4681,6 @@ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", "dev": true, - "license": "ISC", "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^3.0.2" @@ -4306,17 +4690,17 @@ } }, "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" + "hasown": "^2.0.2", + "mime-types": "^2.1.12" }, "engines": { "node": ">= 6" @@ -4512,106 +4896,108 @@ "node": ">= 0.4" } }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" + "pump": "^3.0.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "inBundle": true, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/get-uri/node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", "dev": true, "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": ">= 14" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "node_modules/get-uri/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" + "ms": "^2.1.3" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/glob/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "node_modules/get-uri/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } + "license": "MIT" }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "node_modules/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": "*" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "inBundle": true, "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "is-glob": "^4.0.1" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 6" } }, "node_modules/globals": { - "version": "17.6.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", - "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", + "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", "dev": true, "license": "MIT", "engines": { @@ -4704,9 +5090,9 @@ } }, "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "inBundle": true, "license": "MIT", "dependencies": { @@ -4779,6 +5165,84 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -4837,7 +5301,6 @@ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -4882,6 +5345,16 @@ "node": ">=12" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5002,11 +5475,16 @@ "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "inBundle": true + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5063,21 +5541,21 @@ } }, "node_modules/istanbul-lib-processinfo": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-3.0.0.tgz", - "integrity": "sha512-P7nLXRRlo7Sqinty6lNa7+4o9jBUYGpqtejqCOZKfgXlRoxY/QArflcB86YO500Ahj4pDJEG34JjMRbQgePLnQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz", + "integrity": "sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==", "dev": true, - "license": "ISC", "dependencies": { "archy": "^1.0.0", - "cross-spawn": "^7.0.3", - "istanbul-lib-coverage": "^3.2.0", + "cross-spawn": "^7.0.0", + "istanbul-lib-coverage": "^3.0.0-alpha.1", + "make-dir": "^3.0.0", "p-map": "^3.0.0", - "rimraf": "^6.1.3", - "uuid": "^8.3.2" + "rimraf": "^3.0.0", + "uuid": "^3.3.3" }, "engines": { - "node": "20 || >=22" + "node": ">=8" } }, "node_modules/istanbul-lib-report": { @@ -5184,9 +5662,9 @@ } }, "node_modules/joi": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", - "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.0.tgz", + "integrity": "sha512-fpbpXN/TD04Xz1/cCXzUR3ghDkhyiHjbzTILx3wNyKXIzQJ55uYAkUGWwhX72uHge/6MdFA/kp1ZUh35DlYmaA==", "inBundle": true, "license": "BSD-3-Clause", "dependencies": { @@ -5196,18 +5674,17 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.1.0" + "@standard-schema/spec": "^1.0.0" }, "engines": { "node": ">= 20" } }, "node_modules/jose": { - "version": "4.15.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", - "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "version": "4.15.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", + "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", "inBundle": true, - "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } @@ -5262,11 +5739,10 @@ } }, "node_modules/js-beautify/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -5297,20 +5773,10 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5326,9 +5792,9 @@ "dev": true }, "node_modules/jsdoc-type-pratt-parser": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.2.0.tgz", - "integrity": "sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.1.0.tgz", + "integrity": "sha512-SX7q7XyCwzM/MEDCYz0l8GgGbJAACGFII9+WfNYr5SLEKukHWRy2Jk3iWRe7P+lpYJNs7oQ+OSei4JtKGUjd7A==", "dev": true, "license": "MIT", "engines": { @@ -5353,6 +5819,12 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5390,22 +5862,15 @@ } }, "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", + "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", "inBundle": true, - "license": "MIT", "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", + "jws": "^3.2.2", + "lodash": "^4.17.21", "ms": "^2.1.1", - "semver": "^7.5.4" + "semver": "^7.3.8" }, "engines": { "node": ">=12", @@ -5416,15 +5881,16 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "inBundle": true, - "license": "MIT" + "inBundle": true }, "node_modules/jsonwebtoken/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "inBundle": true, - "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, "bin": { "semver": "bin/semver.js" }, @@ -5433,25 +5899,23 @@ } }, "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", "inBundle": true, - "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "^1.0.1", + "buffer-equal-constant-time": "1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", "inBundle": true, - "license": "MIT", "dependencies": { - "jwa": "^2.0.1", + "jwa": "^1.4.1", "safe-buffer": "^5.0.1" } }, @@ -5494,18 +5958,11 @@ "node": ">= 0.8.0" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true }, "node_modules/locate-path": { "version": "6.0.0", @@ -5540,46 +5997,12 @@ "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", "dev": true }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "inBundle": true, - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "inBundle": true, - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "inBundle": true, - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "inBundle": true, - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "inBundle": true, - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "inBundle": true, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -5588,13 +6011,6 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "inBundle": true, - "license": "MIT" - }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -5838,11 +6254,10 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5850,12 +6265,22 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "inBundle": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" } @@ -6052,13 +6477,13 @@ } }, "node_modules/mocha/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^2.0.1" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -6154,16 +6579,6 @@ "node": ">=12" } }, - "node_modules/modern-tar": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", - "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", @@ -6193,23 +6608,50 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", - "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", "inBundle": true, "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", - "type-is": "^1.6.18" + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" }, "engines": { "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mysql": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.18.1.tgz", + "integrity": "sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig==", + "inBundle": true, + "dependencies": { + "bignumber.js": "9.0.0", + "readable-stream": "2.3.7", + "safe-buffer": "5.1.2", + "sqlstring": "2.3.1" + }, + "engines": { + "node": ">= 0.6" } }, "node_modules/natural-compare": { @@ -6228,6 +6670,16 @@ "node": ">= 0.6" } }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/next-tick": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", @@ -6380,9 +6832,9 @@ } }, "node_modules/nyc": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/nyc/-/nyc-18.0.0.tgz", - "integrity": "sha512-G5UyHinFkB1BxqGTrmZdB6uIYH0+v7ZnVssuflUDi+J+RhKWyAhRT1RCehBSI6jLFLuUUgFDyLt49mUtdO1XeQ==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-17.1.0.tgz", + "integrity": "sha512-U42vQ4czpKa0QdI1hu950XuNhYqgoM+ZF1HT+VuUHL9hPfDPVvNQyltmMqdE9bUHMVa+8yNbc3QKTj8zQhlVxQ==", "dev": true, "license": "ISC", "dependencies": { @@ -6395,11 +6847,11 @@ "find-up": "^4.1.0", "foreground-child": "^3.3.0", "get-package-type": "^0.1.0", - "glob": "^13.0.6", + "glob": "^7.1.6", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-hook": "^3.0.0", "istanbul-lib-instrument": "^6.0.2", - "istanbul-lib-processinfo": "^3.0.0", + "istanbul-lib-processinfo": "^2.0.2", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^4.0.0", "istanbul-reports": "^3.0.2", @@ -6408,17 +6860,17 @@ "p-map": "^3.0.0", "process-on-spawn": "^1.0.0", "resolve-from": "^5.0.0", - "rimraf": "^6.1.3", + "rimraf": "^3.0.0", "signal-exit": "^3.0.2", - "spawn-wrap": "^3.0.0", - "test-exclude": "^8.0.0", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", "yargs": "^15.0.2" }, "bin": { "nyc": "bin/nyc.js" }, "engines": { - "node": "20 || >=22" + "node": ">=18" } }, "node_modules/nyc/node_modules/ansi-styles": { @@ -6606,6 +7058,15 @@ "node": ">=6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "inBundle": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-deep-merge": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.0.tgz", @@ -6618,7 +7079,6 @@ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", "inBundle": true, - "license": "MIT", "engines": { "node": ">= 6" } @@ -6637,11 +7097,10 @@ } }, "node_modules/oidc-token-hash": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", - "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", "inBundle": true, - "license": "MIT", "engines": { "node": "^10.13.0 || >=12.0.0" } @@ -6679,13 +7138,12 @@ } }, "node_modules/openid-client": { - "version": "5.6.5", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.5.tgz", - "integrity": "sha512-5P4qO9nGJzB5PI0LFlhj4Dzg3m4odt0qsJTfyEtZyOlkgpILwEioOhVVJOrS1iVH494S4Ee5OCjjg6Bf5WOj3w==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz", + "integrity": "sha512-PtrWsY+dXg6y8mtMPyL/namZSYVz8pjXz3yJiBNZsEdCnu9miHLB4ELVC85WvneMKo2Rg62Ay7NkuCpM0bgiLQ==", "inBundle": true, - "license": "MIT", "dependencies": { - "jose": "^4.15.5", + "jose": "^4.15.1", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" @@ -6746,7 +7204,6 @@ "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", "dev": true, - "license": "MIT", "dependencies": { "aggregate-error": "^3.0.0" }, @@ -6763,6 +7220,65 @@ "node": ">=6" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/pac-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-hash": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", @@ -6806,6 +7322,24 @@ "parse-statements": "1.0.11" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse-statements": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", @@ -6831,6 +7365,15 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -6871,9 +7414,9 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "inBundle": true, "license": "MIT" }, @@ -6886,6 +7429,13 @@ "node": "*" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/pg-connection-string": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", @@ -7002,6 +7552,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "inBundle": true + }, "node_modules/process-on-spawn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", @@ -7014,12 +7570,47 @@ "node": ">=8" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "dev": true }, + "node_modules/protobufjs": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.0.tgz", + "integrity": "sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==", + "hasInstallScript": true, + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7034,6 +7625,68 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -7046,6 +7699,17 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7056,45 +7720,71 @@ } }, "node_modules/puppeteer": { - "version": "25.1.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.1.0.tgz", - "integrity": "sha512-7L6/0JM7XStK99lIL4xQySyNEXNfII6pk0BxkI5kKBTOhR7AsoQiv067YTsE/rIXxQiq9ajlO4WcqBjS/FWK1A==", + "version": "24.37.2", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.37.2.tgz", + "integrity": "sha512-FV1W/919ve0y0oiS/3Rp5XY4MUNUokpZOH/5M4MMDfrrvh6T9VbdKvAHrAFHBuCxvluDxhjra20W7Iz6HJUcIQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "3.0.4", - "chromium-bidi": "16.0.1", - "devtools-protocol": "0.0.1624250", - "lilconfig": "^3.1.3", - "puppeteer-core": "25.1.0", - "typed-query-selector": "^2.12.2" + "@puppeteer/browsers": "2.12.0", + "chromium-bidi": "13.1.1", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1566079", + "puppeteer-core": "24.37.2", + "typed-query-selector": "^2.12.0" }, "bin": { - "puppeteer": "lib/puppeteer/node/cli.js" + "puppeteer": "lib/cjs/puppeteer/node/cli.js" }, "engines": { - "node": ">=22.12.0" + "node": ">=18" } }, "node_modules/puppeteer-core": { - "version": "25.1.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.1.0.tgz", - "integrity": "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ==", + "version": "24.37.2", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.2.tgz", + "integrity": "sha512-nN8qwE3TGF2vA/+xemPxbesntTuqD9vCGOiZL2uh8HES3pPzLX20MyQjB42dH2rhQ3W3TljZ4ZaKZ0yX/abQuw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "3.0.4", - "chromium-bidi": "16.0.1", - "devtools-protocol": "0.0.1624250", - "typed-query-selector": "^2.12.2", - "webdriver-bidi-protocol": "0.4.2", - "ws": "^8.21.0" + "@puppeteer/browsers": "2.12.0", + "chromium-bidi": "13.1.1", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1566079", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.4.0", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" }, "engines": { - "node": ">=22.12.0" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, + "node_modules/puppeteer-core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/puppeteer-to-istanbul": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/puppeteer-to-istanbul/-/puppeteer-to-istanbul-1.4.0.tgz", @@ -7265,9 +7955,9 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "inBundle": true, "license": "BSD-3-Clause", "dependencies": { @@ -7335,6 +8025,21 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "inBundle": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -7430,20 +8135,15 @@ } }, "node_modules/rimraf": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", - "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^13.0.3", - "package-json-from-dist": "^1.0.1" + "glob": "^7.1.3" }, "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" + "rimraf": "bin.js" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7554,9 +8254,9 @@ "license": "MIT" }, "node_modules/sequelize": { - "version": "6.37.8", - "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz", - "integrity": "sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==", + "version": "6.37.0", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.0.tgz", + "integrity": "sha512-MS6j6aXqWzB3fe9FhmfpQMgVC16bBdYroJCqIqR0l9M2ko8pZdKoi/0PiNWgMyFQDXUHxXyAOG3K07CbnOhteQ==", "funding": [ { "type": "opencollective", @@ -7564,7 +8264,6 @@ } ], "inBundle": true, - "license": "MIT", "dependencies": { "@types/debug": "^4.1.8", "@types/validator": "^13.7.17", @@ -7697,6 +8396,15 @@ "node": ">=10" } }, + "node_modules/sequelize/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "inBundle": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -7878,32 +8586,110 @@ } }, "node_modules/sinon": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.0.0.tgz", - "integrity": "sha512-sq/6DpdXOrLyfbKlXLg/Usc7xu8YXPeLkOFZRvA3bNUSA2lhbrZ06yuXbH1fkzBPCbz9O10+7hznzUsjaYNm0Q==", + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.0.tgz", + "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^15.4.0", - "@sinonjs/samsam": "^10.0.2", - "diff": "^9.0.0" + "@sinonjs/fake-timers": "^13.0.5", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "supports-color": "^7.2.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/sinon" } }, - "node_modules/sinon/node_modules/diff": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", - "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "license": "BSD-3-Clause", "engines": { - "node": ">=0.3.1" + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7914,17 +8700,15 @@ } }, "node_modules/spawn-wrap": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-3.0.0.tgz", - "integrity": "sha512-z+s5vv4KzFPJVddGab0xX2n7kQPGMdNUX5l9T8EJqsXdKTWpcxmAqWHpsgHEXoC1taGBCc7b79bi62M5kdbrxQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { - "cross-spawn": "^7.0.6", "foreground-child": "^2.0.0", "is-windows": "^1.0.2", "make-dir": "^3.0.0", - "rimraf": "^6.1.3", + "rimraf": "^3.0.0", "signal-exit": "^3.0.2", "which": "^2.0.1" }, @@ -7960,6 +8744,15 @@ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "inBundle": true }, + "node_modules/sqlstring": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz", + "integrity": "sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A=", + "inBundle": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stack-chain": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", @@ -7995,6 +8788,18 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -8198,58 +9003,55 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/test-exclude": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", - "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^13.0.6", - "minimatch": "^10.2.2" + "pump": "^3.0.0", + "tar-stream": "^3.1.5" }, - "engines": { - "node": "20 || >=22" + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" } }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", "dev": true, "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" } }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", "dev": true, - "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=8" } }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "b4a": "^1.6.4" } }, "node_modules/text-hex": { @@ -8336,6 +9138,13 @@ "node": ">= 14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/type": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", @@ -8387,9 +9196,9 @@ } }, "node_modules/typed-query-selector": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", - "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", "dev": true, "license": "MIT" }, @@ -8519,13 +9328,13 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "inBundle": true, - "license": "MIT", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, "bin": { - "uuid": "dist/bin/uuid" + "uuid": "bin/uuid" } }, "node_modules/v8-to-istanbul": { @@ -8566,9 +9375,9 @@ } }, "node_modules/webdriver-bidi-protocol": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", - "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.0.tgz", + "integrity": "sha512-U9VIlNRrq94d1xxR9JrCEAx5Gv/2W7ERSv8oWRoNe/QYbfccS0V3h/H6qeNeCRJxXGMhhnkqvwNrvPAYeuP9VA==", "dev": true, "license": "Apache-2.0" }, @@ -8801,9 +9610,9 @@ } }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "inBundle": true, "license": "MIT", "engines": { @@ -8822,6 +9631,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "inBundle": true, + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz", @@ -8909,6 +9727,17 @@ "node": ">=10" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 87e13806bf..1020f607da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aliceo2/bookkeeping", - "version": "1.18.1", + "version": "1.17.1", "author": "ALICEO2", "repository": { "type": "git", @@ -26,20 +26,21 @@ "node": ">= 22.x" }, "dependencies": { - "@aliceo2/web-ui": "2.11.0", - "@grpc/grpc-js": "1.14.4", + "@aliceo2/web-ui": "2.9.0", + "@grpc/grpc-js": "1.14.0", "@grpc/proto-loader": "0.8.0", "cls-hooked": "4.2.2", - "d3": "7.9.0", + "d3": "7.8.5", "deepmerge": "4.3.0", - "dotenv": "17.4.2", - "joi": "18.2.1", + "dotenv": "17.2.0", + "joi": "18.0.0", "kafkajs": "2.2.0", "mariadb": "3.0.0", "mkdirp": "3.0.1", - "multer": "2.2.0", + "multer": "2.0.2", "node-fetch": "3.3.1", - "sequelize": "6.37.8", + "protobufjs": "8.0.0", + "sequelize": "6.37.0", "umzug": "3.8.2" }, "files": [ @@ -50,20 +51,20 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@stylistic/eslint-plugin-js": "^4.4.1", - "@types/d3": "7.4.3", + "@types/d3": "7.4.0", "chai": "4.5.0", "date-and-time": "3.6.0", "eslint": "^9.37.0", - "eslint-plugin-jsdoc": "^62.9.0", - "globals": "^17.6.0", - "js-yaml": "4.2.0", + "eslint-plugin-jsdoc": "^62.5.0", + "globals": "^17.3.0", + "js-yaml": "4.1.1", "mocha": "11.7.0", "nodemon": "3.1.3", - "nyc": "18.0.0", - "puppeteer": "25.1.0", + "nyc": "17.1.0", + "puppeteer": "24.37.2", "puppeteer-to-istanbul": "1.4.0", "sequelize-cli": "6.6.0", - "sinon": "22.0.0", + "sinon": "21.0.0", "supertest": "7.2.2" }, "bundleDependencies": [ @@ -78,6 +79,7 @@ "mariadb", "multer", "node-fetch", + "protobufjs", "sequelize", "umzug" ] diff --git a/test/api/dataPasses.test.js b/test/api/dataPasses.test.js index a092118267..3ce5947435 100644 --- a/test/api/dataPasses.test.js +++ b/test/api/dataPasses.test.js @@ -296,13 +296,13 @@ module.exports = () => { }); }); it('should successfully include TEST productions', async () => { - const response = await request(server).get('/api/dataPasses?filter[lhcPeriodIds][]=2&filter[permittedNonPhysicsNames]=test'); + const response = await request(server).get('/api/dataPasses?filter[lhcPeriodIds][]=2&filter[include][byName]=test'); expect(response.status).to.be.equal(200); const { data } = await response.body; expect(data.map(({ name }) => name)).to.have.all.members(['LHC22b_apass1', 'LHC22b_skimming','LHC22b_apass2_skimmed', 'LHC22b_test']); }); it('should successfully include DEBUG productions', async () => { - const response = await request(server).get('/api/dataPasses?filter[lhcPeriodIds][]=2&filter[permittedNonPhysicsNames]=debug'); + const response = await request(server).get('/api/dataPasses?filter[lhcPeriodIds][]=2&filter[include][byName]=debug'); expect(response.status).to.be.equal(200); const { data } = await response.body; expect(data.map(({ name }) => name)).to.have.all.members(['LHC22b_apass1', 'LHC22b_skimming','LHC22b_apass2_skimmed', 'LHC22b_debug']); diff --git a/test/api/logs.test.js b/test/api/logs.test.js index 9d2f774ad3..ada81f070e 100644 --- a/test/api/logs.test.js +++ b/test/api/logs.test.js @@ -233,7 +233,7 @@ module.exports = () => { }); it('should successfully filter by run number', async () => { - const response = await request(server).get('/api/logs?filter[runNumbers]=1,2'); + const response = await request(server).get('/api/logs?filter[run][values]=1,2&filter[run][operation]=and'); expect(response.status).to.equal(200); expect(response.body.data).to.be.an('array'); @@ -244,30 +244,6 @@ module.exports = () => { } }); - it('should successfully filter by lhcFillNumber', async () => { - const response = await request(server).get('/api/logs?filter[fillNumbers]=1,4,6'); - expect(response.status).to.equal(200); - - expect(response.body.data).to.be.an('array'); - expect(response.body.data).to.lengthOf(1); - for (const { lhcFills } of response.body.data) { - const fillNumbers = lhcFills.map(({ fillNumber }) => fillNumber); - expect([1, 4, 6].every((fillNumber) => fillNumbers.includes(fillNumber))).to.be.true; - } - }); - - it('should successfully filter by EnvironmentIds', async () => { - const response = await request(server).get('/api/logs?filter[environmentIds]=Dxi029djX,eZF99lH6'); - expect(response.status).to.equal(200); - - expect(response.body.data).to.be.an('array'); - expect(response.body.data).to.lengthOf(1); - for (const { environments } of response.body.data) { - const environmentIds = environments.map(({ id }) => id); - expect(["Dxi029djX", "eZF99lH6"].every((environmentId) => environmentIds.includes(environmentId))).to.be.true; - } - }); - it('should successfully filter by content', async () => { const response = await request(server).get('/api/logs?filter[content]=particle'); expect(response.status).to.equal(200); @@ -280,30 +256,6 @@ module.exports = () => { } }); - it('should successfully filter by rootOnly', async () => { - const unfilteredResponse = await request(server).get('/api/logs'); - expect(unfilteredResponse.status).to.equal(200); - - // When a log has no rootLogId the logs adapter will set the row itself as the root log - let hasChildLogs = unfilteredResponse.body.data.some(({ rootLogId, id }) => rootLogId !== id); - expect(hasChildLogs).to.be.true; - - const filteredResponse = await request(server).get('/api/logs?filter[rootOnly]=true'); - expect(filteredResponse.status).to.equal(200); - - hasChildLogs = filteredResponse.body.data.every(({ rootLogId, id }) => rootLogId !== id); - expect(hasChildLogs).to.be.false; - }) - - it('should successfully ignore rootOnly filters if rootLog is provided', async () => { - const response = await request(server).get('/api/logs?filter[rootOnly]=true&filter[rootLog]=1'); - - expect(response.status).to.equal(200); - - expect(response.body.data).to.lengthOf(3); - expect(response.body.data.every(({ rootLogId, id }) => rootLogId !== id)).to.be.true; - }) - it('should return 400 if the author filter is left empty', (done) => { request(server) .get('/api/logs?filter[author]= ') @@ -654,105 +606,6 @@ module.exports = () => { expect(response.body.meta.page.totalCount).to.equal(totalNumber); }); - it('should support sorting, runs DESC', (done) => { - request(server) - .get('/api/logs?sort[runs]=desc') - .expect(200) - .end((err, res) => { - if (err) { - done(err); - return; - } - - const { data } = res.body; - const logsWithRuns = data.filter(({ runs }) => runs.length > 0); - - for (let i = 0; i < logsWithRuns.length - 1; i++) { - const currentId = logsWithRuns[i].runs[0].id; - const nextId = logsWithRuns[i + 1].runs[0].id; - - expect(currentId).to.be.at.least(nextId); - } - - - done(); - }); - }); - - it('should support sorting, runs ASC', (done) => { - request(server) - .get('/api/logs?sort[runs]=asc') - .expect(200) - .end((err, res) => { - if (err) { - done(err); - return; - } - - const { data } = res.body; - const logsWithRuns = data.filter(({ runs }) => runs.length > 0); - for (let i = 0; i < logsWithRuns.length - 1; i++) { - - const currentId = logsWithRuns[i].runs[0].id; - const nextId = logsWithRuns[i + 1].runs[0].id; - - expect(currentId).to.be.at.most(nextId); - } - - - - done(); - }); - }); - - it('should support sorting, environments DESC', (done) => { - request(server) - .get('/api/logs?sort[environments]=desc') - .expect(200) - .end((err, res) => { - if (err) { - done(err); - return; - } - - const { data } = res.body; - const logsWithEnvs = data.filter(({ environments }) => environments.length > 0); - - for (let i = 0; i < logsWithEnvs.length - 1; i++) { - const currentId = logsWithEnvs[i].environments[0].id; - const nextId = logsWithEnvs[i + 1].environments[0].id; - - expect(currentId >= nextId).to.be.true; - } - - done(); - }); - }); - - it('should support sorting, environments ASC', (done) => { - request(server) - .get('/api/logs?sort[environments]=asc') - .expect(200) - .end((err, res) => { - if (err) { - done(err); - return; - } - - const { data } = res.body; - const logsWithEnvs = data.filter(({ environments }) => environments.length > 0); - - for (let i = 0; i < logsWithEnvs.length - 1; i++) { - const currentId = logsWithEnvs[i].environments[0].id; - const nextId = logsWithEnvs[i + 1].environments[0].id; - - expect(currentId <= nextId).to.be.true; - } - - done(); - }); - }); - it('should support sorting, id DESC', (done) => { request(server) .get('/api/logs?sort[id]=desc') diff --git a/test/api/qcFlags.test.js b/test/api/qcFlags.test.js index 24e843d98e..092df4d883 100644 --- a/test/api/qcFlags.test.js +++ b/test/api/qcFlags.test.js @@ -743,8 +743,8 @@ module.exports = () => { const response = await request(server).get(`/api/qcFlags/synchronous?runNumber=${runNumber}&detectorId=${detectorId}`); expect(response.status).to.be.equal(200); const { data: flags, meta } = response.body; - expect(meta).to.be.eql({ page: { totalCount: 3, pageCount: 1 } }); - expect(flags.map(({ id }) => id)).to.have.all.ordered.members([103, 101, 100]); + expect(meta).to.be.eql({ page: { totalCount: 2, pageCount: 1 } }); + expect(flags.map(({ id }) => id)).to.have.all.ordered.members([101, 100]); }); it('should successfully fetch synchronous flags with pagination', async () => { @@ -752,11 +752,11 @@ module.exports = () => { const detectorId = 7; { const response = await request(server) - .get(`/api/qcFlags/synchronous?runNumber=${runNumber}&detectorId=${detectorId}&page[limit]=1&page[offset]=2`); + .get(`/api/qcFlags/synchronous?runNumber=${runNumber}&detectorId=${detectorId}&page[limit]=1&page[offset]=1`); expect(response.status).to.be.equal(200); const { data: flags, meta } = response.body; - expect(meta).to.be.eql({ page: { totalCount: 3, pageCount: 3 } }); + expect(meta).to.be.eql({ page: { totalCount: 2, pageCount: 2 } }); expect(flags).to.be.lengthOf(1); const [flag] = flags; expect(flag.id).to.be.equal(100); @@ -770,7 +770,7 @@ module.exports = () => { { const response = await request(server) .get(`/api/qcFlags/synchronous?runNumber=${runNumber}&detectorId=${detectorId}&filter[createdBy][names]=Jan%20Jansen&filter[createdBy][operator]=or`); - expect(response.body.data).to.be.lengthOf(3); + expect(response.body.data).to.be.lengthOf(2); } { diff --git a/test/api/runs.test.js b/test/api/runs.test.js index e3272b6cef..083771bf02 100644 --- a/test/api/runs.test.js +++ b/test/api/runs.test.js @@ -311,24 +311,6 @@ module.exports = () => { expect(data.map(({ runNumber }) => runNumber)).to.have.all.members([1, 2, 55, 49, 54, 56, 105]); }); - it('should return 400 if GAQ notBadFraction is used with multiple dataPassIds', (done) => { - const url = '/api/runs?filter[dataPassIds][]=2&filter[dataPassIds][]=3&filter[gaq][notBadFraction][operator]==&filter[gaq][notBadFraction][limit]=0.5'; - request(server) - .get(url) - .expect(400) - .end((err, res) => { - if (err) { - done(err); - return; - } - - const { errors } = res.body; - expect(errors[0].detail).to.equal('Filtering by GAQ is enabled only when filtering with one dataPassId'); - - done(); - }); - }); - it('should successfully filter on simulation pass id', async () => { const response = await request(server).get('/api/runs?filter[simulationPassIds][]=1'); expect(response.status).to.equal(200); @@ -472,11 +454,11 @@ module.exports = () => { } }); - it('should successfully filter by detectorsQcNotBadFraction', async () => { + it('should successfully filter by detectors notBadFraction', async () => { const dataPassId = 1; { const response = await request(server).get(`/api/runs?filter[dataPassIds][]=${dataPassId}` - + '&filter[detectorsQcNotBadFraction][_1][operator]=>&filter[detectorsQcNotBadFraction][_1][limit]=0.7'); + + '&filter[detectorsQc][_1][notBadFraction][operator]=>&filter[detectorsQc][_1][notBadFraction][limit]=0.7'); expect(response.status).to.equal(200); const { data: runs } = response.body; @@ -486,7 +468,7 @@ module.exports = () => { } { const response = await request(server).get(`/api/runs?filter[dataPassIds][]=${dataPassId}` - + '&filter[detectorsQcNotBadFraction][_1][operator]=<&filter[detectorsQcNotBadFraction][_1][limit]=0.9&filter[detectorsQcNotBadFraction][mcReproducibleAsNotBad]=true'); + + '&filter[detectorsQc][_1][notBadFraction][operator]=<&filter[detectorsQc][_1][notBadFraction][limit]=0.9&filter[detectorsQc][mcReproducibleAsNotBad]=true'); expect(response.status).to.equal(200); const { data: runs } = response.body; @@ -496,8 +478,8 @@ module.exports = () => { } { const response = await request(server).get(`/api/runs?filter[dataPassIds][]=${dataPassId}` - + '&filter[detectorsQcNotBadFraction][_1][operator]=<&filter[detectorsQcNotBadFraction][_1][limit]=0.7' - + '&filter[detectorsQcNotBadFraction][_16][operator]=>&filter[detectorsQcNotBadFraction][_16][limit]=0.9' + + '&filter[detectorsQc][_1][notBadFraction][operator]=<&filter[detectorsQc][_1][notBadFraction][limit]=0.7' + + '&filter[detectorsQc][_16][notBadFraction][operator]=>&filter[detectorsQc][_16][notBadFraction][limit]=0.9' ); expect(response.status).to.equal(200); diff --git a/test/lib/server/services/qualityControlFlag/QcFlagService.test.js b/test/lib/server/services/qualityControlFlag/QcFlagService.test.js index 3aa4300ab4..f4c533444f 100644 --- a/test/lib/server/services/qualityControlFlag/QcFlagService.test.js +++ b/test/lib/server/services/qualityControlFlag/QcFlagService.test.js @@ -142,15 +142,15 @@ module.exports = () => { const detectorId = 7; { const { rows: flags, count } = await qcFlagService.getAllSynchronousPerRunAndDetector({ runNumber, detectorId }); - expect(count).to.be.equal(3); - expect(flags.map(({ id }) => id)).to.have.all.ordered.members([103, 101, 100]); + expect(count).to.be.equal(2); + expect(flags.map(({ id }) => id)).to.have.all.ordered.members([101, 100]); } { const { rows: flags, count } = await qcFlagService.getAllSynchronousPerRunAndDetector( { runNumber, detectorId }, - { limit: 1, offset: 2 }, + { limit: 1, offset: 1 }, ); - expect(count).to.be.equal(3); + expect(count).to.be.equal(2); expect(flags).to.be.lengthOf(1); const [flag] = flags; expect(flag.id).to.be.equal(100); @@ -2124,10 +2124,10 @@ module.exports = () => { }); }); - it('should successfully filter sync flags by created by name', async () => { + it('should successfult fiter sync flags by created by name', async () => { { const { rows } = await qcFlagService.getAllSynchronousPerRunAndDetector({ runNumber: 56, detectorId: 7 }, {}, { createdBy: { names: ['Jan Jansen'], operator: 'or' }}); - expect(rows).to.be.lengthOf(3); + expect(rows).to.be.lengthOf(2); } { @@ -2136,7 +2136,7 @@ module.exports = () => { } }); - it('should successfully filter data pass flags by created by name', async () => { + it('should successfult fiter data pass flags by created by name', async () => { { const { rows } = await qcFlagService.getAllPerDataPassAndRunAndDetector({ dataPassId: 1, runNumber: 107, detectorId: 1 }, {}, { createdBy: { names: ['John Doe'], operator: 'or' }}); expect(rows).to.be.lengthOf(2); @@ -2148,7 +2148,7 @@ module.exports = () => { } }); - it('should successfully filter simulation pass flags by created by name', async () => { + it('should successfult fiter simulation pass flags by created by name', async () => { { const { rows } = await qcFlagService.getAllPerSimulationPassAndRunAndDetector({ simulationPassId: 1, runNumber: 106, detectorId: 1 }, {}, { createdBy: { names: ['Jan Jansen'], operator: 'or' }}); expect(rows).to.be.lengthOf(2); diff --git a/test/lib/usecases/environment/GetAllEnvironmentsUseCase.test.js b/test/lib/usecases/environment/GetAllEnvironmentsUseCase.test.js index 5f1e816571..96b4ee1c11 100644 --- a/test/lib/usecases/environment/GetAllEnvironmentsUseCase.test.js +++ b/test/lib/usecases/environment/GetAllEnvironmentsUseCase.test.js @@ -225,40 +225,4 @@ module.exports = () => { expect(environments).to.be.an('array'); expect(environments.length).to.be.equal(0); // Environments from seeders }); - - it('should return correct total count and all filtered results across pages', async () => { - const totalMatchingFilter = 6; // 'RUNNING, ERROR' matches 6 environments at this point - const limit = 2; - - // First page - getAllEnvsDto.query = { page: { limit, offset: 0 }, filter: { currentStatus: 'RUNNING, ERROR' } }; - const page1 = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); - - expect(page1.count).to.be.equal(totalMatchingFilter); - expect(page1.environments).to.be.an('array'); - expect(page1.environments.length).to.be.equal(limit); - - // Second page - getAllEnvsDto.query = { page: { limit, offset: 2 }, filter: { currentStatus: 'RUNNING, ERROR' } }; - const page2 = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); - - expect(page2.count).to.be.equal(totalMatchingFilter); - expect(page2.environments).to.be.an('array'); - expect(page2.environments.length).to.be.equal(limit); - - // Third page - getAllEnvsDto.query = { page: { limit, offset: 4 }, filter: { currentStatus: 'RUNNING, ERROR' } }; - const page3 = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); - - expect(page3.count).to.be.equal(totalMatchingFilter); - expect(page3.environments).to.be.an('array'); - expect(page3.environments.length).to.be.equal(limit); - - // Collect all environment IDs and verify no duplicates and all present - const allIds = [page1, page2, page3].flatMap(({ environments })=> environments.map(({ id }) => id)); - - expect(allIds.length).to.be.equal(totalMatchingFilter); - expect(new Set(allIds).size).to.be.equal(totalMatchingFilter); - expect(allIds).to.have.members(['SomeId', 'newId', 'CmCvjNbg', 'EIDO13i3D', '8E4aZTjY', 'Dxi029djX']); - }); }; diff --git a/test/lib/usecases/log/GetAllLogsUseCase.test.js b/test/lib/usecases/log/GetAllLogsUseCase.test.js index d4475d2d60..61a402cdb8 100644 --- a/test/lib/usecases/log/GetAllLogsUseCase.test.js +++ b/test/lib/usecases/log/GetAllLogsUseCase.test.js @@ -73,7 +73,7 @@ module.exports = () => { it('should successfully filter on run numbers', async () => { const runNumbers = [1, 2]; - getAllLogsDto.query = { filter: { runNumbers } }; + getAllLogsDto.query = { filter: { run: { operation: 'and', values: runNumbers } } }; { const { logs: filteredResult } = await new GetAllLogsUseCase().execute(getAllLogsDto); @@ -83,6 +83,17 @@ module.exports = () => { expect(runNumbers.every((runNumber) => relatedRunNumbers.includes(runNumber))).to.be.true; } } + + getAllLogsDto.query = { filter: { run: { operation: 'or', values: runNumbers } } }; + + { + const { logs: filteredResult } = await new GetAllLogsUseCase().execute(getAllLogsDto); + expect(filteredResult).to.lengthOf(6); + for (const log of filteredResult) { + const relatedRunNumbers = log.runs.map(({ runNumber }) => runNumber); + expect(runNumbers.some((runNumber) => relatedRunNumbers.includes(runNumber))).to.be.true; + } + } }); it('should successfully filter on log content', async () => { @@ -106,9 +117,9 @@ module.exports = () => { }); it('should successfully filter on lhc fills', async () => { - const fillNumbers = [1, 6]; + const lhcFills = [1, 6]; - getAllLogsDto.query = { filter: { fillNumbers } }; + getAllLogsDto.query = { filter: { lhcFills: { operation: 'and', values: lhcFills } } }; { const { logs: filteredResult } = await new GetAllLogsUseCase().execute(getAllLogsDto); expect(filteredResult).to.have.lengthOf(1); @@ -117,24 +128,47 @@ module.exports = () => { // For each returned log, check at least one of the associated fill numbers was in the filter query expect(fillNumbersPerLog.every((logFillNumbers) => - logFillNumbers.includes(fillNumbers[0]) && logFillNumbers.includes(fillNumbers[1]))).to.be.true; + logFillNumbers.includes(lhcFills[0]) && logFillNumbers.includes(lhcFills[1]))).to.be.true; + } + + getAllLogsDto.query = { filter: { lhcFills: { operation: 'or', values: lhcFills } } }; + { + const { logs: filteredResult } = await new GetAllLogsUseCase().execute(getAllLogsDto); + expect(filteredResult).to.have.lengthOf(3); + + const fillNumbersPerLog = filteredResult.map(({ lhcFills }) => lhcFills.map(({ fillNumber }) => fillNumber)); + + // For each returned log, check at least one of the associated fill numbers was in the filter query + expect(fillNumbersPerLog.every((logFillNumbers) => + logFillNumbers.includes(lhcFills[0]) || logFillNumbers.includes(lhcFills[1]))).to.be.true; } }); it ('should successfully filter on log environment', async () => { - const environmentIds = ['8E4aZTjY', 'eZF99lH6']; - getAllLogsDto.query = { filter: { environmentIds } }; + const environments = ['8E4aZTjY', 'eZF99lH6']; + getAllLogsDto.query = { filter: { environments: { operation: 'and', values: environments } } }; { const { logs: filteredResult } = await new GetAllLogsUseCase().execute(getAllLogsDto); expect(filteredResult).to.lengthOf(2); for (const log of filteredResult) { - const relatedenvironmentIds = log.environments.map(({ id }) => id); - expect(environmentIds.every((env) => relatedenvironmentIds.includes(env))).to.be.true; + const relatedEnvironments = log.environments.map(({ id }) => id); + expect(environments.every((env) => relatedEnvironments.includes(env))).to.be.true; + } + } + + getAllLogsDto.query = { filter: { environments: { operation: 'or', values: environments } } }; + + { + const { logs: filteredResult } = await new GetAllLogsUseCase().execute(getAllLogsDto); + expect(filteredResult).to.lengthOf(5); + for (const log of filteredResult) { + const relatedEnvironments = log.environments.map(({ id }) => id); + expect(environments.some((env) => relatedEnvironments.includes(env))).to.be.true; } } - getAllLogsDto.query = { filter: { environmentIds: ['non-existent-environment'] } }; + getAllLogsDto.query = { filter: { environments: { operation: 'and', values: ['non-existent-environment'] } } }; { const { logs: filteredResult } = await new GetAllLogsUseCase().execute(getAllLogsDto); diff --git a/test/lib/usecases/run/GetAllRunsUseCase.test.js b/test/lib/usecases/run/GetAllRunsUseCase.test.js index febeae02aa..5b080d056c 100644 --- a/test/lib/usecases/run/GetAllRunsUseCase.test.js +++ b/test/lib/usecases/run/GetAllRunsUseCase.test.js @@ -831,7 +831,7 @@ module.exports = () => { query: { filter: { dataPassIds, - detectorsQcNotBadFraction: { '_1': { operator: '<', limit: 0.7 } }, + detectorsQc: { '_1': { notBadFraction: { operator: '<', limit: 0.7 } } }, }, }, }); @@ -843,7 +843,7 @@ module.exports = () => { query: { filter: { dataPassIds, - detectorsQcNotBadFraction: { '_1': { operator: '<', limit: 0.8 } }, + detectorsQc: { '_1': { notBadFraction: { operator: '<', limit: 0.8 } } }, }, }, }); @@ -855,7 +855,7 @@ module.exports = () => { query: { filter: { dataPassIds, - detectorsQcNotBadFraction: { '_1': { operator: '<', limit: 0.9 }, mcReproducibleAsNotBad: true }, + detectorsQc: { '_1': { notBadFraction: { operator: '<', limit: 0.9 } }, mcReproducibleAsNotBad: true }, }, }, }); @@ -867,10 +867,9 @@ module.exports = () => { query: { filter: { dataPassIds, - detectorsQcNotBadFraction: - { - '_2': { operator: '>', limit: 0.8 }, - '_1': { operator: '<', limit: 0.8 }, + detectorsQc: { + '_2': { notBadFraction: { operator: '>', limit: 0.8 } }, + '_1': { notBadFraction: {operator: '<', limit: 0.8 } }, }, }, }, diff --git a/test/public/Filters/FilteringModel.test.js b/test/public/Filters/FilteringModel.test.js deleted file mode 100644 index a00ff7996b..0000000000 --- a/test/public/Filters/FilteringModel.test.js +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @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. - */ - -const { - defaultBefore, - defaultAfter, - goToPage, - fillInput, - pressElement, - waitForTableTotalRowsCountToEqual, - getPopoverSelector, - getPeriodInputsSelectors, -} = require('../defaults.js'); - -module.exports = () => { - let page; - let browser; - - before(async () => { - [page, browser] = await defaultBefore(); - }); - - // Not all filters for the pages will be checked, as many of them are identical between pages. - // Environments is not checked at all because it has no filter implementations not allready covered by other pages - const runSelectionFiltersChecks = { - 'tags': [{ count: 1, selector: '#tag-dropdown-option-FOOD' }, { count: 0, selector: '#tag-dropdown-option-CTP' }, { count: 1, selector: '#tag-filter-combination-operator-radio-button-or' }], - 'beam mode': [{ count: 1, selector: '#beam-mode-dropdown-option-NO\\ BEAM' }, { count: 2, selector: '#beam-mode-dropdown-option-UNSTABLE\\ BEAMS' }], - 'definitions': [{ count: 1, selector: '#run-definition-checkbox-TECHNICAL' }, { count: 3, selector: '#run-definition-checkbox-SYNTHETIC' }], - 'quality': [{ count: 1, selector: '#checkboxes-checkbox-none' }, { count: 3, selector: '#checkboxes-checkbox-bad' }], - 'detectors': [{ count: 3, selector: '#detector-filter-dropdown-option-ACO' }, { count: 0, selector: '#detector-filter-dropdown-option-FDD' }, { count: 3, selector: '#detector-filter-combination-operator-radio-button-or' }], - 'runTypes': [{ count: 4, selector: '#run-types-dropdown-option-14' }, { count: 5, selector: '#run-types-dropdown-option-2' }], - 'ddFLP': [{ count: 101, selector: '#ddFlpFilterRadioON' }, { count: 8, selector: '#ddFlpFilterRadioOFF' }], - 'magnets': [{ count: 1, selector: '#l3-dipole-current-dropdown-option-20003kA\\/0kA' }, { count: 3, selector: '#l3-dipole-current-dropdown-option-30003kA\\/0kA' }], - }; - - const logSelectionFiltersChecks = { - 'tags': [{ count: 1, selector: '#tag-dropdown-option-DPG' }, { count: 0, selector: '#tag-dropdown-option-FOOD' }, { count: 3, selector: '#tag-filter-combination-operator-radio-button-or' } ], - }; - - const lhcFillsSelectionFiltersChecks = { - 'hasStableBeams': [{ count: 6, selector: '.switch > input' }], - 'beamTypes': [{ count: 1, selector: '#beam-types-checkbox-p-p' }, { count: 2, selector: '#beam-types-checkbox-p-Pb' }] - }; - - const checkSelectionFilters = async (selectionFilterObject, baseRowCount) => { - for (const [_key, checks] of Object.entries(selectionFilterObject)) { - await waitForTableTotalRowsCountToEqual(page, baseRowCount); - - for (const { count, selector } of checks) { - await pressElement(page, selector, true); - await waitForTableTotalRowsCountToEqual(page, count); - } - - for (const { count } of checks.reverse()) { - await waitForTableTotalRowsCountToEqual(page, count); - await page.goBack(); - } - - await waitForTableTotalRowsCountToEqual(page, baseRowCount); - } - }; - - it('should undo filters if the user presses go-back on the runs page', async () => { - await goToPage(page, 'run-overview'); - const baseRowCount = 109; - const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); - - const { fromDateSelector, fromTimeSelector } = getPeriodInputsSelectors(startPopoverSelector); - - await checkSelectionFilters(runSelectionFiltersChecks, baseRowCount); - - // Run duration - await page.select('#duration-operator', '>'); - await fillInput(page, '#duration-operand', 500, ['change']); - await waitForTableTotalRowsCountToEqual(page, 8); - await page.select('#duration-operator', '='); - await waitForTableTotalRowsCountToEqual(page, 0); - await page.goBack(); - await waitForTableTotalRowsCountToEqual(page, 8); - await page.goBack(); - await waitForTableTotalRowsCountToEqual(page, baseRowCount); - - // EorReason filter - await page.select('#eorCategories', 'DETECTORS'); - await waitForTableTotalRowsCountToEqual(page, 3); - await page.select('#eorTitles', 'CPV'); - await waitForTableTotalRowsCountToEqual(page, 2); - await fillInput(page, '#eorDescription', 'some', ['change']); - await waitForTableTotalRowsCountToEqual(page, 1); - await page.goBack(); - await waitForTableTotalRowsCountToEqual(page, 2); - await page.goBack(); - await waitForTableTotalRowsCountToEqual(page, 3); - await page.goBack(); - await waitForTableTotalRowsCountToEqual(page, baseRowCount); - - // O2 Start Filter: - await fillInput(page, fromTimeSelector, '11:11', ['change']); - await fillInput(page, fromDateSelector, '2021-02-03', ['change']); - await waitForTableTotalRowsCountToEqual(page, 1); - await fillInput(page, fromDateSelector, '2020-02-03', ['change']); - await waitForTableTotalRowsCountToEqual(page, 2); - await page.goBack(); - await waitForTableTotalRowsCountToEqual(page, 1); - await page.goBack(); - await waitForTableTotalRowsCountToEqual(page, baseRowCount); - }); - - it('should undo filters if the user presses go-back on the LHC fills page', async () => { - await goToPage(page, 'lhc-fill-overview'); - await checkSelectionFilters(lhcFillsSelectionFiltersChecks, 5) - }); - - it('should undo filters if the user presses go-back on the logs page', async () => { - await goToPage(page, 'log-overview'); - await waitForTableTotalRowsCountToEqual(page, 119); - - // AuthorFilter - await pressElement(page, '.author-filter .switch input', true); - await waitForTableTotalRowsCountToEqual(page, 117); - await fillInput(page, '#authorFilterText', '!Anonymous,John', ['change']); - await waitForTableTotalRowsCountToEqual(page, 5); - await page.goBack(); - await waitForTableTotalRowsCountToEqual(page, 117); - await page.goBack(); - await waitForTableTotalRowsCountToEqual(page, 119); - - await checkSelectionFilters(logSelectionFiltersChecks, 119); - }); - - it('should undo filters if the user presses go-back on the lhc periods page', async () => { - await goToPage(page, 'lhc-period-overview'); - await waitForTableTotalRowsCountToEqual(page, 3); - - // Name - await fillInput(page, '.name-filter input', 'LHC23f'); - await waitForTableTotalRowsCountToEqual(page, 1); - await fillInput(page, '.name-filter input', 'bogus'); - await waitForTableTotalRowsCountToEqual(page, 0); - await page.goBack(); - await waitForTableTotalRowsCountToEqual(page, 1); - await page.goBack(); - await waitForTableTotalRowsCountToEqual(page, 3); - }); - - after(async () => await defaultAfter(page, browser)); -} diff --git a/test/public/Filters/filtersToUrl.test.js b/test/public/Filters/filtersToUrl.test.js deleted file mode 100644 index c89547e244..0000000000 --- a/test/public/Filters/filtersToUrl.test.js +++ /dev/null @@ -1,529 +0,0 @@ -/** - * @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. - */ - -const { expect } = require('chai'); -const { - defaultBefore, - defaultAfter, - goToPage, - fillInput, - getPopoverSelector, - getPeriodInputsSelectors, - pressElement, - openFilteringPanel, - waitForTableLength, -} = require('../defaults.js'); - -module.exports = () => { - let page; - let browser; - - before(async () => { - [page, browser] = await defaultBefore(); - }); - - const getQueryParameters = (page) => Object.fromEntries(new URL(page.url()).searchParams.entries()); - - it('should set filters from LogsOverview to the URL', async () => { - await goToPage(page, 'log-overview'); - const firstCheckboxId = 'tag-dropdown-option-DPG'; - const popoverTrigger = '.createdAt-filter .popover-trigger'; - - await page.waitForSelector(popoverTrigger); - await openFilteringPanel(page); - - const popOverSelector = await getPopoverSelector(await page.$(popoverTrigger)); - const { fromDateSelector, toDateSelector, fromTimeSelector, toTimeSelector } = getPeriodInputsSelectors(popOverSelector); - - await fillInput(page, '.title-textFilter', 'bogusbogusbogus', ['change']); - await fillInput(page, '#authorFilterText', 'Jane', ['change']); - await fillInput(page, '.content-textFilter', 'particle', ['change']); - await pressElement(page, '.tags-filter .dropdown-trigger'); - await pressElement(page, `#${firstCheckboxId}`, true); - await fillInput(page, '.environments-filter input', '8E4aZTjY', ['change']); - await fillInput(page, '.runNumbers-textFilter', '1,2', ['change']); - await fillInput(page, '.fillNumbers-textFilter', '1, 6', ['change']); - await fillInput(page, fromDateSelector, '2020-02-02', ['change']); - await fillInput(page, toDateSelector, '2020-02-02', ['change']); - await fillInput(page, fromTimeSelector, '11:00', ['change']); - await fillInput(page, toTimeSelector, '12:00', ['change']); - - const queryParameters = getQueryParameters(page); - expect(queryParameters).to.deep.equal({ - "page": "log-overview", - "filter[author]": "Jane", - "filter[title]": "bogusbogusbogus", - "filter[content]": "particle", - "filter[tags][values]": "DPG", - "filter[tags][operation]": "and", - "filter[runNumbers]": "1,2", - "filter[environmentIds]": "8E4aZTjY", - "filter[fillNumbers]": "1, 6", - "filter[created][from]": "1580641200000", - "filter[created][to]": "1580644800000" - }); - }); - - it('should set filters from EnvironmentsOverview to the URL', async () => { - await goToPage(page, 'env-overview'); - const popoverTrigger = '.createdAt-filter .popover-trigger'; - - await page.waitForSelector(popoverTrigger); - await openFilteringPanel(page); - - const createdAtPopoverSelector = await getPopoverSelector(await page.$(popoverTrigger)); - const periodInputsSelectors = getPeriodInputsSelectors(createdAtPopoverSelector); - - await fillInput(page, '.runs-filter input', '10', ['change']); - await fillInput(page, '.id-filter input', 'Dxi029djX, TDI59So3d', ['change']); - await pressElement(page, '#checkboxes-checkbox-DESTROYED'); - await fillInput(page, '.historyItems-filter input', 'C-R-D-X', ['change']); - await fillInput(page, periodInputsSelectors.fromDateSelector, '2019-08-09', ['change']); - await fillInput(page, periodInputsSelectors.toDateSelector, '2019-08-10', ['change']); - await fillInput(page, periodInputsSelectors.fromTimeSelector, '00:00', ['change']); - await fillInput(page, periodInputsSelectors.toTimeSelector, '23:59', ['change']); - - const queryParameters = getQueryParameters(page); - expect(queryParameters).to.deep.equal({ - "page": "env-overview", - "filter[created][from]": "1565308800000", - "filter[created][to]": "1565481540000", - "filter[runNumbers]": "10", - "filter[statusHistory]": "C-R-D-X", - "filter[currentStatus]": "DESTROYED", - "filter[ids]": "Dxi029djX, TDI59So3d" - }); - }); - - it('should set filters from LhcFillsOverview to the URL', async () => { - await goToPage(page, 'lhc-fill-overview'); - await waitForTableLength(page, 5); - const sbEndPopoverTrigger = '.stableBeamsEnd-filter .popover-trigger'; - const sbStartPopoverTrigger = '.stableBeamsStart-filter .popover-trigger'; - const sbStartPopOverSelector = await getPopoverSelector(await page.$(sbStartPopoverTrigger)); - const sbEndPopOverSelector = await getPopoverSelector(await page.$(sbEndPopoverTrigger)); - const filterSchemeNameInputField= '.fillingSchemeName-filter input'; - const { - fromDateSelector: sbStartFromDateSelector, - toDateSelector: sbStartToDateSelector, - fromTimeSelector: sbStartFromTimeSelector, - toTimeSelector: sbStartToTimeSelector - } = getPeriodInputsSelectors(sbStartPopOverSelector); - - const { - fromDateSelector: sbEndFromDateSelector, - toDateSelector: sbEndToDateSelector, - fromTimeSelector: sbEndFromTimeSelector, - toTimeSelector: sbEndToTimeSelector - } = getPeriodInputsSelectors(sbEndPopOverSelector); - - await openFilteringPanel(page); - await fillInput(page, '#beam-duration-filter-operand', '00:01:40', ['change']); - await fillInput(page, '#run-duration-filter-operand', '00:00:00', ['change']); - await pressElement(page, '#beam-types-checkbox-p-Pb'); - await fillInput(page, sbStartFromDateSelector, '2019-08-08', ['change']); - await fillInput(page, sbStartToDateSelector, '2019-08-08', ['change']); - await fillInput(page, sbStartFromTimeSelector, '10:00', ['change']); - await fillInput(page, sbStartToTimeSelector, '12:00', ['change']); - await fillInput(page, sbEndFromDateSelector, '2022-03-22', ['change']); - await fillInput(page, sbEndToDateSelector, '2022-03-22', ['change']); - await fillInput(page, sbEndFromTimeSelector, '01:00', ['change']); - await fillInput(page, sbEndToTimeSelector, '23:59', ['change']); - await fillInput(page, filterSchemeNameInputField, 'Single_12b_8_1024_8_2018', ['change']); - - const queryParameters = getQueryParameters(page); - expect(queryParameters).to.deep.equal({ - "page": "lhc-fill-overview", - "filter[beamDuration][operator]": "=", - "filter[beamDuration][limit]": "00:01:40", - "filter[runDuration][operator]": "=", - "filter[runDuration][limit]": "00:00:00", - "filter[hasStableBeams]": "true", - "filter[stableBeamsEnd][from]": "1647910800000", - "filter[stableBeamsEnd][to]": "1647993540000", - "filter[stableBeamsStart][from]": "1565258400000", - "filter[stableBeamsStart][to]": "1565265600000", - "filter[beamTypes]": "p-Pb", - "filter[schemeName]": "Single_12b_8_1024_8_2018" - }); - }); - - it('should set filters from runsOverview to the URL', async () => { - await goToPage(page, 'run-overview'); - const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); - const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); - const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); - - const { - fromDateSelector: startFromDateSelector, - toDateSelector: startToDateSelector, - fromTimeSelector: startFromTimeSelector, - toTimeSelector: startToTimeSelector - } = getPeriodInputsSelectors(startPopoverSelector); - - const { - fromDateSelector: endFromDateSelector, - toDateSelector: endToDateSelector, - fromTimeSelector: endFromTimeSelector, - toTimeSelector: endToTimeSelector - } = getPeriodInputsSelectors(endPopoverSelector); - - await openFilteringPanel(page); - await pressElement(page, '#detector-filter-dropdown-option-ITS', true); - await pressElement(page, '#tag-dropdown-option-FOOD', true); - await pressElement(page, '#run-definition-checkbox-PHYSICS', true); - await pressElement(page, '.timeO2Start-filter .popover-trigger'); - await fillInput(page, startFromTimeSelector, '11:11', ['change']); - await fillInput(page, startToTimeSelector, '14:00', ['change']); - await fillInput(page, startFromDateSelector, '2021-02-03', ['change']); - await fillInput(page, startToDateSelector, '2021-02-03', ['change']); - await fillInput(page, endFromTimeSelector, '11:11', ['change']); - await fillInput(page, endToTimeSelector, '14:00', ['change']); - await fillInput(page, endFromDateSelector, '2021-02-03', ['change']); - await fillInput(page, endToDateSelector, '2021-02-03', ['change']); - await fillInput(page, '#duration-operand', '1500', ['change']); - await pressElement(page, `${dipolePopoverSelector} .dropdown-option:last-child`, true); - await pressElement(page, '#checkboxes-checkbox-bad'); - await pressElement(page, '#triggerValue-checkbox-OFF'); - await fillInput(page, '#runOverviewFilter .runNumbers-textFilter', '101'); - await fillInput(page, '.fillNumbers-textFilter', '1, 3', ['change']); - await fillInput(page, '.environmentIds-textFilter', 'Dxi029djX, TDI59So3d', ['change']); - await pressElement(page, '#run-types-dropdown-option-2', true); - await pressElement(page, '#beam-mode-dropdown-option-NO\\ BEAM', true); - await fillInput(page, '#nDetectors-operand', '1', ['change']); - await fillInput(page, '#nFlps-operand', '10', ['change']); - await fillInput(page, '#nEpns-operand', '10', ['change']); - await fillInput(page, '#ctfFileCount-operand', '1', ['change']); - await fillInput(page, '#tfFileCount-operand', '1', ['change']); - await fillInput(page, '#otherFileCount-operand', '1', ['change']); - await pressElement(page, '#epnFilterRadioOFF', true); - await page.select('#eorCategories', 'DETECTORS'); - await page.select('#eorTitles', 'CPV'); - await fillInput(page, '#eorDescription', 'some', ['change']); - - const queryParameters = getQueryParameters(page); - expect(queryParameters).to.deep.equal({ - "page": "run-overview", - "filter[runNumbers]": "101", - "filter[detectors][operator]": "and", - "filter[detectors][values]": "ITS", - "filter[tags][values]": "FOOD", - "filter[tags][operation]": "and", - "filter[fillNumbers]": "1, 3", - "filter[o2start][from]": "1612350660000", - "filter[o2start][to]": "1612360800000", - "filter[o2end][from]": "1612350660000", - "filter[o2end][to]": "1612360800000", - "filter[definitions]": "PHYSICS", - "filter[runDuration][operator]": "=", - "filter[runDuration][limit]": "90000000", - "filter[environmentIds]": "Dxi029djX, TDI59So3d", - "filter[runTypes]": "2", - "filter[beamModes]": "NO BEAM", - "filter[runQualities]": "bad", - "filter[nDetectors][operator]": "=", - "filter[nDetectors][limit]": "1", - "filter[nEpns][operator]": "=", - "filter[nEpns][limit]": "10", - "filter[nFlps][operator]": "=", - "filter[nFlps][limit]": "10", - "filter[ctfFileCount][operator]": "=", - "filter[ctfFileCount][limit]": "1", - "filter[tfFileCount][operator]": "=", - "filter[tfFileCount][limit]": "1", - "filter[otherFileCount][operator]": "=", - "filter[otherFileCount][limit]": "1", - "filter[eorReason][category]": "DETECTORS", - "filter[eorReason][title]": "CPV", - "filter[eorReason][description]": "some", - "filter[magnets][l3]": "30003", - "filter[magnets][dipole]": "0", - "filter[epn]": "false", - "filter[triggerValues]": "OFF" - }); - }); - - it('should set filters from lhcPriodOverview to the URL', async () => { - await goToPage(page, 'lhc-period-overview'); - - await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22a'); - await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(2) input[type=text]', '2022'); - await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(3) input[type=text]', 'PbPb'); - const queryParameters = getQueryParameters(page); - expect(queryParameters).to.deep.equal({ - "page": "lhc-period-overview", - "filter[names][]": "LHC22a", - "filter[years][]": "2022", - "filter[pdpBeamTypes][]": "PbPb" - }); - }); - - it('should set filters from qcFlagTypesOverview to the URL', async () => { - await goToPage(page, 'qc-flag-types-overview'); - - await fillInput(page, '.name-filter input[type=text]', 'bad'); - await fillInput(page, '.method-filter input[type=text]', 'bad'); - await pressElement(page, '#badFilterRadioBad', true); - - const queryParameters = getQueryParameters(page); - expect(queryParameters).to.deep.equal({ - "page": "qc-flag-types-overview", - "filter[names][]": "bad", - "filter[methods][]": "bad", - "filter[bad]": "true" - }); - }); - - it('should set filters from runsPerLhcPeriodOverview to the URL', async () => { - await goToPage(page, 'runs-per-lhc-period', { queryParameters: { lhcPeriodId: 2 }}); - const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); - const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); - const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); - - const { - fromDateSelector: startFromDateSelector, - toDateSelector: startToDateSelector, - fromTimeSelector: startFromTimeSelector, - toTimeSelector: startToTimeSelector - } = getPeriodInputsSelectors(startPopoverSelector); - - const { - fromDateSelector: endFromDateSelector, - toDateSelector: endToDateSelector, - fromTimeSelector: endFromTimeSelector, - toTimeSelector: endToTimeSelector - } = getPeriodInputsSelectors(endPopoverSelector); - - await fillInput(page, '#inelasticInteractionRateAvg-operand', '100000', ['change']); - await fillInput(page, '#muInelasticInteractionRate-operand', '100000', ['change']); - await fillInput(page, '#runOverviewFilter .runNumbers-textFilter', '101'); - await fillInput(page, '.fillNumbers-textFilter', '1, 3', ['change']); - - await pressElement(page, `${dipolePopoverSelector} .dropdown-option:last-child`, true); - await fillInput(page, startFromTimeSelector, '11:11', ['change']); - await fillInput(page, startToTimeSelector, '14:00', ['change']); - await fillInput(page, startFromDateSelector, '2021-02-03', ['change']); - await fillInput(page, startToDateSelector, '2021-02-03', ['change']); - await fillInput(page, endFromTimeSelector, '11:11', ['change']); - await fillInput(page, endToTimeSelector, '14:00', ['change']); - await fillInput(page, endFromDateSelector, '2021-02-03', ['change']); - await fillInput(page, endToDateSelector, '2021-02-03', ['change']); - - - const queryParameters = getQueryParameters(page); - expect(queryParameters).to.deep.equal({ - "page": "runs-per-lhc-period", - "lhcPeriodId": "2", - "filter[runNumbers]": "101", - "filter[fillNumbers]": "1, 3", - "filter[o2end][from]": "1612350660000", - "filter[o2end][to]": "1612360800000", - "filter[o2start][from]": "1612350660000", - "filter[o2start][to]": "1612360800000", - "filter[magnets][l3]": "30003", - "filter[magnets][dipole]": "0", - "filter[muInelasticInteractionRate][operator]": "=", - "filter[muInelasticInteractionRate][limit]": "100000", - "filter[inelasticInteractionRateAvg][operator]": "=", - "filter[inelasticInteractionRateAvg][limit]": "100000", - "filter[detectorsQcNotBadFraction][mcReproducibleAsNotBad]": "false" - }); - }); - - it('should set filters from DataPassesPerLhcPeriodOverview to the URL', async () => { - await goToPage(page, 'data-passes-per-lhc-period-overview', { queryParameters: { lhcPeriodId: 2 }}); - - await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1', ['input']); - await pressElement(page, '#checkboxes-checkbox-test', true); - - - const queryParameters = getQueryParameters(page); - expect(queryParameters).to.deep.equal({ - "page": "data-passes-per-lhc-period-overview", - "lhcPeriodId": "2", - "filter[names][]": "LHC22b_apass1", - "filter[permittedNonPhysicsNames]": "test" - }); - }); - - it('should set filters from DataPassesPerSimulationPassOverview to the URL', async () => { - await goToPage(page, 'data-passes-per-simulation-pass-overview', { queryParameters: { simulationPassId: 1 }}); - - await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1', ['input']); - await pressElement(page, '#checkboxes-checkbox-test', true); - - const queryParameters = getQueryParameters(page); - expect(queryParameters).to.deep.equal({ - "page": "data-passes-per-simulation-pass-overview", - "simulationPassId": "1", - "filter[names][]": "LHC22b_apass1", - "filter[permittedNonPhysicsNames]": "test" - }); - }); - - it('should set filters from AnchoredSimulationPassesOverview to the URL', async () => { - await goToPage(page, 'anchored-simulation-passes-overview', { queryParameters: { dataPassId: 1 }}); - - await fillInput(page, '.name-filter input', 'LHC23k6c', ['input']); - - const queryParameters = getQueryParameters(page); - expect(queryParameters).to.deep.equal({ - "page": "anchored-simulation-passes-overview", - "dataPassId": "1", - "filter[names][]": "LHC23k6c" - }); - }); - - it('should set filters from RunsPerSimulationPass to the URL', async () => { - await goToPage(page, 'runs-per-simulation-pass', { queryParameters: { simulationPassId: 2 }}); - - const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); - const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); - const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); - - const { - fromDateSelector: startFromDateSelector, - toDateSelector: startToDateSelector, - fromTimeSelector: startFromTimeSelector, - toTimeSelector: startToTimeSelector - } = getPeriodInputsSelectors(startPopoverSelector); - - const { - fromDateSelector: endFromDateSelector, - toDateSelector: endToDateSelector, - fromTimeSelector: endFromTimeSelector, - toTimeSelector: endToTimeSelector - } = getPeriodInputsSelectors(endPopoverSelector); - - await openFilteringPanel(page); - await pressElement(page, '.timeO2Start-filter .popover-trigger'); - await fillInput(page, startFromTimeSelector, '11:11', ['change']); - await fillInput(page, startToTimeSelector, '14:00', ['change']); - await fillInput(page, startFromDateSelector, '2021-02-03', ['change']); - await fillInput(page, startToDateSelector, '2021-02-03', ['change']); - await fillInput(page, endFromTimeSelector, '11:11', ['change']); - await fillInput(page, endToTimeSelector, '14:00', ['change']); - await fillInput(page, endFromDateSelector, '2021-02-03', ['change']); - await fillInput(page, endToDateSelector, '2021-02-03', ['change']); - await fillInput(page, '.inelasticInteractionRateAtMid-filter input', '1', ['change']); - await fillInput(page, '.inelasticInteractionRateAtEnd-filter input', '1', ['change']); - await fillInput(page, '.inelasticInteractionRateAtStart-filter input', '1', ['change']); - await pressElement(page, `${dipolePopoverSelector} .dropdown-option:last-child`, true); - await pressElement(page, '#mcReproducibleAsNotBadToggle', true); - - // These two are detectorQCNotBadFraction[_id] filters. There are a dozen more, but they are all identical hence why only these were tested - await fillInput(page, '.QC-SPECIFIC-filter input', '1', ['change']); - await fillInput(page, '.ACO-filter input', '1', ['change']); - - const queryParameters = getQueryParameters(page); - expect(queryParameters).to.deep.equal({ - "page": "runs-per-simulation-pass", - "simulationPassId": "2", - "filter[o2end][from]": "1612350660000", - "filter[o2start][from]": "1612350660000", - "filter[o2end][to]": "1612360800000", - "filter[o2start][to]": "1612360800000", - "filter[magnets][l3]": "30003", - "filter[magnets][dipole]": "0", - "filter[inelasticInteractionRateAtStart][operator]": "=", - "filter[inelasticInteractionRateAtStart][limit]": "1", - "filter[inelasticInteractionRateAtMid][operator]": "=", - "filter[inelasticInteractionRateAtMid][limit]": "1", - "filter[inelasticInteractionRateAtEnd][operator]": "=", - "filter[inelasticInteractionRateAtEnd][limit]": "1", - "filter[detectorsQcNotBadFraction][mcReproducibleAsNotBad]": "true", - "filter[detectorsQcNotBadFraction][_20][operator]": "=", - "filter[detectorsQcNotBadFraction][_20][limit]": "0.01", - "filter[detectorsQcNotBadFraction][_17][operator]": "=", - "filter[detectorsQcNotBadFraction][_17][limit]": "0.01" - }); - }); - - it('should set filters from RunsPerSimulationPass to the URL', async () => { - await goToPage(page, 'runs-per-data-pass', { queryParameters: { dataPassId: 1 }}); - - const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); - const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); - const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); - - const { - fromDateSelector: startFromDateSelector, - toDateSelector: startToDateSelector, - fromTimeSelector: startFromTimeSelector, - toTimeSelector: startToTimeSelector - } = getPeriodInputsSelectors(startPopoverSelector); - - const { - fromDateSelector: endFromDateSelector, - toDateSelector: endToDateSelector, - fromTimeSelector: endFromTimeSelector, - toTimeSelector: endToTimeSelector - } = getPeriodInputsSelectors(endPopoverSelector); - - await openFilteringPanel(page); - await pressElement(page, '#detector-filter-dropdown-option-ITS', true); - await pressElement(page, '#tag-dropdown-option-FOOD', true); - await pressElement(page, '.timeO2Start-filter .popover-trigger'); - await fillInput(page, startFromTimeSelector, '11:11', ['change']); - await fillInput(page, startToTimeSelector, '14:00', ['change']); - await fillInput(page, startFromDateSelector, '2021-02-03', ['change']); - await fillInput(page, startToDateSelector, '2021-02-03', ['change']); - await fillInput(page, endFromTimeSelector, '11:11', ['change']); - await fillInput(page, endToTimeSelector, '14:00', ['change']); - await fillInput(page, endFromDateSelector, '2021-02-03', ['change']); - await fillInput(page, endToDateSelector, '2021-02-03', ['change']); - await fillInput(page, '#duration-operand', '1500', ['change']); - await fillInput(page, '.muInelasticInteractionRate-filter input', '1', ['change']); - await fillInput(page, '.inelasticInteractionRateAvg-filter input', '1', ['change']); - await fillInput(page, '.globalAggregatedQuality-filter input', '1', ['change']); - - await pressElement(page, `${dipolePopoverSelector} .dropdown-option:last-child`, true); - await pressElement(page, '#mcReproducibleAsNotBadToggle', true); - - // These two are detectorQCNotBadFraction[_id] filters. There are a dozen more, but they are all identical hence why only these were tested - await fillInput(page, '.QC-SPECIFIC-filter input', '1', ['change']); - await fillInput(page, '.ACO-filter input', '1', ['change']); - - const queryParameters = getQueryParameters(page); - expect(queryParameters).to.deep.equal({ - "page": "runs-per-data-pass", - "dataPassId": "1", - "filter[detectors][operator]": "and", - "filter[detectors][values]": "ITS", - "filter[tags][values]": "FOOD", - "filter[tags][operation]": "and", - "filter[o2end][from]": "1612350660000", - "filter[o2end][to]": "1612360800000", - "filter[o2start][from]": "1612350660000", - "filter[o2start][to]": "1612360800000", - "filter[runDuration][limit]": "90000000", - "filter[runDuration][operator]": "=", - "filter[magnets][l3]": "30003", - "filter[magnets][dipole]": "0", - "filter[muInelasticInteractionRate][operator]": "=", - "filter[muInelasticInteractionRate][limit]": "1", - "filter[inelasticInteractionRateAvg][operator]": "=", - "filter[inelasticInteractionRateAvg][limit]": "1", - "filter[detectorsQcNotBadFraction][mcReproducibleAsNotBad]": "true", - "filter[detectorsQcNotBadFraction][_20][operator]": "=", - "filter[detectorsQcNotBadFraction][_20][limit]": "0.01", - "filter[detectorsQcNotBadFraction][_17][operator]": "=", - "filter[detectorsQcNotBadFraction][_17][limit]": "0.01", - "filter[gaq][notBadFraction][operator]": "=", - "filter[gaq][notBadFraction][limit]": "0.01", - "filter[gaq][mcReproducibleAsNotBad]": "true" - }); - }); - - after(async () => await defaultAfter(page, browser)); -} diff --git a/test/public/Filters/urlToFilter.test.js b/test/public/Filters/urlToFilter.test.js deleted file mode 100644 index 06eb280039..0000000000 --- a/test/public/Filters/urlToFilter.test.js +++ /dev/null @@ -1,372 +0,0 @@ -/** - * @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. - */ - -const { - defaultBefore, - defaultAfter, - fillInput, - getPopoverSelector, - getPeriodInputsSelectors, - pressElement, - openFilteringPanel, - expectInputValue, -} = require('../defaults.js'); - -module.exports = () => { - let page; - let browser; - - before(async () => { - [page, browser] = await defaultBefore(); - }); - - it('should apply filters from url in logsOverviewPage', async () => { - const url = 'http://localhost:4000/?page=log-overview&filter[author]=Jane&filter[title]=bogusbogusbogus&filter[content]=particle'+ - '&filter[tags][values]=DPG&filter[tags][operation]=and&filter[runNumbers]=1%2C2&filter[environmentIds]=8E4aZTjY'+ - '&filter[fillNumbers]=1%2C%206&filter[created][from]=1580637600000&filter[created][to]=1580641200000'; - - - await page.goto(url, { waitUntil: 'load' }); - - const firstCheckboxId = 'tag-dropdown-option-DPG'; - const popoverTrigger = '.createdAt-filter .popover-trigger'; - - await page.waitForSelector(popoverTrigger); - await openFilteringPanel(page); - - const popOverSelector = await getPopoverSelector(await page.$(popoverTrigger)); - const { fromDateSelector, toDateSelector, fromTimeSelector, toTimeSelector } = getPeriodInputsSelectors(popOverSelector); - - await expectInputValue(page, '.title-textFilter', 'bogusbogusbogus'); - await expectInputValue(page, '#authorFilterText', 'Jane'); - await expectInputValue(page, '.content-textFilter', 'particle'); - await pressElement(page, '.tags-filter .dropdown-trigger'); - await page.waitForSelector(`#${firstCheckboxId}:checked`); - await expectInputValue(page, '.environments-filter input', '8E4aZTjY'); - await expectInputValue(page, '.runNumbers-textFilter', '1,2'); - await expectInputValue(page, '.fillNumbers-textFilter', '1, 6'); - await expectInputValue(page, fromDateSelector, '2020-02-02'); - await expectInputValue(page, toDateSelector, '2020-02-02'); - - await expectInputValue(page, fromTimeSelector, '10:00'); - await expectInputValue(page, toTimeSelector, '11:00'); - }); - - it('should set filters from EnvironmentsOverview to the URL', async () => { - const url = 'http://localhost:4000/?page=env-overview&filter[created][from]=1565301600000&filter[created][to]=1565474340000' + - '&filter[runNumbers]=10&filter[statusHistory]=C-R-D-X&filter[currentStatus]=DESTROYED&filter[ids]=Dxi029djX%2C%20TDI59So3d'; - await page.goto(url, { waitUntil: 'load' }); - await openFilteringPanel(page); - - const popoverTrigger = '.createdAt-filter .popover-trigger'; - const createdAtPopoverSelector = await getPopoverSelector(await page.$(popoverTrigger)); - const periodInputsSelectors = getPeriodInputsSelectors(createdAtPopoverSelector); - - await expectInputValue(page, '.runs-filter input', '10'); - await expectInputValue(page, '.id-filter input', 'Dxi029djX, TDI59So3d'); - await page.waitForSelector('#checkboxes-checkbox-DESTROYED:checked'); - await expectInputValue(page, '.historyItems-filter input', 'C-R-D-X'); - await expectInputValue(page, periodInputsSelectors.fromDateSelector, '2019-08-08'); - await expectInputValue(page, periodInputsSelectors.toDateSelector, '2019-08-10'); - await expectInputValue(page, periodInputsSelectors.fromTimeSelector, '22:00'); - await expectInputValue(page, periodInputsSelectors.toTimeSelector, '21:59'); - }); - - it('should set filters from LhcFillsOverview to the URL', async () => { - const url = 'http://localhost:4000/?page=lhc-fill-overview&filter[beamDuration][operator]=%3D&filter[beamDuration][limit]=00%3A01%3A40&' + - 'filter[runDuration][operator]=%3D&filter[runDuration][limit]=00%3A00%3A00&filter[hasStableBeams]=true&filter[stableBeamsStart][from]=1565251200000&' + - 'filter[stableBeamsStart][to]=1565258400000&filter[stableBeamsEnd][from]=1647907200000&filter[stableBeamsEnd][to]=1647989940000&filter[beamTypes]=p-Pb&filter[schemeName]=Single_12b_8_1024_8_2018'; - - await page.goto(url, { waitUntil: 'load' }); - - const sbEndPopoverTrigger = '.stableBeamsEnd-filter .popover-trigger'; - const sbStartPopoverTrigger = '.stableBeamsStart-filter .popover-trigger'; - const sbStartPopOverSelector = await getPopoverSelector(await page.$(sbStartPopoverTrigger)); - const sbEndPopOverSelector = await getPopoverSelector(await page.$(sbEndPopoverTrigger)); - const filterSchemeNameInputField= '.fillingSchemeName-filter input'; - const { - fromDateSelector: sbStartFromDateSelector, - toDateSelector: sbStartToDateSelector, - fromTimeSelector: sbStartFromTimeSelector, - toTimeSelector: sbStartToTimeSelector - } = getPeriodInputsSelectors(sbStartPopOverSelector); - - const { - fromDateSelector: sbEndFromDateSelector, - toDateSelector: sbEndToDateSelector, - fromTimeSelector: sbEndFromTimeSelector, - toTimeSelector: sbEndToTimeSelector - } = getPeriodInputsSelectors(sbEndPopOverSelector); - - await openFilteringPanel(page); - await expectInputValue(page, '#beam-duration-filter-operand', '00:01:40'); - await expectInputValue(page, '#run-duration-filter-operand', '00:00:00'); - await expectInputValue(page, sbStartFromDateSelector, '2019-08-08'); - await expectInputValue(page, sbStartToDateSelector, '2019-08-08'); - await expectInputValue(page, sbStartFromTimeSelector, '08:00'); - await expectInputValue(page, sbStartToTimeSelector, '10:00'); - await expectInputValue(page, sbEndFromDateSelector, '2022-03-22'); - await expectInputValue(page, sbEndToDateSelector, '2022-03-22'); - await expectInputValue(page, sbEndFromTimeSelector, '00:00'); - await expectInputValue(page, sbEndToTimeSelector, '22:59'); - await expectInputValue(page, filterSchemeNameInputField, 'Single_12b_8_1024_8_2018'); - await page.waitForSelector('#beam-types-checkbox-p-Pb:checked'); - }); - - it('should set filters from runsOverview to the URL', async () => { - const url = 'http://localhost:4000/?page=run-overview&filter[runNumbers]=101&filter[detectors][operator]=and&filter[detectors][values]=ITS&filter[tags][values]=FOOD&' + - 'filter[tags][operation]=and&filter[fillNumbers]=1%2C%203&filter[o2start][from]=1612347060000&filter[o2start][to]=1612357200000&filter[o2end][from]=1612347060000&' + - 'filter[o2end][to]=1612357200000&filter[definitions]=PHYSICS&filter[runDuration][operator]=%3D&filter[runDuration][limit]=90000000' + - '&filter[environmentIds]=Dxi029djX%2C%20TDI59So3d&filter[runTypes]=2&filter[beamModes]=NO%20BEAM&filter[runQualities]=bad&filter[nDetectors][operator]=%3D&' + - 'filter[nDetectors][limit]=1&filter[nEpns][operator]=%3D&filter[nEpns][limit]=10&filter[nFlps][operator]=%3D&filter[nFlps][limit]=10&filter[ctfFileCount][operator]=%3D&' + - 'filter[ctfFileCount][limit]=1&filter[tfFileCount][operator]=%3D&filter[tfFileCount][limit]=1&filter[otherFileCount][operator]=%3D&filter[otherFileCount][limit]=1&' + - 'filter[eorReason][category]=DETECTORS&filter[eorReason][title]=CPV&filter[eorReason][description]=some&filter[magnets][l3]=30003&filter[magnets][dipole]=0&filter[epn]=false&filter[triggerValues]=OFF'; - - await page.goto(url, { waitUntil: 'load' }); - - const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); - const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); - const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); - - const { - fromDateSelector: startFromDateSelector, - toDateSelector: startToDateSelector, - fromTimeSelector: startFromTimeSelector, - toTimeSelector: startToTimeSelector - } = getPeriodInputsSelectors(startPopoverSelector); - - const { - fromDateSelector: endFromDateSelector, - toDateSelector: endToDateSelector, - fromTimeSelector: endFromTimeSelector, - toTimeSelector: endToTimeSelector - } = getPeriodInputsSelectors(endPopoverSelector); - - await openFilteringPanel(page); - await page.waitForSelector('#detector-filter-dropdown-option-ITS:checked'); - await page.waitForSelector('#run-types-dropdown-option-2:checked'); - await page.waitForSelector('#beam-mode-dropdown-option-NO\\ BEAM:checked'); - await page.waitForSelector('#tag-dropdown-option-FOOD:checked'); - await page.waitForSelector('#run-definition-checkbox-PHYSICS:checked'); - await page.waitForSelector('#epnFilterRadioOFF:checked'); - await pressElement(page, '.timeO2Start-filter .popover-trigger'); - await page.waitForSelector('#checkboxes-checkbox-bad:checked'); - await page.waitForSelector('#triggerValue-checkbox-OFF:checked'); - await page.waitForSelector(`${dipolePopoverSelector} .dropdown-option:last-child input:checked`); - await expectInputValue(page, '#duration-operand', '1500'); - await expectInputValue(page, '#runOverviewFilter .runNumbers-textFilter', '101'); - await expectInputValue(page, '.fillNumbers-textFilter', '1, 3'); - await expectInputValue(page, '.environmentIds-textFilter', 'Dxi029djX, TDI59So3d'); - await expectInputValue(page, '#nDetectors-operand', '1'); - await expectInputValue(page, '#nFlps-operand', '10'); - await expectInputValue(page, '#nEpns-operand', '10'); - await expectInputValue(page, '#ctfFileCount-operand', '1'); - await expectInputValue(page, '#tfFileCount-operand', '1'); - await expectInputValue(page, '#otherFileCount-operand', '1'); - await expectInputValue(page, '#eorDescription', 'some'); - await expectInputValue(page, '#eorTitles', 'CPV'); - await expectInputValue(page, '#eorCategories', 'DETECTORS'); - await expectInputValue(page, startFromTimeSelector, '10:11'); - await expectInputValue(page, startToTimeSelector, '13:00'); - await expectInputValue(page, startFromDateSelector, '2021-02-03'); - await expectInputValue(page, startToDateSelector, '2021-02-03'); - await expectInputValue(page, endFromTimeSelector, '10:11'); - await expectInputValue(page, endToTimeSelector, '13:00'); - await expectInputValue(page, endFromDateSelector, '2021-02-03'); - await expectInputValue(page, endToDateSelector, '2021-02-03'); - }); - - it('should set filters from lhcPriodOverview to the URL', async () => { - const url = 'http://localhost:4000/?page=lhc-period-overview&filter[names][]=LHC22a&filter[years][]=2022&filter[pdpBeamTypes][]=PbPb'; - await page.goto(url, { waitUntil: 'load' }); - - await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22a'); - await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(2) input[type=text]', '2022'); - await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(3) input[type=text]', 'PbPb'); - }); - - it('should set filters from qcFlagTypesOverview to the URL', async () => { - const url = 'http://localhost:4000/?page=qc-flag-types-overview&filter[names][]=bad&filter[methods][]=bad&filter[bad]=true'; - await page.goto(url, { waitUntil: 'load' }); - - await expectInputValue(page, '.name-filter input[type=text]', 'bad'); - await expectInputValue(page, '.method-filter input[type=text]', 'bad'); - await page.waitForSelector('#badFilterRadioBad:checked'); - }); - - it('should set filters from runsPerLhcPeriodOverview to the URL', async () => { - const url = 'http://localhost:4000/?page=runs-per-lhc-period&lhcPeriodId=2&filter[runNumbers]=101&filter[fillNumbers]=1%2C%203&filter[o2start][from]=1612347060000&' + - 'filter[o2start][to]=1612357200000&filter[o2end][from]=1612347060000&filter[o2end][to]=1612357200000&filter[magnets][l3]=30003&filter[magnets][dipole]=0&' + - 'filter[muInelasticInteractionRate][operator]=%3D&filter[muInelasticInteractionRate][limit]=100000&filter[inelasticInteractionRateAvg][operator]=%3D&filter[inelasticInteractionRateAvg][limit]=100000'; - await page.goto(url, { waitUntil: 'load' }); - - const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); - const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); - const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); - - const { - fromDateSelector: startFromDateSelector, - toDateSelector: startToDateSelector, - fromTimeSelector: startFromTimeSelector, - toTimeSelector: startToTimeSelector - } = getPeriodInputsSelectors(startPopoverSelector); - - const { - fromDateSelector: endFromDateSelector, - toDateSelector: endToDateSelector, - fromTimeSelector: endFromTimeSelector, - toTimeSelector: endToTimeSelector - } = getPeriodInputsSelectors(endPopoverSelector); - - await expectInputValue(page, '#inelasticInteractionRateAvg-operand', '100000'); - await expectInputValue(page, '#muInelasticInteractionRate-operand', '100000'); - await expectInputValue(page, '#runOverviewFilter .runNumbers-textFilter', '101'); - await expectInputValue(page, '.fillNumbers-textFilter', '1, 3'); - await expectInputValue(page, startFromTimeSelector, '10:11'); - await expectInputValue(page, startToTimeSelector, '13:00'); - await expectInputValue(page, startFromDateSelector, '2021-02-03'); - await expectInputValue(page, startToDateSelector, '2021-02-03'); - await expectInputValue(page, endFromTimeSelector, '10:11'); - await expectInputValue(page, endToTimeSelector, '13:00'); - await expectInputValue(page, endFromDateSelector, '2021-02-03'); - await expectInputValue(page, endToDateSelector, '2021-02-03'); - await page.waitForSelector(`${dipolePopoverSelector} .dropdown-option:last-child input:checked`); - }); - - it('should set filters from DataPassesPerLhcPeriodOverview to the URL', async () => { - const url = 'http://localhost:4000/?page=data-passes-per-lhc-period-overview&lhcPeriodId=2&filter[names][]=LHC22b_apass1&filter[permittedNonPhysicsNames]=test'; - await page.goto(url, { waitUntil: 'load' }); - - await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1'); - await page.waitForSelector('#checkboxes-checkbox-test:checked'); - }); - - it('should set filters from DataPassesPerSimulationPassOverview to the URL', async () => { - const url = 'http://localhost:4000/?page=data-passes-per-simulation-pass-overview&simulationPassId=1&filter[names][]=LHC22b_apass1&filter[permittedNonPhysicsNames]=test'; - await page.goto(url, { waitUntil: 'load' }); - - await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1'); - await page.waitForSelector('#checkboxes-checkbox-test:checked'); - }); - - it('should set filters from AnchoredSimulationPassesOverview to the URL', async () => { - const url = 'http://localhost:4000/?page=anchored-simulation-passes-overview&dataPassId=1&filter[names][]=LHC23k6c'; - await page.goto(url, { waitUntil: 'load' }); - - await expectInputValue(page, '.name-filter input', 'LHC23k6c'); - }); - - it('should set filters from RunsPerSimulationPass to the URL', async () => { - const url = 'http://localhost:4000/?page=runs-per-simulation-pass&simulationPassId=2&filter[o2start][from]=1612347060000&' + - 'filter[o2start][to]=1612357200000&filter[o2end][from]=1612347060000&filter[o2end][to]=1612357200000&' + - 'filter[magnets][l3]=30003&filter[magnets][dipole]=0&filter[inelasticInteractionRateAtStart][operator]=%3D&' + - 'filter[inelasticInteractionRateAtStart][limit]=1&filter[inelasticInteractionRateAtMid][operator]=%3D&' + - 'filter[inelasticInteractionRateAtMid][limit]=1&filter[inelasticInteractionRateAtEnd][operator]=%3D&' + - 'filter[inelasticInteractionRateAtEnd][limit]=1&filter[detectorsQcNotBadFraction][mcReproducibleAsNotBad]=true&' + - 'filter[detectorsQcNotBadFraction][_20][operator]=%3D&filter[detectorsQcNotBadFraction][_20][limit]=0.01&' + - 'filter[detectorsQcNotBadFraction][_17][operator]=%3D&filter[detectorsQcNotBadFraction][_17][limit]=0.01'; - - await page.goto(url, { waitUntil: 'load' }); - - const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); - const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); - const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); - - const { - fromDateSelector: startFromDateSelector, - toDateSelector: startToDateSelector, - fromTimeSelector: startFromTimeSelector, - toTimeSelector: startToTimeSelector - } = getPeriodInputsSelectors(startPopoverSelector); - - const { - fromDateSelector: endFromDateSelector, - toDateSelector: endToDateSelector, - fromTimeSelector: endFromTimeSelector, - toTimeSelector: endToTimeSelector - } = getPeriodInputsSelectors(endPopoverSelector); - - await openFilteringPanel(page); - await expectInputValue(page, '.inelasticInteractionRateAtMid-filter input', '1'); - await expectInputValue(page, '.inelasticInteractionRateAtEnd-filter input', '1'); - await expectInputValue(page, '.inelasticInteractionRateAtStart-filter input', '1'); - await expectInputValue(page, startFromTimeSelector, '10:11'); - await expectInputValue(page, startToTimeSelector, '13:00'); - await expectInputValue(page, startFromDateSelector, '2021-02-03'); - await expectInputValue(page, startToDateSelector, '2021-02-03'); - await expectInputValue(page, endFromTimeSelector, '10:11'); - await expectInputValue(page, endToTimeSelector, '13:00'); - await expectInputValue(page, endFromDateSelector, '2021-02-03'); - await expectInputValue(page, endToDateSelector, '2021-02-03'); - await page.waitForSelector(`${dipolePopoverSelector} .dropdown-option:last-child input:checked`); - await page.waitForSelector('#mcReproducibleAsNotBadToggle input:checked'); - - - // These two are detectorQCNotBadFraction[_id] filters. There are a dozen more, but they are all identical hence why only these were tested - await expectInputValue(page, '.QC-SPECIFIC-filter input', '1'); - await expectInputValue(page, '.ACO-filter input', '1'); - }); - - it('should set filters from RunsPerSimulationPass to the URL', async () => { - const url = 'http://localhost:4000/?page=runs-per-data-pass&dataPassId=1&filter[detectors][operator]=and&filter[detectors][values]=ITS&' + - 'filter[tags][values]=FOOD&filter[tags][operation]=and&filter[o2start][from]=1612347060000&filter[o2start][to]=1612357200000&' + - 'filter[o2end][from]=1612347060000&filter[o2end][to]=1612357200000&filter[runDuration][operator]=%3D&filter[runDuration][limit]=90000000&' + - 'filter[magnets][l3]=30003&filter[magnets][dipole]=0&filter[muInelasticInteractionRate][operator]=%3D&filter[muInelasticInteractionRate][limit]=1&' + - 'filter[inelasticInteractionRateAvg][operator]=%3D&filter[inelasticInteractionRateAvg][limit]=1&filter[detectorsQcNotBadFraction][mcReproducibleAsNotBad]=true&' + - 'filter[detectorsQcNotBadFraction][_20][operator]=%3D&filter[detectorsQcNotBadFraction][_20][limit]=0.01&filter[detectorsQcNotBadFraction][_17][operator]=%3D&' + - 'filter[detectorsQcNotBadFraction][_17][limit]=0.01&filter[gaq][notBadFraction][operator]=%3D&filter[gaq][notBadFraction][limit]=0.01&filter[gaq][mcReproducibleAsNotBad]=true'; - - await page.goto(url, { waitUntil: 'load' }); - - const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); - const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); - const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); - - const { - fromDateSelector: startFromDateSelector, - toDateSelector: startToDateSelector, - fromTimeSelector: startFromTimeSelector, - toTimeSelector: startToTimeSelector - } = getPeriodInputsSelectors(startPopoverSelector); - - const { - fromDateSelector: endFromDateSelector, - toDateSelector: endToDateSelector, - fromTimeSelector: endFromTimeSelector, - toTimeSelector: endToTimeSelector - } = getPeriodInputsSelectors(endPopoverSelector); - - await openFilteringPanel(page); - await expectInputValue(page, startFromTimeSelector, '10:11'); - await expectInputValue(page, startToTimeSelector, '13:00'); - await expectInputValue(page, startFromDateSelector, '2021-02-03'); - await expectInputValue(page, startToDateSelector, '2021-02-03'); - await expectInputValue(page, endFromTimeSelector, '10:11'); - await expectInputValue(page, endToTimeSelector, '13:00'); - await expectInputValue(page, endFromDateSelector, '2021-02-03'); - await expectInputValue(page, endToDateSelector, '2021-02-03'); - await expectInputValue(page, '#duration-operand', '1500'); - await expectInputValue(page, '.muInelasticInteractionRate-filter input', '1'); - await expectInputValue(page, '.inelasticInteractionRateAvg-filter input', '1'); - await expectInputValue(page, '.globalAggregatedQuality-filter input', '1'); - await fillInput(page, '.ACO-filter input', '1', ['change']); - await fillInput(page, '.QC-SPECIFIC-filter input', '1', ['change']); - - await page.waitForSelector('#detector-filter-dropdown-option-ITS'); - await page.waitForSelector('#tag-dropdown-option-FOOD'); - await page.waitForSelector(`${dipolePopoverSelector} .dropdown-option:last-child input:checked`); - await page.waitForSelector('#mcReproducibleAsNotBadToggle input:checked'); - }); - - after(async () => await defaultAfter(page, browser)); -} diff --git a/test/public/components/filtersPopoverPanel.test.js b/test/public/components/filtersPopoverPanel.test.js deleted file mode 100644 index 53df9e8ee3..0000000000 --- a/test/public/components/filtersPopoverPanel.test.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * @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. - */ - -const { expect } = require('chai'); -const { defaultBefore, defaultAfter, pressElement, takeScreenshot, expectInputValue } = require('../defaults.js'); - -module.exports = () => { - let page; - let browser; - let context; - let url; - - before(async () => { - [page, browser, url] = await defaultBefore(page, browser); - context = browser.defaultBrowserContext(); - context.overridePermissions(url, ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']); - }); - - it('Should copy url when clicking filer copy button', async () => { - const url = 'http://localhost:4000/?page=lhc-period-overview&filter[names][]=name&filter[years][]=100&filter[pdpBeamTypes][]=PbPb'; - await page.goto(url, { waitUntil: 'load' }); - await takeScreenshot(page, 'test'); - await pressElement(page, '#copy-filters', true); - - const clipboardContents = await page.evaluate(async () => decodeURI(await navigator.clipboard.readText())); - expect(clipboardContents).to.equal(url); - }); - - it('Should set filters when pressing paste active filters button', async () => { - const url = 'http://localhost:4000/?page=lhc-period-overview&filter[names][]=name&filter[years][]=100&filter[pdpBeamTypes][]=PbPb'; - - await page.evaluate(async (url) => await navigator.clipboard.writeText(url), url); - await pressElement(page, '#paste-filters', true); - - const actualUrl = page.url(); - expect(actualUrl).to.equal(url); - - await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'name'); - await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(2) input[type=text]', '100'); - await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(3) input[type=text]', 'PbPb'); - }); - - it('Should reset filters when pressing the reset all filters button', async () => { - const url = 'http://localhost:4000/?page=lhc-period-overview&filter[names][]=name&filter[years][]=100&filter[pdpBeamTypes][]=PbPb'; - - await page.goto(url, { waitUntil: 'load' }); - - await pressElement(page, '.dropdown #reset-filters', true); - const actualUrl = page.url(); - expect(actualUrl).to.equal('http://localhost:4000/?page=lhc-period-overview'); - - await expectInputValue(page, '.name-filter input', ''); - await expectInputValue(page, '.year-filter input', ''); - await expectInputValue(page, '.pdpBeamTypes-filter input', ''); - }); - - after(async () => { - await defaultAfter(page, browser); - }); -}; diff --git a/test/public/components/index.js b/test/public/components/index.js index 794ae79252..5e06743c62 100644 --- a/test/public/components/index.js +++ b/test/public/components/index.js @@ -12,11 +12,7 @@ */ const NavBarSuite = require('./navBar.test') -const WarningSuite = require('./warnings.test') -const FiltersPanelSuite = require('./filtersPopoverPanel.test') module.exports = () => { describe('Navbar component', NavBarSuite); - describe('Warning component', WarningSuite) - describe('FiltersPanelPopover component', FiltersPanelSuite) }; diff --git a/test/public/components/warnings.test.js b/test/public/components/warnings.test.js deleted file mode 100644 index 5fb32457f3..0000000000 --- a/test/public/components/warnings.test.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * @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. - */ - -const { expect } = require('chai'); -const { - defaultBefore, - defaultAfter, - getInnerText, - pressElement, - goToPage, -} = require('../defaults.js'); - -module.exports = () => { - let page; - let browser; - let url; - let context; - - before(async () => { - [page, browser, url] = await defaultBefore(page, browser); - context = browser.defaultBrowserContext(); - context.overridePermissions(url, ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']); - }); - - it('Should show warning when a filter in the url is not recognised', async () => { - await page.goto('http://localhost:4000/?page=log-overview&filter[fake]=fake', { waitUntil: 'load' }); - const warningText = await getInnerText(await page.waitForSelector('.alert-warning > ul')); - - expect(warningText).to.equal('Unknown Filters:\nThe filters: [\'fake\']; are not reccognised. Check if they are spelled correctly.'); - }); - - it('Should remove warnings entry after clicking the x icon', async () => { - await pressElement(page, '.alert-warning .btn', true); - const warning = await page.$('.alert-warning'); - - expect(warning).to.be.null; - }); - - it('Should show warning when a url filter cannot be parsed/normalized', async () => { - await page.goto('http://localhost:4000/?page=run-overview&filter[detectors][operator]=or&filter[detecttors][values]=CTP&filter[tagss][values]=CPV&filter[tags][operation]=or', { waitUntil: 'load' }); - const unparsableWarningText = await getInnerText(await page.waitForSelector('.alert-warning > ul > li:nth-of-type(1)')); - const unknownFilterWarningText = await getInnerText(await page.waitForSelector('.alert-warning > ul > li:nth-of-type(2)')); - - // The tags and detectors filters will fail if it has no value. - // However, if the url also contains its operator, it will still attempt to set the filters, which would fail, hence the warning - expect(unparsableWarningText).to.equal('Unparsable Filters:\nThe following filter-value pairs could not be parsed: [detectors[operator]=or, tags[operation]=or]'); - expect(unknownFilterWarningText).to.equal('Unknown Filters:\nThe filters: [\'detecttors\', \'tagss\']; are not reccognised. Check if they are spelled correctly.'); - }); - - it('Should show warning if an unparsable filter url is pasted', async () => { - const url = 'unparsable url'; - await goToPage(page, 'log-overview'); - - await page.evaluate(async (url) => await navigator.clipboard.writeText(url), url); - await pressElement(page, '.dropdown #paste-filters', true); - - const warningText = await getInnerText(await page.waitForSelector('.alert-warning > ul')); - expect(warningText).to.equal('Unparseable URL:\nURL could not be parsed. URL: unparsable url'); - }); - - it('Should show warning if filter url is pasted on the wong page', async () => { - const url = 'http://localhost:4000/?page=lhc-period-overview&filter[names][]=name'; - await goToPage(page, 'log-overview'); - - await page.evaluate(async (url) => await navigator.clipboard.writeText(url), url); - await pressElement(page, '.dropdown #paste-filters', true); - - const warningText = await getInnerText(await page.waitForSelector('.alert-warning > ul')); - expect(warningText).to.equal('Page-Filter mismatch:\nThe filters provided were meant for lhc-period-overview'); - }); - - after(async () => { - await defaultAfter(page, browser); - }); -}; diff --git a/test/public/dataPasses/overviewPerLhcPeriod.test.js b/test/public/dataPasses/overviewPerLhcPeriod.test.js index a6215dd989..4ab08e8de0 100644 --- a/test/public/dataPasses/overviewPerLhcPeriod.test.js +++ b/test/public/dataPasses/overviewPerLhcPeriod.test.js @@ -164,7 +164,7 @@ module.exports = () => { it('should successfully apply data pass name filter', async () => { await pressElement(page, '#openFilterToggle'); - await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1'); + await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1', ['change']); await expectColumnValues(page, 'name', ['deleted\nLHC22b_apass1\nSkimmable']); diff --git a/test/public/dataPasses/overviewPerSimulationPass.test.js b/test/public/dataPasses/overviewPerSimulationPass.test.js index 188ec17dc2..27b6c2d2c9 100644 --- a/test/public/dataPasses/overviewPerSimulationPass.test.js +++ b/test/public/dataPasses/overviewPerSimulationPass.test.js @@ -113,7 +113,7 @@ module.exports = () => { it('should successfully apply data pass name filter', async () => { await pressElement(page, '#openFilterToggle'); - await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1'); + await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1', ['change']); await expectColumnValues(page, 'name', ['deleted\nLHC22b_apass1\nSkimmable']); await pressElement(page, '#reset-filters', true); diff --git a/test/public/defaults.js b/test/public/defaults.js index 9dac35f2bb..d841c4bc05 100644 --- a/test/public/defaults.js +++ b/test/public/defaults.js @@ -198,15 +198,14 @@ module.exports.waitForTableLength = waitForTableToLength; * Wait for the total number of elements to be the expected one * * @param {puppeteer.Page} page The puppeteer page where the table is located - * @param {number} amount the expected amount of items. If amount is 0 it is converted to undefined, as empty tables don't display a row count + * @param {number} amount the expected amount of items * @return {Promise} resolves once the expected amount is present */ module.exports.waitForTableTotalRowsCountToEqual = async (page, amount) => { try { - amount = amount === 0 ? undefined : `${amount}`; await page.waitForSelector('#totalRowsCount'); await page.waitForFunction( - (amount) => document.querySelector('#totalRowsCount')?.innerText === amount, + (amount) => document.querySelector('#totalRowsCount').innerText === `${amount}`, {}, amount, ); @@ -276,26 +275,12 @@ exports.waitForNavigation = waitForNavigation; * @returns {Promise} Whether the element was clickable or not. */ module.exports.pressElement = async (page, selector, jsClick = false) => { - await page.waitForFunction( - (sel, isJsClick) => { - const element = document.querySelector(sel); - - if (!element) { - return false; - } - // Moving the click to outside the function causes it to fail for unknown reasons - if (isJsClick) { - element.click(); - } + const elementHandler = await page.waitForSelector(selector); - return true; - }, - {}, - selector, jsClick - ); - - if (!jsClick) { - await page.click(selector); + if (jsClick) { + await elementHandler.evaluate((element) => element.click()); + } else { + await elementHandler.click(selector); } }; @@ -668,24 +653,14 @@ module.exports.checkColumnBalloon = async (page, rowIndex, columnIndex) => { * @return {Promise} resolves once the value has been typed */ module.exports.fillInput = async (page, inputSelector, value, events = ['input']) => { - await page.waitForFunction((inputSelector, value, events) => { + await page.waitForSelector(inputSelector); + await page.evaluate((inputSelector, value, events) => { const element = document.querySelector(inputSelector); - - if (!element) { - return false; - } - element.value = value; - for (const eventKey of events) { element.dispatchEvent(new Event(eventKey, { bubbles: true })); } - - return true; - }, - {}, - inputSelector, value, events - ); + }, inputSelector, value, events); }; /** @@ -880,10 +855,10 @@ module.exports.testTableSortingByColumn = async (page, columnId) => { * @return {Promise} resolve once data was successfully validated */ module.exports.validateTableData = async (page, validators) => { + await page.waitForSelector('table tbody'); for (const [columnId, validator] of validators) { - await page.waitForSelector(`table tbody .column-${columnId}`); - const columnData = await getColumnCellsInnerTexts(page, columnId); + expect(columnData, `Too few values for column ${columnId} or there is no such column`).to.be.length.greaterThan(0); expect( columnData.every((cellData) => validator(cellData)), `Invalid data in column ${columnId}: (${columnData})`, @@ -1002,14 +977,3 @@ module.exports.resetFilters = async (page) => { { timeout: 5000 }, ); }; - -/** - * Fuction that waits for a button to become active - * @param {puppeteer.page} page page handler - * @param {string} selector Css selector for the button. - */ -module.exports.waitForButtonToBecomeActive = async (page, selector) => await page.waitForFunction((sel) => { - const button = document.querySelector(sel); - return button && !button.disabled; - }, {}, selector); - diff --git a/test/public/index.js b/test/public/index.js index 293d9a9e94..8ebdc23e68 100644 --- a/test/public/index.js +++ b/test/public/index.js @@ -27,11 +27,9 @@ const ComponentsSuite = require('./components'); const SimulationPassesSuite = require('./simulationPasses'); const QcFlagTypesSuite = require('./qcFlagTypes'); const QcFlagsSuite = require('./qcFlags'); -const FilterSuite = require('./Filters'); module.exports = () => { describe('Components', ComponentsSuite); - describe('Filters', FilterSuite); describe('LhcPeriods', LhcPeriodsSuite); describe('LhcFills', LhcFillsSuite); describe('Logs', LogsSuite); diff --git a/test/public/logs/overview.test.js b/test/public/logs/overview.test.js index f2b0d14f89..39119d7ef1 100644 --- a/test/public/logs/overview.test.js +++ b/test/public/logs/overview.test.js @@ -34,9 +34,6 @@ const { waitForEmptyTable, waitForTableTotalRowsCountToEqual, waitForTableFirstRowIndexToEqual, - resetFilters, - getPeriodInputsSelectors, - openFilteringPanel, } = require('../defaults.js'); const { resetDatabaseContent } = require('../../utilities/resetDatabaseContent.js'); @@ -91,8 +88,184 @@ module.exports = () => { await checkColumnBalloon(page, 1, 5); }); + it('can filter by log title', async () => { + await waitForTableLength(page, 10); + + await pressElement(page, '#openFilterToggle'); + await page.waitForSelector('#titleFilterText'); + + await fillInput(page, '#titleFilterText', 'first'); + await waitForTableLength(page, 1); + + await fillInput(page, '#titleFilterText', 'bogusbogusbogus'); + await waitForEmptyTable(page); + + await pressElement(page, '#reset-filters'); + }); + + it('should successfully provide an input to filter on log content', async () => { + await waitForTableLength(page, 10); + + await fillInput(page, '#contentFilterText', 'particle'); + await waitForTableLength(page, 2); + + await fillInput(page, '#titleFilterText', 'this-content-do-not-exists-anywhere'); + await waitForEmptyTable(page); + + await pressElement(page, '#reset-filters'); + }); + + it('can filter by log author', async () => { + await waitForTableLength(page, 10); + + await fillInput(page, '#authorFilterText', 'Jane'); + await waitForEmptyTable(page); + + await pressElement(page, '#reset-filters'); + + await waitForTableLength(page, 10); + + await fillInput(page, '#authorFilterText', 'John'); + await waitForTableLength(page, 5); + + await pressElement(page, '#reset-filters'); + }); + + it('should successfully provide an easy-to-access button to filter in/out anonymous logs', async () => { + // Close the filter panel + await pressElement(page, '#openFilterToggle'); + await waitForTableTotalRowsCountToEqual(page, 119); + + const authors = await getColumnCellsInnerTexts(page, 'author'); + expect(authors.some((author) => author === 'Anonymous')).to.be.true; + + await pressElement(page, '#main-action-bar > div:nth-child(1) .switch'); + await waitForTableTotalRowsCountToEqual(page, 117); + + await checkColumnValuesWithRegex(page, 'author', '^Anonymous$', { + negation: true, + }); + + await pressElement(page, '#main-action-bar > div:nth-child(1) .switch'); + await waitForTableTotalRowsCountToEqual(page, 119); + await checkColumnValuesWithRegex(page, 'author', '^Anonymous$', { + valuesCheckingMode: 'some', + }); + }); + + it('can filter by creation date', async () => { + await pressElement(page, '#openFilterToggle'); + + await waitForTableTotalRowsCountToEqual(page, 119); + + // Insert a minimum date into the filter + const limit = '2020-02-02'; + await fillInput(page, '#createdFilterFrom', limit); + await fillInput(page, '#createdFilterTo', limit); + await waitForTableLength(page, 1); + + await pressElement(page, '#reset-filters'); + }); + + it('can filter by tags', async () => { + await waitForTableTotalRowsCountToEqual(page, 119); + + await pressElement(page, '.tags-filter .dropdown-trigger'); + + // Select the second available filter and wait for the changes to be processed + const firstCheckboxId = 'tag-dropdown-option-DPG'; + await pressElement(page, `#${firstCheckboxId}`, true); + await waitForTableLength(page, 1); + + // Deselect the filter and wait for the changes to process + await pressElement(page, `#${firstCheckboxId}`, true); + await waitForTableLength(page, 10); + + // Select the first available filter and the second one at once + const secondCheckboxId = 'tag-dropdown-option-FOOD'; + await pressElement(page, `#${firstCheckboxId}`, true); + await pressElement(page, `#${secondCheckboxId}`, true); + await waitForEmptyTable(page); + + // Set the filter operation to "OR" + await pressElement(page, '#tag-filter-combination-operator-radio-button-or', true); + await waitForTableLength(page, 3); + + await pressElement(page, '#reset-filters'); + }); + + it('can filter by environments', async () => { + await waitForTableLength(page, 10); + + await fillInput(page, '.environments-filter input', '8E4aZTjY'); + await waitForTableLength(page, 3); + + await pressElement(page, '#reset-filters'); + await waitForTableLength(page, 10); + + await fillInput(page, '.environments-filter input', 'abcdefgh'); + await waitForEmptyTable(page); + + await pressElement(page, '#reset-filters'); + }); + + it('can search for tag in the dropdown', async () => { + await pressElement(page, '.tags-filter .dropdown-trigger'); + + { + await fillInput(page, '#tag-dropdown-search-input', 'food'); + const popoverTrigger = await page.$('.tags-filter .popover-trigger'); + const popoverSelector = await getPopoverSelector(popoverTrigger); + await page.waitForSelector(`${popoverSelector} .dropdown-option:nth-child(2)`, { hidden: true }); + const options = await page.$$(`${popoverSelector} .dropdown-option`); + expect(await options[0].evaluate((option) => option.innerText)).to.equal('FOOD'); + } + { + await fillInput(page, '#tag-dropdown-search-input', 'fOoD'); + const popoverTrigger = await page.$('.tags-filter .popover-trigger'); + const popoverSelector = await getPopoverSelector(popoverTrigger); + await page.waitForSelector(`${popoverSelector} .dropdown-option:nth-child(2)`, { hidden: true }); + const options = await page.$$(`${popoverSelector} .dropdown-option`); + expect(await options[0].evaluate((option) => option.innerText)).to.equal('FOOD'); + } + }); + + it('can filter by run number', async () => { + await waitForTableLength(page, 10); + + // Insert some text into the filter + await fillInput(page, '#runsFilterText', '1, 2'); + await waitForTableLength(page, 2); + + await pressElement(page, '#reset-filters'); + await waitForTableLength(page, 10); + + await fillInput(page, '#runsFilterText', '1234567890'); + await waitForEmptyTable(page); + + await pressElement(page, '#reset-filters'); + }); + + it('can filter by lhc fill number', async () => { + await waitForTableLength(page, 10); + + await fillInput(page, '#lhcFillsFilter', '1, 6'); + await waitForTableLength(page, 1); + + await pressElement(page, '#reset-filters'); + await waitForTableLength(page, 10); + + await fillInput(page, '#lhcFillsFilter', '1234567890'); + await waitForEmptyTable(page); + + await pressElement(page, '#reset-filters'); + }); + it('can sort by columns in ascending and descending manners', async () => { + await waitForTableLength(page, 10); + // Close the filter panel + await pressElement(page, '#openFilterToggle'); await waitForFirstRowToHaveId(page, 'row119'); await page.waitForSelector('th#title'); @@ -352,158 +525,4 @@ module.exports = () => { await waitForNavigation(page, () => pressElement(page, `${popoverSelector} a`)) expectUrlParams(page, { page: 'run-detail', runNumber: 2 }) }); - - describe('Filters', () => { - before(async () => { - await goToPage(page, 'log-overview'); - }) - - beforeEach(async () => { - await resetFilters(page); - await waitForTableLength(page, 10); - }) - - it('can filter by log title', async () => { - await fillInput(page, '.title-textFilter', 'first', ['change']); - await waitForTableLength(page, 1); - - await fillInput(page, '.title-textFilter', 'bogusbogusbogus', ['change']); - await waitForEmptyTable(page); - }); - - it('can filter by log author', async () => { - await fillInput(page, '#authorFilterText', 'Jane', ['change']); - await waitForEmptyTable(page); - - await resetFilters(page); - - await waitForTableLength(page, 10); - - await fillInput(page, '#authorFilterText', 'John', ['change']); - await waitForTableLength(page, 5); - }); - - it('should successfully provide an input to filter on log content', async () => { - await fillInput(page, '.content-textFilter', 'particle', ['change']); - await waitForTableLength(page, 2); - - await fillInput(page, '.title-textFilter', 'this-content-do-not-exists-anywhere', ['change']); - await waitForEmptyTable(page); - }); - - it('should successfully provide an easy-to-access button to filter in/out anonymous logs', async () => { - await waitForTableTotalRowsCountToEqual(page, 119); - const authors = await getColumnCellsInnerTexts(page, 'author'); - - expect(authors.some((author) => author === 'Anonymous')).to.be.true; - - await pressElement(page, '#main-action-bar > div:nth-child(1) .switch'); - await waitForTableTotalRowsCountToEqual(page, 117); - await checkColumnValuesWithRegex(page, 'author', '^Anonymous$', { - negation: true, - }); - - await pressElement(page, '#main-action-bar > div:nth-child(1) .switch'); - await waitForTableTotalRowsCountToEqual(page, 119); - await checkColumnValuesWithRegex(page, 'author', '^Anonymous$', { - valuesCheckingMode: 'some', - }); - }); - - it('can filter by creation date', async () => { - const popoverTrigger = '.createdAt-filter .popover-trigger'; - const popOverSelector = await getPopoverSelector(await page.$(popoverTrigger)); - - await waitForTableTotalRowsCountToEqual(page, 119); - - const { fromDateSelector, toDateSelector, fromTimeSelector, toTimeSelector } = getPeriodInputsSelectors(popOverSelector); - - const limit = '2020-02-02'; - - await fillInput(page, fromDateSelector, limit, ['change']); - await fillInput(page, toDateSelector, limit, ['change']); - await fillInput(page, fromTimeSelector, '11:00', ['change']); - await fillInput(page, toTimeSelector, '12:00', ['change']); - - await waitForTableLength(page, 1); - }); - - it('can filter by tags', async () => { - await openFilteringPanel(page); - await pressElement(page, '.tags-filter .dropdown-trigger'); - - // Select the second available filter and wait for the changes to be processed - const firstCheckboxId = 'tag-dropdown-option-DPG'; - await pressElement(page, `#${firstCheckboxId}`, true); - await waitForTableLength(page, 1); - - // Deselect the filter and wait for the changes to process - await pressElement(page, `#${firstCheckboxId}`, true); - await waitForTableLength(page, 10); - - // Select the first available filter and the second one at once - const secondCheckboxId = 'tag-dropdown-option-FOOD'; - await pressElement(page, `#${firstCheckboxId}`, true); - await pressElement(page, `#${secondCheckboxId}`, true); - await waitForEmptyTable(page); - - // Set the filter operation to "OR" - await pressElement(page, '#tag-filter-combination-operator-radio-button-or', true); - await waitForTableLength(page, 3); - }); - - it('can filter by environments', async () => { - await fillInput(page, '.environments-filter input', '8E4aZTjY', ['change']); - await waitForTableLength(page, 3); - await resetFilters(page); - await waitForTableLength(page, 10); - - await fillInput(page, '.environments-filter input', 'abcdefgh', ['change']); - await waitForEmptyTable(page); - }); - - it('can search for tag in the dropdown', async () => { - await pressElement(page, '.tags-filter .dropdown-trigger'); - - { - await fillInput(page, '#tag-dropdown-search-input', 'food'); - const popoverTrigger = await page.$('.tags-filter .popover-trigger'); - const popoverSelector = await getPopoverSelector(popoverTrigger); - await page.waitForSelector(`${popoverSelector} .dropdown-option:nth-child(2)`, { hidden: true }); - const options = await page.$$(`${popoverSelector} .dropdown-option`); - expect(await options[0].evaluate((option) => option.innerText)).to.equal('FOOD'); - } - { - await fillInput(page, '#tag-dropdown-search-input', 'fOoD'); - const popoverTrigger = await page.$('.tags-filter .popover-trigger'); - const popoverSelector = await getPopoverSelector(popoverTrigger); - await page.waitForSelector(`${popoverSelector} .dropdown-option:nth-child(2)`, { hidden: true }); - const options = await page.$$(`${popoverSelector} .dropdown-option`); - expect(await options[0].evaluate((option) => option.innerText)).to.equal('FOOD'); - } - }); - - it('can filter by run number', async () => { - // Insert some text into the filter - await fillInput(page, '.runNumbers-textFilter', '1, 2', ['change']); - await waitForTableLength(page, 2); - await resetFilters(page); - - await waitForTableLength(page, 10); - - await fillInput(page, '.runNumbers-textFilter', '1234567890', ['change']); - await waitForEmptyTable(page); - }); - - it('can filter by lhc fill number', async () => { - await fillInput(page, '.fillNumbers-textFilter', '1, 6', ['change']); - await waitForTableLength(page, 1); - await resetFilters(page); - - await waitForTableLength(page, 10); - - await fillInput(page, '.fillNumbers-textFilter', '1234567890', ['change']); - await waitForEmptyTable(page); - }); - }) }; diff --git a/test/public/qcFlagTypes/overview.test.js b/test/public/qcFlagTypes/overview.test.js index 77b4fe656b..0bf4d519cc 100644 --- a/test/public/qcFlagTypes/overview.test.js +++ b/test/public/qcFlagTypes/overview.test.js @@ -112,7 +112,7 @@ module.exports = () => { it('should successfully apply QC flag type bad filter', async () => { await waitForTableLength(page, 7); - await pressElement(page, '#badFilterRadioBad', true); + await pressElement(page, '.bad-filter input[type=checkbox]', true); await checkColumnValuesWithRegex(page, 'bad', '^Yes$'); await pressElement(page, '#reset-filters', true); diff --git a/test/public/qcFlags/synchronousOverview.test.js b/test/public/qcFlags/synchronousOverview.test.js index 16c2900904..e72c4eca91 100644 --- a/test/public/qcFlags/synchronousOverview.test.js +++ b/test/public/qcFlags/synchronousOverview.test.js @@ -22,7 +22,6 @@ const { expectUrlParams, waitForNavigation, getColumnCellsInnerTexts, - getPopoverContent, } = require('../defaults.js'); const { expect } = chai; @@ -60,21 +59,14 @@ module.exports = () => { it('shows correct datatypes in respective columns', async () => { // eslint-disable-next-line require-jsdoc - const validateDate = (date) => date === '-' || !isNaN(dateAndTime.parse(date, 'DD/MM/YYYY, hh:mm:ss')); + const validateDate = (date) => date === '-' || !isNaN(dateAndTime.parse(date, 'DD/MM/YYYY hh:mm:ss')); const tableDataValidators = { flagType: (flagType) => flagType && flagType !== '-', - from: (cellContent) => { - const match = cellContent.match(/^From:\s*(.+)\nTo:\s*(.+)$/); - if (!match) return false; - const [, from, to] = match; - return (['Whole run coverage', 'Since run start'].includes(from) || validateDate(from)) - && (['Whole run coverage', 'Until run end'].includes(to) || validateDate(to)); - }, - deleted: (value) => value === 'Yes' || value === 'No', - createdBy: (cellContent) => { - const match = cellContent.match(/^By:\s*(.+)\nAt:\s*(.+)$/); - return match && match[1] !== '-' && validateDate(match[2]); - }, + createdBy: (userName) => userName && userName !== '-', + from: (timestamp) => timestamp === 'Whole run coverage' || timestamp === 'Since run start' || validateDate(timestamp), + to: (timestamp) => timestamp === 'Whole run coverage' || timestamp === 'Until run end' || validateDate(timestamp), + createdAt: validateDate, + updatedAt: validateDate, }; await validateTableData(page, new Map(Object.entries(tableDataValidators))); @@ -84,34 +76,8 @@ module.exports = () => { it('Should display the correct items counter at the bottom of the page', async () => { await expectInnerText(page, '#firstRowIndex', '1'); - await expectInnerText(page, '#lastRowIndex', '3'); - await expectInnerText(page, '#totalRowsCount', '3'); - }); - - it('should display Comment tooltip with full information', async () => { - let popoverTrigger = await page.$(`#row100-comment .popover-trigger`); - expect(popoverTrigger).to.not.be.null; - - const popoverContent = await getPopoverContent(popoverTrigger); - expect(popoverContent).to.equal('first part good'); - }); - - it('should display CreatedBy tooltip with full information', async () => { - let popoverTrigger = await page.$(`#row100-createdBy .popover-trigger`); - expect(popoverTrigger).to.not.be.null; - - const popoverContent = await getPopoverContent(popoverTrigger); - expect(popoverContent).to.equal('By: Jan JansenAt: 12/08/2024, 12:00:00'); - }); - - it('should display correct Deleted text colour', async () => { - const deletedCell = await page.$('#row103-deleted-text:nth-child(1)'); - - const deletedCellText = await page.evaluate(cell => cell.textContent.trim(), deletedCell); - expect(deletedCellText).to.equal('Yes'); - - const deletedCellFirstChildClass = await page.evaluate(cell => cell.firstElementChild.className, deletedCell); - expect(deletedCellFirstChildClass).to.include('danger'); + await expectInnerText(page, '#lastRowIndex', '2'); + await expectInnerText(page, '#totalRowsCount', '2'); }); it('can navigate to run details page from breadcrumbs link', async () => { diff --git a/test/public/runs/detail.test.js b/test/public/runs/detail.test.js index 515f36d8d8..fa94143746 100644 --- a/test/public/runs/detail.test.js +++ b/test/public/runs/detail.test.js @@ -54,7 +54,7 @@ const banIconPath = */ const goToRunDetails = async (page, runNumber) => { await waitForNavigation(page, () => pressElement(page, '#run-overview')); - await fillInput(page, '.runNumbers-textFilter', `${runNumber},${runNumber}`, ['change']); + await fillInput(page, '.run-numbers-filter', `${runNumber},${runNumber}`, ['change']); await waitForTableLength(page, 1); return waitForNavigation(page, () => pressElement(page, `a[href="?page=run-detail&runNumber=${runNumber}"]`)); }; @@ -208,10 +208,10 @@ module.exports = () => { expect(eorReasons).to.lengthOf(2); expect(await eorReasons[0].evaluate((element) => element.innerText)) - .to.equal('DETECTORS - TPC - Some Reason other than selected plus one\nAnonymous'); + .to.equal('DETECTORS - TPC - Some Reason other than selected plus one'); expect(await eorReasons[1].evaluate((element) => element.innerText)) - .to.equal('DETECTORS - CPV - A new EOR reason\nAnonymous'); + .to.equal('DETECTORS - CPV - A new EOR reason'); }); it('should successfully revert the update end of run reasons', async () => { @@ -234,19 +234,10 @@ module.exports = () => { expect(eorReasons).to.lengthOf(2); expect(await eorReasons[0].evaluate((element) => element.innerText)) - .to.equal('DETECTORS - TPC - Some Reason other than selected plus one\nAnonymous'); + .to.equal('DETECTORS - TPC - Some Reason other than selected plus one'); expect(await eorReasons[1].evaluate((element) => element.innerText)) - .to.equal('DETECTORS - CPV - A new EOR reason\nAnonymous'); - }); - - it('should display lastEditedName tooltip with "Last edited by" on formatRunEorReason', async () => { - const eorReasonElement = await page.$('#eor-reasons .eor-reason'); - const popoverTrigger = await eorReasonElement.$('.popover-trigger'); - expect(popoverTrigger).to.not.be.null; - - const popoverContent = await getPopoverContent(popoverTrigger); - expect(popoverContent).to.equal('Last edited by'); + .to.equal('DETECTORS - CPV - A new EOR reason'); }); it('should successfully update inelasticInteractionRate values of PbPb run', async () => { diff --git a/test/public/runs/overview.test.js b/test/public/runs/overview.test.js index 66333186d8..807b821ffc 100644 --- a/test/public/runs/overview.test.js +++ b/test/public/runs/overview.test.js @@ -40,7 +40,6 @@ const { getColumnCellsInnerTexts, resetFilters, openFilteringPanel, - waitForButtonToBecomeActive, } = require('../defaults.js'); const { RUN_QUALITIES, RunQualities } = require('../../../lib/domain/enums/RunQualities.js'); const { resetDatabaseContent } = require('../../utilities/resetDatabaseContent.js'); @@ -601,7 +600,7 @@ module.exports = () => { it('Should successfully filter runs by their trigger value', async () => { await navigateToRunsOverview(page); - const filterInputSelectorPrefix = '#triggerValue-checkbox-'; + const filterInputSelectorPrefix = '#triggerValueCheckbox'; const offFilterSelector = `${filterInputSelectorPrefix}OFF`; const ltuFilterSelector = `${filterInputSelectorPrefix}LTU`; @@ -671,7 +670,7 @@ module.exports = () => { }; // First filter validation on the main page. - await filterOnRun('#runOverviewFilter .runNumbers-textFilter'); + await filterOnRun('#runOverviewFilter .run-numbers-filter'); // Validate if the filter tab value is equal to the main page value. await expectInputValue(page, filterPanelRunNumbersInputSelector, inputValue); await resetFilters(page); @@ -698,7 +697,7 @@ module.exports = () => { await expectColumnValues(page, 'runNumber', ['10']); }; - await filterOnRun('#runOverviewFilter .runNumbers-textFilter'); + await filterOnRun('#runOverviewFilter .run-numbers-filter'); await expectInputValue(page, filterPanelRunNumbersInputSelector, inputValue); await resetFilters(page); await filterOnRun(filterPanelRunNumbersInputSelector); @@ -706,7 +705,7 @@ module.exports = () => { it('should successfully filter on a list of fill numbers and inform the user about it', async () => { await page.evaluate(() => window.model.disableInputDebounce()); - const filterInputSelector = '.fillNumbers-textFilter'; + const filterInputSelector = '.fill-numbers-filter'; expect(await page.$eval(filterInputSelector, (input) => input.placeholder)).to.equal('e.g. 7966, 7954, 7948...'); await fillInput(page, filterInputSelector, '1, 3', ['change']); @@ -714,7 +713,7 @@ module.exports = () => { }); it('should successfully filter on a list of environment ids and inform the user about it', async () => { - const filterInputSelector = '.environmentIds-textFilter'; + const filterInputSelector = '.environment-ids-filter'; expect(await page.$eval(filterInputSelector, (input) => input.placeholder)).to.equal('e.g. Dxi029djX, TDI59So3d...'); await fillInput(page, filterInputSelector, 'Dxi029djX, TDI59So3d', ['change']); @@ -886,7 +885,6 @@ module.exports = () => { let exportModal = await page.$('#export-data-modal'); expect(exportModal).to.be.null; - await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); await page.waitForSelector('#export-data-modal', { timeout: 5000 }); exportModal = await page.$('#export-data-modal'); @@ -895,7 +893,6 @@ module.exports = () => { }); it('should successfully display information when export will be truncated', async () => { - await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR, true); const truncatedExportWarning = await page.waitForSelector('#export-data-modal #truncated-export-warning'); @@ -915,7 +912,6 @@ module.exports = () => { }); it('should successfully export filtered runs', async () => { - await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); const targetFileName = 'data.json'; // First export @@ -954,9 +950,9 @@ module.exports = () => { await page.waitForSelector(badFilterSelector); await page.$eval(badFilterSelector, (element) => element.click()); await page.waitForSelector('tbody tr:nth-child(2)'); + await page.waitForSelector(EXPORT_RUNS_TRIGGER_SELECTOR); ///// Download - await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); await page.waitForSelector('#export-data-modal', { timeout: 5000 }); diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js index 4d1edbb4d6..f45d004e55 100644 --- a/test/public/runs/runsPerDataPass.overview.test.js +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -152,17 +152,6 @@ module.exports = () => { .to.be.equal('Missing 3 verifications'); }); - it('should display detector columns in RCT order (AOT/MUON after physical)', async () => { - const headers = await page.$$eval( - 'table thead th', - (ths) => ths.map((th) => th.id).filter(Boolean), - ); - - // See DetectorOrders.RCT in detectorOrders.js - expect(headers.indexOf('VTX')).to.be.greaterThan(headers.indexOf('ZDC')); - expect(headers.indexOf('MUD')).to.be.greaterThan(headers.indexOf('ZDC')); - }); - it('should ignore QC flags created by services in QC summaries of AOT and MUON ', async () => { await navigateToRunsPerDataPass(page, 2, 1, 3); // apass await expectInnerText(page, '#row106-VTX-text', '100'); @@ -405,10 +394,10 @@ module.exports = () => { const exportContent = fs.readFileSync(path.resolve(downloadPath, targetFileName)).toString(); expect(exportContent.trim()).to.be.eql([ - 'runNumber;CPV;VTX', + 'runNumber;VTX;CPV', '108;"";""', - '107;"Limited Acceptance MC Reproducible (from: 1565269140000 to: 1565290800000) | Good (from: 1565290800000 to: 1565359260000)";""', - '106;"Limited Acceptance MC Reproducible (from: 1565304200000 to: 1565324200000) | Limited acceptance (from: 1565329200000 to: 1565334200000) | Bad (from: 1565339200000 to: 1565344200000)";"Good (from: 1565269200000 to: 1565304200000) | Good (from: 1565324200000 to: 1565359200000)"', + '107;"";"Good (from: 1565290800000 to: 1565359260000) | Limited Acceptance MC Reproducible (from: 1565269140000 to: 1565290800000)"', + '106;"Good (from: 1565269200000 to: 1565304200000) | Good (from: 1565324200000 to: 1565359200000)";"Limited Acceptance MC Reproducible (from: 1565304200000 to: 1565324200000) | Limited acceptance (from: 1565329200000 to: 1565334200000) | Bad (from: 1565339200000 to: 1565344200000)"', ].join('\r\n')); fs.unlinkSync(path.resolve(downloadPath, targetFileName)); }); @@ -423,6 +412,7 @@ module.exports = () => { await waitForTableLength(page, 2); await expectColumnValues(page, 'runNumber', ['108', '107']); + await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters'); await waitForTableLength(page, 3); await expectColumnValues(page, 'runNumber', ['108', '107', '106']); @@ -437,6 +427,7 @@ module.exports = () => { await pressElement(page, '#detector-filter-dropdown-option-CPV', true); await expectColumnValues(page, 'runNumber', ['2', '1']); + await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters'); await expectColumnValues(page, 'runNumber', ['55', '2', '1']); }); @@ -452,6 +443,8 @@ module.exports = () => { await expectColumnValues(page, 'runNumber', ['106']); + await page.waitForSelector('#openFilterToggle'); + await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters'); await expectColumnValues(page, 'runNumber', ['108', '107', '106']); }); @@ -473,6 +466,7 @@ module.exports = () => { await expectColumnValues(page, 'runNumber', ['55', '1']); + await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters'); await expectColumnValues(page, 'runNumber', ['55', '2', '1']); }); @@ -486,6 +480,7 @@ module.exports = () => { await expectColumnValues(page, 'runNumber', ['54']); + await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters'); await expectColumnValues(page, 'runNumber', ['105', '56', '54', '49']); }); @@ -508,6 +503,7 @@ module.exports = () => { await fillInput(page, `#${property}-operand`, value, ['change']); await expectColumnValues(page, 'runNumber', expectedRuns); + await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters', true); await expectColumnValues(page, 'runNumber', ['105', '56', '54', '49']); }); @@ -516,6 +512,8 @@ module.exports = () => { it('should successfully apply gaqNotBadFraction filters', async () => { await navigateToRunsPerDataPass(page, 2, 1, 3); + await pressElement(page, '#openFilterToggle', true); + await page.waitForSelector('#gaqNotBadFraction-operator'); await page.select('#gaqNotBadFraction-operator', '<='); await fillInput(page, '#gaqNotBadFraction-operand', '80', ['change']); @@ -524,6 +522,7 @@ module.exports = () => { await pressElement(page, '#mcReproducibleAsNotBadToggle input', true); await expectColumnValues(page, 'runNumber', []); + await pressElement(page, '#openFilterToggle', true); await pressElement(page, '#reset-filters', true); await expectColumnValues(page, 'runNumber', ['108', '107', '106']); }); @@ -532,8 +531,12 @@ module.exports = () => { await page.waitForSelector('#detectorsQc-for-1-notBadFraction-operator'); await page.select('#detectorsQc-for-1-notBadFraction-operator', '<='); await fillInput(page, '#detectorsQc-for-1-notBadFraction-operand', '90', ['change']); + await expectColumnValues(page, 'runNumber', ['106']); + + await pressElement(page, '#mcReproducibleAsNotBadToggle input', true); await expectColumnValues(page, 'runNumber', ['107', '106']); + await pressElement(page, '#openFilterToggle', true); await pressElement(page, '#reset-filters', true); await expectColumnValues(page, 'runNumber', ['108', '107', '106']); }); @@ -547,6 +550,7 @@ module.exports = () => { await fillInput(page, '#muInelasticInteractionRate-operand', 0.03, ['change']); await expectColumnValues(page, 'runNumber', ['106']); + await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters', true); await expectColumnValues(page, 'runNumber', ['108', '107', '106']); }); @@ -605,6 +609,7 @@ module.exports = () => { it('should successfully disable QC flag creation when data pass is frozen', async () => { await waitForTableLength(page, 3); await page.waitForSelector('.select-multi-flag', { hidden: true }); + await pressElement(page, '#actions-dropdown-button .popover-trigger'); await page.waitForSelector('#set-qc-flags-trigger[disabled]'); await page.waitForSelector('#row107-ACO-text button[disabled]'); }); @@ -618,10 +623,16 @@ module.exports = () => { it('should successfully enable QC flag creation when data pass is un-frozen', async () => { await waitForTableLength(page, 3); - await page.waitForSelector('#set-qc-flags-trigger[disabled]'); + await pressElement(page, '.select-multi-flag'); + await pressElement(page, '#actions-dropdown-button .popover-trigger'); + await page.waitForSelector('#set-qc-flags-trigger[disabled]', { hidden: true }); await page.waitForSelector('#set-qc-flags-trigger'); await page.waitForSelector('#row107-ACO-text a'); }); + + after(async () => { + await pressElement(page, '#actions-dropdown-button .popover-trigger', true); + }); }); it('should successfully not display button to discard all QC flags for the data pass', async () => { @@ -643,8 +654,8 @@ module.exports = () => { // Press again actions dropdown to re-trigger render await pressElement(page, '#actions-dropdown-button .popover-trigger', true); setConfirmationDialogToBeAccepted(page); - const oldTable = await page.waitForSelector('table').then((table) => table.evaluate((t) => t.innerHTML)); await pressElement(page, `${popoverSelector} button:nth-child(4)`, true); + const oldTable = await page.waitForSelector('table').then((table) => table.evaluate((t) => t.innerHTML)); await pressElement(page, '#actions-dropdown-button .popover-trigger', true); await waitForTableLength(page, 3, undefined, oldTable); // Processing of data might take a bit of time, but then expect QC flag button to be there diff --git a/test/public/runs/runsPerLhcPeriod.overview.test.js b/test/public/runs/runsPerLhcPeriod.overview.test.js index 77d1ec4a24..f38dc635a9 100644 --- a/test/public/runs/runsPerLhcPeriod.overview.test.js +++ b/test/public/runs/runsPerLhcPeriod.overview.test.js @@ -32,7 +32,6 @@ const { expectColumnValues, openFilteringPanel, resetFilters, - waitForButtonToBecomeActive } = require('../defaults.js'); const { RUN_QUALITIES, RunQualities } = require('../../../lib/domain/enums/RunQualities.js'); const { resetDatabaseContent } = require('../../utilities/resetDatabaseContent.js'); @@ -76,7 +75,6 @@ module.exports = () => { after(async () => { [page, browser] = await defaultAfter(page, browser); }); - const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; it('loads the page successfully', async () => { const response = await goToPage(page, 'runs-per-lhc-period', { queryParameters: { lhcPeriodId: 1 } }); @@ -132,17 +130,6 @@ module.exports = () => { await expectInnerText(page, '#row56-FT0', '83'); }); - it('should display detector columns in RCT order (AOT/MUON after physical) for synchronous flags', async () => { - // Note test starts already on synchronous flags tab - const headers = await page.$$eval( - 'table thead th', - (ths) => ths.map((th) => th.id).filter(Boolean), - ); - - // See DetectorOrders.RCT in detectorOrders.js - expect(headers.indexOf('MUD')).to.be.greaterThan(headers.indexOf('ZDC')); - }); - it('should successfully sort by runNumber in ascending and descending manners', async () => { await testTableSortingByColumn(page, 'runNumber'); }); @@ -201,19 +188,25 @@ module.exports = () => { // Revert changes for next test await page.evaluate(() => { // eslint-disable-next-line no-undef - model.runs.perLhcPeriodOverviewModel.pagination.itemsPerPage = 2; + model.runs.perLhcPeriodOverviewModel.pagination.itemsPerPage = 10; }); - await waitForTableLength(page, 2); + await waitForTableLength(page, 4); }); + const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; it('should successfully export all runs per lhc Period', async () => { + await page.evaluate(() => { + // eslint-disable-next-line no-undef + model.runs.perLhcPeriodOverviewModel.pagination.itemsPerPage = 2; + }); + const targetFileName = 'data.json'; - await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); + // First export await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR, true); - await page.waitForSelector('select.form-control'); - await page.waitForSelector('option[value=runNumber]'); + await page.waitForSelector('select.form-control', { timeout: 200 }); + await page.waitForSelector('option[value=runNumber]', { timeout: 200 }); await page.select('select.form-control', 'runQuality', 'runNumber', 'definition', 'lhcPeriod'); await expectInnerText(page, '#send:enabled', 'Export'); @@ -282,9 +275,9 @@ module.exports = () => { await navigateToRunsPerLhcPeriod(page, 1, 4); const targetFileName = 'data.csv'; - await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); + // Export - await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR); + await pressElement(page, '#export-data-trigger'); await page.waitForSelector('#export-data-modal'); await page.waitForSelector('#send:disabled'); await page.waitForSelector('.form-control'); diff --git a/test/public/runs/runsPerSimulationPass.overview.test.js b/test/public/runs/runsPerSimulationPass.overview.test.js index f3c2d47316..b7b1c725fd 100644 --- a/test/public/runs/runsPerSimulationPass.overview.test.js +++ b/test/public/runs/runsPerSimulationPass.overview.test.js @@ -31,7 +31,6 @@ const { testTableSortingByColumn, waitForTableLength, expectColumnValues, - waitForButtonToBecomeActive, } = require('../defaults.js'); const { expect } = chai; @@ -75,8 +74,6 @@ module.exports = () => { [page, browser] = await defaultAfter(page, browser); }); - const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; - it('loads the page successfully', async () => { const response = await goToPage(page, 'runs-per-simulation-pass', { queryParameters: { simulationPassId: 2 } }); @@ -140,17 +137,6 @@ module.exports = () => { await qcFlagService.delete(tmpQcFlag.id); }); - it('should display detector columns in RCT order (AOT/MUON after physical)', async () => { - const headers = await page.$$eval( - 'table thead th', - (ths) => ths.map((th) => th.id).filter(Boolean), - ); - - // See DetectorOrders.RCT in detectorOrders.js - expect(headers.indexOf('VTX')).to.be.greaterThan(headers.indexOf('ZDC')); - expect(headers.indexOf('MUD')).to.be.greaterThan(headers.indexOf('ZDC')); - }); - it('should successfully sort by runNumber in ascending and descending manners', async () => { await testTableSortingByColumn(page, 'runNumber'); }); @@ -217,6 +203,7 @@ module.exports = () => { await fillInput(page, '#detectorsQc-for-1-notBadFraction-operand', '90', ['change']); await expectColumnValues(page, 'runNumber', ['106']); + await pressElement(page, '#openFilterToggle', true); await pressElement(page, '#reset-filters', true); await expectColumnValues(page, 'runNumber', ['107', '106', '105']); }); @@ -230,16 +217,18 @@ module.exports = () => { await fillInput(page, '#detectorsQc-for-1-notBadFraction-operand', '90', ['change']); await expectColumnValues(page, 'runNumber', ['106']); + await pressElement(page, '#openFilterToggle', true); await pressElement(page, '#reset-filters', true); await expectColumnValues(page, 'runNumber', ['107', '106', '105']); }); it('should successfully export runs', async () => { await navigateToRunsPerSimulationPass(page, 1, 2, 3); + const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; + const targetFileName = 'data.json'; // Export - await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR); await page.waitForSelector('#export-data-modal'); await page.waitForSelector('#send:disabled'); @@ -270,8 +259,7 @@ module.exports = () => { const targetFileName = 'data.csv'; // Export - await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); - await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR); + await pressElement(page, '#export-data-trigger'); await page.waitForSelector('#export-data-modal'); await page.waitForSelector('#send:disabled'); await page.waitForSelector('.form-control'); From cfbc5ac5bbc04569181b7736226ef7c61e8f1ce0 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:39:31 +0200 Subject: [PATCH 58/72] [O2B-1564] Preserve GAQ worker pause state across resetDatabaseContent resetDatabaseContent unconditionally called gaqWorker.resume(), which silently undid any pause set up by a calling test suite. Capture the worker's paused state before pausing and only resume it if it wasn't already paused on entry. Adds an isPaused getter on GaqWorker to expose the state without reaching into the private field. --- lib/server/services/gaq/GaqWorker.js | 8 ++++++++ test/utilities/resetDatabaseContent.js | 12 +++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/server/services/gaq/GaqWorker.js b/lib/server/services/gaq/GaqWorker.js index 5e4cbccf7c..a01a1a5abe 100644 --- a/lib/server/services/gaq/GaqWorker.js +++ b/lib/server/services/gaq/GaqWorker.js @@ -25,6 +25,14 @@ class GaqWorker { this._overflowConsecutiveTicks = 0; } + /** + * Whether the worker is currently paused + * @return {boolean} true if paused, false otherwise + */ + get isPaused() { + return this._isPaused; + } + /** * Pause the worker so it skips future scheduled calls, and await any in-flight call to finish * so callers can safely mutate shared state (e.g. drop tables in tests) once this resolves diff --git a/test/utilities/resetDatabaseContent.js b/test/utilities/resetDatabaseContent.js index 79d9996f7d..690b17c7b0 100644 --- a/test/utilities/resetDatabaseContent.js +++ b/test/utilities/resetDatabaseContent.js @@ -15,11 +15,17 @@ const { database } = require('../../lib/application.js'); const { gaqWorker } = require('../../lib/server/services/gaq/GaqWorker.js'); exports.resetDatabaseContent = async () => { - // Pause GAQ worker and await any in-flight call before dropping tables, otherwise a tick - // already past the guard would hit dropped tables and log a spurious ERROR + /* + * Pause GAQ worker and await any in-flight call before dropping tables, otherwise a tick + * already past the guard would hit dropped tables and log a spurious ERROR. Restore the + * prior paused state on the way out so callers that paused first stay paused. + */ + const wasPaused = gaqWorker.isPaused; await gaqWorker.pause(); await database.dropAllTables(); await database.migrate(); await database.seed(); - gaqWorker.resume(); + if (!wasPaused) { + gaqWorker.resume(); + } }; From e5749a04f99395aab05c87d615e65ffd5af0c5a0 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:51:10 +0200 Subject: [PATCH 59/72] [O2B-1565] Return null fields for unavailable GAQ summary rows getSummary previously ran arithmetic on raw summary rows without checking for null coverage values. Making "no data yet" look identical to a real 0% coverage. _formatSummary now short-circuits when badRunCoverage is null and returns a full RunGaqSummary shape with null coverage fields. The single-run branch is switched to findOne for a cleaner row-or-null result, and the JSDoc typedef gains the missing undefinedQualityPeriodsCount field. Adds tests covering: single-run, no row, notComputable, invalidated-never-computed, computed-then-invalidated (stale values preserved), data-pass isolation, empty map, full mixed-state map, and the mcReproducibleAsNotBad invariant. --- lib/server/services/gaq/GaqService.js | 25 +++- .../server/services/gaq/GaqService.test.js | 121 ++++++++++++++++++ 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index 3957f7f29f..16943a90b2 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -25,6 +25,7 @@ * @property {number} explicitlyNotBadEffectiveRunCoverage - fraction of run's data, which aggregated quality is explicitly good * @property {number} missingVerificationsCount - number of not verified QC flags which are not discarded * @property {boolean} mcReproducible - states whether some aggregation of QC flags is Limited Acceptance MC Reproducible + * @property {number} undefinedQualityPeriodsCount - number of GAQ periods with no contributing QC flag */ const { getOneDataPassOrFail } = require('../dataPasses/getOneDataPassOrFail.js'); @@ -53,7 +54,9 @@ class GaqService { * @param {boolean} [options.mcReproducibleAsNotBad = false] if set to true, * `Limited Acceptance MC Reproducible` flag type is treated as good one * @param {number} [options.runNumber] Optional run number to filter by - * @return {Promise|RunGaqSummary|null>} when runNumber is given, the run's summary or null if no row exists yet; otherwise a map of runNumber to summary + * @return {Promise|RunGaqSummary|null>} when runNumber is given, the run's summary or + * null if no row exists; otherwise a map of runNumber to summary. Rows with no coverage values yet (notComputable, + * or invalidated without ever having been computed) are returned with all coverage fields set to null. */ async getSummary(dataPassId, { mcReproducibleAsNotBad = false, runNumber } = {}) { await getOneDataPassOrFail({ id: dataPassId }); @@ -61,21 +64,31 @@ class GaqService { const where = { dataPassId }; if (runNumber !== undefined) { where.runNumber = runNumber; + const row = await GaqSummaryRepository.findOne({ where }); + return row ? this._formatSummary(row, mcReproducibleAsNotBad) : null; } - const summaries = await GaqSummaryRepository.findAll({ where }); - const summaryByRun = Object.fromEntries(summaries.map((s) => [s.runNumber, this._formatSummary(s, mcReproducibleAsNotBad)])); - - return runNumber !== undefined ? summaryByRun[runNumber] ?? null : summaryByRun; + const rows = await GaqSummaryRepository.findAll({ where }); + return Object.fromEntries(rows.map((row) => [row.runNumber, this._formatSummary(row, mcReproducibleAsNotBad)])); } /** - * Format a raw GAQ summary record into its API representation + * Format a raw GAQ summary record into its API representation. Rows without coverage values (notComputable, or + * invalidated before any compute) return null coverage fields so callers can distinguish "no data" from real 0%. * @param {object} summary raw summary from the database * @param {boolean} mcReproducibleAsNotBad whether MC reproducible coverage counts as not-bad * @return {RunGaqSummary} formatted summary */ _formatSummary(summary, mcReproducibleAsNotBad) { + if (summary.badRunCoverage === null) { + return { + [QcSummarProperties.BAD_EFFECTIVE_RUN_COVERAGE]: null, + [QcSummarProperties.EXPLICITELY_NOT_BAD_EFFECTIVE_RUN_COVERAGE]: null, + [QcSummarProperties.MC_REPRODUCIBLE]: false, + [QcSummarProperties.MISSING_VERIFICATIONS]: null, + [QcSummarProperties.UNDEFINED_QUALITY_PERIODS_COUNT]: null, + }; + } return { [QcSummarProperties.BAD_EFFECTIVE_RUN_COVERAGE]: summary.badRunCoverage + (mcReproducibleAsNotBad ? 0 : summary.mcReproducibleCoverage), diff --git a/test/lib/server/services/gaq/GaqService.test.js b/test/lib/server/services/gaq/GaqService.test.js index b2a96c49e9..ab69692000 100644 --- a/test/lib/server/services/gaq/GaqService.test.js +++ b/test/lib/server/services/gaq/GaqService.test.js @@ -16,6 +16,7 @@ const sinon = require('sinon'); const { resetDatabaseContent } = require('../../../../utilities/resetDatabaseContent.js'); const { repositories: { GaqSummaryRepository} } = require('../../../../../lib/database'); const { gaqService } = require('../../../../../lib/server/services/gaq/GaqService.js'); +const { gaqWorker } = require('../../../../../lib/server/services/gaq/GaqWorker.js'); const { Op } = require('sequelize'); /** @@ -149,6 +150,126 @@ module.exports = () => { }); }); + describe('getSummary', () => { + before(() => gaqWorker.pause()); + after(() => gaqWorker.resume()); + + afterEach(async () => { + await GaqSummaryRepository.removeAll({ where: { dataPassId } }); + }); + + it('should return the summary for a single run when runNumber is given', async () => { + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + + const result = await gaqService.getSummary(dataPassId, { runNumber }); + expect(result).to.be.an('object'); + expect(result).to.have.all.keys( + 'badEffectiveRunCoverage', + 'explicitlyNotBadEffectiveRunCoverage', + 'mcReproducible', + 'missingVerificationsCount', + 'undefinedQualityPeriodsCount', + ); + expect(result.missingVerificationsCount).to.equal(3); + expect(result.mcReproducible).to.be.true; + }); + + it('should return null when runNumber is given and no row exists', async () => { + const result = await gaqService.getSummary(dataPassId, { runNumber: 999999 }); + expect(result).to.be.null; + }); + + it('should return a summary with null coverage fields when the row is notComputable', async () => { + // Run 49 has no QC flags in data pass 1, so calculateAndStoreGaqSummary writes a notComputable row + await gaqService.calculateAndStoreGaqSummary(dataPassId, 49); + + const result = await gaqService.getSummary(dataPassId, { runNumber: 49 }); + expect(result).to.deep.equal({ + badEffectiveRunCoverage: null, + explicitlyNotBadEffectiveRunCoverage: null, + mcReproducible: false, + missingVerificationsCount: null, + undefinedQualityPeriodsCount: null, + }); + }); + + it('should return a summary with null coverage fields when the row is invalidated but never computed', async () => { + await GaqSummaryRepository.invalidate(dataPassId, runNumber); + + const result = await gaqService.getSummary(dataPassId, { runNumber }); + expect(result).to.deep.equal({ + badEffectiveRunCoverage: null, + explicitlyNotBadEffectiveRunCoverage: null, + mcReproducible: false, + missingVerificationsCount: null, + undefinedQualityPeriodsCount: null, + }); + }); + + it('should include every existing row in the map, with null fields for those without coverage values', async () => { + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + await gaqService.calculateAndStoreGaqSummary(dataPassId, 49); // notComputable + await GaqSummaryRepository.invalidate(dataPassId, 106); // invalidated, never computed + + const result = await gaqService.getSummary(dataPassId); + expect(Object.keys(result).map(Number).sort()).to.deep.equal([49, 106, runNumber].sort()); + expect(result[runNumber].badEffectiveRunCoverage).to.not.be.null; + expect(result[49].badEffectiveRunCoverage).to.be.null; + expect(result[106].badEffectiveRunCoverage).to.be.null; + }); + + it('should return the previously-computed values for a row that has been invalidated but not yet recomputed', async () => { + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + const expected = await gaqService.getSummary(dataPassId, { runNumber }); + + await GaqSummaryRepository.invalidate(dataPassId, runNumber); + + const result = await gaqService.getSummary(dataPassId, { runNumber }); + expect(result).to.deep.equal(expected); + }); + + it('should not leak rows from other data passes into the result', async () => { + const otherDataPassId = 2; + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + await GaqSummaryRepository.upsert({ + dataPassId: otherDataPassId, + runNumber, + badRunCoverage: 0.5, + explicitlyNotBadRunCoverage: 0.5, + mcReproducibleCoverage: 0, + missingVerificationsCount: 0, + undefinedQualityPeriodsCount: 0, + notComputable: false, + }); + + try { + const result = await gaqService.getSummary(dataPassId); + expect(Object.keys(result).map(Number)).to.have.members([runNumber]); + } finally { + await GaqSummaryRepository.removeAll({ where: { dataPassId: otherDataPassId, runNumber } }); + } + }); + + it('should return an empty map when no summary rows exist for the data pass', async () => { + const result = await gaqService.getSummary(dataPassId); + expect(result).to.deep.equal({}); + }); + + it('should treat mcReproducible coverage as not-bad when mcReproducibleAsNotBad is true', async () => { + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + + const defaultResult = await gaqService.getSummary(dataPassId, { runNumber }); + const asNotBadResult = await gaqService.getSummary(dataPassId, { runNumber, mcReproducibleAsNotBad: true }); + + expect(asNotBadResult.badEffectiveRunCoverage).to.be.lessThan(defaultResult.badEffectiveRunCoverage + 1e-9); + expect(asNotBadResult.explicitlyNotBadEffectiveRunCoverage) + .to.be.greaterThan(defaultResult.explicitlyNotBadEffectiveRunCoverage - 1e-9); + const defaultTotal = defaultResult.badEffectiveRunCoverage + defaultResult.explicitlyNotBadEffectiveRunCoverage; + const asNotBadTotal = asNotBadResult.badEffectiveRunCoverage + asNotBadResult.explicitlyNotBadEffectiveRunCoverage; + expect(asNotBadTotal).to.be.closeTo(defaultTotal, 1e-9); + }); + }); + describe('popNInvalidSummaryAndRecalculate', () => { beforeEach(async () => { await GaqSummaryRepository.updateAll({ invalidatedAt: null }, { where: {} }); From 58fceb6ce8be063df972fc74dc72e4ae2432ddd5 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:53:04 +0200 Subject: [PATCH 60/72] [O2B-1565] Compute GAQ summaries explicitly in fetch test After migrating getSummary to read from the precomputed table, flag creation only invalidates it no longer computes. The background worker eventually fills these in on its 30s tick, but the test runs faster than that, so the assertions saw empty/null data. Call calculateAndStoreGaqSummary for each affected run after creating flags, so the read path has rows to return. --- .../services/qualityControlFlag/QcFlagService.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/lib/server/services/qualityControlFlag/QcFlagService.test.js b/test/lib/server/services/qualityControlFlag/QcFlagService.test.js index e62fd06b4e..c3e0ec58b5 100644 --- a/test/lib/server/services/qualityControlFlag/QcFlagService.test.js +++ b/test/lib/server/services/qualityControlFlag/QcFlagService.test.js @@ -2074,6 +2074,9 @@ module.exports = () => { expectedGaqSummary.missingVerificationsCount = 11; expectedGaqSummary.undefinedQualityPeriodsCount = 8; + // getSummary now reads from the summary table, so compute first + await gaqService.calculateAndStoreGaqSummary(dataPassId, runNumber); + const { [runNumber]: runGaqSummary } = await gaqService.getSummary(dataPassId); expect(runGaqSummary).to.be.eql(expectedGaqSummary); @@ -2103,6 +2106,9 @@ module.exports = () => { relations, ); + await gaqService.calculateAndStoreGaqSummary(dataPassId, 56); + await gaqService.calculateAndStoreGaqSummary(dataPassId, 54); + const gaqSummary = await gaqService.getSummary(dataPassId); expect(gaqSummary).to.be.eql({ [runNumber]: expectedGaqSummary, From 1404025ffd33cf1a8e17a6435e80add6ae8cd710 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:43:24 +0200 Subject: [PATCH 61/72] [O2B-1565] Seed gaq_summaries rows Seeds gaq_summaries with the rows the worker would produce against the existing flag/detector fixtures, so UI tests can assert against GAQ values without waiting on the background recompute. --- .../seeders/20260223120000-gaq-summaries.js | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 lib/database/seeders/20260223120000-gaq-summaries.js diff --git a/lib/database/seeders/20260223120000-gaq-summaries.js b/lib/database/seeders/20260223120000-gaq-summaries.js new file mode 100644 index 0000000000..61d0052e6d --- /dev/null +++ b/lib/database/seeders/20260223120000-gaq-summaries.js @@ -0,0 +1,84 @@ +/** + * @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. + */ + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: async (queryInterface) => queryInterface.bulkInsert('gaq_summaries', [ + { + data_pass_id: 1, + run_number: 106, + bad_run_coverage: 0, + mc_reproducible_coverage: 0, + missing_verifications_count: 3, + undefined_quality_periods_count: 11, + not_computable: 0, + invalidated_at: null, + created_at: '2026-06-29 13:09:09.382', + updated_at: '2026-06-29 13:09:09.382', + }, + { + data_pass_id: 1, + run_number: 107, + bad_run_coverage: 0, + explicitly_not_bad_run_coverage: 0.759654, + mc_reproducible_coverage: 0.240346, + missing_verifications_count: 3, + undefined_quality_periods_count: 0, + not_computable: 0, + invalidated_at: null, + created_at: '2026-06-29 13:09:09.382', + updated_at: '2026-06-29 13:09:09.382', + }, + { + data_pass_id: 2, + run_number: 1, + bad_run_coverage: null, + explicitly_not_bad_run_coverage: null, + mc_reproducible_coverage: null, + missing_verifications_count: null, + undefined_quality_periods_count: null, + not_computable: 1, + invalidated_at: null, + created_at: '2026-06-29 13:09:09.382', + updated_at: '2026-06-29 13:09:09.382', + }, + { + data_pass_id: 4, + run_number: 100, + bad_run_coverage: null, + explicitly_not_bad_run_coverage: null, + mc_reproducible_coverage: null, + missing_verifications_count: null, + undefined_quality_periods_count: null, + not_computable: 1, + invalidated_at: null, + created_at: '2026-06-29 13:09:09.382', + updated_at: '2026-06-29 13:09:09.382', + + }, + { + data_pass_id: 4, + run_number: 105, + bad_run_coverage: null, + explicitly_not_bad_run_coverage: null, + mc_reproducible_coverage: null, + missing_verifications_count: null, + undefined_quality_periods_count: null, + not_computable: 1, + invalidated_at: null, + created_at: '2026-06-29 13:09:09.382', + updated_at: '2026-06-29 13:09:09.382', + }, + ]), + down: async (queryInterface) => queryInterface.bulkDelete('gaq_summaries', null, {}), +}; From 66f1773edb374824689734765037a9d3c2f041d3 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:45:26 +0200 Subject: [PATCH 62/72] [O2B-1565] Adapt QC flag tests to table-backed getSummary --- test/api/qcFlags.test.js | 8 ++++++-- .../services/qualityControlFlag/QcFlagService.test.js | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/test/api/qcFlags.test.js b/test/api/qcFlags.test.js index 092df4d883..a3fc03e1d9 100644 --- a/test/api/qcFlags.test.js +++ b/test/api/qcFlags.test.js @@ -16,6 +16,7 @@ const request = require('supertest'); const { server } = require('../../lib/application'); const { resetDatabaseContent } = require('../utilities/resetDatabaseContent.js'); const { qcFlagService } = require('../../lib/server/services/qualityControlFlag/QcFlagService'); +const { gaqService } = require('../../lib/server/services/gaq/GaqService.js'); const { BkpRoles } = require('../../lib/domain/enums/BkpRoles.js'); const { dataPassService } = require('../../lib/server/services/dataPasses/DataPassService.js'); const { expectSuccessStatus } = require('./utils.js'); @@ -556,6 +557,9 @@ module.exports = () => { relations, ); + // getSummary now reads from the summary table, so compute first + await gaqService.calculateAndStoreGaqSummary(3, 54); + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=3&runNumber=54'); expect(response.status).to.be.equal(200); const { body: { data } } = response; @@ -569,11 +573,11 @@ module.exports = () => { ); }); - it('should return empty GAQ summary if no data exists for given dataPassId & runNumber combination', async () => { + it('should return null GAQ summary if no data exists for given dataPassId & runNumber combination', async () => { const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=3&runNumber=999'); expect(response.status).to.equal(200); const { body: { data } } = response; - expect(data).to.eql({}); + expect(data).to.be.null; }); it('should return 400 if dataPassId is not positive', async () => { diff --git a/test/lib/server/services/qualityControlFlag/QcFlagService.test.js b/test/lib/server/services/qualityControlFlag/QcFlagService.test.js index c3e0ec58b5..fb578b594f 100644 --- a/test/lib/server/services/qualityControlFlag/QcFlagService.test.js +++ b/test/lib/server/services/qualityControlFlag/QcFlagService.test.js @@ -2109,7 +2109,10 @@ module.exports = () => { await gaqService.calculateAndStoreGaqSummary(dataPassId, 56); await gaqService.calculateAndStoreGaqSummary(dataPassId, 54); - const gaqSummary = await gaqService.getSummary(dataPassId); + const allGaqSummaries = await gaqService.getSummary(dataPassId); + const gaqSummary = Object.fromEntries( + [runNumber, 56, 54].map((n) => [n, allGaqSummaries[n]]), + ); expect(gaqSummary).to.be.eql({ [runNumber]: expectedGaqSummary, 56: { From f16a52e45cd363fd347261ab47f66829c611e8d0 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:48:00 +0200 Subject: [PATCH 63/72] [O2B-1565] Remove obsolete runNumber required API test --- test/api/qcFlags.test.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/api/qcFlags.test.js b/test/api/qcFlags.test.js index a3fc03e1d9..fe72874791 100644 --- a/test/api/qcFlags.test.js +++ b/test/api/qcFlags.test.js @@ -606,14 +606,6 @@ module.exports = () => { expect(errors[0].detail).to.equal('"query.runNumber" must be a number'); }); - it('should return 400 when runNumber parameter is missing', async () => { - const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=3'); - expect(response.status).to.be.equal(400); - const { errors } = response.body; - const titleError = errors.find((err) => err.source.pointer === '/data/attributes/query/runNumber'); - expect(titleError.detail).to.equal('"query.runNumber" is required'); - }); - it('should return 400 when dataPassId parameter is missing', async () => { const response = await request(server).get('/api/qcFlags/summary/gaq?runNumber=54'); expect(response.status).to.be.equal(400); From c7d37b71b7585e307561eca0e8bb0d434621d78e Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:56:50 +0200 Subject: [PATCH 64/72] [O2B-1564] Remove overkill parsePositiveInt helper and use Number like the rest --- lib/config/services.js | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/lib/config/services.js b/lib/config/services.js index 599cc1ad13..2cf56c9d64 100644 --- a/lib/config/services.js +++ b/lib/config/services.js @@ -27,25 +27,6 @@ const { CCDB_RUN_INFO_URL, } = process.env ?? {}; -/** - * Parse a positive integer env var, falling back to the default and warning if the value is invalid - * - * @param {string|undefined} raw the raw env var value - * @param {number} defaultValue value to use when raw is unset or invalid - * @param {string} name env var name (for the warning message) - * @return {number} the parsed value or the default - */ -const parsePositiveInt = (raw, defaultValue, name) => { - if (raw === undefined) return defaultValue; - const parsed = Number(raw); - if (!Number.isInteger(parsed) || parsed <= 0) { - // eslint-disable-next-line no-console - console.warn(`Invalid ${name}=${JSON.stringify(raw)}; falling back to ${defaultValue}`); - return defaultValue; - } - return parsed; -}; - exports.services = { enableHousekeeping: process.env?.ENABLE_HOUSEKEEPING?.toLowerCase() === 'true', userCertificate: { @@ -90,8 +71,8 @@ exports.services = { gaq: { enableRecalculation: process.env?.GAQ_ENABLE_RECALCULATION?.toLowerCase() === 'true', - recalculationPeriod: parsePositiveInt(process.env?.GAQ_RECALCULATION_PERIOD, 30 * 1000, 'GAQ_RECALCULATION_PERIOD'), // 30s default - minBatchSize: parsePositiveInt(process.env?.GAQ_RECALCULATION_MIN_BATCH_SIZE, 1, 'GAQ_RECALCULATION_MIN_BATCH_SIZE'), - maxBatchSize: parsePositiveInt(process.env?.GAQ_RECALCULATION_MAX_BATCH_SIZE, 100, 'GAQ_RECALCULATION_MAX_BATCH_SIZE'), + recalculationPeriod: Number(process.env?.GAQ_RECALCULATION_PERIOD) || 30 * 1000, // 30s default + minBatchSize: Number(process.env?.GAQ_RECALCULATION_MIN_BATCH_SIZE) || 1, + maxBatchSize: Number(process.env?.GAQ_RECALCULATION_MAX_BATCH_SIZE) || 100, }, }; From c2d24f04b836a45601f6edf0f0dd89ff0046e327 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:13:12 +0200 Subject: [PATCH 65/72] [O2B-1565] Fix for test to wait a little longer to be cleared --- test/lib/server/services/gaq/GaqSummary.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/lib/server/services/gaq/GaqSummary.test.js b/test/lib/server/services/gaq/GaqSummary.test.js index fc15274711..3ade1a9fe6 100644 --- a/test/lib/server/services/gaq/GaqSummary.test.js +++ b/test/lib/server/services/gaq/GaqSummary.test.js @@ -195,7 +195,7 @@ module.exports = () => { ); await expectInvalidation(workerDataPassId, workerRunNumber); - await sleep(2000); + await sleep(3000); await expectInvalidation(workerDataPassId, workerRunNumber, true); From 6b72a6a6639d8f1129cc360b674ddd9763468130 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:27:00 +0200 Subject: [PATCH 66/72] [O2B-1565] Debug logs to fix CI failing test --- lib/server/services/gaq/GaqService.js | 4 ++++ test/lib/server/index.js | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index 16943a90b2..ec8be9c2c9 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -196,6 +196,8 @@ class GaqService { { where: { dataPassId, runNumber, invalidatedAt: expectedInvalidatedAt } }, ); + console.log('[GAQ DEBUG]', { dataPassId, runNumber, expectedInvalidatedAt: expectedInvalidatedAt?.toISOString(), rows }); + if (rows === 0) { // Write fresh summary fields but leave invalidatedAt unchanged await GaqSummaryRepository.updateAll( @@ -217,6 +219,8 @@ class GaqService { limit: batchSize, }); + console.log('[GAQ POP]', rows.map((r) => ({ dataPassId: r.dataPassId, runNumber: r.runNumber, invalidatedAt: r.invalidatedAt?.toISOString() }))); + await Promise.all(rows.map(({ dataPassId, runNumber, invalidatedAt }) => this.calculateAndStoreGaqSummary(dataPassId, runNumber, { expectedInvalidatedAt: invalidatedAt }))); diff --git a/test/lib/server/index.js b/test/lib/server/index.js index 64c13effab..7132714736 100644 --- a/test/lib/server/index.js +++ b/test/lib/server/index.js @@ -17,8 +17,8 @@ const MiddlewareSuite = require('./middleware/index.js'); const ExternalServicesSynchronizationSuite = require('./externalServicesSynchronization/index.js'); module.exports = () => { - describe('Utilities', UtilitiesSuite); + // describe('Utilities', UtilitiesSuite); describe('Services', ServicesSuite); - describe('Middlewares', MiddlewareSuite); - describe('External Services Synchronization', ExternalServicesSynchronizationSuite); + // describe('Middlewares', MiddlewareSuite); + // describe('External Services Synchronization', ExternalServicesSynchronizationSuite); }; From 4a976020f2e58ebf73205f42ecf742a082a4bad2 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:44:18 +0200 Subject: [PATCH 67/72] [O2B-1564] Add missing docker env vars to CI dockerfile --- docker-compose.test-parallel-base.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker-compose.test-parallel-base.yml b/docker-compose.test-parallel-base.yml index 7147451bb6..444a40b762 100644 --- a/docker-compose.test-parallel-base.yml +++ b/docker-compose.test-parallel-base.yml @@ -16,6 +16,10 @@ services: FLP_INFOLOGGER_URL: "${FLP_INFOLOGGER_URL:-http://localhost:8081}" QC_GUI_URL: "${QC_GUI_URL:-http://localhost:8082}" ALI_FLP_INDEX_URL: "${ALI_FLP_INDEX_URL:-http://localhost:80}" + GAQ_ENABLE_RECALCULATION: "True" + GAQ_RECALCULATION_PERIOD: 1000 + GAQ_RECALCULATION_MIN_BATCH_SIZE: 1 + GAQ_RECALCULATION_MAX_BATCH_SIZE: 1 links: - test_db restart: "no" From f37984435a94b1f087623b5ebce7ba89c374c13c Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:47:11 +0200 Subject: [PATCH 68/72] Revert "[O2B-1565] Debug logs to fix CI failing test" This reverts commit 6b72a6a6639d8f1129cc360b674ddd9763468130. --- lib/server/services/gaq/GaqService.js | 4 ---- test/lib/server/index.js | 6 +++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index ec8be9c2c9..16943a90b2 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -196,8 +196,6 @@ class GaqService { { where: { dataPassId, runNumber, invalidatedAt: expectedInvalidatedAt } }, ); - console.log('[GAQ DEBUG]', { dataPassId, runNumber, expectedInvalidatedAt: expectedInvalidatedAt?.toISOString(), rows }); - if (rows === 0) { // Write fresh summary fields but leave invalidatedAt unchanged await GaqSummaryRepository.updateAll( @@ -219,8 +217,6 @@ class GaqService { limit: batchSize, }); - console.log('[GAQ POP]', rows.map((r) => ({ dataPassId: r.dataPassId, runNumber: r.runNumber, invalidatedAt: r.invalidatedAt?.toISOString() }))); - await Promise.all(rows.map(({ dataPassId, runNumber, invalidatedAt }) => this.calculateAndStoreGaqSummary(dataPassId, runNumber, { expectedInvalidatedAt: invalidatedAt }))); diff --git a/test/lib/server/index.js b/test/lib/server/index.js index 7132714736..64c13effab 100644 --- a/test/lib/server/index.js +++ b/test/lib/server/index.js @@ -17,8 +17,8 @@ const MiddlewareSuite = require('./middleware/index.js'); const ExternalServicesSynchronizationSuite = require('./externalServicesSynchronization/index.js'); module.exports = () => { - // describe('Utilities', UtilitiesSuite); + describe('Utilities', UtilitiesSuite); describe('Services', ServicesSuite); - // describe('Middlewares', MiddlewareSuite); - // describe('External Services Synchronization', ExternalServicesSynchronizationSuite); + describe('Middlewares', MiddlewareSuite); + describe('External Services Synchronization', ExternalServicesSynchronizationSuite); }; From 5fee0a4678a91ce81092a64faf2b27bcdb24c5b5 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:36:06 +0200 Subject: [PATCH 69/72] Update ca-certificates version in Dockerfile (#2138) From 467630a0e2013a69a04f477cbf260d0b1d538572 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:36:19 +0200 Subject: [PATCH 70/72] Reapply "Merge branch 'main' into feature/O2B-1563/Create-GAQ-summary-invalidation-mechanism" This reverts commit 23f6e0442e16511db656ed942338df95e424f9fc. --- .github/CODEOWNERS | 1 + .github/workflows/bookkeeping.yml | 8 +- .github/workflows/docker.yml | 4 + Dockerfile | 2 +- ...260305110000-add-qcf-run-detector-index.js | 28 + .../seeders/20240404100811-qc-flags.js | 21 + lib/domain/dtos/GetAllLogsDto.js | 19 +- lib/domain/dtos/filters/RunFilterDto.js | 25 +- .../enums/NonPhysicsProductionsNamesWords.js | 2 + lib/public/Model.js | 18 +- lib/public/app.css | 68 + .../LhcFillsFilter/BeamTypeFilterModel.js | 6 +- .../LhcFillsFilter/StableBeamFilterModel.js | 76 - .../Filters/LhcFillsFilter/beamTypeFilter.js | 6 +- .../LhcFillsFilter/fillNumberFilter.js | 25 - .../LhcFillsFilter/schemeNameFilter.js | 25 - .../LhcFillsFilter/stableBeamFilter.js | 49 - .../LogsFilter/author/AuthorFilterModel.js | 21 +- .../Filters/LogsFilter/author/authorFilter.js | 33 +- .../components/Filters/LogsFilter/created.js | 53 - .../Filters/LogsFilter/environments.js | 28 - .../components/Filters/LogsFilter/runs.js | 28 - .../Filters/RunsFilter/BeamModeFilterModel.js | 37 +- .../RunsFilter/DetectorsFilterModel.js | 10 +- .../RunsFilter/EorReasonFilterModel.js | 9 + .../Filters/RunsFilter/GaqFilterModel.js | 97 + .../RunsFilter/MagnetsFilteringModel.js | 79 +- .../RunsFilter/MultiCompositionFilterModel.js | 110 + .../RunsFilter/RunDefinitionFilterModel.js | 11 +- .../Filters/RunsFilter/TimeRangeFilter.js | 7 + .../components/Filters/RunsFilter/dcs.js | 50 - .../components/Filters/RunsFilter/ddflp.js | 50 - .../components/Filters/RunsFilter/epn.js | 50 - .../Filters/RunsFilter/runDefinitionFilter.js | 5 +- .../Filters/RunsFilter/runNumbersFilter.js | 25 - .../Filters/RunsFilter/triggerValueFilter.js | 21 - .../components/Filters/common/FilterModel.js | 20 + .../Filters/common/FilteringModel.js | 183 +- .../Filters/common/RadioButtonFilterModel.js | 48 + .../Filters/common/TagFilterModel.js | 12 +- .../common/filters/FilterInputModel.js | 119 -- .../filters/NumericalComparisonFilterModel.js | 19 +- .../common/filters/ProcessedTextInputModel.js | 21 + .../common/filters/RawTextFilterModel.js | 7 + .../common/filters/SelectionFilterModel.js | 63 - .../filters/TextComparisonFilterModel.js | 12 +- .../common/filters/TextTokensFilterModel.js | 7 + .../common/filters/TimeRangeInputModel.js | 8 + .../common/filters/ToggleFilterModel.js | 74 + .../Filters/common/filters/checkboxFilter.js | 26 - .../common/filters/radioButtonFilter.js | 38 + .../Filters/common/filters/textFilter.js | 8 +- .../Filters/common/filters/textInputFilter.js | 26 + .../Filters/common/filters/toggleFilter.js | 45 + .../Filters/common/filtersPanelPopover.js | 131 +- .../common/form/inputs/DateTimeInputModel.js | 10 +- .../components/common/form/switchInput.js | 4 +- .../common/messages/warningComponent.js | 35 + .../common/selection/SelectionModel.js | 69 +- .../runEorReasons/runEorReasonSelection.js | 5 +- .../runTypes/RunTypesFilterModel.js | 37 +- lib/public/domain/enums/DetectorOrders.js | 43 + .../models/FilterableOverviewPageModel.js | 132 ++ lib/public/models/OverviewModel.js | 19 +- .../services/detectors/detectorsProvider.js | 18 +- .../ActiveColumns/dataPassesActiveColumns.js | 9 +- .../views/DataPasses/DataPassesModel.js | 9 +- .../DataPasses/DataPassesOverviewModel.js | 62 +- .../DataPassesPerLhcPeriodOverviewModel.js | 6 +- .../DataPassesPerLhcPeriodOverviewPage.js | 21 +- ...ataPassesPerSimulationPassOverviewModel.js | 6 +- ...DataPassesPerSimulationPassOverviewPage.js | 19 +- .../environmentsActiveColumns.js | 25 +- .../views/Environments/EnvironmentModel.js | 3 +- .../Overview/EnvironmentOverviewModel.js | 109 +- .../Overview/environmentOverviewComponent.js | 7 +- lib/public/views/Home/Overview/HomePage.js | 2 +- .../views/Home/Overview/HomePageModel.js | 8 +- .../ActiveColumns/lhcFillsActiveColumns.js | 26 +- lib/public/views/LhcFills/LhcFills.js | 3 +- .../Overview/LhcFillsOverviewModel.js | 102 +- lib/public/views/LhcFills/Overview/index.js | 17 +- .../Logs/ActiveColumns/logsActiveColumns.js | 87 +- lib/public/views/Logs/LogsModel.js | 5 +- .../views/Logs/Overview/LogsOverviewModel.js | 409 +--- lib/public/views/Logs/Overview/index.js | 15 +- .../ActiveColumns/qcFlagTypesActiveColumns.js | 17 +- .../Overview/QcFlagTypesOverviewModel.js | 105 +- .../Overview/QcFlagTypesOverviewPage.js | 19 +- .../views/QcFlagTypes/QcFlagTypesModel.js | 3 +- .../synchronousQcFlagsActiveColumns.js | 61 + .../SynchronousQcFlagsOverviewPage.js | 16 +- .../views/QcFlags/format/formatQcFlagEnd.js | 5 +- .../views/QcFlags/format/formatQcFlagStart.js | 5 +- .../runDetectorsAsyncQcActiveColumns.js | 2 +- .../Runs/ActiveColumns/runsActiveColumns.js | 73 +- lib/public/views/Runs/Details/RunPatch.js | 11 +- .../views/Runs/Details/runDetailsComponent.js | 7 +- .../FixedPdpBeamTypeRunsOverviewModel.js | 5 +- .../views/Runs/Overview/RunsOverviewModel.js | 316 +-- .../views/Runs/Overview/RunsOverviewPage.js | 20 +- .../views/Runs/Overview/RunsWithQcModel.js | 136 +- .../RunsPerDataPassOverviewModel.js | 43 +- .../RunsPerDataPassOverviewPage.js | 350 ++-- .../RunsPerLhcPeriodOverviewModel.js | 38 +- .../RunsPerLhcPeriodOverviewPage.js | 69 +- lib/public/views/Runs/RunsModel.js | 30 +- .../RunsPerSimulationPassOverviewModel.js | 38 +- .../RunsPerSimulationPassOverviewPage.js | 129 +- .../views/Runs/format/editRunEorReasons.js | 15 +- .../views/Runs/format/formatRunEorReason.js | 36 + .../Runs/mcReproducibleAsNotBadToggle.js | 28 - .../simulationPassesActiveColumns.js | 4 +- .../AnchoredSimulationPassesOverviewModel.js | 65 +- .../AnchoredSimulationPassesOverviewPage.js | 12 +- ...mulationPassesPerLhcPeriodOverviewModel.js | 65 +- ...imulationPassesPerLhcPeriodOverviewPage.js | 19 +- .../SimulationPasses/SimulationPassesModel.js | 9 +- .../ActiveColumns/lhcPeriodsActiveColumns.js | 12 +- .../views/lhcPeriods/LhcPeriodsModel.js | 6 +- .../Overview/LhcPeriodsOverviewModel.js | 96 +- .../Overview/LhcPeriodsOverviewPage.js | 24 +- lib/server/Loggers/FilterLogger.js | 60 + .../controllers/dataPasses.controller.js | 16 +- .../lhcPeriodStatistics.controller.js | 2 +- .../InfoLoggerListener.middleware.js | 23 + lib/server/routers/dataPasses.router.js | 4 +- lib/server/routers/environments.router.js | 4 +- lib/server/routers/lhcFills.router.js | 4 +- .../routers/lhcPeriodsStatistics.router.js | 4 +- lib/server/routers/logs.router.js | 4 +- lib/server/routers/qcFlag.router.js | 4 +- lib/server/routers/runs.router.js | 4 +- lib/server/routers/simulationPasses.router.js | 4 +- .../services/dataPasses/DataPassService.js | 55 +- .../lhcPeriod/LhcPeriodStatisticsService.js | 21 +- .../environment/GetAllEnvironmentsUseCase.js | 50 +- lib/usecases/lhcFill/GetAllLhcFillsUseCase.js | 9 +- lib/usecases/log/GetAllLogsUseCase.js | 74 +- lib/usecases/run/GetAllRunsUseCase.js | 57 +- lib/utilities/setTimeRangeQuery.js | 25 + package-lock.json | 1835 +++++------------ package.json | 32 +- test/api/dataPasses.test.js | 4 +- test/api/logs.test.js | 149 +- test/api/qcFlags.test.js | 10 +- test/api/runs.test.js | 28 +- .../qualityControlFlag/QcFlagService.test.js | 16 +- .../GetAllEnvironmentsUseCase.test.js | 36 + .../usecases/log/GetAllLogsUseCase.test.js | 52 +- .../usecases/run/GetAllRunsUseCase.test.js | 13 +- test/public/Filters/FilteringModel.test.js | 157 ++ test/public/Filters/filtersToUrl.test.js | 529 +++++ .../public/Filters/index.js | 14 +- test/public/Filters/urlToFilter.test.js | 372 ++++ .../components/filtersPopoverPanel.test.js | 70 + test/public/components/index.js | 4 + test/public/components/warnings.test.js | 85 + .../dataPasses/overviewPerLhcPeriod.test.js | 2 +- .../overviewPerSimulationPass.test.js | 2 +- test/public/defaults.js | 60 +- test/public/index.js | 2 + test/public/logs/overview.test.js | 333 ++- test/public/qcFlagTypes/overview.test.js | 2 +- .../qcFlags/synchronousOverview.test.js | 50 +- test/public/runs/detail.test.js | 19 +- test/public/runs/overview.test.js | 16 +- .../runs/runsPerDataPass.overview.test.js | 43 +- .../runs/runsPerLhcPeriod.overview.test.js | 33 +- .../runsPerSimulationPass.overview.test.js | 22 +- 170 files changed, 4957 insertions(+), 4642 deletions(-) create mode 100644 lib/database/migrations/v1/20260305110000-add-qcf-run-detector-index.js delete mode 100644 lib/public/components/Filters/LhcFillsFilter/StableBeamFilterModel.js delete mode 100644 lib/public/components/Filters/LhcFillsFilter/fillNumberFilter.js delete mode 100644 lib/public/components/Filters/LhcFillsFilter/schemeNameFilter.js delete mode 100644 lib/public/components/Filters/LhcFillsFilter/stableBeamFilter.js delete mode 100644 lib/public/components/Filters/LogsFilter/created.js delete mode 100644 lib/public/components/Filters/LogsFilter/environments.js delete mode 100644 lib/public/components/Filters/LogsFilter/runs.js create mode 100644 lib/public/components/Filters/RunsFilter/GaqFilterModel.js create mode 100644 lib/public/components/Filters/RunsFilter/MultiCompositionFilterModel.js delete mode 100644 lib/public/components/Filters/RunsFilter/dcs.js delete mode 100644 lib/public/components/Filters/RunsFilter/ddflp.js delete mode 100644 lib/public/components/Filters/RunsFilter/epn.js delete mode 100644 lib/public/components/Filters/RunsFilter/runNumbersFilter.js delete mode 100644 lib/public/components/Filters/RunsFilter/triggerValueFilter.js create mode 100644 lib/public/components/Filters/common/RadioButtonFilterModel.js delete mode 100644 lib/public/components/Filters/common/filters/FilterInputModel.js delete mode 100644 lib/public/components/Filters/common/filters/SelectionFilterModel.js create mode 100644 lib/public/components/Filters/common/filters/ToggleFilterModel.js create mode 100644 lib/public/components/Filters/common/filters/radioButtonFilter.js create mode 100644 lib/public/components/Filters/common/filters/textInputFilter.js create mode 100644 lib/public/components/Filters/common/filters/toggleFilter.js create mode 100644 lib/public/components/common/messages/warningComponent.js create mode 100644 lib/public/domain/enums/DetectorOrders.js create mode 100644 lib/public/models/FilterableOverviewPageModel.js create mode 100644 lib/public/views/QcFlags/ActiveColumns/synchronousQcFlagsActiveColumns.js create mode 100644 lib/public/views/Runs/format/formatRunEorReason.js delete mode 100644 lib/public/views/Runs/mcReproducibleAsNotBadToggle.js create mode 100644 lib/server/Loggers/FilterLogger.js create mode 100644 lib/server/middleware/InfoLoggerListener.middleware.js create mode 100644 lib/utilities/setTimeRangeQuery.js create mode 100644 test/public/Filters/FilteringModel.test.js create mode 100644 test/public/Filters/filtersToUrl.test.js rename lib/domain/dtos/filters/EnvironmentsFilterDto.js => test/public/Filters/index.js (58%) create mode 100644 test/public/Filters/urlToFilter.test.js create mode 100644 test/public/components/filtersPopoverPanel.test.js create mode 100644 test/public/components/warnings.test.js diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b806224b85..3be7e511ff 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,2 @@ * @graduta +* @isaachilly diff --git a/.github/workflows/bookkeeping.yml b/.github/workflows/bookkeeping.yml index c3c2358c27..d4ee530717 100644 --- a/.github/workflows/bookkeeping.yml +++ b/.github/workflows/bookkeeping.yml @@ -10,6 +10,10 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: parallel_tests: name: ${{ matrix.test_type }} @@ -39,7 +43,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Set up Docker - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Create Coverage Directory run: mkdir -p ${{ github.workspace }}/coverage @@ -71,7 +75,7 @@ jobs: env: TEST_TYPE: ${{ matrix.test_type }} - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: files: ./coverage/lcov.info env: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 69dd060eb0..0521614f2a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: linter: runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index b0373db06a..d4f9caed24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,7 @@ RUN apk add --no-cache \ freetype=2.13.2-r0 \ freetype-dev=2.13.2-r0 \ harfbuzz=8.5.0-r0 \ - ca-certificates=20250911-r0 + ca-certificates=20260413-r0 # Tell Puppeteer to skip installing Chrome. We'll be using the installed package. ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true diff --git a/lib/database/migrations/v1/20260305110000-add-qcf-run-detector-index.js b/lib/database/migrations/v1/20260305110000-add-qcf-run-detector-index.js new file mode 100644 index 0000000000..4c04e5920e --- /dev/null +++ b/lib/database/migrations/v1/20260305110000-add-qcf-run-detector-index.js @@ -0,0 +1,28 @@ +/* + * @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. + */ + +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: async (queryInterface) => queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addIndex('quality_control_flags', { + name: 'quality_control_flags_run_detector_idx', + fields: ['run_number', 'detector_id'], + }, { transaction }); + }), + + down: async (queryInterface) => queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.removeIndex('quality_control_flags', 'quality_control_flags_run_detector_idx', { transaction }); + }), +}; diff --git a/lib/database/seeders/20240404100811-qc-flags.js b/lib/database/seeders/20240404100811-qc-flags.js index b66ca15bce..560cb644bc 100644 --- a/lib/database/seeders/20240404100811-qc-flags.js +++ b/lib/database/seeders/20240404100811-qc-flags.js @@ -281,6 +281,21 @@ module.exports = { created_at: '2024-08-12 12:00:10', updated_at: '2024-08-12 12:00:10', }, + { + id: 103, + deleted: true, + from: null, + to: '2019-08-08 20:50:00', + comment: 'deleted flag', + + run_number: 56, + flag_type_id: 13, // Bad + created_by_id: 2, + detector_id: 7, // FT0 + + created_at: '2024-08-12 12:00:15', + updated_at: '2024-08-12 12:00:15', + }, // Run : 56, ITS { @@ -394,6 +409,12 @@ module.exports = { from: '2019-08-08 20:50:00', to: null, }, + { + id: 103, + flag_id: 103, + from: null, + to: '2019-08-08 20:50:00', + }, // Run : 56, ITS { diff --git a/lib/domain/dtos/GetAllLogsDto.js b/lib/domain/dtos/GetAllLogsDto.js index 8f6be452d7..7a0ef08306 100644 --- a/lib/domain/dtos/GetAllLogsDto.js +++ b/lib/domain/dtos/GetAllLogsDto.js @@ -17,17 +17,10 @@ const PaginationDto = require('./PaginationDto'); const { CustomJoi } = require('./CustomJoi.js'); const { TagsFilterDto } = require('./filters/TagsFilterDto.js'); const { FromToFilterDto } = require('./filters/FromToFilterDto.js'); -const { EnvironmentsFilterDto } = require('./filters/EnvironmentsFilterDto'); -const RunFilterDto = Joi.object({ - values: CustomJoi.stringArray().items(EntityIdDto).single().required(), - operation: Joi.string().valid('and', 'or').required(), -}); - -const LhcFillFilterDto = Joi.object({ - values: CustomJoi.stringArray().items(EntityIdDto).single().required(), - operation: Joi.string().valid('and', 'or').required(), -}); +const RunFilterDto = CustomJoi.stringArray().items(EntityIdDto).single(); +const EnvironmentsFilterDto = CustomJoi.stringArray().items(Joi.string()).single(); +const LhcFillFilterDto = CustomJoi.stringArray().items(EntityIdDto).single(); const FilterDto = Joi.object({ title: Joi.string().trim(), @@ -35,14 +28,14 @@ const FilterDto = Joi.object({ author: Joi.string().trim(), created: FromToFilterDto, tags: TagsFilterDto, - lhcFills: LhcFillFilterDto, - run: RunFilterDto, + fillNumbers: LhcFillFilterDto, + runNumbers: RunFilterDto, origin: Joi.string() .valid('human', 'process'), parentLog: EntityIdDto, rootLog: EntityIdDto, rootOnly: Joi.boolean(), - environments: EnvironmentsFilterDto, + environmentIds: EnvironmentsFilterDto, }); const SortDto = Joi.object({ diff --git a/lib/domain/dtos/filters/RunFilterDto.js b/lib/domain/dtos/filters/RunFilterDto.js index c66a194778..e67106d9f0 100644 --- a/lib/domain/dtos/filters/RunFilterDto.js +++ b/lib/domain/dtos/filters/RunFilterDto.js @@ -88,26 +88,27 @@ exports.RunFilterDto = Joi.object({ inelasticInteractionRateAtEnd: FloatComparisonDto, gaq: Joi.object({ - notBadFraction: FloatComparisonDto.when( - 'dataPassIds', - { - is: Joi.array().length(1), - then: FloatComparisonDto, - otherwise: Joi.forbidden().error(new Error('Filtering by GAQ is enabled only when filtering with one dataPassId')), - }, - ), + notBadFraction: FloatComparisonDto.custom((value, helpers) => { + const [, { dataPassIds }] = helpers.state.ancestors; + + if (!dataPassIds || dataPassIds.length !== 1) { + return helpers.message('Filtering by GAQ is enabled only when filtering with one dataPassId'); + } + + return value; + }), mcReproducibleAsNotBad: Joi.boolean().optional(), }), - detectorsQc: Joi.object() + detectorsQcNotBadFraction: Joi.object() .pattern( Joi.string().regex(/^_\d+$/), // Detector id with '_' prefix - Joi.object({ notBadFraction: FloatComparisonDto }), + FloatComparisonDto, ) .keys({ mcReproducibleAsNotBad: Joi.boolean().optional(), }) - .custom((detectorsQcObj, helpers) => { + .custom((detectorsQcNotBadFractionObj, helpers) => { const [{ dataPassIds, simulationPassIds, lhcPeriodIds }] = helpers.state.ancestors; singleRunsCollectionCustomCheck( @@ -117,6 +118,6 @@ exports.RunFilterDto = Joi.object({ 'the dataPassIds, simulationPassIds and lhcPeriodIds filters collectively contain exactly one ID', ); - return detectorsQcObj; + return detectorsQcNotBadFractionObj; }), }); diff --git a/lib/domain/enums/NonPhysicsProductionsNamesWords.js b/lib/domain/enums/NonPhysicsProductionsNamesWords.js index fb3d55cc07..0d76a56d09 100644 --- a/lib/domain/enums/NonPhysicsProductionsNamesWords.js +++ b/lib/domain/enums/NonPhysicsProductionsNamesWords.js @@ -23,3 +23,5 @@ const NonPhysicsProductionsNamesWords = Object.freeze({ module.exports.NonPhysicsProductionsNamesWords = NonPhysicsProductionsNamesWords; module.exports.NON_PHYSICS_PRODUCTIONS_NAMES_WORDS = Object.values(NonPhysicsProductionsNamesWords); + +module.exports.NON_PHYSICS_PRODUCTIONS_NAMES_TOTAL_LENGTH = Object.values(NonPhysicsProductionsNamesWords).join(',').length; diff --git a/lib/public/Model.js b/lib/public/Model.js index 6818118c81..0d0ae222f3 100644 --- a/lib/public/Model.js +++ b/lib/public/Model.js @@ -95,21 +95,27 @@ export default class Model extends Observable { this._appConfiguration$ = new Observable(); this._inputDebounceTime = INPUT_DEBOUNCE_TIME; + // Setup router + this.router = new QueryRouter(); + this.router.observe(this.handleLocationChange.bind(this)); + this.router.bubbleTo(this); + registerFrontLinkListener((e) => this.router.handleLinkEvent(e)); + // Models this.home = new HomePageModel(this); this.home.bubbleTo(this); - this.lhcPeriods = new LhcPeriodsModel(this); + this.lhcPeriods = new LhcPeriodsModel(this.router); this.lhcPeriods.bubbleTo(this); - this.dataPasses = new DataPassesModel(this); + this.dataPasses = new DataPassesModel(this.router); this.dataPasses.bubbleTo(this); this.qcFlags = new QcFlagsModel(this); this.qcFlags.bubbleTo(this); - this.simulationPasses = new SimulationPassesModel(this); + this.simulationPasses = new SimulationPassesModel(this.router); this.simulationPasses.bubbleTo(this); this.qcFlagTypes = new QcFlagTypesModel(this); @@ -178,12 +184,6 @@ export default class Model extends Observable { this.errorModel = new ErrorModel(); this.errorModel.bubbleTo(this); - // Setup router - this.router = new QueryRouter(); - this.router.observe(this.handleLocationChange.bind(this)); - this.router.bubbleTo(this); - registerFrontLinkListener((e) => this.router.handleLinkEvent(e)); - // Init pages this.handleLocationChange(); this.window.addEventListener('resize', debounce(() => this.notify(), 100)); diff --git a/lib/public/app.css b/lib/public/app.css index ec66c3717c..0e88f93174 100644 --- a/lib/public/app.css +++ b/lib/public/app.css @@ -266,6 +266,12 @@ th.text-center, td.text-center { border-color: #f5c6cb; } +.alert-warning { + color: var(--color-warning); + background-color: #ffe8c8; + border-color: #fdd69f; +} + .alert-danger hr { border-top-color: #f1b0b7; } @@ -712,6 +718,68 @@ label { opacity: 0.5; } +.active-filters-indicator { + position: relative; + z-index: 10; + background-color: white; + border-radius: .25rem; + padding: var(--space-xs) var(--space-s) var(--space-xs) var(--space-s); + margin: 0 0 0 var(--space-s); +} + +.active-filters-indicator:has(+ .clear-filter-icon-container) { + border-right: 0; + border-radius: .25rem 0 0 .25rem +} + +.clear-filter-icon-container { + background-color: white; + border-radius: 0 .25rem .25rem 0; + font-weight: 700; + cursor: pointer; +} + +.clear-filter-icon { + padding: var(--space-xs); + background-color: white; + color: var(--color-danger); + position: relative; + border-radius: 0 .25rem .25rem 0; + z-index: 10; +} + +.clear-filter-icon:hover { + background-color: var(--color-danger); + color: white; +} + +.inactive { + opacity: 0.5; + pointer-events: none; +} + +.pulse-green { + --pulse-color: 102, 255, 7; + animation: pulse 2s infinite; +} + +.pulse-red { + --pulse-color: 206, 42, 42; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0px rgba(var(--pulse-color), 0.6); + } + 50% { + box-shadow: 0 0 10px rgba(var(--pulse-color), 0.9); + } + 100% { + box-shadow: 0 0 0px rgba(var(--pulse-color), 0.6); + } +} + /** * Breakpoints : * small : x < 600 (default styles) diff --git a/lib/public/components/Filters/LhcFillsFilter/BeamTypeFilterModel.js b/lib/public/components/Filters/LhcFillsFilter/BeamTypeFilterModel.js index 18be7af40d..fc0964da04 100644 --- a/lib/public/components/Filters/LhcFillsFilter/BeamTypeFilterModel.js +++ b/lib/public/components/Filters/LhcFillsFilter/BeamTypeFilterModel.js @@ -12,12 +12,12 @@ */ import { beamTypesProvider } from '../../../services/beamTypes/beamTypesProvider.js'; -import { SelectionFilterModel } from '../common/filters/SelectionFilterModel.js'; +import { SelectionModel } from '../../common/selection/SelectionModel.js'; /** * Beam type filter model */ -export class BeamTypeFilterModel extends SelectionFilterModel { +export class BeamTypeFilterModel extends SelectionModel { /** * Constructor */ @@ -28,7 +28,7 @@ export class BeamTypeFilterModel extends SelectionFilterModel { beamTypesProvider.items$.getCurrent().apply({ Success: (types) => { const beamTypes = types.map((type) => ({ value: type.beam_type })); - this._selectionModel.setAvailableOptions(beamTypes); + this.setAvailableOptions(beamTypes); }, }); }); diff --git a/lib/public/components/Filters/LhcFillsFilter/StableBeamFilterModel.js b/lib/public/components/Filters/LhcFillsFilter/StableBeamFilterModel.js deleted file mode 100644 index 1bc3f8aed2..0000000000 --- a/lib/public/components/Filters/LhcFillsFilter/StableBeamFilterModel.js +++ /dev/null @@ -1,76 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { SelectionModel } from '../../common/selection/SelectionModel.js'; - -/** - * Stable beam filter model - * Holds true or false value - */ -export class StableBeamFilterModel extends SelectionModel { - /** - * Constructor - */ - constructor() { - super({ availableOptions: [{ value: true }, { value: false }], - defaultSelection: [{ value: false }], - multiple: false, - allowEmpty: false }); - } - - /** - * Returns true if the current filter is stable beams only - * - * @return {boolean} true if filter is stable beams only - */ - isStableBeamsOnly() { - return this.current; - } - - /** - * Sets the current filter to stable beams only - * - * @param {boolean} value value to set this stable beams only filter with - * @return {void} - */ - setStableBeamsOnly(value) { - this.select({ value }); - } - - /** - * Get normalized selected option - */ - get normalized() { - return this.current; - } - - /** - * Overrides SelectionModel.isEmpty to respect the fact that stable beam filter cannot be empty. - * @returns {boolean} true if the current value of the filter is false. - */ - get isEmpty() { - return this.current === false; - } - - /** - * Reset the filter to default values - * - * @return {void} - */ - resetDefaults() { - if (!this.isEmpty) { - this.reset(); - this.notify(); - } - } -} diff --git a/lib/public/components/Filters/LhcFillsFilter/beamTypeFilter.js b/lib/public/components/Filters/LhcFillsFilter/beamTypeFilter.js index 7872734704..83f1487922 100644 --- a/lib/public/components/Filters/LhcFillsFilter/beamTypeFilter.js +++ b/lib/public/components/Filters/LhcFillsFilter/beamTypeFilter.js @@ -19,8 +19,4 @@ import { checkboxes } from '../common/filters/checkboxFilter.js'; * @param {BeamTypeFilterModel} beamTypeFilterModel beamTypeFilterModel * @return {Component} the filter */ -export const beamTypeFilter = (beamTypeFilterModel) => - checkboxes( - beamTypeFilterModel.selectionModel, - { selector: 'beam-types' }, - ); +export const beamTypeFilter = (beamTypeFilterModel) => checkboxes(beamTypeFilterModel, { selector: 'beam-types' }); diff --git a/lib/public/components/Filters/LhcFillsFilter/fillNumberFilter.js b/lib/public/components/Filters/LhcFillsFilter/fillNumberFilter.js deleted file mode 100644 index de13af7586..0000000000 --- a/lib/public/components/Filters/LhcFillsFilter/fillNumberFilter.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { rawTextFilter } from '../common/filters/rawTextFilter.js'; - -/** - * Component to filter LHC-fills by fill number - * - * @param {RawTextFilterModel} filterModel the filter model - * @returns {Component} the text field - */ -export const fillNumberFilter = (filterModel) => rawTextFilter( - filterModel, - { classes: ['w-100', 'fill-numbers-filter'], placeholder: 'e.g. 11392, 11383, 7625' }, -); diff --git a/lib/public/components/Filters/LhcFillsFilter/schemeNameFilter.js b/lib/public/components/Filters/LhcFillsFilter/schemeNameFilter.js deleted file mode 100644 index 7b644f382a..0000000000 --- a/lib/public/components/Filters/LhcFillsFilter/schemeNameFilter.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { rawTextFilter } from '../common/filters/rawTextFilter.js'; - -/** - * Component to filter LHC-fills by scheme name - * - * @param {RawTextFilterModel} filterModel the filter model - * @returns {Component} the text field - */ -export const schemeNameFilter = (filterModel) => rawTextFilter( - filterModel, - { classes: ['w-100'], placeholder: 'e.g. Single_12b_8_1024_8_2018' }, -); diff --git a/lib/public/components/Filters/LhcFillsFilter/stableBeamFilter.js b/lib/public/components/Filters/LhcFillsFilter/stableBeamFilter.js deleted file mode 100644 index b4429c002c..0000000000 --- a/lib/public/components/Filters/LhcFillsFilter/stableBeamFilter.js +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { h } from '/js/src/index.js'; -import { switchInput } from '../../common/form/switchInput.js'; -import { radioButton } from '../../common/form/inputs/radioButton.js'; - -/** - * Display a toggle switch or radio buttons to filter stable beams only - * - * @param {StableBeamFilterModel} stableBeamFilterModel the stableBeamFilterModel - * @param {boolean} radioButtonMode define whether or not to return radio buttons or a switch. - * @returns {Component} the toggle switch - */ -export const toggleStableBeamOnlyFilter = (stableBeamFilterModel, radioButtonMode = false) => { - const name = 'stableBeamsOnlyRadio'; - const labelOff = 'OFF'; - const labelOn = 'ON'; - if (radioButtonMode) { - return h('.form-group-header.flex-row.w-100', [ - radioButton({ - label: labelOff, - isChecked: !stableBeamFilterModel.isStableBeamsOnly(), - action: () => stableBeamFilterModel.setStableBeamsOnly(false), - name: name, - }), - radioButton({ - label: labelOn, - isChecked: stableBeamFilterModel.isStableBeamsOnly(), - action: () => stableBeamFilterModel.setStableBeamsOnly(true), - name: name, - }), - ]); - } else { - return switchInput(stableBeamFilterModel.isStableBeamsOnly(), (newState) => { - stableBeamFilterModel.setStableBeamsOnly(newState); - }, { labelAfter: 'STABLE BEAM ONLY' }); - } -}; diff --git a/lib/public/components/Filters/LogsFilter/author/AuthorFilterModel.js b/lib/public/components/Filters/LogsFilter/author/AuthorFilterModel.js index 1b7a133916..f41a4458e2 100644 --- a/lib/public/components/Filters/LogsFilter/author/AuthorFilterModel.js +++ b/lib/public/components/Filters/LogsFilter/author/AuthorFilterModel.js @@ -11,12 +11,12 @@ * or submit itself to any jurisdiction. */ -import { FilterInputModel } from '../../common/filters/FilterInputModel.js'; +import { RawTextFilterModel } from '../../common/filters/RawTextFilterModel.js'; /** * Model to handle the state of the Author Filter */ -export class AuthorFilterModel extends FilterInputModel { +export class AuthorFilterModel extends RawTextFilterModel { /** * Constructor * @@ -32,7 +32,7 @@ export class AuthorFilterModel extends FilterInputModel { * @return {boolean} true if '!Anonymous' is included in the raw filter string, false otherwise. */ isAnonymousExcluded() { - return this._raw.includes('!Anonymous'); + return this._value.includes('!Anonymous'); } /** @@ -42,28 +42,25 @@ export class AuthorFilterModel extends FilterInputModel { */ toggleAnonymousFilter() { if (this.isAnonymousExcluded()) { - this._raw = this._raw.split(',') + this._value = this._value.split(',') .filter((author) => author.trim() !== '!Anonymous') .join(','); } else { - this._raw += super.isEmpty ? '!Anonymous' : ', !Anonymous'; + this._value += super.isEmpty ? '!Anonymous' : ', !Anonymous'; } - this._value = this.valueFromRaw(this._raw); this.notify(); } /** - * Reset the filter to its default value and notify the observers. + * Reset the filter to its default value and notify the observers if the reset changed anything. * * @return {void} */ clear() { - if (this.isEmpty) { - return; + if (!this.isEmpty) { + super.reset(); + this.notify(); } - - super.reset(); - this.notify(); } } diff --git a/lib/public/components/Filters/LogsFilter/author/authorFilter.js b/lib/public/components/Filters/LogsFilter/author/authorFilter.js index d5fe5a7a45..f40d2c160d 100644 --- a/lib/public/components/Filters/LogsFilter/author/authorFilter.js +++ b/lib/public/components/Filters/LogsFilter/author/authorFilter.js @@ -14,19 +14,7 @@ import { h } from '/js/src/index.js'; import { iconX } from '/js/src/icons.js'; import { switchInput } from '../../../common/form/switchInput.js'; - -/** - * Returns a text input field that can be used to filter logs by author - * - * @param {AuthorFilterModel} authorFilterModel The author filter model object - * @returns {Component} A text box that allows the user to enter an author substring to match against all logs - */ -const authorFilterTextInput = (authorFilterModel) => h('input.w-40', { - type: 'text', - id: 'authorFilterText', - value: authorFilterModel.raw, - oninput: (e) => authorFilterModel.update(e.target.value), -}); +import { rawTextFilter } from '../../common/filters/rawTextFilter.js'; /** * Returns a button that can be used to reset the author filter. @@ -34,11 +22,8 @@ const authorFilterTextInput = (authorFilterModel) => h('input.w-40', { * @param {AuthorFilterModel} authorFilterModel The author filter model object * @return {Component} A button that can be used to reset the author filter */ -const resetAuthorFilterButton = (authorFilterModel) => h( - '.btn.btn-pill.f7', - { disabled: authorFilterModel.isEmpty, onclick: () => authorFilterModel.clear() }, - iconX(), -); +const resetAuthorFilterButton = (authorFilterModel) => + h('.btn.btn-pill.f7', { disabled: authorFilterModel.isEmpty, onclick: () => authorFilterModel.clear() }, iconX()); /** * Returns a toggle that can be used to exclude anonymous authors @@ -55,11 +40,11 @@ export const excludeAnonymousLogAuthorToggle = (authorFilterModel) => switchInpu /** * Returns a authorFilter component with text input, reset button, and anonymous exclusion button. * - * @param {LogModel} logModel the log model object - * @returns {Component} the author filter component + * @param {AuthorFilterModel} authorFilterModel the authorFilterModel + * @return {Component} the author filter component */ -export const authorFilter = ({ authorFilter }) => h('.flex-row.items-center.g3', [ - authorFilterTextInput(authorFilter), - resetAuthorFilterButton(authorFilter), - excludeAnonymousLogAuthorToggle(authorFilter), +export const authorFilter = (authorFilterModel) => h('.flex-row.items-center.g3', [ + rawTextFilter(authorFilterModel, { classes: ['w-50'], id: 'authorFilterText', value: authorFilterModel.raw, placeholder: 'e.g. John Doe' }), + resetAuthorFilterButton(authorFilterModel), + excludeAnonymousLogAuthorToggle(authorFilterModel), ]); diff --git a/lib/public/components/Filters/LogsFilter/created.js b/lib/public/components/Filters/LogsFilter/created.js deleted file mode 100644 index 3a86526c85..0000000000 --- a/lib/public/components/Filters/LogsFilter/created.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @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 { h } from '/js/src/index.js'; - -const DATE_FORMAT = 'YYYY-MM-DD'; - -let today = new Date(); -today.setMinutes(today.getMinutes() - today.getTimezoneOffset()); -[today] = today.toISOString().split('T'); - -/** - * Returns the creation date filter components - * @param {LogModel} logModel the log model object - * @return {vnode} Two date selection boxes to control the minimum and maximum creation dates for the log filters - */ -const createdFilter = (logModel) => { - const createdFrom = logModel.getCreatedFilterFrom(); - const createdTo = logModel.getCreatedFilterTo(); - return h('', [ - h('.f6', 'From:'), - h('input.w-75.mv1', { - type: 'date', - id: 'createdFilterFrom', - placeholder: DATE_FORMAT, - max: createdTo || today, - value: createdFrom, - oninput: (e) => logModel.setCreatedFilter('From', e.target.value, e.target.validity.valid), - }, ''), - h('.f6', 'To:'), - h('input.w-75.mv1', { - type: 'date', - id: 'createdFilterTo', - placeholder: DATE_FORMAT, - min: createdFrom, - max: today, - value: createdTo, - oninput: (e) => logModel.setCreatedFilter('To', e.target.value, e.target.validity.valid), - }, ''), - ]); -}; - -export default createdFilter; diff --git a/lib/public/components/Filters/LogsFilter/environments.js b/lib/public/components/Filters/LogsFilter/environments.js deleted file mode 100644 index 665ae9eb44..0000000000 --- a/lib/public/components/Filters/LogsFilter/environments.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @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 { h } from '/js/src/index.js'; - -/** - * Returns a filter component to filter on environment Ids, either a coma separated list of specific ids or a substring - * search - * @param {LogsOverviewModel} logModel The global model object - * @return {vnode} A text box that allows the user to enter an environment substring to match against all runs or a - * list of environment ids - */ -export const environmentFilter = (logModel) => h('input.w-75.mt1', { - type: 'text', - value: logModel.getEnvFilterRaw(), - placeholder: 'e.g. Dxi029djX, TDI59So3d...', - oninput: (e) => logModel.setEnvFilter(e.target.value), -}, ''); diff --git a/lib/public/components/Filters/LogsFilter/runs.js b/lib/public/components/Filters/LogsFilter/runs.js deleted file mode 100644 index 659d04a401..0000000000 --- a/lib/public/components/Filters/LogsFilter/runs.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @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 { h } from '/js/src/index.js'; - -/** - * Returns the runs filter component - * @param {LogModel} logsModel the log model object - * @return {vnode} A text box that allows the user to enter runNumbers to filter the logs - */ -const runsFilter = (logsModel) => h('input.w-75.mt1', { - type: 'text', - id: 'runsFilterText', - value: logsModel.getRunsFilterRaw(), - oninput: (e) => logsModel.setRunsFilter(e.target.value), -}, ''); - -export default runsFilter; diff --git a/lib/public/components/Filters/RunsFilter/BeamModeFilterModel.js b/lib/public/components/Filters/RunsFilter/BeamModeFilterModel.js index 0704fc684d..626644ae88 100644 --- a/lib/public/components/Filters/RunsFilter/BeamModeFilterModel.js +++ b/lib/public/components/Filters/RunsFilter/BeamModeFilterModel.js @@ -12,50 +12,17 @@ */ import { ObservableBasedSelectionDropdownModel } from '../../detector/ObservableBasedSelectionDropdownModel.js'; -import { FilterModel } from '../common/FilterModel.js'; /** * Beam mode filter model */ -export class BeamModeFilterModel extends FilterModel { +export class BeamModeFilterModel extends ObservableBasedSelectionDropdownModel { /** * Constructor * * @param {ObservableData>} beamModes$ observable remote data of objects representing beam modes */ constructor(beamModes$) { - super(); - this._selectionDropdownModel = new ObservableBasedSelectionDropdownModel(beamModes$, ({ name }) => ({ value: name })); - this._addSubmodel(this._selectionDropdownModel); - } - - /** - * @inheritDoc - */ - reset() { - this._selectionDropdownModel.reset(); - } - - /** - * @inheritDoc - */ - get isEmpty() { - return this._selectionDropdownModel.isEmpty; - } - - /** - * Return the underlying dropdown model - * - * @return {ObservableDropDownModel} the underlying dropdown model - */ - get selectionDropdownModel() { - return this._selectionDropdownModel; - } - - /** - * @inheritDoc - */ - get normalized() { - return this._selectionDropdownModel.selected; + super(beamModes$, ({ name }) => ({ value: name })); } } diff --git a/lib/public/components/Filters/RunsFilter/DetectorsFilterModel.js b/lib/public/components/Filters/RunsFilter/DetectorsFilterModel.js index 432ecc58df..7d75c417c8 100644 --- a/lib/public/components/Filters/RunsFilter/DetectorsFilterModel.js +++ b/lib/public/components/Filters/RunsFilter/DetectorsFilterModel.js @@ -62,11 +62,19 @@ export class DetectorsFilterModel extends FilterModel { operator: this._combinationOperatorModel.current, }; if (!this.isNone()) { - normalized.values = this._dropdownModel.selected.join(); + normalized.values = this._dropdownModel.normalized; } return normalized; } + /** + * @inheritDoc + */ + set normalized({ operator, values }) { + this._combinationOperatorModel.normalized = operator; + this._dropdownModel.normalized = values; + } + /** * Return true if the current combination operator is none * diff --git a/lib/public/components/Filters/RunsFilter/EorReasonFilterModel.js b/lib/public/components/Filters/RunsFilter/EorReasonFilterModel.js index f57c810cce..b3b1e649bf 100644 --- a/lib/public/components/Filters/RunsFilter/EorReasonFilterModel.js +++ b/lib/public/components/Filters/RunsFilter/EorReasonFilterModel.js @@ -66,6 +66,15 @@ export class EorReasonFilterModel extends FilterModel { return ret; } + /** + * @inheritDoc + */ + set normalized({ category, title, description }) { + this._category = category; + this._title = title; + this._description = description; + } + /** * Returns the EOR reason filter category * diff --git a/lib/public/components/Filters/RunsFilter/GaqFilterModel.js b/lib/public/components/Filters/RunsFilter/GaqFilterModel.js new file mode 100644 index 0000000000..fe80fb8745 --- /dev/null +++ b/lib/public/components/Filters/RunsFilter/GaqFilterModel.js @@ -0,0 +1,97 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { FilterModel } from '../common/FilterModel.js'; +import { NumericalComparisonFilterModel } from '../common/filters/NumericalComparisonFilterModel.js'; + +/** + * FilterModel that filters by the fraction of gaq that was not bad + */ +export class GaqFilterModel extends FilterModel { + /** + * Constructor + * @param {ToggleFilterModel} mcReproducibleAsNotBad model that determines if a 'not bad' status was reproduceable for a Monte Carlo. + * This param is required as multiple other filters models need to make use of the same ToggleFilterModel instance + */ + constructor(mcReproducibleAsNotBad) { + super(); + + this._notBadFraction = new NumericalComparisonFilterModel({ scale: 0.01, integer: false }); + this._addSubmodel(this._notBadFraction); + this._mcReproducibleAsNotBad = mcReproducibleAsNotBad; + + /** + * _mcReproducableAsNotBad will only be added to the normalize call notBadFraction is not empty + * So, notifying when it is empty will just send an unneeded request. + */ + this._mcReproducibleAsNotBad.visualChange$.bubbleTo(this._visualChange$); + this._mcReproducibleAsNotBad.observe(() => { + if (!this.notBadFraction.isEmpty) { + this.notify(); + } + }); + } + + /** + * @inheritDoc + */ + reset() { + this._notBadFraction.reset(); + } + + /** + * @inheritDoc + */ + get isEmpty() { + return this._notBadFraction.isEmpty; + } + + /** + * @inheritDoc + */ + get normalized() { + const normalized = { notBadFraction: this._notBadFraction.normalized }; + + if (!this.isEmpty) { + normalized.mcReproducibleAsNotBad = this._mcReproducibleAsNotBad.isToggled; + } + + return normalized; + } + + /** + * @inheritDoc + */ + set normalized({ notBadFraction, mcReproducibleAsNotBad }) { + this._notBadFraction.normalized = notBadFraction; + this._mcReproducibleAsNotBad.normalized = mcReproducibleAsNotBad; + } + + /** + * Return the underlying notBadFraction model + * + * @return {NumericalComparisonFilterModel} the filter model + */ + get notBadFraction() { + return this._notBadFraction; + } + + /** + * Return the underlying mcReproducibleAsNotBad model + * + * @return {ToggleFilterModel} the filter model + */ + get mcReproducibleAsNotBad() { + return this._mcReproducibleAsNotBad; + } +} diff --git a/lib/public/components/Filters/RunsFilter/MagnetsFilteringModel.js b/lib/public/components/Filters/RunsFilter/MagnetsFilteringModel.js index 015f991286..9e38dfbbf3 100644 --- a/lib/public/components/Filters/RunsFilter/MagnetsFilteringModel.js +++ b/lib/public/components/Filters/RunsFilter/MagnetsFilteringModel.js @@ -11,21 +11,31 @@ * or submit itself to any jurisdiction. */ -import { FilterModel } from '../common/FilterModel.js'; import { ObservableBasedSelectionDropdownModel } from '../../detector/ObservableBasedSelectionDropdownModel.js'; /** * Return the option value corresponding to a given magnets current level * * @param {MagnetsCurrentLevels} currentLevels the current levels - * @return {string} the option's value + * @return {object} the option's value */ -const magnetsCurrentLevelsToOptionValue = ({ l3, dipole }) => `${l3}kA/${dipole}kA`; +const magnetsCurrentLevelsToKey = ({ l3, dipole }) => ({ value: `${l3}kA/${dipole}kA` }); + +/** + * Return the magnets current lever based on a key string + * + * @param {object} value string containing the current levels + * @return {MagnetsCurrentLevels} + */ +const keyToMagnetsCurrentLevels = (value) => { + const [l3, dipole] = value.split('/').map((str) => parseFloat(str.slice(0, -2))); + return { l3, dipole }; +}; /** * AliceL3AndDipoleFilteringModel */ -export class MagnetsFilteringModel extends FilterModel { +export class MagnetsFilteringModel extends ObservableBasedSelectionDropdownModel { /** * Constructor * @@ -33,64 +43,31 @@ export class MagnetsFilteringModel extends FilterModel { * levels */ constructor(magnetsCurrentLevels$) { - super(); - this._selectionDropdownModel = new ObservableBasedSelectionDropdownModel( - magnetsCurrentLevels$, - (magnetsCurrentLevels) => ({ value: magnetsCurrentLevelsToOptionValue(magnetsCurrentLevels) }), - { multiple: false }, - ); - this._addSubmodel(this._selectionDropdownModel); - - this._valueToFilteringParamsMap = new Map(); - magnetsCurrentLevels$.observe(() => { - magnetsCurrentLevels$.getCurrent().match({ - - /** - * Fill map indexing current level by their corresponding value - * - * @param {MagnetsCurrentLevels[]} currentLevels the current levels to map - * @return {void} - */ - Success: (currentLevels) => { - this._valueToFilteringParamsMap = new Map(currentLevels.map(({ l3, dipole }) => [ - magnetsCurrentLevelsToOptionValue({ l3, dipole }), - { l3, dipole }, - ])); - }, - Other: () => { - this._valueToFilteringParamsMap = new Map(); - }, - }); - }); + super(magnetsCurrentLevels$, magnetsCurrentLevelsToKey, { multiple: false }); } /** * @inheritDoc */ - reset() { - this._selectionDropdownModel.reset(); - } + get normalized() { + const [selectedOption] = this.selected; - /** - * @inheritDoc - */ - get isEmpty() { - return this._selectionDropdownModel.isEmpty; - } + if (selectedOption === undefined) { + return null; + } - /** - * @inheritDoc - */ - get normalized() { - return this._valueToFilteringParamsMap.get(this._selectionDropdownModel.selected[0]) ?? null; + return keyToMagnetsCurrentLevels(selectedOption); } /** - * Return the underlying selection dropdown model + * Sets selected options based on an object containing l3 and dipole fields. + * Accounts for the options being either RemoteData or an array. * - * @return {SelectionDropdownModel} the dropdown model + * @param {MagnetsCurrentLevels} value the magnets current levels + * @param {number} value.l3 the L3 current level in kA + * @param {number} value.dipole the dipole current level in kA */ - get selectionDropdownModel() { - return this._selectionDropdownModel; + set normalized(value) { + super.normalized = magnetsCurrentLevelsToKey(value).value; } } diff --git a/lib/public/components/Filters/RunsFilter/MultiCompositionFilterModel.js b/lib/public/components/Filters/RunsFilter/MultiCompositionFilterModel.js new file mode 100644 index 0000000000..80aafc8644 --- /dev/null +++ b/lib/public/components/Filters/RunsFilter/MultiCompositionFilterModel.js @@ -0,0 +1,110 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { FilterModel } from '../common/FilterModel.js'; + +/** + * FilterModel that allows devs to create custom filters from multiple other filters during instantiation, or using putFilter + */ +export class MultiCompositionFilterModel extends FilterModel { + /** + * Constructor + * @param {Object} filters the filters that will make up the composite filter + */ + constructor(filters = {}) { + super(); + + /** + * @type {Object} + */ + this._filters = {}; + + Object.entries(filters).forEach(([key, filter]) => this.putFilter(key, filter)); + } + + /** + * Return a subfilter by key + * + * @param {string} key the key of the subfilter + * @return {FilterModel} the subfilter + */ + putFilter(key, filterModel) { + if (key in this._filters) { + return; + } + + this._filters[key] = filterModel; + this._addSubmodel(filterModel); + } + + /** + * Add new subfilter + * + * @param {string} key key of the subfilter + * @param {FilterModel} filter the the subfilter + */ + getFilter(key) { + if (!(key in this._filters)) { + throw new Error(`No filter found with key ${key}`); + } + + return this._filters[key]; + } + + /** + * @inheritDoc + */ + reset() { + Object.values(this._filters).forEach((filter) => filter.reset()); + } + + /** + * @inheritDoc + */ + get isEmpty() { + return Object.values(this._filters).every((filter) => filter.isEmpty); + } + + /** + * @inheritDoc + */ + get isInactive() { + return Object.values(this._filters).every((filter) => filter.isInactive); + } + + /** + * @inheritDoc + */ + get normalized() { + const normalized = {}; + + for (const [id, filter] of Object.entries(this._filters)) { + if (!filter.isEmpty) { + normalized[id] = filter.normalized; + } + } + + return normalized; + } + + /** + * @inheritDoc + */ + set normalized(filters) { + for (const [key, value] of Object.entries(filters)) { + if (key in this._filters) { + this._filters[key].normalized = value; + } + } + } +} diff --git a/lib/public/components/Filters/RunsFilter/RunDefinitionFilterModel.js b/lib/public/components/Filters/RunsFilter/RunDefinitionFilterModel.js index 8fb9347735..ac41defd53 100644 --- a/lib/public/components/Filters/RunsFilter/RunDefinitionFilterModel.js +++ b/lib/public/components/Filters/RunsFilter/RunDefinitionFilterModel.js @@ -1,10 +1,10 @@ import { RUN_DEFINITIONS, RunDefinition } from '../../../domain/enums/RunDefinition.js'; -import { SelectionFilterModel } from '../common/filters/SelectionFilterModel.js'; +import { SelectionModel } from '../../common/selection/SelectionModel.js'; /** * Run definition filter model */ -export class RunDefinitionFilterModel extends SelectionFilterModel { +export class RunDefinitionFilterModel extends SelectionModel { /** * Constructor */ @@ -18,7 +18,7 @@ export class RunDefinitionFilterModel extends SelectionFilterModel { * @return {boolean} true if filter is physics only */ isPhysicsOnly() { - const selectedOptions = this._selectionModel.selected; + const selectedOptions = this.selected; return selectedOptions.length === 1 && selectedOptions[0] === RunDefinition.Physics; } @@ -29,9 +29,8 @@ export class RunDefinitionFilterModel extends SelectionFilterModel { */ setPhysicsOnly() { if (!this.isPhysicsOnly()) { - this._selectionModel.selectedOptions = []; - this._selectionModel.select(RunDefinition.Physics); - this.notify(); + this.selectedOptions = []; + this.select(RunDefinition.Physics); } } diff --git a/lib/public/components/Filters/RunsFilter/TimeRangeFilter.js b/lib/public/components/Filters/RunsFilter/TimeRangeFilter.js index e765137afa..296e4f4753 100644 --- a/lib/public/components/Filters/RunsFilter/TimeRangeFilter.js +++ b/lib/public/components/Filters/RunsFilter/TimeRangeFilter.js @@ -45,6 +45,13 @@ export class TimeRangeFilterModel extends FilterModel { return normalized; } + /** + * @inheritDoc + */ + set normalized({ from, to }) { + this._timeRangeInputModel.normalized = { from, to }; + } + /** * Return the underlying time range input model * diff --git a/lib/public/components/Filters/RunsFilter/dcs.js b/lib/public/components/Filters/RunsFilter/dcs.js deleted file mode 100644 index 590eb81b78..0000000000 --- a/lib/public/components/Filters/RunsFilter/dcs.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { radioButton } from '../../common/form/inputs/radioButton.js'; -import { h } from '/js/src/index.js'; - -/** - * Filter panel for DCS toggle; ON/OFF/ANY - * @param {RunsOverviewModel} runModel the run model object - * @return {vnode} Three radio buttons inline - */ -const dcsOperationRadioButtons = (runModel) => { - const state = runModel.getDcsFilterOperation(); - const name = 'dcsFilterRadio'; - const labelAny = 'ANY'; - const labelOff = 'OFF'; - const labelOn = 'ON'; - return h('.form-group-header.flex-row.w-100', [ - radioButton({ - label: labelAny, - isChecked: state === '', - action: () => runModel.removeDcs(), - name, - }), - radioButton({ - label: labelOff, - isChecked: state === false, - action: () => runModel.setDcsFilterOperation(false), - name, - }), - radioButton({ - label: labelOn, - isChecked: state === true, - action: () => runModel.setDcsFilterOperation(true), - name, - }), - ]); -}; - -export default dcsOperationRadioButtons; diff --git a/lib/public/components/Filters/RunsFilter/ddflp.js b/lib/public/components/Filters/RunsFilter/ddflp.js deleted file mode 100644 index 74bf28f4ba..0000000000 --- a/lib/public/components/Filters/RunsFilter/ddflp.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { radioButton } from '../../common/form/inputs/radioButton.js'; -import { h } from '/js/src/index.js'; - -/** - * Filter panel for Data Distribution toggle; ON/OFF/ANY - * @param {RunsOverviewModel} runModel the run model object - * @return {vnode} Three radio buttons inline - */ -const ddflpOperationRadioButtons = (runModel) => { - const state = runModel.getDdflpFilterOperation(); - const name = 'ddFlpFilterRadio'; - const labelAny = 'ANY'; - const labelOff = 'OFF'; - const labelOn = 'ON'; - return h('.form-group-header.flex-row.w-100', [ - radioButton({ - label: labelAny, - isChecked: state === '', - action: () => runModel.removeDdflp(), - name, - }), - radioButton({ - label: labelOff, - isChecked: state === false, - action: () => runModel.setDdflpFilterOperation(false), - name, - }), - radioButton({ - label: labelOn, - isChecked: state === true, - action: () => runModel.setDdflpFilterOperation(true), - name, - }), - ]); -}; - -export default ddflpOperationRadioButtons; diff --git a/lib/public/components/Filters/RunsFilter/epn.js b/lib/public/components/Filters/RunsFilter/epn.js deleted file mode 100644 index 5e639d8afb..0000000000 --- a/lib/public/components/Filters/RunsFilter/epn.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @license - * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { radioButton } from '../../common/form/inputs/radioButton.js'; -import { h } from '/js/src/index.js'; - -/** - * Filter panel for EPN toggle; ON/OFF/ANY - * @param {RunsOverviewModel} runModel the run model object - * @return {vnode} Three radio buttons inline - */ -const epnOperationRadioButtons = (runModel) => { - const state = runModel.getEpnFilterOperation(); - const name = 'epnFilterRadio'; - const labelAny = 'ANY'; - const labelOff = 'OFF'; - const labelOn = 'ON'; - return h('.form-group-header.flex-row.w-100', [ - radioButton({ - label: labelAny, - isChecked: state === '', - action: () => runModel.removeEpn(), - name, - }), - radioButton({ - label: labelOff, - isChecked: state === false, - action: () => runModel.setEpnFilterOperation(false), - name, - }), - radioButton({ - label: labelOn, - isChecked: state === true, - action: () => runModel.setEpnFilterOperation(true), - name, - }), - ]); -}; - -export default epnOperationRadioButtons; diff --git a/lib/public/components/Filters/RunsFilter/runDefinitionFilter.js b/lib/public/components/Filters/RunsFilter/runDefinitionFilter.js index d53ba62428..2a799ff675 100644 --- a/lib/public/components/Filters/RunsFilter/runDefinitionFilter.js +++ b/lib/public/components/Filters/RunsFilter/runDefinitionFilter.js @@ -19,7 +19,4 @@ import { checkboxes } from '../common/filters/checkboxFilter.js'; * @param {RunDefinitionFilterModel} runDefinitionFilterModel run definition filter model * @return {Component} the filter */ -export const runDefinitionFilter = (runDefinitionFilterModel) => checkboxes( - runDefinitionFilterModel.selectionModel, - { selector: 'run-definition' }, -); +export const runDefinitionFilter = (runDefinitionFilterModel) => checkboxes(runDefinitionFilterModel, { selector: 'run-definition' }); diff --git a/lib/public/components/Filters/RunsFilter/runNumbersFilter.js b/lib/public/components/Filters/RunsFilter/runNumbersFilter.js deleted file mode 100644 index 1beeadee0a..0000000000 --- a/lib/public/components/Filters/RunsFilter/runNumbersFilter.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @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 { rawTextFilter } from '../common/filters/rawTextFilter.js'; - -/** - * Component to filter runs on run number - * - * @param {RawTextFilterModel} filterModel the filter model - * @return {Component} the filter - */ -export const runNumbersFilter = (filterModel) => rawTextFilter( - filterModel, - { classes: ['w-100', 'run-numbers-filter'], placeholder: 'e.g. 534454, 534455...' }, -); diff --git a/lib/public/components/Filters/RunsFilter/triggerValueFilter.js b/lib/public/components/Filters/RunsFilter/triggerValueFilter.js deleted file mode 100644 index 5addab02fe..0000000000 --- a/lib/public/components/Filters/RunsFilter/triggerValueFilter.js +++ /dev/null @@ -1,21 +0,0 @@ -import { checkboxFilter } from '../common/filters/checkboxFilter.js'; -import { TRIGGER_VALUES } from '../../../domain/enums/TriggerValue.js'; - -/** - * Returns a panel to be used by user to filter runs by trigger value - * @param {RunsOverviewModel} runModel The global model object - * @return {vnode} Multiple checkboxes for a user to select the values to be filtered. - */ -export const triggerValueFilter = (runModel) => checkboxFilter( - 'triggerValue', - TRIGGER_VALUES, - (value) => runModel.triggerValuesFilters.has(value), - (e, value) => { - if (e.target.checked) { - runModel.triggerValuesFilters.add(value); - } else { - runModel.triggerValuesFilters.delete(value); - } - runModel.triggerValuesFilters = Array.from(runModel.triggerValuesFilters); - }, -); diff --git a/lib/public/components/Filters/common/FilterModel.js b/lib/public/components/Filters/common/FilterModel.js index cc7badb53c..d16f1226f7 100644 --- a/lib/public/components/Filters/common/FilterModel.js +++ b/lib/public/components/Filters/common/FilterModel.js @@ -57,6 +57,17 @@ export class FilterModel extends Observable { throw new Error('Abstract function call'); } + /** + * Sets filters from normalised values to submodels in needed. + * + * @param {string|number|object|string[]|number[]|null} _value The value used to set filters + * @return {void} the normalized value + * @abstract + */ + set normalized(_value) { + throw new Error('Abstract function call'); + } + /** * Returns the observable notified any time there is a visual change which has no impact on the actual filter value * @@ -66,6 +77,15 @@ export class FilterModel extends Observable { return this._visualChange$; } + /** + * States if the filter is active. By default this is equivalent to isEmpty + * + * @return {boolean} true if the filter is active + */ + get isInactive() { + return this.isEmpty; + } + /** * Utility function to register a filter model as sub-filter model * diff --git a/lib/public/components/Filters/common/FilteringModel.js b/lib/public/components/Filters/common/FilteringModel.js index e937786456..2f196d4f7c 100644 --- a/lib/public/components/Filters/common/FilteringModel.js +++ b/lib/public/components/Filters/common/FilteringModel.js @@ -12,7 +12,16 @@ */ import { expandQueryLikeNestedKey } from '../../../utilities/expandNestedKey.js'; -import { Observable } from '/js/src/index.js'; +import { SelectionModel } from '../../common/selection/SelectionModel.js'; +import { FilterModel } from './FilterModel.js'; +import { buildUrl, Observable, parseUrlParameters } from '/js/src/index.js'; + +const WARNING_TYPES = Object.freeze({ + PAGE_MISMATCH: 'Page-Filter mismatch', + UNKNOWN_FILTERS: 'Unknown Filters', + UNPARSABLE_URL: 'Unparseable URL', + UNPARSABLE_FILTERS: 'Unparsable Filters', +}); /** * Model representing a filtering system, including filter inputs visibility, filters values and so on @@ -21,28 +30,45 @@ export class FilteringModel extends Observable { /** * Constructor * + * @param {QueryRouter} router router that controls the application's page navigation * @param {Object} filters the filters with their label and model + * @param {Map} warnings object reference used to define warnings. */ - constructor(filters) { + constructor(router, filters, warnings) { super(); - this._visualChange$ = new Observable(); + this._pageIdentifier = null; + this._warnings = warnings; - this._filters = filters; - this._filterModels = Object.values(filters); - for (const model of this._filterModels) { - model.bubbleTo(this); - model.visualChange$?.bubbleTo(this._visualChange$); - } + this._router = router; + this._filters = {}; + this._filterModels = []; + Object.entries(filters).forEach(([key, model]) => this.put(key, model)); + } + + /** + * Sets the page identifiers + * + * @param {string} identifier a string identifies a page from the router params. + * Used to prevent unneeded reads/writes from/to the url + * @returns {void} + */ + set pageIdentifier(identifier) { + this._pageIdentifier = identifier; } /** * Reset the filters * * @param {boolean} [notify=false] if true the model notifies its observers + * @param {boolean} [clearUrl=false] if true filters will be removed from the url * @return {void} */ - reset(notify = false) { + reset(notify = false, clearUrl = false) { + if (!this.isAnyFilterActive()) { + return; + } + for (const model of this._filterModels) { model.reset(); } @@ -50,6 +76,13 @@ export class FilteringModel extends Observable { if (notify) { this.notify(); } + + if (clearUrl) { + this._clearWarnings(); + const { params } = this._router; + params.filter = this.normalized; + this._router.go(buildUrl('?', params), false, true); + } } /** @@ -74,12 +107,7 @@ export class FilteringModel extends Observable { * @return {boolean} true if at least one filter is active */ isAnyFilterActive() { - for (const model of this._filterModels) { - if (!model.isEmpty) { - return true; - } - } - return false; + return !this._filterModels.every((model) => model.isInactive); } /** @@ -105,6 +133,123 @@ export class FilteringModel extends Observable { return this._filters[key]; } + /** + * When the user updates the displayed Objects, the filters should be placed in the URL as well + * @returns {undefined} + */ + setFilterToURL() { + const { params } = this._router; + const newParams = { ...params }; + newParams.filter = this.normalized; + + if (this._pageIdentifier === params.page) { + this._router.go(buildUrl('?', newParams), false, true); + } + + this.notify(); + } + + /** + * Compute seach parameters based a url or router + * + * @param {string} url the url that is to be parsed + * @returns {object} the serach parameters object + */ + _computeParameters(url) { + try { + return parseUrlParameters(new URL(url).searchParams); + } catch { + this._warnings.set(WARNING_TYPES.UNPARSABLE_URL, `URL could not be parsed. URL: ${url}`); + this.notify(); + return {}; + } + } + + /** + * Look for parameters used for filtering in URL and apply them in the layout if it exists + * + * @param {boolean} notify if observers should be notified after setting the filters + * @param {string|null} [url=null] the url that is to be parsed into active filters + * @returns {undefined} + */ + setFilterFromURL(notify = false, url = null) { + this._clearWarnings(); + + const params = url ? this._computeParameters(url) : this._router.params; + const { page, filter } = params; + + if (this._pageIdentifier !== page) { + if (url && page) { // 'page' might be undefined if the url is unparsable + this._warnings.set(WARNING_TYPES.PAGE_MISMATCH, `The filters provided were meant for ${page}`); + } + } else { + if (!filter) { + this.reset(); + return; + } + + const { setFilterErrors, unknownFilters } = this._setFilters(filter); + + if (setFilterErrors.length > 0) { + this._warnings.set( + WARNING_TYPES.UNPARSABLE_FILTERS, + `The following filter-value pairs could not be parsed: [${setFilterErrors.join(', ')}]`, + ); + } + + if (unknownFilters.length > 0) { + this._warnings.set( + WARNING_TYPES.UNKNOWN_FILTERS, + `The filters: [${unknownFilters.join(', ')}]; are not reccognised. Check if they are spelled correctly.`, + ); + } + + if (url) { + this._router.go(buildUrl('?', params), false, true); + } + } + + if (notify) { + this.notify(); + } + } + + /** + * Clear all filter-related warnings from the warnings map + * + * @returns {undefined} + */ + _clearWarnings() { + for (const key in Object.keys(WARNING_TYPES)) { + this._warnings.delete(key); + } + } + + /** + * Sets all filters using their normalized setters + * + * @param {object} an object containging the uknown filters and the filters that failed to parse + */ + _setFilters(filters) { + const unknownFilters = []; + const setFilterErrors = []; + + for (const [key, value] of Object.entries(filters)) { + if (key in this._filters) { + try { + this._filters[key].normalized = value; + } catch { + setFilterErrors.push(`${buildUrl('', { [key]: value }).slice(1)}`); + } + } else { + unknownFilters.push(`'${key}'`); + } + } + + return { unknownFilters, setFilterErrors }; + } + /** * Add new filter * @@ -118,9 +263,13 @@ export class FilteringModel extends Observable { return; } + if (!(filter instanceof FilterModel || filter instanceof SelectionModel)) { + throw new Error('Filter must extend FilterModel or SelectionModel'); + } + this._filters[key] = filter; this._filterModels.push(filter); - filter.bubbleTo(this); + filter.observe(() => this.setFilterToURL()); filter.visualChange$?.bubbleTo(this._visualChange$); } } diff --git a/lib/public/components/Filters/common/RadioButtonFilterModel.js b/lib/public/components/Filters/common/RadioButtonFilterModel.js new file mode 100644 index 0000000000..0aaa6e70af --- /dev/null +++ b/lib/public/components/Filters/common/RadioButtonFilterModel.js @@ -0,0 +1,48 @@ +/** + * @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 { SelectionModel } from '../../common/selection/SelectionModel.js'; + +/** + * Model for managing a radiobutton view and state + */ +export class RadioButtonFilterModel extends SelectionModel { + /** + * Constructor + * + * @param {SelectionOption[]} [availableOptions] the list of possible operators + * @param {function} [setDefault] function that selects the default from the list of available options. Selects first entry by default + * @param {boolean} [defaultIsEmpty] if true, the default selection will be treated as empty + */ + constructor(availableOptions, setDefault = (options) => [options[0]], defaultIsEmpty = true) { + super({ + availableOptions, + defaultSelection: setDefault(availableOptions), + multiple: false, + allowEmpty: false, + }); + + this._defaultIsEmpty = defaultIsEmpty; + } + + /** + * @inheritdoc + */ + get isEmpty() { + if (this._defaultIsEmpty) { + return this.hasOnlyDefaultSelection(); + } + + return false; + } +} diff --git a/lib/public/components/Filters/common/TagFilterModel.js b/lib/public/components/Filters/common/TagFilterModel.js index c3ce81e09f..e92d129eed 100644 --- a/lib/public/components/Filters/common/TagFilterModel.js +++ b/lib/public/components/Filters/common/TagFilterModel.js @@ -58,11 +58,19 @@ export class TagFilterModel extends FilterModel { */ get normalized() { return { - values: this.selected.join(), - operation: this.combinationOperator, + values: this._selectionModel.normalized, + operation: this._combinationOperatorModel.normalized, }; } + /** + * @inheritDoc + */ + set normalized({ values, operation }) { + this._selectionModel.normalized = values; + this._combinationOperatorModel.normalized = operation; + } + /** * Return the model handling tag selection state * diff --git a/lib/public/components/Filters/common/filters/FilterInputModel.js b/lib/public/components/Filters/common/filters/FilterInputModel.js deleted file mode 100644 index 8860edf61d..0000000000 --- a/lib/public/components/Filters/common/filters/FilterInputModel.js +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @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 { Observable } from '/js/src/index.js'; - -/** - * Model for a generic filter input - */ -export class FilterInputModel extends Observable { - /** - * Constructor - */ - constructor() { - super(); - - this._value = null; - this._raw = ''; - - this._visualChange$ = new Observable(); - } - - /** - * Define the current value of the filter - * - * @param {string} raw the raw value of the filter - * @return {void} - */ - update(raw) { - const previousValues = this.value; - - this._value = this.valueFromRaw(raw); - this._raw = raw; - - if (this.areValuesEquals(this.value, previousValues)) { - // Only raw value changed - this._visualChange$.notify(); - } else { - this.notify(); - } - } - - /** - * Reset the filter to its default value - * - * @return {void} - */ - reset() { - this._value = null; - this._raw = ''; - } - - /** - * Returns the raw value of the filter (the user input) - * - * @return {string} the raw value - */ - get raw() { - return this._raw; - } - - /** - * Return the parsed values of the filter - * - * @return {*} the parsed values - */ - get value() { - return this._value; - } - - /** - * States if the filter has been filled - * - * @return {boolean} true if the filter has been filled - */ - get isEmpty() { - return !this.value; - } - - /** - * Returns the observable notified any time there is a visual change which has no impact on the actual filter value - * - * @return {Observable} the observable - */ - get visualChange$() { - return this._visualChange$; - } - - /** - * Returns the processed value from raw input - * - * @param {string} raw the raw input value - * @return {*} the processed value - * @protected - */ - valueFromRaw(raw) { - return raw.trim(); - } - - /** - * Compares two values - * - * @param {*} first the first value - * @param {*} second the second value - * @return {boolean} true if the values are equals - * @protected - */ - areValuesEquals(first, second) { - return first === second; - } -} diff --git a/lib/public/components/Filters/common/filters/NumericalComparisonFilterModel.js b/lib/public/components/Filters/common/filters/NumericalComparisonFilterModel.js index ee00126389..843500ad1f 100644 --- a/lib/public/components/Filters/common/filters/NumericalComparisonFilterModel.js +++ b/lib/public/components/Filters/common/filters/NumericalComparisonFilterModel.js @@ -27,6 +27,7 @@ export class NumericalComparisonFilterModel extends FilterModel { constructor(options) { super(); const { scale = 1, integer = false } = options || {}; + this._scale = scale; this._operatorSelectionModel = new ComparisonSelectionModel(); this._operatorSelectionModel.visualChange$.bubbleTo(this._visualChange$); @@ -82,11 +83,25 @@ export class NumericalComparisonFilterModel extends FilterModel { */ get normalized() { return { - operator: this._operatorSelectionModel.current, - limit: this._operandInputModel.value, + operator: this._operatorSelectionModel.normalized, + limit: this._operandInputModel.normalized, }; } + /** + * @inheritDoc + */ + set normalized({ operator, limit }) { + const numericLimit = parseFloat(limit); + const scaledLimit = numericLimit / this._scale; + + if (!isNaN(numericLimit) || !isNaN(scaledLimit)) { + this._operandInputModel.normalized = { value: numericLimit, raw: scaledLimit }; + } + + this._operatorSelectionModel.normalized = operator; + } + /** * @inheritDoc */ diff --git a/lib/public/components/Filters/common/filters/ProcessedTextInputModel.js b/lib/public/components/Filters/common/filters/ProcessedTextInputModel.js index 9e46fe95b5..d9488cd8f1 100644 --- a/lib/public/components/Filters/common/filters/ProcessedTextInputModel.js +++ b/lib/public/components/Filters/common/filters/ProcessedTextInputModel.js @@ -98,6 +98,27 @@ export class ProcessedTextInputModel extends Observable { this._value = null; } + /** + * Returns the normalized value of the filter, that can be used as URL parameter + * @returns {string} + */ + get normalized() { + return this._value; + } + + /** + * Sets filters from normalised values. + * + * @param {string} value The value used to set the parsed value + * @param {string} raw The value used to set the raw value + * @return {void} + * @abstract + */ + set normalized({ value, raw }) { + this._value = value; + this._raw = raw; + } + /** * Return the visual change observable * diff --git a/lib/public/components/Filters/common/filters/RawTextFilterModel.js b/lib/public/components/Filters/common/filters/RawTextFilterModel.js index f996b7b976..d156c86e10 100644 --- a/lib/public/components/Filters/common/filters/RawTextFilterModel.js +++ b/lib/public/components/Filters/common/filters/RawTextFilterModel.js @@ -35,6 +35,13 @@ export class RawTextFilterModel extends FilterModel { return this._value; } + /** + * @inheritDoc + */ + set normalized(value) { + this._value = value; + } + /** * Return the filter current value * diff --git a/lib/public/components/Filters/common/filters/SelectionFilterModel.js b/lib/public/components/Filters/common/filters/SelectionFilterModel.js deleted file mode 100644 index 4bb602d7aa..0000000000 --- a/lib/public/components/Filters/common/filters/SelectionFilterModel.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * @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 { FilterModel } from '../FilterModel.js'; -import { SelectionModel } from '../../../common/selection/SelectionModel.js'; - -/** - * Filter model based on a selection model - */ -export class SelectionFilterModel extends FilterModel { - /** - * Constructor - * - * @param {object} [configuration] the selection filter configuration - * @param {SelectionOption[]} [configuration.availableOptions=[]] the list of available options - */ - constructor(configuration) { - super(); - - this._selectionModel = new SelectionModel({ availableOptions: configuration.availableOptions }); - this._selectionModel.bubbleTo(this); - } - - /** - * @inheritDoc - */ - reset() { - this._selectionModel.reset(); - } - - /** - * @inheritDoc - */ - get isEmpty() { - return this._selectionModel.isEmpty; - } - - /** - * @inheritDoc - */ - get normalized() { - return this._selectionModel.selected.join(','); - } - - /** - * Return the underlying selection model - * - * @return {SelectionModel} the underlying selection model - */ - get selectionModel() { - return this._selectionModel; - } -} diff --git a/lib/public/components/Filters/common/filters/TextComparisonFilterModel.js b/lib/public/components/Filters/common/filters/TextComparisonFilterModel.js index b6510f8fae..7f843d6295 100644 --- a/lib/public/components/Filters/common/filters/TextComparisonFilterModel.js +++ b/lib/public/components/Filters/common/filters/TextComparisonFilterModel.js @@ -64,11 +64,19 @@ export class TextComparisonFilterModel extends FilterModel { */ get normalized() { return { - operator: this._operatorSelectionModel.current, - limit: this._operandInputModel.value, + operator: this._operatorSelectionModel.normalized, + limit: this._operandInputModel.normalized, }; } + /** + * @inheritDoc + */ + set normalized({ operator, limit }) { + this._operatorSelectionModel.normalized = operator; + this._operandInputModel.normalized = limit; + } + /** * @inheritDoc */ diff --git a/lib/public/components/Filters/common/filters/TextTokensFilterModel.js b/lib/public/components/Filters/common/filters/TextTokensFilterModel.js index 60e192febe..8c838e5abf 100644 --- a/lib/public/components/Filters/common/filters/TextTokensFilterModel.js +++ b/lib/public/components/Filters/common/filters/TextTokensFilterModel.js @@ -78,6 +78,13 @@ export class TextTokensFilterModel extends FilterModel { .filter((token) => token.length > 0); } + /** + * @inheritDoc + */ + set normalized(value) { + this._raw = value.join(TOKENS_DELIMITER); + } + /** * Returns the observable notified any time there is a visual change which has no impact on the actual filter value * @return {Observable} the observable diff --git a/lib/public/components/Filters/common/filters/TimeRangeInputModel.js b/lib/public/components/Filters/common/filters/TimeRangeInputModel.js index 54ee3fe7b0..66a4481847 100644 --- a/lib/public/components/Filters/common/filters/TimeRangeInputModel.js +++ b/lib/public/components/Filters/common/filters/TimeRangeInputModel.js @@ -142,6 +142,14 @@ export class TimeRangeInputModel extends FilterModel { }; } + /** + * @inheritDoc + */ + set normalized({ from, to }) { + this._fromTimeInputModel.setValue(parseInt(from, 10), true); + this._toTimeInputModel.setValue(parseInt(to, 10), true); + } + /** * States if the filter value is valid * diff --git a/lib/public/components/Filters/common/filters/ToggleFilterModel.js b/lib/public/components/Filters/common/filters/ToggleFilterModel.js new file mode 100644 index 0000000000..ee22703852 --- /dev/null +++ b/lib/public/components/Filters/common/filters/ToggleFilterModel.js @@ -0,0 +1,74 @@ +/** + * @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 { SelectionModel } from '../../../common/selection/SelectionModel.js'; + +/** + * SelectionModel that restricts the selection to a boolean toggle (true/false). + */ +export class ToggleFilterModel extends SelectionModel { + /** + * Constructor + * @param {boolean} toggledByDefault If the filter should be toggled by default + * @param {boolean} defaultIsInactive if true, will treat the untoggled state (false) as empty. + */ + constructor(toggledByDefault = false, defaultIsInactive = false) { + super({ + availableOptions: [{ value: true }, { value: false }], + defaultSelection: [{ value: toggledByDefault }], + multiple: false, + allowEmpty: false, + }); + + this._defaultIsInactive = defaultIsInactive; + } + + /** + * Returns true if the current value is set to true + * + * @return {boolean} true if filter is stable beams only + */ + get isToggled() { + return this.current; + } + + /** + * Toggles the filter state + * + * @return {void} + */ + toggle() { + this.select({ value: !this.current }); + } + + /** + * Toggles are always filled, as 'false' / untoggled is also considered a value + * + * @return {boolean} `false` + */ + get isEmpty() { + return false; + } + + /** + * Returns if the toggle filter is considered 'inactive' + * + * @return {boolean} + */ + get isInactive() { + if (this._defaultIsInactive) { + return this.hasOnlyDefaultSelection(); + } + + return false; + } +} diff --git a/lib/public/components/Filters/common/filters/checkboxFilter.js b/lib/public/components/Filters/common/filters/checkboxFilter.js index dcfcb4a95b..2cf550c091 100644 --- a/lib/public/components/Filters/common/filters/checkboxFilter.js +++ b/lib/public/components/Filters/common/filters/checkboxFilter.js @@ -14,32 +14,6 @@ import { h } from '/js/src/index.js'; -/** - * A general component for generating checkboxes. - * - * @param {string} name The general name of the element. - * @param {Array} values the list of options to display - * @param {function} isChecked true if the checkbox is checked, else false - * @param {function} onChange the handler called once the checkbox state changes (change event is passed as first parameter, value as second) - * @param {Object} [additionalProperties] Additional options that can be given to the class. - * @returns {vnode} An object that has one or multiple checkboxes. - * @deprecated use checkboxes - */ -export const checkboxFilter = (name, values, isChecked, onChange, additionalProperties) => - h('.flex-row.flex-wrap', values.map((value) => h('.form-check.flex-grow', [ - h('input.form-check-input', { - id: `${name}Checkbox${value}`, - class: name, - type: 'checkbox', - checked: isChecked(value), - onchange: (e) => onChange(e, value), - ...additionalProperties || {}, - }), - h('label.form-check-label', { - for: `${name}Checkbox${value}`, - }, value.toUpperCase()), - ]))); - /** * Display a filter composed of checkbox listing pre-defined options * @param {SelectionModel} selectionModel filter model diff --git a/lib/public/components/Filters/common/filters/radioButtonFilter.js b/lib/public/components/Filters/common/filters/radioButtonFilter.js new file mode 100644 index 0000000000..88f42c610a --- /dev/null +++ b/lib/public/components/Filters/common/filters/radioButtonFilter.js @@ -0,0 +1,38 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { radioButton } from '../../../common/form/inputs/radioButton.js'; +import { h } from '/js/src/index.js'; + +/** + * Radio button filter component + * + * @param {RadioSelectionModel} selectionModel the a selectionmodel + * @param {string} filterName the name of the filter + * @return {vnode} A number of radio buttons corresponding with the selection options + */ +const radioButtonFilter = (selectionModel, filterName) => { + const name = `${filterName}FilterRadio`; + return h( + '.flex-row.w-100', + selectionModel.options.map((option) => { + const { label } = option; + const action = () => selectionModel.select(option); + const isChecked = selectionModel.isSelected(option); + + return radioButton({ label, isChecked, action, name }); + }), + ); +}; + +export default radioButtonFilter; diff --git a/lib/public/components/Filters/common/filters/textFilter.js b/lib/public/components/Filters/common/filters/textFilter.js index 6b288d54ac..d6ae0cdfa4 100644 --- a/lib/public/components/Filters/common/filters/textFilter.js +++ b/lib/public/components/Filters/common/filters/textFilter.js @@ -16,13 +16,13 @@ import { h } from '/js/src/index.js'; /** * Returns a text filter component * - * @param {FilterInputModel|TextTokensFilterModel} filterInputModel the model of the text filter + * @param {TextTokensFilterModel} textTokensFilterModel the model of the text filter * @param {Object} attributes the additional attributes to pass to the component, such as id and classes * @return {Component} the filter component */ -export const textFilter = (filterInputModel, attributes) => h('input', { +export const textFilter = (textTokensFilterModel, attributes) => h('input', { ...attributes, type: 'text', - value: filterInputModel.raw, - oninput: (e) => filterInputModel.update(e.target.value), + value: textTokensFilterModel.raw, + oninput: (e) => textTokensFilterModel.update(e.target.value), }, ''); diff --git a/lib/public/components/Filters/common/filters/textInputFilter.js b/lib/public/components/Filters/common/filters/textInputFilter.js new file mode 100644 index 0000000000..27a36f112d --- /dev/null +++ b/lib/public/components/Filters/common/filters/textInputFilter.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 { rawTextFilter } from './rawTextFilter.js'; + +/** + * Standardised component for a rawTextFilter that span the width of their container + * + * @param {FilteringModel} filteringModel the page's filteringModel + * @param {string} key the identifier to serve as css selector and to fetch the correct filter from the filteringModel + * @param {string} placeholder placeholder text for the input element + * @param {string} width class that determines the width of the input + * @return {Component} the filter + */ +export const textInputFilter = (filteringModel, key, placeholder, widthClass = 'w-100') => + rawTextFilter(filteringModel.get(key), { classes: [widthClass, `${key}-textFilter`], placeholder }); diff --git a/lib/public/components/Filters/common/filters/toggleFilter.js b/lib/public/components/Filters/common/filters/toggleFilter.js new file mode 100644 index 0000000000..ac37063779 --- /dev/null +++ b/lib/public/components/Filters/common/filters/toggleFilter.js @@ -0,0 +1,45 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE Trg. 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-Trg.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 { h } from '/js/src/index.js'; +import { switchInput } from '../../../common/form/switchInput.js'; +import { radioButton } from '../../../common/form/inputs/radioButton.js'; + +/** + * Display a toggle switch or radio buttons for toggle filters + * + * @param {ToggleFilterModel} toggleFilterModel a ToggleFilterModel + * @param {name} toggleFilterModel the name used to identify and label the filter + * @param {boolean} radioButtonMode define whether or not to return radio buttons or a switch. + * @returns {Component} the toggle switch + */ +export const toggleFilter = (toggleFilterModel, name, id, radioButtonMode = false) => { + if (radioButtonMode) { + return h('.flex-row.w-100', [ + radioButton({ + label: 'OFF', + isChecked: !toggleFilterModel.isToggled, + action: () => toggleFilterModel.toggle(), + name, + }), + radioButton({ + label: 'ON', + isChecked: toggleFilterModel.isToggled, + action: () => toggleFilterModel.toggle(), + name, + }), + ]); + } + + return h('', switchInput(toggleFilterModel.isToggled, () => toggleFilterModel.toggle(), { labelAfter: name, id })); +}; diff --git a/lib/public/components/Filters/common/filtersPanelPopover.js b/lib/public/components/Filters/common/filtersPanelPopover.js index e0c0a7490c..648427db34 100644 --- a/lib/public/components/Filters/common/filtersPanelPopover.js +++ b/lib/public/components/Filters/common/filtersPanelPopover.js @@ -10,7 +10,8 @@ * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. */ -import { h, info, popover, PopoverAnchors, PopoverTriggerPreConfiguration } from '/js/src/index.js'; +import { h, info, popover, PopoverAnchors, PopoverTriggerPreConfiguration, DropdownComponent, CopyToClipboardComponent } from '/js/src/index.js'; +import { iconCaretBottom } from '/js/src/icons.js'; import { profiles } from '../../common/table/profiles.js'; import { applyProfile } from '../../../utilities/applyProfile.js'; import { tooltip } from '../../common/popover/tooltip.js'; @@ -35,7 +36,27 @@ import { tooltip } from '../../common/popover/tooltip.js'; * * @return {Component} the button component */ -const filtersToggleTrigger = () => h('button#openFilterToggle.btn.btn.btn-primary', 'Filters'); +const filtersToggleTrigger = () => h('button#openFilterToggle.btn.btn.btn-primary.first-item', 'Filters'); + +/** + * Button component that resets all filters upon click + * + * @param {FilteringModel|OverviewPageModel} filteringModel the FilteringModel + * @param {bool} [isIcon=false] if the component is rendered as a regular button with text or as a component with an 'X' icon + * @returns {Component} the reset button component + */ +const resetFiltersButton = (filteringModel, isIcon = false) => { + const attributes = { + disabled: !filteringModel.isAnyFilterActive(), + onclick: () => filteringModel.resetFiltering + ? filteringModel.resetFiltering(true, true) + : filteringModel.reset(true, true), + }; + + return isIcon + ? h('.clear-filter-icon-container.btn-group-item.last-item.pulse-red', attributes, h('.clear-filter-icon.b1.b-danger', 'X')) + : h('button#reset-filters.btn.btn-danger', attributes, 'Reset all filters'); +}; /** * Create main header of the filters panel @@ -44,16 +65,7 @@ const filtersToggleTrigger = () => h('button#openFilterToggle.btn.btn.btn-primar */ const filtersToggleContentHeader = (filteringModel) => h('.flex-row.justify-between', [ h('.f4', 'Filters'), - h( - 'button#reset-filters.btn.btn-danger', - { - onclick: () => filteringModel.resetFiltering - ? filteringModel.resetFiltering() - : filteringModel.reset(true), - disabled: !filteringModel.isAnyFilterActive(), - }, - 'Reset all filters', - ), + resetFiltersButton(filteringModel), ]); /** @@ -114,9 +126,9 @@ const filtersToggleContent = ( * @param {FiltersConfiguration} filtersConfiguration filters configuration * @param {object} [configuration] optional configuration * @param {string} [configuration.profile] specify for which profile filtering should be enabled - * @return {Component} the filter component + * @return {Component} the filter button component */ -export const filtersPanelPopover = (filteringModel, filtersConfiguration, configuration) => popover( +const filtersPanelButton = (filteringModel, filtersConfiguration, configuration) => popover( filtersToggleTrigger(), filtersToggleContent(filteringModel, filtersConfiguration, configuration), { @@ -124,3 +136,94 @@ export const filtersPanelPopover = (filteringModel, filtersConfiguration, config anchor: PopoverAnchors.RIGHT_START, }, ); + +/** + * A button component that lets the user copy the url if there are active filters. + * + * @param {boolean} activeFilters if false, will disable the button + * @returns {Component} the copy button component + */ +const copyButtonOption = (activeFilters) => h( + '', + { style: activeFilters ? {} : { opacity: 0.5, pointerEvents: 'none' } }, + h(CopyToClipboardComponent, { value: location.href, id: 'filters' }, 'Copy Active Filters'), +); + +/** + * A button component that lets the user paste the first entry of their clipboard as a filter url. + * + * @param {FilteringModel|OverviewPageModel} model the FilteringModel + * @returns {Component} the paste button component + */ +const pasteButtonOption = (model) => { + const clipboardSupported = navigator?.clipboard && window.isSecureContext; + + // Sometimes, the overview model is passed to filterPanelPopover instead of the filteringmodel (e.g. envirionments) + const { filteringModel = model } = model; + + return h('button.btn.btn-primary', { + onclick: async () => { + const url = await navigator.clipboard.readText(); + filteringModel.setFilterFromURL(true, url); + }, + disabled: !clipboardSupported, + id: 'paste-filters', + }, 'Paste filters'); +}; + +/** + * A indicates if any filters are currently active on the page + * + * @param {FilteringModel} model the filtering model + * @returns {Component} the active filters indicator + */ +const activeFilterIndicator = (model) => { + // Sometimes, the overview model is passed to filterPanelPopover instead of the filteringmodel (e.g. envirionments) + const { filteringModel = model } = model; + + const hasActiveFilters = filteringModel.isAnyFilterActive(); + const innerText = `Filters ${hasActiveFilters ? 'Active' : 'Inactive'}`; + + let indicator = '.active-filters-indicator.b1'; + indicator += hasActiveFilters ? '.b-success.success.pulse-green' : '.inactive'; + + const children = [h(indicator, innerText)]; + + if (hasActiveFilters) { + children.push(resetFiltersButton(filteringModel, true)); + } + + return h('.flex-row.items-center', children); +}; + +/** + * Return component composed of the filter popover button and a dropdown trigger + * + * @param {FilteringModel} filteringModel the filtering model + * @param {FiltersConfiguration} filtersConfiguration filters configuration + * @param {object} [configuration] optional configuration + * @param {string} [configuration.profile] specify for which profile filtering should be enabled + * @return {Component} the filter component + */ +export const filtersPanelPopover = (filteringModel, filtersConfiguration, configuration) => { + const hasActiveFilters = filteringModel.isAnyFilterActive(); + + return h( + '.flex-row.items-center.btn-group', + [ + filtersPanelButton(filteringModel, filtersConfiguration, configuration), + DropdownComponent( + h('.btn.btn-group-item.last-item', iconCaretBottom()), + h( + '.flex-column.p2.g2', + [ + copyButtonOption(hasActiveFilters), + pasteButtonOption(filteringModel), + resetFiltersButton(filteringModel), + ], + ), + ), + activeFilterIndicator(filteringModel), + ], + ); +}; diff --git a/lib/public/components/common/form/inputs/DateTimeInputModel.js b/lib/public/components/common/form/inputs/DateTimeInputModel.js index 2aec85f59f..69456fd95d 100644 --- a/lib/public/components/common/form/inputs/DateTimeInputModel.js +++ b/lib/public/components/common/form/inputs/DateTimeInputModel.js @@ -65,13 +65,15 @@ export class DateTimeInputModel extends Observable { */ update(raw) { this._raw = raw; + const hasDateAndTime = raw.date && raw.time; + try { - this._value = raw.date && raw.time ? extractTimestampFromDateTimeInput(raw, { seconds: this._seconds }) : null; + this._value = hasDateAndTime ? extractTimestampFromDateTimeInput(raw) : null; } catch { this._value = null; } - this.notify(); + hasDateAndTime && this.notify(); } /** @@ -121,6 +123,10 @@ export class DateTimeInputModel extends Observable { return; } + if (isNaN(value)) { + return; + } + this._value = value; this._raw = value !== null ? formatTimestampForDateTimeInput(value, this._seconds) diff --git a/lib/public/components/common/form/switchInput.js b/lib/public/components/common/form/switchInput.js index ad7f7f8135..f06cb5154a 100644 --- a/lib/public/components/common/form/switchInput.js +++ b/lib/public/components/common/form/switchInput.js @@ -32,7 +32,7 @@ import { h } from '/js/src/index.js'; * @return {Component} the switch component */ export const switchInput = (value, onChange, options) => { - const { key, labelAfter, labelBefore, color } = options || {}; + const { key, labelAfter, labelBefore, color, id } = options || {}; const attributes = { ...key ? { key } : {} }; return h( @@ -40,7 +40,7 @@ export const switchInput = (value, onChange, options) => { attributes, [ labelBefore, - h('.switch', [ + h('.switch', { id }, [ h('input', { onchange: (e) => onChange(e.target.checked), type: 'checkbox', diff --git a/lib/public/components/common/messages/warningComponent.js b/lib/public/components/common/messages/warningComponent.js new file mode 100644 index 0000000000..1c37ddf5d7 --- /dev/null +++ b/lib/public/components/common/messages/warningComponent.js @@ -0,0 +1,35 @@ +import { h } from '/js/src/index.js'; +import { iconX } from '/js/src/icons.js'; + +/** + * Component to display whenever a page has warnings. + * + * @param {OverviewPageModel} overviewModel model that controlls an overview page + * @returns {Component} the warning componen + */ +export const warningComponent = (overviewModel) => { + const { warnings } = overviewModel; + + if (!warnings.size) { + return null; + } + + return h('details.alert.alert-warning', { open: true }, [ + h('summary', 'Warnings'), + h('ul', warnings.entries().toArray().map(([key, message]) => + h('li.flex-row.items-center', [ + h( + '.btn.btn-pill.alert-warning.mh1', + { + onclick: () => { + warnings.delete(key); + overviewModel.notify(); + }, + }, + iconX(), + ), + h('strong.mh1', `${key}:`), + h('span', message), + ]))), + ]); +}; diff --git a/lib/public/components/common/selection/SelectionModel.js b/lib/public/components/common/selection/SelectionModel.js index 8b28aa28d1..b9926b4f32 100644 --- a/lib/public/components/common/selection/SelectionModel.js +++ b/lib/public/components/common/selection/SelectionModel.js @@ -42,6 +42,12 @@ export class SelectionModel extends Observable { super(); const { availableOptions = [], defaultSelection = [], multiple = true, allowEmpty = true } = configuration || {}; + /** + * @type {SelectionOption[]} + * @protected + */ + this._selectionBacklog = []; + /** * @type {RemoteData|SelectionOption[]} * @protected @@ -107,6 +113,15 @@ export class SelectionModel extends Observable { return selected.length === defaultSelection.length && selected.every((item) => defaultSelection.includes(item)); } + /** + * States if the filter is active. By default this is equivalent to isEmpty + * + * @return {boolean} true if the filter is active + */ + get isInactive() { + return this.isEmpty; + } + /** * Reset the selection to the default * @@ -243,7 +258,7 @@ export class SelectionModel extends Observable { } /** - * Defines the list of available options + * Defines the list of available options and if there is a selection backlog, these will be applied * * @param {RemoteData|SelectionOption[]} availableOptions the new available options * @return {void} @@ -251,6 +266,11 @@ export class SelectionModel extends Observable { setAvailableOptions(availableOptions) { this._availableOptions = availableOptions; this.visualChange$.notify(); + + if (this._selectionBacklog.length) { + this.selectedOptions = this._selectionBacklog; + this.notify(); + } } /** @@ -315,12 +335,19 @@ export class SelectionModel extends Observable { } /** - * Define (overrides) the list of currently selected options + * Define (overrides) the list of currently selected options. + * Invalid selection options are excluded * * @param {SelectionOption[]} selected the list of selected options */ set selectedOptions(selected) { - this._selectedOptions = selected; + let { options } = this; + + if (this.options instanceof RemoteData) { + options = options.isSuccess() ? options.payload : []; + } + + this._selectedOptions = options.filter((option) => selected.some(({ value }) => String(value) === String(option.value)));; } /** @@ -331,4 +358,40 @@ export class SelectionModel extends Observable { get optionsSelectedByDefault() { return this._defaultSelection; } + + /** + * Sets selected options based on a comma-seperated string. + * Accounts for the options being either RemoteData or an array. + * + * @param {string} value the value that is to be set. + */ + set normalized(value) { + const options = value.split(',').map((option) => ({ value: option.trim() })); + const isRemoteData = this.options instanceof RemoteData; + const noOptions = !this.options?.length; + + if (isRemoteData) { + this._availableOptions.match({ + Success: (_) => { + this.selectedOptions = options; + }, + Other: () => { + this._selectionBacklog = options; + }, + }); + } else if (noOptions) { + this._selectionBacklog = options; + } else { + this.selectedOptions = options; + } + } + + /** + * Returns the normalized value of the selection + * + * @return {string|string[]|boolean|boolean[]|number|number[]|SelectionOption|SelectionOption[]} the normalized value + */ + get normalized() { + return (this._allowEmpty || this._multiple) ? this.selected.join(',') : this.current; + } } diff --git a/lib/public/components/runEorReasons/runEorReasonSelection.js b/lib/public/components/runEorReasons/runEorReasonSelection.js index dbe86cde87..c7a3ad14a3 100644 --- a/lib/public/components/runEorReasons/runEorReasonSelection.js +++ b/lib/public/components/runEorReasons/runEorReasonSelection.js @@ -22,6 +22,7 @@ import { h } from '/js/src/index.js'; */ export const eorReasonFilterComponent = (eorReasonFilterModel, eorReasonTypes) => { const eorReasonsCategories = [...new Set(eorReasonTypes.map(({ category }) => category))]; + const { category: currentCategory, title: currentTitle } = eorReasonFilterModel; return [ h('.flex-row', [ @@ -36,7 +37,7 @@ export const eorReasonFilterComponent = (eorReasonFilterModel, eorReasonTypes) = h('option', { selected: eorReasonFilterModel.category === '', value: '' }, '-'), eorReasonsCategories.map((category, index) => h( `option#eorCategory${index}`, - { key: category, value: category }, + { key: category, value: category, selected: category === currentCategory }, category, )), ], @@ -54,7 +55,7 @@ export const eorReasonFilterComponent = (eorReasonFilterModel, eorReasonTypes) = .filter((reason) => reason.category === eorReasonFilterModel.category) .map(({ title }, index) => h( `option#eorTitle${index}`, - { key: title, value: title }, + { key: title, value: title, selected: title === currentTitle }, title || '(empty)', )), ], diff --git a/lib/public/components/runTypes/RunTypesFilterModel.js b/lib/public/components/runTypes/RunTypesFilterModel.js index 60a923cbc6..9767fb0e08 100644 --- a/lib/public/components/runTypes/RunTypesFilterModel.js +++ b/lib/public/components/runTypes/RunTypesFilterModel.js @@ -12,51 +12,18 @@ */ import { runTypeToOption } from './runTypeToOption.js'; -import { FilterModel } from '../Filters/common/FilterModel.js'; import { ObservableBasedSelectionDropdownModel } from '../detector/ObservableBasedSelectionDropdownModel.js'; /** * Model storing state of a selection of run types picked from the list of all the existing run types */ -export class RunTypesFilterModel extends FilterModel { +export class RunTypesFilterModel extends ObservableBasedSelectionDropdownModel { /** * Constructor * * @param {ObservableData>} runTypes$ observable remote data of run types list */ constructor(runTypes$) { - super(); - this._selectionDropdownModel = new ObservableBasedSelectionDropdownModel(runTypes$, runTypeToOption); - this._addSubmodel(this._selectionDropdownModel); - } - - /** - * @inheritDoc - */ - reset() { - this._selectionDropdownModel.reset(); - } - - /** - * @inheritDoc - */ - get isEmpty() { - return this._selectionDropdownModel.isEmpty; - } - - /** - * @inheritDoc - */ - get normalized() { - return this._selectionDropdownModel.selected; - } - - /** - * Return the underlying selection dropdown model - * - * @return {SelectionDropdownModel} the selection dropdown model - */ - get selectionDropdownModel() { - return this._selectionDropdownModel; + super(runTypes$, runTypeToOption); } } diff --git a/lib/public/domain/enums/DetectorOrders.js b/lib/public/domain/enums/DetectorOrders.js new file mode 100644 index 0000000000..90094c7f21 --- /dev/null +++ b/lib/public/domain/enums/DetectorOrders.js @@ -0,0 +1,43 @@ +/** + * @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 { DetectorType } from './DetectorTypes.js'; + +/** + * Defines priority mappings for detector types. + * Each key is a mapping between {@link DetectorType} values and their numeric priority + * (larger values will appear first - see detectorsProvider LN88). + * + * - **DEFAULT**: Standard ordering used across most views. + * - **RCT**: Ordering used in the Run Condition Table, which prioritizes PHYSICAL detectors. + */ +export const DetectorOrders = Object.freeze({ + DEFAULT: { + [DetectorType.OTHER]: 0, + [DetectorType.VIRTUAL]: 1, + [DetectorType.PHYSICAL]: 2, + [DetectorType.AOT_GLO]: 3, + [DetectorType.AOT_EVENT]: 4, + [DetectorType.MUON_GLO]: 5, + [DetectorType.QC_ONLY]: 6, + }, + RCT: { + [DetectorType.OTHER]: 0, + [DetectorType.AOT_GLO]: 1, + [DetectorType.AOT_EVENT]: 2, + [DetectorType.MUON_GLO]: 3, + [DetectorType.VIRTUAL]: 4, + [DetectorType.PHYSICAL]: 5, + [DetectorType.QC_ONLY]: 6, + }, +}); diff --git a/lib/public/models/FilterableOverviewPageModel.js b/lib/public/models/FilterableOverviewPageModel.js new file mode 100644 index 0000000000..b3d954e05d --- /dev/null +++ b/lib/public/models/FilterableOverviewPageModel.js @@ -0,0 +1,132 @@ +/** + * @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 { buildUrl } from '/js/src/index.js'; +import { OverviewPageModel } from './OverviewModel.js'; +import { FilteringModel } from '../components/Filters/common/FilteringModel.js'; + +/** + * Base model for a filterable overview page + * + * @template T the type of data displayed in the overview page + */ +export class FilterableOverviewPageModel extends OverviewPageModel { + /** + * Constructor + * @param {QueryRouter} router router that controls the application's page navigation + * @param {string} pageIdentifier string that indicates what page this model represents + * @param {Object} filters the filters with their label and model + */ + constructor(router, pageIdentifier, filters) { + super(); + this._filteringModel = new FilteringModel(router, filters, this._warnings); + + this._filteringModel.pageIdentifier = pageIdentifier; + this._filteringModel.visualChange$.bubbleTo(this); + this._filteringModel.observe(() => this._applyFilters()); + this._sortModel.unobserve(this._sortModelCallback); + this._sortModel.observe(() => this._applyFilters()); + this._debouncedLoad = (_time) => {}; // Abstract, does nothing on purpose + this._fetchInstantly = true; + } + + /** + * Builds a url string from filters and a base string + * + * @param {string} base the base string from which the endpoint will be built + * @return {string} + */ + buildRootEndpoint(base) { + return buildUrl(base, { filter: this.getFilterParams() }); + } + + /** + * Sets the fetchInstantly boolean + * @param {boolean} bool the value to set + * @return {void} + */ + set fetchInstantly(bool) { + this._fetchInstantly = bool; + } + + /** + * Returns all filtering, sorting and pagination settings to their default values + * @param {boolean} [fetch = true] whether to refetch all data after filters have been reset + * @return {void} + */ + reset(fetch = true) { + super.reset(); + this.resetFiltering(fetch); + } + + /** + * Reset all filtering models + * @param {boolean} fetch Whether to refetch all data after filters have been reset + * @param {boolean} [clearUrl=false] if true filters will be removed from the url + * @return {void} + */ + resetFiltering(fetch = true, clearUrl = false) { + this._filteringModel.reset(false, clearUrl); + + if (fetch) { + this._applyFilters(true); + } + } + + /** + * Checks if any filter value has been modified from their default (empty) + * @return {Boolean} If any filter is active + */ + isAnyFilterActive() { + return this._filteringModel.isAnyFilterActive(); + } + + /** + * Apply the current filtering and update the remote data list + * + * @param {boolean} now if true, filtering will be applied now without debouncing + * + * @return {void} + */ + _applyFilters() { + this._pagination.silentlySetCurrentPage(1); + this._fetchInstantly ? this.load() : this._debouncedLoad(); + } + + /** + * Set underlying FilteringModel's filters from the query parameters in the URL + * + * @param {boolean} notify if the FilteringModel should notify it's observers after finishing setting the filters + */ + setFilterFromURL(notify) { + this._filteringModel.setFilterFromURL(notify); + } + + /** + * Return the filtering model + * + * @return {FilteringModel} the filtering model + */ + get filteringModel() { + return this._filteringModel; + } + + /** + * Return filter params of base model + * + * @return {object} filter + */ + getFilterParams() { + return this._filteringModel.normalized; + } +} diff --git a/lib/public/models/OverviewModel.js b/lib/public/models/OverviewModel.js index 69ae0c3df3..73334c204b 100644 --- a/lib/public/models/OverviewModel.js +++ b/lib/public/models/OverviewModel.js @@ -38,12 +38,15 @@ export class OverviewPageModel extends Observable { */ constructor() { super(); - + this._warnings = new Map(); this._sortModel = new SortModel(); - this._sortModel.observe(() => { + + this._sortModelCallback = () => { this._pagination.silentlySetCurrentPage(1); this.load(); - }); + }; + + this._sortModel.observe(this._sortModelCallback); this._sortModel.visualChange$.bubbleTo(this); // Single page data handling @@ -97,6 +100,7 @@ export class OverviewPageModel extends Observable { reset() { this._item$.setCurrent(RemoteData.notAsked()); this._pagination.reset(); + this._warnings.clear(); } /** @@ -249,4 +253,13 @@ export class OverviewPageModel extends Observable { hasAnyData() { return this._item$.getCurrent().match({ Success: ({ length = 0 } = {}) => length > 0, Other: () => false }); } + + /** + * Returns the warnings object + * + * @return {object} the warning model + */ + get warnings() { + return this._warnings; + } } diff --git a/lib/public/services/detectors/detectorsProvider.js b/lib/public/services/detectors/detectorsProvider.js index 3825835d66..2370f19942 100644 --- a/lib/public/services/detectors/detectorsProvider.js +++ b/lib/public/services/detectors/detectorsProvider.js @@ -15,6 +15,7 @@ import { switchCase } from '/js/src/index.js'; import { getRemoteData } from '../../utilities/fetch/getRemoteData.js'; import { ObservableData } from '../../utilities/ObservableData.js'; import { DetectorType, DATA_TAKING_DETECTOR_TYPES, QC_DETECTORS } from '../../domain/enums/DetectorTypes.js'; +import { DetectorOrders } from '../../domain/enums/DetectorOrders.js'; import { NonPhysicalDetector } from '../../domain/enums/detectorsNames.mjs'; @@ -44,9 +45,12 @@ const getQcDetectorsFromAllDetectors = (allDetectors) => allDetectors export class DetectorsProvider extends RemoteDataProvider { /** * Constructor + * + * @param {DetectorOrders} detectorOrder the order to base sorting on, default is DetectorOrders.DEFAULT */ - constructor() { + constructor(detectorOrder = DetectorOrders.DEFAULT) { super(); + this._detectorOrder = detectorOrder; this._physical$ = ObservableData.builder() .source(this._items$) .apply((remoteDetectors) => remoteDetectors.apply({ @@ -74,21 +78,14 @@ export class DetectorsProvider extends RemoteDataProvider { */ async getRemoteData() { const { data: detectors } = await getRemoteData('/api/detectors'); - const typeToOrderingKey = (type) => switchCase(type, { - [DetectorType.OTHER]: 0, - [DetectorType.VIRTUAL]: 1, - [DetectorType.PHYSICAL]: 2, - [DetectorType.AOT_GLO]: 3, - [DetectorType.AOT_EVENT]: 4, - [DetectorType.MUON_GLO]: 5, - [DetectorType.QC_ONLY]: 6, - }); + const typeToOrderingKey = (type) => switchCase(type, this._detectorOrder); const orderingKey = (detector1, detector2) => { const specialPair = ['ZDC', 'TST']; if (specialPair.includes(detector1.name) && specialPair.includes(detector2.name)) { return detector1.name === 'ZDC' ? 1 : -1; } + // Note the negative sign to have larger priority types appear first return -(typeToOrderingKey(detector1.type) - typeToOrderingKey(detector2.type)) * 10 + detector1.name.localeCompare(detector2.name); }; @@ -161,3 +158,4 @@ export class DetectorsProvider extends RemoteDataProvider { } export const detectorsProvider = new DetectorsProvider(); +export const rctDetectorsProvider = new DetectorsProvider(DetectorOrders.RCT); diff --git a/lib/public/views/DataPasses/ActiveColumns/dataPassesActiveColumns.js b/lib/public/views/DataPasses/ActiveColumns/dataPassesActiveColumns.js index 45d55bf6c6..e0b6d87316 100644 --- a/lib/public/views/DataPasses/ActiveColumns/dataPassesActiveColumns.js +++ b/lib/public/views/DataPasses/ActiveColumns/dataPassesActiveColumns.js @@ -20,7 +20,7 @@ import { h } from '/js/src/index.js'; import { formatDataPassName } from '../format/formatDataPassName.js'; import { formatDataPassStatusHistory } from '../format/formatStatusHistory.js'; import { checkboxes } from '../../../components/Filters/common/filters/checkboxFilter.js'; -import { rawTextFilter } from '../../../components/Filters/common/filters/rawTextFilter.js'; +import { textFilter } from '../../../components/Filters/common/filters/textFilter.js'; /** * List of active columns for a generic data passes table @@ -35,10 +35,7 @@ export const dataPassesActiveColumns = { visible: true, sortable: true, format: (_, dataPass) => formatDataPassName(dataPass), - filter: (filteringModel) => rawTextFilter( - filteringModel.get('names'), - { classes: ['w-75', 'mt1'], placeholder: 'e.g. LHC22a_apass1, ...' }, - ), + filter: (filteringModel) => textFilter(filteringModel.get('names'), { class: 'w-75 mt1', placeholder: 'e.g. LHC22a, lhc23b, ...' }), balloon: true, classes: 'w-20', }, @@ -105,7 +102,7 @@ export const dataPassesActiveColumns = { nonPhysicsProductions: { name: 'Include nonphysics productions', - filter: (filteringModel) => checkboxes(filteringModel.get('include[byName]').selectionModel), + filter: (filteringModel) => checkboxes(filteringModel.get('permittedNonPhysicsNames')), visible: false, }, }; diff --git a/lib/public/views/DataPasses/DataPassesModel.js b/lib/public/views/DataPasses/DataPassesModel.js index 5d987b31d7..42fed10c3a 100644 --- a/lib/public/views/DataPasses/DataPassesModel.js +++ b/lib/public/views/DataPasses/DataPassesModel.js @@ -21,14 +21,15 @@ import { DataPassesPerSimulationPassOverviewModel } from './PerSimulationPassOve export class DataPassesModel extends Observable { /** * The constructor of the model + * @param {QueryRouter} router router that controls the application's page navigation */ - constructor() { + constructor(router) { super(); - this._perLhcPeriodOverviewModel = new DataPassesPerLhcPeriodOverviewModel(); + this._perLhcPeriodOverviewModel = new DataPassesPerLhcPeriodOverviewModel(router, 'data-passes-per-lhc-period-overview'); this._perLhcPeriodOverviewModel.bubbleTo(this); - this._perSimulationPassOverviewModel = new DataPassesPerSimulationPassOverviewModel(); + this._perSimulationPassOverviewModel = new DataPassesPerSimulationPassOverviewModel(router, 'data-passes-per-simulation-pass-overview'); this._perSimulationPassOverviewModel.bubbleTo(this); } @@ -39,6 +40,7 @@ export class DataPassesModel extends Observable { * @returns {void} */ loadPerLhcPeriodOverview({ lhcPeriodId }) { + this._perLhcPeriodOverviewModel.setFilterFromURL(false); this._perLhcPeriodOverviewModel.load({ lhcPeriodId }); } @@ -67,6 +69,7 @@ export class DataPassesModel extends Observable { */ loadPerSimulationPassOverview({ simulationPassId }) { this._perSimulationPassOverviewModel.simulationPassId = parseInt(simulationPassId, 10); + this._perSimulationPassOverviewModel.setFilterFromURL(false); this._perSimulationPassOverviewModel.load(); } diff --git a/lib/public/views/DataPasses/DataPassesOverviewModel.js b/lib/public/views/DataPasses/DataPassesOverviewModel.js index b85cc052d7..4d07c34e51 100644 --- a/lib/public/views/DataPasses/DataPassesOverviewModel.js +++ b/lib/public/views/DataPasses/DataPassesOverviewModel.js @@ -10,60 +10,30 @@ * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. */ -import { FilteringModel } from '../../components/Filters/common/FilteringModel.js'; -import { SelectionFilterModel } from '../../components/Filters/common/filters/SelectionFilterModel.js'; +import { SelectionModel } from '../../components/common/selection/SelectionModel.js'; import { TextTokensFilterModel } from '../../components/Filters/common/filters/TextTokensFilterModel.js'; import { NON_PHYSICS_PRODUCTIONS_NAMES_WORDS } from '../../domain/enums/NonPhysicsProductionsNamesWords.js'; -import { OverviewPageModel } from '../../models/OverviewModel.js'; +import { FilterableOverviewPageModel } from '../../models/FilterableOverviewPageModel.js'; /** * Data Passes overview model */ -export class DataPassesOverviewModel extends OverviewPageModel { +export class DataPassesOverviewModel extends FilterableOverviewPageModel { /** * Constructor + * @param {QueryRouter} router router that controls the application's page navigation + * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor() { - super(); - this._filteringModel = new FilteringModel({ - names: new TextTokensFilterModel(), - 'include[byName]': new SelectionFilterModel({ - availableOptions: NON_PHYSICS_PRODUCTIONS_NAMES_WORDS.map((word) => ({ label: word.toUpperCase(), value: word })), - }), - }); - - this._filteringModel.visualChange$.bubbleTo(this); - this._filteringModel.observe(() => { - this._pagination.currentPage = 1; - this.load(); - }); - } - - /** - * Return filter params of base model - * - * @return {object} filter - */ - getFilterParams() { - return this._filteringModel.normalized; - } - - /** - * Reset this model to its default - * - * @returns {void} - */ - reset() { - this._filteringModel.reset(); - super.reset(); - } - - /** - * Return the filtering model - * - * @return {FilteringModel} the filtering model - */ - get filteringModel() { - return this._filteringModel; + constructor(router, pageIdentifier) { + super( + router, + pageIdentifier, + { + names: new TextTokensFilterModel(), + permittedNonPhysicsNames: new SelectionModel({ + availableOptions: NON_PHYSICS_PRODUCTIONS_NAMES_WORDS.map((word) => ({ label: word.toUpperCase(), value: word })), + }), + }, + ); } } diff --git a/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewModel.js b/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewModel.js index dc125e1a94..6da2205751 100644 --- a/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewModel.js +++ b/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewModel.js @@ -19,9 +19,11 @@ import { buildUrl } from '/js/src/index.js'; export class DataPassesPerLhcPeriodOverviewModel extends DataPassesOverviewModel { /** * Constructor + * @param {QueryRouter} router router that controls the application's page navigation + * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor() { - super(); + constructor(router, pageIdentifier) { + super(router, pageIdentifier); this._lhcPeriodId = null; } diff --git a/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewPage.js b/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewPage.js index e97dca2170..7cb3e5fb65 100644 --- a/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewPage.js +++ b/lib/public/views/DataPasses/PerLhcPeriodOverview/DataPassesPerLhcPeriodOverviewPage.js @@ -19,6 +19,7 @@ import { filtersPanelPopover } from '../../../components/Filters/common/filtersP import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; import { dataPassesActiveColumns } from '../ActiveColumns/dataPassesActiveColumns.js'; import { DataPassVersionStatus } from '../../../domain/enums/DataPassVersionStatus.js'; +import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 42; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -42,24 +43,18 @@ const getRowClasses = ({ versions }) => { * @returns {Component} The overview screen */ export const DataPassesPerLhcPeriodOverviewPage = ({ dataPasses: { perLhcPeriodOverviewModel: dataPassesPerLhcPeriodOverviewModel } }) => { - dataPassesPerLhcPeriodOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( - TABLEROW_HEIGHT, - PAGE_USED_HEIGHT, - )); + const { filteringModel, sortModel, pagination, items } = dataPassesPerLhcPeriodOverviewModel; + + pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); return h('', { onremove: () => dataPassesPerLhcPeriodOverviewModel.reset(), }, [ - h('.flex-row.header-container.pv2', filtersPanelPopover(dataPassesPerLhcPeriodOverviewModel.filteringModel, dataPassesActiveColumns)), + h('.flex-row.header-container.pv2', filtersPanelPopover(filteringModel, dataPassesActiveColumns)), + warningComponent(dataPassesPerLhcPeriodOverviewModel), h('.w-100.flex-column', [ - table( - dataPassesPerLhcPeriodOverviewModel.items, - dataPassesActiveColumns, - { classes: getRowClasses }, - null, - { sort: dataPassesPerLhcPeriodOverviewModel.sortModel }, - ), - paginationComponent(dataPassesPerLhcPeriodOverviewModel.pagination), + table(items, dataPassesActiveColumns, { classes: getRowClasses }, null, { sort: sortModel }), + paginationComponent(pagination), ]), ]); }; diff --git a/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewModel.js b/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewModel.js index 30fd3c616c..d9b1008552 100644 --- a/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewModel.js +++ b/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewModel.js @@ -21,9 +21,11 @@ import { DataPassesOverviewModel } from '../DataPassesOverviewModel.js'; export class DataPassesPerSimulationPassOverviewModel extends DataPassesOverviewModel { /** * Constructor + * @param {QueryRouter} router router that controls the application's page navigation + * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor() { - super(); + constructor(router, pageIdentifier) { + super(router, pageIdentifier); this._simulationPass = new ObservableData(RemoteData.notAsked()); this._simulationPass.bubbleTo(this); } diff --git a/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewPage.js b/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewPage.js index 2473f3383d..6e11d594a8 100644 --- a/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewPage.js +++ b/lib/public/views/DataPasses/PerSimulationPassOverview/DataPassesPerSimulationPassOverviewPage.js @@ -22,6 +22,7 @@ import { breadcrumbs } from '../../../components/common/navigation/breadcrumbs.j import spinner from '../../../components/common/spinner.js'; import { tooltip } from '../../../components/common/popover/tooltip.js'; import { DataPassVersionStatus } from '../../../domain/enums/DataPassVersionStatus.js'; +import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 42; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -46,12 +47,9 @@ const getRowClasses = ({ versions }) => { */ export const DataPassesPerSimulationPassOverviewPage = ({ dataPasses: { perSimulationPassOverviewModel: dataPassesPerSimulationPassOverviewModel } }) => { - dataPassesPerSimulationPassOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( - TABLEROW_HEIGHT, - PAGE_USED_HEIGHT, - )); + const { items, simulationPass, pagination, filteringModel, sortModel } = dataPassesPerSimulationPassOverviewModel; - const { items, simulationPass, pagination } = dataPassesPerSimulationPassOverviewModel; + pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); const commonTitle = h('h2#breadcrumb-header', 'Data Passes per MC'); @@ -59,7 +57,7 @@ export const DataPassesPerSimulationPassOverviewPage = ({ dataPasses: { onremove: () => dataPassesPerSimulationPassOverviewModel.reset(), }, [ h('.flex-row.items-center.g2', [ - filtersPanelPopover(dataPassesPerSimulationPassOverviewModel.filteringModel, dataPassesActiveColumns), + filtersPanelPopover(filteringModel, dataPassesActiveColumns), h( '.flex-row.g1.items-center', simulationPass.match({ @@ -70,14 +68,9 @@ export const DataPassesPerSimulationPassOverviewPage = ({ dataPasses: { }), ), ]), + warningComponent(dataPassesPerSimulationPassOverviewModel), h('.w-100.flex-column', [ - table( - items, - dataPassesActiveColumns, - { classes: getRowClasses }, - null, - { sort: dataPassesPerSimulationPassOverviewModel.sortModel }, - ), + table(items, dataPassesActiveColumns, { classes: getRowClasses }, null, { sort: sortModel }), paginationComponent(pagination), ]), ]); diff --git a/lib/public/views/Environments/ActiveColumns/environmentsActiveColumns.js b/lib/public/views/Environments/ActiveColumns/environmentsActiveColumns.js index d392ffb53a..1226d7f7cb 100644 --- a/lib/public/views/Environments/ActiveColumns/environmentsActiveColumns.js +++ b/lib/public/views/Environments/ActiveColumns/environmentsActiveColumns.js @@ -26,7 +26,7 @@ import { aliEcsEnvironmentLinkComponent } from '../../../components/common/exter import { StatusAcronym } from '../../../domain/enums/statusAcronym.mjs'; import { timeRangeFilter } from '../../../components/Filters/common/filters/timeRangeFilter.js'; import { checkboxes } from '../../../components/Filters/common/filters/checkboxFilter.js'; -import { rawTextFilter } from '../../../components/Filters/common/filters/rawTextFilter.js'; +import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; /** * List of active columns for a generic Environments component @@ -60,13 +60,10 @@ export const environmentsActiveColumns = { /** * Environment IDs filter component * - * @param {EnvironmentOverviewModel} environmentOverviewModel the environment overview model + * @param {EnvironmentOverviewModel} environmentOverviewModel.filteringModel the filtering model * @return {Component} the filter component */ - filter: (environmentOverviewModel) => rawTextFilter( - environmentOverviewModel.filteringModel.get('ids'), - { classes: ['w-100'], placeholder: 'e.g. CmCvjNbg, TDI59So3d...' }, - ), + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'ids', 'e.g. CmCvjNbg, TDI59So3d...'), }, runs: { name: 'Runs', @@ -79,13 +76,10 @@ export const environmentsActiveColumns = { /** * Run numbers filter component * - * @param {EnvironmentOverviewModel} environmentOverviewModel the environment overview model + * @param {EnvironmentOverviewModel} environmentOverviewModel.filteringModel the filtering model * @return {Component} the filter component */ - filter: (environmentOverviewModel) => rawTextFilter( - environmentOverviewModel.filteringModel.get('runNumbers'), - { classes: ['w-100'], placeholder: 'e.g. 553203, 553221, ...' }, - ), + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'runNumbers', 'e.g. 553203, 553221, ...'), }, updatedAt: { name: 'Last Update', @@ -123,7 +117,7 @@ export const environmentsActiveColumns = { * @param {EnvironmentOverviewModel} environmentOverviewModel the environment overview model * @return {Component} the filter component */ - filter: (environmentOverviewModel) => checkboxes(environmentOverviewModel.filteringModel.get('currentStatus').selectionModel), + filter: (environmentOverviewModel) => checkboxes(environmentOverviewModel.filteringModel.get('currentStatus')), }, historyItems: { name: h('.flex-row.g2.items-center', ['Status History', infoTooltip(environmentStatusHistoryLegendComponent())]), @@ -140,12 +134,9 @@ export const environmentsActiveColumns = { /** * Status history filter component * - * @param {EnvironmentOverviewModel} environmentOverviewModel the environment overview model + * @param {EnvironmentOverviewModel} environmentOverviewModel.filteringModel the filtering model * @return {Component} the filter component */ - filter: (environmentOverviewModel) => rawTextFilter( - environmentOverviewModel.filteringModel.get('statusHistory'), - { classes: ['w-100'], placeholder: 'e.g. D-R-X' }, - ), + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'statusHistory', 'e.g. D-R-X'), }, }; diff --git a/lib/public/views/Environments/EnvironmentModel.js b/lib/public/views/Environments/EnvironmentModel.js index ba4b1e86bf..1cc7fa484d 100644 --- a/lib/public/views/Environments/EnvironmentModel.js +++ b/lib/public/views/Environments/EnvironmentModel.js @@ -29,7 +29,7 @@ export class EnvironmentModel extends Observable { super(); // Sub-models - this._overviewModel = new EnvironmentOverviewModel(model); + this._overviewModel = new EnvironmentOverviewModel(model, 'env-overview'); this._overviewModel.bubbleTo(this); this._detailsModel = new EnvironmentDetailsModel(); @@ -42,6 +42,7 @@ export class EnvironmentModel extends Observable { */ loadOverview() { if (!this._overviewModel.pagination.isInfiniteScrollEnabled) { + this._overviewModel.setFilterFromURL(false); this._overviewModel.load(); } } diff --git a/lib/public/views/Environments/Overview/EnvironmentOverviewModel.js b/lib/public/views/Environments/Overview/EnvironmentOverviewModel.js index 8498a02d79..9621e4df33 100644 --- a/lib/public/views/Environments/Overview/EnvironmentOverviewModel.js +++ b/lib/public/views/Environments/Overview/EnvironmentOverviewModel.js @@ -11,58 +11,47 @@ * or submit itself to any jurisdiction. */ -import { buildUrl } from '/js/src/index.js'; -import { FilteringModel } from '../../../components/Filters/common/FilteringModel.js'; -import { OverviewPageModel } from '../../../models/OverviewModel.js'; import { TimeRangeInputModel } from '../../../components/Filters/common/filters/TimeRangeInputModel.js'; -import { SelectionFilterModel } from '../../../components/Filters/common/filters/SelectionFilterModel.js'; import { RawTextFilterModel } from '../../../components/Filters/common/filters/RawTextFilterModel.js'; -import { debounce } from '../../../utilities/debounce.js'; import { coloredEnvironmentStatusComponent } from '../ColoredEnvironmentStatusComponent.js'; import { StatusAcronym } from '../../../domain/enums/statusAcronym.mjs'; +import { SelectionModel } from '../../../components/common/selection/SelectionModel.js'; +import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; /** * Environment overview page model */ -export class EnvironmentOverviewModel extends OverviewPageModel { +export class EnvironmentOverviewModel extends FilterableOverviewPageModel { /** * Constructor * @param {Model} model global model + * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(model) { - super(); - - this._filteringModel = new FilteringModel({ - created: new TimeRangeInputModel(), - runNumbers: new RawTextFilterModel(), - statusHistory: new RawTextFilterModel(), - currentStatus: new SelectionFilterModel({ - availableOptions: Object.keys(StatusAcronym).map((status) => ({ - value: status, - label: coloredEnvironmentStatusComponent(status), - rawLabel: status, - })), - }), - ids: new RawTextFilterModel(), - }); - - this._filteringModel.observe(() => this._applyFilters(true)); - this._filteringModel.visualChange$?.bubbleTo(this); - - this.reset(false); - const updateDebounceTime = () => { - this._debouncedLoad = debounce(this.load.bind(this), model.inputDebounceTime); - }; - - model.appConfiguration$.observe(() => updateDebounceTime()); - updateDebounceTime(); + constructor(model, pageIdentifier) { + super( + model.router, + pageIdentifier, + { + created: new TimeRangeInputModel(), + runNumbers: new RawTextFilterModel(), + statusHistory: new RawTextFilterModel(), + currentStatus: new SelectionModel({ + availableOptions: Object.keys(StatusAcronym).map((status) => ({ + value: status, + label: coloredEnvironmentStatusComponent(status), + rawLabel: status, + })), + }), + ids: new RawTextFilterModel(), + }, + ); } /** * @inheritDoc */ getRootEndpoint() { - return buildUrl('/api/environments', { filter: this.filteringModel.normalized }); + return this.buildRootEndpoint('/api/environments'); } /** @@ -73,56 +62,4 @@ export class EnvironmentOverviewModel extends OverviewPageModel { get environments() { return this.items; } - - /** - * Returns all filtering, sorting and pagination settings to their default values - * @param {boolean} [fetch = true] whether to refetch all data after filters have been reset - * @return {void} - */ - reset(fetch = true) { - super.reset(); - this.resetFiltering(fetch); - } - - /** - * Reset all filtering models - * @param {boolean} fetch Whether to refetch all data after filters have been reset - * @return {void} - */ - resetFiltering(fetch = true) { - this._filteringModel.reset(); - - if (fetch) { - this._applyFilters(true); - } - } - - /** - * Checks if any filter value has been modified from their default (empty) - * @return {Boolean} If any filter is active - */ - isAnyFilterActive() { - return this._filteringModel.isAnyFilterActive(); - } - - /** - * Return the filtering model - * - * @return {FilteringModel} the filtering model - */ - get filteringModel() { - return this._filteringModel; - } - - /** - * Apply the current filtering and update the remote data list - * - * @param {boolean} now if true, filtering will be applied now without debouncing - * - * @return {void} - */ - _applyFilters(now = false) { - this._pagination.currentPage = 1; - now ? this.load() : this._debouncedLoad(true); - } } diff --git a/lib/public/views/Environments/Overview/environmentOverviewComponent.js b/lib/public/views/Environments/Overview/environmentOverviewComponent.js index 7cc60ecd22..df8f5a332d 100644 --- a/lib/public/views/Environments/Overview/environmentOverviewComponent.js +++ b/lib/public/views/Environments/Overview/environmentOverviewComponent.js @@ -17,6 +17,7 @@ import { environmentsActiveColumns } from '../ActiveColumns/environmentsActiveCo import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; +import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 58; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -30,16 +31,14 @@ const PAGE_USED_HEIGHT = 181; export const environmentOverviewComponent = (envsOverviewModel) => { const { pagination, environments } = envsOverviewModel; - pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( - TABLEROW_HEIGHT, - PAGE_USED_HEIGHT, - )); + pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); return h('', [ h( '.flex-row.header-container.g2.pv2', filtersPanelPopover(envsOverviewModel, environmentsActiveColumns), ), + warningComponent(envsOverviewModel), h('.w-100.flex-column', [ h('.header-container.pv2'), table(environments, environmentsActiveColumns, { classes: 'table-sm' }), diff --git a/lib/public/views/Home/Overview/HomePage.js b/lib/public/views/Home/Overview/HomePage.js index 92705e6973..3768f6cf6d 100644 --- a/lib/public/views/Home/Overview/HomePage.js +++ b/lib/public/views/Home/Overview/HomePage.js @@ -46,7 +46,7 @@ export const HomePage = ({ home: { logsOverviewModel, runsOverviewModel, lhcFill h('.flex-row.g2', [ h('.flex-column', [ h('h3', 'Log Entries'), - h('.f6#logs-panel', table(logsOverviewModel.logs, logsActiveColumns, null, { profile: 'home' })), + h('.f6#logs-panel', table(logsOverviewModel.items, logsActiveColumns, null, { profile: 'home' })), ]), h('.flex-column', [ h('h3', 'LHC Fills'), diff --git a/lib/public/views/Home/Overview/HomePageModel.js b/lib/public/views/Home/Overview/HomePageModel.js index 40b6cfac85..e40fe38952 100644 --- a/lib/public/views/Home/Overview/HomePageModel.js +++ b/lib/public/views/Home/Overview/HomePageModel.js @@ -26,13 +26,13 @@ export class HomePageModel extends Observable { */ constructor(model) { super(); - this._runsOverviewModel = new RunsOverviewModel(model); + this._runsOverviewModel = new RunsOverviewModel(model, 'home'); this._runsOverviewModel.bubbleTo(this); - this._logsOverviewModel = new LogsOverviewModel(model, true); + this._logsOverviewModel = new LogsOverviewModel(model, true, 'home'); this._logsOverviewModel.bubbleTo(this); - this._lhcFillsOverviewModel = new LhcFillsOverviewModel(true); + this._lhcFillsOverviewModel = new LhcFillsOverviewModel(model.router, true, 'home'); this._lhcFillsOverviewModel.bubbleTo(this); } @@ -42,7 +42,7 @@ export class HomePageModel extends Observable { */ loadOverview() { this._runsOverviewModel.load(); - this._logsOverviewModel.fetchLogs(true); + this._logsOverviewModel.load(true); this._lhcFillsOverviewModel.load(); } diff --git a/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js b/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js index b2657c8cfd..be4311d7e4 100644 --- a/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js +++ b/lib/public/views/LhcFills/ActiveColumns/lhcFillsActiveColumns.js @@ -23,12 +23,11 @@ import { buttonLinkWithDropdown } from '../../../components/common/selection/inf import { infologgerLinksComponents } from '../../../components/common/externalLinks/infologgerLinksComponents.js'; import { formatBeamType } from '../../../utilities/formatting/formatBeamType.js'; import { frontLink } from '../../../components/common/navigation/frontLink.js'; -import { toggleStableBeamOnlyFilter } from '../../../components/Filters/LhcFillsFilter/stableBeamFilter.js'; -import { fillNumberFilter } from '../../../components/Filters/LhcFillsFilter/fillNumberFilter.js'; +import { toggleFilter } from '../../../components/Filters/common/filters/toggleFilter.js'; import { durationFilter } from '../../../components/Filters/LhcFillsFilter/durationFilter.js'; import { beamTypeFilter } from '../../../components/Filters/LhcFillsFilter/beamTypeFilter.js'; -import { schemeNameFilter } from '../../../components/Filters/LhcFillsFilter/schemeNameFilter.js'; import { timeRangeFilter } from '../../../components/Filters/common/filters/timeRangeFilter.js'; +import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; /** * List of active columns for a lhc fills table @@ -54,7 +53,14 @@ export const lhcFillsActiveColumns = { ), ], ), - filter: (lhcFillModel) => fillNumberFilter(lhcFillModel.filteringModel.get('fillNumbers')), + + /** + * FillNumber filter component + * + * @param {FilteringModel} LhcFillsOverviewModel.filteringModel the filtering model + * @return {Component} the filter component + */ + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'fillNumbers', 'e.g. 7966, 7954, 7948...'), profiles: { lhcFill: true, environment: true, @@ -111,7 +117,8 @@ export const lhcFillsActiveColumns = { name: 'Stable Beams Only', visible: false, format: (boolean) => boolean ? 'On' : 'Off', - filter: (lhcFillModel) => toggleStableBeamOnlyFilter(lhcFillModel.filteringModel.get('hasStableBeams'), true), + filter: (lhcFillModel) => + toggleFilter(lhcFillModel.filteringModel.get('hasStableBeams'), 'stableBeamsOnlyRadio', 'stableBeamsOnlyRadio', true), }, stableBeamsDuration: { name: 'SB Duration', @@ -193,7 +200,14 @@ export const lhcFillsActiveColumns = { visible: true, size: 'w-10', format: (value) => value ? value : '-', - filter: (lhcFillModel) => schemeNameFilter(lhcFillModel.filteringModel.get('schemeName')), + + /** + * Schema filter component + * + * @param {FilteringModel} LhcFillsOverviewModel.filteringModel the filtering model + * @return {Component} the filter component + */ + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'schemeName', 'e.g. Single_12b_8_1024_8_2018'), balloon: true, }, runs: { diff --git a/lib/public/views/LhcFills/LhcFills.js b/lib/public/views/LhcFills/LhcFills.js index 70b6c5eb3d..a4343be26a 100644 --- a/lib/public/views/LhcFills/LhcFills.js +++ b/lib/public/views/LhcFills/LhcFills.js @@ -29,7 +29,7 @@ export default class LhcFills extends Observable { this.model = model; // Sub-models - this._overviewModel = new LhcFillsOverviewModel(true); + this._overviewModel = new LhcFillsOverviewModel(model.router, true, 'lhc-fill-overview'); this._overviewModel.bubbleTo(this); this._detailsModel = new LhcFillDetailsModel(); @@ -42,6 +42,7 @@ export default class LhcFills extends Observable { * @returns {void} */ loadOverview() { + this._overviewModel.setFilterFromURL(false); this._overviewModel.load(); } diff --git a/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js b/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js index c57ae69c25..3e73c2fa0f 100644 --- a/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js +++ b/lib/public/views/LhcFills/Overview/LhcFillsOverviewModel.js @@ -11,49 +11,42 @@ * or submit itself to any jurisdiction. */ -import { buildUrl } from '/js/src/index.js'; -import { FilteringModel } from '../../../components/Filters/common/FilteringModel.js'; -import { StableBeamFilterModel } from '../../../components/Filters/LhcFillsFilter/StableBeamFilterModel.js'; import { RawTextFilterModel } from '../../../components/Filters/common/filters/RawTextFilterModel.js'; -import { OverviewPageModel } from '../../../models/OverviewModel.js'; import { addStatisticsToLhcFill } from '../../../services/lhcFill/addStatisticsToLhcFill.js'; import { BeamTypeFilterModel } from '../../../components/Filters/LhcFillsFilter/BeamTypeFilterModel.js'; import { TextComparisonFilterModel } from '../../../components/Filters/common/filters/TextComparisonFilterModel.js'; import { TimeRangeFilterModel } from '../../../components/Filters/RunsFilter/TimeRangeFilter.js'; +import { ToggleFilterModel } from '../../../components/Filters/common/filters/ToggleFilterModel.js'; +import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; /** * Model for the LHC fills overview page * * @implements {OverviewModel} */ -export class LhcFillsOverviewModel extends OverviewPageModel { +export class LhcFillsOverviewModel extends FilterableOverviewPageModel { /** * Constructor * + * @param {QueryRouter} router router that controls the application's page navigation * @param {boolean} [stableBeamsOnly=false] if true, overview will load stable beam only + * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(stableBeamsOnly = false) { - super(); - - this._filteringModel = new FilteringModel({ - fillNumbers: new RawTextFilterModel(), - beamDuration: new TextComparisonFilterModel(), - runDuration: new TextComparisonFilterModel(), - hasStableBeams: new StableBeamFilterModel(), - stableBeamsStart: new TimeRangeFilterModel(), - stableBeamsEnd: new TimeRangeFilterModel(), - beamTypes: new BeamTypeFilterModel(), - schemeName: new RawTextFilterModel(), - }); - - this._filteringModel.observe(() => this._applyFilters()); - this._filteringModel.visualChange$.bubbleTo(this); - - this.reset(false); - - if (stableBeamsOnly) { - this._filteringModel.get('hasStableBeams').setStableBeamsOnly(true); - } + constructor(router, stableBeamsOnly = false, pageIdentifier) { + super( + router, + pageIdentifier, + { + fillNumbers: new RawTextFilterModel(), + beamDuration: new TextComparisonFilterModel(), + runDuration: new TextComparisonFilterModel(), + hasStableBeams: new ToggleFilterModel(stableBeamsOnly, true), + stableBeamsStart: new TimeRangeFilterModel(), + stableBeamsEnd: new TimeRangeFilterModel(), + beamTypes: new BeamTypeFilterModel(), + schemeName: new RawTextFilterModel(), + }, + ); } /** @@ -70,59 +63,6 @@ export class LhcFillsOverviewModel extends OverviewPageModel { * @inheritDoc */ getRootEndpoint() { - const params = { - filter: this.filteringModel.normalized, - }; - return buildUrl('/api/lhcFills', params); - } - - /** - * Returns all filtering, sorting and pagination settings to their default values - * @param {boolean} [fetch = true] whether to refetch all data after filters have been reset - * @return {void} - */ - reset(fetch = true) { - super.reset(); - this.resetFiltering(fetch); - } - - /** - * Reset all filtering models - * @param {boolean} fetch Whether to refetch all data after filters have been reset - * @return {void} - */ - resetFiltering(fetch = true) { - this._filteringModel.reset(); - - if (fetch) { - this._applyFilters(); - } - } - - /** - * Checks if any filter value has been modified from their default (empty) - * @return {Boolean} If any filter is active - */ - isAnyFilterActive() { - return this._filteringModel.isAnyFilterActive(); - } - - /** - * Return the filtering model - * - * @return {FilteringModel} the filtering model - */ - get filteringModel() { - return this._filteringModel; - } - - /** - * Apply the current filtering and update the remote data list - * - * @return {void} - */ - _applyFilters() { - this._pagination.currentPage = 1; - this.load(); + return this.buildRootEndpoint('/api/lhcFills'); } } diff --git a/lib/public/views/LhcFills/Overview/index.js b/lib/public/views/LhcFills/Overview/index.js index e81409f06c..f790bb9957 100644 --- a/lib/public/views/LhcFills/Overview/index.js +++ b/lib/public/views/LhcFills/Overview/index.js @@ -18,7 +18,8 @@ import { lhcFillsActiveColumns } from '../ActiveColumns/lhcFillsActiveColumns.js import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; -import { toggleStableBeamOnlyFilter } from '../../../components/Filters/LhcFillsFilter/stableBeamFilter.js'; +import { toggleFilter } from '../../../components/Filters/common/filters/toggleFilter.js'; +import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 53.3; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -41,20 +42,18 @@ export const Index = (model) => h('', { * @returns {Object} Html page */ const showLhcFillsTable = (lhcFillsOverviewModel) => { - lhcFillsOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( - TABLEROW_HEIGHT, - PAGE_USED_HEIGHT, - 1, - )); + const { items, pagination, filteringModel } = lhcFillsOverviewModel; + pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT, 1)); return [ h('.flex-row.header-container.g2.pv2', [ filtersPanelPopover(lhcFillsOverviewModel, lhcFillsActiveColumns), - toggleStableBeamOnlyFilter(lhcFillsOverviewModel.filteringModel.get('hasStableBeams')), + toggleFilter(filteringModel.get('hasStableBeams'), 'STABLE BEAM ONLY'), ]), + warningComponent(lhcFillsOverviewModel), h('.w-100.flex-column', [ - table(lhcFillsOverviewModel.items, lhcFillsActiveColumns, null, { tableClasses: '.table-sm' }), - paginationComponent(lhcFillsOverviewModel.pagination), + table(items, lhcFillsActiveColumns, null, { tableClasses: '.table-sm' }), + paginationComponent(pagination), ]), ]; }; diff --git a/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js b/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js index c43b04b917..6d99d48fd5 100644 --- a/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js +++ b/lib/public/views/Logs/ActiveColumns/logsActiveColumns.js @@ -15,19 +15,16 @@ import { h } from '/js/src/index.js'; import { iconCommentSquare, iconPaperclip } from '/js/src/icons.js'; import { authorFilter } from '../../../components/Filters/LogsFilter/author/authorFilter.js'; -import createdFilter from '../../../components/Filters/LogsFilter/created.js'; -import runsFilter from '../../../components/Filters/LogsFilter/runs.js'; import { formatTimestamp } from '../../../utilities/formatting/formatTimestamp.js'; import { frontLink } from '../../../components/common/navigation/frontLink.js'; import { frontLinks } from '../../../components/common/navigation/frontLinks.js'; import { tagFilter } from '../../../components/Filters/common/filters/tagFilter.js'; import { formatRunsList } from '../../Runs/format/formatRunsList.js'; import { profiles } from '../../../components/common/table/profiles.js'; -import { textFilter } from '../../../components/Filters/common/filters/textFilter.js'; -import { environmentFilter } from '../../../components/Filters/LogsFilter/environments.js'; import { formatLhcFillsList } from '../../LhcFills/format/formatLhcFillsList.js'; -import { lhcFillsFilter } from '../../../components/Filters/LogsFilter/lhcFill.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'; /** * A method to display a small and simple number/icon collection as a column @@ -71,13 +68,14 @@ export const logsActiveColumns = { visible: true, sortable: true, size: 'w-30', - filter: ({ titleFilter }) => textFilter( - titleFilter, - { - id: 'titleFilterText', - class: 'w-75 mt1', - }, - ), + + /** + * Title filter component + * + * @param {FilteringModel} logOverviewModel.filteringModel filtering model + * @return {Component} the filter component + */ + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'title', 'e.g. Report on runs: ...'), balloon: true, profiles: { embeded: true, @@ -92,13 +90,14 @@ export const logsActiveColumns = { name: 'Content', visible: false, size: 'w-10', - filter: ({ contentFilter }) => textFilter( - contentFilter, - { - id: 'contentFilterText', - class: 'w-75 mt1', - }, - ), + + /** + * Content filter component + * + * @param {FilteringModel} logOverviewModel.filteringModel the filtering model + * @return {Component} the filter component + */ + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'content', 'e.g. Quality of run 52...'), }, author: { name: 'Author', @@ -106,7 +105,14 @@ export const logsActiveColumns = { sortable: true, size: 'w-15', format: (author) => author.name, - filter: authorFilter, + + /** + * Author filter component + * + * @param {FilteringModel} logOverviewModel.filteringModel filtering model + * @return {Component} the filter component + */ + filter: ({ filteringModel }) => authorFilter(filteringModel.get('author')), profiles: [profiles.none, 'embeded'], }, createdAt: { @@ -115,7 +121,14 @@ export const logsActiveColumns = { sortable: true, size: 'w-10', format: (timestamp) => formatTimestamp(timestamp, false), - filter: createdFilter, + + /** + * Created filter component + * + * @param {FilteringModel} logOverviewModel.filteringModel filtering model + * @return {Component} the filter component + */ + filter: ({ filteringModel }) => timeRangeFilter(filteringModel.get('created')), profiles: { embeded: { format: (timestamp) => formatTimestamp(timestamp), @@ -137,10 +150,11 @@ export const logsActiveColumns = { /** * Tag filter component - * @param {LogsOverviewModel} logsModel the log model + * + * @param {FilteringModel} logOverviewModel.filteringModel filtering model * @return {Component} the filter component */ - filter: (logsModel) => tagFilter(logsModel.listingTagsFilterModel), + filter: ({ filteringModel }) => tagFilter(filteringModel.get('tags')), balloon: true, profiles: [profiles.none, 'embeded'], }, @@ -150,7 +164,14 @@ export const logsActiveColumns = { sortable: true, size: 'w-15', format: formatRunsList, - filter: runsFilter, + + /** + * Runs filter component + * + * @param {FilteringModel} logOverviewModel.filteringModel filtering model + * @return {Component} the filter component + */ + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'runNumbers', 'e.g. 553203, 553221, ...'), balloon: true, profiles: [profiles.none, 'embeded'], }, @@ -167,7 +188,14 @@ export const logsActiveColumns = { parameters: { environmentId: id }, }), ), - filter: environmentFilter, + + /** + * Environment filter component + * + * @param {FilteringModel} logOverviewModel.filteringModel filtering model + * @return {Component} the filter component + */ + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'environmentIds', 'e.g. Dxi029djX, TDI59So3d...'), balloon: true, profiles: [profiles.none, 'embeded'], }, @@ -177,7 +205,14 @@ export const logsActiveColumns = { sortable: false, size: 'w-10', format: formatLhcFillsList, - filter: lhcFillsFilter, + + /** + * LhcFills filter component + * + * @param {FilteringModel} logOverviewModel.filteringModel filtering model + * @return {Component} the filter component + */ + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'fillNumbers', 'e.g. 11392, 11383, 7625'), balloon: true, profiles: [profiles.none, 'embeded'], }, diff --git a/lib/public/views/Logs/LogsModel.js b/lib/public/views/Logs/LogsModel.js index b4f9342d42..1c894620a4 100644 --- a/lib/public/views/Logs/LogsModel.js +++ b/lib/public/views/Logs/LogsModel.js @@ -30,7 +30,7 @@ export class LogsModel extends Observable { super(); this.model = model; - this._overviewModel = new LogsOverviewModel(model); + this._overviewModel = new LogsOverviewModel(model, false, 'log-overview'); this._overviewModel.bubbleTo(this); this._treeViewModel = new LogTreeViewModel(); @@ -55,7 +55,8 @@ export class LogsModel extends Observable { */ loadOverview() { if (!this._overviewModel.pagination.isInfiniteScrollEnabled) { - this._overviewModel.fetchLogs(); + this._overviewModel.setFilterFromURL(false); + this._overviewModel.load(); } } diff --git a/lib/public/views/Logs/Overview/LogsOverviewModel.js b/lib/public/views/Logs/Overview/LogsOverviewModel.js index cce376438b..f8244d42a8 100644 --- a/lib/public/views/Logs/Overview/LogsOverviewModel.js +++ b/lib/public/views/Logs/Overview/LogsOverviewModel.js @@ -11,410 +11,49 @@ * or submit itself to any jurisdiction. */ -import { buildUrl, Observable, RemoteData } from '/js/src/index.js'; import { TagFilterModel } from '../../../components/Filters/common/TagFilterModel.js'; -import { SortModel } from '../../../components/common/table/SortModel.js'; -import { debounce } from '../../../utilities/debounce.js'; -import { FilterInputModel } from '../../../components/Filters/common/filters/FilterInputModel.js'; import { AuthorFilterModel } from '../../../components/Filters/LogsFilter/author/AuthorFilterModel.js'; -import { PaginationModel } from '../../../components/Pagination/PaginationModel.js'; -import { getRemoteDataSlice } from '../../../utilities/fetch/getRemoteDataSlice.js'; import { tagsProvider } from '../../../services/tag/tagsProvider.js'; +import { RawTextFilterModel } from '../../../components/Filters/common/filters/RawTextFilterModel.js'; +import { TimeRangeInputModel } from '../../../components/Filters/common/filters/TimeRangeInputModel.js'; +import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; /** * Model representing handlers for log entries page * * @implements {OverviewModel} */ -export class LogsOverviewModel extends Observable { +export class LogsOverviewModel extends FilterableOverviewPageModel { /** * The constructor of the Overview model object * * @param {Model} model global model * @param {boolean} excludeAnonymous Whether to exclude anonymous logs - */ - constructor(model, excludeAnonymous = false) { - super(); - - this.model = model; - - // Sub-models - this._listingTagsFilterModel = new TagFilterModel(tagsProvider.items$); - this._listingTagsFilterModel.observe(() => this._applyFilters()); - this._listingTagsFilterModel.visualChange$.bubbleTo(this); - - this._overviewSortModel = new SortModel(); - this._overviewSortModel.observe(() => this._applyFilters(true)); - this._overviewSortModel.visualChange$.bubbleTo(this); - - this._pagination = new PaginationModel(); - this._pagination.observe(() => this.fetchLogs()); - this._pagination.itemsPerPageSelector$.observe(() => this.notify()); - - // Filtering models - this._authorFilter = new AuthorFilterModel(); - this._registerFilter(this._authorFilter); - - this._titleFilter = new FilterInputModel(); - this._registerFilter(this._titleFilter); - - this._contentFilter = new FilterInputModel(); - this._registerFilter(this._contentFilter); - - this._logs = RemoteData.NotAsked(); - - const updateDebounceTime = () => { - this._debouncedFetchAllLogs = debounce(this.fetchLogs.bind(this), model.inputDebounceTime); - }; - model.appConfiguration$.observe(() => updateDebounceTime()); - updateDebounceTime(); - - excludeAnonymous && this._authorFilter.update('!Anonymous'); - - this.reset(false); - } - - /** - * Retrieve every relevant log from the API - * @returns {Promise} Injects the data object with the response data - */ - async fetchLogs() { - const keepExisting = this._pagination.currentPage > 1 && this._pagination.isInfiniteScrollEnabled; - - if (!keepExisting) { - this._logs = RemoteData.loading(); - this.notify(); - } - - const params = { - ...this._getFilterQueryParams(), - 'page[offset]': this._pagination.firstItemOffset, - 'page[limit]': this._pagination.itemsPerPage, - }; - - const endpoint = buildUrl('/api/logs', params); - - try { - const { items, totalCount } = await getRemoteDataSlice(endpoint); - const concatenateWith = keepExisting ? this._logs.payload ?? [] : []; - this._logs = RemoteData.success([...concatenateWith, ...items]); - this._pagination.itemsCount = totalCount; - } catch (errors) { - this._logs = RemoteData.failure(errors); - } - - this.notify(); - } - - /** - * Return current logs - * @return {RemoteData<*[]>} current data - */ - get logs() { - return this._logs; - } - - /** - * Reset all filtering, sorting and pagination settings to their default values - * - * @param {boolean} fetch Whether to refetch all logs after filters have been reset - * @return {undefined} - */ - reset(fetch = true) { - this.titleFilter.reset(); - this.contentFilter.reset(); - this.authorFilter.reset(); - - this.createdFilterFrom = ''; - this.createdFilterTo = ''; - - this.listingTagsFilterModel.reset(); - - this.runFilterOperation = 'AND'; - this.runFilterValues = []; - this._runFilterRawValue = ''; - - this.environmentFilterOperation = 'AND'; - this.environmentFilterValues = []; - this._environmentFilterRawValue = ''; - - this.lhcFillFilterOperation = 'AND'; - this.lhcFillFilterValues = []; - this._lhcFillFilterRawValue = ''; - - this._pagination.reset(); - - if (fetch) { - this._applyFilters(true); - } - } - - /** - * Checks if any filter value has been modified from their default (empty) - * @returns {boolean} If any filter is active - */ - isAnyFilterActive() { - return ( - !this._titleFilter.isEmpty - || !this._contentFilter.isEmpty - || !this._authorFilter.isEmpty - || this.createdFilterFrom !== '' - || this.createdFilterTo !== '' - || !this.listingTagsFilterModel.isEmpty - || this.runFilterValues.length !== 0 - || this.environmentFilterValues.length !== 0 - || this.lhcFillFilterValues.length !== 0 + * @param {string} pageIdentifier string that indicates what page this model represents + */ + constructor(model, excludeAnonymous = false, pageIdentifier) { + super( + model.router, + pageIdentifier, + { + author: new AuthorFilterModel(), + title: new RawTextFilterModel(), + content: new RawTextFilterModel(), + tags: new TagFilterModel(tagsProvider.items$), + runNumbers: new RawTextFilterModel(), + environmentIds: new RawTextFilterModel(), + fillNumbers: new RawTextFilterModel(), + created: new TimeRangeInputModel(), + }, ); - } - - /** - * Returns the current title substring filter - * @returns {string} The current title substring filter - */ - getRunsFilterRaw() { - return this._runFilterRawValue; - } - - /** - * Add a run to the filter - * @param {string} rawRuns The runs to be added to the filter criteria - * @returns {undefined} - */ - setRunsFilter(rawRuns) { - this._runFilterRawValue = rawRuns; - const runs = []; - const valuesRegex = /([0-9]+),?/g; - - let match = valuesRegex.exec(rawRuns); - while (match) { - runs.push(parseInt(match[1], 10)); - match = valuesRegex.exec(rawRuns); - } - - // Allow empty runs only if raw runs is an empty string - if (runs.length > 0 || rawRuns.length === 0) { - this.runFilterValues = runs; - this._applyFilters(); - } - } - - /** - * Returns the raw current environment filter - * @returns {string} the raw current environment filter - */ - getEnvFilterRaw() { - return this._environmentFilterRawValue; - } - - /** - * Returns the current environment filter - * @returns {string[]} The current environment filter - */ - getEnvFilter() { - return this.environmentFilterValues; - } - - /** - * Sets the environment filter - * @param {string} rawEnvironments The environments to apply to the filter - * @returns {undefined} - */ - setEnvFilter(rawEnvironments) { - this._environmentFilterRawValue = rawEnvironments; - const envs = rawEnvironments - .split(/[ ,]+/) - .filter(Boolean) - .map((id) => id.trim()); - - if (envs.length > 0 || rawEnvironments.length === 0) { - this.environmentFilterValues = envs; - this._applyFilters(); - } - } - - /** - * Returns the current title substring filter - * @returns {string} The current title substring filter - */ - getLhcFillsFilterRaw() { - return this._lhcFillFilterRawValue; - } - - /** - * Add a lhcFill to the filter - * @param {string} rawLhcFills The LHC fills to be added to the filter criteria - * @returns {void} - */ - setLhcFillsFilter(rawLhcFills) { - this._lhcFillFilterRawValue = rawLhcFills; - - // Split the lhc fills string by comma or whitespace, remove falsy values like empty strings, and convert to int - const lhcFills = rawLhcFills - .split(/[ ,]+/) - .filter(Boolean) - .map((fillNumberStr) => parseInt(fillNumberStr.trim(), 10)); - - // Allow empty lhcFills only if raw lhcFills is an empty string - if (lhcFills.length > 0 || rawLhcFills.length === 0) { - this.lhcFillFilterValues = lhcFills; - this._applyFilters(); - } - } - - /** - * Returns the current minimum creation datetime - * @returns {Integer} The current minimum creation datetime - */ - getCreatedFilterFrom() { - return this.createdFilterFrom; - } - /** - * Returns the current maximum creation datetime - * @returns {Integer} The current maximum creation datetime - */ - getCreatedFilterTo() { - return this.createdFilterTo; + excludeAnonymous && this._filteringModel.get('author').update('!Anonymous'); } /** - * Set a datetime for the creation datetime filter - * @param {string} key The filter value to apply the datetime to - * @param {Object} date The datetime to be applied to the creation datetime filter - * @param {boolean} valid Whether the inserted date passes validity check - * @returns {undefined} - */ - setCreatedFilter(key, date, valid) { - if (valid) { - this[`createdFilter${key}`] = date; - this._applyFilters(); - } - } - - /** - * Return the model handling the filtering on tags - * - * @return {TagFilterModel} the filtering model + * @inheritdoc */ - get listingTagsFilterModel() { - return this._listingTagsFilterModel; - } - - /** - * Returns the model handling the overview page table sort - * - * @return {SortModel} the sort model - */ - get overviewSortModel() { - return this._overviewSortModel; - } - - /** - * Returns the filter model for author filter - * - * @return {FilterInputModel} the filter model - */ - get authorFilter() { - return this._authorFilter; - } - - /** - * Returns the filter model for title filter - * - * @return {FilterInputModel} the filter model - */ - get titleFilter() { - return this._titleFilter; - } - - /** - * Returns the model for body filter - * @return {FilterInputModel} the filter model - */ - get contentFilter() { - return this._contentFilter; - } - - /** - * Returns the pagination model - * - * @return {PaginationModel} the pagination model - */ - get pagination() { - return this._pagination; - } - - /** - * Apply the current filtering and update the remote data list - * - * @param {boolean} now if true, filtering will be applied now without debouncing - * - * @return {void} - */ - _applyFilters(now = false) { - this._pagination.silentlySetCurrentPage(1); - now ? this.fetchLogs() : this._debouncedFetchAllLogs(); - } - - /** - * Register a new filter model - * @param {FilterInputModel} filter the filter to register - * @return {void} - * @private - */ - _registerFilter(filter) { - filter.visualChange$.bubbleTo(this); - filter.observe(() => this._applyFilters()); - } - - /** - * Returns the list of URL params corresponding to the currently applied filter - * - * @return {Object} the URL params - * - * @private - */ - _getFilterQueryParams() { - const sortOn = this._overviewSortModel.appliedOn; - const sortDirection = this._overviewSortModel.appliedDirection; - - return { - ...!this._titleFilter.isEmpty && { - 'filter[title]': this._titleFilter.value, - }, - ...!this._contentFilter.isEmpty && { - 'filter[content]': this._contentFilter.value, - }, - ...!this._authorFilter.isEmpty && { - 'filter[author]': this._authorFilter.value, - }, - ...this.createdFilterFrom && { - 'filter[created][from]': - new Date(`${this.createdFilterFrom.replace(/\//g, '-')}T00:00:00.000`).getTime(), - }, - ...this.createdFilterTo && { - 'filter[created][to]': - new Date(`${this.createdFilterTo.replace(/\//g, '-')}T23:59:59.999`).getTime(), - }, - ...!this.listingTagsFilterModel.isEmpty && { - 'filter[tags][values]': this.listingTagsFilterModel.selected.join(), - 'filter[tags][operation]': this.listingTagsFilterModel.combinationOperator, - }, - ...this.runFilterValues.length > 0 && { - 'filter[run][values]': this.runFilterValues.join(), - 'filter[run][operation]': this.runFilterOperation.toLowerCase(), - }, - ...this.environmentFilterValues.length > 0 && { - 'filter[environments][values]': this.environmentFilterValues, - 'filter[environments][operation]': this.environmentFilterOperation.toLowerCase(), - }, - ...this.lhcFillFilterValues.length > 0 && { - 'filter[lhcFills][values]': this.lhcFillFilterValues.join(), - 'filter[lhcFills][operation]': this.lhcFillFilterOperation.toLowerCase(), - }, - ...sortOn && sortDirection && { - [`sort[${sortOn}]`]: sortDirection, - }, - }; + getRootEndpoint() { + return this.buildRootEndpoint('/api/logs'); } } diff --git a/lib/public/views/Logs/Overview/index.js b/lib/public/views/Logs/Overview/index.js index 012f6e7bfe..bf72f81c3a 100644 --- a/lib/public/views/Logs/Overview/index.js +++ b/lib/public/views/Logs/Overview/index.js @@ -19,6 +19,7 @@ import { paginationComponent } from '../../../components/Pagination/paginationCo import { frontLink } from '../../../components/common/navigation/frontLink.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; import { excludeAnonymousLogAuthorToggle } from '../../../components/Filters/LogsFilter/author/authorFilter.js'; +import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 69; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -30,22 +31,22 @@ const PAGE_USED_HEIGHT = 215; * @return {Component} Returns a vnode with the table containing the logs */ const logOverviewScreen = ({ logs: { overviewModel: logsOverviewModel } }) => { - logsOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( - TABLEROW_HEIGHT, - PAGE_USED_HEIGHT, - )); + const { pagination, filteringModel, items, sortModel } = logsOverviewModel; + + pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); return h('', [ h('#main-action-bar.flex-row.justify-between.header-container.pv2', [ h('.flex-row.g3', [ filtersPanelPopover(logsOverviewModel, logsActiveColumns), - excludeAnonymousLogAuthorToggle(logsOverviewModel.authorFilter), + excludeAnonymousLogAuthorToggle(filteringModel.get('author')), ]), actionButtons(), ]), + warningComponent(logsOverviewModel), h('.w-100.flex-column', [ - table(logsOverviewModel.logs, logsActiveColumns, null, null, { sort: logsOverviewModel.overviewSortModel }), - paginationComponent(logsOverviewModel.pagination), + table(items, logsActiveColumns, null, null, { sort: sortModel }), + paginationComponent(pagination), ]), ]); }; diff --git a/lib/public/views/QcFlagTypes/ActiveColumns/qcFlagTypesActiveColumns.js b/lib/public/views/QcFlagTypes/ActiveColumns/qcFlagTypesActiveColumns.js index 7f4ae8aa69..6f1ae72b84 100644 --- a/lib/public/views/QcFlagTypes/ActiveColumns/qcFlagTypesActiveColumns.js +++ b/lib/public/views/QcFlagTypes/ActiveColumns/qcFlagTypesActiveColumns.js @@ -14,8 +14,8 @@ import { h } from '/js/src/index.js'; import { formatTimestamp } from '../../../utilities/formatting/formatTimestamp.js'; import { textFilter } from '../../../components/Filters/common/filters/textFilter.js'; -import { checkboxes } from '../../../components/Filters/common/filters/checkboxFilter.js'; import { qcFlagTypeColoredBadge } from '../../../components/qcFlags/qcFlagTypeColoredBadge.js'; +import radioButtonFilter from '../../../components/Filters/common/filters/radioButtonFilter.js'; /** * List of active columns for a QC Flag Types table @@ -30,10 +30,7 @@ export const qcFlagTypesActiveColumns = { name: { name: 'Name', visible: true, - filter: ({ namesFilterModel }) => textFilter( - namesFilterModel, - { class: 'w-75 mt1', placeholder: 'e.g. BadPID, ...' }, - ), + filter: ({ filteringModel }) => textFilter(filteringModel.get('names'), { class: 'w-75 mt1', placeholder: 'e.g. BadPID, ...' }), classes: 'f6', sortable: true, format: (_, qcFlagType) => qcFlagTypeColoredBadge(qcFlagType), @@ -43,10 +40,7 @@ export const qcFlagTypesActiveColumns = { name: 'Method', visible: true, sortable: true, - filter: ({ methodsFilterModel }) => textFilter( - methodsFilterModel, - { class: 'w-75 mt1', placeholder: 'e.g. Bad PID, ...' }, - ), + filter: ({ filteringModel }) => textFilter(filteringModel.get('methods'), { class: 'w-75 mt1', placeholder: 'e.g. Bad PID, ...' }), classes: 'f6', }, @@ -54,10 +48,7 @@ export const qcFlagTypesActiveColumns = { name: 'Bad', visible: true, sortable: true, - filter: ({ isBadFilterModel }) => checkboxes( - isBadFilterModel, - { class: 'w-75 mt1', selector: 'qc-flag-type-bad-filter' }, - ), + filter: ({ filteringModel }) => radioButtonFilter(filteringModel.get('bad'), 'bad'), classes: 'f6 w-5', format: (bad) => bad ? h('.danger', 'Yes') : h('.success', 'No'), }, diff --git a/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewModel.js b/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewModel.js index 6c80ada996..8f12861e8f 100644 --- a/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewModel.js +++ b/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewModel.js @@ -12,107 +12,34 @@ */ import { TextTokensFilterModel } from '../../../components/Filters/common/filters/TextTokensFilterModel.js'; -import { OverviewPageModel } from '../../../models/OverviewModel.js'; -import { SelectionModel } from '../../../components/common/selection/SelectionModel.js'; -import { buildUrl } from '/js/src/index.js'; +import { RadioButtonFilterModel } from '../../../components/Filters/common/RadioButtonFilterModel.js'; +import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; /** * QcFlagTypesOverviewModel */ -export class QcFlagTypesOverviewModel extends OverviewPageModel { +export class QcFlagTypesOverviewModel extends FilterableOverviewPageModel { /** * Constructor + * @param {QueryRouter} router router that controls the application's page navigation + * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor() { - super(); - - this._namesFilterModel = new TextTokensFilterModel(); - this._registerFilter(this._namesFilterModel); - this._methodsFilterModel = new TextTokensFilterModel(); - this._registerFilter(this._methodsFilterModel); - this._isBadFilterModel = - new SelectionModel({ availableOptions: [{ label: 'Bad', value: true }, { label: 'Not Bad', value: false }] }); - this._registerFilter(this._isBadFilterModel); + constructor(router, pageIdentifier) { + super( + router, + pageIdentifier, + { + names: new TextTokensFilterModel(), + methods: new TextTokensFilterModel(), + bad: new RadioButtonFilterModel([{ label: 'Any' }, { label: 'Bad', value: true }, { label: 'Not Bad', value: false }]), + }, + ); } /** * @inheritdoc */ getRootEndpoint() { - const params = {}; - if (this.isAnyFilterActive()) { - params.filter = { - names: this._namesFilterModel.normalized, - methods: this._methodsFilterModel.normalized, - bad: this._isBadFilterModel.selected.length === 2 - ? undefined - : this._isBadFilterModel.selected[0], - }; - } - - return buildUrl('/api/qcFlagTypes', params); - } - - /** - * Get names filter model - * - * @return {TextTokensFilterModel} names filter model - */ - get namesFilterModel() { - return this._namesFilterModel; - } - - /** - * Get methods filter model - * - * @return {TextTokensFilterModel} methods filter model - */ - get methodsFilterModel() { - return this._methodsFilterModel; - } - - /** - * Returns filter model for filtering bad and not bad flags - * - * @return {TextTokensFilterModel} filter model for filtering bad and not bad flags - */ - get isBadFilterModel() { - return this._isBadFilterModel; - } - - /** - * Register a new filter model - * - * @param {FilterModel} filterModel the filter model to register - * @return {void} - * @private - */ - _registerFilter(filterModel) { - filterModel.visualChange$.bubbleTo(this); - filterModel.observe(() => { - this._pagination.silentlySetCurrentPage(1); - this.load(); - }); - } - - /** - * States whether any filter is active - * - * @return {boolean} true if any filter is active - */ - isAnyFilterActive() { - return !this._namesFilterModel.isEmpty || !this._methodsFilterModel.isEmpty || this._isBadFilterModel.selected.length; - } - - /** - * Reset this model to its default - * - * @returns {void} - */ - reset() { - this._methodsFilterModel.reset(); - this._namesFilterModel.reset(); - this._isBadFilterModel.reset(); - super.reset(); + return this.buildRootEndpoint('/api/qcFlagTypes'); } } diff --git a/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewPage.js b/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewPage.js index 6b2a818527..0c3fb2a71e 100644 --- a/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewPage.js +++ b/lib/public/views/QcFlagTypes/Overview/QcFlagTypesOverviewPage.js @@ -19,6 +19,7 @@ import { qcFlagTypesActiveColumns } from '../ActiveColumns/qcFlagTypesActiveColu import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; import { frontLink } from '../../../components/common/navigation/frontLink.js'; import { BkpRoles } from '../../../domain/enums/BkpRoles.js'; +import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 30; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -30,12 +31,9 @@ const PAGE_USED_HEIGHT = 215; * @return {Component} The overview page */ export const QcFlagTypesOverviewPage = ({ qcFlagTypes: { overviewModel } }) => { - overviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( - TABLEROW_HEIGHT, - PAGE_USED_HEIGHT, - )); + const { items: qcFlagTypes, pagination, sortModel } = overviewModel; - const { items: qcFlagTypes } = overviewModel; + pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); return h('', [ h('.flex-row.justify-between.items-center.g2', [ @@ -50,15 +48,10 @@ export const QcFlagTypesOverviewPage = ({ qcFlagTypes: { overviewModel } }) => { }), ], ]), + warningComponent(overviewModel), h('.flex-column.w-100', [ - table( - qcFlagTypes, - qcFlagTypesActiveColumns, - { classes: '.table-sm' }, - null, - { sort: overviewModel.sortModel }, - ), - paginationComponent(overviewModel.pagination), + table(qcFlagTypes, qcFlagTypesActiveColumns, { classes: '.table-sm' }, null, { sort: sortModel }), + paginationComponent(pagination), ]), ]); }; diff --git a/lib/public/views/QcFlagTypes/QcFlagTypesModel.js b/lib/public/views/QcFlagTypes/QcFlagTypesModel.js index 43468d3e34..9fe8118a76 100644 --- a/lib/public/views/QcFlagTypes/QcFlagTypesModel.js +++ b/lib/public/views/QcFlagTypes/QcFlagTypesModel.js @@ -29,7 +29,7 @@ export class QcFlagTypesModel extends Observable { this.model = model; // Overview - this._overviewModel = new QcFlagTypesOverviewModel(); + this._overviewModel = new QcFlagTypesOverviewModel(model.router, 'qc-flag-types-overview'); this._overviewModel.bubbleTo(this); } @@ -38,6 +38,7 @@ export class QcFlagTypesModel extends Observable { * @return {void} */ loadOverview() { + this._overviewModel.setFilterFromURL(false); this._overviewModel.load(); } diff --git a/lib/public/views/QcFlags/ActiveColumns/synchronousQcFlagsActiveColumns.js b/lib/public/views/QcFlags/ActiveColumns/synchronousQcFlagsActiveColumns.js new file mode 100644 index 0000000000..e087e2b780 --- /dev/null +++ b/lib/public/views/QcFlags/ActiveColumns/synchronousQcFlagsActiveColumns.js @@ -0,0 +1,61 @@ +/** + * @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 { h } from '/js/src/index.js'; +import { qcFlagsActiveColumns } from './qcFlagsActiveColumns.js'; +import { formatQcFlagStart } from '../format/formatQcFlagStart.js'; +import { formatQcFlagEnd } from '../format/formatQcFlagEnd.js'; +import { formatQcFlagCreatedBy } from '../format/formatQcFlagCreatedBy.js'; +import { formatTimestamp } from '../../../utilities/formatting/formatTimestamp.js'; + +/** + * Active columns configuration for synchronous QC flags table + */ +export const synchronousQcFlagsActiveColumns = { + id: { + name: 'Id', + visible: false, + }, + flagType: { + ...qcFlagsActiveColumns.flagType, + classes: 'w-15', + }, + from: { + name: 'From/To', + visible: true, + format: (_, qcFlag) => h('', [ + h('.flex-row', ['From: ', formatQcFlagStart(qcFlag, true)]), + h('.flex-row', ['To: ', formatQcFlagEnd(qcFlag, true)]), + ]), + classes: 'w-15', + }, + comment: { + ...qcFlagsActiveColumns.comment, + balloon: true, + }, + deleted: { + name: 'Deleted', + visible: true, + classes: 'w-5', + format: (deleted) => deleted ? h('.danger', 'Yes') : 'No', + }, + createdBy: { + name: 'Created', + visible: true, + balloon: true, + format: (_, qcFlag) => h('', [ + h('.flex-row', ['By: ', formatQcFlagCreatedBy(qcFlag)]), + h('.flex-row', ['At: ', formatTimestamp(qcFlag.createdAt)]), + ]), + }, +}; diff --git a/lib/public/views/QcFlags/Synchronous/SynchronousQcFlagsOverviewPage.js b/lib/public/views/QcFlags/Synchronous/SynchronousQcFlagsOverviewPage.js index b8937c51ba..8fa5058b25 100644 --- a/lib/public/views/QcFlags/Synchronous/SynchronousQcFlagsOverviewPage.js +++ b/lib/public/views/QcFlags/Synchronous/SynchronousQcFlagsOverviewPage.js @@ -16,7 +16,7 @@ import { h } from '/js/src/index.js'; import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; import { table } from '../../../components/common/table/table.js'; import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; -import { qcFlagsActiveColumns } from '../ActiveColumns/qcFlagsActiveColumns.js'; +import { synchronousQcFlagsActiveColumns } from '../ActiveColumns/synchronousQcFlagsActiveColumns.js'; import { qcFlagsBreadcrumbs } from '../../../components/qcFlags/qcFlagsBreadcrumbs.js'; import { mergeRemoteData } from '../../../utilities/mergeRemoteData.js'; import errorAlert from '../../../components/common/errorAlert.js'; @@ -46,16 +46,6 @@ export const SynchronousQcFlagsOverviewPage = ({ qcFlags: { synchronousOverviewM PAGE_USED_HEIGHT, )); - const activeColumns = { - qcFlagId: { - name: 'Id', - visible: false, - classes: 'w-5', - }, - ...qcFlagsActiveColumns, - }; - delete activeColumns.verified; - return h( '', { onremove: () => synchronousOverviewModel.reset() }, @@ -70,8 +60,8 @@ export const SynchronousQcFlagsOverviewPage = ({ qcFlags: { synchronousOverviewM h('.w-100.flex-column', [ table( qcFlags, - activeColumns, - { classes: '.table-sm' }, + synchronousQcFlagsActiveColumns, + { classes: '.table-sm.f6' }, null, { sort: sortModel }, ), diff --git a/lib/public/views/QcFlags/format/formatQcFlagEnd.js b/lib/public/views/QcFlags/format/formatQcFlagEnd.js index dac1426802..9cb2a7857d 100644 --- a/lib/public/views/QcFlags/format/formatQcFlagEnd.js +++ b/lib/public/views/QcFlags/format/formatQcFlagEnd.js @@ -17,11 +17,12 @@ import { formatTimestamp } from '../../../utilities/formatting/formatTimestamp.j * Format QC flag `to` timestamp * * @param {QcFlag} qcFlag QC flag + * @param {boolean} inline if true, date and time are on a single line * @return {Component} formatted `to` timestamp */ -export const formatQcFlagEnd = ({ from, to }) => { +export const formatQcFlagEnd = ({ from, to }, inline = false) => { if (to) { - return formatTimestamp(to, false); + return formatTimestamp(to, inline); } else { return from ? 'Until run end' diff --git a/lib/public/views/QcFlags/format/formatQcFlagStart.js b/lib/public/views/QcFlags/format/formatQcFlagStart.js index b5a11b9b6d..bf9e8ccae5 100644 --- a/lib/public/views/QcFlags/format/formatQcFlagStart.js +++ b/lib/public/views/QcFlags/format/formatQcFlagStart.js @@ -17,11 +17,12 @@ import { formatTimestamp } from '../../../utilities/formatting/formatTimestamp.j * Format QC flag `from` timestamp * * @param {QcFlag} qcFlag QC flag + * @param {boolean} inline if true, date and time are on a single line * @return {Component} formatted `from` timestamp */ -export const formatQcFlagStart = ({ from, to }) => { +export const formatQcFlagStart = ({ from, to }, inline = false) => { if (from) { - return formatTimestamp(from, false); + return formatTimestamp(from, inline); } else { return to ? 'Since run start' diff --git a/lib/public/views/Runs/ActiveColumns/runDetectorsAsyncQcActiveColumns.js b/lib/public/views/Runs/ActiveColumns/runDetectorsAsyncQcActiveColumns.js index f4497010c4..2647f8589a 100644 --- a/lib/public/views/Runs/ActiveColumns/runDetectorsAsyncQcActiveColumns.js +++ b/lib/public/views/Runs/ActiveColumns/runDetectorsAsyncQcActiveColumns.js @@ -161,7 +161,7 @@ export const createRunDetectorsAsyncQcActiveColumns = ( visible: false, profiles: profile, filter: (filteringModel) => { - const filterModel = filteringModel.get(`detectorsQc[_${dplDetectorId}][notBadFraction]`); + const filterModel = filteringModel.get('detectorsQcNotBadFraction').getFilter(`_${dplDetectorId}`); return filterModel ? numericalComparisonFilter(filterModel, { step: 0.1, selectorPrefix: `detectorsQc-for-${dplDetectorId}-notBadFraction` }) : null; diff --git a/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js b/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js index eefe0f006f..9992a93407 100644 --- a/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js +++ b/lib/public/views/Runs/ActiveColumns/runsActiveColumns.js @@ -12,11 +12,7 @@ */ import { CopyToClipboardComponent, h } from '/js/src/index.js'; -import { runNumbersFilter } from '../../../components/Filters/RunsFilter/runNumbersFilter.js'; import { displayRunEorReasonsOverview } from '../format/displayRunEorReasonOverview.js'; -import ddflpFilter from '../../../components/Filters/RunsFilter/ddflp.js'; -import dcsFilter from '../../../components/Filters/RunsFilter/dcs.js'; -import epnFilter from '../../../components/Filters/RunsFilter/epn.js'; import { formatTimestamp } from '../../../utilities/formatting/formatTimestamp.js'; import { displayRunDuration } from '../format/displayRunDuration.js'; import { frontLink } from '../../../components/common/navigation/frontLink.js'; @@ -44,10 +40,10 @@ import { isRunConsideredRunning } from '../../../services/run/isRunConsideredRun import { aliEcsEnvironmentLinkComponent } from '../../../components/common/externalLinks/aliEcsEnvironmentLinkComponent.js'; import { detectorsFilterComponent } from '../../../components/Filters/RunsFilter/detectorsFilterComponent.js'; import { timeRangeFilter } from '../../../components/Filters/common/filters/timeRangeFilter.js'; -import { rawTextFilter } from '../../../components/Filters/common/filters/rawTextFilter.js'; import { numericalComparisonFilter } from '../../../components/Filters/common/filters/numericalComparisonFilter.js'; import { checkboxes } from '../../../components/Filters/common/filters/checkboxFilter.js'; -import { triggerValueFilter } from '../../../components/Filters/RunsFilter/triggerValueFilter.js'; +import radioButtonFilter from '../../../components/Filters/common/filters/radioButtonFilter.js'; +import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; /** * List of active columns for a generic runs table @@ -67,10 +63,10 @@ export const runsActiveColumns = { /** * Run numbers filter component * - * @param {RunsOverviewModel} runsOverviewModel the runs overview model + * @param {FilteringModel} runsOverviewModel.filteringModel the filtering model * @return {Component} the filter component */ - filter: (runsOverviewModel) => runNumbersFilter(runsOverviewModel.filteringModel.get('runNumbers')), + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'runNumbers', 'e.g. 534454, 534455...'), format: (runNumber, run) => buttonLinkWithDropdown( runNumber, 'run-detail', @@ -162,8 +158,7 @@ export const runsActiveColumns = { * @param {RunsOverviewModel} runsOverviewModel the runs overview model * @return {Component} the beam modes filter component */ - filter: (runsOverviewModel) => - selectionDropdown(runsOverviewModel.filteringModel.get('beamModes').selectionDropdownModel, { selectorPrefix: 'beam-mode' }), + filter: (runsOverviewModel) => selectionDropdown(runsOverviewModel.filteringModel.get('beamModes'), { selectorPrefix: 'beam-mode' }), }, fillNumber: { name: 'Fill No.', @@ -189,13 +184,10 @@ export const runsActiveColumns = { /** * Fill number filter component * - * @param {RunsOverviewModel} runsOverviewModel the runs overview model + * @param {FilteringModel} RunsOverviewModel.filteringModel the filtering model * @return {Component} the filter component */ - filter: (runsOverviewModel) => rawTextFilter( - runsOverviewModel.filteringModel.get('fillNumbers'), - { classes: ['w-100', 'fill-numbers-filter'], placeholder: 'e.g. 7966, 7954, 7948...' }, - ), + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'fillNumbers', 'e.g. 7966, 7954, 7948...'), }, lhcPeriod: { name: 'LHC Period', @@ -207,13 +199,10 @@ export const runsActiveColumns = { /** * LHC Periods filter * - * @param {RunsOverviewModel} runsOverviewModel the runs overview model + * @param {FilteringModel} RunsOverviewModel.filteringModel the filtering model * @return {Component} the filter component */ - filter: (runsOverviewModel) => rawTextFilter( - runsOverviewModel.filteringModel.get('lhcPeriods'), - { classes: ['w-100'], placeholder: 'e.g. LHC22b, LHC22a...' }, - ), + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'lhcPeriods', 'e.g. LHC22b, LHC22a...'), }, timeO2Start: { name: 'Start', @@ -399,13 +388,10 @@ export const runsActiveColumns = { /** * Environment ids filter component * - * @param {RunsOverviewModel} runsOverviewModel the runs overview model - * @return {Component} the environment ids filter component + * @param {FilteringModel} RunsOverviewModel.filteringModel the filtering model + * @return {Component} the filter component */ - filter: (runsOverviewModel) => rawTextFilter( - runsOverviewModel.filteringModel.get('environmentIds'), - { classes: ['environment-ids-filter', 'w-100'], placeholder: 'e.g. Dxi029djX, TDI59So3d...' }, - ), + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'environmentIds', 'e.g. Dxi029djX, TDI59So3d...'), format: (id) => id ? frontLink(id, 'env-details', { environmentId: id }) : '-', }, runType: { @@ -420,10 +406,7 @@ export const runsActiveColumns = { * @param {RunsOverviewModel} runsOverviewModel the runs overview model * @return {Component} the run types filter component */ - filter: (runsOverviewModel) => selectionDropdown( - runsOverviewModel.filteringModel.get('runTypes').selectionDropdownModel, - { selectorPrefix: 'run-types' }, - ), + filter: (runsOverviewModel) => selectionDropdown(runsOverviewModel.filteringModel.get('runTypes'), { selectorPrefix: 'run-types' }), }, runQuality: { name: 'Quality', @@ -454,7 +437,7 @@ export const runsActiveColumns = { * @param {RunsOverviewModel} runsOverviewModel the runs overview model * @return {Component} the run quality filter component */ - filter: (runsOverviewModel) => checkboxes(runsOverviewModel.filteringModel.get('runQualities').selectionModel), + filter: (runsOverviewModel) => checkboxes(runsOverviewModel.filteringModel.get('runQualities')), }, nDetectors: { name: 'DETs #', @@ -525,7 +508,7 @@ export const runsActiveColumns = { classes: 'w-2 f6 w-wrapped', format: (boolean) => boolean ? 'On' : 'Off', exportFormat: (boolean) => boolean ? 'On' : 'Off', - filter: ddflpFilter, + filter: ({ filteringModel }) => radioButtonFilter(filteringModel.get('ddflp'), 'ddFlp'), }, dcs: { name: 'DCS', @@ -534,14 +517,21 @@ export const runsActiveColumns = { classes: 'w-2 f6 w-wrapped', format: (boolean) => boolean ? 'On' : 'Off', exportFormat: (boolean) => boolean ? 'On' : 'Off', - filter: dcsFilter, + filter: ({ filteringModel }) => radioButtonFilter(filteringModel.get('dcs'), 'dcs'), }, triggerValue: { name: 'TRG', visible: true, profiles: [profiles.none, 'lhcFill', 'environment'], classes: 'w-5 f6 w-wrapped', - filter: triggerValueFilter, + + /** + * TriggerValue filter component + * + * @param {RunsOverviewModel} runsOverviewModel the runs overview model + * @return {Component} the trigger value filter component + */ + filter: ({ filteringModel }) => checkboxes(filteringModel.get('triggerValues'), { selector: 'triggerValue' }), format: (trgValue) => trgValue ? trgValue : '-', }, epn: { @@ -551,7 +541,7 @@ export const runsActiveColumns = { classes: 'w-2 f6 w-wrapped', format: (boolean) => boolean ? 'On' : 'Off', exportFormat: (boolean) => boolean ? 'On' : 'Off', - filter: epnFilter, + filter: ({ filteringModel }) => radioButtonFilter(filteringModel.get('epn'), 'epn'), }, epnTopology: { name: 'EPN Topology', @@ -568,13 +558,10 @@ export const runsActiveColumns = { /** * ODC topology full name filter component * - * @param {RunsOverviewModel} runsOverviewModel the runs overview model + * @param {FilteringModel} RunsOverviewModel.filteringModel the filtering model * @return {Component} the filter component */ - filter: (runsOverviewModel) => rawTextFilter( - runsOverviewModel.filteringModel.get('odcTopologyFullName'), - { classes: ['w-100'] }, - ), + filter: ({ filteringModel }) => textInputFilter(filteringModel, 'odcTopologyFullName'), balloon: true, }, eorReasons: { @@ -676,10 +663,8 @@ export const runsActiveColumns = { * @param {RunsOverviewModel} runsOverviewModel the runs overview model * @return {Component} the run types filter component */ - filter: (runsOverviewModel) => selectionDropdown( - runsOverviewModel.filteringModel.get('magnets').selectionDropdownModel, - { selectorPrefix: 'l3-dipole-current' }, - ), + filter: (runsOverviewModel) => + selectionDropdown(runsOverviewModel.filteringModel.get('magnets'), { selectorPrefix: 'l3-dipole-current' }), profiles: ['runsPerLhcPeriod', 'runsPerDataPass', 'runsPerSimulationPass', profiles.none], }, diff --git a/lib/public/views/Runs/Details/RunPatch.js b/lib/public/views/Runs/Details/RunPatch.js index 3b000b8223..54f7e347c6 100644 --- a/lib/public/views/Runs/Details/RunPatch.js +++ b/lib/public/views/Runs/Details/RunPatch.js @@ -9,6 +9,7 @@ import { RunQualities } from '../../../domain/enums/RunQualities.js'; * @property {string} category * @property {string} title * @property {string} description + * @property {string|null} [lastEditedName] */ /** @@ -75,7 +76,8 @@ export class RunPatch extends Observable { } if (this._eorReasons.length !== this._run.eorReasons.length || this._eorReasons.some(({ id }) => id === undefined)) { - ret.eorReasons = this._eorReasons; + // Strip lastEditedName — the server's EorReasonDto only accepts id, reasonTypeId, and description + ret.eorReasons = this._eorReasons.map(({ id, reasonTypeId, description }) => ({ id, reasonTypeId, description })); } if (this._hasRunQualityChange()) { @@ -126,7 +128,12 @@ export class RunPatch extends Observable { } = this._run || {}; this._runQuality = runQuality; - this._eorReasons = eorReasons.map(({ id, description, reasonTypeId }) => ({ id, description, reasonTypeId })); + this._eorReasons = eorReasons.map(({ id, description, reasonTypeId, lastEditedName }) => ({ + id, + description, + reasonTypeId, + lastEditedName, + })); this._tags = tags.map(({ text }) => text); this.formData = { diff --git a/lib/public/views/Runs/Details/runDetailsComponent.js b/lib/public/views/Runs/Details/runDetailsComponent.js index ebb7ae4fe7..fe370ced70 100644 --- a/lib/public/views/Runs/Details/runDetailsComponent.js +++ b/lib/public/views/Runs/Details/runDetailsComponent.js @@ -40,7 +40,7 @@ import { RunDefinition } from '../../../domain/enums/RunDefinition.js'; import { formatFloat } from '../../../utilities/formatting/formatFloat.js'; import { formatEditableNumber } from '../format/formatEditableNumber.js'; import { editRunEorReasons } from '../format/editRunEorReasons.js'; -import { formatEorReason } from '../format/formatEorReason.mjs'; +import { formatRunEorReason } from '../format/formatRunEorReason.js'; import { selectionDropdown } from '../../../components/common/selection/dropdown/selectionDropdown.js'; import { formatRunCalibrationStatus } from '../format/formatRunCalibrationStatus.js'; import { BeamModes } from '../../../domain/enums/BeamModes.js'; @@ -533,7 +533,10 @@ export const runDetailsComponent = (runDetailsModel, router) => runDetailsModel. h('#eor-reasons.flex-row', [ runDetailsModel.isEditModeEnabled ? editRunEorReasons(runDetailsModel) - : h('.flex-column.g2', run.eorReasons.map((eorReason) => h('.eor-reason', formatEorReason(eorReason)))), + : h( + '.flex-column.g2.w-100', + run.eorReasons.map((eorReason) => h('.eor-reason', formatRunEorReason(eorReason))), + ), ]), ]), ]), diff --git a/lib/public/views/Runs/Overview/FixedPdpBeamTypeRunsOverviewModel.js b/lib/public/views/Runs/Overview/FixedPdpBeamTypeRunsOverviewModel.js index 82eaf9e819..5edce4c14e 100644 --- a/lib/public/views/Runs/Overview/FixedPdpBeamTypeRunsOverviewModel.js +++ b/lib/public/views/Runs/Overview/FixedPdpBeamTypeRunsOverviewModel.js @@ -23,9 +23,10 @@ export class FixedPdpBeamTypeRunsOverviewModel extends RunsWithQcModel { /** * Constructor * @param {Model} model global model + * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(model) { - super(model); + constructor(model, pageIdentifier) { + super(model, pageIdentifier); this._pdpBeamTypes = []; } diff --git a/lib/public/views/Runs/Overview/RunsOverviewModel.js b/lib/public/views/Runs/Overview/RunsOverviewModel.js index 0249c66085..c6912513b6 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewModel.js +++ b/lib/public/views/Runs/Overview/RunsOverviewModel.js @@ -11,18 +11,15 @@ * or submit itself to any jurisdiction. */ -import { buildUrl } from '/js/src/index.js'; import { TagFilterModel } from '../../../components/Filters/common/TagFilterModel.js'; -import { debounce } from '../../../utilities/debounce.js'; import { DetectorsFilterModel } from '../../../components/Filters/RunsFilter/DetectorsFilterModel.js'; import { RunTypesFilterModel } from '../../../components/runTypes/RunTypesFilterModel.js'; import { EorReasonFilterModel } from '../../../components/Filters/RunsFilter/EorReasonFilterModel.js'; -import { OverviewPageModel } from '../../../models/OverviewModel.js'; +import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; import { CombinationOperator } from '../../../components/Filters/common/CombinationOperatorChoiceModel.js'; import { NumericalComparisonFilterModel } from '../../../components/Filters/common/filters/NumericalComparisonFilterModel.js'; import { detectorsProvider } from '../../../services/detectors/detectorsProvider.js'; import { MagnetsFilteringModel } from '../../../components/Filters/RunsFilter/MagnetsFilteringModel.js'; -import { FilteringModel } from '../../../components/Filters/common/FilteringModel.js'; import { tagsProvider } from '../../../services/tag/tagsProvider.js'; import { eorReasonTypeProvider } from '../../../services/eorReason/eorReasonTypeProvider.js'; import { runTypesProvider } from '../../../services/runTypes/runTypesProvider.js'; @@ -31,74 +28,75 @@ import { magnetsCurrentLevelsProvider } from '../../../services/magnets/magnetsC import { RawTextFilterModel } from '../../../components/Filters/common/filters/RawTextFilterModel.js'; import { RunDefinitionFilterModel } from '../../../components/Filters/RunsFilter/RunDefinitionFilterModel.js'; import { RUN_QUALITIES } from '../../../domain/enums/RunQualities.js'; -import { SelectionFilterModel } from '../../../components/Filters/common/filters/SelectionFilterModel.js'; import { DataExportModel } from '../../../models/DataExportModel.js'; import { runsActiveColumns as dataExportConfiguration } from '../ActiveColumns/runsActiveColumns.js'; import { BeamModeFilterModel } from '../../../components/Filters/RunsFilter/BeamModeFilterModel.js'; import { beamModesProvider } from '../../../services/beamModes/beamModesProvider.js'; +import { RadioButtonFilterModel } from '../../../components/Filters/common/RadioButtonFilterModel.js'; +import { SelectionModel } from '../../../components/common/selection/SelectionModel.js'; +import { TRIGGER_VALUES } from '../../../domain/enums/TriggerValue.js'; /** * Model representing handlers for runs page * * @implements {OverviewModel} */ -export class RunsOverviewModel extends OverviewPageModel { +export class RunsOverviewModel extends FilterableOverviewPageModel { /** * The constructor of the Overview model object * @param {Model} model global model - */ - constructor(model) { - super(); - - this._filteringModel = new FilteringModel({ - runNumbers: new RawTextFilterModel(), - detectors: new DetectorsFilterModel(detectorsProvider.dataTaking$), - tags: new TagFilterModel( - tagsProvider.items$, - [ - CombinationOperator.AND, - CombinationOperator.OR, - CombinationOperator.NONE_OF, - ], - ), - fillNumbers: new RawTextFilterModel(), - lhcPeriods: new RawTextFilterModel(), - o2start: new TimeRangeFilterModel(), - o2end: new TimeRangeFilterModel(), - definitions: new RunDefinitionFilterModel(), - runDuration: new NumericalComparisonFilterModel({ scale: 60 * 1000 }), - environmentIds: new RawTextFilterModel(), - runTypes: new RunTypesFilterModel(runTypesProvider.items$), - beamModes: new BeamModeFilterModel(beamModesProvider.items$), - runQualities: new SelectionFilterModel({ - availableOptions: RUN_QUALITIES.map((quality) => ({ - label: quality.toUpperCase(), - value: quality, - })), - }), - nDetectors: new NumericalComparisonFilterModel({ integer: true }), - nEpns: new NumericalComparisonFilterModel({ integer: true }), - nFlps: new NumericalComparisonFilterModel({ integer: true }), - ctfFileCount: new NumericalComparisonFilterModel({ integer: true }), - tfFileCount: new NumericalComparisonFilterModel({ integer: true }), - otherFileCount: new NumericalComparisonFilterModel({ integer: true }), - odcTopologyFullName: new RawTextFilterModel(), - eorReason: new EorReasonFilterModel(eorReasonTypeProvider.items$), - magnets: new MagnetsFilteringModel(magnetsCurrentLevelsProvider.items$), - muInelasticInteractionRate: new NumericalComparisonFilterModel(), - inelasticInteractionRateAvg: new NumericalComparisonFilterModel(), - inelasticInteractionRateAtStart: new NumericalComparisonFilterModel(), - inelasticInteractionRateAtMid: new NumericalComparisonFilterModel(), - inelasticInteractionRateAtEnd: new NumericalComparisonFilterModel(), - }); - - this._filteringModel.observe(() => this._applyFilters(true)); - this._filteringModel.visualChange$.bubbleTo(this); - - this.reset(false); - const updateDebounceTime = () => { - this._debouncedLoad = debounce(this.load.bind(this), model.inputDebounceTime); - }; + * @param {string} pageIdentifier string that indicates what page this model represents + */ + constructor(model, pageIdentifier) { + super( + model.router, + pageIdentifier, + { + runNumbers: new RawTextFilterModel(), + detectors: new DetectorsFilterModel(detectorsProvider.dataTaking$), + tags: new TagFilterModel( + tagsProvider.items$, + [ + CombinationOperator.AND, + CombinationOperator.OR, + CombinationOperator.NONE_OF, + ], + ), + fillNumbers: new RawTextFilterModel(), + lhcPeriods: new RawTextFilterModel(), + o2start: new TimeRangeFilterModel(), + o2end: new TimeRangeFilterModel(), + definitions: new RunDefinitionFilterModel(), + runDuration: new NumericalComparisonFilterModel({ scale: 60 * 1000 }), + environmentIds: new RawTextFilterModel(), + runTypes: new RunTypesFilterModel(runTypesProvider.items$), + beamModes: new BeamModeFilterModel(beamModesProvider.items$), + runQualities: new SelectionModel({ + availableOptions: RUN_QUALITIES.map((quality) => ({ + label: quality.toUpperCase(), + value: quality, + })), + }), + nDetectors: new NumericalComparisonFilterModel({ integer: true }), + nEpns: new NumericalComparisonFilterModel({ integer: true }), + nFlps: new NumericalComparisonFilterModel({ integer: true }), + ctfFileCount: new NumericalComparisonFilterModel({ integer: true }), + tfFileCount: new NumericalComparisonFilterModel({ integer: true }), + otherFileCount: new NumericalComparisonFilterModel({ integer: true }), + odcTopologyFullName: new RawTextFilterModel(), + eorReason: new EorReasonFilterModel(eorReasonTypeProvider.items$), + magnets: new MagnetsFilteringModel(magnetsCurrentLevelsProvider.items$), + muInelasticInteractionRate: new NumericalComparisonFilterModel(), + inelasticInteractionRateAvg: new NumericalComparisonFilterModel(), + inelasticInteractionRateAtStart: new NumericalComparisonFilterModel(), + inelasticInteractionRateAtMid: new NumericalComparisonFilterModel(), + inelasticInteractionRateAtEnd: new NumericalComparisonFilterModel(), + ddflp: new RadioButtonFilterModel([{ label: 'ANY' }, { label: 'ON', value: true }, { label: 'OFF', value: false }]), + dcs: new RadioButtonFilterModel([{ label: 'ANY' }, { label: 'ON', value: true }, { label: 'OFF', value: false }]), + epn: new RadioButtonFilterModel([{ label: 'ANY' }, { label: 'ON', value: true }, { label: 'OFF', value: false }]), + triggerValues: new SelectionModel({ availableOptions: TRIGGER_VALUES.map((value) => ({ label: value, value })) }), + }, + ); this._exportModel = new DataExportModel(this._allItems$, dataExportConfiguration, () => this.loadAll()); this._exportModel.bubbleTo(this); @@ -106,212 +104,28 @@ export class RunsOverviewModel extends OverviewPageModel { this._exportModel.setDisabled(!this.hasAnyData()); this._exportModel.setTotalExistingItemsCount(this._pagination.itemsCount); }); - - model.appConfiguration$.observe(() => updateDebounceTime()); - updateDebounceTime(); - } - - /** - * Get export model - * @return {DataExportModel} export model - */ - get exportModel() { - return this._exportModel; } /** * @inheritdoc */ - getRootEndpoint() { - return buildUrl('/api/runs', { ...this._getFilterQueryParams(), ...{ filter: this.filteringModel.normalized } }); - } - - /** - * Returns all filtering, sorting and pagination settings to their default values - * @param {boolean} [fetch = true] whether to refetch all data after filters have been reset - * @return {void} - */ reset(fetch = true) { - super.reset(); this._exportModel?.reset(); - this.resetFiltering(fetch); - } - - /** - * Reset all filtering models - * @param {boolean} fetch Whether to refetch all data after filters have been reset - * @return {void} - */ - resetFiltering(fetch = true) { - this._filteringModel.reset(); - - this._triggerValuesFilters = new Set(); - - this.ddflpFilter = ''; - - this.dcsFilter = ''; - - this.epnFilter = ''; - - if (fetch) { - this._applyFilters(true); - } + super.reset(fetch); } /** - * Checks if any filter value has been modified from their default (empty) - * @return {Boolean} If any filter is active - */ - isAnyFilterActive() { - return this._filteringModel.isAnyFilterActive() - || this._triggerValuesFilters.size !== 0 - || this.ddflpFilter !== '' - || this.dcsFilter !== '' - || this.epnFilter !== ''; - } - - /** - * Return the filtering model - * - * @return {FilteringModel} the filtering model - */ - get filteringModel() { - return this._filteringModel; - } - - /** - * Getter for the trigger values filter Set - * @return {Set} set of trigger filter values - */ - get triggerValuesFilters() { - return this._triggerValuesFilters; - } - - /** - * Setter for trigger values filter, this replaces the current set - * @param {Array} newTriggerValues new Set of values - * @return {undefined} - */ - set triggerValuesFilters(newTriggerValues) { - this._triggerValuesFilters = new Set(newTriggerValues); - this._applyFilters(); - } - - /** - * Returns the boolean of ddflp - * @return {Boolean} if ddflp is on - */ - getDdflpFilterOperation() { - return this.ddflpFilter; - } - - /** - * Sets the boolean of the filter if no new inputs were detected for 200 milliseconds - * @param {boolean} operation if the ddflp is on - * @return {undefined} - */ - setDdflpFilterOperation(operation) { - this.ddflpFilter = operation; - this._applyFilters(); - } - - /** - * Unchecks the ddflp checkbox and fetches all the runs. - * @return {undefined} - * - */ - removeDdflp() { - this.ddflpFilter = ''; - this._applyFilters(); - } - - /** - * Returns the boolean of dcs - * @return {Boolean} if dcs is on - */ - getDcsFilterOperation() { - return this.dcsFilter; - } - - /** - * Sets the boolean of the filter if no new inputs were detected for 200 milliseconds - * @param {boolean} operation if the dcs is on - * @return {undefined} - */ - setDcsFilterOperation(operation) { - this.dcsFilter = operation; - this._applyFilters(); - } - - /** - * Unchecks the dcs checkbox and fetches all the runs. - * @return {undefined} - */ - removeDcs() { - this.dcsFilter = ''; - this._applyFilters(); - } - - /** - * Returns the boolean of epn - * @return {Boolean} if epn is on - */ - getEpnFilterOperation() { - return this.epnFilter; - } - - /** - * Sets the boolean of the filter if no new inputs were detected for 200 milliseconds - * @param {boolean} operation if the epn is on - * @return {undefined} - */ - setEpnFilterOperation(operation) { - this.epnFilter = operation; - this._applyFilters(); - } - - /** - * Unchecks the epn checkbox and fetches all the runs. - * @return {undefined} - */ - removeEpn() { - this.epnFilter = ''; - this._applyFilters(); - } - - /** - * Returns the list of URL params corresponding to the currently applied filter - * - * @return {Object} the URL params - * - * @private + * Get export model + * @return {DataExportModel} export model */ - _getFilterQueryParams() { - return { - ...this._triggerValuesFilters.size !== 0 && { - 'filter[triggerValues]': Array.from(this._triggerValuesFilters).join(), - }, - ...(this.ddflpFilter === true || this.ddflpFilter === false) && { - 'filter[ddflp]': this.ddflpFilter, - }, - ...(this.dcsFilter === true || this.dcsFilter === false) && { - 'filter[dcs]': this.dcsFilter, - }, - ...(this.epnFilter === true || this.epnFilter === false) && { - 'filter[epn]': this.epnFilter, - }, - }; + get exportModel() { + return this._exportModel; } /** - * Apply the current filtering and update the remote data list - * - * @param {boolean} now if true, filtering will be applied now without debouncing - * - * @return {void} + * @inheritdoc */ - _applyFilters(now = false) { - this._pagination.currentPage = 1; - now ? this.load() : this._debouncedLoad(true); + getRootEndpoint() { + return this.buildRootEndpoint('/api/runs'); } } diff --git a/lib/public/views/Runs/Overview/RunsOverviewPage.js b/lib/public/views/Runs/Overview/RunsOverviewPage.js index ab43fcbfe9..4f76d417d9 100644 --- a/lib/public/views/Runs/Overview/RunsOverviewPage.js +++ b/lib/public/views/Runs/Overview/RunsOverviewPage.js @@ -17,9 +17,10 @@ import { filtersPanelPopover } from '../../../components/Filters/common/filtersP import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { runsActiveColumns } from '../ActiveColumns/runsActiveColumns.js'; import { table } from '../../../components/common/table/table.js'; -import { runNumbersFilter } from '../../../components/Filters/RunsFilter/runNumbersFilter.js'; import { switchInput } from '../../../components/common/form/switchInput.js'; import { exportTriggerAndModal } from '../../../components/common/dataExport/exportTriggerAndModal.js'; +import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; +import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 59; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -46,21 +47,20 @@ export const togglePhysicsOnlyFilter = (runDefinitionFilterModel) => { * @return {Component} Returns a vnode with the table containing the runs */ export const RunsOverviewPage = ({ runs: { overviewModel: runsOverviewModel }, modalModel }) => { - runsOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( - TABLEROW_HEIGHT, - PAGE_USED_HEIGHT, - )); + const { pagination, items, exportModel, filteringModel } = runsOverviewModel; + pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); return h('', [ h('.flex-row.header-container.g2.pv2', [ filtersPanelPopover(runsOverviewModel, runsActiveColumns), - h('.pl2#runOverviewFilter', runNumbersFilter(runsOverviewModel.filteringModel.get('runNumbers'))), - togglePhysicsOnlyFilter(runsOverviewModel.filteringModel.get('definitions')), - exportTriggerAndModal(runsOverviewModel.exportModel, modalModel), + h('.pl2#runOverviewFilter', textInputFilter(runsOverviewModel.filteringModel, 'runNumbers', 'e.g. 534454, 534455...')), + togglePhysicsOnlyFilter(filteringModel.get('definitions')), + exportTriggerAndModal(exportModel, modalModel), ]), + warningComponent(runsOverviewModel), h('.flex-column.w-100', [ - table(runsOverviewModel.items, runsActiveColumns), - paginationComponent(runsOverviewModel.pagination), + table(items, runsActiveColumns), + paginationComponent(pagination), ]), ]); }; diff --git a/lib/public/views/Runs/Overview/RunsWithQcModel.js b/lib/public/views/Runs/Overview/RunsWithQcModel.js index ad09ea4718..e9ec4f36c5 100644 --- a/lib/public/views/Runs/Overview/RunsWithQcModel.js +++ b/lib/public/views/Runs/Overview/RunsWithQcModel.js @@ -43,6 +43,8 @@ const qcFlagsExportConfigurationFactory = (detectors) => Object.fromEntries(dete import { ObservableData } from '../../../utilities/ObservableData.js'; import { DetectorType } from '../../../domain/enums/DetectorTypes.js'; import { mergeRemoteData } from '../../../utilities/mergeRemoteData.js'; +import { ToggleFilterModel } from '../../../components/Filters/common/filters/ToggleFilterModel.js'; +import { MultiCompositionFilterModel } from '../../../components/Filters/RunsFilter/MultiCompositionFilterModel.js'; /** * Merge QC summaries @@ -66,11 +68,17 @@ export class RunsWithQcModel extends RunsOverviewModel { /** * Constructor * @param {Model} model global model + * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(model) { - super(model); + constructor(model, pageIdentifier) { + super(model, pageIdentifier); - this._mcReproducibleAsNotBad = false; + this._detectorsNotBadFractionRegistered = false; + this._detectorsForQcFlagRegistered = false; + + this._observablesQcFlagsSummaryDependsOn$ = null; + // This filter instance will be added as a sub-filter for a MultiCompositionFilter and a GaqFilter later. + this._mcReproducibleAsNotBad = new ToggleFilterModel(false, true); this._runDetectorsSelectionModel = new RunDetectorsSelectionModel(); this._runDetectorsSelectionModel.bubbleTo(this); @@ -83,35 +91,22 @@ export class RunsWithQcModel extends RunsOverviewModel { verticalScrollEnabled: true, freezeFirstColumn: true, }); + + this._filteringModel + .put('detectorsQcNotBadFraction', new MultiCompositionFilterModel({ mcReproducibleAsNotBad: this._mcReproducibleAsNotBad })); } /** * @inheritdoc */ getRootEndpoint() { - const filter = {}; - filter.detectorsQc = { - mcReproducibleAsNotBad: this._mcReproducibleAsNotBad, - }; - - return buildUrl(super.getRootEndpoint(), { filter, include: { effectiveQcFlags: true } }); - } - - /** - * Set mcReproducibleAsNotBad - * - * @param {boolean} mcReproducibleAsNotBad new value - * @return {void} - */ - setMcReproducibleAsNotBad(mcReproducibleAsNotBad) { - this._mcReproducibleAsNotBad = mcReproducibleAsNotBad; - this.load(); + return buildUrl(super.getRootEndpoint(), { include: { effectiveQcFlags: true } }); } /** * Get mcReproducibleAsNotBad * - * @return {boolean} mcReproducibleAsNotBad + * @return {ToggleFilterModel} mcReproducibleAsNotBad */ get mcReproducibleAsNotBad() { return this._mcReproducibleAsNotBad; @@ -131,57 +126,86 @@ export class RunsWithQcModel extends RunsOverviewModel { */ async load() { this._runDetectorsSelectionModel.reset(); - this._fetchQcSummary(); + // Only fetch QC summary manually if no observer is registered + if (!this._observablesQcFlagsSummaryDependsOn$) { + this._fetchQcSummary(); + } super.load(); } /** - * Register not-bad fraction detectors filtering model + * Register not-bad fraction detectors filtering model and update it when detectors are loaded + * Also, trigger an immediate update if detectors are already loaded at the moment of registration * * @param {ObservableData>} detectors$ detectors remote data observable */ registerDetectorsNotBadFractionFilterModels(detectors$) { - detectors$.observe((observableData) => observableData.getCurrent().apply({ - Success: (detectors) => detectors.forEach(({ id }) => { - this._filteringModel.put(`detectorsQc[_${id}][notBadFraction]`, new NumericalComparisonFilterModel({ - scale: 0.01, - integer: false, - })); - }), - })); + const detectorsQcNotBadFraction = this._filteringModel.get('detectorsQcNotBadFraction'); + + const callback = (observableData) => { + const current = observableData.getCurrent(); + current?.apply({ + Success: (detectors) => detectors.forEach(({ id }) => + detectorsQcNotBadFraction.putFilter(`_${id}`, new NumericalComparisonFilterModel({ scale: 0.01, integer: false }))), + }); + + if (current?.isSuccess() && !this._detectorsNotBadFractionRegistered) { + this.filteringModel.setFilterFromURL(); + this._detectorsNotBadFractionRegistered = true; + } + }; + + if (!this._detectorsNotBadFractionRegistered) { + detectors$.observe(callback.bind(this)); + callback(detectors$); + } } /** - * Register detectors for QC flags data export + * Register detectors for QC flags data export and update export configuration when detectors are loaded + * Also, trigger an immediate update if detectors are already loaded at the moment of registration * * @param {ObservableData>} detectors$ detectors remote data observable */ registerDetectorsForQcFlagsDataExport(detectors$) { - detectors$.observe((observableData) => observableData.getCurrent().apply({ - Success: (detectors) => { - this._exportModel.setDataExportConfiguration({ - ...baseDataExportConfiguration, - ...qcFlagsExportConfigurationFactory(detectors), - }); - }, - Other: () => null, - })); + const callback = (observableData) => { + const current = observableData.getCurrent(); + current?.apply({ + Success: (detectors) => { + this._detectorsForQcFlagRegistered = true; + this._exportModel.setDataExportConfiguration({ + ...baseDataExportConfiguration, + ...qcFlagsExportConfigurationFactory(detectors), + }); + }, + Other: () => null, + }); + }; + + if (!this._detectorsForQcFlagRegistered) { + detectors$.observe(callback.bind(this)); + callback(detectors$); + } } /** - * Register obervables data, which QC flags fetching operation success dependes on + * Register observables data, which QC flags fetching operation success depends on * - * @param {ObservableData[]} observables obervable data list + * @param {ObservableData>} detectors$ observable data which QC flags fetching operation success depends on */ - registerObervablesQcSummaryDependesOn(observables) { - this._observablesQcFlagsSummaryDepndsOn$ = ObservableData - .builder() - .sources(observables) - .apply((remoteDataList) => mergeRemoteData(remoteDataList)) - .build(); - - this._observablesQcFlagsSummaryDepndsOn$ - .observe((observableData) => observableData.getCurrent().apply({ Success: () => this._fetchQcSummary() })); + registerObservablesQcSummaryDependsOn(detectors$) { + if (detectors$ === this._observablesQcFlagsSummaryDependsOn$) { + return; + } + + this._observablesQcFlagsSummaryDependsOn$ = detectors$; + const callback = (observableData) => { + const current = observableData.getCurrent(); + current?.apply({ Success: () => this._fetchQcSummary() }); + }; + this._observablesQcFlagsSummaryDependsOn$.observe(callback); + // Also trigger immediately if detectors are already loaded + callback(this._observablesQcFlagsSummaryDependsOn$); } /** @@ -208,8 +232,8 @@ export class RunsWithQcModel extends RunsOverviewModel { async _fetchQcSummary() { const qcSummaryScopeValid = Object.entries(this.qcSummaryScope).filter(([, id]) => id).length == 1; - if (qcSummaryScopeValid && this.detectors && this._observablesQcFlagsSummaryDepndsOn$.getCurrent()) { - mergeRemoteData([this.detectors, this._observablesQcFlagsSummaryDepndsOn$.getCurrent()]).match({ + if (qcSummaryScopeValid && this.detectors && this._observablesQcFlagsSummaryDependsOn$?.getCurrent()) { + mergeRemoteData([this.detectors, this._observablesQcFlagsSummaryDependsOn$.getCurrent()]).match({ Success: async ([detectors]) => { this._qcSummary$.setCurrent(RemoteData.loading()); try { @@ -218,7 +242,7 @@ export class RunsWithQcModel extends RunsOverviewModel { detectorIds: detectors .filter(({ type }) => type === DetectorType.PHYSICAL) .map(({ id }) => id).join(','), - mcReproducibleAsNotBad: this._mcReproducibleAsNotBad, + mcReproducibleAsNotBad: this._mcReproducibleAsNotBad.isToggled, })); const { data: qcSummary2 } = await getRemoteData(buildUrl('/api/qcFlags/summary', { @@ -232,7 +256,7 @@ export class RunsWithQcModel extends RunsOverviewModel { operator: 'none', }, }, - mcReproducibleAsNotBad: this._mcReproducibleAsNotBad, + mcReproducibleAsNotBad: this._mcReproducibleAsNotBad.isToggled, })); this._qcSummary$.setCurrent(RemoteData.success(mergeQcSummaries([qcSummary1, qcSummary2]))); } catch (error) { diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js index 0aca49d627..5bceeeeeb4 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js @@ -14,16 +14,16 @@ import { buildUrl, RemoteData } from '/js/src/index.js'; import { ObservableData } from '../../../utilities/ObservableData.js'; import { getRemoteDataSlice } from '../../../utilities/fetch/getRemoteDataSlice.js'; import { getRemoteData } from '../../../utilities/fetch/getRemoteData.js'; -import { detectorsProvider } from '../../../services/detectors/detectorsProvider.js'; +import { rctDetectorsProvider } from '../../../services/detectors/detectorsProvider.js'; import { FixedPdpBeamTypeRunsOverviewModel } from '../Overview/FixedPdpBeamTypeRunsOverviewModel.js'; import { jsonPatch } from '../../../utilities/fetch/jsonPatch.js'; import { jsonPut } from '../../../utilities/fetch/jsonPut.js'; import { SkimmingStage } from '../../../domain/enums/SkimmingStage.js'; -import { NumericalComparisonFilterModel } from '../../../components/Filters/common/filters/NumericalComparisonFilterModel.js'; import { jsonFetch } from '../../../utilities/fetch/jsonFetch.js'; import { mergeRemoteData } from '../../../utilities/mergeRemoteData.js'; import { RemoteDataSource } from '../../../utilities/fetch/RemoteDataSource.js'; import { DetectorType } from '../../../domain/enums/DetectorTypes.js'; +import { GaqFilterModel } from '../../../components/Filters/RunsFilter/GaqFilterModel.js'; const ALL_CPASS_PRODUCTIONS_REGEX = /cpass\d+/; const DETECTOR_NAMES_NOT_IN_CPASSES = ['EVS']; @@ -35,15 +35,16 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo /** * Constructor * @param {Model} model global model + * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(model) { - super(model); + constructor(model, pageIdentifier) { + super(model, pageIdentifier); this._dataPass$ = new ObservableData(RemoteData.notAsked()); this._dataPass$.bubbleTo(this); this._detectors$ = ObservableData .builder() - .sources([detectorsProvider.qc$, this._dataPass$]) + .sources([rctDetectorsProvider.qc$, this._dataPass$]) .apply((remoteDataList) => mergeRemoteData(remoteDataList) .apply({ Success: ([detectors, dataPass]) => ALL_CPASS_PRODUCTIONS_REGEX.test(dataPass.name) ? detectors.filter(({ name, type }) => type !== DetectorType.AOT_GLO || DETECTOR_NAMES_NOT_IN_CPASSES.includes(name)) @@ -51,10 +52,9 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo })) .build(); + this._filteringModel.put('gaq', new GaqFilterModel(this._mcReproducibleAsNotBad)); + this._detectors$.bubbleTo(this); - this.registerDetectorsNotBadFractionFilterModels(this._detectors$); - this.registerDetectorsForQcFlagsDataExport(this._detectors$); - this.registerObervablesQcSummaryDependesOn([this._detectors$]); this._markAsSkimmableRequestResult$ = new ObservableData(RemoteData.notAsked()); this._markAsSkimmableRequestResult$.bubbleTo(this); @@ -68,11 +68,6 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo this._skimmableRuns$ = new ObservableData(RemoteData.notAsked()); this._skimmableRuns$.bubbleTo(this); - this._filteringModel.put('gaq[notBadFraction]', new NumericalComparisonFilterModel({ - scale: 0.01, - integer: false, - })); - this._freezeOrUnfreezeActionState$ = new ObservableData(RemoteData.notAsked()); this._freezeOrUnfreezeActionState$.bubbleTo(this); @@ -135,6 +130,11 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo }, Other: () => null, })); + + this.registerDetectorsNotBadFractionFilterModels(this._detectors$); + this.registerDetectorsForQcFlagsDataExport(this._detectors$); + this.registerObservablesQcSummaryDependsOn(this._detectors$); + super.load(); } @@ -142,22 +142,15 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo * @inheritdoc */ getRootEndpoint() { - const gaqNotBadFilter = this._filteringModel.get('gaq[notBadFraction]'); - const filter = { dataPassIds: [this._dataPassId] }; - if (!gaqNotBadFilter.isEmpty) { - filter.gaq = { - mcReproducibleAsNotBad: this._mcReproducibleAsNotBad, - }; - } - + const filter = { ...this._filteringModel.normalized, dataPassIds: [this._dataPassId] }; return buildUrl(super.getRootEndpoint(), { filter }); } /** * @inheritdoc */ - resetFiltering(fetch = true) { - super.resetFiltering(fetch); + resetFiltering(fetch = true, clearUrl = false) { + super.resetFiltering(fetch, clearUrl); } /** @@ -279,7 +272,7 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo * @param {number} dataPassId id of Data Pass */ set dataPassId(dataPassId) { - if (dataPassId !== this._dataPassId) { + if (this._dataPassId && dataPassId !== this._dataPassId) { this.reset(false); } this._dataPassId = dataPassId; @@ -363,7 +356,7 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo }); const url = buildUrl('/api/qcFlags/summary/gaq', { dataPassId: this._dataPassId, - mcReproducibleAsNotBad: this._mcReproducibleAsNotBad, + mcReproducibleAsNotBad: this._mcReproducibleAsNotBad.isToggled, runNumber: runNumber, }); await this._gaqSummarySources[runNumber].fetch(url); diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js index 8f63fb608b..fd847389f5 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js @@ -21,7 +21,6 @@ import { tooltip } from '../../../components/common/popover/tooltip.js'; import { breadcrumbs } from '../../../components/common/navigation/breadcrumbs.js'; import { createRunDetectorsAsyncQcActiveColumns } from '../ActiveColumns/runDetectorsAsyncQcActiveColumns.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; -import { runNumbersFilter } from '../../../components/Filters/RunsFilter/runNumbersFilter.js'; import { qcSummaryLegendTooltip } from '../../../components/qcFlags/qcSummaryLegendTooltip.js'; import { isRunNotSubjectToQc } from '../../../components/qcFlags/isRunNotSubjectToQc.js'; import { frontLink } from '../../../components/common/navigation/frontLink.js'; @@ -38,7 +37,9 @@ import { iconCaretBottom } from '/js/src/icons.js'; import { BkpRoles } from '../../../domain/enums/BkpRoles.js'; import { getInelasticInteractionRateColumns } from '../ActiveColumns/getInelasticInteractionRateActiveColumns.js'; import { exportTriggerAndModal } from '../../../components/common/dataExport/exportTriggerAndModal.js'; -import { mcReproducibleAsNotBadToggle } from '../mcReproducibleAsNotBadToggle.js'; +import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; +import { toggleFilter } from '../../../components/Filters/common/filters/toggleFilter.js'; +import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 59; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -117,177 +118,194 @@ export const RunsPerDataPassOverviewPage = ({ const commonTitle = h('h2#breadcrumb-header', { style: 'white-space: nowrap;' }, 'Physics Runs'); const runDetectorsSelectionIsEmpty = perDataPassOverviewModel.runDetectorsSelectionModel.selectedQueryString.length === 0; + const dataPass = remoteDataPass.match({ Other: () => null, Success: (data) => data }); + const detectors = remoteDetectors.match({ Other: () => null, Success: (data) => data }); + const qcSummary = remoteQcSummary.match({ Other: () => null, Success: (data) => data }); - return h( - '.intermediate-flex-column', - { onremove: () => { - perDataPassOverviewModel._abortGaqFetches(); - } }, - mergeRemoteData([remoteDataPass, remoteRuns, remoteDetectors, remoteQcSummary]).match({ - NotAsked: () => null, - Failure: (errors) => errorAlert(errors), - Success: ([dataPass, runs, detectors, qcSummary]) => { - const activeColumns = { - ...runsActiveColumns, - ...getInelasticInteractionRateColumns(pdpBeamTypes), - ...dataPass.skimmingStage === SkimmingStage.SKIMMABLE - ? { - readyForSkimming: { - name: 'Ready for skimming', - visible: true, - format: (_, { runNumber }) => remoteSkimmableRuns.match({ - Success: (skimmableRuns) => switchInput( - skimmableRuns[runNumber], - () => perDataPassOverviewModel.changeReadyForSkimmingFlagForRun({ - runNumber, - readyForSkimming: !skimmableRuns[runNumber], - }), - { - labelAfter: skimmableRuns[runNumber] - ? badge('ready', { color: Color.GREEN }) - : badge('not ready', { color: Color.WARNING_DARKER }), - }, + /* + * The table drawing can be done without using mergeRemoteData, but that will redraw it + * each independent update to dataPass, detectors, or qcSummary. + * While this wouldn't necessarily be noticeable for users, it would detach nodes from + * the document, which would make writing integration test difficult and unreliable. + */ + const fullPageData = mergeRemoteData([remoteRuns, remoteDataPass, remoteDetectors, remoteQcSummary]); + + const activeColumns = { + ...runsActiveColumns, + ...getInelasticInteractionRateColumns(pdpBeamTypes), + + globalAggregatedQuality: { + name: 'GAQ', + information: h( + '', + h('', 'Global aggregated flag based on critical detectors.'), + h('', 'Default detectors: FT0, ITS, TPC (and ZDC for heavy-ion runs)'), + ), + visible: true, + + format: (_, { runNumber }) => { + const runGaqSummary = remoteGaqSummary[runNumber]; + const spinnerEl = h('.flex-row.items-center.justify-center.black', spinner({ size: 2, absolute: false })); + + return runGaqSummary.match({ + Success: (gaqSummary) => { + const gaqDisplay = + gaqSummary?.undefinedQualityPeriodsCount === 0 + ? getQcSummaryDisplay(gaqSummary) + : h('button.btn.btn-primary.w-100', 'GAQ'); + + return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, runNumber }); + }, + Loading: () => tooltip(spinnerEl), + NotAsked: () => tooltip(spinnerEl), + Failure: () => + frontLink( + h('button.btn.btn-primary.w-100', [ + 'GAQ', + h( + '.d-inline-block.va-t-bottom', + tooltip( + h('.f7', iconWarning()), + 'GAQ Summary failed, please click to view GAQ flags', ), - Loading: () => h('.mh3.ph4', '... ...'), - Failure: () => tooltip(iconWarning(), 'Error occurred'), - NotAsked: () => tooltip(iconWarning(), 'Not asked for data'), - }), - profiles: ['runsPerDataPass'], - }, - } - : {}, - globalAggregatedQuality: { - name: 'GAQ', - information: h( - '', - h('', 'Global aggregated flag based on critical detectors.'), - h('', 'Default detectors: FT0, ITS, TPC (and ZDC for heavy-ion runs)'), + ), + ]), + 'gaq-flags', + { dataPassId, runNumber }, ), - visible: true, - format: (_, { runNumber }) => { - const gaqLoadingSpinner = h('.flex-row.items-center.justify-center.black', spinner({ size: 2, absolute: false })); - const runGaqSummary = remoteGaqSummary[runNumber]; - - return runGaqSummary.match({ - Success: (gaqSummary) => { - const gaqDisplay = gaqSummary?.undefinedQualityPeriodsCount === 0 - ? getQcSummaryDisplay(gaqSummary) - : h('button.btn.btn-primary.w-100', 'GAQ'); + }); + }, + filter: ({ filteringModel }) => + numericalComparisonFilter(filteringModel.get('gaq').notBadFraction, { step: 0.1, selectorPrefix: 'gaqNotBadFraction' }), + filterTooltip: 'not-bad fraction expressed as a percentage', + profiles: ['runsPerDataPass'], + }, + ...dataPass?.skimmingStage === SkimmingStage.SKIMMABLE && { + readyForSkimming: { + name: 'Ready for skimming', + visible: true, - return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, runNumber }); + format: (_, { runNumber }) => + remoteSkimmableRuns.match({ + Success: (skimmableRuns) => + switchInput( + skimmableRuns[runNumber], + () => + perDataPassOverviewModel.changeReadyForSkimmingFlagForRun({ + runNumber, + readyForSkimming: !skimmableRuns[runNumber], + }), + { + labelAfter: skimmableRuns[runNumber] + ? badge('ready', { color: Color.GREEN }) + : badge('not ready', { color: Color.WARNING_DARKER }), }, - Loading: () => tooltip(gaqLoadingSpinner, 'Loading GAQ summary...'), - NotAsked: () => tooltip(gaqLoadingSpinner, 'Loading GAQ summary...'), - Failure: () => { - const gaqDisplay = h('button.btn.btn-primary.w-100', [ - 'GAQ', - h( - '.d-inline-block.va-t-bottom', - tooltip(h('.f7', iconWarning()), 'GAQ Summary failed, please click to view GAQ flags'), - ), - ]); - return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, runNumber }); - }, - }); + ), + + Loading: () => h('.mh3.ph4', '... ...'), + Failure: () => tooltip(iconWarning(), 'Error occurred'), + NotAsked: () => tooltip(iconWarning(), 'Not asked for data'), + }), + + profiles: ['runsPerDataPass'], + }, + }, + ...detectors && dataPass && createRunDetectorsAsyncQcActiveColumns( + perDataPassOverviewModel.runDetectorsSelectionModel, + detectors, + remoteDplDetectorsUserHasAccessTo, + { dataPass }, + { + profile: 'runsPerDataPass', + qcSummary, + mcReproducibleAsNotBad, + }, + ), + }; + + return [ + h('.flex-row.justify-between.items-center.g2', [ + filtersPanelPopover(perDataPassOverviewModel, activeColumns, { profile: 'runsPerDataPass' }), + h('.pl2#runOverviewFilter', textInputFilter(perDataPassOverviewModel.filteringModel, 'runNumbers', 'e.g. 534454, 534455...')), + h( + '.flex-row.g1.items-center', + h('.flex-row.items-center.g1', [ + breadcrumbs([commonTitle, h('h2#breadcrumb-data-pass-name', dataPass?.name ?? spinner({ size: 1, absolute: false }))]), + h('#skimmableControl', dataPass && skimmableControl( + dataPass, + () => { + if (confirm('The data pass is going to be set as skimmable. Do you want to continue?')) { + perDataPassOverviewModel.markDataPassAsSkimmable(); + } }, - filter: ({ filteringModel }) => numericalComparisonFilter( - filteringModel.get('gaq[notBadFraction]'), - { step: 0.1, selectorPrefix: 'gaqNotBadFraction' }, - ), - filterTooltip: 'not-bad fraction expressed as a percentage', - profiles: ['runsPerDataPass'], - }, - ...createRunDetectorsAsyncQcActiveColumns( - perDataPassOverviewModel.runDetectorsSelectionModel, - detectors, - remoteDplDetectorsUserHasAccessTo, - { dataPass }, + markAsSkimmableRequestResult, + )), + ]), + ), + toggleFilter(mcReproducibleAsNotBad, h('em', 'MC.R as not-bad'), 'mcReproducibleAsNotBadToggle'), + h('.mlauto', qcSummaryLegendTooltip()), + h('#actions-dropdown-button', DropdownComponent( + h('button.btn.btn-primary', h('.flex-row.g2', ['Actions', iconCaretBottom()])), + h('.flex-column.p2.g2', [ + exportTriggerAndModal(perDataPassOverviewModel.exportModel, modalModel, { autoMarginLeft: false }), + frontLink( + h('button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', { + disabled: runDetectorsSelectionIsEmpty, + }, 'Set QC Flags'), + 'qc-flag-creation-for-data-pass', { - profile: 'runsPerDataPass', - qcSummary, - mcReproducibleAsNotBad, + runNumberDetectorsMap: perDataPassOverviewModel.runDetectorsSelectionModel.selectedQueryString, + dataPassId, }, ), - }; - - return [ - h('.flex-row.justify-between.items-center.g2', [ - filtersPanelPopover(perDataPassOverviewModel, activeColumns, { profile: 'runsPerDataPass' }), - h('.pl2#runOverviewFilter', runNumbersFilter(perDataPassOverviewModel.filteringModel.get('runNumbers'))), + sessionService.hasAccess([BkpRoles.DPG_ASYNC_QC_ADMIN]) && [ h( - '.flex-row.g1.items-center', - h('.flex-row.items-center.g1', [ - breadcrumbs([commonTitle, h('h2#breadcrumb-data-pass-name', dataPass.name)]), - h('#skimmableControl', skimmableControl( - dataPass, - () => { - if (confirm('The data pass is going to be set as skimmable. Do you want to continue?')) { - perDataPassOverviewModel.markDataPassAsSkimmable(); - } - }, - markAsSkimmableRequestResult, - )), - ]), + 'button.btn.btn-danger', + { + ...freezeOrUnfreezeActionState.match({ + Loading: () => ({ + disabled: true, + title: 'Loading', + }), + Other: () => ({}), + }), + onclick: () => dataPass?.isFrozen + ? perDataPassOverviewModel.unfreezeDataPass() + : perDataPassOverviewModel.freezeDataPass(), + }, + `${dataPass?.isFrozen ? 'Unfreeze' : 'Freeze'} the data pass`, ), - mcReproducibleAsNotBadToggle( - mcReproducibleAsNotBad, - () => perDataPassOverviewModel.setMcReproducibleAsNotBad(!mcReproducibleAsNotBad), + h( + 'button.btn.btn-danger', + { + ...discardAllQcFlagsActionState.match({ + Loading: () => ({ + disabled: true, + title: 'Loading', + }), + Other: () => ({}), + }), + onclick: () => { + if (confirm('Are you sure you want to delete ALL the QC flags for this data pass?')) { + perDataPassOverviewModel.discardAllQcFlags(); + } + }, + }, + 'Delete ALL QC flags', ), - h('.mlauto', qcSummaryLegendTooltip()), - h('#actions-dropdown-button', DropdownComponent( - h('button.btn.btn-primary', h('.flex-row.g2', ['Actions', iconCaretBottom()])), - h('.flex-column.p2.g2', [ - exportTriggerAndModal(perDataPassOverviewModel.exportModel, modalModel, { autoMarginLeft: false }), - frontLink( - h('button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', { - disabled: runDetectorsSelectionIsEmpty, - }, 'Set QC Flags'), - 'qc-flag-creation-for-data-pass', - { - runNumberDetectorsMap: perDataPassOverviewModel.runDetectorsSelectionModel.selectedQueryString, - dataPassId, - }, - ), - sessionService.hasAccess([BkpRoles.DPG_ASYNC_QC_ADMIN]) && [ - h( - 'button.btn.btn-danger', - { - ...freezeOrUnfreezeActionState.match({ - Loading: () => ({ - disabled: true, - title: 'Loading', - }), - Other: () => ({}), - }), - onclick: () => dataPass.isFrozen - ? perDataPassOverviewModel.unfreezeDataPass() - : perDataPassOverviewModel.freezeDataPass(), - }, - `${dataPass.isFrozen ? 'Unfreeze' : 'Freeze'} the data pass`, - ), - h( - 'button.btn.btn-danger', - { - ...discardAllQcFlagsActionState.match({ - Loading: () => ({ - disabled: true, - title: 'Loading', - }), - Other: () => ({}), - }), - onclick: () => { - if (confirm('Are you sure you want to delete ALL the QC flags for this data pass?')) { - perDataPassOverviewModel.discardAllQcFlags(); - } - }, - }, - 'Delete ALL QC flags', - ), - ], - ]), - { alignment: 'right' }, - )), - ]), + ], + ]), + { alignment: 'right' }, + )), + ]), + warningComponent(perDataPassOverviewModel), + h( + '.intermediate-flex-column', + { onremove: () => perDataPassOverviewModel._abortGaqFetches() }, + fullPageData.match({ + NotAsked: () => null, + Failure: (errors) => errorAlert(errors), + Success: ([runs]) => [ markAsSkimmableRequestResult.match({ Failure: (errors) => errorAlert(errors), Other: () => null, @@ -311,9 +329,9 @@ export const RunsPerDataPassOverviewPage = ({ { sort: sortModel }, ), paginationComponent(perDataPassOverviewModel.pagination), - ]; - }, - Loading: () => spinner(), - }), - ); + ], + Loading: () => spinner(), + }), + ), + ]; }; diff --git a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewModel.js b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewModel.js index b361522b8b..2ae78a395c 100644 --- a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewModel.js +++ b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewModel.js @@ -12,7 +12,7 @@ */ import { buildUrl, RemoteData } from '/js/src/index.js'; import { TabbedPanelModel } from '../../../components/TabbedPanel/TabbedPanelModel.js'; -import { detectorsProvider } from '../../../services/detectors/detectorsProvider.js'; +import { rctDetectorsProvider } from '../../../services/detectors/detectorsProvider.js'; import { jsonFetch } from '../../../utilities/fetch/jsonFetch.js'; import { DetectorType } from '../../../domain/enums/DetectorTypes.js'; import { ObservableData } from '../../../utilities/ObservableData.js'; @@ -31,27 +31,25 @@ export class RunsPerLhcPeriodOverviewModel extends FixedPdpBeamTypeRunsOverviewM * Constructor * * @param {Model} model global model + * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(model) { - super(model); + constructor(model, pageIdentifier) { + super(model, pageIdentifier); this._lhcPeriodId = null; this._lhcPeriodStatistics$ = new ObservableData(RemoteData.notAsked()); - this._onlineDetectors$ = detectorsProvider.physical$; + this._onlineDetectors$ = rctDetectorsProvider.physical$; this._syncDetectors$ = ObservableData .builder() - .source(detectorsProvider.qc$) + .source(rctDetectorsProvider.qc$) .apply((remoteDetectors) => remoteDetectors.apply({ Success: (detectors) => detectors.filter(({ type }) => [DetectorType.PHYSICAL, DetectorType.MUON_GLO].includes(type)), })) .build(); - this.registerDetectorsForQcFlagsDataExport(this._syncDetectors$); - this.registerObervablesQcSummaryDependesOn([this._syncDetectors$]); - this._syncDetectors$.bubbleTo(this); this._onlineDetectors$.bubbleTo(this); this._lhcPeriodStatistics$.bubbleTo(this); @@ -82,12 +80,15 @@ export class RunsPerLhcPeriodOverviewModel extends FixedPdpBeamTypeRunsOverviewM return; } - await this._fetchLhcPeriod().then(() => { - this._lhcPeriodStatistics$.getCurrent().match({ - Success: ({ pdpBeamTypes }) => this.setPdpBeamTypes(pdpBeamTypes), - Other: () => null, - }); + await this._fetchLhcPeriod(); + this._lhcPeriodStatistics$.getCurrent().match({ + Success: ({ pdpBeamTypes }) => this.setPdpBeamTypes(pdpBeamTypes), + Other: () => null, }); + + this.registerDetectorsForQcFlagsDataExport(this._syncDetectors$); + this.registerObservablesQcSummaryDependsOn(this._syncDetectors$); + super.load(); } @@ -95,13 +96,8 @@ export class RunsPerLhcPeriodOverviewModel extends FixedPdpBeamTypeRunsOverviewM * @inheritdoc */ getRootEndpoint() { - return buildUrl(super.getRootEndpoint(), { - filter: { - lhcPeriodIds: [this._lhcPeriodId], - runQualities: 'good', - definitions: 'PHYSICS', - }, - }); + const filter = { lhcPeriodIds: [this._lhcPeriodId], runQualities: 'good', definitions: 'PHYSICS' }; + return buildUrl(super.getRootEndpoint(), { filter }); } /** @@ -151,7 +147,7 @@ export class RunsPerLhcPeriodOverviewModel extends FixedPdpBeamTypeRunsOverviewM * @param {string} lhcPeriodId id of a LHC period */ set lhcPeriodId(lhcPeriodId) { - if (lhcPeriodId !== this._lhcPeriodId) { + if (this._lhcPeriodId && lhcPeriodId !== this._lhcPeriodId) { this.reset(false); } this._lhcPeriodId = lhcPeriodId; diff --git a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js index 7526324b35..4a08a95565 100644 --- a/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js +++ b/lib/public/views/Runs/RunPerPeriod/RunsPerLhcPeriodOverviewPage.js @@ -26,9 +26,10 @@ import errorAlert from '../../../components/common/errorAlert.js'; import spinner from '../../../components/common/spinner.js'; import { getInelasticInteractionRateColumns } from '../ActiveColumns/getInelasticInteractionRateActiveColumns.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; -import { runNumbersFilter } from '../../../components/Filters/RunsFilter/runNumbersFilter.js'; -import { mcReproducibleAsNotBadToggle } from '../mcReproducibleAsNotBadToggle.js'; import { exportTriggerAndModal } from '../../../components/common/dataExport/exportTriggerAndModal.js'; +import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; +import { toggleFilter } from '../../../components/Filters/common/filters/toggleFilter.js'; +import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 62; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -50,11 +51,6 @@ const getRowClasses = (run) => isRunNotSubjectToQc(run) ? '.danger' : null; * @return {Component} The overview page */ export const RunsPerLhcPeriodOverviewPage = ({ runs: { perLhcPeriodOverviewModel }, modalModel }) => { - perLhcPeriodOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( - TABLEROW_HEIGHT, - PAGE_USED_HEIGHT, - )); - const { items: remoteRuns, lhcPeriodStatistics: remoteLhcPeriodStatistics, @@ -66,8 +62,11 @@ export const RunsPerLhcPeriodOverviewPage = ({ runs: { perLhcPeriodOverviewModel mcReproducibleAsNotBad, qcSummary: remoteQcSummary, pdpBeamTypes, + pagination, } = perLhcPeriodOverviewModel; + pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); + /** * Render runs table with given detectors' active columns configuration * @@ -95,30 +94,32 @@ export const RunsPerLhcPeriodOverviewPage = ({ runs: { perLhcPeriodOverviewModel { sort: sortModel }, ); - return h( - '.intermediate-flex-column', - mergeRemoteData([remoteLhcPeriodStatistics, remoteRuns]).match({ - NotAsked: () => null, - Failure: (errors) => errorAlert(errors), - Loading: () => spinner(), - Success: ([lhcPeriodStatistics]) => { - const activeColumns = { - ...runsActiveColumns, - ...getInelasticInteractionRateColumns(pdpBeamTypes), + const activeColumns = { + ...runsActiveColumns, + ...getInelasticInteractionRateColumns(pdpBeamTypes), + }; - }; + const lhcPeriodName = remoteLhcPeriodStatistics?.match({ + Success: (lhcPeriodStatistics) => lhcPeriodStatistics.lhcPeriod.name, + Other: () => spinner({ size: 1, absolute: false }), + }); - return [ - h('.flex-row.justify-between.items-center.g2', [ - filtersPanelPopover(perLhcPeriodOverviewModel, activeColumns, { profile: 'runsPerLhcPeriod' }), - h('.pl2#runOverviewFilter', runNumbersFilter(perLhcPeriodOverviewModel.filteringModel.get('runNumbers'))), - h('h2', `Good, physics runs of ${lhcPeriodStatistics.lhcPeriod.name}`), - mcReproducibleAsNotBadToggle( - mcReproducibleAsNotBad, - () => perLhcPeriodOverviewModel.setMcReproducibleAsNotBad(!mcReproducibleAsNotBad), - ), - exportTriggerAndModal(perLhcPeriodOverviewModel.exportModel, modalModel), - ]), + return [ + h('.flex-row.justify-between.items-center.g2', [ + filtersPanelPopover(perLhcPeriodOverviewModel, activeColumns, { profile: 'runsPerLhcPeriod' }), + h('.pl2#runOverviewFilter', textInputFilter(perLhcPeriodOverviewModel.filteringModel, 'runNumbers', 'e.g. 534454, 534455...')), + h('h2', ['Good, physics runs of ', lhcPeriodName]), + warningComponent(perLhcPeriodOverviewModel), + toggleFilter(mcReproducibleAsNotBad, h('em', 'MC.R as not-bad'), 'mcReproducibleAsNotBadToggle'), + exportTriggerAndModal(perLhcPeriodOverviewModel.exportModel, modalModel), + ]), + h( + '.intermediate-flex-column', + remoteRuns.match({ + NotAsked: () => null, + Failure: (errors) => errorAlert(errors), + Loading: () => spinner(), + Success: () => [ ...tabbedPanelComponent( tabbedPanelModel, { @@ -152,10 +153,8 @@ export const RunsPerLhcPeriodOverviewPage = ({ runs: { perLhcPeriodOverviewModel }, { panelClass: ['scroll-auto'] }, ), - paginationComponent(perLhcPeriodOverviewModel.pagination), - ]; - }, - }), - - ); + paginationComponent(pagination), + ] }), + ), + ]; }; diff --git a/lib/public/views/Runs/RunsModel.js b/lib/public/views/Runs/RunsModel.js index ba30c3519a..007a456368 100644 --- a/lib/public/views/Runs/RunsModel.js +++ b/lib/public/views/Runs/RunsModel.js @@ -32,13 +32,13 @@ export class RunsModel extends Observable { super(); this._detailsModel = new RunDetailsModel(); this._detailsModel.bubbleTo(this); - this._overviewModel = new RunsOverviewModel(model); + this._overviewModel = new RunsOverviewModel(model, 'run-overview'); this._overviewModel.bubbleTo(this); - this._perLhcPeriodOverviewModel = new RunsPerLhcPeriodOverviewModel(model); + this._perLhcPeriodOverviewModel = new RunsPerLhcPeriodOverviewModel(model, 'runs-per-lhc-period'); this._perLhcPeriodOverviewModel.bubbleTo(this); - this._perDataPassOverviewModel = new RunsPerDataPassOverviewModel(model); + this._perDataPassOverviewModel = new RunsPerDataPassOverviewModel(model, 'runs-per-data-pass'); this._perDataPassOverviewModel.bubbleTo(this); - this._perSimulationPassOverviewModel = new RunsPerSimulationPassOverviewModel(model); + this._perSimulationPassOverviewModel = new RunsPerSimulationPassOverviewModel(model, 'runs-per-simulation-pass'); this._perSimulationPassOverviewModel.bubbleTo(this); } @@ -48,6 +48,7 @@ export class RunsModel extends Observable { */ loadOverview() { if (! this._overviewModel.pagination.isInfiniteScrollEnabled) { + this._overviewModel.setFilterFromURL(false); this._overviewModel.load(); } } @@ -93,6 +94,7 @@ export class RunsModel extends Observable { this._perLhcPeriodOverviewModel.tabbedPanelModel.currentPanelKey = panel; if (!this._perLhcPeriodOverviewModel.pagination.isInfiniteScrollEnabled) { this._perLhcPeriodOverviewModel.lhcPeriodId = lhcPeriodId; + this._perLhcPeriodOverviewModel.setFilterFromURL(false); this._perLhcPeriodOverviewModel.load(); } } @@ -114,7 +116,15 @@ export class RunsModel extends Observable { loadPerDataPassOverview({ dataPassId }) { if (!this._perDataPassOverviewModel.pagination.isInfiniteScrollEnabled) { this._perDataPassOverviewModel.dataPassId = parseInt(dataPassId, 10); - this._perDataPassOverviewModel.load(); + if (this._perDataPassOverviewModel.pagination._defaultItemsPerPage) { + /** + * If the default items per page is set, it means model has loaded already once, + * so the pagination trigger will not refresh the data. + * Thus, we need to trigger the load here. + */ + this._perDataPassOverviewModel.setFilterFromURL(false); + this._perDataPassOverviewModel.load(); + } } } @@ -135,7 +145,15 @@ export class RunsModel extends Observable { loadPerSimulationPassOverview({ simulationPassId }) { if (!this._perSimulationPassOverviewModel.pagination.isInfiniteScrollEnabled) { this._perSimulationPassOverviewModel.simulationPassId = parseInt(simulationPassId, 10); - this._perSimulationPassOverviewModel.load(); + if (this._perSimulationPassOverviewModel.pagination._defaultItemsPerPage) { + /** + * If the default items per page is set, it means model has loaded already once, + * so the pagination trigger will not refresh the data. + * Thus, we need to trigger the load here. + */ + this._perSimulationPassOverviewModel.setFilterFromURL(false); + this._perSimulationPassOverviewModel.load(); + } } } diff --git a/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewModel.js b/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewModel.js index 9b8b982d4b..084b57d130 100644 --- a/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewModel.js +++ b/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewModel.js @@ -13,7 +13,7 @@ import { buildUrl, RemoteData } from '/js/src/index.js'; import { ObservableData } from '../../../utilities/ObservableData.js'; import { getRemoteData } from '../../../utilities/fetch/getRemoteData.js'; -import { detectorsProvider } from '../../../services/detectors/detectorsProvider.js'; +import { rctDetectorsProvider } from '../../../services/detectors/detectorsProvider.js'; import { FixedPdpBeamTypeRunsOverviewModel } from '../Overview/FixedPdpBeamTypeRunsOverviewModel.js'; /** @@ -23,17 +23,14 @@ export class RunsPerSimulationPassOverviewModel extends FixedPdpBeamTypeRunsOver /** * Constructor * @param {Model} model global model + * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor(model) { - super(model); + constructor(model, pageIdentifier) { + super(model, pageIdentifier); this._simulationPass$ = new ObservableData(RemoteData.notAsked()); - this._detectors$ = detectorsProvider.qc$; - - this.registerObervablesQcSummaryDependesOn([this._detectors$]); - this.registerDetectorsNotBadFractionFilterModels(this._detectors$); - this.registerDetectorsForQcFlagsDataExport(this._detectors$); + this._detectors$ = rctDetectorsProvider.qc$; this._detectors$.bubbleTo(this); this._simulationPass$.bubbleTo(this); @@ -61,12 +58,16 @@ export class RunsPerSimulationPassOverviewModel extends FixedPdpBeamTypeRunsOver return; } - this._fetchSimulationPass().then(() => { - this._simulationPass$.getCurrent().match({ - Success: ({ pdpBeamTypes }) => this.setPdpBeamTypes(pdpBeamTypes), - Other: () => null, - }); + await this._fetchSimulationPass(); + this._simulationPass$.getCurrent().match({ + Success: ({ pdpBeamTypes }) => this.setPdpBeamTypes(pdpBeamTypes), + Other: () => null, }); + + this.registerDetectorsNotBadFractionFilterModels(this._detectors$); + this.registerDetectorsForQcFlagsDataExport(this._detectors$); + this.registerObservablesQcSummaryDependsOn(this._detectors$); + super.load(); } @@ -74,13 +75,8 @@ export class RunsPerSimulationPassOverviewModel extends FixedPdpBeamTypeRunsOver * @inheritdoc */ getRootEndpoint() { - const params = { - filter: { - simulationPassIds: [this._simulationPassId], - }, - }; - - return buildUrl(super.getRootEndpoint(), params); + const filter = { simulationPassIds: [this._simulationPassId] }; + return buildUrl(super.getRootEndpoint(), { filter }); } /** @@ -88,7 +84,7 @@ export class RunsPerSimulationPassOverviewModel extends FixedPdpBeamTypeRunsOver * @param {number} simulationPassId simulation pass id */ set simulationPassId(simulationPassId) { - if (simulationPassId !== this._simulationPassId) { + if (this._simulationPassId && simulationPassId !== this._simulationPassId) { this.reset(false); } this._simulationPassId = simulationPassId; diff --git a/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js b/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js index 55d4cdb988..c64fcbe6c8 100644 --- a/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js +++ b/lib/public/views/Runs/RunsPerSimulationPass/RunsPerSimulationPassOverviewPage.js @@ -27,8 +27,9 @@ import errorAlert from '../../../components/common/errorAlert.js'; import { getInelasticInteractionRateColumns } from '../ActiveColumns/getInelasticInteractionRateActiveColumns.js'; import { exportTriggerAndModal } from '../../../components/common/dataExport/exportTriggerAndModal.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; -import { runNumbersFilter } from '../../../components/Filters/RunsFilter/runNumbersFilter.js'; -import { mcReproducibleAsNotBadToggle } from '../mcReproducibleAsNotBadToggle.js'; +import { toggleFilter } from '../../../components/Filters/common/filters/toggleFilter.js'; +import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js'; +import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 59; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -52,11 +53,6 @@ export const RunsPerSimulationPassOverviewPage = ({ dplDetectorsUserHasAccessTo: remoteDplDetectorsUserHasAccessTo, modalModel, }) => { - perSimulationPassOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( - TABLEROW_HEIGHT, - PAGE_USED_HEIGHT, - )); - const { items: remoteRuns, detectors: remoteDetectors, @@ -67,60 +63,69 @@ export const RunsPerSimulationPassOverviewPage = ({ sortModel, pdpBeamTypes, mcReproducibleAsNotBad, + pagination, } = perSimulationPassOverviewModel; + pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); + const commonTitle = h('h2', 'Runs per MC'); - return h( - '.intermediate-flex-column', - mergeRemoteData([remoteSimulationPass, remoteRuns, remoteDetectors, remoteQcSummary]).match({ - NotAsked: () => null, - Failure: (errors) => errorAlert(errors), - Success: ([simulationPass, runs, detectors, qcSummary]) => { - const activeColumns = { - ...runsActiveColumns, - ...getInelasticInteractionRateColumns(pdpBeamTypes), - ...createRunDetectorsAsyncQcActiveColumns( - perSimulationPassOverviewModel.runDetectorsSelectionModel, - detectors, - remoteDplDetectorsUserHasAccessTo, - { simulationPass }, - { - profile: 'runsPerSimulationPass', - qcSummary, - }, - ), - }; + const fullPageData = mergeRemoteData([remoteRuns, remoteSimulationPass, remoteDetectors, remoteQcSummary]); + const simulationPass = remoteSimulationPass.match({ Other: () => null, Success: (data) => data }); + const detectors = remoteDetectors.match({ Other: () => null, Success: (data) => data }); + const qcSummary = remoteQcSummary.match({ Other: () => null, Success: (data) => data }); + + const activeColumns = { + ...runsActiveColumns, + ...getInelasticInteractionRateColumns(pdpBeamTypes), + ...detectors && qcSummary && createRunDetectorsAsyncQcActiveColumns( + perSimulationPassOverviewModel.runDetectorsSelectionModel, + detectors, + remoteDplDetectorsUserHasAccessTo, + { simulationPass }, + { + profile: 'runsPerSimulationPass', + qcSummary, + }, + ), + }; - return [ - h('.flex-row.justify-between.items-center.g2', [ - filtersPanelPopover(perSimulationPassOverviewModel, activeColumns, { profile: 'runsPerSimulationPass' }), - h('.pl2#runOverviewFilter', runNumbersFilter(perSimulationPassOverviewModel.filteringModel.get('runNumbers'))), - h( - '.flex-row.g1.items-center', - breadcrumbs([commonTitle, h('h2#breadcrumb-simulation-pass-name', simulationPass.name)]), - ), - mcReproducibleAsNotBadToggle( - mcReproducibleAsNotBad, - () => perSimulationPassOverviewModel.setMcReproducibleAsNotBad(!mcReproducibleAsNotBad), - ), - h('.mlauto', qcSummaryLegendTooltip()), - exportTriggerAndModal(perSimulationPassOverviewModel.exportModel, modalModel, { autoMarginLeft: false }), - frontLink( - h( - 'button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', - { - disabled: perSimulationPassOverviewModel.runDetectorsSelectionModel.selectedQueryString.length < 1, - }, - 'Set QC Flags', - ), - 'qc-flag-creation-for-simulation-pass', - { - runNumberDetectorsMap: perSimulationPassOverviewModel.runDetectorsSelectionModel.selectedQueryString, - simulationPassId, - }, - ), - ]), + return [ + h('.flex-row.justify-between.items-center.g2', [ + filtersPanelPopover(perSimulationPassOverviewModel, activeColumns, { profile: 'runsPerSimulationPass' }), + h('.pl2#runOverviewFilter', textInputFilter(perSimulationPassOverviewModel.filteringModel, 'runNumbers', 'e.g. 534454, 534455...')), + h( + '.flex-row.g1.items-center', + breadcrumbs([ + commonTitle, + h('h2#breadcrumb-simulation-pass-name', simulationPass?.name ?? spinner({ size: 1, absolute: false })), + ]), + ), + toggleFilter(mcReproducibleAsNotBad, h('em', 'MC.R as not-bad'), 'mcReproducibleAsNotBadToggle'), + h('.mlauto', qcSummaryLegendTooltip()), + exportTriggerAndModal(perSimulationPassOverviewModel.exportModel, modalModel, { autoMarginLeft: false }), + frontLink( + h( + 'button.btn.btn-primary.w-100.h2}#set-qc-flags-trigger', + { + disabled: perSimulationPassOverviewModel.runDetectorsSelectionModel.selectedQueryString.length < 1, + }, + 'Set QC Flags', + ), + 'qc-flag-creation-for-simulation-pass', + { + runNumberDetectorsMap: perSimulationPassOverviewModel.runDetectorsSelectionModel.selectedQueryString, + simulationPassId, + }, + ), + ]), + warningComponent(perSimulationPassOverviewModel), + h( + '.intermediate-flex-column', + fullPageData.match({ + NotAsked: () => null, + Failure: (errors) => errorAlert(errors), + Success: ([runs]) => [ table( runs, activeColumns, @@ -131,10 +136,10 @@ export const RunsPerSimulationPassOverviewPage = ({ }, { sort: sortModel }, ), - paginationComponent(perSimulationPassOverviewModel.pagination), - ]; - }, - Loading: () => spinner(), - }), - ); + paginationComponent(pagination), + ], + Loading: () => spinner(), + }), + ), + ]; }; diff --git a/lib/public/views/Runs/format/editRunEorReasons.js b/lib/public/views/Runs/format/editRunEorReasons.js index 56c69e6f04..6ba0d59e24 100644 --- a/lib/public/views/Runs/format/editRunEorReasons.js +++ b/lib/public/views/Runs/format/editRunEorReasons.js @@ -94,20 +94,23 @@ export const editRunEorReasons = (runDetailsModel) => { */ runDetailsModel.runPatch.eorReasons.length > 0 ? runDetailsModel.runPatch.eorReasons.map((eorReason) => { - const { reasonTypeId, description } = eorReason; + const { reasonTypeId, description, lastEditedName } = eorReason; const { category = '-', title } = eorReasonTypes.find((eorReasonType) => eorReasonType.id === reasonTypeId) || {}; const titleString = title ? ` - ${title}` : ''; const descriptionString = description ? ` - ${description}` : ''; return h( - '.flex-row.items-center', + '.flex-row.justify-between', { key: `${category} ${titleString} ${descriptionString}`, }, [ - h('label.remove-eor-reason.danger.ph1.actionable-icon', { - onclick: () => runDetailsModel.runPatch.removeEorReason(eorReason), - }, iconTrash()), - h('.w-wrapped', `${category} ${titleString} ${descriptionString}`), + h('.flex-row.items-center', [ + h('label.remove-eor-reason.danger.ph1.actionable-icon', { + onclick: () => runDetailsModel.runPatch.removeEorReason(eorReason), + }, iconTrash()), + h('.w-wrapped', `${category} ${titleString} ${descriptionString}`), + ]), + h('.w-wrapped', lastEditedName || null), ], ); }) diff --git a/lib/public/views/Runs/format/formatRunEorReason.js b/lib/public/views/Runs/format/formatRunEorReason.js new file mode 100644 index 0000000000..b97ab4a223 --- /dev/null +++ b/lib/public/views/Runs/format/formatRunEorReason.js @@ -0,0 +1,36 @@ +/** + * @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 { h } from '/js/src/index.js'; +import { tooltip } from '../../../../components/common/popover/tooltip.js'; +import { formatEorReason } from './formatEorReason.mjs'; + +/** + * Display the given EoR reason as a vnode component with lastEditedName tooltip + * + * @param {Partial<{ + * category: string, + * title: string, + * description: string, + * lastEditedName: string, + * }>} eorReason the EoR reason to display + * @return {VNode} the vnode component + */ +export const formatRunEorReason = (eorReason) => { + const { lastEditedName } = eorReason; + const reasonText = formatEorReason(eorReason); + return h('.w-100.flex-row.justify-between', [ + h('', reasonText), + lastEditedName ? tooltip(h('.w-wrapped', lastEditedName), 'Last edited by') : null, + ]); +}; diff --git a/lib/public/views/Runs/mcReproducibleAsNotBadToggle.js b/lib/public/views/Runs/mcReproducibleAsNotBadToggle.js deleted file mode 100644 index 636ed0f245..0000000000 --- a/lib/public/views/Runs/mcReproducibleAsNotBadToggle.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @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 { switchInput } from '../../components/common/form/switchInput.js'; -import { h } from '/js/src/index.js'; - -/** - * Display a toggle switch to change interpretation of MC.Reproducible flag type from bad to not-bad - * - * @param {boolean} value current value - * @param {function} onChange to be called when switching - * @returns {Component} the toggle switch - */ -export const mcReproducibleAsNotBadToggle = (value, onChange) => h('#mcReproducibleAsNotBadToggle', switchInput( - value, - onChange, - { labelAfter: h('em', 'MC.R as not-bad') }, -)); diff --git a/lib/public/views/SimulationPasses/ActiveColumns/simulationPassesActiveColumns.js b/lib/public/views/SimulationPasses/ActiveColumns/simulationPassesActiveColumns.js index 05b796bcf8..95f9940c22 100644 --- a/lib/public/views/SimulationPasses/ActiveColumns/simulationPassesActiveColumns.js +++ b/lib/public/views/SimulationPasses/ActiveColumns/simulationPassesActiveColumns.js @@ -31,8 +31,8 @@ export const simulationPassesActiveColumns = { name: 'Name', visible: true, sortable: true, - filter: ({ namesFilterModel }) => textFilter( - namesFilterModel, + filter: ({ filteringModel }) => textFilter( + filteringModel.get('names'), { class: 'w-75 mt1', placeholder: 'e.g. LHC23k5, ...' }, ), classes: 'w-10 f6', diff --git a/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewModel.js b/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewModel.js index ed6b776215..3054251391 100644 --- a/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewModel.js +++ b/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewModel.js @@ -10,24 +10,23 @@ * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. */ -import { buildUrl, RemoteData } from '/js/src/index.js'; +import { RemoteData } from '/js/src/index.js'; import { TextTokensFilterModel } from '../../../components/Filters/common/filters/TextTokensFilterModel.js'; -import { OverviewPageModel } from '../../../models/OverviewModel.js'; import { getRemoteData } from '../../../utilities/fetch/getRemoteData.js'; import { ObservableData } from '../../../utilities/ObservableData.js'; +import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; /** * Simulation Passes Per Data Pass overview model */ -export class AnchoredSimulationPassesOverviewModel extends OverviewPageModel { +export class AnchoredSimulationPassesOverviewModel extends FilterableOverviewPageModel { /** * Constructor + * @param {QueryRouter} router router that controls the application's page navigation + * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor() { - super(); - this._namesFilterModel = new TextTokensFilterModel(); - this._registerFilter(this._namesFilterModel); - + constructor(router, pageIdentifier) { + super(router, pageIdentifier, { names: new TextTokensFilterModel() }); this._dataPass = new ObservableData(RemoteData.notAsked()); } @@ -56,25 +55,15 @@ export class AnchoredSimulationPassesOverviewModel extends OverviewPageModel { /** * @inheritdoc */ - getRootEndpoint() { - const params = { - filter: { - names: this._namesFilterModel.normalized, - dataPassIds: [this._dataPassId], - }, - }; - - return buildUrl('/api/simulationPasses', params); + getFilterParams() { + return { ...super.getFilterParams(), dataPassIds: [this._dataPassId] }; } /** - * Reset this model to its default - * - * @returns {void} + * @inheritdoc */ - reset() { - this._namesFilterModel.reset(); - super.reset(); + getRootEndpoint() { + return this.buildRootEndpoint('/api/simulationPasses'); } /** @@ -91,34 +80,4 @@ export class AnchoredSimulationPassesOverviewModel extends OverviewPageModel { get dataPass() { return this._dataPass.getCurrent(); } - - /** - * Returns data passes names filter model - * @return {TextTokensFilterModel} data passes names filter model - */ - get namesFilterModel() { - return this._namesFilterModel; - } - - /** - * Register a new filter model - * @param {FilterModel} filterModel the filter model to register - * @return {void} - * @private - */ - _registerFilter(filterModel) { - filterModel.visualChange$.bubbleTo(this); - filterModel.observe(() => { - this._pagination.silentlySetCurrentPage(1); - this.load(); - }); - } - - /** - * States whether any filter is active - * @return {boolean} true if any filter is active - */ - isAnyFilterActive() { - return !this._namesFilterModel.isEmpty; - } } diff --git a/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewPage.js b/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewPage.js index 5894ba1a05..f9f752836c 100644 --- a/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewPage.js +++ b/lib/public/views/SimulationPasses/AnchoredOverview/AnchoredSimulationPassesOverviewPage.js @@ -21,6 +21,7 @@ import { simulationPassesActiveColumns } from '../ActiveColumns/simulationPasses import { breadcrumbs } from '../../../components/common/navigation/breadcrumbs.js'; import spinner from '../../../components/common/spinner.js'; import { tooltip } from '../../../components/common/popover/tooltip.js'; +import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 42; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -34,13 +35,9 @@ const PAGE_USED_HEIGHT = 215; export const AnchoredSimulationPassesOverviewPage = ({ simulationPasses: { anchoredOverviewModel: anchoredSimulationPassesOverviewModel }, }) => { - anchoredSimulationPassesOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( - TABLEROW_HEIGHT, - PAGE_USED_HEIGHT, - )); - - const { items, dataPass, pagination } = anchoredSimulationPassesOverviewModel; + const { items, dataPass, pagination, sortModel } = anchoredSimulationPassesOverviewModel; + pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); const commonTitle = h('h2#breadcrumb-header', { style: 'white-space: nowrap;' }, 'Anchored MC'); return h( @@ -61,13 +58,14 @@ export const AnchoredSimulationPassesOverviewPage = ({ }), ), ]), + warningComponent(anchoredSimulationPassesOverviewModel), h('.w-100.flex-column', [ table( items, simulationPassesActiveColumns, { classes: '.table-sm' }, null, - { sort: anchoredSimulationPassesOverviewModel.sortModel }, + { sort: sortModel }, ), paginationComponent(pagination), ]), diff --git a/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewModel.js b/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewModel.js index 98e5d12059..0980a8c961 100644 --- a/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewModel.js +++ b/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewModel.js @@ -11,23 +11,22 @@ * or submit itself to any jurisdiction. */ import { TextTokensFilterModel } from '../../../components/Filters/common/filters/TextTokensFilterModel.js'; -import { OverviewPageModel } from '../../../models/OverviewModel.js'; -import { buildUrl, RemoteData } from '/js/src/index.js'; +import { RemoteData } from '/js/src/index.js'; import { ObservableData } from '../../../utilities/ObservableData.js'; import { getRemoteData } from '../../../utilities/fetch/getRemoteData.js'; +import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; /** * Simulation Passes Per LHC Period overview model */ -export class SimulationPassesPerLhcPeriodOverviewModel extends OverviewPageModel { +export class SimulationPassesPerLhcPeriodOverviewModel extends FilterableOverviewPageModel { /** * Constructor + * @param {QueryRouter} router router that controls the application's page navigation + * @param {string} pageIdentifier string that indicates what page this model represents */ - constructor() { - super(); - - this._namesFilterModel = new TextTokensFilterModel(); - this._registerFilter(this._namesFilterModel); + constructor(router, pageIdentifier) { + super(router, pageIdentifier, { names: new TextTokensFilterModel() }); this._lhcPeriod = new ObservableData(RemoteData.notAsked()); this._lhcPeriod.bubbleTo(this); @@ -60,25 +59,15 @@ export class SimulationPassesPerLhcPeriodOverviewModel extends OverviewPageModel /** * @inheritdoc */ - getRootEndpoint() { - const params = { - filter: { - names: this._namesFilterModel.normalized, - lhcPeriodIds: [this._lhcPeriodId], - }, - }; - - return buildUrl('/api/simulationPasses', params); + getFilterParams() { + return { ...super.getFilterParams(), lhcPeriodIds: [this._lhcPeriodId] }; } /** - * Reset this model to its default - * - * @returns {void} + * @inheritdoc */ - reset() { - this._namesFilterModel.reset(); - super.reset(); + getRootEndpoint() { + return this.buildRootEndpoint('/api/simulationPasses'); } /** @@ -95,34 +84,4 @@ export class SimulationPassesPerLhcPeriodOverviewModel extends OverviewPageModel get lhcPeriod() { return this._lhcPeriod.getCurrent(); } - - /** - * Returns simulation passes names filter model - * @return {TextTokensFilterModel} simulation passes names filter model - */ - get namesFilterModel() { - return this._namesFilterModel; - } - - /** - * Register a new filter model - * @param {FilterModel} filterModel the filter model to register - * @return {void} - * @private - */ - _registerFilter(filterModel) { - filterModel.visualChange$.bubbleTo(this); - filterModel.observe(() => { - this._pagination.silentlySetCurrentPage(1); - this.load(); - }); - } - - /** - * States whether any filter is active - * @return {boolean} true if any filter is active - */ - isAnyFilterActive() { - return !this._namesFilterModel.isEmpty; - } } diff --git a/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewPage.js b/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewPage.js index 3cc12756d0..0d2961b5f3 100644 --- a/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewPage.js +++ b/lib/public/views/SimulationPasses/PerLhcPeriodOverview/SimulationPassesPerLhcPeriodOverviewPage.js @@ -21,6 +21,7 @@ import { simulationPassesActiveColumns } from '../ActiveColumns/simulationPasses import spinner from '../../../components/common/spinner.js'; import { tooltip } from '../../../components/common/popover/tooltip.js'; import { breadcrumbs } from '../../../components/common/navigation/breadcrumbs.js'; +import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 42; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -33,12 +34,9 @@ const PAGE_USED_HEIGHT = 215; */ export const SimulationPassesPerLhcPeriodOverviewPage = ({ simulationPasses: { perLhcPeriodOverviewModel: simulationPassesPerLhcPeriodOverviewModel } }) => { - simulationPassesPerLhcPeriodOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( - TABLEROW_HEIGHT, - PAGE_USED_HEIGHT, - )); + const { items: simulationPasses, lhcPeriod, pagination, sortModel } = simulationPassesPerLhcPeriodOverviewModel; - const { items: simulationPasses, lhcPeriod } = simulationPassesPerLhcPeriodOverviewModel; + pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); const commonTitle = h('h2#breadcrumb-header', { style: 'white-space: nowrap;' }, 'Monte Carlo'); @@ -57,15 +55,10 @@ export const SimulationPassesPerLhcPeriodOverviewPage = ({ simulationPasses: { }), ), ]), + warningComponent(simulationPassesPerLhcPeriodOverviewModel), h('.w-100.flex-column', [ - table( - simulationPasses, - simulationPassesActiveColumns, - { classes: '.table-sm' }, - null, - { sort: simulationPassesPerLhcPeriodOverviewModel.sortModel }, - ), - paginationComponent(simulationPassesPerLhcPeriodOverviewModel.pagination), + table(simulationPasses, simulationPassesActiveColumns, { classes: '.table-sm' }, null, { sort: sortModel }), + paginationComponent(pagination), ]), ]); }; diff --git a/lib/public/views/SimulationPasses/SimulationPassesModel.js b/lib/public/views/SimulationPasses/SimulationPassesModel.js index 8e8d6e7969..8ba624efd8 100644 --- a/lib/public/views/SimulationPasses/SimulationPassesModel.js +++ b/lib/public/views/SimulationPasses/SimulationPassesModel.js @@ -21,14 +21,15 @@ import { AnchoredSimulationPassesOverviewModel } from './AnchoredOverview/Anchor export class SimulationPassesModel extends Observable { /** * The constructor of the model + * @param {QueryRouter} router router that controls the application's page navigation */ - constructor() { + constructor(router) { super(); - this._perLhcPeriodOverviewModel = new SimulationPassesPerLhcPeriodOverviewModel(); + this._perLhcPeriodOverviewModel = new SimulationPassesPerLhcPeriodOverviewModel(router, 'simulation-passes-per-lhc-period-overview'); this._perLhcPeriodOverviewModel.bubbleTo(this); - this._anchoredOverviewModel = new AnchoredSimulationPassesOverviewModel(); + this._anchoredOverviewModel = new AnchoredSimulationPassesOverviewModel(router, 'anchored-simulation-passes-overview'); this._anchoredOverviewModel.bubbleTo(this); } @@ -41,6 +42,7 @@ export class SimulationPassesModel extends Observable { loadPerLhcPeriodOverview({ lhcPeriodId }) { if (!this._perLhcPeriodOverviewModel.pagination.isInfiniteScrollEnabled) { this._perLhcPeriodOverviewModel.lhcPeriodId = lhcPeriodId; + this._perLhcPeriodOverviewModel.setFilterFromURL(false); this._perLhcPeriodOverviewModel.load(); } } @@ -70,6 +72,7 @@ export class SimulationPassesModel extends Observable { */ loadAnchoredOverview({ dataPassId }) { this._anchoredOverviewModel.dataPassId = dataPassId; + this._anchoredOverviewModel.setFilterFromURL(false); this._anchoredOverviewModel.load(); } diff --git a/lib/public/views/lhcPeriods/ActiveColumns/lhcPeriodsActiveColumns.js b/lib/public/views/lhcPeriods/ActiveColumns/lhcPeriodsActiveColumns.js index 6f757e25d0..6c312fbecf 100644 --- a/lib/public/views/lhcPeriods/ActiveColumns/lhcPeriodsActiveColumns.js +++ b/lib/public/views/lhcPeriods/ActiveColumns/lhcPeriodsActiveColumns.js @@ -30,8 +30,8 @@ export const lhcPeriodsActiveColumns = { name: 'Name', visible: true, sortable: true, - filter: ({ namesFilterModel }) => textFilter( - namesFilterModel, + filter: ({ filteringModel }) => textFilter( + filteringModel.get('names'), { class: 'w-75 mt1', placeholder: 'e.g. LHC22a, lhc23b, ...' }, ), classes: 'w-15', @@ -92,8 +92,8 @@ export const lhcPeriodsActiveColumns = { visible: true, sortable: true, format: (_, lhcPeriod) => formatLhcPeriodYear(lhcPeriod.name), - filter: ({ yearsFilterModel }) => textFilter( - yearsFilterModel, + filter: ({ filteringModel }) => textFilter( + filteringModel.get('years'), { class: 'w-75 mt1', placeholder: 'e.g. 2022, 2023, ...' }, ), classes: 'w-7', @@ -104,8 +104,8 @@ export const lhcPeriodsActiveColumns = { visible: true, sortable: true, format: (pdpBeamTypes) => pdpBeamTypes.length > 0 ? pdpBeamTypes.join(',') : '-', - filter: ({ pdpBeamTypesFilterModel }) => textFilter( - pdpBeamTypesFilterModel, + filter: ({ filteringModel }) => textFilter( + filteringModel.get('pdpBeamTypes'), { class: 'w-75 mt1', placeholder: 'e.g. pp, PbPb' }, ), classes: 'w-7', diff --git a/lib/public/views/lhcPeriods/LhcPeriodsModel.js b/lib/public/views/lhcPeriods/LhcPeriodsModel.js index 4f9d0ed185..74df7b9dc7 100644 --- a/lib/public/views/lhcPeriods/LhcPeriodsModel.js +++ b/lib/public/views/lhcPeriods/LhcPeriodsModel.js @@ -20,11 +20,12 @@ import { LhcPeriodsOverviewModel } from './Overview/LhcPeriodsOverviewModel.js'; export class LhcPeriodsModel extends Observable { /** * The constructor of the model + * @param {QueryRouter} router router that controls the application's page navigation */ - constructor() { + constructor(router) { super(); - this._overviewModel = new LhcPeriodsOverviewModel(); + this._overviewModel = new LhcPeriodsOverviewModel(router, 'lhc-period-overview'); this._overviewModel.bubbleTo(this); } @@ -34,6 +35,7 @@ export class LhcPeriodsModel extends Observable { * @returns {void} */ loadOverview() { + this._overviewModel.setFilterFromURL(false); this._overviewModel.load(); } diff --git a/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewModel.js b/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewModel.js index eb2d5e48cd..88bf797877 100644 --- a/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewModel.js +++ b/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewModel.js @@ -12,42 +12,36 @@ */ import { TextTokensFilterModel } from '../../../components/Filters/common/filters/TextTokensFilterModel.js'; -import { OverviewPageModel } from '../../../models/OverviewModel.js'; -import { buildUrl } from '/js/src/index.js'; +import { FilterableOverviewPageModel } from '../../../models/FilterableOverviewPageModel.js'; /** * LHC Periods overview model * * @implements {OverviewModel} */ -export class LhcPeriodsOverviewModel extends OverviewPageModel { +export class LhcPeriodsOverviewModel extends FilterableOverviewPageModel { /** * The constructor of the Overview model object - */ - constructor() { - super(); - - this._namesFilterModel = new TextTokensFilterModel(); - this._registerFilter(this._namesFilterModel); - this._yearsFilterModel = new TextTokensFilterModel(); - this._registerFilter(this._yearsFilterModel); - this._pdpBeamTypesFilterModel = new TextTokensFilterModel(); - this._registerFilter(this._pdpBeamTypesFilterModel); + * @param {QueryRouter} router router that controls the application's page navigation + * @param {string} pageIdentifier string that indicates what page this model represents + */ + constructor(router, pageIdentifier) { + super( + router, + pageIdentifier, + { + names: new TextTokensFilterModel(), + years: new TextTokensFilterModel(), + pdpBeamTypes: new TextTokensFilterModel(), + }, + ); } /** * @inheritdoc */ getRootEndpoint() { - const params = { - filter: { - names: this._namesFilterModel.normalized, - years: this._yearsFilterModel.normalized, - pdpBeamTypes: this._pdpBeamTypesFilterModel.normalized, - }, - }; - - return buildUrl('/api/lhcPeriodsStatistics', params); + return this.buildRootEndpoint('/api/lhcPeriodsStatistics'); } /** @@ -65,62 +59,4 @@ export class LhcPeriodsOverviewModel extends OverviewPageModel { }; }); } - - /** - * Returns lhc periods names filter model - * @return {TextTokensFilterModel} lhc periods names filter model - */ - get namesFilterModel() { - return this._namesFilterModel; - } - - /** - * Returns lhc periods years filter model - * @return {TextTokensFilterModel} lhc periods years filter model - */ - get yearsFilterModel() { - return this._yearsFilterModel; - } - - /** - * Returns lhc periods beam type filter model - * @return {TextTokensFilterModel} lhc periods beam type filter model - */ - get pdpBeamTypesFilterModel() { - return this._pdpBeamTypesFilterModel; - } - - /** - * Reset this model to its default - * - * @returns {void} - */ - reset() { - super.reset(); - this._namesFilterModel.reset(); - this._yearsFilterModel.reset(); - this._pdpBeamTypesFilterModel.reset(); - } - - /** - * Register a new filter model - * @param {FilterModel} filterModel the filter model to register - * @return {void} - * @private - */ - _registerFilter(filterModel) { - filterModel.visualChange$.bubbleTo(this); - filterModel.observe(() => { - this._pagination.silentlySetCurrentPage(1); - this.load(); - }); - } - - /** - * States whether any filter is active - * @return {boolean} true if any filter is active - */ - isAnyFilterActive() { - return !this._namesFilterModel.isEmpty || !this._yearsFilterModel.isEmpty || !this._pdpBeamTypesFilterModel.isEmpty; - } } diff --git a/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewPage.js b/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewPage.js index b431c62d42..89c0def48c 100644 --- a/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewPage.js +++ b/lib/public/views/lhcPeriods/Overview/LhcPeriodsOverviewPage.js @@ -18,6 +18,7 @@ import { lhcPeriodsActiveColumns } from '../ActiveColumns/lhcPeriodsActiveColumn import { paginationComponent } from '../../../components/Pagination/paginationComponent.js'; import { filtersPanelPopover } from '../../../components/Filters/common/filtersPanelPopover.js'; import { estimateDisplayableRowsCount } from '../../../utilities/estimateDisplayableRowsCount.js'; +import { warningComponent } from '../../../components/common/messages/warningComponent.js'; const TABLEROW_HEIGHT = 35; // Estimate of the navbar and pagination elements height total; Needs to be updated in case of changes; @@ -29,22 +30,19 @@ const PAGE_USED_HEIGHT = 215; * @returns {Component} The overview screen */ export const LhcPeriodsOverviewPage = ({ lhcPeriods: { overviewModel: lhcPeriodsOverviewModel } }) => { - lhcPeriodsOverviewModel.pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount( - TABLEROW_HEIGHT, - PAGE_USED_HEIGHT, - )); + const { sortModel, pagination, items } = lhcPeriodsOverviewModel; + + pagination.provideDefaultItemsPerPage(estimateDisplayableRowsCount(TABLEROW_HEIGHT, PAGE_USED_HEIGHT)); return h('', [ - h('.flex-row.header-container.pv2', filtersPanelPopover(lhcPeriodsOverviewModel, lhcPeriodsActiveColumns)), + h( + '.flex-row.header-container.pv2', + filtersPanelPopover(lhcPeriodsOverviewModel, lhcPeriodsActiveColumns), + ), + warningComponent(lhcPeriodsOverviewModel), h('.w-100.flex-column', [ - table( - lhcPeriodsOverviewModel.items, - lhcPeriodsActiveColumns, - { classes: '.table-sm' }, - null, - { sort: lhcPeriodsOverviewModel.sortModel }, - ), - paginationComponent(lhcPeriodsOverviewModel.pagination), + table(items, lhcPeriodsActiveColumns, { classes: '.table-sm' }, null, { sort: sortModel }), + paginationComponent(pagination), ]), ]); }; diff --git a/lib/server/Loggers/FilterLogger.js b/lib/server/Loggers/FilterLogger.js new file mode 100644 index 0000000000..0ae19af9bf --- /dev/null +++ b/lib/server/Loggers/FilterLogger.js @@ -0,0 +1,60 @@ +/** + * @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. + */ +const { LogManager, LogLevel } = require('@aliceo2/web-ui'); +const { isInTestMode } = require('../../utilities/env-utils'); + +/** + * Logger dedicated to filter-related endpoint access events. + */ +class FilterLogger { + /** + * Creates an instance of FilterLogger. + */ + constructor(silent = isInTestMode()) { + LogManager.configure({ infologger: true }); + this._logger = LogManager.getLogger('FILTERING'); + this._logLevel = LogLevel.OPERATIONS; + this._silent = silent; + } + + /** + * Logs an informational message about endpoint access and applied filters. + * + * @param {object} request the request received at any given endpoint. + * @param {string} endpoint the endpoint that was accessed. + * @param {string|number} id identifier of the user accessing the endpoint. + * @param {Object} [filters={}] filters applied to the request. + * @returns {void} + */ + infoMessage({ path, session: { id } = {}, query = {} }) { + if (this._silent) { + return; + } + + const filters = query.filter ?? {}; + + let message = `Endpoint ${path} was accessed by `; + message += id ? `user ${id} ` : 'an unauthenticated user '; + + if (!Object.keys(filters).length) { + message += 'without filters'; + } else { + message += 'with the following filters:\n'; + message += JSON.stringify(filters); + } + + this._logger.infoMessage(message, { level: this._logLevel }); + } +} + +module.exports = new FilterLogger(); diff --git a/lib/server/controllers/dataPasses.controller.js b/lib/server/controllers/dataPasses.controller.js index 81e2de5d6b..116673beaf 100644 --- a/lib/server/controllers/dataPasses.controller.js +++ b/lib/server/controllers/dataPasses.controller.js @@ -21,7 +21,10 @@ const { dtoValidator } = require('../utilities/dtoValidator.js'); const { countedItemsToHttpView } = require('../utilities/countedItemsToHttpView.js'); const { updateExpressResponseFromNativeError } = require('../express/updateExpressResponseFromNativeError'); const PaginationDto = require('../../domain/dtos/PaginationDto.js'); -const { NON_PHYSICS_PRODUCTIONS_NAMES_WORDS } = require('../../domain/enums/NonPhysicsProductionsNamesWords.js'); +const { + NON_PHYSICS_PRODUCTIONS_NAMES_WORDS, + NON_PHYSICS_PRODUCTIONS_NAMES_TOTAL_LENGTH, +} = require('../../domain/enums/NonPhysicsProductionsNamesWords.js'); /** * List All DataPasses with statistics @@ -34,17 +37,14 @@ const listDataPassesHandler = async (req, res) => { lhcPeriodIds: Joi.array().items(Joi.number()), ids: Joi.array().items(Joi.number()), names: Joi.array().items(Joi.string()), - include: Joi.object({ byName: Joi.string().custom((value, helper) => { - if (value.length > 10) { - return helper.error('byName cannot have more than 10 characters'); - } - const nameTokens = value?.split(','); + permittedNonPhysicsNames: Joi.string().max(NON_PHYSICS_PRODUCTIONS_NAMES_TOTAL_LENGTH).custom((value, helper) => { + const nameTokens = value.split(','); const allTokensCorrect = nameTokens.every((token) => NON_PHYSICS_PRODUCTIONS_NAMES_WORDS.includes(token)); if (!allTokensCorrect) { - return helper.error(`All byName must comma delimited list of ${NON_PHYSICS_PRODUCTIONS_NAMES_WORDS}`); + return helper.error(`All permittedNonPhysicsNames must comma delimited list of ${NON_PHYSICS_PRODUCTIONS_NAMES_WORDS}`); } return nameTokens; - }) }), + }), }, page: PaginationDto, sort: DtoFactory.order(['id', 'name']), diff --git a/lib/server/controllers/lhcPeriodStatistics.controller.js b/lib/server/controllers/lhcPeriodStatistics.controller.js index c70b04b67c..8784e3871e 100644 --- a/lib/server/controllers/lhcPeriodStatistics.controller.js +++ b/lib/server/controllers/lhcPeriodStatistics.controller.js @@ -42,7 +42,7 @@ const listLhcPeriodStatisticsHandler = async (req, res) => { ); if (validatedDTO) { try { - const { filter, page: { limit = ApiConfig.pagination.limit, offset } = {}, sort = { name: 'DESC' } } = validatedDTO.query; + const { filter, page: { limit = ApiConfig.pagination.limit, offset } = {}, sort = { id: 'DESC' } } = validatedDTO.query; const { count, rows: items } = await lhcPeriodStatisticsService.getAllForPhysicsRuns({ filter, limit, diff --git a/lib/server/middleware/InfoLoggerListener.middleware.js b/lib/server/middleware/InfoLoggerListener.middleware.js new file mode 100644 index 0000000000..858b8a805d --- /dev/null +++ b/lib/server/middleware/InfoLoggerListener.middleware.js @@ -0,0 +1,23 @@ +/** + * @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. + */ + +/** + * Logger based middleware generator + * + * @param {Class} logger class that exposes an infoMessage function that recceives the request and then sends specific data to InfoLogger + * @return {(function(*, *, *): void)} the infoLoggerListener middleware + */ +exports.infoLoggerListenerMiddleware = (logger) => (request, _response, next) => { + logger.infoMessage(request); + next(); +}; diff --git a/lib/server/routers/dataPasses.router.js b/lib/server/routers/dataPasses.router.js index 34f97d2547..89e0e7cd58 100644 --- a/lib/server/routers/dataPasses.router.js +++ b/lib/server/routers/dataPasses.router.js @@ -14,11 +14,13 @@ const { DataPassesController } = require('../controllers/dataPasses.controller.js'); const { rbacMiddleware } = require('../middleware/rbac.middleware.js'); const { BkpRoles } = require('../../domain/enums/BkpRoles.js'); +const FilterLogger = require('../Loggers/FilterLogger.js'); +const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware.js'); exports.dataPassesRouter = { path: '/dataPasses', method: 'get', - controller: DataPassesController.listDataPassesHandler, + controller: [infoLoggerListenerMiddleware(FilterLogger), DataPassesController.listDataPassesHandler], children: [ { diff --git a/lib/server/routers/environments.router.js b/lib/server/routers/environments.router.js index 56d4066e73..1c0769bc68 100644 --- a/lib/server/routers/environments.router.js +++ b/lib/server/routers/environments.router.js @@ -12,11 +12,13 @@ */ const { EnvironmentsController } = require('../controllers'); +const FilterLogger = require('../Loggers/FilterLogger'); +const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware'); module.exports = { method: 'get', path: '/environments', - controller: EnvironmentsController.getAllEnvironments, + controller: [infoLoggerListenerMiddleware(FilterLogger), EnvironmentsController.getAllEnvironments], children: [ { method: 'post', diff --git a/lib/server/routers/lhcFills.router.js b/lib/server/routers/lhcFills.router.js index 2b33cedb8c..73bc50433e 100644 --- a/lib/server/routers/lhcFills.router.js +++ b/lib/server/routers/lhcFills.router.js @@ -12,13 +12,15 @@ */ const { LhcFillsController } = require('../controllers'); +const FilterLogger = require('../Loggers/FilterLogger'); +const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware'); module.exports = { path: '/lhcFills', children: [ { method: 'get', - controller: LhcFillsController.listLhcFills, + controller: [infoLoggerListenerMiddleware(FilterLogger), LhcFillsController.listLhcFills], }, { method: 'post', diff --git a/lib/server/routers/lhcPeriodsStatistics.router.js b/lib/server/routers/lhcPeriodsStatistics.router.js index 073288903c..feb0f4b058 100644 --- a/lib/server/routers/lhcPeriodsStatistics.router.js +++ b/lib/server/routers/lhcPeriodsStatistics.router.js @@ -12,13 +12,15 @@ */ const { LhcPeriodStatisticsController } = require('../controllers/lhcPeriodStatistics.controller.js'); +const FilterLogger = require('../Loggers/FilterLogger.js'); +const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware.js'); exports.lhcPeriodsRouter = { path: '/lhcPeriodsStatistics', children: [ { method: 'get', - controller: LhcPeriodStatisticsController.listLhcPeriodStatisticsHandler, + controller: [infoLoggerListenerMiddleware(FilterLogger), LhcPeriodStatisticsController.listLhcPeriodStatisticsHandler], }, { method: 'get', diff --git a/lib/server/routers/logs.router.js b/lib/server/routers/logs.router.js index d4381dc170..9c115f855e 100644 --- a/lib/server/routers/logs.router.js +++ b/lib/server/routers/logs.router.js @@ -12,12 +12,14 @@ */ const { LogsController } = require('../controllers'); +const FilterLogger = require('../Loggers/FilterLogger'); const { multerMiddleware: { attachmentMiddleware } } = require('../middleware'); +const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware'); module.exports = { method: 'get', path: '/logs', - controller: LogsController.listLogs, + controller: [infoLoggerListenerMiddleware(FilterLogger), LogsController.listLogs], children: [ { method: 'get', diff --git a/lib/server/routers/qcFlag.router.js b/lib/server/routers/qcFlag.router.js index 569a6802ec..f97a565c86 100644 --- a/lib/server/routers/qcFlag.router.js +++ b/lib/server/routers/qcFlag.router.js @@ -13,6 +13,8 @@ const { BkpRoles } = require('../../domain/enums/BkpRoles.js'); const { QcFlagController } = require('../controllers/qcFlag.controller.js'); +const FilterLogger = require('../Loggers/FilterLogger.js'); +const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware.js'); const { rbacMiddleware } = require('../middleware/rbac.middleware.js'); exports.qcFlagsRouter = { @@ -21,7 +23,7 @@ exports.qcFlagsRouter = { { path: 'gaq', method: 'get', - controller: QcFlagController.getGaqQcFlagsHandler, + controller: [infoLoggerListenerMiddleware(FilterLogger), QcFlagController.getGaqQcFlagsHandler], }, { path: 'summary', diff --git a/lib/server/routers/runs.router.js b/lib/server/routers/runs.router.js index cfff057864..59d453d9bc 100644 --- a/lib/server/routers/runs.router.js +++ b/lib/server/routers/runs.router.js @@ -12,6 +12,8 @@ */ const { RunsController } = require('../controllers'); +const FilterLogger = require('../Loggers/FilterLogger'); +const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware'); module.exports = { children: [ @@ -30,7 +32,7 @@ module.exports = { }, { method: 'get', - controller: RunsController.listRuns, + controller: [infoLoggerListenerMiddleware(FilterLogger), RunsController.listRuns], }, { method: 'get', diff --git a/lib/server/routers/simulationPasses.router.js b/lib/server/routers/simulationPasses.router.js index 057a914fbc..ccd7ba18c1 100644 --- a/lib/server/routers/simulationPasses.router.js +++ b/lib/server/routers/simulationPasses.router.js @@ -12,6 +12,8 @@ */ const { SimulationPassesController } = require('../controllers/simulationPasses.controller.js'); +const FilterLogger = require('../Loggers/FilterLogger.js'); +const { infoLoggerListenerMiddleware } = require('../middleware/InfoLoggerListener.middleware.js'); exports.simulationPassesRouter = { path: '/simulationPasses', @@ -23,7 +25,7 @@ exports.simulationPassesRouter = { }, { method: 'get', - controller: SimulationPassesController.listSimulationPassesHandler, + controller: [infoLoggerListenerMiddleware(FilterLogger), SimulationPassesController.listSimulationPassesHandler], }, ], }; diff --git a/lib/server/services/dataPasses/DataPassService.js b/lib/server/services/dataPasses/DataPassService.js index 617aa9c7e4..df29634c9c 100644 --- a/lib/server/services/dataPasses/DataPassService.js +++ b/lib/server/services/dataPasses/DataPassService.js @@ -88,13 +88,25 @@ class DataPassService { * @returns {Promise>} result */ async getAll({ - filter, + filter = {}, limit, offset, sort, } = {}) { const queryBuilder = this.prepareQueryBuilder(); + /** + * @typedef + * @property {object} filter + * @property {number[]} [filter.lhcPeriodIds] lhcPeriod identifier to filter with + * @property {number[]} [filter.simulationPassIds] simulationPass identifier to filter with + * @property {number[]} [filter.ids] data passes identifier to filter with + * @property {string[]} [filter.names] data passes names to filter with + * @property {string[]} [filter.permittedNonPhysicsNames] list of tokens in data passes names which indicate + * a given data pass should not be excluded, possible tokens are 'test', 'debug'. + */ + const { ids, names, permittedNonPhysicsNames = [], lhcPeriodIds, simulationPassIds } = filter; + if (sort) { for (const property in sort) { queryBuilder.orderBy(property, sort[property]); @@ -108,37 +120,24 @@ class DataPassService { queryBuilder.offset(offset); } - if (filter) { - /** - * @typedef - * @property {object} filter - * @property {number[]} [filter.lhcPeriodIds] lhcPeriod identifier to filter with - * @property {number[]} [filter.simulationPassIds] simulationPass identifier to filter with - * @property {number[]} [filter.ids] data passes identifier to filter with - * @property {string[]} [filter.names] data passes names to filter with - * @property {boolean} [filter.include.byName] list of tokens in data passes names which indicate - * a given data pass should not be excluded, possible tokens are 'test', 'debug'. - */ - const { ids, names, lhcPeriodIds, simulationPassIds } = filter; - if (lhcPeriodIds) { - queryBuilder.where('lhcPeriodId').oneOf(...lhcPeriodIds); - } - if (simulationPassIds) { - queryBuilder.whereAssociation('anchoredSimulationPasses', 'id').oneOf(...simulationPassIds); - } - if (ids) { - queryBuilder.where('id').oneOf(...ids); - } - if (names) { - queryBuilder.where('name').oneOf(...names); - } + if (lhcPeriodIds) { + queryBuilder.where('lhcPeriodId').oneOf(...lhcPeriodIds); + } + if (simulationPassIds) { + queryBuilder.whereAssociation('anchoredSimulationPasses', 'id').oneOf(...simulationPassIds); + } + if (ids) { + queryBuilder.where('id').oneOf(...ids); + } + if (names) { + queryBuilder.where('name').oneOf(...names); } - const byName = filter?.include?.byName ?? []; - if (!byName.includes(NonPhysicsProductionsNamesWords.TEST)) { + if (!permittedNonPhysicsNames.includes(NonPhysicsProductionsNamesWords.TEST)) { queryBuilder.where('name').not().substring(`\\_${NonPhysicsProductionsNamesWords.TEST}`); } - if (!byName.includes(NonPhysicsProductionsNamesWords.DEBUG)) { + + if (!permittedNonPhysicsNames.includes(NonPhysicsProductionsNamesWords.DEBUG)) { queryBuilder.where('name').not().substring(`\\_${NonPhysicsProductionsNamesWords.DEBUG}`); } diff --git a/lib/server/services/lhcPeriod/LhcPeriodStatisticsService.js b/lib/server/services/lhcPeriod/LhcPeriodStatisticsService.js index 4e92ae675e..a64c5566ee 100644 --- a/lib/server/services/lhcPeriod/LhcPeriodStatisticsService.js +++ b/lib/server/services/lhcPeriod/LhcPeriodStatisticsService.js @@ -21,6 +21,12 @@ const { NotFoundError } = require('../../errors/NotFoundError'); const { RunDefinition } = require('../../../domain/enums/RunDefinition.js'); const { NonPhysicsProductionsNamesWords } = require('../../../domain/enums/NonPhysicsProductionsNamesWords.js'); +const sortExpressionMap = { + name: (sequelize) => sequelize.col('`lhcPeriod`.`name`'), + year: (sequelize) => sequelize.literal('SUBSTRING(lhcPeriod.name, 4, 2)'), + pdpBeamTypes: (sequelize) => sequelize.literal('pdpBeamTypes'), +}; + /** * @typedef LhcPeriodIdentifier object to uniquely identify a lhc period * @property {string} [name] the lhc period name @@ -85,22 +91,9 @@ class LhcPeriodStatisticsService { sort, } = {}) { const queryBuilder = this.prepareQueryBuilder(); - if (sort) { for (const property in sort) { - let expression; - switch (property) { - case 'name': - expression = (sequelize) => sequelize.col('`lhcPeriod`.`name`'); - break; - case 'year': - expression = (sequelize) => sequelize.literal('SUBSTRING(lhcPeriod.name, 4, 2)'); - break; - case 'pdpBeamTypes': - expression = (sequelize) => sequelize.literal('pdpBeamTypes'); - break; - } - + const expression = sortExpressionMap[property]; queryBuilder.orderBy(expression ?? property, sort[property]); } } diff --git a/lib/usecases/environment/GetAllEnvironmentsUseCase.js b/lib/usecases/environment/GetAllEnvironmentsUseCase.js index c742c53b62..14923a63ca 100644 --- a/lib/usecases/environment/GetAllEnvironmentsUseCase.js +++ b/lib/usecases/environment/GetAllEnvironmentsUseCase.js @@ -23,6 +23,7 @@ const { dataSource } = require('../../database/DataSource.js'); const { statusAcronyms } = require('../../domain/enums/StatusAcronyms.js'); const { unpackNumberRange } = require('../../utilities/rangeUtils.js'); const { splitStringToStringsTrimmed } = require('../../utilities/stringUtils.js'); +const { setTimeRangeQuery } = require('../../utilities/setTimeRangeQuery.js'); /** * Subquery to select the latest history item for each environment. @@ -69,18 +70,11 @@ class GetAllEnvironmentsUseCase { const { filter, page = {} } = query; const { limit = ApiConfig.pagination.limit, offset = 0 } = page; - /** - * Prepare a query builder with ordering, limit and offset - * - * @return {QueryBuilder} the created query builder - */ - const prepareQueryBuilder = () => dataSource.createQueryBuilder() + const queryBuilder = dataSource.createQueryBuilder() .orderBy('updatedAt', 'desc') .limit(limit) .offset(offset); - const fetchQueryBuilder = prepareQueryBuilder(); - if (filter) { const { ids: idsExpression, @@ -90,12 +84,8 @@ class GetAllEnvironmentsUseCase { created, } = filter; - const filterQueryBuilder = prepareQueryBuilder(); - if (created) { - const from = created.from !== undefined ? created.from : 0; - const to = created.to !== undefined ? created.to : Date.now(); - filterQueryBuilder.where('createdAt').between(from, to); + setTimeRangeQuery(created, 'createdAt', queryBuilder); } if (idsExpression) { @@ -103,12 +93,12 @@ class GetAllEnvironmentsUseCase { // Filter should be like with only one filter if (filters.length === 1) { - filterQueryBuilder.where('id').substring(filters[0]); + queryBuilder.where('id').substring(filters[0]); } // Filters should be exact with more than one filter if (filters.length > 1) { - filterQueryBuilder.andWhere({ id: { [Op.in]: filters } }); + queryBuilder.andWhere({ id: { [Op.in]: filters } }); } } @@ -116,12 +106,12 @@ class GetAllEnvironmentsUseCase { const filters = currentStatusExpression.split(',').map((status) => status.trim()); // Filter the environments by current status using the subquery - filterQueryBuilder.literalWhere( + queryBuilder.literalWhere( `${ENVIRONMENT_LATEST_HISTORY_ITEM_SUBQUERY} IN (:filters)`, { filters }, ); - filterQueryBuilder.includeAttribute({ + queryBuilder.includeAttribute({ query: ENVIRONMENT_LATEST_HISTORY_ITEM_SUBQUERY, alias: 'currentStatus', }); @@ -157,7 +147,7 @@ class GetAllEnvironmentsUseCase { * Use OR condition to match subsequences ending with either DESTROYED or DONE * Filter the environments by using LIKE for subsequence matching */ - filterQueryBuilder.literalWhere( + queryBuilder.literalWhere( `(${ENVIRONMENT_STATUS_HISTORY_SUBQUERY} LIKE :statusFiltersWithDestroyed OR ` + `${ENVIRONMENT_STATUS_HISTORY_SUBQUERY} LIKE :statusFiltersWithDone)`, { @@ -166,17 +156,17 @@ class GetAllEnvironmentsUseCase { }, ); - filterQueryBuilder.includeAttribute({ + queryBuilder.includeAttribute({ query: ENVIRONMENT_STATUS_HISTORY_SUBQUERY, alias: 'statusHistory', }); } else { - filterQueryBuilder.literalWhere( + queryBuilder.literalWhere( `${ENVIRONMENT_STATUS_HISTORY_SUBQUERY} LIKE :statusFilters`, { statusFilters: `%${statusFilters.join(',')}%` }, ); - filterQueryBuilder.includeAttribute({ + queryBuilder.includeAttribute({ query: ENVIRONMENT_STATUS_HISTORY_SUBQUERY, alias: 'statusHistory', }); @@ -190,7 +180,7 @@ class GetAllEnvironmentsUseCase { // Check that the final run numbers list contains at least one valid run number if (finalRunNumberList.length > 0) { - filterQueryBuilder.include({ + queryBuilder.include({ association: 'runs', where: { // Filter should be like with only one filter and exact with more than one filter @@ -198,22 +188,12 @@ class GetAllEnvironmentsUseCase { }, }); } - }; - - const filteredEnvironmentsIds = (await EnvironmentRepository.findAll(filterQueryBuilder)).map(({ id }) => id); - // If no environments match the filter, return an empty result - if (filteredEnvironmentsIds.length === 0) { - return { - count: 0, - environments: [], - }; } - fetchQueryBuilder.where('id').oneOf(filteredEnvironmentsIds); } - fetchQueryBuilder.include({ association: 'runs' }); - fetchQueryBuilder.include({ association: 'historyItems' }); - const { count, rows } = await EnvironmentRepository.findAndCountAll(fetchQueryBuilder); + queryBuilder.include({ association: 'runs' }); + queryBuilder.include({ association: 'historyItems' }); + const { count, rows } = await EnvironmentRepository.findAndCountAll(queryBuilder); return { count, environments: rows.map((environment) => environmentAdapter.toEntity(environment)), diff --git a/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js b/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js index 4315cf9e1a..f69ed2de34 100644 --- a/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js +++ b/lib/usecases/lhcFill/GetAllLhcFillsUseCase.js @@ -24,6 +24,7 @@ const { ApiConfig } = require('../../config/index.js'); const { RunDefinition } = require('../../domain/enums/RunDefinition.js'); const { unpackNumberRange } = require('../../utilities/rangeUtils.js'); const { splitStringToStringsTrimmed } = require('../../utilities/stringUtils.js'); +const { setTimeRangeQuery } = require('../../utilities/setTimeRangeQuery.js'); /** * GetAllLhcFillsUseCase @@ -54,15 +55,11 @@ class GetAllLhcFillsUseCase { } if (stableBeamsStart) { - const from = stableBeamsStart.from !== undefined ? stableBeamsStart.from : 0; - const to = stableBeamsStart.to !== undefined ? stableBeamsStart.to : new Date().getTime(); - queryBuilder.where('stableBeamsStart').between(from, to); + setTimeRangeQuery(stableBeamsStart, 'stableBeamsStart', queryBuilder); } if (stableBeamsEnd) { - const from = stableBeamsEnd.from !== undefined ? stableBeamsEnd.from : 0; - const to = stableBeamsEnd.to !== undefined ? stableBeamsEnd.to : new Date().getTime(); - queryBuilder.where('stableBeamsEnd').between(from, to); + setTimeRangeQuery(stableBeamsEnd, 'stableBeamsEnd', queryBuilder); } if (fillNumbers) { diff --git a/lib/usecases/log/GetAllLogsUseCase.js b/lib/usecases/log/GetAllLogsUseCase.js index b1f7ea72b5..c70fabfb31 100644 --- a/lib/usecases/log/GetAllLogsUseCase.js +++ b/lib/usecases/log/GetAllLogsUseCase.js @@ -29,6 +29,7 @@ const { ApiConfig } = require('../../config/index.js'); const { Op } = require('sequelize'); const { dataSource } = require('../../database/DataSource.js'); const { checkForFilterExclusion } = require('../common/checkForFilterExclusion.js'); +const { setTimeRangeQuery } = require('../../utilities/setTimeRangeQuery.js'); /** * Apply the given filter on the given query builder @@ -39,7 +40,19 @@ const { checkForFilterExclusion } = require('../common/checkForFilterExclusion.j * @return {Promise} resolves once the filter has been applied */ const applyFilter = async (dataSource, queryBuilder, filter) => { - const { title, content, author, created, origin, parentLog, rootLog, rootOnly } = filter; + const { + title, + content, + author, + created, + origin, + parentLog, + rootLog, + rootOnly, + runNumbers, + environmentIds, + fillNumbers, + } = filter; if (title) { queryBuilder.where('title').substring(title); @@ -73,9 +86,7 @@ const applyFilter = async (dataSource, queryBuilder, filter) => { } if (created) { - const from = created.from !== undefined ? created.from : 0; - const to = created.to !== undefined ? created.to : new Date().getTime(); - queryBuilder.where('createdAt').between(from, to); + setTimeRangeQuery(created, 'createdAt', queryBuilder); } if (origin) { @@ -112,74 +123,47 @@ const applyFilter = async (dataSource, queryBuilder, filter) => { queryBuilder.where('id').oneOf(...logIds); } - if (filter.run?.values?.length > 0) { + if (runNumbers) { const runQueryBuilder = dataSource.createQueryBuilder(); runQueryBuilder.include({ association: 'run', - where: { runNumber: { [Op.in]: filter.run.values } }, + where: { runNumber: { [Op.in]: runNumbers } }, }).orderBy('logId', 'asc'); - let logRuns; - switch (filter.run.operation) { - case 'and': - logRuns = await LogRunsRepository - .findAllAndGroup(runQueryBuilder); - logRuns = logRuns - .filter((logRun) => filter.run.values.every((runNumber) => logRun.runNumbers.includes(runNumber))); - break; - case 'or': - logRuns = await LogRunsRepository - .findAll(runQueryBuilder); - break; - } + let logRuns = await LogRunsRepository.findAllAndGroup(runQueryBuilder); + logRuns = logRuns.filter((logRun) => runNumbers.every((runNumber) => logRun.runNumbers.includes(runNumber))); const logIds = logRuns.map((logRun) => logRun.logId); queryBuilder.where('id').oneOf(...logIds); } - if (filter.lhcFills?.values?.length > 0) { + if (fillNumbers) { const logLhcFillQueryBuilder = dataSource.createQueryBuilder(); logLhcFillQueryBuilder.include({ association: 'lhcFill', - where: { fill_number: { [Op.in]: filter.lhcFills.values } }, + where: { fill_number: { [Op.in]: fillNumbers } }, }).orderBy('logId', 'asc'); - let logLhcFills; - switch (filter.lhcFills.operation) { - case 'and': - logLhcFills = await LogLhcFillsRepository.findAllAndGroup(logLhcFillQueryBuilder); - logLhcFills = logLhcFills - .filter((logLhcFill) => filter.lhcFills.values.every((fillNumber) => logLhcFill.fillNumbers.includes(fillNumber))); - break; - case 'or': - logLhcFills = await LogLhcFillsRepository.findAll(logLhcFillQueryBuilder); - break; - } + let logLhcFills = await LogLhcFillsRepository.findAllAndGroup(logLhcFillQueryBuilder); + logLhcFills = logLhcFills.filter((logLhcFill) => + fillNumbers.every((fillNumber) => logLhcFill.fillNumbers.includes(fillNumber))); const logIds = logLhcFills.map((logLhcFill) => logLhcFill.logId); queryBuilder.where('id').oneOf(...logIds); } - if (filter.environments?.values?.length > 0) { - const validEnvironments = await EnvironmentRepository.findAll({ where: { id: { [Op.in]: filter.environments.values } } }); + if (environmentIds) { + const validEnvironments = await EnvironmentRepository.findAll({ where: { id: { [Op.in]: environmentIds } } }); const logEnvironmentQueryBuilder = dataSource.createQueryBuilder() .where('environmentId') .oneOf(...validEnvironments.map(({ id }) => id)) .orderBy('logId', 'asc'); - let logIds; - switch (filter.environments.operation) { - case 'and': - logIds = groupByProperty(await LogEnvironmentsRepository.findAll(logEnvironmentQueryBuilder), 'logId') - .filter(({ values }) => validEnvironments.every((env) => values.some((item) => item.environmentId === env.id))) - .map(({ index }) => index); - break; - case 'or': - logIds = (await LogEnvironmentsRepository.findAll(logEnvironmentQueryBuilder)).map(({ logId }) => logId); - break; - } + const logIds = groupByProperty(await LogEnvironmentsRepository.findAll(logEnvironmentQueryBuilder), 'logId') + .filter(({ values }) => validEnvironments.every((env) => values.some((item) => item.environmentId === env.id))) + .map(({ index }) => index); queryBuilder.where('id').oneOf(...logIds); } diff --git a/lib/usecases/run/GetAllRunsUseCase.js b/lib/usecases/run/GetAllRunsUseCase.js index df1b5f7f5b..ae0b14d071 100644 --- a/lib/usecases/run/GetAllRunsUseCase.js +++ b/lib/usecases/run/GetAllRunsUseCase.js @@ -25,6 +25,7 @@ const { qcFlagSummaryService } = require('../../server/services/qualityControlFl const { DetectorType } = require('../../domain/enums/DetectorTypes.js'); const { unpackNumberRange } = require('../../utilities/rangeUtils.js'); const { splitStringToStringsTrimmed } = require('../../utilities/stringUtils.js'); +const { setTimeRangeQuery } = require('../../utilities/setTimeRangeQuery.js'); /** * GetAllRunsUseCase @@ -81,7 +82,7 @@ class GetAllRunsUseCase { inelasticInteractionRateAtMid, inelasticInteractionRateAtEnd, gaq, - detectorsQc, + detectorsQcNotBadFraction, beamModes, } = filter; @@ -151,21 +152,15 @@ class GetAllRunsUseCase { } if (o2start) { - const from = o2start.from !== undefined ? o2start.from : 0; - const to = o2start.to !== undefined ? o2start.to : new Date().getTime(); - filteringQueryBuilder.where('timeO2Start').between(from, to); + setTimeRangeQuery(o2start, 'timeO2Start', filteringQueryBuilder); } if (o2end) { - const from = o2end.from !== undefined ? o2end.from : 0; - const to = o2end.to !== undefined ? o2end.to : new Date().getTime(); - filteringQueryBuilder.where('timeO2End').between(from, to); + setTimeRangeQuery(o2end, 'timeO2End', filteringQueryBuilder); } if (updatedAt) { - const from = updatedAt.from ?? 0; - const to = updatedAt.to ?? new Date().getTime(); - filteringQueryBuilder.where('updatedAt').between(from, to); + setTimeRangeQuery(updatedAt, 'updatedAt', filteringQueryBuilder); } if (triggerValues) { @@ -345,13 +340,16 @@ class GetAllRunsUseCase { } if (dataPassIds) { + const whereDataPassIds = dataPassIds.length === 1 + ? { id: { [Op.eq]: dataPassIds[0] } } + : { id: { [Op.in]: dataPassIds } }; const runNumbers = (await RunRepository.findAll({ attributes: ['runNumber'], raw: true, include: [ { association: 'dataPass', - where: { id: { [Op.in]: dataPassIds } }, + where: whereDataPassIds, }, ], })).map(({ runNumber }) => runNumber); @@ -391,28 +389,21 @@ class GetAllRunsUseCase { } } - if (detectorsQc) { + if (detectorsQcNotBadFraction) { const [dataPassId] = dataPassIds ?? []; const [simulationPassId] = simulationPassIds ?? []; const [lhcPeriodId] = lhcPeriodIds ?? []; - const { mcReproducibleAsNotBad } = detectorsQc; - delete detectorsQc.mcReproducibleAsNotBad; + const { mcReproducibleAsNotBad } = detectorsQcNotBadFraction; + delete detectorsQcNotBadFraction.mcReproducibleAsNotBad; - const dplDetectorIds = Object.keys(detectorsQc).map((id) => parseInt(id.slice(1), 10)); + const dplDetectorIds = Object.keys(detectorsQcNotBadFraction).map((id) => parseInt(id.slice(1), 10)); if (dplDetectorIds.length > 0) { - const qcSummary = await qcFlagSummaryService.getSummary( - { - dataPassId, - simulationPassId, - lhcPeriodId, - dplDetectorIds, - }, - { mcReproducibleAsNotBad }, - ); + const scope = { dataPassId, simulationPassId, lhcPeriodId, dplDetectorIds }; + const qcSummary = await qcFlagSummaryService.getSummary(scope, { mcReproducibleAsNotBad }); const runNumbers = Object.entries(qcSummary) .filter(([_, runSummary]) => { - const mask = Object.entries(detectorsQc).map(([prefixedDetectorId, { notBadFraction: { operator, limit } }]) => { + const mask = Object.entries(detectorsQcNotBadFraction).map(([prefixedDetectorId, { operator, limit }]) => { const dplDetectorId = parseInt(prefixedDetectorId.slice(1), 10); if (!(dplDetectorId in runSummary)) { return false; @@ -534,15 +525,17 @@ class GetAllRunsUseCase { const qcFlagsAssociationDef = { association: 'qcFlags', required: false, + separate: true, + order: [['from', 'ASC']], where: { [Op.and]: [ { deleted: false }, sequelize.literal(`( - \`qcFlags->detector\`.\`type\` not in (${detectorTypesOfNoneExportableAnonymousFlagsEscaped}) - OR \`qcFlags->createdBy\`.\`name\` != 'Anonymous' + \`detector\`.\`type\` not in (${detectorTypesOfNoneExportableAnonymousFlagsEscaped}) + OR \`createdBy\`.\`name\` != 'Anonymous' )`), ] }, include: [ - { association: 'effectivePeriods', required: true }, + { association: 'effectivePeriods', required: true, separate: true }, { association: 'flagType' }, { association: 'detector', required: true }, { association: 'createdBy' }, @@ -558,13 +551,7 @@ class GetAllRunsUseCase { } else { qcFlagsAssociationDef.include.push({ association: 'dataPasses', required: false }); qcFlagsAssociationDef.include.push({ association: 'simulationPasses', required: false }); - qcFlagsAssociationDef.where[Op.or] = [ - { '$qcFlags.id$': null }, - { - '$qcFlags.dataPasses.id$': null, - '$qcFlags.simulationPasses.id$': null, - }, - ]; + qcFlagsAssociationDef.where[Op.and].push(sequelize.literal('(`dataPasses`.`id` IS NULL AND `simulationPasses`.`id` IS NULL)')); fetchQueryBuilder.include(qcFlagsAssociationDef); } diff --git a/lib/utilities/setTimeRangeQuery.js b/lib/utilities/setTimeRangeQuery.js new file mode 100644 index 0000000000..ced721ce0f --- /dev/null +++ b/lib/utilities/setTimeRangeQuery.js @@ -0,0 +1,25 @@ +/** + * @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. + */ + +/** + * Function that sets a time range in a QueryBuilder. + * + * @param {object} timerange an object that defines a time range to add to the query + * @param {number} timerange.from the lower bound of the time range + * @param {number} timerange.to the upper bound of the time range + * @param {string} attribute the model attribute for which the range will be set + * @param {QueryBuilder} queryBuilder queryBuider instance in which the time range will be set. + * @returns {void} + */ +exports.setTimeRangeQuery = ({ from = 0, to = Date.now() }, attribute, queryBuilder) => + queryBuilder.where(attribute).between(from, to); diff --git a/package-lock.json b/package-lock.json index 14d73124ef..ba5c5640fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aliceo2/bookkeeping", - "version": "1.17.1", + "version": "1.18.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aliceo2/bookkeeping", - "version": "1.17.1", + "version": "1.18.1", "bundleDependencies": [ "@aliceo2/web-ui", "@grpc/grpc-js", @@ -19,45 +19,43 @@ "mariadb", "multer", "node-fetch", - "protobufjs", "sequelize", "umzug" ], "dependencies": { - "@aliceo2/web-ui": "2.9.0", - "@grpc/grpc-js": "1.14.0", + "@aliceo2/web-ui": "2.11.0", + "@grpc/grpc-js": "1.14.4", "@grpc/proto-loader": "0.8.0", "cls-hooked": "4.2.2", - "d3": "7.8.5", + "d3": "7.9.0", "deepmerge": "4.3.0", - "dotenv": "17.2.0", - "joi": "18.0.0", + "dotenv": "17.4.2", + "joi": "18.2.1", "kafkajs": "2.2.0", "mariadb": "3.0.0", "mkdirp": "3.0.1", - "multer": "2.0.2", + "multer": "2.2.0", "node-fetch": "3.3.1", - "protobufjs": "8.0.0", - "sequelize": "6.37.0", + "sequelize": "6.37.8", "umzug": "3.8.2" }, "devDependencies": { "@eslint/js": "^9.39.1", "@stylistic/eslint-plugin-js": "^4.4.1", - "@types/d3": "7.4.0", + "@types/d3": "7.4.3", "chai": "4.5.0", "date-and-time": "3.6.0", "eslint": "^9.37.0", - "eslint-plugin-jsdoc": "^62.5.0", - "globals": "^17.3.0", - "js-yaml": "4.1.1", + "eslint-plugin-jsdoc": "^62.9.0", + "globals": "^17.6.0", + "js-yaml": "4.2.0", "mocha": "11.7.0", "nodemon": "3.1.3", - "nyc": "17.1.0", - "puppeteer": "24.37.2", + "nyc": "18.0.0", + "puppeteer": "25.1.0", "puppeteer-to-istanbul": "1.4.0", "sequelize-cli": "6.6.0", - "sinon": "21.0.0", + "sinon": "22.0.0", "supertest": "7.2.2" }, "engines": { @@ -74,47 +72,45 @@ } }, "node_modules/@aliceo2/web-ui": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@aliceo2/web-ui/-/web-ui-2.9.0.tgz", - "integrity": "sha512-bPSpI/xXUPNShKF2muu5IIKgwZSfS37toneKBCCentw1seeTEsZBIo3kavdrb3v5SXOPkGeZiS6n7ixrkRKBEw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@aliceo2/web-ui/-/web-ui-2.11.0.tgz", + "integrity": "sha512-ISVPe8BqekVsNlIJFTsqrw1c2nhSxDDUVgvOsio2ZKsRRBlV5ndZkQFbAXSjpMabswKi+BCE466TG0Oagc3fuQ==", "inBundle": true, "license": "GPL-3.0", "dependencies": { - "express": "^4.22.1", - "helmet": "^8.1.0", - "jsonwebtoken": "^9.0.0", - "kafkajs": "^2.2.0", + "express": "4.22.2", + "helmet": "8.1.0", + "jsonwebtoken": "9.0.3", + "kafkajs": "2.2.4", "mithril": "1.1.7", - "mysql": "^2.18.1", - "openid-client": "^5.6.0", - "protobufjs": "^7.5.0", + "openid-client": "5.6.5", + "protobufjs": "8.4.2", "winston": "3.19.0", - "ws": "^8.19.0" + "ws": "8.21.0" }, "engines": { "node": ">= 22.x" } }, + "node_modules/@aliceo2/web-ui/node_modules/kafkajs": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/kafkajs/-/kafkajs-2.2.4.tgz", + "integrity": "sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@aliceo2/web-ui/node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.2.tgz", + "integrity": "sha512-64rfNzkWOZAIazXzpBFPWq6F9up6gMvTzjE2oWIzApx2N/dqVUEE7+bCn2+40780dFVtKOUab8QfxJ6KJDWbqA==", "hasInstallScript": true, "inBundle": true, "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -550,17 +546,17 @@ } }, "node_modules/@es-joy/jsdoccomment": { - "version": "0.83.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.83.0.tgz", - "integrity": "sha512-e1MHSEPJ4m35zkBvNT6kcdeH1SvMaJDsPC3Xhfseg3hvF50FUE3f46Yn36jgbrPYYXezlWUQnevv23c+lx2MCA==", + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.86.0.tgz", + "integrity": "sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==", "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.8", - "@typescript-eslint/types": "^8.53.1", - "comment-parser": "1.4.5", + "@typescript-eslint/types": "^8.58.0", + "comment-parser": "1.4.6", "esquery": "^1.7.0", - "jsdoc-type-pratt-parser": "~7.1.0" + "jsdoc-type-pratt-parser": "~7.2.0" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -766,9 +762,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.0.tgz", - "integrity": "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg==", + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "inBundle": true, "license": "Apache-2.0", "dependencies": { @@ -1387,25 +1383,28 @@ "inBundle": true }, "node_modules/@puppeteer/browsers": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.12.0.tgz", - "integrity": "sha512-Xuq42yxcQJ54ti8ZHNzF5snFvtpgXzNToJ1bXUGQRaiO8t+B6UM8sTUJfvV+AJnqtkJU/7hdy6nbKyA12aHtRw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.4.tgz", + "integrity": "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "debug": "^4.4.3", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.5.0", - "semver": "^7.7.3", - "tar-fs": "^3.1.1", + "modern-tar": "^0.7.6", "yargs": "^17.7.2" }, "bin": { - "browsers": "lib/cjs/main-cli.js" + "browsers": "lib/main-cli.js" }, "engines": { - "node": ">=18" + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + } } }, "node_modules/@puppeteer/browsers/node_modules/cliui": { @@ -1423,44 +1422,6 @@ "node": ">=12" } }, - "node_modules/@puppeteer/browsers/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@puppeteer/browsers/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@puppeteer/browsers/node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -1546,9 +1507,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1556,14 +1517,13 @@ } }, "node_modules/@sinonjs/samsam": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", - "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz", + "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "lodash.get": "^4.4.2", "type-detect": "^4.1.0" } }, @@ -1579,9 +1539,9 @@ } }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "inBundle": true, "license": "MIT" }, @@ -1613,13 +1573,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/argparse": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", @@ -1633,10 +1586,11 @@ "dev": true }, "node_modules/@types/d3": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.0.tgz", - "integrity": "sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA==", + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", "dev": true, + "license": "MIT", "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", @@ -1938,21 +1892,10 @@ "integrity": "sha512-aqayTNmeWrZcvnG2MG9eGYI6b7S5fl+yKgPs6bAjOTwPS316R5SxBGKvtSExfyoJU7pIeHJfsHI0Ji41RVMkvQ==", "inBundle": true }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", "dev": true, "license": "MIT", "engines": { @@ -1984,10 +1927,11 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -2004,21 +1948,12 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/aggregate-error": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz", - "integrity": "sha512-quoaXsZ9/BLNae5yiNoUz+Nhkwz83GhWwtYFglcjEQB2NDHCIpApbqXxIFnm4Pq/Nvhrsq5sYJFyohrrxnTGAA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", "dev": true, + "license": "MIT", "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" @@ -2028,10 +1963,11 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2086,8 +2022,9 @@ "node_modules/archy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", - "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", - "dev": true + "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", + "dev": true, + "license": "MIT" }, "node_modules/are-docs-informative": { "version": "0.0.2", @@ -2130,19 +2067,6 @@ "node": "*" } }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2178,143 +2102,12 @@ "node": ">= 4.0.0" } }, - "node_modules/b4a": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", - "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, "node_modules/balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bare-fs": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.3.tgz", - "integrity": "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", - "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, - "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" - } - }, - "node_modules/basic-ftp": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", - "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/bignumber.js": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz", - "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==", - "inBundle": true, - "engines": { - "node": "*" - } - }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -2331,9 +2124,9 @@ "dev": true }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "inBundle": true, "license": "MIT", "dependencies": { @@ -2345,7 +2138,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -2416,21 +2209,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "inBundle": true + "inBundle": true, + "license": "BSD-3-Clause" }, "node_modules/buffer-from": { "version": "1.1.2", @@ -2675,15 +2459,18 @@ } }, "node_modules/chromium-bidi": { - "version": "13.1.1", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-13.1.1.tgz", - "integrity": "sha512-zB9MpoPd7VJwjowQqiW3FKOvQwffFMjQ8Iejp5ZW+sJaKLRhZX1sTxzl3Zt22TDB4zP0OOqs8lRoY7eAW5geyQ==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz", + "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", "dev": true, "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, "peerDependencies": { "devtools-protocol": "*" } @@ -2693,6 +2480,7 @@ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -2826,9 +2614,9 @@ "dev": true }, "node_modules/comment-parser": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.5.tgz", - "integrity": "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==", + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.6.tgz", + "integrity": "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==", "dev": true, "license": "MIT", "engines": { @@ -2972,38 +2760,6 @@ "dev": true, "license": "MIT" }, - "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "inBundle": true - }, - "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", - "dev": true, - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3030,10 +2786,11 @@ } }, "node_modules/d3": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/d3/-/d3-7.8.5.tgz", - "integrity": "sha512-JgoahDG51ncUfJu6wX/1vWQEqOflgXyl4MaHqlcSruTez7yhaRKR9i8VjjcQGeS2en/jnFivXuaIMnseMMt0XA==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", "inBundle": true, + "license": "ISC", "dependencies": { "d3-array": "3", "d3-axis": "3", @@ -3525,21 +3282,6 @@ "node": ">=8" } }, - "node_modules/degenerator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", - "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/delaunator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.0.tgz", @@ -3590,9 +3332,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1566079", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", - "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", + "version": "0.0.1624250", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz", + "integrity": "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==", "dev": true, "license": "BSD-3-Clause" }, @@ -3618,9 +3360,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.0.tgz", - "integrity": "sha512-Q4sgBT60gzd0BB0lSyYD3xM4YxrXA9y4uBDof1JNYGzOXrQdQ6yX+7XIAqoFOGQFOTK1D3Hts5OllpxMDZFONQ==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -3662,6 +3404,7 @@ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", "inBundle": true, + "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" } @@ -3754,40 +3497,6 @@ "node": ">= 0.8" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/error-ex/node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3909,28 +3618,6 @@ "inBundle": true, "license": "MIT" }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, "node_modules/eslint": { "version": "9.39.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", @@ -3992,24 +3679,24 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "62.5.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.5.0.tgz", - "integrity": "sha512-D+1haMVDzW/ZMoPwOnsbXCK07rJtsq98Z1v+ApvDKxSzYTTcPgmFc/nyUDCGmxm2cP7g7hszyjYHO7Zodl/43w==", + "version": "62.9.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.9.0.tgz", + "integrity": "sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@es-joy/jsdoccomment": "~0.83.0", + "@es-joy/jsdoccomment": "~0.86.0", "@es-joy/resolve.exports": "1.2.0", "are-docs-informative": "^0.0.2", - "comment-parser": "1.4.5", + "comment-parser": "1.4.6", "debug": "^4.4.3", "escape-string-regexp": "^4.0.0", - "espree": "^11.1.0", + "espree": "^11.2.0", "esquery": "^1.7.0", "html-entities": "^2.6.0", "object-deep-merge": "^2.0.0", "parse-imports-exports": "^0.2.4", - "semver": "^7.7.3", + "semver": "^7.7.4", "spdx-expression-parse": "^4.0.0", "to-valid-identifier": "^1.0.0" }, @@ -4017,7 +3704,7 @@ "node": "^20.19.0 || ^22.13.0 || >=24" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-plugin-jsdoc/node_modules/debug": { @@ -4051,9 +3738,9 @@ } }, "node_modules/eslint-plugin-jsdoc/node_modules/eslint-visitor-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", - "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4064,15 +3751,15 @@ } }, "node_modules/eslint-plugin-jsdoc/node_modules/espree": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.0.tgz", - "integrity": "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.0" + "eslint-visitor-keys": "^5.0.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" @@ -4089,9 +3776,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-jsdoc/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -4329,26 +4016,16 @@ "es5-ext": "~0.10.14" } }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "inBundle": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -4367,7 +4044,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -4422,65 +4099,12 @@ "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==", "dev": true }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extract-zip/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/extract-zip/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -4525,16 +4149,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -4664,10 +4278,11 @@ } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" }, "node_modules/fn.name": { "version": "1.1.0", @@ -4681,6 +4296,7 @@ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", "dev": true, + "license": "ISC", "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^3.0.2" @@ -4690,17 +4306,17 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4896,108 +4512,106 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "pump": "^3.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/get-uri": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", - "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", - "dev": true, - "license": "MIT", + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "inBundle": true, "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" + "is-glob": "^4.0.1" }, "engines": { - "node": ">= 14" + "node": ">= 6" } }, - "node_modules/get-uri/node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", - "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14" + "node": "18 || 20 || >=22" } }, - "node_modules/get-uri/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": "18 || 20 || >=22" } }, - "node_modules/get-uri/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/glob/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, - "license": "MIT" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, - "node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "inBundle": true, + "node_modules/glob/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "is-glob": "^4.0.1" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">= 6" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/globals": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", - "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -5090,9 +4704,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "inBundle": true, "license": "MIT", "dependencies": { @@ -5165,84 +4779,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/http-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/http-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/https-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -5301,6 +4837,7 @@ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -5345,16 +4882,6 @@ "node": ">=12" } }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5475,16 +5002,11 @@ "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "inBundle": true - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5541,21 +5063,21 @@ } }, "node_modules/istanbul-lib-processinfo": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz", - "integrity": "sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-3.0.0.tgz", + "integrity": "sha512-P7nLXRRlo7Sqinty6lNa7+4o9jBUYGpqtejqCOZKfgXlRoxY/QArflcB86YO500Ahj4pDJEG34JjMRbQgePLnQ==", "dev": true, + "license": "ISC", "dependencies": { "archy": "^1.0.0", - "cross-spawn": "^7.0.0", - "istanbul-lib-coverage": "^3.0.0-alpha.1", - "make-dir": "^3.0.0", + "cross-spawn": "^7.0.3", + "istanbul-lib-coverage": "^3.2.0", "p-map": "^3.0.0", - "rimraf": "^3.0.0", - "uuid": "^3.3.3" + "rimraf": "^6.1.3", + "uuid": "^8.3.2" }, "engines": { - "node": ">=8" + "node": "20 || >=22" } }, "node_modules/istanbul-lib-report": { @@ -5662,9 +5184,9 @@ } }, "node_modules/joi": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.0.tgz", - "integrity": "sha512-fpbpXN/TD04Xz1/cCXzUR3ghDkhyiHjbzTILx3wNyKXIzQJ55uYAkUGWwhX72uHge/6MdFA/kp1ZUh35DlYmaA==", + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", + "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", "inBundle": true, "license": "BSD-3-Clause", "dependencies": { @@ -5674,17 +5196,18 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" + "@standard-schema/spec": "^1.1.0" }, "engines": { "node": ">= 20" } }, "node_modules/jose": { - "version": "4.15.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", - "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", "inBundle": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } @@ -5739,10 +5262,11 @@ } }, "node_modules/js-beautify/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -5773,10 +5297,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5792,9 +5326,9 @@ "dev": true }, "node_modules/jsdoc-type-pratt-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.1.0.tgz", - "integrity": "sha512-SX7q7XyCwzM/MEDCYz0l8GgGbJAACGFII9+WfNYr5SLEKukHWRy2Jk3iWRe7P+lpYJNs7oQ+OSei4JtKGUjd7A==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.2.0.tgz", + "integrity": "sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==", "dev": true, "license": "MIT", "engines": { @@ -5819,12 +5353,6 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5862,15 +5390,22 @@ } }, "node_modules/jsonwebtoken": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", - "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", "inBundle": true, + "license": "MIT", "dependencies": { - "jws": "^3.2.2", - "lodash": "^4.17.21", + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", "ms": "^2.1.1", - "semver": "^7.3.8" + "semver": "^7.5.4" }, "engines": { "node": ">=12", @@ -5881,16 +5416,15 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "inBundle": true + "inBundle": true, + "license": "MIT" }, "node_modules/jsonwebtoken/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "inBundle": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -5899,23 +5433,25 @@ } }, "node_modules/jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "inBundle": true, + "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "inBundle": true, + "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -5958,11 +5494,18 @@ "node": ">= 0.8.0" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } }, "node_modules/locate-path": { "version": "6.0.0", @@ -5997,12 +5540,46 @@ "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", "dev": true }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "dev": true, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "inBundle": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "inBundle": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "inBundle": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "inBundle": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "inBundle": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "inBundle": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -6011,6 +5588,13 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "inBundle": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -6254,10 +5838,11 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -6265,22 +5850,12 @@ "node": "*" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "inBundle": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -6477,13 +6052,13 @@ } }, "node_modules/mocha/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -6579,6 +6154,16 @@ "node": ">=12" } }, + "node_modules/modern-tar": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", @@ -6608,50 +6193,23 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "inBundle": true, "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" + "type-is": "^1.6.18" }, "engines": { "node": ">= 10.16.0" - } - }, - "node_modules/multer/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/mysql": { - "version": "2.18.1", - "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.18.1.tgz", - "integrity": "sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig==", - "inBundle": true, - "dependencies": { - "bignumber.js": "9.0.0", - "readable-stream": "2.3.7", - "safe-buffer": "5.1.2", - "sqlstring": "2.3.1" }, - "engines": { - "node": ">= 0.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/natural-compare": { @@ -6670,16 +6228,6 @@ "node": ">= 0.6" } }, - "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/next-tick": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", @@ -6832,9 +6380,9 @@ } }, "node_modules/nyc": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/nyc/-/nyc-17.1.0.tgz", - "integrity": "sha512-U42vQ4czpKa0QdI1hu950XuNhYqgoM+ZF1HT+VuUHL9hPfDPVvNQyltmMqdE9bUHMVa+8yNbc3QKTj8zQhlVxQ==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-18.0.0.tgz", + "integrity": "sha512-G5UyHinFkB1BxqGTrmZdB6uIYH0+v7ZnVssuflUDi+J+RhKWyAhRT1RCehBSI6jLFLuUUgFDyLt49mUtdO1XeQ==", "dev": true, "license": "ISC", "dependencies": { @@ -6847,11 +6395,11 @@ "find-up": "^4.1.0", "foreground-child": "^3.3.0", "get-package-type": "^0.1.0", - "glob": "^7.1.6", + "glob": "^13.0.6", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-hook": "^3.0.0", "istanbul-lib-instrument": "^6.0.2", - "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-processinfo": "^3.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^4.0.0", "istanbul-reports": "^3.0.2", @@ -6860,17 +6408,17 @@ "p-map": "^3.0.0", "process-on-spawn": "^1.0.0", "resolve-from": "^5.0.0", - "rimraf": "^3.0.0", + "rimraf": "^6.1.3", "signal-exit": "^3.0.2", - "spawn-wrap": "^2.0.0", - "test-exclude": "^6.0.0", + "spawn-wrap": "^3.0.0", + "test-exclude": "^8.0.0", "yargs": "^15.0.2" }, "bin": { "nyc": "bin/nyc.js" }, "engines": { - "node": ">=18" + "node": "20 || >=22" } }, "node_modules/nyc/node_modules/ansi-styles": { @@ -7058,15 +6606,6 @@ "node": ">=6" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "inBundle": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-deep-merge": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.0.tgz", @@ -7079,6 +6618,7 @@ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", "inBundle": true, + "license": "MIT", "engines": { "node": ">= 6" } @@ -7097,10 +6637,11 @@ } }, "node_modules/oidc-token-hash": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", - "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", "inBundle": true, + "license": "MIT", "engines": { "node": "^10.13.0 || >=12.0.0" } @@ -7138,12 +6679,13 @@ } }, "node_modules/openid-client": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz", - "integrity": "sha512-PtrWsY+dXg6y8mtMPyL/namZSYVz8pjXz3yJiBNZsEdCnu9miHLB4ELVC85WvneMKo2Rg62Ay7NkuCpM0bgiLQ==", + "version": "5.6.5", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.5.tgz", + "integrity": "sha512-5P4qO9nGJzB5PI0LFlhj4Dzg3m4odt0qsJTfyEtZyOlkgpILwEioOhVVJOrS1iVH494S4Ee5OCjjg6Bf5WOj3w==", "inBundle": true, + "license": "MIT", "dependencies": { - "jose": "^4.15.1", + "jose": "^4.15.5", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" @@ -7204,6 +6746,7 @@ "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", "dev": true, + "license": "MIT", "dependencies": { "aggregate-error": "^3.0.0" }, @@ -7220,65 +6763,6 @@ "node": ">=6" } }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", - "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/pac-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", - "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", - "dev": true, - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/package-hash": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", @@ -7319,25 +6803,7 @@ "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", "dev": true, "dependencies": { - "parse-statements": "1.0.11" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "parse-statements": "1.0.11" } }, "node_modules/parse-statements": { @@ -7365,15 +6831,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -7414,9 +6871,9 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "inBundle": true, "license": "MIT" }, @@ -7429,13 +6886,6 @@ "node": "*" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/pg-connection-string": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", @@ -7552,12 +7002,6 @@ "node": ">= 0.8.0" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "inBundle": true - }, "node_modules/process-on-spawn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", @@ -7570,47 +7014,12 @@ "node": ">=8" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "dev": true }, - "node_modules/protobufjs": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.0.tgz", - "integrity": "sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==", - "hasInstallScript": true, - "inBundle": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7625,68 +7034,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, - "license": "MIT" - }, "node_modules/pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -7699,17 +7046,6 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7720,71 +7056,45 @@ } }, "node_modules/puppeteer": { - "version": "24.37.2", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.37.2.tgz", - "integrity": "sha512-FV1W/919ve0y0oiS/3Rp5XY4MUNUokpZOH/5M4MMDfrrvh6T9VbdKvAHrAFHBuCxvluDxhjra20W7Iz6HJUcIQ==", + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.1.0.tgz", + "integrity": "sha512-7L6/0JM7XStK99lIL4xQySyNEXNfII6pk0BxkI5kKBTOhR7AsoQiv067YTsE/rIXxQiq9ajlO4WcqBjS/FWK1A==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.12.0", - "chromium-bidi": "13.1.1", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1566079", - "puppeteer-core": "24.37.2", - "typed-query-selector": "^2.12.0" + "@puppeteer/browsers": "3.0.4", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1624250", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.1.0", + "typed-query-selector": "^2.12.2" }, "bin": { - "puppeteer": "lib/cjs/puppeteer/node/cli.js" + "puppeteer": "lib/puppeteer/node/cli.js" }, "engines": { - "node": ">=18" + "node": ">=22.12.0" } }, "node_modules/puppeteer-core": { - "version": "24.37.2", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.2.tgz", - "integrity": "sha512-nN8qwE3TGF2vA/+xemPxbesntTuqD9vCGOiZL2uh8HES3pPzLX20MyQjB42dH2rhQ3W3TljZ4ZaKZ0yX/abQuw==", + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.1.0.tgz", + "integrity": "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.12.0", - "chromium-bidi": "13.1.1", - "debug": "^4.4.3", - "devtools-protocol": "0.0.1566079", - "typed-query-selector": "^2.12.0", - "webdriver-bidi-protocol": "0.4.0", - "ws": "^8.19.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer-core/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" + "@puppeteer/browsers": "3.0.4", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1624250", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=22.12.0" } }, - "node_modules/puppeteer-core/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/puppeteer-to-istanbul": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/puppeteer-to-istanbul/-/puppeteer-to-istanbul-1.4.0.tgz", @@ -7955,9 +7265,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "inBundle": true, "license": "BSD-3-Clause", "dependencies": { @@ -8025,21 +7335,6 @@ "node": ">= 0.8" } }, - "node_modules/readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "inBundle": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -8135,15 +7430,20 @@ } }, "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^7.1.3" + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" }, "bin": { - "rimraf": "bin.js" + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -8254,9 +7554,9 @@ "license": "MIT" }, "node_modules/sequelize": { - "version": "6.37.0", - "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.0.tgz", - "integrity": "sha512-MS6j6aXqWzB3fe9FhmfpQMgVC16bBdYroJCqIqR0l9M2ko8pZdKoi/0PiNWgMyFQDXUHxXyAOG3K07CbnOhteQ==", + "version": "6.37.8", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz", + "integrity": "sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==", "funding": [ { "type": "opencollective", @@ -8264,6 +7564,7 @@ } ], "inBundle": true, + "license": "MIT", "dependencies": { "@types/debug": "^4.1.8", "@types/validator": "^13.7.17", @@ -8396,15 +7697,6 @@ "node": ">=10" } }, - "node_modules/sequelize/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "inBundle": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -8586,110 +7878,32 @@ } }, "node_modules/sinon": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.0.tgz", - "integrity": "sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.0.0.tgz", + "integrity": "sha512-sq/6DpdXOrLyfbKlXLg/Usc7xu8YXPeLkOFZRvA3bNUSA2lhbrZ06yuXbH1fkzBPCbz9O10+7hznzUsjaYNm0Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^13.0.5", - "@sinonjs/samsam": "^8.0.1", - "diff": "^7.0.0", - "supports-color": "^7.2.0" + "@sinonjs/fake-timers": "^15.4.0", + "@sinonjs/samsam": "^10.0.2", + "diff": "^9.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/sinon" } }, - "node_modules/sinon/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/sinon/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/socks-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/sinon/node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, + "license": "BSD-3-Clause", "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=0.3.1" } }, - "node_modules/socks-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8700,15 +7914,17 @@ } }, "node_modules/spawn-wrap": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", - "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-3.0.0.tgz", + "integrity": "sha512-z+s5vv4KzFPJVddGab0xX2n7kQPGMdNUX5l9T8EJqsXdKTWpcxmAqWHpsgHEXoC1taGBCc7b79bi62M5kdbrxQ==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { + "cross-spawn": "^7.0.6", "foreground-child": "^2.0.0", "is-windows": "^1.0.2", "make-dir": "^3.0.0", - "rimraf": "^3.0.0", + "rimraf": "^6.1.3", "signal-exit": "^3.0.2", "which": "^2.0.1" }, @@ -8744,15 +7960,6 @@ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "inBundle": true }, - "node_modules/sqlstring": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz", - "integrity": "sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A=", - "inBundle": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/stack-chain": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", @@ -8788,18 +7995,6 @@ "node": ">=10.0.0" } }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -9003,55 +8198,58 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" + "engines": { + "node": "20 || >=22" } }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, + "license": "MIT", "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" } }, - "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "Apache-2.0", + "license": "BlueOak-1.0.0", "dependencies": { - "b4a": "^1.6.4" + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/text-hex": { @@ -9138,13 +8336,6 @@ "node": ">= 14.0.0" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, "node_modules/type": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", @@ -9196,9 +8387,9 @@ } }, "node_modules/typed-query-selector": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", - "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", "dev": true, "license": "MIT" }, @@ -9328,13 +8519,13 @@ } }, "node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", - "dev": true, + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "inBundle": true, + "license": "MIT", "bin": { - "uuid": "bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/v8-to-istanbul": { @@ -9375,9 +8566,9 @@ } }, "node_modules/webdriver-bidi-protocol": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.0.tgz", - "integrity": "sha512-U9VIlNRrq94d1xxR9JrCEAx5Gv/2W7ERSv8oWRoNe/QYbfccS0V3h/H6qeNeCRJxXGMhhnkqvwNrvPAYeuP9VA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", "dev": true, "license": "Apache-2.0" }, @@ -9610,9 +8801,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "inBundle": true, "license": "MIT", "engines": { @@ -9631,15 +8822,6 @@ } } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "inBundle": true, - "engines": { - "node": ">=0.4" - } - }, "node_modules/y18n": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz", @@ -9727,17 +8909,6 @@ "node": ">=10" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 1020f607da..87e13806bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aliceo2/bookkeeping", - "version": "1.17.1", + "version": "1.18.1", "author": "ALICEO2", "repository": { "type": "git", @@ -26,21 +26,20 @@ "node": ">= 22.x" }, "dependencies": { - "@aliceo2/web-ui": "2.9.0", - "@grpc/grpc-js": "1.14.0", + "@aliceo2/web-ui": "2.11.0", + "@grpc/grpc-js": "1.14.4", "@grpc/proto-loader": "0.8.0", "cls-hooked": "4.2.2", - "d3": "7.8.5", + "d3": "7.9.0", "deepmerge": "4.3.0", - "dotenv": "17.2.0", - "joi": "18.0.0", + "dotenv": "17.4.2", + "joi": "18.2.1", "kafkajs": "2.2.0", "mariadb": "3.0.0", "mkdirp": "3.0.1", - "multer": "2.0.2", + "multer": "2.2.0", "node-fetch": "3.3.1", - "protobufjs": "8.0.0", - "sequelize": "6.37.0", + "sequelize": "6.37.8", "umzug": "3.8.2" }, "files": [ @@ -51,20 +50,20 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@stylistic/eslint-plugin-js": "^4.4.1", - "@types/d3": "7.4.0", + "@types/d3": "7.4.3", "chai": "4.5.0", "date-and-time": "3.6.0", "eslint": "^9.37.0", - "eslint-plugin-jsdoc": "^62.5.0", - "globals": "^17.3.0", - "js-yaml": "4.1.1", + "eslint-plugin-jsdoc": "^62.9.0", + "globals": "^17.6.0", + "js-yaml": "4.2.0", "mocha": "11.7.0", "nodemon": "3.1.3", - "nyc": "17.1.0", - "puppeteer": "24.37.2", + "nyc": "18.0.0", + "puppeteer": "25.1.0", "puppeteer-to-istanbul": "1.4.0", "sequelize-cli": "6.6.0", - "sinon": "21.0.0", + "sinon": "22.0.0", "supertest": "7.2.2" }, "bundleDependencies": [ @@ -79,7 +78,6 @@ "mariadb", "multer", "node-fetch", - "protobufjs", "sequelize", "umzug" ] diff --git a/test/api/dataPasses.test.js b/test/api/dataPasses.test.js index 3ce5947435..a092118267 100644 --- a/test/api/dataPasses.test.js +++ b/test/api/dataPasses.test.js @@ -296,13 +296,13 @@ module.exports = () => { }); }); it('should successfully include TEST productions', async () => { - const response = await request(server).get('/api/dataPasses?filter[lhcPeriodIds][]=2&filter[include][byName]=test'); + const response = await request(server).get('/api/dataPasses?filter[lhcPeriodIds][]=2&filter[permittedNonPhysicsNames]=test'); expect(response.status).to.be.equal(200); const { data } = await response.body; expect(data.map(({ name }) => name)).to.have.all.members(['LHC22b_apass1', 'LHC22b_skimming','LHC22b_apass2_skimmed', 'LHC22b_test']); }); it('should successfully include DEBUG productions', async () => { - const response = await request(server).get('/api/dataPasses?filter[lhcPeriodIds][]=2&filter[include][byName]=debug'); + const response = await request(server).get('/api/dataPasses?filter[lhcPeriodIds][]=2&filter[permittedNonPhysicsNames]=debug'); expect(response.status).to.be.equal(200); const { data } = await response.body; expect(data.map(({ name }) => name)).to.have.all.members(['LHC22b_apass1', 'LHC22b_skimming','LHC22b_apass2_skimmed', 'LHC22b_debug']); diff --git a/test/api/logs.test.js b/test/api/logs.test.js index ada81f070e..9d2f774ad3 100644 --- a/test/api/logs.test.js +++ b/test/api/logs.test.js @@ -233,7 +233,7 @@ module.exports = () => { }); it('should successfully filter by run number', async () => { - const response = await request(server).get('/api/logs?filter[run][values]=1,2&filter[run][operation]=and'); + const response = await request(server).get('/api/logs?filter[runNumbers]=1,2'); expect(response.status).to.equal(200); expect(response.body.data).to.be.an('array'); @@ -244,6 +244,30 @@ module.exports = () => { } }); + it('should successfully filter by lhcFillNumber', async () => { + const response = await request(server).get('/api/logs?filter[fillNumbers]=1,4,6'); + expect(response.status).to.equal(200); + + expect(response.body.data).to.be.an('array'); + expect(response.body.data).to.lengthOf(1); + for (const { lhcFills } of response.body.data) { + const fillNumbers = lhcFills.map(({ fillNumber }) => fillNumber); + expect([1, 4, 6].every((fillNumber) => fillNumbers.includes(fillNumber))).to.be.true; + } + }); + + it('should successfully filter by EnvironmentIds', async () => { + const response = await request(server).get('/api/logs?filter[environmentIds]=Dxi029djX,eZF99lH6'); + expect(response.status).to.equal(200); + + expect(response.body.data).to.be.an('array'); + expect(response.body.data).to.lengthOf(1); + for (const { environments } of response.body.data) { + const environmentIds = environments.map(({ id }) => id); + expect(["Dxi029djX", "eZF99lH6"].every((environmentId) => environmentIds.includes(environmentId))).to.be.true; + } + }); + it('should successfully filter by content', async () => { const response = await request(server).get('/api/logs?filter[content]=particle'); expect(response.status).to.equal(200); @@ -256,6 +280,30 @@ module.exports = () => { } }); + it('should successfully filter by rootOnly', async () => { + const unfilteredResponse = await request(server).get('/api/logs'); + expect(unfilteredResponse.status).to.equal(200); + + // When a log has no rootLogId the logs adapter will set the row itself as the root log + let hasChildLogs = unfilteredResponse.body.data.some(({ rootLogId, id }) => rootLogId !== id); + expect(hasChildLogs).to.be.true; + + const filteredResponse = await request(server).get('/api/logs?filter[rootOnly]=true'); + expect(filteredResponse.status).to.equal(200); + + hasChildLogs = filteredResponse.body.data.every(({ rootLogId, id }) => rootLogId !== id); + expect(hasChildLogs).to.be.false; + }) + + it('should successfully ignore rootOnly filters if rootLog is provided', async () => { + const response = await request(server).get('/api/logs?filter[rootOnly]=true&filter[rootLog]=1'); + + expect(response.status).to.equal(200); + + expect(response.body.data).to.lengthOf(3); + expect(response.body.data.every(({ rootLogId, id }) => rootLogId !== id)).to.be.true; + }) + it('should return 400 if the author filter is left empty', (done) => { request(server) .get('/api/logs?filter[author]= ') @@ -606,6 +654,105 @@ module.exports = () => { expect(response.body.meta.page.totalCount).to.equal(totalNumber); }); + it('should support sorting, runs DESC', (done) => { + request(server) + .get('/api/logs?sort[runs]=desc') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + const { data } = res.body; + const logsWithRuns = data.filter(({ runs }) => runs.length > 0); + + for (let i = 0; i < logsWithRuns.length - 1; i++) { + const currentId = logsWithRuns[i].runs[0].id; + const nextId = logsWithRuns[i + 1].runs[0].id; + + expect(currentId).to.be.at.least(nextId); + } + + + done(); + }); + }); + + it('should support sorting, runs ASC', (done) => { + request(server) + .get('/api/logs?sort[runs]=asc') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + const { data } = res.body; + const logsWithRuns = data.filter(({ runs }) => runs.length > 0); + for (let i = 0; i < logsWithRuns.length - 1; i++) { + + const currentId = logsWithRuns[i].runs[0].id; + const nextId = logsWithRuns[i + 1].runs[0].id; + + expect(currentId).to.be.at.most(nextId); + } + + + + done(); + }); + }); + + it('should support sorting, environments DESC', (done) => { + request(server) + .get('/api/logs?sort[environments]=desc') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + const { data } = res.body; + const logsWithEnvs = data.filter(({ environments }) => environments.length > 0); + + for (let i = 0; i < logsWithEnvs.length - 1; i++) { + const currentId = logsWithEnvs[i].environments[0].id; + const nextId = logsWithEnvs[i + 1].environments[0].id; + + expect(currentId >= nextId).to.be.true; + } + + done(); + }); + }); + + it('should support sorting, environments ASC', (done) => { + request(server) + .get('/api/logs?sort[environments]=asc') + .expect(200) + .end((err, res) => { + if (err) { + done(err); + return; + } + + const { data } = res.body; + const logsWithEnvs = data.filter(({ environments }) => environments.length > 0); + + for (let i = 0; i < logsWithEnvs.length - 1; i++) { + const currentId = logsWithEnvs[i].environments[0].id; + const nextId = logsWithEnvs[i + 1].environments[0].id; + + expect(currentId <= nextId).to.be.true; + } + + done(); + }); + }); + it('should support sorting, id DESC', (done) => { request(server) .get('/api/logs?sort[id]=desc') diff --git a/test/api/qcFlags.test.js b/test/api/qcFlags.test.js index 092df4d883..24e843d98e 100644 --- a/test/api/qcFlags.test.js +++ b/test/api/qcFlags.test.js @@ -743,8 +743,8 @@ module.exports = () => { const response = await request(server).get(`/api/qcFlags/synchronous?runNumber=${runNumber}&detectorId=${detectorId}`); expect(response.status).to.be.equal(200); const { data: flags, meta } = response.body; - expect(meta).to.be.eql({ page: { totalCount: 2, pageCount: 1 } }); - expect(flags.map(({ id }) => id)).to.have.all.ordered.members([101, 100]); + expect(meta).to.be.eql({ page: { totalCount: 3, pageCount: 1 } }); + expect(flags.map(({ id }) => id)).to.have.all.ordered.members([103, 101, 100]); }); it('should successfully fetch synchronous flags with pagination', async () => { @@ -752,11 +752,11 @@ module.exports = () => { const detectorId = 7; { const response = await request(server) - .get(`/api/qcFlags/synchronous?runNumber=${runNumber}&detectorId=${detectorId}&page[limit]=1&page[offset]=1`); + .get(`/api/qcFlags/synchronous?runNumber=${runNumber}&detectorId=${detectorId}&page[limit]=1&page[offset]=2`); expect(response.status).to.be.equal(200); const { data: flags, meta } = response.body; - expect(meta).to.be.eql({ page: { totalCount: 2, pageCount: 2 } }); + expect(meta).to.be.eql({ page: { totalCount: 3, pageCount: 3 } }); expect(flags).to.be.lengthOf(1); const [flag] = flags; expect(flag.id).to.be.equal(100); @@ -770,7 +770,7 @@ module.exports = () => { { const response = await request(server) .get(`/api/qcFlags/synchronous?runNumber=${runNumber}&detectorId=${detectorId}&filter[createdBy][names]=Jan%20Jansen&filter[createdBy][operator]=or`); - expect(response.body.data).to.be.lengthOf(2); + expect(response.body.data).to.be.lengthOf(3); } { diff --git a/test/api/runs.test.js b/test/api/runs.test.js index 083771bf02..e3272b6cef 100644 --- a/test/api/runs.test.js +++ b/test/api/runs.test.js @@ -311,6 +311,24 @@ module.exports = () => { expect(data.map(({ runNumber }) => runNumber)).to.have.all.members([1, 2, 55, 49, 54, 56, 105]); }); + it('should return 400 if GAQ notBadFraction is used with multiple dataPassIds', (done) => { + const url = '/api/runs?filter[dataPassIds][]=2&filter[dataPassIds][]=3&filter[gaq][notBadFraction][operator]==&filter[gaq][notBadFraction][limit]=0.5'; + request(server) + .get(url) + .expect(400) + .end((err, res) => { + if (err) { + done(err); + return; + } + + const { errors } = res.body; + expect(errors[0].detail).to.equal('Filtering by GAQ is enabled only when filtering with one dataPassId'); + + done(); + }); + }); + it('should successfully filter on simulation pass id', async () => { const response = await request(server).get('/api/runs?filter[simulationPassIds][]=1'); expect(response.status).to.equal(200); @@ -454,11 +472,11 @@ module.exports = () => { } }); - it('should successfully filter by detectors notBadFraction', async () => { + it('should successfully filter by detectorsQcNotBadFraction', async () => { const dataPassId = 1; { const response = await request(server).get(`/api/runs?filter[dataPassIds][]=${dataPassId}` - + '&filter[detectorsQc][_1][notBadFraction][operator]=>&filter[detectorsQc][_1][notBadFraction][limit]=0.7'); + + '&filter[detectorsQcNotBadFraction][_1][operator]=>&filter[detectorsQcNotBadFraction][_1][limit]=0.7'); expect(response.status).to.equal(200); const { data: runs } = response.body; @@ -468,7 +486,7 @@ module.exports = () => { } { const response = await request(server).get(`/api/runs?filter[dataPassIds][]=${dataPassId}` - + '&filter[detectorsQc][_1][notBadFraction][operator]=<&filter[detectorsQc][_1][notBadFraction][limit]=0.9&filter[detectorsQc][mcReproducibleAsNotBad]=true'); + + '&filter[detectorsQcNotBadFraction][_1][operator]=<&filter[detectorsQcNotBadFraction][_1][limit]=0.9&filter[detectorsQcNotBadFraction][mcReproducibleAsNotBad]=true'); expect(response.status).to.equal(200); const { data: runs } = response.body; @@ -478,8 +496,8 @@ module.exports = () => { } { const response = await request(server).get(`/api/runs?filter[dataPassIds][]=${dataPassId}` - + '&filter[detectorsQc][_1][notBadFraction][operator]=<&filter[detectorsQc][_1][notBadFraction][limit]=0.7' - + '&filter[detectorsQc][_16][notBadFraction][operator]=>&filter[detectorsQc][_16][notBadFraction][limit]=0.9' + + '&filter[detectorsQcNotBadFraction][_1][operator]=<&filter[detectorsQcNotBadFraction][_1][limit]=0.7' + + '&filter[detectorsQcNotBadFraction][_16][operator]=>&filter[detectorsQcNotBadFraction][_16][limit]=0.9' ); expect(response.status).to.equal(200); diff --git a/test/lib/server/services/qualityControlFlag/QcFlagService.test.js b/test/lib/server/services/qualityControlFlag/QcFlagService.test.js index f4c533444f..3aa4300ab4 100644 --- a/test/lib/server/services/qualityControlFlag/QcFlagService.test.js +++ b/test/lib/server/services/qualityControlFlag/QcFlagService.test.js @@ -142,15 +142,15 @@ module.exports = () => { const detectorId = 7; { const { rows: flags, count } = await qcFlagService.getAllSynchronousPerRunAndDetector({ runNumber, detectorId }); - expect(count).to.be.equal(2); - expect(flags.map(({ id }) => id)).to.have.all.ordered.members([101, 100]); + expect(count).to.be.equal(3); + expect(flags.map(({ id }) => id)).to.have.all.ordered.members([103, 101, 100]); } { const { rows: flags, count } = await qcFlagService.getAllSynchronousPerRunAndDetector( { runNumber, detectorId }, - { limit: 1, offset: 1 }, + { limit: 1, offset: 2 }, ); - expect(count).to.be.equal(2); + expect(count).to.be.equal(3); expect(flags).to.be.lengthOf(1); const [flag] = flags; expect(flag.id).to.be.equal(100); @@ -2124,10 +2124,10 @@ module.exports = () => { }); }); - it('should successfult fiter sync flags by created by name', async () => { + it('should successfully filter sync flags by created by name', async () => { { const { rows } = await qcFlagService.getAllSynchronousPerRunAndDetector({ runNumber: 56, detectorId: 7 }, {}, { createdBy: { names: ['Jan Jansen'], operator: 'or' }}); - expect(rows).to.be.lengthOf(2); + expect(rows).to.be.lengthOf(3); } { @@ -2136,7 +2136,7 @@ module.exports = () => { } }); - it('should successfult fiter data pass flags by created by name', async () => { + it('should successfully filter data pass flags by created by name', async () => { { const { rows } = await qcFlagService.getAllPerDataPassAndRunAndDetector({ dataPassId: 1, runNumber: 107, detectorId: 1 }, {}, { createdBy: { names: ['John Doe'], operator: 'or' }}); expect(rows).to.be.lengthOf(2); @@ -2148,7 +2148,7 @@ module.exports = () => { } }); - it('should successfult fiter simulation pass flags by created by name', async () => { + it('should successfully filter simulation pass flags by created by name', async () => { { const { rows } = await qcFlagService.getAllPerSimulationPassAndRunAndDetector({ simulationPassId: 1, runNumber: 106, detectorId: 1 }, {}, { createdBy: { names: ['Jan Jansen'], operator: 'or' }}); expect(rows).to.be.lengthOf(2); diff --git a/test/lib/usecases/environment/GetAllEnvironmentsUseCase.test.js b/test/lib/usecases/environment/GetAllEnvironmentsUseCase.test.js index 96b4ee1c11..5f1e816571 100644 --- a/test/lib/usecases/environment/GetAllEnvironmentsUseCase.test.js +++ b/test/lib/usecases/environment/GetAllEnvironmentsUseCase.test.js @@ -225,4 +225,40 @@ module.exports = () => { expect(environments).to.be.an('array'); expect(environments.length).to.be.equal(0); // Environments from seeders }); + + it('should return correct total count and all filtered results across pages', async () => { + const totalMatchingFilter = 6; // 'RUNNING, ERROR' matches 6 environments at this point + const limit = 2; + + // First page + getAllEnvsDto.query = { page: { limit, offset: 0 }, filter: { currentStatus: 'RUNNING, ERROR' } }; + const page1 = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + + expect(page1.count).to.be.equal(totalMatchingFilter); + expect(page1.environments).to.be.an('array'); + expect(page1.environments.length).to.be.equal(limit); + + // Second page + getAllEnvsDto.query = { page: { limit, offset: 2 }, filter: { currentStatus: 'RUNNING, ERROR' } }; + const page2 = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + + expect(page2.count).to.be.equal(totalMatchingFilter); + expect(page2.environments).to.be.an('array'); + expect(page2.environments.length).to.be.equal(limit); + + // Third page + getAllEnvsDto.query = { page: { limit, offset: 4 }, filter: { currentStatus: 'RUNNING, ERROR' } }; + const page3 = await new GetAllEnvironmentsUseCase().execute(getAllEnvsDto); + + expect(page3.count).to.be.equal(totalMatchingFilter); + expect(page3.environments).to.be.an('array'); + expect(page3.environments.length).to.be.equal(limit); + + // Collect all environment IDs and verify no duplicates and all present + const allIds = [page1, page2, page3].flatMap(({ environments })=> environments.map(({ id }) => id)); + + expect(allIds.length).to.be.equal(totalMatchingFilter); + expect(new Set(allIds).size).to.be.equal(totalMatchingFilter); + expect(allIds).to.have.members(['SomeId', 'newId', 'CmCvjNbg', 'EIDO13i3D', '8E4aZTjY', 'Dxi029djX']); + }); }; diff --git a/test/lib/usecases/log/GetAllLogsUseCase.test.js b/test/lib/usecases/log/GetAllLogsUseCase.test.js index 61a402cdb8..d4475d2d60 100644 --- a/test/lib/usecases/log/GetAllLogsUseCase.test.js +++ b/test/lib/usecases/log/GetAllLogsUseCase.test.js @@ -73,7 +73,7 @@ module.exports = () => { it('should successfully filter on run numbers', async () => { const runNumbers = [1, 2]; - getAllLogsDto.query = { filter: { run: { operation: 'and', values: runNumbers } } }; + getAllLogsDto.query = { filter: { runNumbers } }; { const { logs: filteredResult } = await new GetAllLogsUseCase().execute(getAllLogsDto); @@ -83,17 +83,6 @@ module.exports = () => { expect(runNumbers.every((runNumber) => relatedRunNumbers.includes(runNumber))).to.be.true; } } - - getAllLogsDto.query = { filter: { run: { operation: 'or', values: runNumbers } } }; - - { - const { logs: filteredResult } = await new GetAllLogsUseCase().execute(getAllLogsDto); - expect(filteredResult).to.lengthOf(6); - for (const log of filteredResult) { - const relatedRunNumbers = log.runs.map(({ runNumber }) => runNumber); - expect(runNumbers.some((runNumber) => relatedRunNumbers.includes(runNumber))).to.be.true; - } - } }); it('should successfully filter on log content', async () => { @@ -117,9 +106,9 @@ module.exports = () => { }); it('should successfully filter on lhc fills', async () => { - const lhcFills = [1, 6]; + const fillNumbers = [1, 6]; - getAllLogsDto.query = { filter: { lhcFills: { operation: 'and', values: lhcFills } } }; + getAllLogsDto.query = { filter: { fillNumbers } }; { const { logs: filteredResult } = await new GetAllLogsUseCase().execute(getAllLogsDto); expect(filteredResult).to.have.lengthOf(1); @@ -128,47 +117,24 @@ module.exports = () => { // For each returned log, check at least one of the associated fill numbers was in the filter query expect(fillNumbersPerLog.every((logFillNumbers) => - logFillNumbers.includes(lhcFills[0]) && logFillNumbers.includes(lhcFills[1]))).to.be.true; - } - - getAllLogsDto.query = { filter: { lhcFills: { operation: 'or', values: lhcFills } } }; - { - const { logs: filteredResult } = await new GetAllLogsUseCase().execute(getAllLogsDto); - expect(filteredResult).to.have.lengthOf(3); - - const fillNumbersPerLog = filteredResult.map(({ lhcFills }) => lhcFills.map(({ fillNumber }) => fillNumber)); - - // For each returned log, check at least one of the associated fill numbers was in the filter query - expect(fillNumbersPerLog.every((logFillNumbers) => - logFillNumbers.includes(lhcFills[0]) || logFillNumbers.includes(lhcFills[1]))).to.be.true; + logFillNumbers.includes(fillNumbers[0]) && logFillNumbers.includes(fillNumbers[1]))).to.be.true; } }); it ('should successfully filter on log environment', async () => { - const environments = ['8E4aZTjY', 'eZF99lH6']; - getAllLogsDto.query = { filter: { environments: { operation: 'and', values: environments } } }; + const environmentIds = ['8E4aZTjY', 'eZF99lH6']; + getAllLogsDto.query = { filter: { environmentIds } }; { const { logs: filteredResult } = await new GetAllLogsUseCase().execute(getAllLogsDto); expect(filteredResult).to.lengthOf(2); for (const log of filteredResult) { - const relatedEnvironments = log.environments.map(({ id }) => id); - expect(environments.every((env) => relatedEnvironments.includes(env))).to.be.true; - } - } - - getAllLogsDto.query = { filter: { environments: { operation: 'or', values: environments } } }; - - { - const { logs: filteredResult } = await new GetAllLogsUseCase().execute(getAllLogsDto); - expect(filteredResult).to.lengthOf(5); - for (const log of filteredResult) { - const relatedEnvironments = log.environments.map(({ id }) => id); - expect(environments.some((env) => relatedEnvironments.includes(env))).to.be.true; + const relatedenvironmentIds = log.environments.map(({ id }) => id); + expect(environmentIds.every((env) => relatedenvironmentIds.includes(env))).to.be.true; } } - getAllLogsDto.query = { filter: { environments: { operation: 'and', values: ['non-existent-environment'] } } }; + getAllLogsDto.query = { filter: { environmentIds: ['non-existent-environment'] } }; { const { logs: filteredResult } = await new GetAllLogsUseCase().execute(getAllLogsDto); diff --git a/test/lib/usecases/run/GetAllRunsUseCase.test.js b/test/lib/usecases/run/GetAllRunsUseCase.test.js index 5b080d056c..febeae02aa 100644 --- a/test/lib/usecases/run/GetAllRunsUseCase.test.js +++ b/test/lib/usecases/run/GetAllRunsUseCase.test.js @@ -831,7 +831,7 @@ module.exports = () => { query: { filter: { dataPassIds, - detectorsQc: { '_1': { notBadFraction: { operator: '<', limit: 0.7 } } }, + detectorsQcNotBadFraction: { '_1': { operator: '<', limit: 0.7 } }, }, }, }); @@ -843,7 +843,7 @@ module.exports = () => { query: { filter: { dataPassIds, - detectorsQc: { '_1': { notBadFraction: { operator: '<', limit: 0.8 } } }, + detectorsQcNotBadFraction: { '_1': { operator: '<', limit: 0.8 } }, }, }, }); @@ -855,7 +855,7 @@ module.exports = () => { query: { filter: { dataPassIds, - detectorsQc: { '_1': { notBadFraction: { operator: '<', limit: 0.9 } }, mcReproducibleAsNotBad: true }, + detectorsQcNotBadFraction: { '_1': { operator: '<', limit: 0.9 }, mcReproducibleAsNotBad: true }, }, }, }); @@ -867,9 +867,10 @@ module.exports = () => { query: { filter: { dataPassIds, - detectorsQc: { - '_2': { notBadFraction: { operator: '>', limit: 0.8 } }, - '_1': { notBadFraction: {operator: '<', limit: 0.8 } }, + detectorsQcNotBadFraction: + { + '_2': { operator: '>', limit: 0.8 }, + '_1': { operator: '<', limit: 0.8 }, }, }, }, diff --git a/test/public/Filters/FilteringModel.test.js b/test/public/Filters/FilteringModel.test.js new file mode 100644 index 0000000000..a00ff7996b --- /dev/null +++ b/test/public/Filters/FilteringModel.test.js @@ -0,0 +1,157 @@ +/** + * @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. + */ + +const { + defaultBefore, + defaultAfter, + goToPage, + fillInput, + pressElement, + waitForTableTotalRowsCountToEqual, + getPopoverSelector, + getPeriodInputsSelectors, +} = require('../defaults.js'); + +module.exports = () => { + let page; + let browser; + + before(async () => { + [page, browser] = await defaultBefore(); + }); + + // Not all filters for the pages will be checked, as many of them are identical between pages. + // Environments is not checked at all because it has no filter implementations not allready covered by other pages + const runSelectionFiltersChecks = { + 'tags': [{ count: 1, selector: '#tag-dropdown-option-FOOD' }, { count: 0, selector: '#tag-dropdown-option-CTP' }, { count: 1, selector: '#tag-filter-combination-operator-radio-button-or' }], + 'beam mode': [{ count: 1, selector: '#beam-mode-dropdown-option-NO\\ BEAM' }, { count: 2, selector: '#beam-mode-dropdown-option-UNSTABLE\\ BEAMS' }], + 'definitions': [{ count: 1, selector: '#run-definition-checkbox-TECHNICAL' }, { count: 3, selector: '#run-definition-checkbox-SYNTHETIC' }], + 'quality': [{ count: 1, selector: '#checkboxes-checkbox-none' }, { count: 3, selector: '#checkboxes-checkbox-bad' }], + 'detectors': [{ count: 3, selector: '#detector-filter-dropdown-option-ACO' }, { count: 0, selector: '#detector-filter-dropdown-option-FDD' }, { count: 3, selector: '#detector-filter-combination-operator-radio-button-or' }], + 'runTypes': [{ count: 4, selector: '#run-types-dropdown-option-14' }, { count: 5, selector: '#run-types-dropdown-option-2' }], + 'ddFLP': [{ count: 101, selector: '#ddFlpFilterRadioON' }, { count: 8, selector: '#ddFlpFilterRadioOFF' }], + 'magnets': [{ count: 1, selector: '#l3-dipole-current-dropdown-option-20003kA\\/0kA' }, { count: 3, selector: '#l3-dipole-current-dropdown-option-30003kA\\/0kA' }], + }; + + const logSelectionFiltersChecks = { + 'tags': [{ count: 1, selector: '#tag-dropdown-option-DPG' }, { count: 0, selector: '#tag-dropdown-option-FOOD' }, { count: 3, selector: '#tag-filter-combination-operator-radio-button-or' } ], + }; + + const lhcFillsSelectionFiltersChecks = { + 'hasStableBeams': [{ count: 6, selector: '.switch > input' }], + 'beamTypes': [{ count: 1, selector: '#beam-types-checkbox-p-p' }, { count: 2, selector: '#beam-types-checkbox-p-Pb' }] + }; + + const checkSelectionFilters = async (selectionFilterObject, baseRowCount) => { + for (const [_key, checks] of Object.entries(selectionFilterObject)) { + await waitForTableTotalRowsCountToEqual(page, baseRowCount); + + for (const { count, selector } of checks) { + await pressElement(page, selector, true); + await waitForTableTotalRowsCountToEqual(page, count); + } + + for (const { count } of checks.reverse()) { + await waitForTableTotalRowsCountToEqual(page, count); + await page.goBack(); + } + + await waitForTableTotalRowsCountToEqual(page, baseRowCount); + } + }; + + it('should undo filters if the user presses go-back on the runs page', async () => { + await goToPage(page, 'run-overview'); + const baseRowCount = 109; + const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); + + const { fromDateSelector, fromTimeSelector } = getPeriodInputsSelectors(startPopoverSelector); + + await checkSelectionFilters(runSelectionFiltersChecks, baseRowCount); + + // Run duration + await page.select('#duration-operator', '>'); + await fillInput(page, '#duration-operand', 500, ['change']); + await waitForTableTotalRowsCountToEqual(page, 8); + await page.select('#duration-operator', '='); + await waitForTableTotalRowsCountToEqual(page, 0); + await page.goBack(); + await waitForTableTotalRowsCountToEqual(page, 8); + await page.goBack(); + await waitForTableTotalRowsCountToEqual(page, baseRowCount); + + // EorReason filter + await page.select('#eorCategories', 'DETECTORS'); + await waitForTableTotalRowsCountToEqual(page, 3); + await page.select('#eorTitles', 'CPV'); + await waitForTableTotalRowsCountToEqual(page, 2); + await fillInput(page, '#eorDescription', 'some', ['change']); + await waitForTableTotalRowsCountToEqual(page, 1); + await page.goBack(); + await waitForTableTotalRowsCountToEqual(page, 2); + await page.goBack(); + await waitForTableTotalRowsCountToEqual(page, 3); + await page.goBack(); + await waitForTableTotalRowsCountToEqual(page, baseRowCount); + + // O2 Start Filter: + await fillInput(page, fromTimeSelector, '11:11', ['change']); + await fillInput(page, fromDateSelector, '2021-02-03', ['change']); + await waitForTableTotalRowsCountToEqual(page, 1); + await fillInput(page, fromDateSelector, '2020-02-03', ['change']); + await waitForTableTotalRowsCountToEqual(page, 2); + await page.goBack(); + await waitForTableTotalRowsCountToEqual(page, 1); + await page.goBack(); + await waitForTableTotalRowsCountToEqual(page, baseRowCount); + }); + + it('should undo filters if the user presses go-back on the LHC fills page', async () => { + await goToPage(page, 'lhc-fill-overview'); + await checkSelectionFilters(lhcFillsSelectionFiltersChecks, 5) + }); + + it('should undo filters if the user presses go-back on the logs page', async () => { + await goToPage(page, 'log-overview'); + await waitForTableTotalRowsCountToEqual(page, 119); + + // AuthorFilter + await pressElement(page, '.author-filter .switch input', true); + await waitForTableTotalRowsCountToEqual(page, 117); + await fillInput(page, '#authorFilterText', '!Anonymous,John', ['change']); + await waitForTableTotalRowsCountToEqual(page, 5); + await page.goBack(); + await waitForTableTotalRowsCountToEqual(page, 117); + await page.goBack(); + await waitForTableTotalRowsCountToEqual(page, 119); + + await checkSelectionFilters(logSelectionFiltersChecks, 119); + }); + + it('should undo filters if the user presses go-back on the lhc periods page', async () => { + await goToPage(page, 'lhc-period-overview'); + await waitForTableTotalRowsCountToEqual(page, 3); + + // Name + await fillInput(page, '.name-filter input', 'LHC23f'); + await waitForTableTotalRowsCountToEqual(page, 1); + await fillInput(page, '.name-filter input', 'bogus'); + await waitForTableTotalRowsCountToEqual(page, 0); + await page.goBack(); + await waitForTableTotalRowsCountToEqual(page, 1); + await page.goBack(); + await waitForTableTotalRowsCountToEqual(page, 3); + }); + + after(async () => await defaultAfter(page, browser)); +} diff --git a/test/public/Filters/filtersToUrl.test.js b/test/public/Filters/filtersToUrl.test.js new file mode 100644 index 0000000000..c89547e244 --- /dev/null +++ b/test/public/Filters/filtersToUrl.test.js @@ -0,0 +1,529 @@ +/** + * @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. + */ + +const { expect } = require('chai'); +const { + defaultBefore, + defaultAfter, + goToPage, + fillInput, + getPopoverSelector, + getPeriodInputsSelectors, + pressElement, + openFilteringPanel, + waitForTableLength, +} = require('../defaults.js'); + +module.exports = () => { + let page; + let browser; + + before(async () => { + [page, browser] = await defaultBefore(); + }); + + const getQueryParameters = (page) => Object.fromEntries(new URL(page.url()).searchParams.entries()); + + it('should set filters from LogsOverview to the URL', async () => { + await goToPage(page, 'log-overview'); + const firstCheckboxId = 'tag-dropdown-option-DPG'; + const popoverTrigger = '.createdAt-filter .popover-trigger'; + + await page.waitForSelector(popoverTrigger); + await openFilteringPanel(page); + + const popOverSelector = await getPopoverSelector(await page.$(popoverTrigger)); + const { fromDateSelector, toDateSelector, fromTimeSelector, toTimeSelector } = getPeriodInputsSelectors(popOverSelector); + + await fillInput(page, '.title-textFilter', 'bogusbogusbogus', ['change']); + await fillInput(page, '#authorFilterText', 'Jane', ['change']); + await fillInput(page, '.content-textFilter', 'particle', ['change']); + await pressElement(page, '.tags-filter .dropdown-trigger'); + await pressElement(page, `#${firstCheckboxId}`, true); + await fillInput(page, '.environments-filter input', '8E4aZTjY', ['change']); + await fillInput(page, '.runNumbers-textFilter', '1,2', ['change']); + await fillInput(page, '.fillNumbers-textFilter', '1, 6', ['change']); + await fillInput(page, fromDateSelector, '2020-02-02', ['change']); + await fillInput(page, toDateSelector, '2020-02-02', ['change']); + await fillInput(page, fromTimeSelector, '11:00', ['change']); + await fillInput(page, toTimeSelector, '12:00', ['change']); + + const queryParameters = getQueryParameters(page); + expect(queryParameters).to.deep.equal({ + "page": "log-overview", + "filter[author]": "Jane", + "filter[title]": "bogusbogusbogus", + "filter[content]": "particle", + "filter[tags][values]": "DPG", + "filter[tags][operation]": "and", + "filter[runNumbers]": "1,2", + "filter[environmentIds]": "8E4aZTjY", + "filter[fillNumbers]": "1, 6", + "filter[created][from]": "1580641200000", + "filter[created][to]": "1580644800000" + }); + }); + + it('should set filters from EnvironmentsOverview to the URL', async () => { + await goToPage(page, 'env-overview'); + const popoverTrigger = '.createdAt-filter .popover-trigger'; + + await page.waitForSelector(popoverTrigger); + await openFilteringPanel(page); + + const createdAtPopoverSelector = await getPopoverSelector(await page.$(popoverTrigger)); + const periodInputsSelectors = getPeriodInputsSelectors(createdAtPopoverSelector); + + await fillInput(page, '.runs-filter input', '10', ['change']); + await fillInput(page, '.id-filter input', 'Dxi029djX, TDI59So3d', ['change']); + await pressElement(page, '#checkboxes-checkbox-DESTROYED'); + await fillInput(page, '.historyItems-filter input', 'C-R-D-X', ['change']); + await fillInput(page, periodInputsSelectors.fromDateSelector, '2019-08-09', ['change']); + await fillInput(page, periodInputsSelectors.toDateSelector, '2019-08-10', ['change']); + await fillInput(page, periodInputsSelectors.fromTimeSelector, '00:00', ['change']); + await fillInput(page, periodInputsSelectors.toTimeSelector, '23:59', ['change']); + + const queryParameters = getQueryParameters(page); + expect(queryParameters).to.deep.equal({ + "page": "env-overview", + "filter[created][from]": "1565308800000", + "filter[created][to]": "1565481540000", + "filter[runNumbers]": "10", + "filter[statusHistory]": "C-R-D-X", + "filter[currentStatus]": "DESTROYED", + "filter[ids]": "Dxi029djX, TDI59So3d" + }); + }); + + it('should set filters from LhcFillsOverview to the URL', async () => { + await goToPage(page, 'lhc-fill-overview'); + await waitForTableLength(page, 5); + const sbEndPopoverTrigger = '.stableBeamsEnd-filter .popover-trigger'; + const sbStartPopoverTrigger = '.stableBeamsStart-filter .popover-trigger'; + const sbStartPopOverSelector = await getPopoverSelector(await page.$(sbStartPopoverTrigger)); + const sbEndPopOverSelector = await getPopoverSelector(await page.$(sbEndPopoverTrigger)); + const filterSchemeNameInputField= '.fillingSchemeName-filter input'; + const { + fromDateSelector: sbStartFromDateSelector, + toDateSelector: sbStartToDateSelector, + fromTimeSelector: sbStartFromTimeSelector, + toTimeSelector: sbStartToTimeSelector + } = getPeriodInputsSelectors(sbStartPopOverSelector); + + const { + fromDateSelector: sbEndFromDateSelector, + toDateSelector: sbEndToDateSelector, + fromTimeSelector: sbEndFromTimeSelector, + toTimeSelector: sbEndToTimeSelector + } = getPeriodInputsSelectors(sbEndPopOverSelector); + + await openFilteringPanel(page); + await fillInput(page, '#beam-duration-filter-operand', '00:01:40', ['change']); + await fillInput(page, '#run-duration-filter-operand', '00:00:00', ['change']); + await pressElement(page, '#beam-types-checkbox-p-Pb'); + await fillInput(page, sbStartFromDateSelector, '2019-08-08', ['change']); + await fillInput(page, sbStartToDateSelector, '2019-08-08', ['change']); + await fillInput(page, sbStartFromTimeSelector, '10:00', ['change']); + await fillInput(page, sbStartToTimeSelector, '12:00', ['change']); + await fillInput(page, sbEndFromDateSelector, '2022-03-22', ['change']); + await fillInput(page, sbEndToDateSelector, '2022-03-22', ['change']); + await fillInput(page, sbEndFromTimeSelector, '01:00', ['change']); + await fillInput(page, sbEndToTimeSelector, '23:59', ['change']); + await fillInput(page, filterSchemeNameInputField, 'Single_12b_8_1024_8_2018', ['change']); + + const queryParameters = getQueryParameters(page); + expect(queryParameters).to.deep.equal({ + "page": "lhc-fill-overview", + "filter[beamDuration][operator]": "=", + "filter[beamDuration][limit]": "00:01:40", + "filter[runDuration][operator]": "=", + "filter[runDuration][limit]": "00:00:00", + "filter[hasStableBeams]": "true", + "filter[stableBeamsEnd][from]": "1647910800000", + "filter[stableBeamsEnd][to]": "1647993540000", + "filter[stableBeamsStart][from]": "1565258400000", + "filter[stableBeamsStart][to]": "1565265600000", + "filter[beamTypes]": "p-Pb", + "filter[schemeName]": "Single_12b_8_1024_8_2018" + }); + }); + + it('should set filters from runsOverview to the URL', async () => { + await goToPage(page, 'run-overview'); + const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); + const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); + const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); + + const { + fromDateSelector: startFromDateSelector, + toDateSelector: startToDateSelector, + fromTimeSelector: startFromTimeSelector, + toTimeSelector: startToTimeSelector + } = getPeriodInputsSelectors(startPopoverSelector); + + const { + fromDateSelector: endFromDateSelector, + toDateSelector: endToDateSelector, + fromTimeSelector: endFromTimeSelector, + toTimeSelector: endToTimeSelector + } = getPeriodInputsSelectors(endPopoverSelector); + + await openFilteringPanel(page); + await pressElement(page, '#detector-filter-dropdown-option-ITS', true); + await pressElement(page, '#tag-dropdown-option-FOOD', true); + await pressElement(page, '#run-definition-checkbox-PHYSICS', true); + await pressElement(page, '.timeO2Start-filter .popover-trigger'); + await fillInput(page, startFromTimeSelector, '11:11', ['change']); + await fillInput(page, startToTimeSelector, '14:00', ['change']); + await fillInput(page, startFromDateSelector, '2021-02-03', ['change']); + await fillInput(page, startToDateSelector, '2021-02-03', ['change']); + await fillInput(page, endFromTimeSelector, '11:11', ['change']); + await fillInput(page, endToTimeSelector, '14:00', ['change']); + await fillInput(page, endFromDateSelector, '2021-02-03', ['change']); + await fillInput(page, endToDateSelector, '2021-02-03', ['change']); + await fillInput(page, '#duration-operand', '1500', ['change']); + await pressElement(page, `${dipolePopoverSelector} .dropdown-option:last-child`, true); + await pressElement(page, '#checkboxes-checkbox-bad'); + await pressElement(page, '#triggerValue-checkbox-OFF'); + await fillInput(page, '#runOverviewFilter .runNumbers-textFilter', '101'); + await fillInput(page, '.fillNumbers-textFilter', '1, 3', ['change']); + await fillInput(page, '.environmentIds-textFilter', 'Dxi029djX, TDI59So3d', ['change']); + await pressElement(page, '#run-types-dropdown-option-2', true); + await pressElement(page, '#beam-mode-dropdown-option-NO\\ BEAM', true); + await fillInput(page, '#nDetectors-operand', '1', ['change']); + await fillInput(page, '#nFlps-operand', '10', ['change']); + await fillInput(page, '#nEpns-operand', '10', ['change']); + await fillInput(page, '#ctfFileCount-operand', '1', ['change']); + await fillInput(page, '#tfFileCount-operand', '1', ['change']); + await fillInput(page, '#otherFileCount-operand', '1', ['change']); + await pressElement(page, '#epnFilterRadioOFF', true); + await page.select('#eorCategories', 'DETECTORS'); + await page.select('#eorTitles', 'CPV'); + await fillInput(page, '#eorDescription', 'some', ['change']); + + const queryParameters = getQueryParameters(page); + expect(queryParameters).to.deep.equal({ + "page": "run-overview", + "filter[runNumbers]": "101", + "filter[detectors][operator]": "and", + "filter[detectors][values]": "ITS", + "filter[tags][values]": "FOOD", + "filter[tags][operation]": "and", + "filter[fillNumbers]": "1, 3", + "filter[o2start][from]": "1612350660000", + "filter[o2start][to]": "1612360800000", + "filter[o2end][from]": "1612350660000", + "filter[o2end][to]": "1612360800000", + "filter[definitions]": "PHYSICS", + "filter[runDuration][operator]": "=", + "filter[runDuration][limit]": "90000000", + "filter[environmentIds]": "Dxi029djX, TDI59So3d", + "filter[runTypes]": "2", + "filter[beamModes]": "NO BEAM", + "filter[runQualities]": "bad", + "filter[nDetectors][operator]": "=", + "filter[nDetectors][limit]": "1", + "filter[nEpns][operator]": "=", + "filter[nEpns][limit]": "10", + "filter[nFlps][operator]": "=", + "filter[nFlps][limit]": "10", + "filter[ctfFileCount][operator]": "=", + "filter[ctfFileCount][limit]": "1", + "filter[tfFileCount][operator]": "=", + "filter[tfFileCount][limit]": "1", + "filter[otherFileCount][operator]": "=", + "filter[otherFileCount][limit]": "1", + "filter[eorReason][category]": "DETECTORS", + "filter[eorReason][title]": "CPV", + "filter[eorReason][description]": "some", + "filter[magnets][l3]": "30003", + "filter[magnets][dipole]": "0", + "filter[epn]": "false", + "filter[triggerValues]": "OFF" + }); + }); + + it('should set filters from lhcPriodOverview to the URL', async () => { + await goToPage(page, 'lhc-period-overview'); + + await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22a'); + await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(2) input[type=text]', '2022'); + await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(3) input[type=text]', 'PbPb'); + const queryParameters = getQueryParameters(page); + expect(queryParameters).to.deep.equal({ + "page": "lhc-period-overview", + "filter[names][]": "LHC22a", + "filter[years][]": "2022", + "filter[pdpBeamTypes][]": "PbPb" + }); + }); + + it('should set filters from qcFlagTypesOverview to the URL', async () => { + await goToPage(page, 'qc-flag-types-overview'); + + await fillInput(page, '.name-filter input[type=text]', 'bad'); + await fillInput(page, '.method-filter input[type=text]', 'bad'); + await pressElement(page, '#badFilterRadioBad', true); + + const queryParameters = getQueryParameters(page); + expect(queryParameters).to.deep.equal({ + "page": "qc-flag-types-overview", + "filter[names][]": "bad", + "filter[methods][]": "bad", + "filter[bad]": "true" + }); + }); + + it('should set filters from runsPerLhcPeriodOverview to the URL', async () => { + await goToPage(page, 'runs-per-lhc-period', { queryParameters: { lhcPeriodId: 2 }}); + const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); + const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); + const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); + + const { + fromDateSelector: startFromDateSelector, + toDateSelector: startToDateSelector, + fromTimeSelector: startFromTimeSelector, + toTimeSelector: startToTimeSelector + } = getPeriodInputsSelectors(startPopoverSelector); + + const { + fromDateSelector: endFromDateSelector, + toDateSelector: endToDateSelector, + fromTimeSelector: endFromTimeSelector, + toTimeSelector: endToTimeSelector + } = getPeriodInputsSelectors(endPopoverSelector); + + await fillInput(page, '#inelasticInteractionRateAvg-operand', '100000', ['change']); + await fillInput(page, '#muInelasticInteractionRate-operand', '100000', ['change']); + await fillInput(page, '#runOverviewFilter .runNumbers-textFilter', '101'); + await fillInput(page, '.fillNumbers-textFilter', '1, 3', ['change']); + + await pressElement(page, `${dipolePopoverSelector} .dropdown-option:last-child`, true); + await fillInput(page, startFromTimeSelector, '11:11', ['change']); + await fillInput(page, startToTimeSelector, '14:00', ['change']); + await fillInput(page, startFromDateSelector, '2021-02-03', ['change']); + await fillInput(page, startToDateSelector, '2021-02-03', ['change']); + await fillInput(page, endFromTimeSelector, '11:11', ['change']); + await fillInput(page, endToTimeSelector, '14:00', ['change']); + await fillInput(page, endFromDateSelector, '2021-02-03', ['change']); + await fillInput(page, endToDateSelector, '2021-02-03', ['change']); + + + const queryParameters = getQueryParameters(page); + expect(queryParameters).to.deep.equal({ + "page": "runs-per-lhc-period", + "lhcPeriodId": "2", + "filter[runNumbers]": "101", + "filter[fillNumbers]": "1, 3", + "filter[o2end][from]": "1612350660000", + "filter[o2end][to]": "1612360800000", + "filter[o2start][from]": "1612350660000", + "filter[o2start][to]": "1612360800000", + "filter[magnets][l3]": "30003", + "filter[magnets][dipole]": "0", + "filter[muInelasticInteractionRate][operator]": "=", + "filter[muInelasticInteractionRate][limit]": "100000", + "filter[inelasticInteractionRateAvg][operator]": "=", + "filter[inelasticInteractionRateAvg][limit]": "100000", + "filter[detectorsQcNotBadFraction][mcReproducibleAsNotBad]": "false" + }); + }); + + it('should set filters from DataPassesPerLhcPeriodOverview to the URL', async () => { + await goToPage(page, 'data-passes-per-lhc-period-overview', { queryParameters: { lhcPeriodId: 2 }}); + + await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1', ['input']); + await pressElement(page, '#checkboxes-checkbox-test', true); + + + const queryParameters = getQueryParameters(page); + expect(queryParameters).to.deep.equal({ + "page": "data-passes-per-lhc-period-overview", + "lhcPeriodId": "2", + "filter[names][]": "LHC22b_apass1", + "filter[permittedNonPhysicsNames]": "test" + }); + }); + + it('should set filters from DataPassesPerSimulationPassOverview to the URL', async () => { + await goToPage(page, 'data-passes-per-simulation-pass-overview', { queryParameters: { simulationPassId: 1 }}); + + await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1', ['input']); + await pressElement(page, '#checkboxes-checkbox-test', true); + + const queryParameters = getQueryParameters(page); + expect(queryParameters).to.deep.equal({ + "page": "data-passes-per-simulation-pass-overview", + "simulationPassId": "1", + "filter[names][]": "LHC22b_apass1", + "filter[permittedNonPhysicsNames]": "test" + }); + }); + + it('should set filters from AnchoredSimulationPassesOverview to the URL', async () => { + await goToPage(page, 'anchored-simulation-passes-overview', { queryParameters: { dataPassId: 1 }}); + + await fillInput(page, '.name-filter input', 'LHC23k6c', ['input']); + + const queryParameters = getQueryParameters(page); + expect(queryParameters).to.deep.equal({ + "page": "anchored-simulation-passes-overview", + "dataPassId": "1", + "filter[names][]": "LHC23k6c" + }); + }); + + it('should set filters from RunsPerSimulationPass to the URL', async () => { + await goToPage(page, 'runs-per-simulation-pass', { queryParameters: { simulationPassId: 2 }}); + + const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); + const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); + const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); + + const { + fromDateSelector: startFromDateSelector, + toDateSelector: startToDateSelector, + fromTimeSelector: startFromTimeSelector, + toTimeSelector: startToTimeSelector + } = getPeriodInputsSelectors(startPopoverSelector); + + const { + fromDateSelector: endFromDateSelector, + toDateSelector: endToDateSelector, + fromTimeSelector: endFromTimeSelector, + toTimeSelector: endToTimeSelector + } = getPeriodInputsSelectors(endPopoverSelector); + + await openFilteringPanel(page); + await pressElement(page, '.timeO2Start-filter .popover-trigger'); + await fillInput(page, startFromTimeSelector, '11:11', ['change']); + await fillInput(page, startToTimeSelector, '14:00', ['change']); + await fillInput(page, startFromDateSelector, '2021-02-03', ['change']); + await fillInput(page, startToDateSelector, '2021-02-03', ['change']); + await fillInput(page, endFromTimeSelector, '11:11', ['change']); + await fillInput(page, endToTimeSelector, '14:00', ['change']); + await fillInput(page, endFromDateSelector, '2021-02-03', ['change']); + await fillInput(page, endToDateSelector, '2021-02-03', ['change']); + await fillInput(page, '.inelasticInteractionRateAtMid-filter input', '1', ['change']); + await fillInput(page, '.inelasticInteractionRateAtEnd-filter input', '1', ['change']); + await fillInput(page, '.inelasticInteractionRateAtStart-filter input', '1', ['change']); + await pressElement(page, `${dipolePopoverSelector} .dropdown-option:last-child`, true); + await pressElement(page, '#mcReproducibleAsNotBadToggle', true); + + // These two are detectorQCNotBadFraction[_id] filters. There are a dozen more, but they are all identical hence why only these were tested + await fillInput(page, '.QC-SPECIFIC-filter input', '1', ['change']); + await fillInput(page, '.ACO-filter input', '1', ['change']); + + const queryParameters = getQueryParameters(page); + expect(queryParameters).to.deep.equal({ + "page": "runs-per-simulation-pass", + "simulationPassId": "2", + "filter[o2end][from]": "1612350660000", + "filter[o2start][from]": "1612350660000", + "filter[o2end][to]": "1612360800000", + "filter[o2start][to]": "1612360800000", + "filter[magnets][l3]": "30003", + "filter[magnets][dipole]": "0", + "filter[inelasticInteractionRateAtStart][operator]": "=", + "filter[inelasticInteractionRateAtStart][limit]": "1", + "filter[inelasticInteractionRateAtMid][operator]": "=", + "filter[inelasticInteractionRateAtMid][limit]": "1", + "filter[inelasticInteractionRateAtEnd][operator]": "=", + "filter[inelasticInteractionRateAtEnd][limit]": "1", + "filter[detectorsQcNotBadFraction][mcReproducibleAsNotBad]": "true", + "filter[detectorsQcNotBadFraction][_20][operator]": "=", + "filter[detectorsQcNotBadFraction][_20][limit]": "0.01", + "filter[detectorsQcNotBadFraction][_17][operator]": "=", + "filter[detectorsQcNotBadFraction][_17][limit]": "0.01" + }); + }); + + it('should set filters from RunsPerSimulationPass to the URL', async () => { + await goToPage(page, 'runs-per-data-pass', { queryParameters: { dataPassId: 1 }}); + + const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); + const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); + const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); + + const { + fromDateSelector: startFromDateSelector, + toDateSelector: startToDateSelector, + fromTimeSelector: startFromTimeSelector, + toTimeSelector: startToTimeSelector + } = getPeriodInputsSelectors(startPopoverSelector); + + const { + fromDateSelector: endFromDateSelector, + toDateSelector: endToDateSelector, + fromTimeSelector: endFromTimeSelector, + toTimeSelector: endToTimeSelector + } = getPeriodInputsSelectors(endPopoverSelector); + + await openFilteringPanel(page); + await pressElement(page, '#detector-filter-dropdown-option-ITS', true); + await pressElement(page, '#tag-dropdown-option-FOOD', true); + await pressElement(page, '.timeO2Start-filter .popover-trigger'); + await fillInput(page, startFromTimeSelector, '11:11', ['change']); + await fillInput(page, startToTimeSelector, '14:00', ['change']); + await fillInput(page, startFromDateSelector, '2021-02-03', ['change']); + await fillInput(page, startToDateSelector, '2021-02-03', ['change']); + await fillInput(page, endFromTimeSelector, '11:11', ['change']); + await fillInput(page, endToTimeSelector, '14:00', ['change']); + await fillInput(page, endFromDateSelector, '2021-02-03', ['change']); + await fillInput(page, endToDateSelector, '2021-02-03', ['change']); + await fillInput(page, '#duration-operand', '1500', ['change']); + await fillInput(page, '.muInelasticInteractionRate-filter input', '1', ['change']); + await fillInput(page, '.inelasticInteractionRateAvg-filter input', '1', ['change']); + await fillInput(page, '.globalAggregatedQuality-filter input', '1', ['change']); + + await pressElement(page, `${dipolePopoverSelector} .dropdown-option:last-child`, true); + await pressElement(page, '#mcReproducibleAsNotBadToggle', true); + + // These two are detectorQCNotBadFraction[_id] filters. There are a dozen more, but they are all identical hence why only these were tested + await fillInput(page, '.QC-SPECIFIC-filter input', '1', ['change']); + await fillInput(page, '.ACO-filter input', '1', ['change']); + + const queryParameters = getQueryParameters(page); + expect(queryParameters).to.deep.equal({ + "page": "runs-per-data-pass", + "dataPassId": "1", + "filter[detectors][operator]": "and", + "filter[detectors][values]": "ITS", + "filter[tags][values]": "FOOD", + "filter[tags][operation]": "and", + "filter[o2end][from]": "1612350660000", + "filter[o2end][to]": "1612360800000", + "filter[o2start][from]": "1612350660000", + "filter[o2start][to]": "1612360800000", + "filter[runDuration][limit]": "90000000", + "filter[runDuration][operator]": "=", + "filter[magnets][l3]": "30003", + "filter[magnets][dipole]": "0", + "filter[muInelasticInteractionRate][operator]": "=", + "filter[muInelasticInteractionRate][limit]": "1", + "filter[inelasticInteractionRateAvg][operator]": "=", + "filter[inelasticInteractionRateAvg][limit]": "1", + "filter[detectorsQcNotBadFraction][mcReproducibleAsNotBad]": "true", + "filter[detectorsQcNotBadFraction][_20][operator]": "=", + "filter[detectorsQcNotBadFraction][_20][limit]": "0.01", + "filter[detectorsQcNotBadFraction][_17][operator]": "=", + "filter[detectorsQcNotBadFraction][_17][limit]": "0.01", + "filter[gaq][notBadFraction][operator]": "=", + "filter[gaq][notBadFraction][limit]": "0.01", + "filter[gaq][mcReproducibleAsNotBad]": "true" + }); + }); + + after(async () => await defaultAfter(page, browser)); +} diff --git a/lib/domain/dtos/filters/EnvironmentsFilterDto.js b/test/public/Filters/index.js similarity index 58% rename from lib/domain/dtos/filters/EnvironmentsFilterDto.js rename to test/public/Filters/index.js index 3baa97a747..1023d11de8 100644 --- a/lib/domain/dtos/filters/EnvironmentsFilterDto.js +++ b/test/public/Filters/index.js @@ -11,10 +11,12 @@ * or submit itself to any jurisdiction. */ -const Joi = require('joi'); -const { CustomJoi } = require('../CustomJoi.js'); +const ToUrlSuite = require('./filtersToUrl.test.js'); +const ToFilterSuite = require('./urlToFilter.test.js'); +const FilteringModelSuite = require('./filteringModel.test.js'); -exports.EnvironmentsFilterDto = Joi.object({ - values: CustomJoi.stringArray().items(Joi.string()).single().required(), - operation: Joi.string().valid('and', 'or').required(), -}); +module.exports = () => { + describe('Filters to URL', ToUrlSuite); + describe('URL to Filters', ToFilterSuite); + describe('FilteringModel', FilteringModelSuite); +}; diff --git a/test/public/Filters/urlToFilter.test.js b/test/public/Filters/urlToFilter.test.js new file mode 100644 index 0000000000..06eb280039 --- /dev/null +++ b/test/public/Filters/urlToFilter.test.js @@ -0,0 +1,372 @@ +/** + * @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. + */ + +const { + defaultBefore, + defaultAfter, + fillInput, + getPopoverSelector, + getPeriodInputsSelectors, + pressElement, + openFilteringPanel, + expectInputValue, +} = require('../defaults.js'); + +module.exports = () => { + let page; + let browser; + + before(async () => { + [page, browser] = await defaultBefore(); + }); + + it('should apply filters from url in logsOverviewPage', async () => { + const url = 'http://localhost:4000/?page=log-overview&filter[author]=Jane&filter[title]=bogusbogusbogus&filter[content]=particle'+ + '&filter[tags][values]=DPG&filter[tags][operation]=and&filter[runNumbers]=1%2C2&filter[environmentIds]=8E4aZTjY'+ + '&filter[fillNumbers]=1%2C%206&filter[created][from]=1580637600000&filter[created][to]=1580641200000'; + + + await page.goto(url, { waitUntil: 'load' }); + + const firstCheckboxId = 'tag-dropdown-option-DPG'; + const popoverTrigger = '.createdAt-filter .popover-trigger'; + + await page.waitForSelector(popoverTrigger); + await openFilteringPanel(page); + + const popOverSelector = await getPopoverSelector(await page.$(popoverTrigger)); + const { fromDateSelector, toDateSelector, fromTimeSelector, toTimeSelector } = getPeriodInputsSelectors(popOverSelector); + + await expectInputValue(page, '.title-textFilter', 'bogusbogusbogus'); + await expectInputValue(page, '#authorFilterText', 'Jane'); + await expectInputValue(page, '.content-textFilter', 'particle'); + await pressElement(page, '.tags-filter .dropdown-trigger'); + await page.waitForSelector(`#${firstCheckboxId}:checked`); + await expectInputValue(page, '.environments-filter input', '8E4aZTjY'); + await expectInputValue(page, '.runNumbers-textFilter', '1,2'); + await expectInputValue(page, '.fillNumbers-textFilter', '1, 6'); + await expectInputValue(page, fromDateSelector, '2020-02-02'); + await expectInputValue(page, toDateSelector, '2020-02-02'); + + await expectInputValue(page, fromTimeSelector, '10:00'); + await expectInputValue(page, toTimeSelector, '11:00'); + }); + + it('should set filters from EnvironmentsOverview to the URL', async () => { + const url = 'http://localhost:4000/?page=env-overview&filter[created][from]=1565301600000&filter[created][to]=1565474340000' + + '&filter[runNumbers]=10&filter[statusHistory]=C-R-D-X&filter[currentStatus]=DESTROYED&filter[ids]=Dxi029djX%2C%20TDI59So3d'; + await page.goto(url, { waitUntil: 'load' }); + await openFilteringPanel(page); + + const popoverTrigger = '.createdAt-filter .popover-trigger'; + const createdAtPopoverSelector = await getPopoverSelector(await page.$(popoverTrigger)); + const periodInputsSelectors = getPeriodInputsSelectors(createdAtPopoverSelector); + + await expectInputValue(page, '.runs-filter input', '10'); + await expectInputValue(page, '.id-filter input', 'Dxi029djX, TDI59So3d'); + await page.waitForSelector('#checkboxes-checkbox-DESTROYED:checked'); + await expectInputValue(page, '.historyItems-filter input', 'C-R-D-X'); + await expectInputValue(page, periodInputsSelectors.fromDateSelector, '2019-08-08'); + await expectInputValue(page, periodInputsSelectors.toDateSelector, '2019-08-10'); + await expectInputValue(page, periodInputsSelectors.fromTimeSelector, '22:00'); + await expectInputValue(page, periodInputsSelectors.toTimeSelector, '21:59'); + }); + + it('should set filters from LhcFillsOverview to the URL', async () => { + const url = 'http://localhost:4000/?page=lhc-fill-overview&filter[beamDuration][operator]=%3D&filter[beamDuration][limit]=00%3A01%3A40&' + + 'filter[runDuration][operator]=%3D&filter[runDuration][limit]=00%3A00%3A00&filter[hasStableBeams]=true&filter[stableBeamsStart][from]=1565251200000&' + + 'filter[stableBeamsStart][to]=1565258400000&filter[stableBeamsEnd][from]=1647907200000&filter[stableBeamsEnd][to]=1647989940000&filter[beamTypes]=p-Pb&filter[schemeName]=Single_12b_8_1024_8_2018'; + + await page.goto(url, { waitUntil: 'load' }); + + const sbEndPopoverTrigger = '.stableBeamsEnd-filter .popover-trigger'; + const sbStartPopoverTrigger = '.stableBeamsStart-filter .popover-trigger'; + const sbStartPopOverSelector = await getPopoverSelector(await page.$(sbStartPopoverTrigger)); + const sbEndPopOverSelector = await getPopoverSelector(await page.$(sbEndPopoverTrigger)); + const filterSchemeNameInputField= '.fillingSchemeName-filter input'; + const { + fromDateSelector: sbStartFromDateSelector, + toDateSelector: sbStartToDateSelector, + fromTimeSelector: sbStartFromTimeSelector, + toTimeSelector: sbStartToTimeSelector + } = getPeriodInputsSelectors(sbStartPopOverSelector); + + const { + fromDateSelector: sbEndFromDateSelector, + toDateSelector: sbEndToDateSelector, + fromTimeSelector: sbEndFromTimeSelector, + toTimeSelector: sbEndToTimeSelector + } = getPeriodInputsSelectors(sbEndPopOverSelector); + + await openFilteringPanel(page); + await expectInputValue(page, '#beam-duration-filter-operand', '00:01:40'); + await expectInputValue(page, '#run-duration-filter-operand', '00:00:00'); + await expectInputValue(page, sbStartFromDateSelector, '2019-08-08'); + await expectInputValue(page, sbStartToDateSelector, '2019-08-08'); + await expectInputValue(page, sbStartFromTimeSelector, '08:00'); + await expectInputValue(page, sbStartToTimeSelector, '10:00'); + await expectInputValue(page, sbEndFromDateSelector, '2022-03-22'); + await expectInputValue(page, sbEndToDateSelector, '2022-03-22'); + await expectInputValue(page, sbEndFromTimeSelector, '00:00'); + await expectInputValue(page, sbEndToTimeSelector, '22:59'); + await expectInputValue(page, filterSchemeNameInputField, 'Single_12b_8_1024_8_2018'); + await page.waitForSelector('#beam-types-checkbox-p-Pb:checked'); + }); + + it('should set filters from runsOverview to the URL', async () => { + const url = 'http://localhost:4000/?page=run-overview&filter[runNumbers]=101&filter[detectors][operator]=and&filter[detectors][values]=ITS&filter[tags][values]=FOOD&' + + 'filter[tags][operation]=and&filter[fillNumbers]=1%2C%203&filter[o2start][from]=1612347060000&filter[o2start][to]=1612357200000&filter[o2end][from]=1612347060000&' + + 'filter[o2end][to]=1612357200000&filter[definitions]=PHYSICS&filter[runDuration][operator]=%3D&filter[runDuration][limit]=90000000' + + '&filter[environmentIds]=Dxi029djX%2C%20TDI59So3d&filter[runTypes]=2&filter[beamModes]=NO%20BEAM&filter[runQualities]=bad&filter[nDetectors][operator]=%3D&' + + 'filter[nDetectors][limit]=1&filter[nEpns][operator]=%3D&filter[nEpns][limit]=10&filter[nFlps][operator]=%3D&filter[nFlps][limit]=10&filter[ctfFileCount][operator]=%3D&' + + 'filter[ctfFileCount][limit]=1&filter[tfFileCount][operator]=%3D&filter[tfFileCount][limit]=1&filter[otherFileCount][operator]=%3D&filter[otherFileCount][limit]=1&' + + 'filter[eorReason][category]=DETECTORS&filter[eorReason][title]=CPV&filter[eorReason][description]=some&filter[magnets][l3]=30003&filter[magnets][dipole]=0&filter[epn]=false&filter[triggerValues]=OFF'; + + await page.goto(url, { waitUntil: 'load' }); + + const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); + const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); + const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); + + const { + fromDateSelector: startFromDateSelector, + toDateSelector: startToDateSelector, + fromTimeSelector: startFromTimeSelector, + toTimeSelector: startToTimeSelector + } = getPeriodInputsSelectors(startPopoverSelector); + + const { + fromDateSelector: endFromDateSelector, + toDateSelector: endToDateSelector, + fromTimeSelector: endFromTimeSelector, + toTimeSelector: endToTimeSelector + } = getPeriodInputsSelectors(endPopoverSelector); + + await openFilteringPanel(page); + await page.waitForSelector('#detector-filter-dropdown-option-ITS:checked'); + await page.waitForSelector('#run-types-dropdown-option-2:checked'); + await page.waitForSelector('#beam-mode-dropdown-option-NO\\ BEAM:checked'); + await page.waitForSelector('#tag-dropdown-option-FOOD:checked'); + await page.waitForSelector('#run-definition-checkbox-PHYSICS:checked'); + await page.waitForSelector('#epnFilterRadioOFF:checked'); + await pressElement(page, '.timeO2Start-filter .popover-trigger'); + await page.waitForSelector('#checkboxes-checkbox-bad:checked'); + await page.waitForSelector('#triggerValue-checkbox-OFF:checked'); + await page.waitForSelector(`${dipolePopoverSelector} .dropdown-option:last-child input:checked`); + await expectInputValue(page, '#duration-operand', '1500'); + await expectInputValue(page, '#runOverviewFilter .runNumbers-textFilter', '101'); + await expectInputValue(page, '.fillNumbers-textFilter', '1, 3'); + await expectInputValue(page, '.environmentIds-textFilter', 'Dxi029djX, TDI59So3d'); + await expectInputValue(page, '#nDetectors-operand', '1'); + await expectInputValue(page, '#nFlps-operand', '10'); + await expectInputValue(page, '#nEpns-operand', '10'); + await expectInputValue(page, '#ctfFileCount-operand', '1'); + await expectInputValue(page, '#tfFileCount-operand', '1'); + await expectInputValue(page, '#otherFileCount-operand', '1'); + await expectInputValue(page, '#eorDescription', 'some'); + await expectInputValue(page, '#eorTitles', 'CPV'); + await expectInputValue(page, '#eorCategories', 'DETECTORS'); + await expectInputValue(page, startFromTimeSelector, '10:11'); + await expectInputValue(page, startToTimeSelector, '13:00'); + await expectInputValue(page, startFromDateSelector, '2021-02-03'); + await expectInputValue(page, startToDateSelector, '2021-02-03'); + await expectInputValue(page, endFromTimeSelector, '10:11'); + await expectInputValue(page, endToTimeSelector, '13:00'); + await expectInputValue(page, endFromDateSelector, '2021-02-03'); + await expectInputValue(page, endToDateSelector, '2021-02-03'); + }); + + it('should set filters from lhcPriodOverview to the URL', async () => { + const url = 'http://localhost:4000/?page=lhc-period-overview&filter[names][]=LHC22a&filter[years][]=2022&filter[pdpBeamTypes][]=PbPb'; + await page.goto(url, { waitUntil: 'load' }); + + await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22a'); + await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(2) input[type=text]', '2022'); + await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(3) input[type=text]', 'PbPb'); + }); + + it('should set filters from qcFlagTypesOverview to the URL', async () => { + const url = 'http://localhost:4000/?page=qc-flag-types-overview&filter[names][]=bad&filter[methods][]=bad&filter[bad]=true'; + await page.goto(url, { waitUntil: 'load' }); + + await expectInputValue(page, '.name-filter input[type=text]', 'bad'); + await expectInputValue(page, '.method-filter input[type=text]', 'bad'); + await page.waitForSelector('#badFilterRadioBad:checked'); + }); + + it('should set filters from runsPerLhcPeriodOverview to the URL', async () => { + const url = 'http://localhost:4000/?page=runs-per-lhc-period&lhcPeriodId=2&filter[runNumbers]=101&filter[fillNumbers]=1%2C%203&filter[o2start][from]=1612347060000&' + + 'filter[o2start][to]=1612357200000&filter[o2end][from]=1612347060000&filter[o2end][to]=1612357200000&filter[magnets][l3]=30003&filter[magnets][dipole]=0&' + + 'filter[muInelasticInteractionRate][operator]=%3D&filter[muInelasticInteractionRate][limit]=100000&filter[inelasticInteractionRateAvg][operator]=%3D&filter[inelasticInteractionRateAvg][limit]=100000'; + await page.goto(url, { waitUntil: 'load' }); + + const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); + const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); + const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); + + const { + fromDateSelector: startFromDateSelector, + toDateSelector: startToDateSelector, + fromTimeSelector: startFromTimeSelector, + toTimeSelector: startToTimeSelector + } = getPeriodInputsSelectors(startPopoverSelector); + + const { + fromDateSelector: endFromDateSelector, + toDateSelector: endToDateSelector, + fromTimeSelector: endFromTimeSelector, + toTimeSelector: endToTimeSelector + } = getPeriodInputsSelectors(endPopoverSelector); + + await expectInputValue(page, '#inelasticInteractionRateAvg-operand', '100000'); + await expectInputValue(page, '#muInelasticInteractionRate-operand', '100000'); + await expectInputValue(page, '#runOverviewFilter .runNumbers-textFilter', '101'); + await expectInputValue(page, '.fillNumbers-textFilter', '1, 3'); + await expectInputValue(page, startFromTimeSelector, '10:11'); + await expectInputValue(page, startToTimeSelector, '13:00'); + await expectInputValue(page, startFromDateSelector, '2021-02-03'); + await expectInputValue(page, startToDateSelector, '2021-02-03'); + await expectInputValue(page, endFromTimeSelector, '10:11'); + await expectInputValue(page, endToTimeSelector, '13:00'); + await expectInputValue(page, endFromDateSelector, '2021-02-03'); + await expectInputValue(page, endToDateSelector, '2021-02-03'); + await page.waitForSelector(`${dipolePopoverSelector} .dropdown-option:last-child input:checked`); + }); + + it('should set filters from DataPassesPerLhcPeriodOverview to the URL', async () => { + const url = 'http://localhost:4000/?page=data-passes-per-lhc-period-overview&lhcPeriodId=2&filter[names][]=LHC22b_apass1&filter[permittedNonPhysicsNames]=test'; + await page.goto(url, { waitUntil: 'load' }); + + await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1'); + await page.waitForSelector('#checkboxes-checkbox-test:checked'); + }); + + it('should set filters from DataPassesPerSimulationPassOverview to the URL', async () => { + const url = 'http://localhost:4000/?page=data-passes-per-simulation-pass-overview&simulationPassId=1&filter[names][]=LHC22b_apass1&filter[permittedNonPhysicsNames]=test'; + await page.goto(url, { waitUntil: 'load' }); + + await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1'); + await page.waitForSelector('#checkboxes-checkbox-test:checked'); + }); + + it('should set filters from AnchoredSimulationPassesOverview to the URL', async () => { + const url = 'http://localhost:4000/?page=anchored-simulation-passes-overview&dataPassId=1&filter[names][]=LHC23k6c'; + await page.goto(url, { waitUntil: 'load' }); + + await expectInputValue(page, '.name-filter input', 'LHC23k6c'); + }); + + it('should set filters from RunsPerSimulationPass to the URL', async () => { + const url = 'http://localhost:4000/?page=runs-per-simulation-pass&simulationPassId=2&filter[o2start][from]=1612347060000&' + + 'filter[o2start][to]=1612357200000&filter[o2end][from]=1612347060000&filter[o2end][to]=1612357200000&' + + 'filter[magnets][l3]=30003&filter[magnets][dipole]=0&filter[inelasticInteractionRateAtStart][operator]=%3D&' + + 'filter[inelasticInteractionRateAtStart][limit]=1&filter[inelasticInteractionRateAtMid][operator]=%3D&' + + 'filter[inelasticInteractionRateAtMid][limit]=1&filter[inelasticInteractionRateAtEnd][operator]=%3D&' + + 'filter[inelasticInteractionRateAtEnd][limit]=1&filter[detectorsQcNotBadFraction][mcReproducibleAsNotBad]=true&' + + 'filter[detectorsQcNotBadFraction][_20][operator]=%3D&filter[detectorsQcNotBadFraction][_20][limit]=0.01&' + + 'filter[detectorsQcNotBadFraction][_17][operator]=%3D&filter[detectorsQcNotBadFraction][_17][limit]=0.01'; + + await page.goto(url, { waitUntil: 'load' }); + + const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); + const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); + const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); + + const { + fromDateSelector: startFromDateSelector, + toDateSelector: startToDateSelector, + fromTimeSelector: startFromTimeSelector, + toTimeSelector: startToTimeSelector + } = getPeriodInputsSelectors(startPopoverSelector); + + const { + fromDateSelector: endFromDateSelector, + toDateSelector: endToDateSelector, + fromTimeSelector: endFromTimeSelector, + toTimeSelector: endToTimeSelector + } = getPeriodInputsSelectors(endPopoverSelector); + + await openFilteringPanel(page); + await expectInputValue(page, '.inelasticInteractionRateAtMid-filter input', '1'); + await expectInputValue(page, '.inelasticInteractionRateAtEnd-filter input', '1'); + await expectInputValue(page, '.inelasticInteractionRateAtStart-filter input', '1'); + await expectInputValue(page, startFromTimeSelector, '10:11'); + await expectInputValue(page, startToTimeSelector, '13:00'); + await expectInputValue(page, startFromDateSelector, '2021-02-03'); + await expectInputValue(page, startToDateSelector, '2021-02-03'); + await expectInputValue(page, endFromTimeSelector, '10:11'); + await expectInputValue(page, endToTimeSelector, '13:00'); + await expectInputValue(page, endFromDateSelector, '2021-02-03'); + await expectInputValue(page, endToDateSelector, '2021-02-03'); + await page.waitForSelector(`${dipolePopoverSelector} .dropdown-option:last-child input:checked`); + await page.waitForSelector('#mcReproducibleAsNotBadToggle input:checked'); + + + // These two are detectorQCNotBadFraction[_id] filters. There are a dozen more, but they are all identical hence why only these were tested + await expectInputValue(page, '.QC-SPECIFIC-filter input', '1'); + await expectInputValue(page, '.ACO-filter input', '1'); + }); + + it('should set filters from RunsPerSimulationPass to the URL', async () => { + const url = 'http://localhost:4000/?page=runs-per-data-pass&dataPassId=1&filter[detectors][operator]=and&filter[detectors][values]=ITS&' + + 'filter[tags][values]=FOOD&filter[tags][operation]=and&filter[o2start][from]=1612347060000&filter[o2start][to]=1612357200000&' + + 'filter[o2end][from]=1612347060000&filter[o2end][to]=1612357200000&filter[runDuration][operator]=%3D&filter[runDuration][limit]=90000000&' + + 'filter[magnets][l3]=30003&filter[magnets][dipole]=0&filter[muInelasticInteractionRate][operator]=%3D&filter[muInelasticInteractionRate][limit]=1&' + + 'filter[inelasticInteractionRateAvg][operator]=%3D&filter[inelasticInteractionRateAvg][limit]=1&filter[detectorsQcNotBadFraction][mcReproducibleAsNotBad]=true&' + + 'filter[detectorsQcNotBadFraction][_20][operator]=%3D&filter[detectorsQcNotBadFraction][_20][limit]=0.01&filter[detectorsQcNotBadFraction][_17][operator]=%3D&' + + 'filter[detectorsQcNotBadFraction][_17][limit]=0.01&filter[gaq][notBadFraction][operator]=%3D&filter[gaq][notBadFraction][limit]=0.01&filter[gaq][mcReproducibleAsNotBad]=true'; + + await page.goto(url, { waitUntil: 'load' }); + + const dipolePopoverSelector = await getPopoverSelector(await page.$('.aliceL3AndDipoleCurrent-filter .popover-trigger')); + const startPopoverSelector = await getPopoverSelector(await page.$('.timeO2Start-filter .popover-trigger')); + const endPopoverSelector = await getPopoverSelector(await page.$('.timeO2End-filter .popover-trigger')); + + const { + fromDateSelector: startFromDateSelector, + toDateSelector: startToDateSelector, + fromTimeSelector: startFromTimeSelector, + toTimeSelector: startToTimeSelector + } = getPeriodInputsSelectors(startPopoverSelector); + + const { + fromDateSelector: endFromDateSelector, + toDateSelector: endToDateSelector, + fromTimeSelector: endFromTimeSelector, + toTimeSelector: endToTimeSelector + } = getPeriodInputsSelectors(endPopoverSelector); + + await openFilteringPanel(page); + await expectInputValue(page, startFromTimeSelector, '10:11'); + await expectInputValue(page, startToTimeSelector, '13:00'); + await expectInputValue(page, startFromDateSelector, '2021-02-03'); + await expectInputValue(page, startToDateSelector, '2021-02-03'); + await expectInputValue(page, endFromTimeSelector, '10:11'); + await expectInputValue(page, endToTimeSelector, '13:00'); + await expectInputValue(page, endFromDateSelector, '2021-02-03'); + await expectInputValue(page, endToDateSelector, '2021-02-03'); + await expectInputValue(page, '#duration-operand', '1500'); + await expectInputValue(page, '.muInelasticInteractionRate-filter input', '1'); + await expectInputValue(page, '.inelasticInteractionRateAvg-filter input', '1'); + await expectInputValue(page, '.globalAggregatedQuality-filter input', '1'); + await fillInput(page, '.ACO-filter input', '1', ['change']); + await fillInput(page, '.QC-SPECIFIC-filter input', '1', ['change']); + + await page.waitForSelector('#detector-filter-dropdown-option-ITS'); + await page.waitForSelector('#tag-dropdown-option-FOOD'); + await page.waitForSelector(`${dipolePopoverSelector} .dropdown-option:last-child input:checked`); + await page.waitForSelector('#mcReproducibleAsNotBadToggle input:checked'); + }); + + after(async () => await defaultAfter(page, browser)); +} diff --git a/test/public/components/filtersPopoverPanel.test.js b/test/public/components/filtersPopoverPanel.test.js new file mode 100644 index 0000000000..53df9e8ee3 --- /dev/null +++ b/test/public/components/filtersPopoverPanel.test.js @@ -0,0 +1,70 @@ +/** + * @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. + */ + +const { expect } = require('chai'); +const { defaultBefore, defaultAfter, pressElement, takeScreenshot, expectInputValue } = require('../defaults.js'); + +module.exports = () => { + let page; + let browser; + let context; + let url; + + before(async () => { + [page, browser, url] = await defaultBefore(page, browser); + context = browser.defaultBrowserContext(); + context.overridePermissions(url, ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']); + }); + + it('Should copy url when clicking filer copy button', async () => { + const url = 'http://localhost:4000/?page=lhc-period-overview&filter[names][]=name&filter[years][]=100&filter[pdpBeamTypes][]=PbPb'; + await page.goto(url, { waitUntil: 'load' }); + await takeScreenshot(page, 'test'); + await pressElement(page, '#copy-filters', true); + + const clipboardContents = await page.evaluate(async () => decodeURI(await navigator.clipboard.readText())); + expect(clipboardContents).to.equal(url); + }); + + it('Should set filters when pressing paste active filters button', async () => { + const url = 'http://localhost:4000/?page=lhc-period-overview&filter[names][]=name&filter[years][]=100&filter[pdpBeamTypes][]=PbPb'; + + await page.evaluate(async (url) => await navigator.clipboard.writeText(url), url); + await pressElement(page, '#paste-filters', true); + + const actualUrl = page.url(); + expect(actualUrl).to.equal(url); + + await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'name'); + await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(2) input[type=text]', '100'); + await expectInputValue(page, 'div.flex-row.items-baseline:nth-of-type(3) input[type=text]', 'PbPb'); + }); + + it('Should reset filters when pressing the reset all filters button', async () => { + const url = 'http://localhost:4000/?page=lhc-period-overview&filter[names][]=name&filter[years][]=100&filter[pdpBeamTypes][]=PbPb'; + + await page.goto(url, { waitUntil: 'load' }); + + await pressElement(page, '.dropdown #reset-filters', true); + const actualUrl = page.url(); + expect(actualUrl).to.equal('http://localhost:4000/?page=lhc-period-overview'); + + await expectInputValue(page, '.name-filter input', ''); + await expectInputValue(page, '.year-filter input', ''); + await expectInputValue(page, '.pdpBeamTypes-filter input', ''); + }); + + after(async () => { + await defaultAfter(page, browser); + }); +}; diff --git a/test/public/components/index.js b/test/public/components/index.js index 5e06743c62..794ae79252 100644 --- a/test/public/components/index.js +++ b/test/public/components/index.js @@ -12,7 +12,11 @@ */ const NavBarSuite = require('./navBar.test') +const WarningSuite = require('./warnings.test') +const FiltersPanelSuite = require('./filtersPopoverPanel.test') module.exports = () => { describe('Navbar component', NavBarSuite); + describe('Warning component', WarningSuite) + describe('FiltersPanelPopover component', FiltersPanelSuite) }; diff --git a/test/public/components/warnings.test.js b/test/public/components/warnings.test.js new file mode 100644 index 0000000000..5fb32457f3 --- /dev/null +++ b/test/public/components/warnings.test.js @@ -0,0 +1,85 @@ +/** + * @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. + */ + +const { expect } = require('chai'); +const { + defaultBefore, + defaultAfter, + getInnerText, + pressElement, + goToPage, +} = require('../defaults.js'); + +module.exports = () => { + let page; + let browser; + let url; + let context; + + before(async () => { + [page, browser, url] = await defaultBefore(page, browser); + context = browser.defaultBrowserContext(); + context.overridePermissions(url, ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']); + }); + + it('Should show warning when a filter in the url is not recognised', async () => { + await page.goto('http://localhost:4000/?page=log-overview&filter[fake]=fake', { waitUntil: 'load' }); + const warningText = await getInnerText(await page.waitForSelector('.alert-warning > ul')); + + expect(warningText).to.equal('Unknown Filters:\nThe filters: [\'fake\']; are not reccognised. Check if they are spelled correctly.'); + }); + + it('Should remove warnings entry after clicking the x icon', async () => { + await pressElement(page, '.alert-warning .btn', true); + const warning = await page.$('.alert-warning'); + + expect(warning).to.be.null; + }); + + it('Should show warning when a url filter cannot be parsed/normalized', async () => { + await page.goto('http://localhost:4000/?page=run-overview&filter[detectors][operator]=or&filter[detecttors][values]=CTP&filter[tagss][values]=CPV&filter[tags][operation]=or', { waitUntil: 'load' }); + const unparsableWarningText = await getInnerText(await page.waitForSelector('.alert-warning > ul > li:nth-of-type(1)')); + const unknownFilterWarningText = await getInnerText(await page.waitForSelector('.alert-warning > ul > li:nth-of-type(2)')); + + // The tags and detectors filters will fail if it has no value. + // However, if the url also contains its operator, it will still attempt to set the filters, which would fail, hence the warning + expect(unparsableWarningText).to.equal('Unparsable Filters:\nThe following filter-value pairs could not be parsed: [detectors[operator]=or, tags[operation]=or]'); + expect(unknownFilterWarningText).to.equal('Unknown Filters:\nThe filters: [\'detecttors\', \'tagss\']; are not reccognised. Check if they are spelled correctly.'); + }); + + it('Should show warning if an unparsable filter url is pasted', async () => { + const url = 'unparsable url'; + await goToPage(page, 'log-overview'); + + await page.evaluate(async (url) => await navigator.clipboard.writeText(url), url); + await pressElement(page, '.dropdown #paste-filters', true); + + const warningText = await getInnerText(await page.waitForSelector('.alert-warning > ul')); + expect(warningText).to.equal('Unparseable URL:\nURL could not be parsed. URL: unparsable url'); + }); + + it('Should show warning if filter url is pasted on the wong page', async () => { + const url = 'http://localhost:4000/?page=lhc-period-overview&filter[names][]=name'; + await goToPage(page, 'log-overview'); + + await page.evaluate(async (url) => await navigator.clipboard.writeText(url), url); + await pressElement(page, '.dropdown #paste-filters', true); + + const warningText = await getInnerText(await page.waitForSelector('.alert-warning > ul')); + expect(warningText).to.equal('Page-Filter mismatch:\nThe filters provided were meant for lhc-period-overview'); + }); + + after(async () => { + await defaultAfter(page, browser); + }); +}; diff --git a/test/public/dataPasses/overviewPerLhcPeriod.test.js b/test/public/dataPasses/overviewPerLhcPeriod.test.js index 4ab08e8de0..a6215dd989 100644 --- a/test/public/dataPasses/overviewPerLhcPeriod.test.js +++ b/test/public/dataPasses/overviewPerLhcPeriod.test.js @@ -164,7 +164,7 @@ module.exports = () => { it('should successfully apply data pass name filter', async () => { await pressElement(page, '#openFilterToggle'); - await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1', ['change']); + await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1'); await expectColumnValues(page, 'name', ['deleted\nLHC22b_apass1\nSkimmable']); diff --git a/test/public/dataPasses/overviewPerSimulationPass.test.js b/test/public/dataPasses/overviewPerSimulationPass.test.js index 27b6c2d2c9..188ec17dc2 100644 --- a/test/public/dataPasses/overviewPerSimulationPass.test.js +++ b/test/public/dataPasses/overviewPerSimulationPass.test.js @@ -113,7 +113,7 @@ module.exports = () => { it('should successfully apply data pass name filter', async () => { await pressElement(page, '#openFilterToggle'); - await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1', ['change']); + await fillInput(page, 'div.flex-row.items-baseline:nth-of-type(1) input[type=text]', 'LHC22b_apass1'); await expectColumnValues(page, 'name', ['deleted\nLHC22b_apass1\nSkimmable']); await pressElement(page, '#reset-filters', true); diff --git a/test/public/defaults.js b/test/public/defaults.js index d841c4bc05..9dac35f2bb 100644 --- a/test/public/defaults.js +++ b/test/public/defaults.js @@ -198,14 +198,15 @@ module.exports.waitForTableLength = waitForTableToLength; * Wait for the total number of elements to be the expected one * * @param {puppeteer.Page} page The puppeteer page where the table is located - * @param {number} amount the expected amount of items + * @param {number} amount the expected amount of items. If amount is 0 it is converted to undefined, as empty tables don't display a row count * @return {Promise} resolves once the expected amount is present */ module.exports.waitForTableTotalRowsCountToEqual = async (page, amount) => { try { + amount = amount === 0 ? undefined : `${amount}`; await page.waitForSelector('#totalRowsCount'); await page.waitForFunction( - (amount) => document.querySelector('#totalRowsCount').innerText === `${amount}`, + (amount) => document.querySelector('#totalRowsCount')?.innerText === amount, {}, amount, ); @@ -275,12 +276,26 @@ exports.waitForNavigation = waitForNavigation; * @returns {Promise} Whether the element was clickable or not. */ module.exports.pressElement = async (page, selector, jsClick = false) => { - const elementHandler = await page.waitForSelector(selector); + await page.waitForFunction( + (sel, isJsClick) => { + const element = document.querySelector(sel); + + if (!element) { + return false; + } + // Moving the click to outside the function causes it to fail for unknown reasons + if (isJsClick) { + element.click(); + } - if (jsClick) { - await elementHandler.evaluate((element) => element.click()); - } else { - await elementHandler.click(selector); + return true; + }, + {}, + selector, jsClick + ); + + if (!jsClick) { + await page.click(selector); } }; @@ -653,14 +668,24 @@ module.exports.checkColumnBalloon = async (page, rowIndex, columnIndex) => { * @return {Promise} resolves once the value has been typed */ module.exports.fillInput = async (page, inputSelector, value, events = ['input']) => { - await page.waitForSelector(inputSelector); - await page.evaluate((inputSelector, value, events) => { + await page.waitForFunction((inputSelector, value, events) => { const element = document.querySelector(inputSelector); + + if (!element) { + return false; + } + element.value = value; + for (const eventKey of events) { element.dispatchEvent(new Event(eventKey, { bubbles: true })); } - }, inputSelector, value, events); + + return true; + }, + {}, + inputSelector, value, events + ); }; /** @@ -855,10 +880,10 @@ module.exports.testTableSortingByColumn = async (page, columnId) => { * @return {Promise} resolve once data was successfully validated */ module.exports.validateTableData = async (page, validators) => { - await page.waitForSelector('table tbody'); for (const [columnId, validator] of validators) { + await page.waitForSelector(`table tbody .column-${columnId}`); + const columnData = await getColumnCellsInnerTexts(page, columnId); - expect(columnData, `Too few values for column ${columnId} or there is no such column`).to.be.length.greaterThan(0); expect( columnData.every((cellData) => validator(cellData)), `Invalid data in column ${columnId}: (${columnData})`, @@ -977,3 +1002,14 @@ module.exports.resetFilters = async (page) => { { timeout: 5000 }, ); }; + +/** + * Fuction that waits for a button to become active + * @param {puppeteer.page} page page handler + * @param {string} selector Css selector for the button. + */ +module.exports.waitForButtonToBecomeActive = async (page, selector) => await page.waitForFunction((sel) => { + const button = document.querySelector(sel); + return button && !button.disabled; + }, {}, selector); + diff --git a/test/public/index.js b/test/public/index.js index 8ebdc23e68..293d9a9e94 100644 --- a/test/public/index.js +++ b/test/public/index.js @@ -27,9 +27,11 @@ const ComponentsSuite = require('./components'); const SimulationPassesSuite = require('./simulationPasses'); const QcFlagTypesSuite = require('./qcFlagTypes'); const QcFlagsSuite = require('./qcFlags'); +const FilterSuite = require('./Filters'); module.exports = () => { describe('Components', ComponentsSuite); + describe('Filters', FilterSuite); describe('LhcPeriods', LhcPeriodsSuite); describe('LhcFills', LhcFillsSuite); describe('Logs', LogsSuite); diff --git a/test/public/logs/overview.test.js b/test/public/logs/overview.test.js index 39119d7ef1..f2b0d14f89 100644 --- a/test/public/logs/overview.test.js +++ b/test/public/logs/overview.test.js @@ -34,6 +34,9 @@ const { waitForEmptyTable, waitForTableTotalRowsCountToEqual, waitForTableFirstRowIndexToEqual, + resetFilters, + getPeriodInputsSelectors, + openFilteringPanel, } = require('../defaults.js'); const { resetDatabaseContent } = require('../../utilities/resetDatabaseContent.js'); @@ -88,184 +91,8 @@ module.exports = () => { await checkColumnBalloon(page, 1, 5); }); - it('can filter by log title', async () => { - await waitForTableLength(page, 10); - - await pressElement(page, '#openFilterToggle'); - await page.waitForSelector('#titleFilterText'); - - await fillInput(page, '#titleFilterText', 'first'); - await waitForTableLength(page, 1); - - await fillInput(page, '#titleFilterText', 'bogusbogusbogus'); - await waitForEmptyTable(page); - - await pressElement(page, '#reset-filters'); - }); - - it('should successfully provide an input to filter on log content', async () => { - await waitForTableLength(page, 10); - - await fillInput(page, '#contentFilterText', 'particle'); - await waitForTableLength(page, 2); - - await fillInput(page, '#titleFilterText', 'this-content-do-not-exists-anywhere'); - await waitForEmptyTable(page); - - await pressElement(page, '#reset-filters'); - }); - - it('can filter by log author', async () => { - await waitForTableLength(page, 10); - - await fillInput(page, '#authorFilterText', 'Jane'); - await waitForEmptyTable(page); - - await pressElement(page, '#reset-filters'); - - await waitForTableLength(page, 10); - - await fillInput(page, '#authorFilterText', 'John'); - await waitForTableLength(page, 5); - - await pressElement(page, '#reset-filters'); - }); - - it('should successfully provide an easy-to-access button to filter in/out anonymous logs', async () => { - // Close the filter panel - await pressElement(page, '#openFilterToggle'); - await waitForTableTotalRowsCountToEqual(page, 119); - - const authors = await getColumnCellsInnerTexts(page, 'author'); - expect(authors.some((author) => author === 'Anonymous')).to.be.true; - - await pressElement(page, '#main-action-bar > div:nth-child(1) .switch'); - await waitForTableTotalRowsCountToEqual(page, 117); - - await checkColumnValuesWithRegex(page, 'author', '^Anonymous$', { - negation: true, - }); - - await pressElement(page, '#main-action-bar > div:nth-child(1) .switch'); - await waitForTableTotalRowsCountToEqual(page, 119); - await checkColumnValuesWithRegex(page, 'author', '^Anonymous$', { - valuesCheckingMode: 'some', - }); - }); - - it('can filter by creation date', async () => { - await pressElement(page, '#openFilterToggle'); - - await waitForTableTotalRowsCountToEqual(page, 119); - - // Insert a minimum date into the filter - const limit = '2020-02-02'; - await fillInput(page, '#createdFilterFrom', limit); - await fillInput(page, '#createdFilterTo', limit); - await waitForTableLength(page, 1); - - await pressElement(page, '#reset-filters'); - }); - - it('can filter by tags', async () => { - await waitForTableTotalRowsCountToEqual(page, 119); - - await pressElement(page, '.tags-filter .dropdown-trigger'); - - // Select the second available filter and wait for the changes to be processed - const firstCheckboxId = 'tag-dropdown-option-DPG'; - await pressElement(page, `#${firstCheckboxId}`, true); - await waitForTableLength(page, 1); - - // Deselect the filter and wait for the changes to process - await pressElement(page, `#${firstCheckboxId}`, true); - await waitForTableLength(page, 10); - - // Select the first available filter and the second one at once - const secondCheckboxId = 'tag-dropdown-option-FOOD'; - await pressElement(page, `#${firstCheckboxId}`, true); - await pressElement(page, `#${secondCheckboxId}`, true); - await waitForEmptyTable(page); - - // Set the filter operation to "OR" - await pressElement(page, '#tag-filter-combination-operator-radio-button-or', true); - await waitForTableLength(page, 3); - - await pressElement(page, '#reset-filters'); - }); - - it('can filter by environments', async () => { - await waitForTableLength(page, 10); - - await fillInput(page, '.environments-filter input', '8E4aZTjY'); - await waitForTableLength(page, 3); - - await pressElement(page, '#reset-filters'); - await waitForTableLength(page, 10); - - await fillInput(page, '.environments-filter input', 'abcdefgh'); - await waitForEmptyTable(page); - - await pressElement(page, '#reset-filters'); - }); - - it('can search for tag in the dropdown', async () => { - await pressElement(page, '.tags-filter .dropdown-trigger'); - - { - await fillInput(page, '#tag-dropdown-search-input', 'food'); - const popoverTrigger = await page.$('.tags-filter .popover-trigger'); - const popoverSelector = await getPopoverSelector(popoverTrigger); - await page.waitForSelector(`${popoverSelector} .dropdown-option:nth-child(2)`, { hidden: true }); - const options = await page.$$(`${popoverSelector} .dropdown-option`); - expect(await options[0].evaluate((option) => option.innerText)).to.equal('FOOD'); - } - { - await fillInput(page, '#tag-dropdown-search-input', 'fOoD'); - const popoverTrigger = await page.$('.tags-filter .popover-trigger'); - const popoverSelector = await getPopoverSelector(popoverTrigger); - await page.waitForSelector(`${popoverSelector} .dropdown-option:nth-child(2)`, { hidden: true }); - const options = await page.$$(`${popoverSelector} .dropdown-option`); - expect(await options[0].evaluate((option) => option.innerText)).to.equal('FOOD'); - } - }); - - it('can filter by run number', async () => { - await waitForTableLength(page, 10); - - // Insert some text into the filter - await fillInput(page, '#runsFilterText', '1, 2'); - await waitForTableLength(page, 2); - - await pressElement(page, '#reset-filters'); - await waitForTableLength(page, 10); - - await fillInput(page, '#runsFilterText', '1234567890'); - await waitForEmptyTable(page); - - await pressElement(page, '#reset-filters'); - }); - - it('can filter by lhc fill number', async () => { - await waitForTableLength(page, 10); - - await fillInput(page, '#lhcFillsFilter', '1, 6'); - await waitForTableLength(page, 1); - - await pressElement(page, '#reset-filters'); - await waitForTableLength(page, 10); - - await fillInput(page, '#lhcFillsFilter', '1234567890'); - await waitForEmptyTable(page); - - await pressElement(page, '#reset-filters'); - }); - it('can sort by columns in ascending and descending manners', async () => { - await waitForTableLength(page, 10); - // Close the filter panel - await pressElement(page, '#openFilterToggle'); await waitForFirstRowToHaveId(page, 'row119'); await page.waitForSelector('th#title'); @@ -525,4 +352,158 @@ module.exports = () => { await waitForNavigation(page, () => pressElement(page, `${popoverSelector} a`)) expectUrlParams(page, { page: 'run-detail', runNumber: 2 }) }); + + describe('Filters', () => { + before(async () => { + await goToPage(page, 'log-overview'); + }) + + beforeEach(async () => { + await resetFilters(page); + await waitForTableLength(page, 10); + }) + + it('can filter by log title', async () => { + await fillInput(page, '.title-textFilter', 'first', ['change']); + await waitForTableLength(page, 1); + + await fillInput(page, '.title-textFilter', 'bogusbogusbogus', ['change']); + await waitForEmptyTable(page); + }); + + it('can filter by log author', async () => { + await fillInput(page, '#authorFilterText', 'Jane', ['change']); + await waitForEmptyTable(page); + + await resetFilters(page); + + await waitForTableLength(page, 10); + + await fillInput(page, '#authorFilterText', 'John', ['change']); + await waitForTableLength(page, 5); + }); + + it('should successfully provide an input to filter on log content', async () => { + await fillInput(page, '.content-textFilter', 'particle', ['change']); + await waitForTableLength(page, 2); + + await fillInput(page, '.title-textFilter', 'this-content-do-not-exists-anywhere', ['change']); + await waitForEmptyTable(page); + }); + + it('should successfully provide an easy-to-access button to filter in/out anonymous logs', async () => { + await waitForTableTotalRowsCountToEqual(page, 119); + const authors = await getColumnCellsInnerTexts(page, 'author'); + + expect(authors.some((author) => author === 'Anonymous')).to.be.true; + + await pressElement(page, '#main-action-bar > div:nth-child(1) .switch'); + await waitForTableTotalRowsCountToEqual(page, 117); + await checkColumnValuesWithRegex(page, 'author', '^Anonymous$', { + negation: true, + }); + + await pressElement(page, '#main-action-bar > div:nth-child(1) .switch'); + await waitForTableTotalRowsCountToEqual(page, 119); + await checkColumnValuesWithRegex(page, 'author', '^Anonymous$', { + valuesCheckingMode: 'some', + }); + }); + + it('can filter by creation date', async () => { + const popoverTrigger = '.createdAt-filter .popover-trigger'; + const popOverSelector = await getPopoverSelector(await page.$(popoverTrigger)); + + await waitForTableTotalRowsCountToEqual(page, 119); + + const { fromDateSelector, toDateSelector, fromTimeSelector, toTimeSelector } = getPeriodInputsSelectors(popOverSelector); + + const limit = '2020-02-02'; + + await fillInput(page, fromDateSelector, limit, ['change']); + await fillInput(page, toDateSelector, limit, ['change']); + await fillInput(page, fromTimeSelector, '11:00', ['change']); + await fillInput(page, toTimeSelector, '12:00', ['change']); + + await waitForTableLength(page, 1); + }); + + it('can filter by tags', async () => { + await openFilteringPanel(page); + await pressElement(page, '.tags-filter .dropdown-trigger'); + + // Select the second available filter and wait for the changes to be processed + const firstCheckboxId = 'tag-dropdown-option-DPG'; + await pressElement(page, `#${firstCheckboxId}`, true); + await waitForTableLength(page, 1); + + // Deselect the filter and wait for the changes to process + await pressElement(page, `#${firstCheckboxId}`, true); + await waitForTableLength(page, 10); + + // Select the first available filter and the second one at once + const secondCheckboxId = 'tag-dropdown-option-FOOD'; + await pressElement(page, `#${firstCheckboxId}`, true); + await pressElement(page, `#${secondCheckboxId}`, true); + await waitForEmptyTable(page); + + // Set the filter operation to "OR" + await pressElement(page, '#tag-filter-combination-operator-radio-button-or', true); + await waitForTableLength(page, 3); + }); + + it('can filter by environments', async () => { + await fillInput(page, '.environments-filter input', '8E4aZTjY', ['change']); + await waitForTableLength(page, 3); + await resetFilters(page); + await waitForTableLength(page, 10); + + await fillInput(page, '.environments-filter input', 'abcdefgh', ['change']); + await waitForEmptyTable(page); + }); + + it('can search for tag in the dropdown', async () => { + await pressElement(page, '.tags-filter .dropdown-trigger'); + + { + await fillInput(page, '#tag-dropdown-search-input', 'food'); + const popoverTrigger = await page.$('.tags-filter .popover-trigger'); + const popoverSelector = await getPopoverSelector(popoverTrigger); + await page.waitForSelector(`${popoverSelector} .dropdown-option:nth-child(2)`, { hidden: true }); + const options = await page.$$(`${popoverSelector} .dropdown-option`); + expect(await options[0].evaluate((option) => option.innerText)).to.equal('FOOD'); + } + { + await fillInput(page, '#tag-dropdown-search-input', 'fOoD'); + const popoverTrigger = await page.$('.tags-filter .popover-trigger'); + const popoverSelector = await getPopoverSelector(popoverTrigger); + await page.waitForSelector(`${popoverSelector} .dropdown-option:nth-child(2)`, { hidden: true }); + const options = await page.$$(`${popoverSelector} .dropdown-option`); + expect(await options[0].evaluate((option) => option.innerText)).to.equal('FOOD'); + } + }); + + it('can filter by run number', async () => { + // Insert some text into the filter + await fillInput(page, '.runNumbers-textFilter', '1, 2', ['change']); + await waitForTableLength(page, 2); + await resetFilters(page); + + await waitForTableLength(page, 10); + + await fillInput(page, '.runNumbers-textFilter', '1234567890', ['change']); + await waitForEmptyTable(page); + }); + + it('can filter by lhc fill number', async () => { + await fillInput(page, '.fillNumbers-textFilter', '1, 6', ['change']); + await waitForTableLength(page, 1); + await resetFilters(page); + + await waitForTableLength(page, 10); + + await fillInput(page, '.fillNumbers-textFilter', '1234567890', ['change']); + await waitForEmptyTable(page); + }); + }) }; diff --git a/test/public/qcFlagTypes/overview.test.js b/test/public/qcFlagTypes/overview.test.js index 0bf4d519cc..77b4fe656b 100644 --- a/test/public/qcFlagTypes/overview.test.js +++ b/test/public/qcFlagTypes/overview.test.js @@ -112,7 +112,7 @@ module.exports = () => { it('should successfully apply QC flag type bad filter', async () => { await waitForTableLength(page, 7); - await pressElement(page, '.bad-filter input[type=checkbox]', true); + await pressElement(page, '#badFilterRadioBad', true); await checkColumnValuesWithRegex(page, 'bad', '^Yes$'); await pressElement(page, '#reset-filters', true); diff --git a/test/public/qcFlags/synchronousOverview.test.js b/test/public/qcFlags/synchronousOverview.test.js index e72c4eca91..16c2900904 100644 --- a/test/public/qcFlags/synchronousOverview.test.js +++ b/test/public/qcFlags/synchronousOverview.test.js @@ -22,6 +22,7 @@ const { expectUrlParams, waitForNavigation, getColumnCellsInnerTexts, + getPopoverContent, } = require('../defaults.js'); const { expect } = chai; @@ -59,14 +60,21 @@ module.exports = () => { it('shows correct datatypes in respective columns', async () => { // eslint-disable-next-line require-jsdoc - const validateDate = (date) => date === '-' || !isNaN(dateAndTime.parse(date, 'DD/MM/YYYY hh:mm:ss')); + const validateDate = (date) => date === '-' || !isNaN(dateAndTime.parse(date, 'DD/MM/YYYY, hh:mm:ss')); const tableDataValidators = { flagType: (flagType) => flagType && flagType !== '-', - createdBy: (userName) => userName && userName !== '-', - from: (timestamp) => timestamp === 'Whole run coverage' || timestamp === 'Since run start' || validateDate(timestamp), - to: (timestamp) => timestamp === 'Whole run coverage' || timestamp === 'Until run end' || validateDate(timestamp), - createdAt: validateDate, - updatedAt: validateDate, + from: (cellContent) => { + const match = cellContent.match(/^From:\s*(.+)\nTo:\s*(.+)$/); + if (!match) return false; + const [, from, to] = match; + return (['Whole run coverage', 'Since run start'].includes(from) || validateDate(from)) + && (['Whole run coverage', 'Until run end'].includes(to) || validateDate(to)); + }, + deleted: (value) => value === 'Yes' || value === 'No', + createdBy: (cellContent) => { + const match = cellContent.match(/^By:\s*(.+)\nAt:\s*(.+)$/); + return match && match[1] !== '-' && validateDate(match[2]); + }, }; await validateTableData(page, new Map(Object.entries(tableDataValidators))); @@ -76,8 +84,34 @@ module.exports = () => { it('Should display the correct items counter at the bottom of the page', async () => { await expectInnerText(page, '#firstRowIndex', '1'); - await expectInnerText(page, '#lastRowIndex', '2'); - await expectInnerText(page, '#totalRowsCount', '2'); + await expectInnerText(page, '#lastRowIndex', '3'); + await expectInnerText(page, '#totalRowsCount', '3'); + }); + + it('should display Comment tooltip with full information', async () => { + let popoverTrigger = await page.$(`#row100-comment .popover-trigger`); + expect(popoverTrigger).to.not.be.null; + + const popoverContent = await getPopoverContent(popoverTrigger); + expect(popoverContent).to.equal('first part good'); + }); + + it('should display CreatedBy tooltip with full information', async () => { + let popoverTrigger = await page.$(`#row100-createdBy .popover-trigger`); + expect(popoverTrigger).to.not.be.null; + + const popoverContent = await getPopoverContent(popoverTrigger); + expect(popoverContent).to.equal('By: Jan JansenAt: 12/08/2024, 12:00:00'); + }); + + it('should display correct Deleted text colour', async () => { + const deletedCell = await page.$('#row103-deleted-text:nth-child(1)'); + + const deletedCellText = await page.evaluate(cell => cell.textContent.trim(), deletedCell); + expect(deletedCellText).to.equal('Yes'); + + const deletedCellFirstChildClass = await page.evaluate(cell => cell.firstElementChild.className, deletedCell); + expect(deletedCellFirstChildClass).to.include('danger'); }); it('can navigate to run details page from breadcrumbs link', async () => { diff --git a/test/public/runs/detail.test.js b/test/public/runs/detail.test.js index fa94143746..515f36d8d8 100644 --- a/test/public/runs/detail.test.js +++ b/test/public/runs/detail.test.js @@ -54,7 +54,7 @@ const banIconPath = */ const goToRunDetails = async (page, runNumber) => { await waitForNavigation(page, () => pressElement(page, '#run-overview')); - await fillInput(page, '.run-numbers-filter', `${runNumber},${runNumber}`, ['change']); + await fillInput(page, '.runNumbers-textFilter', `${runNumber},${runNumber}`, ['change']); await waitForTableLength(page, 1); return waitForNavigation(page, () => pressElement(page, `a[href="?page=run-detail&runNumber=${runNumber}"]`)); }; @@ -208,10 +208,10 @@ module.exports = () => { expect(eorReasons).to.lengthOf(2); expect(await eorReasons[0].evaluate((element) => element.innerText)) - .to.equal('DETECTORS - TPC - Some Reason other than selected plus one'); + .to.equal('DETECTORS - TPC - Some Reason other than selected plus one\nAnonymous'); expect(await eorReasons[1].evaluate((element) => element.innerText)) - .to.equal('DETECTORS - CPV - A new EOR reason'); + .to.equal('DETECTORS - CPV - A new EOR reason\nAnonymous'); }); it('should successfully revert the update end of run reasons', async () => { @@ -234,10 +234,19 @@ module.exports = () => { expect(eorReasons).to.lengthOf(2); expect(await eorReasons[0].evaluate((element) => element.innerText)) - .to.equal('DETECTORS - TPC - Some Reason other than selected plus one'); + .to.equal('DETECTORS - TPC - Some Reason other than selected plus one\nAnonymous'); expect(await eorReasons[1].evaluate((element) => element.innerText)) - .to.equal('DETECTORS - CPV - A new EOR reason'); + .to.equal('DETECTORS - CPV - A new EOR reason\nAnonymous'); + }); + + it('should display lastEditedName tooltip with "Last edited by" on formatRunEorReason', async () => { + const eorReasonElement = await page.$('#eor-reasons .eor-reason'); + const popoverTrigger = await eorReasonElement.$('.popover-trigger'); + expect(popoverTrigger).to.not.be.null; + + const popoverContent = await getPopoverContent(popoverTrigger); + expect(popoverContent).to.equal('Last edited by'); }); it('should successfully update inelasticInteractionRate values of PbPb run', async () => { diff --git a/test/public/runs/overview.test.js b/test/public/runs/overview.test.js index 807b821ffc..66333186d8 100644 --- a/test/public/runs/overview.test.js +++ b/test/public/runs/overview.test.js @@ -40,6 +40,7 @@ const { getColumnCellsInnerTexts, resetFilters, openFilteringPanel, + waitForButtonToBecomeActive, } = require('../defaults.js'); const { RUN_QUALITIES, RunQualities } = require('../../../lib/domain/enums/RunQualities.js'); const { resetDatabaseContent } = require('../../utilities/resetDatabaseContent.js'); @@ -600,7 +601,7 @@ module.exports = () => { it('Should successfully filter runs by their trigger value', async () => { await navigateToRunsOverview(page); - const filterInputSelectorPrefix = '#triggerValueCheckbox'; + const filterInputSelectorPrefix = '#triggerValue-checkbox-'; const offFilterSelector = `${filterInputSelectorPrefix}OFF`; const ltuFilterSelector = `${filterInputSelectorPrefix}LTU`; @@ -670,7 +671,7 @@ module.exports = () => { }; // First filter validation on the main page. - await filterOnRun('#runOverviewFilter .run-numbers-filter'); + await filterOnRun('#runOverviewFilter .runNumbers-textFilter'); // Validate if the filter tab value is equal to the main page value. await expectInputValue(page, filterPanelRunNumbersInputSelector, inputValue); await resetFilters(page); @@ -697,7 +698,7 @@ module.exports = () => { await expectColumnValues(page, 'runNumber', ['10']); }; - await filterOnRun('#runOverviewFilter .run-numbers-filter'); + await filterOnRun('#runOverviewFilter .runNumbers-textFilter'); await expectInputValue(page, filterPanelRunNumbersInputSelector, inputValue); await resetFilters(page); await filterOnRun(filterPanelRunNumbersInputSelector); @@ -705,7 +706,7 @@ module.exports = () => { it('should successfully filter on a list of fill numbers and inform the user about it', async () => { await page.evaluate(() => window.model.disableInputDebounce()); - const filterInputSelector = '.fill-numbers-filter'; + const filterInputSelector = '.fillNumbers-textFilter'; expect(await page.$eval(filterInputSelector, (input) => input.placeholder)).to.equal('e.g. 7966, 7954, 7948...'); await fillInput(page, filterInputSelector, '1, 3', ['change']); @@ -713,7 +714,7 @@ module.exports = () => { }); it('should successfully filter on a list of environment ids and inform the user about it', async () => { - const filterInputSelector = '.environment-ids-filter'; + const filterInputSelector = '.environmentIds-textFilter'; expect(await page.$eval(filterInputSelector, (input) => input.placeholder)).to.equal('e.g. Dxi029djX, TDI59So3d...'); await fillInput(page, filterInputSelector, 'Dxi029djX, TDI59So3d', ['change']); @@ -885,6 +886,7 @@ module.exports = () => { let exportModal = await page.$('#export-data-modal'); expect(exportModal).to.be.null; + await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); await page.waitForSelector('#export-data-modal', { timeout: 5000 }); exportModal = await page.$('#export-data-modal'); @@ -893,6 +895,7 @@ module.exports = () => { }); it('should successfully display information when export will be truncated', async () => { + await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR, true); const truncatedExportWarning = await page.waitForSelector('#export-data-modal #truncated-export-warning'); @@ -912,6 +915,7 @@ module.exports = () => { }); it('should successfully export filtered runs', async () => { + await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); const targetFileName = 'data.json'; // First export @@ -950,9 +954,9 @@ module.exports = () => { await page.waitForSelector(badFilterSelector); await page.$eval(badFilterSelector, (element) => element.click()); await page.waitForSelector('tbody tr:nth-child(2)'); - await page.waitForSelector(EXPORT_RUNS_TRIGGER_SELECTOR); ///// Download + await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); await page.$eval(EXPORT_RUNS_TRIGGER_SELECTOR, (button) => button.click()); await page.waitForSelector('#export-data-modal', { timeout: 5000 }); diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js index f45d004e55..4d1edbb4d6 100644 --- a/test/public/runs/runsPerDataPass.overview.test.js +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -152,6 +152,17 @@ module.exports = () => { .to.be.equal('Missing 3 verifications'); }); + it('should display detector columns in RCT order (AOT/MUON after physical)', async () => { + const headers = await page.$$eval( + 'table thead th', + (ths) => ths.map((th) => th.id).filter(Boolean), + ); + + // See DetectorOrders.RCT in detectorOrders.js + expect(headers.indexOf('VTX')).to.be.greaterThan(headers.indexOf('ZDC')); + expect(headers.indexOf('MUD')).to.be.greaterThan(headers.indexOf('ZDC')); + }); + it('should ignore QC flags created by services in QC summaries of AOT and MUON ', async () => { await navigateToRunsPerDataPass(page, 2, 1, 3); // apass await expectInnerText(page, '#row106-VTX-text', '100'); @@ -394,10 +405,10 @@ module.exports = () => { const exportContent = fs.readFileSync(path.resolve(downloadPath, targetFileName)).toString(); expect(exportContent.trim()).to.be.eql([ - 'runNumber;VTX;CPV', + 'runNumber;CPV;VTX', '108;"";""', - '107;"";"Good (from: 1565290800000 to: 1565359260000) | Limited Acceptance MC Reproducible (from: 1565269140000 to: 1565290800000)"', - '106;"Good (from: 1565269200000 to: 1565304200000) | Good (from: 1565324200000 to: 1565359200000)";"Limited Acceptance MC Reproducible (from: 1565304200000 to: 1565324200000) | Limited acceptance (from: 1565329200000 to: 1565334200000) | Bad (from: 1565339200000 to: 1565344200000)"', + '107;"Limited Acceptance MC Reproducible (from: 1565269140000 to: 1565290800000) | Good (from: 1565290800000 to: 1565359260000)";""', + '106;"Limited Acceptance MC Reproducible (from: 1565304200000 to: 1565324200000) | Limited acceptance (from: 1565329200000 to: 1565334200000) | Bad (from: 1565339200000 to: 1565344200000)";"Good (from: 1565269200000 to: 1565304200000) | Good (from: 1565324200000 to: 1565359200000)"', ].join('\r\n')); fs.unlinkSync(path.resolve(downloadPath, targetFileName)); }); @@ -412,7 +423,6 @@ module.exports = () => { await waitForTableLength(page, 2); await expectColumnValues(page, 'runNumber', ['108', '107']); - await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters'); await waitForTableLength(page, 3); await expectColumnValues(page, 'runNumber', ['108', '107', '106']); @@ -427,7 +437,6 @@ module.exports = () => { await pressElement(page, '#detector-filter-dropdown-option-CPV', true); await expectColumnValues(page, 'runNumber', ['2', '1']); - await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters'); await expectColumnValues(page, 'runNumber', ['55', '2', '1']); }); @@ -443,8 +452,6 @@ module.exports = () => { await expectColumnValues(page, 'runNumber', ['106']); - await page.waitForSelector('#openFilterToggle'); - await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters'); await expectColumnValues(page, 'runNumber', ['108', '107', '106']); }); @@ -466,7 +473,6 @@ module.exports = () => { await expectColumnValues(page, 'runNumber', ['55', '1']); - await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters'); await expectColumnValues(page, 'runNumber', ['55', '2', '1']); }); @@ -480,7 +486,6 @@ module.exports = () => { await expectColumnValues(page, 'runNumber', ['54']); - await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters'); await expectColumnValues(page, 'runNumber', ['105', '56', '54', '49']); }); @@ -503,7 +508,6 @@ module.exports = () => { await fillInput(page, `#${property}-operand`, value, ['change']); await expectColumnValues(page, 'runNumber', expectedRuns); - await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters', true); await expectColumnValues(page, 'runNumber', ['105', '56', '54', '49']); }); @@ -512,8 +516,6 @@ module.exports = () => { it('should successfully apply gaqNotBadFraction filters', async () => { await navigateToRunsPerDataPass(page, 2, 1, 3); - await pressElement(page, '#openFilterToggle', true); - await page.waitForSelector('#gaqNotBadFraction-operator'); await page.select('#gaqNotBadFraction-operator', '<='); await fillInput(page, '#gaqNotBadFraction-operand', '80', ['change']); @@ -522,7 +524,6 @@ module.exports = () => { await pressElement(page, '#mcReproducibleAsNotBadToggle input', true); await expectColumnValues(page, 'runNumber', []); - await pressElement(page, '#openFilterToggle', true); await pressElement(page, '#reset-filters', true); await expectColumnValues(page, 'runNumber', ['108', '107', '106']); }); @@ -531,12 +532,8 @@ module.exports = () => { await page.waitForSelector('#detectorsQc-for-1-notBadFraction-operator'); await page.select('#detectorsQc-for-1-notBadFraction-operator', '<='); await fillInput(page, '#detectorsQc-for-1-notBadFraction-operand', '90', ['change']); - await expectColumnValues(page, 'runNumber', ['106']); - - await pressElement(page, '#mcReproducibleAsNotBadToggle input', true); await expectColumnValues(page, 'runNumber', ['107', '106']); - await pressElement(page, '#openFilterToggle', true); await pressElement(page, '#reset-filters', true); await expectColumnValues(page, 'runNumber', ['108', '107', '106']); }); @@ -550,7 +547,6 @@ module.exports = () => { await fillInput(page, '#muInelasticInteractionRate-operand', 0.03, ['change']); await expectColumnValues(page, 'runNumber', ['106']); - await pressElement(page, '#openFilterToggle'); await pressElement(page, '#reset-filters', true); await expectColumnValues(page, 'runNumber', ['108', '107', '106']); }); @@ -609,7 +605,6 @@ module.exports = () => { it('should successfully disable QC flag creation when data pass is frozen', async () => { await waitForTableLength(page, 3); await page.waitForSelector('.select-multi-flag', { hidden: true }); - await pressElement(page, '#actions-dropdown-button .popover-trigger'); await page.waitForSelector('#set-qc-flags-trigger[disabled]'); await page.waitForSelector('#row107-ACO-text button[disabled]'); }); @@ -623,16 +618,10 @@ module.exports = () => { it('should successfully enable QC flag creation when data pass is un-frozen', async () => { await waitForTableLength(page, 3); - await pressElement(page, '.select-multi-flag'); - await pressElement(page, '#actions-dropdown-button .popover-trigger'); - await page.waitForSelector('#set-qc-flags-trigger[disabled]', { hidden: true }); + await page.waitForSelector('#set-qc-flags-trigger[disabled]'); await page.waitForSelector('#set-qc-flags-trigger'); await page.waitForSelector('#row107-ACO-text a'); }); - - after(async () => { - await pressElement(page, '#actions-dropdown-button .popover-trigger', true); - }); }); it('should successfully not display button to discard all QC flags for the data pass', async () => { @@ -654,8 +643,8 @@ module.exports = () => { // Press again actions dropdown to re-trigger render await pressElement(page, '#actions-dropdown-button .popover-trigger', true); setConfirmationDialogToBeAccepted(page); - await pressElement(page, `${popoverSelector} button:nth-child(4)`, true); const oldTable = await page.waitForSelector('table').then((table) => table.evaluate((t) => t.innerHTML)); + await pressElement(page, `${popoverSelector} button:nth-child(4)`, true); await pressElement(page, '#actions-dropdown-button .popover-trigger', true); await waitForTableLength(page, 3, undefined, oldTable); // Processing of data might take a bit of time, but then expect QC flag button to be there diff --git a/test/public/runs/runsPerLhcPeriod.overview.test.js b/test/public/runs/runsPerLhcPeriod.overview.test.js index f38dc635a9..77d1ec4a24 100644 --- a/test/public/runs/runsPerLhcPeriod.overview.test.js +++ b/test/public/runs/runsPerLhcPeriod.overview.test.js @@ -32,6 +32,7 @@ const { expectColumnValues, openFilteringPanel, resetFilters, + waitForButtonToBecomeActive } = require('../defaults.js'); const { RUN_QUALITIES, RunQualities } = require('../../../lib/domain/enums/RunQualities.js'); const { resetDatabaseContent } = require('../../utilities/resetDatabaseContent.js'); @@ -75,6 +76,7 @@ module.exports = () => { after(async () => { [page, browser] = await defaultAfter(page, browser); }); + const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; it('loads the page successfully', async () => { const response = await goToPage(page, 'runs-per-lhc-period', { queryParameters: { lhcPeriodId: 1 } }); @@ -130,6 +132,17 @@ module.exports = () => { await expectInnerText(page, '#row56-FT0', '83'); }); + it('should display detector columns in RCT order (AOT/MUON after physical) for synchronous flags', async () => { + // Note test starts already on synchronous flags tab + const headers = await page.$$eval( + 'table thead th', + (ths) => ths.map((th) => th.id).filter(Boolean), + ); + + // See DetectorOrders.RCT in detectorOrders.js + expect(headers.indexOf('MUD')).to.be.greaterThan(headers.indexOf('ZDC')); + }); + it('should successfully sort by runNumber in ascending and descending manners', async () => { await testTableSortingByColumn(page, 'runNumber'); }); @@ -188,25 +201,19 @@ module.exports = () => { // Revert changes for next test await page.evaluate(() => { // eslint-disable-next-line no-undef - model.runs.perLhcPeriodOverviewModel.pagination.itemsPerPage = 10; + model.runs.perLhcPeriodOverviewModel.pagination.itemsPerPage = 2; }); - await waitForTableLength(page, 4); + await waitForTableLength(page, 2); }); - const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; it('should successfully export all runs per lhc Period', async () => { - await page.evaluate(() => { - // eslint-disable-next-line no-undef - model.runs.perLhcPeriodOverviewModel.pagination.itemsPerPage = 2; - }); - const targetFileName = 'data.json'; - + await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); // First export await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR, true); - await page.waitForSelector('select.form-control', { timeout: 200 }); - await page.waitForSelector('option[value=runNumber]', { timeout: 200 }); + await page.waitForSelector('select.form-control'); + await page.waitForSelector('option[value=runNumber]'); await page.select('select.form-control', 'runQuality', 'runNumber', 'definition', 'lhcPeriod'); await expectInnerText(page, '#send:enabled', 'Export'); @@ -275,9 +282,9 @@ module.exports = () => { await navigateToRunsPerLhcPeriod(page, 1, 4); const targetFileName = 'data.csv'; - + await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); // Export - await pressElement(page, '#export-data-trigger'); + await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR); await page.waitForSelector('#export-data-modal'); await page.waitForSelector('#send:disabled'); await page.waitForSelector('.form-control'); diff --git a/test/public/runs/runsPerSimulationPass.overview.test.js b/test/public/runs/runsPerSimulationPass.overview.test.js index b7b1c725fd..f3c2d47316 100644 --- a/test/public/runs/runsPerSimulationPass.overview.test.js +++ b/test/public/runs/runsPerSimulationPass.overview.test.js @@ -31,6 +31,7 @@ const { testTableSortingByColumn, waitForTableLength, expectColumnValues, + waitForButtonToBecomeActive, } = require('../defaults.js'); const { expect } = chai; @@ -74,6 +75,8 @@ module.exports = () => { [page, browser] = await defaultAfter(page, browser); }); + const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; + it('loads the page successfully', async () => { const response = await goToPage(page, 'runs-per-simulation-pass', { queryParameters: { simulationPassId: 2 } }); @@ -137,6 +140,17 @@ module.exports = () => { await qcFlagService.delete(tmpQcFlag.id); }); + it('should display detector columns in RCT order (AOT/MUON after physical)', async () => { + const headers = await page.$$eval( + 'table thead th', + (ths) => ths.map((th) => th.id).filter(Boolean), + ); + + // See DetectorOrders.RCT in detectorOrders.js + expect(headers.indexOf('VTX')).to.be.greaterThan(headers.indexOf('ZDC')); + expect(headers.indexOf('MUD')).to.be.greaterThan(headers.indexOf('ZDC')); + }); + it('should successfully sort by runNumber in ascending and descending manners', async () => { await testTableSortingByColumn(page, 'runNumber'); }); @@ -203,7 +217,6 @@ module.exports = () => { await fillInput(page, '#detectorsQc-for-1-notBadFraction-operand', '90', ['change']); await expectColumnValues(page, 'runNumber', ['106']); - await pressElement(page, '#openFilterToggle', true); await pressElement(page, '#reset-filters', true); await expectColumnValues(page, 'runNumber', ['107', '106', '105']); }); @@ -217,18 +230,16 @@ module.exports = () => { await fillInput(page, '#detectorsQc-for-1-notBadFraction-operand', '90', ['change']); await expectColumnValues(page, 'runNumber', ['106']); - await pressElement(page, '#openFilterToggle', true); await pressElement(page, '#reset-filters', true); await expectColumnValues(page, 'runNumber', ['107', '106', '105']); }); it('should successfully export runs', async () => { await navigateToRunsPerSimulationPass(page, 1, 2, 3); - const EXPORT_RUNS_TRIGGER_SELECTOR = '#export-data-trigger'; - const targetFileName = 'data.json'; // Export + await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR); await page.waitForSelector('#export-data-modal'); await page.waitForSelector('#send:disabled'); @@ -259,7 +270,8 @@ module.exports = () => { const targetFileName = 'data.csv'; // Export - await pressElement(page, '#export-data-trigger'); + await waitForButtonToBecomeActive(page, EXPORT_RUNS_TRIGGER_SELECTOR); + await pressElement(page, EXPORT_RUNS_TRIGGER_SELECTOR); await page.waitForSelector('#export-data-modal'); await page.waitForSelector('#send:disabled'); await page.waitForSelector('.form-control'); From b4600f63fc4dcc70ca6e000aa2b910feba834dd6 Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:05:02 +0200 Subject: [PATCH 71/72] [O2B-1567] Correct incorrect & complicated merge --- .../ActiveColumns/getGAQSummaryDisplay.js | 2 +- .../RunsPerDataPassOverviewPage.js | 57 ++++++++----------- lib/server/services/gaq/GaqWorker.js | 3 +- 3 files changed, 28 insertions(+), 34 deletions(-) diff --git a/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js b/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js index 9d9c4a71b8..985292ab80 100644 --- a/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js +++ b/lib/public/views/Runs/ActiveColumns/getGAQSummaryDisplay.js @@ -34,7 +34,7 @@ const invalidatedDisplay = () => h( tooltip(h( '.f7', { id: 'clock-icon' }, - iconClock(), + h('span', { style: 'color: var(--color-orange)' }, iconClock()), ), 'Summary is invalid. New summary will be calculated shortly. Please wait and refresh the page.'), ); diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js index b1d0577b78..605f343632 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js @@ -144,38 +144,12 @@ export const RunsPerDataPassOverviewPage = ({ ), visible: true, - format: (_, { runNumber }) => { - const runGaqSummary = remoteGaqSummary[runNumber]; - const spinnerEl = h('.flex-row.items-center.justify-center.black', spinner({ size: 2, absolute: false })); - - return runGaqSummary.match({ - Success: (gaqSummary) => { - const gaqDisplay = - gaqSummary?.undefinedQualityPeriodsCount === 0 - ? getQcSummaryDisplay(gaqSummary) - : h('button.btn.btn-primary.w-100', 'GAQ'); - - return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, runNumber }); - }, - Loading: () => tooltip(spinnerEl), - NotAsked: () => tooltip(spinnerEl), - Failure: () => - frontLink( - h('button.btn.btn-primary.w-100', [ - 'GAQ', - h( - '.d-inline-block.va-t-bottom', - tooltip( - h('.f7', iconWarning()), - 'GAQ Summary failed, please click to view GAQ flags', - ), - ), - ]), - 'gaq-flags', - { dataPassId, runNumber }, - ), - }); - }, + format: (_, { runNumber }) => getGAQSummaryDisplay( + remoteGaqSummary[runNumber], + dataPassId, + runNumber, + () => perDataPassOverviewModel.recalculateGaqSummary([runNumber]), + ), filter: ({ filteringModel }) => numericalComparisonFilter(filteringModel.get('gaq').notBadFraction, { step: 0.1, selectorPrefix: 'gaqNotBadFraction' }), filterTooltip: 'not-bad fraction expressed as a percentage', @@ -260,6 +234,25 @@ export const RunsPerDataPassOverviewPage = ({ }, ), sessionService.hasAccess([BkpRoles.DPG_ASYNC_QC_ADMIN]) && [ + h( + 'button.btn.btn-primary.w-100.h2', + { + ...recalculateGaqActionState.match({ + Loading: () => ({ + disabled: true, + title: 'Loading', + }), + Other: () => ({}), + }), + onclick: () => { + if (confirm('Recalculate the GAQ summary for the entire data pass?')) { + perDataPassOverviewModel.recalculateGaqSummary(); + } + }, + id: 'recalculate-gaq-summary-trigger', + }, + 'Recalculate GAQ summary', + ), h( 'button.btn.btn-danger', { diff --git a/lib/server/services/gaq/GaqWorker.js b/lib/server/services/gaq/GaqWorker.js index a01a1a5abe..949f1a9a2f 100644 --- a/lib/server/services/gaq/GaqWorker.js +++ b/lib/server/services/gaq/GaqWorker.js @@ -93,6 +93,7 @@ class GaqWorker { const batchSize = clamp(this._nextBatchSize ?? minBatchSize); try { + const start = Date.now(); const { processedCount, totalInvalidCount } = await gaqService.popNInvalidSummaryAndRecalculate(batchSize); // Adapt next tick's batch size to the observed backlog (clamped to the bounds) @@ -100,7 +101,7 @@ class GaqWorker { if (processedCount > 0) { this._logger.infoMessage(`Processed ${processedCount} out of ${totalInvalidCount} ` + - `invalidated GAQ summaries (batch size: ${batchSize})`); + `invalidated GAQ summaries (batch size: ${batchSize}) in ${Date.now() - start}ms`); } // Overflow: backlog still exceeds the max even at the largest batch we'll fetch From a437aa5f7895f7fa63fa8cfcea1b7a54e0a4d6fe Mon Sep 17 00:00:00 2001 From: Isaac Hill <71404865+isaachilly@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:11:52 +0200 Subject: [PATCH 72/72] [O2B-1567] Fix failing tests --- lib/server/controllers/qcFlag.controller.js | 2 +- lib/server/services/gaq/GaqService.js | 2 ++ test/api/gaqSummary.test.js | 2 +- test/lib/server/services/gaq/GaqService.test.js | 9 ++++++++- test/public/runs/runsPerDataPass.overview.test.js | 13 +++++++++---- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/server/controllers/qcFlag.controller.js b/lib/server/controllers/qcFlag.controller.js index 41fea83726..535755a19c 100644 --- a/lib/server/controllers/qcFlag.controller.js +++ b/lib/server/controllers/qcFlag.controller.js @@ -395,7 +395,7 @@ const getGaqSummaryHandler = async (request, response) => { const { dataPassId, mcReproducibleAsNotBad = false, runNumber } = validatedDTO.query; const data = await gaqService.getSummary(dataPassId, { mcReproducibleAsNotBad, runNumber }); - response.json({ data }); + response.json({ data: data ?? {} }); } catch (error) { updateExpressResponseFromNativeError(response, error); } diff --git a/lib/server/services/gaq/GaqService.js b/lib/server/services/gaq/GaqService.js index c7e6f9f6fa..5917eead79 100644 --- a/lib/server/services/gaq/GaqService.js +++ b/lib/server/services/gaq/GaqService.js @@ -89,6 +89,8 @@ class GaqService { [QcSummarProperties.MC_REPRODUCIBLE]: false, [QcSummarProperties.MISSING_VERIFICATIONS]: null, [QcSummarProperties.UNDEFINED_QUALITY_PERIODS_COUNT]: null, + [QcSummarProperties.NOT_COMPUTABLE]: summary.notComputable, + [QcSummarProperties.INVALIDATED_AT]: summary.invalidatedAt, }; } return { diff --git a/test/api/gaqSummary.test.js b/test/api/gaqSummary.test.js index 3a946bdb92..b6479522e8 100644 --- a/test/api/gaqSummary.test.js +++ b/test/api/gaqSummary.test.js @@ -190,7 +190,7 @@ module.exports = () => { }); it('should return 200 with a not-calculated summary that is invalidated', async () => { - await GaqSummaryRepository.insert({ dataPassId: 1, runNumber: 106, invalidatedAt: new Date() }); + await GaqSummaryRepository.upsert({ dataPassId: 1, runNumber: 106, invalidatedAt: new Date() }); const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=1&mcReproducibleAsNotBad=false&runNumber=106'); diff --git a/test/lib/server/services/gaq/GaqService.test.js b/test/lib/server/services/gaq/GaqService.test.js index ab69692000..d40ea7763a 100644 --- a/test/lib/server/services/gaq/GaqService.test.js +++ b/test/lib/server/services/gaq/GaqService.test.js @@ -166,8 +166,10 @@ module.exports = () => { expect(result).to.have.all.keys( 'badEffectiveRunCoverage', 'explicitlyNotBadEffectiveRunCoverage', + 'invalidatedAt', 'mcReproducible', 'missingVerificationsCount', + 'notComputable', 'undefinedQualityPeriodsCount', ); expect(result.missingVerificationsCount).to.equal(3); @@ -190,6 +192,8 @@ module.exports = () => { mcReproducible: false, missingVerificationsCount: null, undefinedQualityPeriodsCount: null, + notComputable: true, + invalidatedAt: result.invalidatedAt, }); }); @@ -203,6 +207,8 @@ module.exports = () => { mcReproducible: false, missingVerificationsCount: null, undefinedQualityPeriodsCount: null, + notComputable: false, + invalidatedAt: result.invalidatedAt, }); }); @@ -225,7 +231,8 @@ module.exports = () => { await GaqSummaryRepository.invalidate(dataPassId, runNumber); const result = await gaqService.getSummary(dataPassId, { runNumber }); - expect(result).to.deep.equal(expected); + expect(result).to.deep.equal({ ...expected, invalidatedAt: result.invalidatedAt }); + expect(result.invalidatedAt).to.not.be.null; }); it('should not leak rows from other data passes into the result', async () => { diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js index 6d46acde18..c172ad0dc8 100644 --- a/test/public/runs/runsPerDataPass.overview.test.js +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -776,7 +776,7 @@ module.exports = () => { }); it('should create GAQ summary invalidation entries when the specific run recalculate button is clicked', async () => { - await expectInnerText(page, '#row107-globalAggregatedQuality', 'GAQ'); + await expectInnerText(page, '#row107-globalAggregatedQuality', '76'); await pressElement(page, '#row107 [title="Recalculate GAQ for this run"]'); @@ -792,7 +792,7 @@ module.exports = () => { }); it('should create GAQ summary invalidation entries when the recalculate for the whole dataPass button is clicked', async () => { - await expectInnerText(page, '#row107-globalAggregatedQuality', 'GAQ'); + await expectInnerText(page, '#row107-globalAggregatedQuality', '76'); await pressElement(page, '#actions-dropdown-button .popover-trigger', true); setConfirmationDialogToBeAccepted(page); @@ -808,6 +808,10 @@ module.exports = () => { }); it('should update the GAQ cell appropriately after each step in the GAQ summary lifecycle', async () => { + // remove it from the db to simulate a run without a GAQ summary as it gets seeded in resetDatabaseContent + await GaqSummaryRepository.removeAll({ where: { runNumber: 107 } }); + await navigateToRunsPerDataPass(page, 2, 1, 3); + await page.waitForSelector('#row107-globalAggregatedQuality #warning-icon'); await expectInnerText(page, '#row107-globalAggregatedQuality', 'GAQ'); @@ -834,8 +838,9 @@ module.exports = () => { const invalidatedIconPopoverContent = await getPopoverContent(await page.waitForSelector('#row107-globalAggregatedQuality .popover-trigger')); expect(invalidatedIconPopoverContent).to.equal('Summary is invalid. New summary will be calculated shortly. Please wait and refresh the page.'); - - await gaqWorker.recalculateGaqSummaries(1); + + await gaqWorker.resume(); + await gaqWorker.recalculateGaqSummaries(1, 1); await navigateToRunsPerDataPass(page, 2, 1, 3);