diff --git a/QualityControl/lib/QCModel.js b/QualityControl/lib/QCModel.js index aa5364af6..afad949cd 100644 --- a/QualityControl/lib/QCModel.js +++ b/QualityControl/lib/QCModel.js @@ -102,7 +102,10 @@ export const setupQcModel = async (eventEmitter) => { const userController = new UserController(userRepository); const layoutController = new LayoutController(layoutRepository); - const statusService = new StatusService({ version: packageJSON?.version ?? '-' }, { qc: config.qc ?? {} }); + const statusService = new StatusService( + { version: packageJSON?.version ?? '-' }, + { qc: config.qc ?? {}, bookkeeping: config.bookkeeping ?? {} }, + ); const statusController = new StatusController(statusService); const qcdbDownloadService = new QcdbDownloadService(config.ccdb); @@ -116,6 +119,8 @@ export const setupQcModel = async (eventEmitter) => { const intervalsService = new IntervalsService(); const bookkeepingService = new BookkeepingService(config.bookkeeping); + statusService.bookkeepingService = bookkeepingService; + const filterService = new FilterService(bookkeepingService, config); const runModeService = new RunModeService(config.bookkeeping, bookkeepingService, ccdbService, eventEmitter); const objectController = new ObjectController(qcObjectService, runModeService, qcdbDownloadService); diff --git a/QualityControl/lib/api.js b/QualityControl/lib/api.js index 3c02671cd..b63b0c999 100644 --- a/QualityControl/lib/api.js +++ b/QualityControl/lib/api.js @@ -102,6 +102,7 @@ export const setup = async (http, ws, eventEmitter) => { statusController.getServiceStatusHandler.bind(statusController), { public: true }, ); + http.get('/services', statusController.getServicesConfigurationHandler.bind(statusController)); http.get('/checkUser', userController.addUserHandler.bind(userController)); diff --git a/QualityControl/lib/controllers/StatusController.js b/QualityControl/lib/controllers/StatusController.js index 3e8ea025e..0f6de3a63 100644 --- a/QualityControl/lib/controllers/StatusController.js +++ b/QualityControl/lib/controllers/StatusController.js @@ -57,4 +57,14 @@ export class StatusController { ); } } + + /** + * Send back the configuration of the connected services for the frontend + * @param {Request} _ - HTTP request object + * @param {Response} res - HTTP response object + * @returns {undefined} + */ + async getServicesConfigurationHandler(_, res) { + res.status(200).json(this._statusService.retrieveServicesConfiguration()); + } } diff --git a/QualityControl/lib/services/BookkeepingService.js b/QualityControl/lib/services/BookkeepingService.js index f5d9f6353..8037aec89 100644 --- a/QualityControl/lib/services/BookkeepingService.js +++ b/QualityControl/lib/services/BookkeepingService.js @@ -181,6 +181,18 @@ export class BookkeepingService { } } + /** + * Retrieve the configured URL for Bookkeeping + * @returns {string | false} - URL for Bookkeeping, if not configured returns `false` + */ + retrieveBookkeepingURL() { + if (!this.active) { + this._logger.warnMessage('Bookkeeping not configured'); + return false; + } + return `${this._protocol}${this._hostname}${this._port}`; + } + /** * Helper method to construct a URL path with the required authentication token. * Appends the service's token as a query parameter to the provided path. diff --git a/QualityControl/lib/services/Status.service.js b/QualityControl/lib/services/Status.service.js index c6d6e33a9..c9a1e1f89 100644 --- a/QualityControl/lib/services/Status.service.js +++ b/QualityControl/lib/services/Status.service.js @@ -38,6 +38,11 @@ export class StatusService { */ this._dataService = undefined; + /** + * @type {BookkeepingService} + */ + this._bookkeepingService = undefined; + /** * @type {WebSocket} */ @@ -120,6 +125,23 @@ export class StatusService { return { name: 'CCDB', status, version, extras: {} }; } + /** + * Retrieve the configurations of the services for the front end. + * @returns {object} - object containing the configurations of the services for the front end. + */ + retrieveServicesConfiguration() { + const serviceConfig = {}; + + if (this._bookkeepingService?.active) { + serviceConfig.bookkeeping = { + BASE_URL: this._config.bookkeeping.url, + PARTIAL_RUN_DETAILS: '?page=run-detail&runNumber=', + }; + } + + return serviceConfig; + } + /* * Getters & Setters */ @@ -133,6 +155,15 @@ export class StatusService { this._dataService = dataService; } + /** + * Set service to be used for querying status of the Bookkeeping service. + * @param {BookkeepingService} bookkeepingService - service used for retrieving Bookkeeping status + * @returns {void} + */ + set bookkeepingService(bookkeepingService) { + this._bookkeepingService = bookkeepingService; + } + /** * Set instance of websocket server * @param {WebSocket} ws - instance of the WS server diff --git a/QualityControl/public/Model.js b/QualityControl/public/Model.js index 203a6e58b..c46592c02 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 StatusService from './services/Status.service.js'; /** * Represents the application's state and actions as a class @@ -97,6 +98,7 @@ export default class Model extends Observable { this.services = { object: new QCObjectService(this), layout: new LayoutService(this), + status: new StatusService(this), }; this.loader.get('/api/checkUser'); diff --git a/QualityControl/public/app.css b/QualityControl/public/app.css index 9cd733a09..f7c19da7f 100644 --- a/QualityControl/public/app.css +++ b/QualityControl/public/app.css @@ -147,7 +147,7 @@ font-weight: 500; } - &>div:hover { + & > div > div:hover { font-weight: 700; } } diff --git a/QualityControl/public/common/object/objectInfoCard.js b/QualityControl/public/common/object/objectInfoCard.js index 4474e29a9..95e2b38b6 100644 --- a/QualityControl/public/common/object/objectInfoCard.js +++ b/QualityControl/public/common/object/objectInfoCard.js @@ -13,6 +13,7 @@ */ import { h, isContextSecure } from '/js/src/index.js'; +import { iconExternalLink } from '/js/src/icons.js'; import { camelToTitleCase, copyToClipboard, prettyFormatDate } from './../utils.js'; const SPECIFIC_KEY_LABELS = { @@ -65,7 +66,21 @@ const infoRow = (key, value, infoRowAttributes) => { return h(`.flex-row.g2.info-row${highlightedClasses}`, [ h('b.w-25.w-wrapped', formattedKey), - h('.w-75.cursor-pointer', hasValue && infoRowAttributes(formattedKey, formattedValue), formattedValue), + h('.flex-row.w-75', [ + h( + '.cursor-pointer.flex-row', + hasValue && infoRowAttributes(formattedKey, formattedValue), + formattedValue, + ), + model.services.status.isConfigured('bookkeeping') && key === 'runNumber' && hasValue + ? h('a.ph2.text-right.actionable-icon', { + id: 'openRunInBookkeeping', + title: 'Open run in Bookkeeping', + href: model.services.status.buildBookkeepingUrl(value), + target: '_blank', + }, iconExternalLink()) + : '', + ]), ]); }; diff --git a/QualityControl/public/services/Status.service.js b/QualityControl/public/services/Status.service.js new file mode 100644 index 000000000..a64c9bcd0 --- /dev/null +++ b/QualityControl/public/services/Status.service.js @@ -0,0 +1,81 @@ +/** + * @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 { RemoteData } from '/js/src/index.js'; + +/** + * @typedef {object} ServicePayload + * @property {object} [bookkeeping] - Configuration for the Bookkeeping service. + * @property {string} bookkeeping.BASE_URL - The root URL of the Bookkeeping application. + * @property {string} bookkeeping.PARTIAL_RUN_DETAILS - The URL path/query parameters for run details. + */ + +export default class StatusService { + /** + * Initialize service + * @param {Model} model - root model of the application + */ + constructor(model) { + this.model = model; + this.loader = model.loader; + + /** + * @type {RemoteData} + */ + this.serviceConfig = RemoteData.notAsked(); + + this.initStatusService(); + } + + /** + * Fetches service configurations from the backend and updates the internal state. + * Notifies the model once the request completes (success or failure). + * @returns {Promise} + */ + async initStatusService() { + const { result, ok } = await this.loader.get('api/services'); + if (ok) { + this.serviceConfig = RemoteData.success(result || {}); + } else { + this.serviceConfig = RemoteData.failure('Error retrieving services'); + } + + this.model.notify(); + } + + /** + * Checks if a specific service configuration is successfully loaded and available. + * @param {string} service - The name of the service to check (e.g. 'bookkeeping'). + * @returns {boolean} - True if the service key exists in a successful payload. + */ + isConfigured(service) { + return this.serviceConfig.match({ + Success: (config) => Object.hasOwn(config, service), + Other: () => false, + }); + } + + /** + * Constructs a full URL for the bookkeeping run details page. + * @param {string|number} runNumber - The specific run identifier to append to the URL. + * @returns {string|undefined} The formatted URL, or `undefined` if the service is not configured. + */ + buildBookkeepingUrl(runNumber) { + if (!this.isConfigured('bookkeeping')) { + return; + } + const { BASE_URL, PARTIAL_RUN_DETAILS } = this.serviceConfig.payload.bookkeeping; + return `${BASE_URL}/${PARTIAL_RUN_DETAILS}${runNumber}`; + } +} diff --git a/QualityControl/test/lib/controllers/StatusController.test.js b/QualityControl/test/lib/controllers/StatusController.test.js index 7fa073f27..70bdd7c09 100644 --- a/QualityControl/test/lib/controllers/StatusController.test.js +++ b/QualityControl/test/lib/controllers/StatusController.test.js @@ -17,6 +17,7 @@ import { ok } from 'node:assert'; import { suite, test } from 'node:test'; import { StatusController } from './../../../lib/controllers/StatusController.js'; +import { config } from '../../config.js'; export const statusControllerTestSuite = async () => { suite('`getSetServiceStatusHandler()` tests', () => { @@ -91,4 +92,29 @@ export const statusControllerTestSuite = async () => { ok(res.json.calledWith(result)); }); }); + + suite('`getServicesConfigurationHandler()` tests', () => { + test('should successfully respond with result JSON with the configured services', () => { + const mock = { + bookkeeping: { + BASE_URL: config.bookkeeping.url, + PARTIAL_RUN_DETAILS: '?page=run-detail&runNumber=', + }, + }; + + const statusService = { + retrieveServicesConfiguration: stub().returns(mock), + }; + const statusController = new StatusController(statusService); + const res = { + status: stub().returnsThis(), + json: stub(), + }; + statusController.getServicesConfigurationHandler({}, res); + + ok(statusService.retrieveServicesConfiguration.calledOnce, 'Service method should be called once'); + ok(res.status.calledWith(200), 'Response status should be 200'); + ok(res.json.calledWith(mock), 'Response JSON should match the service output'); + }); + }); }; diff --git a/QualityControl/test/lib/services/StatusService.test.js b/QualityControl/test/lib/services/StatusService.test.js index 0bec75015..5f7263fc1 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 { config } from '../../config.js'; export const statusServiceTestSuite = async () => { suite('`retrieveDataServiceStatus()` tests', () => { @@ -100,4 +101,24 @@ export const statusServiceTestSuite = async () => { }); }); }); + + suite('`retrieveServicesConfiguration()` tests', () => { + test('should return bookkeeping configuration if bookkeeping service is active', () => { + const serviceConfig = { + bookkeeping: { url: config.bookkeeping.url }, + }; + const statusService = new StatusService({ version: '0.1.1' }, serviceConfig); + + statusService.bookkeepingService = { active: true }; + + const result = statusService.retrieveServicesConfiguration(); + + deepStrictEqual(result, { + bookkeeping: { + BASE_URL: config.bookkeeping.url, + PARTIAL_RUN_DETAILS: '?page=run-detail&runNumber=', + }, + }); + }); + }); }; diff --git a/QualityControl/test/public/features/filterTest.test.js b/QualityControl/test/public/features/filterTest.test.js index 625a1bec7..274c69d16 100644 --- a/QualityControl/test/public/features/filterTest.test.js +++ b/QualityControl/test/public/features/filterTest.test.js @@ -258,6 +258,7 @@ export const filterTests = async (url, page, timeout = 5000, testParent) => { await page.locator('tr:last-of-type td').click(); await page.waitForSelector(versionsPath); + await delay(100); let versionCount = await page.evaluate((path) => document.querySelectorAll(path).length, versionsPath); strictEqual(versionCount, 1, 'Number of versions is not 1'); diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 31284c3e5..bc02b7e83 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -15,6 +15,7 @@ import { strictEqual, ok, deepStrictEqual, notDeepStrictEqual } from 'node:asser import { delay } from '../../testUtils/delay.js'; import { getLocalStorage, getLocalStorageAsJson } from '../../testUtils/localStorage.js'; import { StorageKeysEnum } from '../../../public/common/enums/storageKeys.enum.js'; +import { config } from '../../config.js'; const OBJECT_TREE_PAGE_PARAM = '?page=objectTree'; const SORTING_BUTTON_PATH = 'header > div > div > div:nth-child(3) > div > button'; @@ -189,7 +190,7 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) const context = page.browserContext(); await context.overridePermissions(url, ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']); - await page.click('#qcObjectInfoPanel > div > div'); + await page.click('#qcObjectInfoPanel > div > div > div'); const clipboard = await page.evaluate(async () => { await new Promise((resolve) => setTimeout(resolve, 500)); @@ -208,7 +209,7 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) const context = page.browserContext(); await context.overridePermissions(url, ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']); - await page.click('#qcObjectInfoPanel > div > div'); // copy path + await page.click('#qcObjectInfoPanel > div > div > div'); // copy path await page.click('#qcObjectInfoPanel > div:nth-child(7) > div'); // try to copy empty value const clipboard = await page.evaluate(async () => { @@ -221,6 +222,24 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) } ); + await testParent.test( + 'should have an external link to bookkeeping inline with the run number row', + { timeout }, + async () => { + const bookkeepingLink = await page.$('#openRunInBookkeeping'); + ok(bookkeepingLink, 'The link to bookkeeping should be present in the DOM'); + + const href = await page.evaluate((element) => element.href, bookkeepingLink); + const runNumber = + await page.evaluate((element) => element.parentElement.children[0].textContent, bookkeepingLink); + const url = new URL(href); + const baseUrl = `${url.origin}${url.pathname}`; + + strictEqual(baseUrl, `${config.bookkeeping.url}/`); + strictEqual(runNumber, url.searchParams.get('runNumber')); + } + ) + await testParent.test( 'should close the object plot upon clicking the close button', { timeout },