Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e2aef9a
feat: add new services endpoint for querying services information
AlexJanson Jan 8, 2026
d389085
feat: add external link icon to the run number when bookkeeping is co…
AlexJanson Jan 8, 2026
ba7882d
fix: update partial run details to proper link structure
AlexJanson Jan 8, 2026
ab22f44
fix: bookkeeping service still being present in service config
AlexJanson Jan 8, 2026
b018bb6
feat: add external link button for opening runs in bookkeeping if boo…
AlexJanson Jan 8, 2026
814d500
style: fix linting errors
AlexJanson Jan 8, 2026
205d7ce
test: fix failing test due to markup changes
AlexJanson Jan 8, 2026
43755bc
fix: when run number is null show no link to bookkeeping
AlexJanson Jan 9, 2026
df406f1
test: fix timing issue introduced with changes
AlexJanson Jan 9, 2026
f96fda1
feat: add id to bookkeeping link for easier testing
AlexJanson Jan 9, 2026
1b4ee22
test: add tests for the backend and frontend
AlexJanson Jan 9, 2026
59e9050
Merge branch 'dev' into feature/QCG/OGUI-1852/open-bookkeeping-from-r…
AlexJanson Jan 9, 2026
beb4ede
style: add missing semicolon
AlexJanson Jan 13, 2026
8c44de7
feat: add open in bookkeeping in run status panel
AlexJanson Jan 15, 2026
82c9335
Merge remote-tracking branch 'origin/dev' into feature/QCG/OGUI-1852/…
AlexJanson Jan 15, 2026
82ac3f8
Merge branch 'dev' into feature/QCG/OGUI-1852/open-bookkeeping-from-r…
AlexJanson Jan 15, 2026
0a31118
Merge branch 'dev' of github.com:AliceO2Group/WebUi into feature/QCG/…
graduta Jan 18, 2026
a409cb7
Simplify config retrieval and remove unused methods and tests
graduta Jan 18, 2026
78764c8
Refactor front-end for reusability
graduta Jan 18, 2026
49b2702
Fix unused imports
graduta Jan 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion QualityControl/lib/QCModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions QualityControl/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
10 changes: 10 additions & 0 deletions QualityControl/lib/controllers/StatusController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
12 changes: 12 additions & 0 deletions QualityControl/lib/services/BookkeepingService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions QualityControl/lib/services/Status.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export class StatusService {
*/
this._dataService = undefined;

/**
* @type {BookkeepingService}
*/
this._bookkeepingService = undefined;

/**
* @type {WebSocket}
*/
Expand Down Expand Up @@ -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
*/
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions QualityControl/public/Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
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
Expand Down Expand Up @@ -97,6 +98,7 @@
this.services = {
object: new QCObjectService(this),
layout: new LayoutService(this),
status: new StatusService(this),
};

this.loader.get('/api/checkUser');
Expand Down Expand Up @@ -276,7 +278,7 @@

/**
* Clear URL parameters and redirect to a certain page
* @param {*} pageName - name of the page to be redirected to

Check warning on line 281 in QualityControl/public/Model.js

View workflow job for this annotation

GitHub Actions / Check eslint rules on ubuntu-latest

Prefer a more specific type to `*`
* @returns {undefined}
*/
clearURL(pageName) {
Expand Down
2 changes: 1 addition & 1 deletion QualityControl/public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@
font-weight: 500;
}

&>div:hover {
& > div > div:hover {
font-weight: 700;
}
}
Expand Down
17 changes: 16 additions & 1 deletion QualityControl/public/common/object/objectInfoCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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())
: '',
]),
]);
};

Expand Down
81 changes: 81 additions & 0 deletions QualityControl/public/services/Status.service.js
Original file line number Diff line number Diff line change
@@ -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<ServicePayload>}
*/
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<void>}
*/
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}`;
}
}
26 changes: 26 additions & 0 deletions QualityControl/test/lib/controllers/StatusController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread Fixed

export const statusControllerTestSuite = async () => {
suite('`getSetServiceStatusHandler()` tests', () => {
Expand Down Expand Up @@ -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');
});
});
};
21 changes: 21 additions & 0 deletions QualityControl/test/lib/services/StatusService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread Fixed

export const statusServiceTestSuite = async () => {
suite('`retrieveDataServiceStatus()` tests', () => {
Expand Down Expand Up @@ -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=',
},
});
});
});
};
1 change: 1 addition & 0 deletions QualityControl/test/public/features/filterTest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
23 changes: 21 additions & 2 deletions QualityControl/test/public/pages/object-tree.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
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';
Expand Down Expand Up @@ -189,7 +190,7 @@
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));
Expand All @@ -208,7 +209,7 @@
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 () => {
Expand All @@ -221,6 +222,24 @@
}
);

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'))
}
)
Comment thread Fixed

await testParent.test(
'should close the object plot upon clicking the close button',
{ timeout },
Expand Down
Loading