diff --git a/lib/database/repositories/QcFlagRepository.js b/lib/database/repositories/QcFlagRepository.js index 12f7f6c65e..ca07d1660a 100644 --- a/lib/database/repositories/QcFlagRepository.js +++ b/lib/database/repositories/QcFlagRepository.js @@ -105,9 +105,10 @@ class QcFlagRepository extends Repository { * and informtion about missing and unverified flags * * @param {number} dataPassId the id of a data-pass + * @param {number} [runNumber] run number to filter by * @return {Promise>} resolves with the map between run number and the corresponding run GAQ summary */ - async getGaqCoverages(dataPassId) { + async getGaqCoverages(dataPassId, runNumber) { const blockAggregationQuery = ` SELECT gaq_periods.data_pass_id, @@ -147,7 +148,8 @@ class QcFlagRepository extends Repository { AND (qcfep.to IS NULL OR qcfep.\`to\` > gaq_periods.\`from\`) WHERE gaq_periods.data_pass_id = :dataPassId - GROUP BY gaq_periods.data_pass_id, gaq_periods.run_number, gaq_periods.\`from\`, gaq_periods.to + ${runNumber !== undefined && runNumber !== null ? 'AND gaq_periods.run_number = :runNumber' : ''} + GROUP BY gaq_periods.data_pass_id, gaq_periods.run_number, gaq_periods.\`from\`, gaq_periods.\`to\` `; const summaryQuery = ` @@ -164,14 +166,20 @@ class QcFlagRepository extends Repository { FROM (${blockAggregationQuery}) AS gaq GROUP BY gaq.data_pass_id, gaq.run_number; `; - const [rows] = await this.model.sequelize.query(summaryQuery, { replacements: { dataPassId } }); + + const replacements = { dataPassId }; + if (runNumber !== undefined && runNumber !== null) { + replacements.runNumber = runNumber; + } + + const [rows] = await this.model.sequelize.query(summaryQuery, { replacements }); const entries = rows.map(({ run_number, bad_coverage, mcr_coverage, good_coverage, flags_list, - verifiedd_flags_list, + verified_flags_list, undefined_quality_periods_count, }) => [ run_number, @@ -180,7 +188,7 @@ class QcFlagRepository extends Repository { mcReproducibleCoverage: parseFloat(mcr_coverage ?? '0'), goodCoverage: parseFloat(good_coverage ?? '0'), flagsIds: [...new Set(flags_list?.split(','))], - verifiedFlagsIds: [...new Set(verifiedd_flags_list?.split(','))], + verifiedFlagsIds: [...new Set(verified_flags_list?.split(','))], undefinedQualityPeriodsCount: undefined_quality_periods_count, }, ]); diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js index 863840d869..c9a68e285b 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewModel.js @@ -22,6 +22,7 @@ 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'; const ALL_CPASS_PRODUCTIONS_REGEX = /cpass\d+/; @@ -58,6 +59,12 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo this._markAsSkimmableRequestResult$ = new ObservableData(RemoteData.notAsked()); this._markAsSkimmableRequestResult$.bubbleTo(this); + this._gaqSummary$ = new ObservableData({}); + this._gaqSummary$.bubbleTo(this); + + this._gaqSummarySource = null; + this._gaqSequenceAbortController = null; + this._skimmableRuns$ = new ObservableData(RemoteData.notAsked()); this._skimmableRuns$.bubbleTo(this); @@ -71,6 +78,8 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo this._discardAllQcFlagsActionState$ = new ObservableData(RemoteData.notAsked()); this._discardAllQcFlagsActionState$.bubbleTo(this); + + this._item$.observe(() => this._fetchGaqSummaryForCurrentRuns()); } /** @@ -321,6 +330,87 @@ export class RunsPerDataPassOverviewModel extends FixedPdpBeamTypeRunsOverviewMo } } + /** + * Cancel all ongoing and future GAQ summary fetches + * @return {void} promise + */ + _abortGaqFetches() { + // Aborts the overall sequence fetch, i.e. stops further individual run fetches + this._gaqSequenceAbortController?.abort(); + + // Aborts individual run fetch in-flight + this._gaqSummarySource?._abortController?.abort(); + } + + /** + * Fetch GAQ summary for given data pass and run + * @param {number} [runNumber] run number to filter by + * @return {Promise} resolves once data has been fetched + */ + async _fetchGaqSummary(runNumber) { + this._gaqSummarySource = new RemoteDataSource(); + // Pipe the result into the correct slot in the gaqSummary$ observable + this._gaqSummarySource.pipe({ + setCurrent: (remoteData) => { + const current = this._gaqSummary$.getCurrent(); + this._gaqSummary$.setCurrent({ + ...current, + [runNumber]: remoteData.apply({ Success: (response) => response.data }), + }); + }, + }); + const url = buildUrl('/api/qcFlags/summary/gaq', { + dataPassId: this._dataPassId, + mcReproducibleAsNotBad: this._mcReproducibleAsNotBad, + runNumber: runNumber, + }); + await this._gaqSummarySource.fetch(url); + } + + /** + * Fetch GAQ summary for currently displayed (paginated) runs + * @return {void} + */ + _fetchGaqSummaryForCurrentRuns() { + // Stop any previous fetch (quickly changing filters, pagination, etc) + this._abortGaqFetches(); + + // Reset abort controller + this._gaqSequenceAbortController = new AbortController(); + const { signal } = this._gaqSequenceAbortController; + + this._item$.getCurrent().match({ + Success: async (runs) => { + 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() }; + } + this._gaqSummary$.setCurrent(gaqSummary); + + // Trigger GAQ summary fetch for each run + for (const runNumber of runNumbers) { + if (signal.aborted) { + return; + } + + try { + await this._fetchGaqSummary(runNumber); + } catch { + if (signal.aborted) { + return; + } + } + } + }, + Other: () => { + // Don't fetch if runs haven't loaded successfully yet + }, + }); + } + /** * Fetch skimmable runs for given data pass * @return {Promise} resolves once data are fetched diff --git a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js index 6b664df587..5292dd3c3e 100644 --- a/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js +++ b/lib/public/views/Runs/RunPerDataPass/RunsPerDataPassOverviewPage.js @@ -25,6 +25,7 @@ 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'; @@ -102,6 +103,7 @@ export const RunsPerDataPassOverviewPage = ({ detectors: remoteDetectors, dataPass: remoteDataPass, qcSummary: remoteQcSummary, + gaqSummary: remoteGaqSummary, displayOptions, dataPassId, sortModel, @@ -118,6 +120,9 @@ export const RunsPerDataPassOverviewPage = ({ return h( '.intermediate-flex-column', + { onremove: () => { + perDataPassOverviewModel._abortGaqFetches(); + } }, mergeRemoteData([remoteDataPass, remoteRuns, remoteDetectors, remoteQcSummary]).match({ NotAsked: () => null, Failure: (errors) => errorAlert(errors), @@ -160,14 +165,21 @@ export const RunsPerDataPassOverviewPage = ({ ), visible: true, format: (_, { runNumber }) => { - const gaqDisplay = h('button.btn.btn-primary.w-100', [ - 'GAQ', - h( - '.d-inline-block.va-t-bottom', - tooltip(h('.f7', iconWarning()), 'GAQ Summary is disabled, please click to view GAQ flags'), - ), - ]); - return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, 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'); + + return frontLink(gaqDisplay, 'gaq-flags', { dataPassId, runNumber }); + }, + Loading: () => tooltip(gaqLoadingSpinner, 'Loading GAQ summary...'), + NotAsked: () => tooltip(gaqLoadingSpinner, 'Loading GAQ summary...'), + Failure: () => tooltip(iconWarning(), 'Failed to load GAQ summary'), + }); }, filter: ({ filteringModel }) => numericalComparisonFilter( filteringModel.get('gaq[notBadFraction]'), diff --git a/lib/server/controllers/qcFlag.controller.js b/lib/server/controllers/qcFlag.controller.js index 373b6ca587..7a088a3eb3 100644 --- a/lib/server/controllers/qcFlag.controller.js +++ b/lib/server/controllers/qcFlag.controller.js @@ -382,17 +382,18 @@ const getGaqQcFlagsHandler = async (request, response) => { const getGaqSummaryHandler = async (request, response) => { const validatedDTO = await dtoValidator( DtoFactory.queryOnly(Joi.object({ - dataPassId: Joi.number().required(), + dataPassId: Joi.number().positive().required(), mcReproducibleAsNotBad: Joi.boolean().optional(), + runNumber: Joi.number().positive().required(), })), request, response, ); if (validatedDTO) { try { - const { dataPassId, mcReproducibleAsNotBad = false } = validatedDTO.query; + const { dataPassId, mcReproducibleAsNotBad = false, runNumber } = validatedDTO.query; - const data = await gaqService.getSummary(dataPassId, { mcReproducibleAsNotBad }); + const data = await gaqService.getSummary(dataPassId, { mcReproducibleAsNotBad, runNumber }); response.json({ data }); } catch (error) { updateExpressResponseFromNativeError(response, error); diff --git a/lib/server/services/qualityControlFlag/GaqService.js b/lib/server/services/qualityControlFlag/GaqService.js index 1f1c9befc9..337a8aebbb 100644 --- a/lib/server/services/qualityControlFlag/GaqService.js +++ b/lib/server/services/qualityControlFlag/GaqService.js @@ -44,11 +44,12 @@ class GaqService { * @param {object} [options] additional options * @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 */ - async getSummary(dataPassId, { mcReproducibleAsNotBad = false } = {}) { + async getSummary(dataPassId, { mcReproducibleAsNotBad = false, runNumber } = {}) { await getOneDataPassOrFail({ id: dataPassId }); - const gaqCoverages = await QcFlagRepository.getGaqCoverages(dataPassId); + const gaqCoverages = await QcFlagRepository.getGaqCoverages(dataPassId, runNumber); const gaqSummary = Object.entries(gaqCoverages).map(([ runNumber, { @@ -71,6 +72,14 @@ class GaqService { }, ]); + /** + * 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]; + } + return Object.fromEntries(gaqSummary); } diff --git a/test/api/qcFlags.test.js b/test/api/qcFlags.test.js index 2b98c603ea..ee67961439 100644 --- a/test/api/qcFlags.test.js +++ b/test/api/qcFlags.test.js @@ -556,22 +556,62 @@ module.exports = () => { relations, ); - const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=3'); + 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; expect(data).to.be.eql({ - 54: { missingVerificationsCount: 1, mcReproducible: true, badEffectiveRunCoverage: 1, explicitlyNotBadEffectiveRunCoverage: 0, undefinedQualityPeriodsCount: 0, }, - }); + ); }); - it('should return 400 when bad query parameter provided', async () => { - const response = await request(server).get('/api/qcFlags/summary/gaq'); + it('should return empty 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({}); + }); + + it('should return 400 if dataPassId is not positive', async () => { + const response = await request(server).get('/api/qcFlags/summary/gaq?dataPassId=-1&runNumber=54'); + 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=3&runNumber=-10'); + 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=54'); + expect(response.status).to.equal(400); + const { errors } = response.body; + expect(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=3&runNumber=abc'); + expect(response.status).to.equal(400); + const { errors } = response.body; + 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); const { errors } = response.body; const titleError = errors.find((err) => err.source.pointer === '/data/attributes/query/dataPassId'); diff --git a/test/public/runs/runsPerDataPass.overview.test.js b/test/public/runs/runsPerDataPass.overview.test.js index 98f69a3648..e8ea387b87 100644 --- a/test/public/runs/runsPerDataPass.overview.test.js +++ b/test/public/runs/runsPerDataPass.overview.test.js @@ -147,8 +147,9 @@ module.exports = () => { await expectInnerText(page, '#row106-globalAggregatedQuality', 'GAQ'); - expect(await getPopoverInnerText(await page.waitForSelector('#row106-globalAggregatedQuality .popover-trigger'))) - .to.be.equal('GAQ Summary is disabled, please click to view GAQ flags'); + await expectInnerText(page, '#row107-globalAggregatedQuality', '76'); + expect(await getPopoverInnerText(await page.waitForSelector('#row107-globalAggregatedQuality .popover-trigger'))) + .to.be.equal('Missing 3 verifications'); }); it('should ignore QC flags created by services in QC summaries of AOT and MUON ', async () => {