diff --git a/QualityControl/public/Model.js b/QualityControl/public/Model.js index 4c8a238f8..d4022f362 100644 --- a/QualityControl/public/Model.js +++ b/QualityControl/public/Model.js @@ -169,7 +169,7 @@ export default class Model extends Observable { this.object.objects = {}; // Remove any in-memory loaded objects this._clearAllIntervals(); await this.filterModel.filterService.initFilterService(); - this.filterModel.setFilterFromURL(); + await this.filterModel.setFilterFromURL(); this.filterModel.setFilterToURL(); this.services.layout.getLayoutsByUserId(this.session.personid, RequestFields.LAYOUT_CARD); diff --git a/QualityControl/public/app.css b/QualityControl/public/app.css index 956128933..c6bdab4d0 100644 --- a/QualityControl/public/app.css +++ b/QualityControl/public/app.css @@ -156,6 +156,11 @@ cursor: pointer; } +.b1 { border-style: solid; border-width: 1px; } + +.b-danger { border-color: var(--color-danger); } +.b-success { border-color: var(--color-success); } + .header-layout { &.edit { display: flex; @@ -187,3 +192,11 @@ .whitespace-nowrap { white-space: nowrap; } + +/* This hacky workaround is required due to `justify-content: center;` being unusable thanks to a horizontal scrolling bug */ +#header-detector-qualities { + &::before, &::after { + content: ''; + flex: 1; + } +} diff --git a/QualityControl/public/common/badge.js b/QualityControl/public/common/badge.js new file mode 100644 index 000000000..50ea3ea8f --- /dev/null +++ b/QualityControl/public/common/badge.js @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { h, iconCheck, iconX } from '/js/src/index.js'; + +/** + * A green success badge with the tick icon + * @param {string} text - Text to display in the badge + * @returns {vnode} The badge virtual node + */ +export const statusBadgeSuccess = (text) => + h('.badge.success.b-success.b1', h('.flex-row.g1', [text, iconCheck()])); + +/** + * A red failure badge with the X icon + * @param {string} text - Text to display in the badge + * @returns {vnode} The badge virtual node + */ +export const statusBadgeFail = (text) => + h('.badge.danger.b-danger.b1', h('.flex-row.g1', [text, iconX()])); + +/** + * A status badge with dynamic color and icon depending on success or failure. + * @param {string} text - Text to display inside the badge + * @param {boolean} success - Whether the badge represents success (`true`) or failure (`false`) + * @returns {vnode} The badge virtual node + */ +export const statusBadge = (text, success) => + success ? statusBadgeSuccess(text) : statusBadgeFail(text); diff --git a/QualityControl/public/common/filters/filterViews.js b/QualityControl/public/common/filters/filterViews.js index 0e6fc1362..e5eb8edd7 100644 --- a/QualityControl/public/common/filters/filterViews.js +++ b/QualityControl/public/common/filters/filterViews.js @@ -16,7 +16,12 @@ import { filterInput, dynamicSelector, ongoingRunsSelector } from './filter.js'; import { FilterType } from './filterTypes.js'; import { filtersConfig, runModeFilterConfig } from './filtersConfig.js'; import { runModeCheckbox } from './runMode/runModeCheckbox.js'; -import { lastUpdatePanel, runStatusPanel } from './runMode/runStatusPanel.js'; +import { + cleanRunInformationPanel, + detectorsQualitiesPanel, + lastUpdatePanel, + runStatusPanel, +} from './runMode/runStatusPanel.js'; import { h, iconChevronBottom, iconChevronTop } from '/js/src/index.js'; /** @@ -75,6 +80,7 @@ export function filtersPanel(filterModel, viewModel) { isVisible, lastRefresh, ONGOING_RUN_INTERVAL_MS: refreshRate, + runInformation, } = filterModel; const { fetchOngoingRuns } = filterService; const onInputCallback = setFilterValue.bind(filterModel); @@ -88,6 +94,7 @@ export function filtersPanel(filterModel, viewModel) { const filtersList = isRunModeActivated ? runModeFilterConfig(filterService) : filtersConfig(filterService); + const { detectorsQualities, ...cleanRunInformation } = runInformation; return h( '.w-100.flex-column.p2.g2.justify-center#filterElement', @@ -101,15 +108,11 @@ export function filtersPanel(filterModel, viewModel) { isRunModeActivated && runStatusPanel(runStatus), ]), lastUpdatePanel(runStatus, lastRefresh, refreshRate), + cleanRunInformationPanel(cleanRunInformation), + detectorsQualitiesPanel(detectorsQualities), ], ); -}; - -/** - * Determines if runs mode is allowed based on current page and context - * @param {object} viewModel - Model that manages the state of the page - * @returns {boolean} - whether runs mode is allowed - */ +} /** * Button which will allow the user to update filter parameters after the input diff --git a/QualityControl/public/common/filters/model/FilterModel.js b/QualityControl/public/common/filters/model/FilterModel.js index 69d02a196..d2f209732 100644 --- a/QualityControl/public/common/filters/model/FilterModel.js +++ b/QualityControl/public/common/filters/model/FilterModel.js @@ -16,8 +16,15 @@ import { Observable } from '/js/src/index.js'; import { buildQueryParametersString } from '../../buildQueryParametersString.js'; import FilterService from '../../../services/Filter.service.js'; import { RunStatus } from '../../../library/runStatus.enum.js'; +import { prettyFormatDate } from '../../utils.js'; + const CCDB_QUERY_PARAMS = ['PeriodName', 'PassName', 'RunNumber', 'RunType']; +const RUN_INFORMATION_MAP = { + startTime: prettyFormatDate, + endTime: prettyFormatDate, +}; + /** * Model namespace that manages the filter state in the application. */ @@ -39,6 +46,7 @@ export default class FilterModel extends Observable { this._runStatus = null; this._isRunModeActivated = false; this._lastRefresh = null; + this._runInformation = {}; this.ONGOING_RUN_INTERVAL_MS = 15000; } @@ -47,7 +55,7 @@ export default class FilterModel extends Observable { * Look for parameters used for filtering in URL and apply them in the layout if it exists * @returns {undefined} */ - setFilterFromURL() { + async setFilterFromURL() { const parameters = this.model.router.params; CCDB_QUERY_PARAMS.forEach((filterKey) => { if (parameters[filterKey]) { @@ -64,6 +72,7 @@ export default class FilterModel extends Observable { Other: () => null, }); + await this.updateRunInformation(); this.notify(); } @@ -114,12 +123,15 @@ export default class FilterModel extends Observable { */ async triggerFilter(baseViewModel) { this.setFilterToURL(); + await this.updateRunInformation(); + if (this.isRunModeActivated) { this.runNumber = this._filterMap['RunNumber']; - this.runStatus = await this.filterService.getRunStatus(this.runNumber); + this.runStatus = this.runInformation.runStatus ?? RunStatus.UNKNOWN; this.notify(); this._manageRunsModeInterval(baseViewModel, true); } + baseViewModel.triggerFilter(); this._lastRefresh = Date.now(); this.notify(); @@ -150,6 +162,7 @@ export default class FilterModel extends Observable { */ clearFilters() { this._filterMap = {}; + this._runInformation = {}; this.setFilterToURL(true); this.notify(); } @@ -285,6 +298,19 @@ export default class FilterModel extends Observable { } } + /** + * Updates the `runInformation` property by fetching data from the `filterService`. + * If `this.runNumber` is defined, it asynchronously retrieves the run information + * via `filterService.getRunInformation(runNumber)` and sets it to `runInformation`, + * automatically applying any filtering and transformations defined in the setter. + * If `this.runNumber` is not defined, `runInformation` is reset to an empty object. + * @returns {undefined} + */ + async updateRunInformation() { + const runNumber = this._filterMap['RunNumber']; + this.runInformation = runNumber ? await this.filterService.getRunInformation(runNumber) : {}; + } + /** * Gets the current run number. * @returns {number} The run number. @@ -301,6 +327,34 @@ export default class FilterModel extends Observable { this._runNumber = value; } + /** + * Gets the current run information. + * @returns {object} The run information. + */ + get runInformation() { + return this._runInformation; + } + + /** + * Sets the run information after filtering and transforming values. + * - Filters out properties that are `null` or `undefined`. + * - Applies a transformation function from `RUN_INFORMATION_MAP` for any matching keys. + * @param {RunInformation} value - The new run information object to set. + * Keys corresponding to functions in `RUN_INFORMATION_MAP` will be transformed accordingly. + * @returns {undefined} + */ + set runInformation(value) { + const runInfo = value && typeof value === 'object' ? value : {}; + const transformed = Object.entries(runInfo) + // Filters out properties that are `null` or `undefined`. + .filter(([_, v]) => v !== null && v !== undefined) + // Applies a transformation function from `RUN_INFORMATION_MAP` for any matching keys. + .map(([key, value]) => + [key, typeof RUN_INFORMATION_MAP[key] === 'function' ? RUN_INFORMATION_MAP[key](value) : value]); + + this._runInformation = Object.fromEntries(transformed); + } + /** * Gets the current run status. * @returns {RemoteData} The run status. diff --git a/QualityControl/public/common/filters/runMode/runStatusPanel.js b/QualityControl/public/common/filters/runMode/runStatusPanel.js index c876f741f..90b6f792a 100644 --- a/QualityControl/public/common/filters/runMode/runStatusPanel.js +++ b/QualityControl/public/common/filters/runMode/runStatusPanel.js @@ -13,6 +13,8 @@ import { RunStatus } from '../../../../../library/runStatus.enum.js'; import { h } from '/js/src/index.js'; +import { camelToTitleCase } from '../../utils.js'; +import { statusBadge } from '../../badge.js'; /** * Creates and returns a run status panel element displaying the current run number, @@ -53,3 +55,48 @@ export const lastUpdatePanel = (runStatus, lastRefresh, refreshRate = 15000) => ), ]); }; + +/** + * Renders the run information panel + * @param {object} cleanRunInformation - The `RunInformation` without `detectorsQualities` + * @returns {vnode} - virtual node element + */ +export const cleanRunInformationPanel = (cleanRunInformation) => + cleanRunInformation && Object.keys(cleanRunInformation).length > 0 && h( + '.flex-row.g4.items-center.f7.gray-darker.text-center.ph4', + { + id: 'header-run-information', + style: 'overflow-x: auto; margin: 0 auto;', + }, + Object.entries(cleanRunInformation).map(([key, value]) => + h('.flex-row.g1', { + key: `${key}-${value}`, + style: 'flex: 0 0 auto;', + }, [ + h('strong', `${camelToTitleCase(key)}:`), + h('span', `${value}`), + ])), + ); + +/** + * Renders the detector qualities panel + * @param {DetectorQuality[]} detectorsQualities - The detector qualities of the run + * @returns {vnode} - virtual node element + */ +export const detectorsQualitiesPanel = (detectorsQualities) => + Array.isArray(detectorsQualities) && detectorsQualities.length > 0 && h( + '.flex-row.g3.items-center.f7.gray-darker.text-center.ph3', + { + id: 'header-detector-qualities', + style: 'overflow-x: auto;', + }, + detectorsQualities.map(({ id, name, quality }) => + h( + '.flex-row.g1', + { + key: `${id}-${name}-${quality}`, + style: 'flex: 0 0 auto;', + }, + statusBadge(name, quality === 'good'), + )), + ); diff --git a/QualityControl/public/services/Filter.service.js b/QualityControl/public/services/Filter.service.js index d39ffac68..e61e0cfeb 100644 --- a/QualityControl/public/services/Filter.service.js +++ b/QualityControl/public/services/Filter.service.js @@ -50,12 +50,22 @@ export default class FilterService { /** * Method to get run status for a specific run number * @param {number} runNumber - The run number to get status for - * @returns {RemoteData} - result within a RemoteData object + * @returns {object} - result as an object containing run information */ - async getRunStatus(runNumber) { + async getRunInformation(runNumber) { const parsedRunNumber = parseInt(runNumber, 10); const { result, ok } = await this.loader.get(`/api/filter/run-status/${parsedRunNumber}`); - return ok ? result?.runStatus : RunStatus.UNKNOWN; + return ok ? result : {}; + } + + /** + * Method to get run status for a specific run number + * @param {number} runNumber - The run number to get status for + * @returns {RunStatus} - result as a run status + */ + async getRunStatus(runNumber) { + const { runStatus } = await this.getRunInformation(runNumber); + return runStatus ?? RunStatus.UNKNOWN; } /** diff --git a/QualityControl/test/public/features/filterTest.test.js b/QualityControl/test/public/features/filterTest.test.js index bb4130d09..b73b28708 100644 --- a/QualityControl/test/public/features/filterTest.test.js +++ b/QualityControl/test/public/features/filterTest.test.js @@ -11,8 +11,9 @@ * or submit itself to any jurisdiction. */ -import { strictEqual } from 'node:assert'; +import { strictEqual, ok } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; +import { RunStatus } from '../../../common/library/runStatus.enum.js'; export const filterTests = async (url, page, timeout = 5000, testParent) => { await testParent.test('filter should persist between pages', { timeout }, async () => { @@ -50,7 +51,157 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { strictEqual(value, '0', 'RunNumber filter should still be set to 0 on layout show page'); }); + await testParent.test( + 'should display detector qualities when filtering by run number if it has any', + { timeout }, + async () => { + const requestHandler = async (interceptedRequest) => { + const url = interceptedRequest.url(); + + if (url.includes('/api/filter/run-status/0')) { + // Mock the response + await interceptedRequest.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + detectorsQualities: [ + { id: 1, name: 'DETECTOR_GOOD_1', quality: 'good' }, + { id: 1, name: 'DETECTOR_GOOD_2', quality: 'good' }, + { id: 2, name: 'DETECTOR_BAD', quality: 'bad' }, + ], + }), + }); + } else { + interceptedRequest.continue(); + } + }; + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.locator('#triggerFilterButton').click(); + const detectorQualities = await page.waitForSelector('#header-detector-qualities', { + visible: true, + timeout: 1000, + }); + ok(detectorQualities, 'Detector qualities should exist on the page'); + + const goodDetectorCount = await page.evaluate(() => + document.querySelectorAll('#header-detector-qualities .success').length); + const badDetectorCount = await page.evaluate(() => + document.querySelectorAll('#header-detector-qualities .danger').length); + + strictEqual(goodDetectorCount, 2, 'Two good detector qualities should exist on the page'); + strictEqual(badDetectorCount, 1, 'One bad detector qualities should exist on the page'); + } catch (error) { + // Test failed + ok(false, error.message); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + }, + ); + + await testParent.test( + 'should not display detector qualities if the run has none when filtering by run number', + { timeout }, + async () => { + const requestHandler = async (interceptedRequest) => { + const url = interceptedRequest.url(); + + if (url.includes('/api/filter/run-status/0')) { + // Mock the response + await interceptedRequest.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + detectorsQualities: [], + }), + }); + } else { + interceptedRequest.continue(); + } + }; + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.locator('#triggerFilterButton').click(); + await delay(100); + + const runDetectorQualitiesExists = await page.evaluate(() => + document.querySelector('#header-detector-qualities') !== null); + strictEqual(runDetectorQualitiesExists, false, 'Run detector qualities should not exists on the page'); + } catch (error) { + // Test failed + ok(false, error.message); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + }, + ); + + await testParent.test('not filtering by run number should not display any run information', { timeout }, async () => { + await page.locator('#runNumberFilter').fill('Backspace'); + await page.locator('#triggerFilterButton').click(); + await delay(100); + + // Not filtering by run number should not display the run information + const runInformationExists = await page.evaluate(() => document.querySelector('#header-run-information') !== null); + strictEqual(runInformationExists, false, 'Run information should not exists on the page'); + }); + + await testParent.test('filtering by run number should display the run information', { timeout }, async () => { + const requestHandler = async (interceptedRequest) => { + const url = interceptedRequest.url(); + + if (url.includes('/api/filter/run-status/0')) { + // Mock the response + await interceptedRequest.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + runStatus: RunStatus.UNKNOWN, + }), + }); + } else { + interceptedRequest.continue(); + } + }; + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + // Fill and trigger the filter + await page.locator('#runNumberFilter').fill('0'); + await page.locator('#triggerFilterButton').click(); + + // Filtering by run number should display the run information + const runInformation = await page.waitForSelector('#header-run-information', { visible: true, timeout: 1000 }); + ok(runInformation, 'Run information should exists on the page'); + } catch (error) { + // Test failed + ok(false, error.message); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + }); + await testParent.test('should list all objects when clearing filters', { timeout }, async () => { + await page.locator('#triggerFilterButton').click(); + await delay(100); //Navigate to object tree ///html/body/div[1]/div/nav/a[2] await page.locator('nav > a:nth-child(3)').click(); diff --git a/QualityControl/test/public/features/runMode.test.js b/QualityControl/test/public/features/runMode.test.js index 1c500f01b..11443a65f 100644 --- a/QualityControl/test/public/features/runMode.test.js +++ b/QualityControl/test/public/features/runMode.test.js @@ -119,6 +119,7 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { await testParent.test('should persist runs mode between pages', { timeout }, async () => { await page.locator('.menu-item:nth-child(3) > .ph2').click(); + expectCountRunStatusCalls++;// fetches run information on page load await page.waitForSelector('#runStatusPanel'); const runInfo = await page.evaluate(() => { const status = document.querySelector('#runStatusBadge').textContent;