From 5b4119131819e0949ca7a8e745d17d476adfdbef Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Sun, 28 Dec 2025 23:58:45 +0100 Subject: [PATCH 01/13] If kafka is not configured, RunMode should not be displayed --- .../enums/Status/integratedServices.enum.js | 1 + QualityControl/lib/QCModel.js | 21 +- QualityControl/lib/services/Status.service.js | 35 ++++ .../services/external/AliEcsSynchronizer.js | 27 ++- QualityControl/public/Model.js | 7 + .../public/common/filters/filterViews.js | 43 ++++- .../public/pages/aboutView/AboutViewModel.js | 16 ++ .../test/lib/services/StatusService.test.js | 65 +++++++ .../test/public/features/runMode.test.js | 180 ++++++++++++++++-- .../test/public/pages/about-page.test.js | 6 +- 10 files changed, 361 insertions(+), 40 deletions(-) diff --git a/QualityControl/common/library/enums/Status/integratedServices.enum.js b/QualityControl/common/library/enums/Status/integratedServices.enum.js index 64acc6354..b5c55b93f 100644 --- a/QualityControl/common/library/enums/Status/integratedServices.enum.js +++ b/QualityControl/common/library/enums/Status/integratedServices.enum.js @@ -21,4 +21,5 @@ export const IntegratedServices = Object.freeze({ QCG: 'qcg', QC: 'qc', CCDB: 'ccdb', + KAFKA: 'kafka', }); diff --git a/QualityControl/lib/QCModel.js b/QualityControl/lib/QCModel.js index aa5364af6..b774a06e9 100644 --- a/QualityControl/lib/QCModel.js +++ b/QualityControl/lib/QCModel.js @@ -78,6 +78,16 @@ export const setupQcModel = async (eventEmitter) => { logger.warnMessage('No database configuration found, skipping database initialization'); } + const layoutRepository = new LayoutRepository(jsonFileService); + const userRepository = new UserRepository(jsonFileService); + const chartRepository = new ChartRepository(jsonFileService); + + const userController = new UserController(userRepository); + const layoutController = new LayoutController(layoutRepository); + + const statusService = new StatusService({ version: packageJSON?.version ?? '-' }, { qc: config.qc ?? {} }); + const statusController = new StatusController(statusService); + if (config?.kafka?.enabled) { try { const validConfig = await KafkaConfigDto.validateAsync(config.kafka); @@ -89,22 +99,13 @@ export const setupQcModel = async (eventEmitter) => { logLevel: logLevel.NOTHING, }); const aliEcsSynchronizer = new AliEcsSynchronizer(kafkaClient, consumerGroups, eventEmitter); + statusService.aliEcsSynchronizer = aliEcsSynchronizer; aliEcsSynchronizer.start(); } catch (error) { logger.errorMessage(`Kafka initialization/connection failed: ${error.message}`); } } - const layoutRepository = new LayoutRepository(jsonFileService); - const userRepository = new UserRepository(jsonFileService); - const chartRepository = new ChartRepository(jsonFileService); - - const userController = new UserController(userRepository); - const layoutController = new LayoutController(layoutRepository); - - const statusService = new StatusService({ version: packageJSON?.version ?? '-' }, { qc: config.qc ?? {} }); - const statusController = new StatusController(statusService); - const qcdbDownloadService = new QcdbDownloadService(config.ccdb); const ccdbService = CcdbService.setup(config.ccdb); diff --git a/QualityControl/lib/services/Status.service.js b/QualityControl/lib/services/Status.service.js index c6d6e33a9..dd31b5d7a 100644 --- a/QualityControl/lib/services/Status.service.js +++ b/QualityControl/lib/services/Status.service.js @@ -17,6 +17,7 @@ import { exec } from 'node:child_process'; import { LogManager } from '@aliceo2/web-ui'; import { IntegratedServices } from './../../common/library/enums/Status/integratedServices.enum.js'; +import { ServiceStatus } from '../../common/library/enums/Status/serviceStatus.enum.js'; const QC_VERSION_EXEC_COMMAND = 'yum info o2-QualityControl | awk \'/Version/ {print $3}\''; const execPromise = promisify(exec); @@ -43,6 +44,11 @@ export class StatusService { */ this._ws = undefined; + /** + * @type {?AliEcsSynchronizer} + */ + this._aliEcsSynchronizer = undefined; + this._packageInfo = packageInfo; this._config = config; } @@ -64,6 +70,9 @@ export class StatusService { case IntegratedServices.CCDB: result = await this.retrieveDataServiceStatus(); break; + case IntegratedServices.KAFKA: + result = this.retrieveKafkaServiceStatus(); + break; } return result; } @@ -120,6 +129,23 @@ export class StatusService { return { name: 'CCDB', status, version, extras: {} }; } + /** + * Retrieve the kafka service status response + * @returns {object} - status of the kafka service + */ + retrieveKafkaServiceStatus() { + const status = this._aliEcsSynchronizer?.status; + return { + name: IntegratedServices.KAFKA, + status: { + ok: status === ServiceStatus.SUCCESS, + }, + extras: { + state: status ?? 'NOT_CONFIGURED', + }, + }; + } + /* * Getters & Setters */ @@ -141,4 +167,13 @@ export class StatusService { set ws(ws) { this._ws = ws; } + + /** + * Set instance of `AliEcsSynchronizer` + * @param {AliEcsSynchronizer} aliEcsSynchronizer - instance of the `AliEcsSynchronizer` + * @returns {void} + */ + set aliEcsSynchronizer(aliEcsSynchronizer) { + this._aliEcsSynchronizer = aliEcsSynchronizer; + } } diff --git a/QualityControl/lib/services/external/AliEcsSynchronizer.js b/QualityControl/lib/services/external/AliEcsSynchronizer.js index bb2b0d902..8b3124e4a 100644 --- a/QualityControl/lib/services/external/AliEcsSynchronizer.js +++ b/QualityControl/lib/services/external/AliEcsSynchronizer.js @@ -13,6 +13,7 @@ import { AliEcsEventMessagesConsumer, LogManager } from '@aliceo2/web-ui'; import { EmitterKeys } from './../../../common/library/enums/emitterKeys.enum.js'; +import { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/ecs-synchronizer`; const RUN_TOPICS = ['aliecs.run']; @@ -38,18 +39,24 @@ export class AliEcsSynchronizer { RUN_TOPICS, ); this._ecsRunConsumer.onMessageReceived(this._onRunMessage.bind(this)); + + this._status = ServiceStatus.NOT_ASKED; } /** * Start the synchronization process and listen to events from various topics via their consumers - * @returns {void} + * @returns {Promise} */ - start() { + async start() { this._logger.infoMessage('Starting to consume AliECS messages for topics:'); - this._ecsRunConsumer - .start() - .catch((error) => - this._logger.errorMessage(`Error when starting ECS run consumer: ${error.message}\n${error.stack}`)); + this._status = ServiceStatus.LOADING; + try { + await this._ecsRunConsumer.start(); + this._status = ServiceStatus.SUCCESS; + } catch (error) { + this._logger.errorMessage(`Error when starting ECS run consumer: ${error.message}\n${error.stack}`); + this._status = ServiceStatus.ERROR; + } } /** @@ -75,4 +82,12 @@ export class AliEcsSynchronizer { }); } } + + /** + * Returns the current kafka service status + * @returns {ServiceStatus} - The kafka service status + */ + get status() { + return this._status; + } } diff --git a/QualityControl/public/Model.js b/QualityControl/public/Model.js index 4c8a238f8..30f401502 100644 --- a/QualityControl/public/Model.js +++ b/QualityControl/public/Model.js @@ -29,6 +29,7 @@ import AboutViewModel from './pages/aboutView/AboutViewModel.js'; import LayoutListModel from './pages/layoutListView/model/LayoutListModel.js'; import { RequestFields } from './common/RequestFields.enum.js'; import FilterModel from './common/filters/model/FilterModel.js'; +import { IntegratedServices } from '../library/enums/Status/integratedServices.enum.js'; /** * Represents the application's state and actions as a class @@ -115,6 +116,12 @@ export default class Model extends Observable { height: 10, }; + // For active run monitoring, the kafka service must be available. + // If we do not yet know the kafka service status, we should request it from the backend + if (!this.aboutViewModel.findService(IntegratedServices.KAFKA)) { + this.aboutViewModel.retrieveIndividualServiceStatus(IntegratedServices.KAFKA); + } + /* * Init first page */ diff --git a/QualityControl/public/common/filters/filterViews.js b/QualityControl/public/common/filters/filterViews.js index 0e6fc1362..dfba10db6 100644 --- a/QualityControl/public/common/filters/filterViews.js +++ b/QualityControl/public/common/filters/filterViews.js @@ -15,9 +15,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 { h, iconChevronBottom, iconChevronTop } from '/js/src/index.js'; +import { h, iconChevronBottom, iconChevronTop, iconWarning } from '/js/src/index.js'; +import { IntegratedServices } from '../../../library/enums/Status/integratedServices.enum.js'; +import { spinner } from '../spinner.js'; +import { ServiceStatus } from '../../../library/enums/Status/serviceStatus.enum.js'; +import { runModeCheckbox } from './runMode/runModeCheckbox.js'; /** * Creates an input element for a specific metadata field; @@ -76,15 +79,16 @@ export function filtersPanel(filterModel, viewModel) { lastRefresh, ONGOING_RUN_INTERVAL_MS: refreshRate, } = filterModel; + if (!isVisible) { + return null; + } const { fetchOngoingRuns } = filterService; const onInputCallback = setFilterValue.bind(filterModel); const onChangeCallback = setFilterValue.bind(filterModel); const onFocusCallback = fetchOngoingRuns.bind(filterService); const onEnterCallback = () => filterModel.triggerFilter(viewModel); const clearFilterCallback = clearFiltersAndTrigger.bind(filterModel, viewModel); - if (!isVisible) { - return null; - } + const kafkaService = filterModel.model.aboutViewModel.findService(IntegratedServices.KAFKA); const filtersList = isRunModeActivated ? runModeFilterConfig(filterService) : filtersConfig(filterService); @@ -93,7 +97,34 @@ export function filtersPanel(filterModel, viewModel) { '.w-100.flex-column.p2.g2.justify-center#filterElement', [ h('.flex-row.g2.justify-center.items-center', [ - runModeCheckbox(filterModel, viewModel), + kafkaService?.match({ + Loading: () => spinner(2, 'Checking if RunMode is configured'), + Failure: (payload) => h('.error-box.danger.flex-column.justify-center.f6.text-center', { + id: 'run-mode-failure', + }, [ + h('span.error-icon', { title: 'RunMode is unavailable. Please contact administrator.' }, iconWarning()), + h('span', payload.status.message), + ]), + Success: (payload) => { + switch (payload.extras.state) { + case ServiceStatus.SUCCESS: + return runModeCheckbox(filterModel, viewModel); + case 'NOT_CONFIGURED': + return null; + default: + return h('.error-box.danger.flex-column.justify-center.f6.text-center', { + id: 'run-mode-failure', + }, [ + h('span.error-icon', { + title: 'RunMode is unavailable. Please contact administrator.', + }, iconWarning()), + h('span', 'Contact an administrator and include this information:'), + h('span', `Kafka service returned status code '${payload.extras.state ?? '?'}'`), + ]); + } + }, + Other: () => {}, + }), !isRunModeActivated && [triggerFiltersButton(onEnterCallback, filterModel), clearFiltersButton(clearFilterCallback)], ...filtersList.map((filter) => diff --git a/QualityControl/public/pages/aboutView/AboutViewModel.js b/QualityControl/public/pages/aboutView/AboutViewModel.js index db24bbacf..0a34119b8 100644 --- a/QualityControl/public/pages/aboutView/AboutViewModel.js +++ b/QualityControl/public/pages/aboutView/AboutViewModel.js @@ -74,4 +74,20 @@ export default class AboutViewModel extends BaseViewModel { this.model.notification.show(`Error fetching data for ${service}: ${error.message}`, 'danger', 2000); } } + + /** + * Iterates through all known {@link ServiceStatus} values and returns the + * first matching service found. This assumes that a given service can exist + * in at most one {@link ServiceStatus} at a time. + * @param {string} service - The service identifier to look up + * @returns {RemoteData|undefined} - The service instance under any `ServiceStatus`, or `undefined` if not found. + */ + findService(service) { + for (const status of Object.values(ServiceStatus)) { + if (this.services[status][service]) { + return this.services[status][service]; + } + } + return undefined; + } } diff --git a/QualityControl/test/lib/services/StatusService.test.js b/QualityControl/test/lib/services/StatusService.test.js index 0bec75015..d976b6fb3 100644 --- a/QualityControl/test/lib/services/StatusService.test.js +++ b/QualityControl/test/lib/services/StatusService.test.js @@ -17,6 +17,7 @@ import { deepStrictEqual } from 'node:assert'; import { suite, test, before } from 'node:test'; import { StatusService } from './../../../lib/services/Status.service.js'; +import { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; export const statusServiceTestSuite = async () => { suite('`retrieveDataServiceStatus()` tests', () => { @@ -47,17 +48,20 @@ export const statusServiceTestSuite = async () => { test('should successfully build an object with framework information from all used sources', async () => { const statusService = new StatusService(); statusService.dataService = { getVersion: stub().resolves({ version: '0.0.1-beta' }) }; + statusService.aliEcsSynchronizer = { status: ServiceStatus.SUCCESS }; const statusInfo = await Promise.all([ statusService.retrieveServiceStatus('qcg'), statusService.retrieveServiceStatus('qc'), statusService.retrieveServiceStatus('ccdb'), + statusService.retrieveServiceStatus('kafka'), ]); const expectedResults = [ { name: 'QCG', version: '', status: { ok: true }, extras: { clients: -1 } }, { name: 'QC', status: { ok: true }, version: 'Not part of an FLP deployment', extras: {} }, { name: 'CCDB', status: { ok: true }, version: '0.0.1-beta', extras: {} }, + { name: 'kafka', status: { ok: true }, extras: { state: ServiceStatus.SUCCESS } }, ]; deepStrictEqual(statusInfo, expectedResults); @@ -100,4 +104,65 @@ export const statusServiceTestSuite = async () => { }); }); }); + + suite('`retrieveKafkaServiceStatus()` tests', () => { + test('marks Kafka service as healthy when synchronizer reports SUCCESS', async () => { + const statusService = new StatusService(); + statusService.aliEcsSynchronizer = { status: ServiceStatus.SUCCESS }; + const result = statusService.retrieveKafkaServiceStatus(); + + deepStrictEqual(result, { + name: 'kafka', + status: { ok: true }, + extras: { state: ServiceStatus.SUCCESS }, + }); + }); + + test('marks Kafka service as idle when synchronizer has not been queried', async () => { + const statusService = new StatusService(); + statusService.aliEcsSynchronizer = { status: ServiceStatus.NOT_ASKED }; + const result = statusService.retrieveKafkaServiceStatus(); + + deepStrictEqual(result, { + name: 'kafka', + status: { ok: false }, + extras: { state: ServiceStatus.NOT_ASKED }, + }); + }); + + test('marks Kafka service as unhealthy when synchronizer reports an error', async () => { + const statusService = new StatusService(); + statusService.aliEcsSynchronizer = { status: ServiceStatus.ERROR }; + const result = statusService.retrieveKafkaServiceStatus(); + + deepStrictEqual(result, { + name: 'kafka', + status: { ok: false }, + extras: { state: ServiceStatus.ERROR }, + }); + }); + + test('marks Kafka service as initializing while synchronizer is loading', async () => { + const statusService = new StatusService(); + statusService.aliEcsSynchronizer = { status: ServiceStatus.LOADING }; + const result = statusService.retrieveKafkaServiceStatus(); + + deepStrictEqual(result, { + name: 'kafka', + status: { ok: false }, + extras: { state: ServiceStatus.LOADING }, + }); + }); + + test('marks Kafka service as not configured when no synchronizer is present', async () => { + const statusService = new StatusService(); + const result = statusService.retrieveKafkaServiceStatus(); + + deepStrictEqual(result, { + name: 'kafka', + status: { ok: false }, + extras: { state: 'NOT_CONFIGURED' }, + }); + }); + }); }; diff --git a/QualityControl/test/public/features/runMode.test.js b/QualityControl/test/public/features/runMode.test.js index 1c500f01b..8f6cbdc25 100644 --- a/QualityControl/test/public/features/runMode.test.js +++ b/QualityControl/test/public/features/runMode.test.js @@ -14,6 +14,8 @@ import { strictEqual, ok } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; +import { IntegratedServices } from '../../../common/library/enums/Status/integratedServices.enum.js'; +import { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; // If using nock for HTTP mocking (uncomment if available) // import nock from 'nock'; @@ -37,20 +39,170 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { } }); - await testParent.test('should have a switch to enable run mode', { timeout }, async () => { - await page.goto( - `${url}?page=objectTree`, - { waitUntil: 'networkidle0' }, - ); - await delay(100); - // Prevent the 'get run status' from re-triggering mid test - await page.evaluate(() => { - window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; - }); - await page.locator('.form-check-label > .switch'); - const runsModeTitle = await page.evaluate(() => - document.querySelector('.form-check-label').textContent); - strictEqual(runsModeTitle, 'Run mode', 'The text displayed is not `Runs mode`'); + await testParent.test('when kafka service is not configured the run mode toggle should be hidden', { timeout }, async () => { + const requestHandler = (interceptedRequest) => { + const url = interceptedRequest.url(); + + if (url.includes(`/api/status/${IntegratedServices.KAFKA}`)) { + interceptedRequest.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + name: IntegratedServices.KAFKA, + status: { + ok: false, + }, + extras: { + state: 'NOT_CONFIGURED', + }, + }), + }); + } else { + interceptedRequest.continue(); + } + }; + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.goto( + `${url}?page=objectTree`, + { waitUntil: 'networkidle0' }, + ); + await delay(100); + // Prevent the 'get run status' from re-triggering mid test + await page.evaluate(() => { + window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; + }); + + const runsModeToggleNoExist = await page.evaluate(() => document.querySelector('.form-check-label') === null); + ok(runsModeToggleNoExist, 'The RunMode switch should not be displayed'); + + const runsModeErrorNoExist = await page.evaluate(() => document.querySelector('#run-mode-failure') === null); + ok(runsModeErrorNoExist, 'The RunMode switch should not be displayed'); + } 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('when kafka service is unavailable an error should be displayed instead of the run mode toggle', { timeout }, async () => { + const requestHandler = (interceptedRequest) => { + const url = interceptedRequest.url(); + + if (url.includes(`/api/status/${IntegratedServices.KAFKA}`)) { + interceptedRequest.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + name: IntegratedServices.KAFKA, + status: { + ok: false, + }, + extras: { + state: ServiceStatus.ERROR, + }, + }), + }); + } else { + interceptedRequest.continue(); + } + }; + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.goto( + `${url}?page=objectTree`, + { waitUntil: 'networkidle0' }, + ); + await delay(100); + // Prevent the 'get run status' from re-triggering mid test + await page.evaluate(() => { + window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; + }); + + const runsModeNoExist = await page.evaluate(() => document.querySelector('.form-check-label') === null); + ok(runsModeNoExist, 'The RunMode switch should not be displayed'); + + const runModeErrorMessage = await page.evaluate(() => { + const spans = document.querySelectorAll('#run-mode-failure > span'); + return Array.from(spans) + .map((span) => span.textContent.trim()) + .filter((text) => text !== '') + .join(' '); + }); + strictEqual( + runModeErrorMessage, + `Contact an administrator and include this information: Kafka service returned status code '${ServiceStatus.ERROR}'`, + 'RunMode failure should have the correct error message', + ); + } 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 have a switch to enable run mode when kafka service is available', { timeout }, async () => { + const requestHandler = (interceptedRequest) => { + const url = interceptedRequest.url(); + + if (url.includes(`/api/status/${IntegratedServices.KAFKA}`)) { + interceptedRequest.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + name: IntegratedServices.KAFKA, + status: { + ok: true, + }, + extras: { + state: ServiceStatus.SUCCESS, + }, + }), + }); + } else { + interceptedRequest.continue(); + } + }; + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.goto( + `${url}?page=objectTree`, + { waitUntil: 'networkidle0' }, + ); + await delay(100); + // Prevent the 'get run status' from re-triggering mid test + await page.evaluate(() => { + window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; + }); + await page.locator('.form-check-label > .switch'); + const runsModeTitle = await page.evaluate(() => document.querySelector('.form-check-label').textContent); + strictEqual(runsModeTitle, 'Run mode', 'The text displayed is not `Run mode`'); + } 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 activate run mode', { timeout }, async () => { diff --git a/QualityControl/test/public/pages/about-page.test.js b/QualityControl/test/public/pages/about-page.test.js index 2fb102596..18cad6a6a 100644 --- a/QualityControl/test/public/pages/about-page.test.js +++ b/QualityControl/test/public/pages/about-page.test.js @@ -12,7 +12,6 @@ */ import { strictEqual } from 'node:assert'; -import { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; const ABOUT_PAGE_PARAM = '?page=about'; @@ -25,6 +24,7 @@ export const aboutPageTests = async (url, page, timeout = 5000, testParent) => { await testServiceStatus(testParent, page, 'qcg', timeout); await testServiceStatus(testParent, page, 'qc', timeout); await testServiceStatus(testParent, page, 'ccdb', timeout); + await testServiceStatus(testParent, page, 'kafka', timeout); }; const testServiceStatus = async (testParent, page, serviceName, timeout = 5000) => { @@ -34,10 +34,8 @@ const testServiceStatus = async (testParent, page, serviceName, timeout = 5000) { timeout }, async () => { const kind = await page.evaluate( - (service, serviceStatus) => - window.model.aboutViewModel.services[serviceStatus.SUCCESS][service].kind, + (service) => window.model.aboutViewModel.findService(service)?.kind, serviceName, - ServiceStatus, ); strictEqual(kind, 'Success'); From 79f4f82d61252154c36fb9d3e9fce437b7b2556d Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 29 Dec 2025 14:48:58 +0100 Subject: [PATCH 02/13] Extract run mode rendering logic into dedicated component --- .../public/common/filters/filterViews.js | 37 ++------------ .../common/filters/runMode/runModeCheckbox.js | 50 ++++++++++++++++++- 2 files changed, 52 insertions(+), 35 deletions(-) diff --git a/QualityControl/public/common/filters/filterViews.js b/QualityControl/public/common/filters/filterViews.js index dfba10db6..34330d41d 100644 --- a/QualityControl/public/common/filters/filterViews.js +++ b/QualityControl/public/common/filters/filterViews.js @@ -15,12 +15,9 @@ import { filterInput, dynamicSelector, ongoingRunsSelector } from './filter.js'; import { FilterType } from './filterTypes.js'; import { filtersConfig, runModeFilterConfig } from './filtersConfig.js'; +import { runModeComponent } from './runMode/runModeCheckbox.js'; import { lastUpdatePanel, runStatusPanel } from './runMode/runStatusPanel.js'; -import { h, iconChevronBottom, iconChevronTop, iconWarning } from '/js/src/index.js'; -import { IntegratedServices } from '../../../library/enums/Status/integratedServices.enum.js'; -import { spinner } from '../spinner.js'; -import { ServiceStatus } from '../../../library/enums/Status/serviceStatus.enum.js'; -import { runModeCheckbox } from './runMode/runModeCheckbox.js'; +import { h, iconChevronBottom, iconChevronTop } from '/js/src/index.js'; /** * Creates an input element for a specific metadata field; @@ -88,7 +85,6 @@ export function filtersPanel(filterModel, viewModel) { const onFocusCallback = fetchOngoingRuns.bind(filterService); const onEnterCallback = () => filterModel.triggerFilter(viewModel); const clearFilterCallback = clearFiltersAndTrigger.bind(filterModel, viewModel); - const kafkaService = filterModel.model.aboutViewModel.findService(IntegratedServices.KAFKA); const filtersList = isRunModeActivated ? runModeFilterConfig(filterService) : filtersConfig(filterService); @@ -97,34 +93,7 @@ export function filtersPanel(filterModel, viewModel) { '.w-100.flex-column.p2.g2.justify-center#filterElement', [ h('.flex-row.g2.justify-center.items-center', [ - kafkaService?.match({ - Loading: () => spinner(2, 'Checking if RunMode is configured'), - Failure: (payload) => h('.error-box.danger.flex-column.justify-center.f6.text-center', { - id: 'run-mode-failure', - }, [ - h('span.error-icon', { title: 'RunMode is unavailable. Please contact administrator.' }, iconWarning()), - h('span', payload.status.message), - ]), - Success: (payload) => { - switch (payload.extras.state) { - case ServiceStatus.SUCCESS: - return runModeCheckbox(filterModel, viewModel); - case 'NOT_CONFIGURED': - return null; - default: - return h('.error-box.danger.flex-column.justify-center.f6.text-center', { - id: 'run-mode-failure', - }, [ - h('span.error-icon', { - title: 'RunMode is unavailable. Please contact administrator.', - }, iconWarning()), - h('span', 'Contact an administrator and include this information:'), - h('span', `Kafka service returned status code '${payload.extras.state ?? '?'}'`), - ]); - } - }, - Other: () => {}, - }), + runModeComponent(filterModel, viewModel), !isRunModeActivated && [triggerFiltersButton(onEnterCallback, filterModel), clearFiltersButton(clearFilterCallback)], ...filtersList.map((filter) => diff --git a/QualityControl/public/common/filters/runMode/runModeCheckbox.js b/QualityControl/public/common/filters/runMode/runModeCheckbox.js index a4cdb48da..0f528abda 100644 --- a/QualityControl/public/common/filters/runMode/runModeCheckbox.js +++ b/QualityControl/public/common/filters/runMode/runModeCheckbox.js @@ -11,7 +11,55 @@ * or submit itself to any jurisdiction. */ -import { h } from '/js/src/index.js'; +import { h, iconWarning } from '/js/src/index.js'; +import { IntegratedServices } from '../../../../library/enums/Status/integratedServices.enum.js'; +import { ServiceStatus } from '../../../../library/enums/Status/serviceStatus.enum.js'; +import { spinner } from '../../spinner.js'; + +/** + * This component determines whether the Run Mode toggle should be displayed + * based on the availability and configuration state of the Kafka integrated service. + * Behavior by service state: + * - Loading: Displays a spinner while checking whether Run Mode is configured. + * - Failure: Displays an error box with a warning icon and the failure message returned by the service. + * - Success: + * - {@link ServiceStatus.SUCCESS}: Renders the Run Mode checkbox component. + * - 'NOT_CONFIGURED': Renders nothing (Run Mode is intentionally unavailable). + * - Any other state: Displays a generic error box instructing the user to contact an administrator. + * - Other: Unsupported or irrelevant state. + * @param {object} filterModel - The filter model containing the aboutViewModel used to locate integrated services. + * @param {object} viewModel - The view model associated with the current view. + * @returns {vnode|null} A vnode representing the RunMode switch or kafka state, or `null` if Kafka is not configured. + */ +export const runModeComponent = (filterModel, viewModel) => + filterModel.model.aboutViewModel.findService(IntegratedServices.KAFKA)?.match({ + Loading: () => spinner(2, 'Checking if RunMode is configured'), + Failure: (payload) => h('.error-box.danger.flex-column.justify-center.f6.text-center', { + id: 'run-mode-failure', + }, [ + h('span.error-icon', { title: 'RunMode is unavailable. Please contact administrator.' }, iconWarning()), + h('span', payload.status.message), + ]), + Success: (payload) => { + switch (payload.extras.state) { + case ServiceStatus.SUCCESS: + return runModeCheckbox(filterModel, viewModel); + case 'NOT_CONFIGURED': + return null; + default: + return h('.error-box.danger.flex-column.justify-center.f6.text-center', { + id: 'run-mode-failure', + }, [ + h('span.error-icon', { + title: 'RunMode is unavailable. Please contact administrator.', + }, iconWarning()), + h('span', 'Contact an administrator and include this information:'), + h('span', `Kafka service returned status code '${payload.extras.state ?? '?'}'`), + ]); + } + }, + Other: () => {}, + }); /** * Render a run mode switch From 2ba8ab877fa954ff783c04a842f376b1290e97ed Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:37:23 +0100 Subject: [PATCH 03/13] Use `switchCase` from Framework --- .../common/filters/runMode/runModeCheckbox.js | 34 +++++++------------ 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/QualityControl/public/common/filters/runMode/runModeCheckbox.js b/QualityControl/public/common/filters/runMode/runModeCheckbox.js index 0f528abda..616144849 100644 --- a/QualityControl/public/common/filters/runMode/runModeCheckbox.js +++ b/QualityControl/public/common/filters/runMode/runModeCheckbox.js @@ -11,7 +11,7 @@ * or submit itself to any jurisdiction. */ -import { h, iconWarning } from '/js/src/index.js'; +import { h, iconWarning, switchCase } from '/js/src/index.js'; import { IntegratedServices } from '../../../../library/enums/Status/integratedServices.enum.js'; import { ServiceStatus } from '../../../../library/enums/Status/serviceStatus.enum.js'; import { spinner } from '../../spinner.js'; @@ -34,30 +34,20 @@ import { spinner } from '../../spinner.js'; export const runModeComponent = (filterModel, viewModel) => filterModel.model.aboutViewModel.findService(IntegratedServices.KAFKA)?.match({ Loading: () => spinner(2, 'Checking if RunMode is configured'), - Failure: (payload) => h('.error-box.danger.flex-column.justify-center.f6.text-center', { - id: 'run-mode-failure', - }, [ + Failure: (payload) => h('.error-box.danger.flex-column.justify-center.f6.text-center', { id: 'run-mode-failure' }, [ h('span.error-icon', { title: 'RunMode is unavailable. Please contact administrator.' }, iconWarning()), h('span', payload.status.message), ]), - Success: (payload) => { - switch (payload.extras.state) { - case ServiceStatus.SUCCESS: - return runModeCheckbox(filterModel, viewModel); - case 'NOT_CONFIGURED': - return null; - default: - return h('.error-box.danger.flex-column.justify-center.f6.text-center', { - id: 'run-mode-failure', - }, [ - h('span.error-icon', { - title: 'RunMode is unavailable. Please contact administrator.', - }, iconWarning()), - h('span', 'Contact an administrator and include this information:'), - h('span', `Kafka service returned status code '${payload.extras.state ?? '?'}'`), - ]); - } - }, + Success: (payload) => switchCase(payload.extras.state, { + [ServiceStatus.SUCCESS]: () => runModeCheckbox(filterModel, viewModel), + NOT_CONFIGURED: () => null, + }, () => h('.error-box.danger.flex-column.justify-center.f6.text-center', { id: 'run-mode-failure' }, [ + h('span.error-icon', { + title: 'RunMode is unavailable. Please contact administrator.', + }, iconWarning()), + h('span', 'Contact an administrator and include this information:'), + h('span', `Kafka service returned status code '${payload.extras.state ?? '?'}'`), + ]))(), Other: () => {}, }); From f811098a7f27bef038a990235f7167e7734b5826 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:10:16 +0100 Subject: [PATCH 04/13] Add `NOT_CONFIGURED` to `ServiceStatus` --- .../enums/Status/serviceStatus.enum.js | 3 +- QualityControl/lib/services/Status.service.js | 14 +-- .../services/external/AliEcsSynchronizer.js | 12 +++ .../common/filters/runMode/runModeCheckbox.js | 6 +- .../public/pages/aboutView/AboutViewModel.js | 5 +- .../public/pages/aboutView/AboutViewPage.js | 24 ++--- .../pages/aboutView/components/serviceCard.js | 9 +- .../aboutView/components/servicePanel.js | 34 +++++++ .../components/servicesLoadingPanel.js | 18 ++-- .../components/servicesResolvedPanel.js | 40 ++++---- .../test/lib/services/StatusService.test.js | 74 ++++++++++---- .../test/public/features/runMode.test.js | 23 ++--- .../test/public/pages/about-page.test.js | 98 ++++++++++++++++++- QualityControl/test/test-index.js | 2 +- 14 files changed, 260 insertions(+), 102 deletions(-) create mode 100644 QualityControl/public/pages/aboutView/components/servicePanel.js diff --git a/QualityControl/common/library/enums/Status/serviceStatus.enum.js b/QualityControl/common/library/enums/Status/serviceStatus.enum.js index 37db33c0b..60def5304 100644 --- a/QualityControl/common/library/enums/Status/serviceStatus.enum.js +++ b/QualityControl/common/library/enums/Status/serviceStatus.enum.js @@ -20,6 +20,7 @@ export const ServiceStatus = Object.freeze({ NOT_ASKED: 'NOT_ASKED', LOADING: 'LOADING', - SUCCESS: 'SUCCESS', ERROR: 'ERROR', + SUCCESS: 'SUCCESS', + NOT_CONFIGURED: 'NOT_CONFIGURED', }); diff --git a/QualityControl/lib/services/Status.service.js b/QualityControl/lib/services/Status.service.js index dd31b5d7a..159c97ac8 100644 --- a/QualityControl/lib/services/Status.service.js +++ b/QualityControl/lib/services/Status.service.js @@ -84,7 +84,7 @@ export class StatusService { retrieveOwnStatus() { return { name: 'QCG', - status: { ok: true }, + status: { ok: true, category: ServiceStatus.SUCCESS }, version: this._packageInfo?.version ?? '', extras: { clients: this._ws?.server?.clients?.size ?? -1, @@ -97,15 +97,16 @@ export class StatusService { * @returns {string} - version of QC deployed on the system */ async retrieveQcVersion() { - let status = { ok: true }; + let status = { ok: false, category: ServiceStatus.NOT_CONFIGURED }; let version = 'Not part of an FLP deployment'; if (this._config.qc?.enabled) { try { const { stdout } = await execPromise(QC_VERSION_EXEC_COMMAND, { timeout: 6000 }); version = stdout.trim(); + status = { ok: true, category: ServiceStatus.SUCCESS }; } catch (error) { - status = { ok: false, message: error.message || error }; + status = { ok: false, category: ServiceStatus.ERROR, message: error.message || error }; this._logger.errorMessage(error, { level: 99, system: 'GUI', facility: 'qcg/status-service' }); } } @@ -118,13 +119,13 @@ export class StatusService { * @returns {Promise<{object}>} - status of the data service */ async retrieveDataServiceStatus() { - let status = { ok: true }; + let status = { ok: true, category: ServiceStatus.SUCCESS }; let version = ''; try { const { version: dataServiceVersion } = await this._dataService.getVersion(); version = dataServiceVersion; } catch (err) { - status = { ok: false, message: err.message || err }; + status = { ok: false, category: ServiceStatus.ERROR, message: err.message || err }; } return { name: 'CCDB', status, version, extras: {} }; } @@ -139,9 +140,10 @@ export class StatusService { name: IntegratedServices.KAFKA, status: { ok: status === ServiceStatus.SUCCESS, + category: status ?? ServiceStatus.NOT_CONFIGURED, }, extras: { - state: status ?? 'NOT_CONFIGURED', + ...this._aliEcsSynchronizer?.extraInfo ?? {}, }, }; } diff --git a/QualityControl/lib/services/external/AliEcsSynchronizer.js b/QualityControl/lib/services/external/AliEcsSynchronizer.js index 8b3124e4a..a5c801464 100644 --- a/QualityControl/lib/services/external/AliEcsSynchronizer.js +++ b/QualityControl/lib/services/external/AliEcsSynchronizer.js @@ -41,6 +41,7 @@ export class AliEcsSynchronizer { this._ecsRunConsumer.onMessageReceived(this._onRunMessage.bind(this)); this._status = ServiceStatus.NOT_ASKED; + this._extraInfo = {}; } /** @@ -56,6 +57,9 @@ export class AliEcsSynchronizer { } catch (error) { this._logger.errorMessage(`Error when starting ECS run consumer: ${error.message}\n${error.stack}`); this._status = ServiceStatus.ERROR; + this._extraInfo = { + message: error.message, + }; } } @@ -90,4 +94,12 @@ export class AliEcsSynchronizer { get status() { return this._status; } + + /** + * Returns extra information about the current kafka service + * @returns {object} - The extra information of the kafka service + */ + get extraInfo() { + return this._extraInfo; + } } diff --git a/QualityControl/public/common/filters/runMode/runModeCheckbox.js b/QualityControl/public/common/filters/runMode/runModeCheckbox.js index 616144849..1eb738b79 100644 --- a/QualityControl/public/common/filters/runMode/runModeCheckbox.js +++ b/QualityControl/public/common/filters/runMode/runModeCheckbox.js @@ -24,7 +24,7 @@ import { spinner } from '../../spinner.js'; * - Failure: Displays an error box with a warning icon and the failure message returned by the service. * - Success: * - {@link ServiceStatus.SUCCESS}: Renders the Run Mode checkbox component. - * - 'NOT_CONFIGURED': Renders nothing (Run Mode is intentionally unavailable). + * - {@link ServiceStatus.NOT_CONFIGURED}: Renders nothing (Run Mode is intentionally unavailable). * - Any other state: Displays a generic error box instructing the user to contact an administrator. * - Other: Unsupported or irrelevant state. * @param {object} filterModel - The filter model containing the aboutViewModel used to locate integrated services. @@ -38,7 +38,7 @@ export const runModeComponent = (filterModel, viewModel) => h('span.error-icon', { title: 'RunMode is unavailable. Please contact administrator.' }, iconWarning()), h('span', payload.status.message), ]), - Success: (payload) => switchCase(payload.extras.state, { + Success: (payload) => switchCase(payload.status.category, { [ServiceStatus.SUCCESS]: () => runModeCheckbox(filterModel, viewModel), NOT_CONFIGURED: () => null, }, () => h('.error-box.danger.flex-column.justify-center.f6.text-center', { id: 'run-mode-failure' }, [ @@ -46,7 +46,7 @@ export const runModeComponent = (filterModel, viewModel) => title: 'RunMode is unavailable. Please contact administrator.', }, iconWarning()), h('span', 'Contact an administrator and include this information:'), - h('span', `Kafka service returned status code '${payload.extras.state ?? '?'}'`), + h('span', `Kafka service returned status '${payload.status.category ?? '?'}'`), ]))(), Other: () => {}, }); diff --git a/QualityControl/public/pages/aboutView/AboutViewModel.js b/QualityControl/public/pages/aboutView/AboutViewModel.js index 0a34119b8..80b4cfc36 100644 --- a/QualityControl/public/pages/aboutView/AboutViewModel.js +++ b/QualityControl/public/pages/aboutView/AboutViewModel.js @@ -62,11 +62,10 @@ export default class AboutViewModel extends BaseViewModel { if (!ok) { this.services[ServiceStatus.ERROR][service] = RemoteData.failure({ name: service, - status: { ok: false, message: result.message }, + status: { ok: false, category: ServiceStatus.ERROR, message: result.message }, }); } else { - const { status: { ok } } = result; - const category = ok ? ServiceStatus.SUCCESS : ServiceStatus.ERROR; + const { status: { category } } = result; this.services[category][service] = RemoteData.success(result); } this.notify(); diff --git a/QualityControl/public/pages/aboutView/AboutViewPage.js b/QualityControl/public/pages/aboutView/AboutViewPage.js index f10a9e556..9abc2860d 100644 --- a/QualityControl/public/pages/aboutView/AboutViewPage.js +++ b/QualityControl/public/pages/aboutView/AboutViewPage.js @@ -12,27 +12,17 @@ * or submit itself to any jurisdiction. */ -import { ServiceStatus } from '../../../library/enums/Status/serviceStatus.enum.js'; -import { servicesLoadingPanel } from './components/servicesLoadingPanel.js'; -import { servicesResolvedPanel } from './components/servicesResolvedPanel.js'; import { h } from '/js/src/index.js'; +import { servicePanel } from './components/servicePanel.js'; /** * Shows a page to view framework information * @param {AboutViewModel} aboutViewModel - root model of the application * @returns {vnode} - virtual node element */ -export default (aboutViewModel) => { - const { services } = aboutViewModel; - return [ - h( - '.flex-column.flex-grow.p2.text-center', - { key: 'about-view-page' }, - [ - servicesLoadingPanel(services[ServiceStatus.LOADING]), - servicesResolvedPanel(services[ServiceStatus.ERROR], 'error'), - servicesResolvedPanel(services[ServiceStatus.SUCCESS], 'success'), - ], - ), - ]; -}; +export default (aboutViewModel) => + h( + '.flex-column.flex-grow.p2.text-center', + { key: 'about-view-page' }, + Object.entries(aboutViewModel.services).map(([serviceStatus, service]) => servicePanel(serviceStatus, service)), + ); diff --git a/QualityControl/public/pages/aboutView/components/serviceCard.js b/QualityControl/public/pages/aboutView/components/serviceCard.js index b7b9860fe..3ab876d84 100644 --- a/QualityControl/public/pages/aboutView/components/serviceCard.js +++ b/QualityControl/public/pages/aboutView/components/serviceCard.js @@ -13,6 +13,7 @@ */ import { h } from '/js/src/index.js'; +import { ServiceStatus } from '../../../../library/enums/Status/serviceStatus.enum.js'; /** * Builds a card for an integrated service @@ -21,18 +22,17 @@ import { h } from '/js/src/index.js'; */ export const serviceCard = (serviceData) => { const { name, status, version, extras = {} } = serviceData || {}; - const { ok, message = null } = status || {}; - const isDown = !ok; + const { category, message = null } = status || {}; const showExtras = Object.keys(extras).length > 0; const extrasToDisplay = JSON.parse(JSON.stringify(extras)); - const titleClass = ok ? '' : 'bg-danger white'; + const titleClass = category === ServiceStatus.ERROR ? 'bg-danger white' : ''; return h('.w-33.flex-column', { id: name }, [ h('.panel-title.p2.flex-row', { class: titleClass }, [ h('h4', name), version && h('i.text-right.flex-grow', { style: 'justify-content: flex-end' }, version), ]), h('.panel.flex-column.g2', [ - isDown && serviceRow('Error', message), + message && serviceRow('Error', message), showExtras && h( '.flex-column.g2', @@ -44,7 +44,6 @@ export const serviceCard = (serviceData) => { h('div', { style: 'flex: 1; text-align: left;' }, value.toString()), ])), ), - ]), ]); }; diff --git a/QualityControl/public/pages/aboutView/components/servicePanel.js b/QualityControl/public/pages/aboutView/components/servicePanel.js new file mode 100644 index 000000000..cf429b0eb --- /dev/null +++ b/QualityControl/public/pages/aboutView/components/servicePanel.js @@ -0,0 +1,34 @@ +/** + * @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 { ServiceStatus } from '../../../../library/enums/Status/serviceStatus.enum.js'; +import { servicesLoadingPanel } from './servicesLoadingPanel.js'; +import { servicesResolvedPanel } from './servicesResolvedPanel.js'; + +/** + * Build a reusable panel to display a wrapped list of service panels with their respective information + * @param {ServiceStatus} serviceStatus - Map of services with their respective information + * @param {Record} servicesRecord - Category of the services to be displayed + * @returns {vnode|null} - A virtual node representing the resolved panel + */ +export const servicePanel = (serviceStatus, servicesRecord) => { + const serviceData = Object.values(servicesRecord); + if (!serviceData.length) { + return null; + } + + return serviceStatus === ServiceStatus.LOADING + ? servicesLoadingPanel(Object.keys(serviceStatus)) + : servicesResolvedPanel(serviceStatus, serviceData); +}; diff --git a/QualityControl/public/pages/aboutView/components/servicesLoadingPanel.js b/QualityControl/public/pages/aboutView/components/servicesLoadingPanel.js index c41e7c7c0..516402738 100644 --- a/QualityControl/public/pages/aboutView/components/servicesLoadingPanel.js +++ b/QualityControl/public/pages/aboutView/components/servicesLoadingPanel.js @@ -14,19 +14,15 @@ import { spinner } from '../../../common/spinner.js'; import { h } from '/js/src/index.js'; +import { ServiceStatus } from '../../../../library/enums/Status/serviceStatus.enum.js'; /** * Build a reusable panel which displays a list of names of service that are currently waiting for their status - * @param {object} services - Object containing service names as keys and their status + * @param {string[]} serviceNames - Object containing service names as keys and their status * @returns {vnode} - A virtual node representing the loading panel */ -export const servicesLoadingPanel = (services) => { - if (Object.keys(services).length > 0) { - const namesAsString = Object.keys(services).join(', '); - return h('.w-100.flex-row.items-center.p2.shadow-level1', [ - spinner(2), - h('.ph2', `Loading status for: ${namesAsString.toUpperCase()}`), - ]); - } - return null; -}; +export const servicesLoadingPanel = (serviceNames) => + h('.w-100.flex-row.items-center.p2.shadow-level1', { id: `service-status-${ServiceStatus.LOADING.toLowerCase()}` }, [ + spinner(2), + h('.ph2', `Loading status for: ${serviceNames.join(', ').toUpperCase()}`), + ]); diff --git a/QualityControl/public/pages/aboutView/components/servicesResolvedPanel.js b/QualityControl/public/pages/aboutView/components/servicesResolvedPanel.js index 223059f54..b3c8c0f02 100644 --- a/QualityControl/public/pages/aboutView/components/servicesResolvedPanel.js +++ b/QualityControl/public/pages/aboutView/components/servicesResolvedPanel.js @@ -13,28 +13,30 @@ */ import { serviceCard } from './serviceCard.js'; -import { h } from '/js/src/index.js'; +import { h, switchCase } from '/js/src/index.js'; +import { ServiceStatus } from '../../../../library/enums/Status/serviceStatus.enum.js'; /** * Build a reusable panel to display a wrapped list of service panels with their respective information - * @param {Map} servicesMap - Map of services with their respective information - * @param {string} category - Category of the services to be displayed + * @param {ServiceStatus} serviceStatus - Category of the service to be displayed + * @param {RemoteData[]} serviceData - Information of services * @returns {vnode} - A virtual node representing the resolved panel */ -export const servicesResolvedPanel = (servicesMap, category) => { - const services = Object.values(servicesMap); - if (services.length > 0) { - const label = `Services that are in ${category.toLocaleUpperCase()} state`; - const classes = category === 'error' ? 'danger' : category ?? ''; - return h( - '.w-100.flex-column.p2.shadow-level1', - h('h4', { class: classes }, label), - h('.flex-wrap.g1', [ - services - .sort(({ payload: { name: nameA } }, { payload: { name: nameB } }) => nameA > nameB ? 1 : -1) - .map(({ payload }) => serviceCard(payload)), - ]), - ); - } - return null; +export const servicesResolvedPanel = (serviceStatus, serviceData) => { + const label = `Services that are in ${serviceStatus.toLocaleUpperCase()} state`; + const classes = switchCase(serviceStatus, { + [ServiceStatus.ERROR]: 'danger', + [ServiceStatus.SUCCESS]: 'success', + [ServiceStatus.NOT_ASKED]: 'gray-darker', + [ServiceStatus.NOT_CONFIGURED]: 'gray-darker', + }, ''); + + return h('.w-100.flex-column.p2.shadow-level1', { id: `service-status-${serviceStatus.toLowerCase()}` }, [ + h('h4', { class: classes }, label), + h('.flex-wrap.g1', [ + serviceData + .sort(({ payload: { name: nameA } }, { payload: { name: nameB } }) => nameA > nameB ? 1 : -1) + .map(({ payload }) => serviceCard(payload)), + ]), + ]); }; diff --git a/QualityControl/test/lib/services/StatusService.test.js b/QualityControl/test/lib/services/StatusService.test.js index d976b6fb3..49124a79e 100644 --- a/QualityControl/test/lib/services/StatusService.test.js +++ b/QualityControl/test/lib/services/StatusService.test.js @@ -32,7 +32,12 @@ export const statusServiceTestSuite = async () => { const result = await statusService.retrieveDataServiceStatus(); deepStrictEqual( result, - { extras: {}, name: 'CCDB', status: { ok: false, message: 'Service is currently unavailable' }, version: '' }, + { + name: 'CCDB', + status: { ok: false, category: ServiceStatus.ERROR, message: 'Service is currently unavailable' }, + version: '', + extras: {}, + }, ); }); test('should successfully return status ok if data connector passed checks', async () => { @@ -40,7 +45,12 @@ export const statusServiceTestSuite = async () => { getVersion: stub().resolves({ version: '0.0.1' }), }; const response = await statusService.retrieveDataServiceStatus(); - deepStrictEqual(response, { name: 'CCDB', status: { ok: true }, version: '0.0.1', extras: {} }); + deepStrictEqual(response, { + name: 'CCDB', + status: { ok: true, category: ServiceStatus.SUCCESS }, + version: '0.0.1', + extras: {}, + }); }); }); @@ -58,10 +68,29 @@ export const statusServiceTestSuite = async () => { ]); const expectedResults = [ - { name: 'QCG', version: '', status: { ok: true }, extras: { clients: -1 } }, - { name: 'QC', status: { ok: true }, version: 'Not part of an FLP deployment', extras: {} }, - { name: 'CCDB', status: { ok: true }, version: '0.0.1-beta', extras: {} }, - { name: 'kafka', status: { ok: true }, extras: { state: ServiceStatus.SUCCESS } }, + { + name: 'QCG', + version: '', + status: { ok: true, category: ServiceStatus.SUCCESS }, + extras: { clients: -1 }, + }, + { + name: 'QC', + status: { ok: false, category: ServiceStatus.NOT_CONFIGURED }, + version: 'Not part of an FLP deployment', + extras: {}, + }, + { + name: 'CCDB', + status: { ok: true, category: ServiceStatus.SUCCESS }, + version: '0.0.1-beta', + extras: {}, + }, + { + name: 'kafka', + status: { ok: true, category: ServiceStatus.SUCCESS }, + extras: {}, + }, ]; deepStrictEqual(statusInfo, expectedResults); @@ -71,7 +100,12 @@ export const statusServiceTestSuite = async () => { test('should return message that is not part of an FLP deployment', async () => { const statusService = new StatusService(); const response = await statusService.retrieveQcVersion(); - const result = { name: 'QC', status: { ok: true }, version: 'Not part of an FLP deployment', extras: {} }; + const result = { + name: 'QC', + status: { ok: false, category: ServiceStatus.NOT_CONFIGURED }, + version: 'Not part of an FLP deployment', + extras: {}, + }; deepStrictEqual(response, result); }); }); @@ -84,7 +118,7 @@ export const statusServiceTestSuite = async () => { deepStrictEqual(result, { name: 'QCG', - status: { ok: true }, + status: { ok: true, category: ServiceStatus.SUCCESS }, version: '0.0.1', extras: { clients: -1, @@ -98,7 +132,7 @@ export const statusServiceTestSuite = async () => { deepStrictEqual(result, { name: 'QCG', - status: { ok: true }, + status: { ok: true, category: ServiceStatus.SUCCESS }, version: '', extras: { clients: -1 }, }); @@ -113,8 +147,8 @@ export const statusServiceTestSuite = async () => { deepStrictEqual(result, { name: 'kafka', - status: { ok: true }, - extras: { state: ServiceStatus.SUCCESS }, + status: { ok: true, category: ServiceStatus.SUCCESS }, + extras: {}, }); }); @@ -125,20 +159,20 @@ export const statusServiceTestSuite = async () => { deepStrictEqual(result, { name: 'kafka', - status: { ok: false }, - extras: { state: ServiceStatus.NOT_ASKED }, + status: { ok: false, category: ServiceStatus.NOT_ASKED }, + extras: {}, }); }); test('marks Kafka service as unhealthy when synchronizer reports an error', async () => { const statusService = new StatusService(); - statusService.aliEcsSynchronizer = { status: ServiceStatus.ERROR }; + statusService.aliEcsSynchronizer = { status: ServiceStatus.ERROR, extraInfo: { message: 'test error' } }; const result = statusService.retrieveKafkaServiceStatus(); deepStrictEqual(result, { name: 'kafka', - status: { ok: false }, - extras: { state: ServiceStatus.ERROR }, + status: { ok: false, category: ServiceStatus.ERROR }, + extras: { message: 'test error' }, }); }); @@ -149,8 +183,8 @@ export const statusServiceTestSuite = async () => { deepStrictEqual(result, { name: 'kafka', - status: { ok: false }, - extras: { state: ServiceStatus.LOADING }, + status: { ok: false, category: ServiceStatus.LOADING }, + extras: {}, }); }); @@ -160,8 +194,8 @@ export const statusServiceTestSuite = async () => { deepStrictEqual(result, { name: 'kafka', - status: { ok: false }, - extras: { state: 'NOT_CONFIGURED' }, + status: { ok: false, category: ServiceStatus.NOT_CONFIGURED }, + extras: {}, }); }); }); diff --git a/QualityControl/test/public/features/runMode.test.js b/QualityControl/test/public/features/runMode.test.js index 6fff8ae07..5e039f5e0 100644 --- a/QualityControl/test/public/features/runMode.test.js +++ b/QualityControl/test/public/features/runMode.test.js @@ -51,10 +51,9 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { name: IntegratedServices.KAFKA, status: { ok: false, + category: ServiceStatus.NOT_CONFIGURED, }, - extras: { - state: 'NOT_CONFIGURED', - }, + extras: {}, }), }); } else { @@ -104,9 +103,10 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { name: IntegratedServices.KAFKA, status: { ok: false, + category: ServiceStatus.ERROR, }, extras: { - state: ServiceStatus.ERROR, + message: 'test error', }, }), }); @@ -142,12 +142,9 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { }); strictEqual( runModeErrorMessage, - `Contact an administrator and include this information: Kafka service returned status code '${ServiceStatus.ERROR}'`, + `Contact an administrator and include this information: Kafka service returned status '${ServiceStatus.ERROR}'`, 'RunMode failure should have the correct error message', ); - } catch (error) { - // Test failed - ok(false, error.message); } finally { // Cleanup: remove listener and disable interception page.off('request', requestHandler); @@ -167,10 +164,9 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { name: IntegratedServices.KAFKA, status: { ok: true, + category: ServiceStatus.SUCCESS, }, - extras: { - state: ServiceStatus.SUCCESS, - }, + extras: {}, }), }); } else { @@ -193,11 +189,8 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; }); await page.locator('.form-check-label > .switch'); - const runsModeTitle = await page.evaluate(() => document.querySelector('.form-check-label').textContent); + const runsModeTitle = await page.evaluate(() => document.querySelector('.form-check-label')?.textContent); strictEqual(runsModeTitle, 'Run mode', 'The text displayed is not `Run mode`'); - } catch (error) { - // Test failed - ok(false, error.message); } finally { // Cleanup: remove listener and disable interception page.off('request', requestHandler); diff --git a/QualityControl/test/public/pages/about-page.test.js b/QualityControl/test/public/pages/about-page.test.js index 18cad6a6a..4f415c0ac 100644 --- a/QualityControl/test/public/pages/about-page.test.js +++ b/QualityControl/test/public/pages/about-page.test.js @@ -11,7 +11,9 @@ * or submit itself to any jurisdiction. */ -import { strictEqual } from 'node:assert'; +import { ok, strictEqual } from 'node:assert'; +import { IntegratedServices } from '../../../common/library/enums/Status/integratedServices.enum.js'; +import { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; const ABOUT_PAGE_PARAM = '?page=about'; @@ -25,6 +27,100 @@ export const aboutPageTests = async (url, page, timeout = 5000, testParent) => { await testServiceStatus(testParent, page, 'qc', timeout); await testServiceStatus(testParent, page, 'ccdb', timeout); await testServiceStatus(testParent, page, 'kafka', timeout); + + await testParent.test('should display message when service errored', { timeout }, async () => { + const ERROR_MESSAGE = 'Service Error'; + + const requestHandler = (interceptedRequest) => { + const url = interceptedRequest.url(); + + if (url.includes(`/api/status/${IntegratedServices.KAFKA}`)) { + interceptedRequest.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + name: IntegratedServices.KAFKA, + status: { + ok: false, + category: ServiceStatus.ERROR, + }, + extras: { + message: ERROR_MESSAGE, + }, + }), + }); + } else { + interceptedRequest.continue(); + } + }; + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.reload({ waitUntil: 'networkidle0' }); + + const extras = await page.evaluate( + (serviceStatus, serviceName) => { + const query = `#service-status-${serviceStatus} #${serviceName} .panel .flex-row div:nth-child(2)`; + return Array.from(document.querySelectorAll(query)).map((element) => element.textContent); + }, + ServiceStatus.ERROR.toLowerCase(), + IntegratedServices.KAFKA, + ); + + ok(extras.includes(ERROR_MESSAGE), `errored service should contain message '${ERROR_MESSAGE}'`); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + }); + + await testParent.test('should have "NOT CONFIGURED" status when service is not configured', { timeout }, async () => { + const requestHandler = (interceptedRequest) => { + const url = interceptedRequest.url(); + + if (url.includes(`/api/status/${IntegratedServices.KAFKA}`)) { + interceptedRequest.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + name: IntegratedServices.KAFKA, + status: { + ok: false, + category: ServiceStatus.NOT_CONFIGURED, + }, + extras: {}, + }), + }); + } else { + interceptedRequest.continue(); + } + }; + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.reload({ waitUntil: 'networkidle0' }); + + const exists = await page.evaluate( + (serviceStatus, serviceName) => + document.querySelector(`#service-status-${serviceStatus} #${serviceName}`) !== null, + ServiceStatus.NOT_CONFIGURED.toLowerCase(), + IntegratedServices.KAFKA, + ); + + ok(exists, `Service '${IntegratedServices.KAFKA}' should have status '${ServiceStatus.NOT_CONFIGURED}'`); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + }); }; const testServiceStatus = async (testParent, page, serviceName, timeout = 5000) => { diff --git a/QualityControl/test/test-index.js b/QualityControl/test/test-index.js index 75514eb4b..53563066a 100644 --- a/QualityControl/test/test-index.js +++ b/QualityControl/test/test-index.js @@ -109,7 +109,7 @@ const OBJECT_TREE_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 20; const OBJECT_VIEW_FROM_OBJECT_TREE_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 5; const OBJECT_VIEW_FROM_LAYOUT_SHOW_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 17; const LAYOUT_SHOW_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 23; -const ABOUT_VIEW_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 4; +const ABOUT_VIEW_PAGE_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 6; const FILTER_TEST_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 26; const RUN_MODE_TEST_TIMEOUT = FRONT_END_PER_TEST_TIMEOUT * 10; From d71caf1be2ea08fd23bb910d030f53d7ce0dfba3 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Thu, 15 Jan 2026 23:24:21 +0100 Subject: [PATCH 05/13] Add requestInterceptor test utility function --- .../test/testUtils/requestInterceptor.js | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 QualityControl/test/testUtils/requestInterceptor.js diff --git a/QualityControl/test/testUtils/requestInterceptor.js b/QualityControl/test/testUtils/requestInterceptor.js new file mode 100644 index 000000000..f328bfd61 --- /dev/null +++ b/QualityControl/test/testUtils/requestInterceptor.js @@ -0,0 +1,44 @@ +/** + * @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. + */ + +/** + * Intercepts and conditionally handles HTTP requests based on URL pattern matching. + * This function evaluates whether a request's URL matches a specified regular expression pattern. + * If the pattern matches, it delegates request handling to a provided callback function. + * Otherwise, it allows the request to continue normally without intervention. + * @param {import('puppeteer').HTTPRequest} request - The HTTP request object to be intercepted. + * @param {RegExp} pathRegex - A regular expression pattern used to match against the request URL. + * @param {(request: import('puppeteer').HTTPRequest) => Promise} callback - A callback function, + * invoked when the URL matches the pattern. Receives the request object as its parameter and is responsible + * for handling the request (e.g., abort, respond, or continue). + * @returns {void} + * @example + * // Block all image requests + * await page.setRequestInterception(true); + * page.on('request', (request) => + * interceptRequest(request, /\.(jpg|jpeg|png|gif)$/i, (req) => req.abort()) + * ); + */ +export const interceptRequest = async (request, pathRegex, callback) => { + if (request.isInterceptResolutionHandled()) { + // The interception has already been handled. + return; + } + + if (pathRegex.test(request.url())) { + await callback(request); + } else { + await request.continue(); + } +}; From 56f52000137df57b21c301d9abd4511a1eb1a0ad Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Thu, 15 Jan 2026 23:24:58 +0100 Subject: [PATCH 06/13] Add `integratedServiceInterceptor` test utility function --- .../integratedServiceInterceptor.js | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 QualityControl/test/testUtils/interceptors/integratedServiceInterceptor.js diff --git a/QualityControl/test/testUtils/interceptors/integratedServiceInterceptor.js b/QualityControl/test/testUtils/interceptors/integratedServiceInterceptor.js new file mode 100644 index 000000000..98ed8d784 --- /dev/null +++ b/QualityControl/test/testUtils/interceptors/integratedServiceInterceptor.js @@ -0,0 +1,44 @@ +/** + * @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 { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; +import { interceptRequest } from '../requestInterceptor.js'; + +/** + * An interceptor services statuses of integrated services. + * @param {import('puppeteer').HTTPRequest} request - The puppeteer request + * @param {IntegratedServices} integratedService - The {@link IntegratedServices} to intercept + * @param {ServiceStatus} serviceStatus - The {@link ServiceStatus} to apply for the integrated service + * @param {object} extras - Optional extras object. + * @returns {undefined} + */ +export const integratedServiceInterceptor = ( + request, + integratedService, + serviceStatus = ServiceStatus.SUCCESS, + extras = {}, +) => interceptRequest(request, new RegExp(`/api/status/${integratedService}`), async (req) => { + await req.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + name: integratedService, + status: { + ok: serviceStatus === ServiceStatus.SUCCESS, + category: serviceStatus, + }, + extras, + }), + }); +}); From a836555642b84ea18f7eeac8ae208950d3558135 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Thu, 15 Jan 2026 23:30:08 +0100 Subject: [PATCH 07/13] Utilize new utility intercepting functions and fix failing tests --- .../public/components/profileHeader.test.js | 40 +++++- .../test/public/features/filterTest.test.js | 94 ++++++-------- .../test/public/features/runMode.test.js | 119 ++++++------------ 3 files changed, 111 insertions(+), 142 deletions(-) diff --git a/QualityControl/test/public/components/profileHeader.test.js b/QualityControl/test/public/components/profileHeader.test.js index 8c05e9524..e269b9e94 100644 --- a/QualityControl/test/public/components/profileHeader.test.js +++ b/QualityControl/test/public/components/profileHeader.test.js @@ -16,7 +16,9 @@ import { doesNotMatch, match, ok, strictEqual } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; import { StorageKeysEnum } from '../../../public/common/enums/storageKeys.enum.js'; import { getLocalStorageAsJson } from '../../testUtils/localStorage.js'; -import { Transition } from '../../../common/library/enums/transition.enum.js'; +import { IntegratedServices } from '../../../common/library/enums/Status/integratedServices.enum.js'; +import { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; +import { integratedServiceInterceptor } from '../../testUtils/interceptors/integratedServiceInterceptor.js'; /** * Performs a series of automated tests on the layoutList page using Puppeteer. @@ -196,8 +198,32 @@ export const profileHeaderTests = async (url, page, timeout = 1000, testParent) } }); - await testParent.test('should enable RunMode when native browser notification is clicked', { timeout }, async () => { - await page.goto(`${url}?page=objectTree`, { waitUntil: 'networkidle0' }); + await testParent.test('should enable RunMode when browser notification is clicked', { timeout }, async () => { + const RUN_NUMBER = 1234; + + /* + * Kafka must be enabled for the browser notification feature to function correctly. + * We intercept the request and return a SUCCESS state of the kafka service. + */ + const requestHandler = (request) => + integratedServiceInterceptor(request, IntegratedServices.KAFKA, ServiceStatus.SUCCESS); + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.reload({ waitUntil: 'networkidle0' }); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } + + /* + * To be able to receive a native browser notification we forcefully enable these permissions. + * After which we call the method triggered by websocket messages to push a fake notification. + */ const context = page.browserContext(); try { @@ -238,7 +264,7 @@ export const profileHeaderTests = async (url, page, timeout = 1000, testParent) // Trigger native browser notification by simulating websocket message await page.evaluate( (wsMessage) => window.model.notificationRunStartModel._handleWSRunTrack(wsMessage), - { runNumber: 1234, transition: Transition.START_ACTIVITY }, + { runNumber: RUN_NUMBER }, ); // `window.__lastNotification` is set by the mocked `Notification` await page.waitForFunction(() => window.__lastNotification !== null); @@ -251,13 +277,17 @@ export const profileHeaderTests = async (url, page, timeout = 1000, testParent) window.__lastNotification.onclick(); } }); + await page.waitForNetworkIdle(); await page.waitForFunction(() => document.querySelector('#run-mode-switch .switch input[type="checkbox"]')?.checked === true); + const location = await page.evaluate(() => window.location); + strictEqual(location.search, `?page=objectTree&RunNumber=${RUN_NUMBER}`); + selectedRun = await page.evaluate(() => document.querySelector('select#ongoingRunsFilter')?.value); strictEqual( selectedRun, - '1234', + RUN_NUMBER.toString(10), 'Should have the newly started run selected in RunMode after clicking the notification', ); } finally { diff --git a/QualityControl/test/public/features/filterTest.test.js b/QualityControl/test/public/features/filterTest.test.js index 129c98391..aae0f0552 100644 --- a/QualityControl/test/public/features/filterTest.test.js +++ b/QualityControl/test/public/features/filterTest.test.js @@ -14,6 +14,7 @@ import { strictEqual, ok } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; import { RunStatus } from '../../../common/library/runStatus.enum.js'; +import {interceptRequest} from "../../testUtils/requestInterceptor.js"; export const filterTests = async (url, page, timeout = 5000, testParent) => { await testParent.test('filter should persist between pages', { timeout }, async () => { @@ -55,26 +56,19 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { '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(); - } - }; + const requestHandler = (request) => interceptRequest(request, /\/api\/filter\/run-status\/0/, (req) => { + req.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' }, + ], + }), + }); + }); try { // Enable interception and attach the handler @@ -110,22 +104,15 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { '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(); - } - }; + const requestHandler = (request) => interceptRequest(request, /\/api\/filter\/run-status\/0/, (req) => { + req.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + detectorsQualities: [], + }), + }); + }); try { // Enable interception and attach the handler @@ -160,22 +147,15 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { }); 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(); - } - }; + const requestHandler = (request) => interceptRequest(request, /\/api\/filter\/run-status\/0/, (req) => { + req.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + runStatus: RunStatus.UNKNOWN, + }), + }); + }); try { // Enable interception and attach the handler @@ -189,9 +169,6 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { // 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); @@ -201,18 +178,21 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { 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.waitForSelector('nav > a:nth-child(3)', { timeout: 1000 }).catch(() => { /* Ignore timeout error */ }); await page.locator('nav > a:nth-child(3)').click(); //With filter applied + await page.waitForFunction(() => window.model.object.list.length === 1, { timeout: 1000 }) + .catch(() => { /* Ignore timeout error */ }); let objectList = await page.evaluate(() => window.model.object.list); strictEqual(objectList.length, 1); //Clear filters await page.locator('#clearFilterButton').click(); - await delay(100); + await page.waitForFunction(() => window.model.object.list.length === 3, { timeout: 1000 }) + .catch(() => { /* Ignore timeout error */ }); objectList = await page.evaluate(() => window.model.object.list); strictEqual(objectList.length, 3); }); diff --git a/QualityControl/test/public/features/runMode.test.js b/QualityControl/test/public/features/runMode.test.js index 2e5846cd4..7a11b1677 100644 --- a/QualityControl/test/public/features/runMode.test.js +++ b/QualityControl/test/public/features/runMode.test.js @@ -16,6 +16,7 @@ import { strictEqual, ok } from 'node:assert'; import { delay } from '../../testUtils/delay.js'; import { IntegratedServices } from '../../../common/library/enums/Status/integratedServices.enum.js'; import { ServiceStatus } from '../../../common/library/enums/Status/serviceStatus.enum.js'; +import { integratedServiceInterceptor } from '../../testUtils/interceptors/integratedServiceInterceptor.js'; // If using nock for HTTP mocking (uncomment if available) // import nock from 'nock'; @@ -40,26 +41,7 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { }); await testParent.test('when kafka service is not configured the run mode toggle should be hidden', { timeout }, async () => { - const requestHandler = (interceptedRequest) => { - const url = interceptedRequest.url(); - - if (url.includes(`/api/status/${IntegratedServices.KAFKA}`)) { - interceptedRequest.respond({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - name: IntegratedServices.KAFKA, - status: { - ok: false, - category: ServiceStatus.NOT_CONFIGURED, - }, - extras: {}, - }), - }); - } else { - interceptedRequest.continue(); - } - }; + const requestHandler = (request) => integratedServiceInterceptor(request, IntegratedServices.KAFKA, ServiceStatus.NOT_CONFIGURED); try { // Enable interception and attach the handler @@ -76,7 +58,7 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; }); - const runsModeToggleNoExist = await page.evaluate(() => document.querySelector('.form-check-label') === null); + const runsModeToggleNoExist = await page.evaluate(() => document.querySelector('#run-mode-switch') === null); ok(runsModeToggleNoExist, 'The RunMode switch should not be displayed'); const runsModeErrorNoExist = await page.evaluate(() => document.querySelector('#run-mode-failure') === null); @@ -89,28 +71,9 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { }); await testParent.test('when kafka service is unavailable an error should be displayed instead of the run mode toggle', { timeout }, async () => { - const requestHandler = (interceptedRequest) => { - const url = interceptedRequest.url(); - - if (url.includes(`/api/status/${IntegratedServices.KAFKA}`)) { - interceptedRequest.respond({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - name: IntegratedServices.KAFKA, - status: { - ok: false, - category: ServiceStatus.ERROR, - }, - extras: { - message: 'test error', - }, - }), - }); - } else { - interceptedRequest.continue(); - } - }; + const requestHandler = (request) => integratedServiceInterceptor(request, IntegratedServices.KAFKA, ServiceStatus.ERROR, { + message: 'test error', + }); try { // Enable interception and attach the handler @@ -127,7 +90,7 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; }); - const runsModeNoExist = await page.evaluate(() => document.querySelector('.form-check-label') === null); + const runsModeNoExist = await page.evaluate(() => document.querySelector('#run-mode-switch') === null); ok(runsModeNoExist, 'The RunMode switch should not be displayed'); const runModeErrorMessage = await page.evaluate(() => { @@ -150,26 +113,8 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { }); await testParent.test('should have a switch to enable run mode when kafka service is available', { timeout }, async () => { - const requestHandler = (interceptedRequest) => { - const url = interceptedRequest.url(); - - if (url.includes(`/api/status/${IntegratedServices.KAFKA}`)) { - interceptedRequest.respond({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - name: IntegratedServices.KAFKA, - status: { - ok: true, - category: ServiceStatus.SUCCESS, - }, - extras: {}, - }), - }); - } else { - interceptedRequest.continue(); - } - }; + // The kafka service is required for run mode to be available + const requestHandler = (request) => integratedServiceInterceptor(request, IntegratedServices.KAFKA, ServiceStatus.SUCCESS); try { // Enable interception and attach the handler @@ -185,8 +130,9 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { await page.evaluate(() => { window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; }); - await page.locator('.form-check-label > .switch'); - const runsModeTitle = await page.evaluate(() => document.querySelector('.form-check-label')?.textContent); + + await page.locator('#run-mode-switch > .switch'); + const runsModeTitle = await page.evaluate(() => document.querySelector('#run-mode-switch')?.textContent); strictEqual(runsModeTitle, 'Run mode', 'The text displayed is not `Run mode`'); } finally { // Cleanup: remove listener and disable interception @@ -194,21 +140,34 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { await page.setRequestInterception(false); } }); - + await testParent.test('should have a switch to enable run mode', { timeout }, async () => { - await page.goto( - `${url}?page=objectTree`, - { waitUntil: 'networkidle0' }, - ); - await delay(100); - // Prevent the 'get run status' from re-triggering mid test - await page.evaluate(() => { - window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; - }); - await page.locator('#run-mode-switch > .switch'); - const runsModeTitle = await page.evaluate(() => - document.querySelector('#run-mode-switch').textContent); - strictEqual(runsModeTitle, 'Run mode', 'The text displayed is not `Runs mode`'); + // The kafka service is required for run mode to be available + const requestHandler = (request) => integratedServiceInterceptor(request, IntegratedServices.KAFKA, ServiceStatus.SUCCESS); + + try { + // Enable interception and attach the handler + await page.setRequestInterception(true); + page.on('request', requestHandler); + + await page.goto( + `${url}?page=objectTree`, + { waitUntil: 'networkidle0' }, + ); + await delay(100); + // Prevent the 'get run status' from re-triggering mid test + await page.evaluate(() => { + window.model.filterModel.ONGOING_RUN_INTERVAL_MS = 12000000; + }); + await page.locator('#run-mode-switch > .switch'); + const runsModeTitle = await page.evaluate(() => + document.querySelector('#run-mode-switch')?.textContent); + strictEqual(runsModeTitle, 'Run mode', 'The text displayed is not `Run mode`'); + } finally { + // Cleanup: remove listener and disable interception + page.off('request', requestHandler); + await page.setRequestInterception(false); + } }); await testParent.test('should activate run mode', { timeout }, async () => { From cfe4dec328ee9a4ccd1d845501871831c3d83484 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Sun, 18 Jan 2026 10:03:36 +0100 Subject: [PATCH 08/13] Improve status on backend --- QualityControl/lib/services/Status.service.js | 3 ++- QualityControl/lib/services/external/AliEcsSynchronizer.js | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/QualityControl/lib/services/Status.service.js b/QualityControl/lib/services/Status.service.js index 159c97ac8..96085081c 100644 --- a/QualityControl/lib/services/Status.service.js +++ b/QualityControl/lib/services/Status.service.js @@ -119,10 +119,11 @@ export class StatusService { * @returns {Promise<{object}>} - status of the data service */ async retrieveDataServiceStatus() { - let status = { ok: true, category: ServiceStatus.SUCCESS }; + let status = { ok: true, category: ServiceStatus.LOADING }; let version = ''; try { const { version: dataServiceVersion } = await this._dataService.getVersion(); + status = { ok: true, category: ServiceStatus.SUCCESS }; version = dataServiceVersion; } catch (err) { status = { ok: false, category: ServiceStatus.ERROR, message: err.message || err }; diff --git a/QualityControl/lib/services/external/AliEcsSynchronizer.js b/QualityControl/lib/services/external/AliEcsSynchronizer.js index a5c801464..ed59a8ffd 100644 --- a/QualityControl/lib/services/external/AliEcsSynchronizer.js +++ b/QualityControl/lib/services/external/AliEcsSynchronizer.js @@ -50,7 +50,11 @@ export class AliEcsSynchronizer { */ async start() { this._logger.infoMessage('Starting to consume AliECS messages for topics:'); - this._status = ServiceStatus.LOADING; + this._status = ServiceStatus.ERROR; + this._extraInfo = { + // KafkaConsumer is currently not supporting "active" status checking [OGUI-1872] + message: 'Kafka is configured but the service has not started yet', + }; try { await this._ecsRunConsumer.start(); this._status = ServiceStatus.SUCCESS; From e1e0cd313366c191200668eeb8dae13888da64a5 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Sun, 18 Jan 2026 10:03:48 +0100 Subject: [PATCH 09/13] In case of error we display only on about page --- .../common/filters/runMode/runModeCheckbox.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/QualityControl/public/common/filters/runMode/runModeCheckbox.js b/QualityControl/public/common/filters/runMode/runModeCheckbox.js index aa2fcdc89..9208e282c 100644 --- a/QualityControl/public/common/filters/runMode/runModeCheckbox.js +++ b/QualityControl/public/common/filters/runMode/runModeCheckbox.js @@ -38,16 +38,14 @@ export const runModeComponent = (filterModel, viewModel) => h('span.error-icon', { title: 'RunMode is unavailable. Please contact administrator.' }, iconWarning()), h('span', payload.status.message), ]), - Success: (payload) => switchCase(payload.status.category, { - [ServiceStatus.SUCCESS]: () => runModeCheckbox(filterModel, viewModel), - NOT_CONFIGURED: () => null, - }, () => h('.error-box.danger.flex-column.justify-center.f6.text-center', { id: 'run-mode-failure' }, [ - h('span.error-icon', { - title: 'RunMode is unavailable. Please contact administrator.', - }, iconWarning()), - h('span', 'Contact an administrator and include this information:'), - h('span', `Kafka service returned status '${payload.status.category ?? '?'}'`), - ]))(), + Success: (payload) => + switchCase( + payload.status.category, + { + [ServiceStatus.SUCCESS]: () => runModeCheckbox(filterModel, viewModel), + }, + () => {}, + )(), Other: () => {}, }); From 83bfe16e724ff7ca1e2b5128f04609e651f51e58 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Sun, 18 Jan 2026 10:04:02 +0100 Subject: [PATCH 10/13] Fix incorrect variable usage --- .../public/pages/aboutView/components/servicePanel.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/QualityControl/public/pages/aboutView/components/servicePanel.js b/QualityControl/public/pages/aboutView/components/servicePanel.js index cf429b0eb..de695d827 100644 --- a/QualityControl/public/pages/aboutView/components/servicePanel.js +++ b/QualityControl/public/pages/aboutView/components/servicePanel.js @@ -18,7 +18,7 @@ import { servicesResolvedPanel } from './servicesResolvedPanel.js'; /** * Build a reusable panel to display a wrapped list of service panels with their respective information - * @param {ServiceStatus} serviceStatus - Map of services with their respective information + * @param {ServiceStatus} serviceStatus - Category of the service to be displayed * @param {Record} servicesRecord - Category of the services to be displayed * @returns {vnode|null} - A virtual node representing the resolved panel */ @@ -27,8 +27,7 @@ export const servicePanel = (serviceStatus, servicesRecord) => { if (!serviceData.length) { return null; } - return serviceStatus === ServiceStatus.LOADING - ? servicesLoadingPanel(Object.keys(serviceStatus)) + ? servicesLoadingPanel(Object.keys(servicesRecord)) : servicesResolvedPanel(serviceStatus, serviceData); }; From 9ec492ebbf9c7f3cd080663cb995ac8790274b78 Mon Sep 17 00:00:00 2001 From: George Raduta Date: Sun, 18 Jan 2026 10:04:07 +0100 Subject: [PATCH 11/13] Small UI improvements --- QualityControl/public/pages/aboutView/AboutViewPage.js | 2 +- .../public/pages/aboutView/components/servicesResolvedPanel.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/QualityControl/public/pages/aboutView/AboutViewPage.js b/QualityControl/public/pages/aboutView/AboutViewPage.js index 9abc2860d..b443b6379 100644 --- a/QualityControl/public/pages/aboutView/AboutViewPage.js +++ b/QualityControl/public/pages/aboutView/AboutViewPage.js @@ -22,7 +22,7 @@ import { servicePanel } from './components/servicePanel.js'; */ export default (aboutViewModel) => h( - '.flex-column.flex-grow.p2.text-center', + '.flex-column.flex-grow.p2', { key: 'about-view-page' }, Object.entries(aboutViewModel.services).map(([serviceStatus, service]) => servicePanel(serviceStatus, service)), ); diff --git a/QualityControl/public/pages/aboutView/components/servicesResolvedPanel.js b/QualityControl/public/pages/aboutView/components/servicesResolvedPanel.js index b3c8c0f02..54d08a3b3 100644 --- a/QualityControl/public/pages/aboutView/components/servicesResolvedPanel.js +++ b/QualityControl/public/pages/aboutView/components/servicesResolvedPanel.js @@ -33,7 +33,7 @@ export const servicesResolvedPanel = (serviceStatus, serviceData) => { return h('.w-100.flex-column.p2.shadow-level1', { id: `service-status-${serviceStatus.toLowerCase()}` }, [ h('h4', { class: classes }, label), - h('.flex-wrap.g1', [ + h(`.flex-wrap.g1.${classes}`, [ serviceData .sort(({ payload: { name: nameA } }, { payload: { name: nameB } }) => nameA > nameB ? 1 : -1) .map(({ payload }) => serviceCard(payload)), From dd174886610e9b37d4b50cba881cf6bc4e99840e Mon Sep 17 00:00:00 2001 From: George Raduta Date: Sun, 18 Jan 2026 10:08:37 +0100 Subject: [PATCH 12/13] Update tests --- .../test/public/features/runMode.test.js | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/QualityControl/test/public/features/runMode.test.js b/QualityControl/test/public/features/runMode.test.js index 7a11b1677..6bc71744e 100644 --- a/QualityControl/test/public/features/runMode.test.js +++ b/QualityControl/test/public/features/runMode.test.js @@ -70,7 +70,7 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { } }); - await testParent.test('when kafka service is unavailable an error should be displayed instead of the run mode toggle', { timeout }, async () => { + await testParent.test('when kafka service is unavailable nothing should be displayed (rely on about page)', { timeout }, async () => { const requestHandler = (request) => integratedServiceInterceptor(request, IntegratedServices.KAFKA, ServiceStatus.ERROR, { message: 'test error', }); @@ -92,19 +92,6 @@ export const runModeTests = async (url, page, timeout = 5000, testParent) => { const runsModeNoExist = await page.evaluate(() => document.querySelector('#run-mode-switch') === null); ok(runsModeNoExist, 'The RunMode switch should not be displayed'); - - const runModeErrorMessage = await page.evaluate(() => { - const spans = document.querySelectorAll('#run-mode-failure > span'); - return Array.from(spans) - .map((span) => span.textContent.trim()) - .filter((text) => text !== '') - .join(' '); - }); - strictEqual( - runModeErrorMessage, - `Contact an administrator and include this information: Kafka service returned status '${ServiceStatus.ERROR}'`, - 'RunMode failure should have the correct error message', - ); } finally { // Cleanup: remove listener and disable interception page.off('request', requestHandler); From fba2c33b07b9a876aa23943660884515c21b1eda Mon Sep 17 00:00:00 2001 From: George Raduta Date: Sun, 18 Jan 2026 10:17:30 +0100 Subject: [PATCH 13/13] Fix GH recommendation --- QualityControl/lib/services/Status.service.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/QualityControl/lib/services/Status.service.js b/QualityControl/lib/services/Status.service.js index 96085081c..a2d75bf0a 100644 --- a/QualityControl/lib/services/Status.service.js +++ b/QualityControl/lib/services/Status.service.js @@ -119,16 +119,20 @@ export class StatusService { * @returns {Promise<{object}>} - status of the data service */ async retrieveDataServiceStatus() { - let status = { ok: true, category: ServiceStatus.LOADING }; - let version = ''; + const statusPackage = { name: 'CCDB', version: '', extras: {} }; try { const { version: dataServiceVersion } = await this._dataService.getVersion(); - status = { ok: true, category: ServiceStatus.SUCCESS }; - version = dataServiceVersion; + return { + ...statusPackage, + status: { ok: true, category: ServiceStatus.SUCCESS }, + version: dataServiceVersion, + }; } catch (err) { - status = { ok: false, category: ServiceStatus.ERROR, message: err.message || err }; + return { + ...statusPackage, + status: { ok: false, category: ServiceStatus.ERROR, message: err.message || err }, + }; } - return { name: 'CCDB', status, version, extras: {} }; } /**