Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fa667bc
Notify user via native browser notification when a new run has started
hehoon Jan 5, 2026
033aeb8
Fix failing tests
hehoon Jan 5, 2026
ac82b0e
Add `getPageTargetModel` to `FilterModel` and rewrite `filterSpecific…
hehoon Jan 5, 2026
591fb80
on notification click, the runmode for that runnumber should be enabled
hehoon Jan 5, 2026
4abdbfa
Backend tests
hehoon Jan 5, 2026
caf1e3f
Add tests
hehoon Jan 6, 2026
22d4ca0
Notify user via native browser notification when a new run has started
hehoon Jan 5, 2026
669f350
Fix failing tests
hehoon Jan 5, 2026
05d4d6a
Add `getPageTargetModel` to `FilterModel` and rewrite `filterSpecific…
hehoon Jan 5, 2026
f0ff74b
on notification click, the runmode for that runnumber should be enabled
hehoon Jan 5, 2026
34331ae
Backend tests
hehoon Jan 5, 2026
db1b8f2
Add tests
hehoon Jan 6, 2026
f2475f2
Merge branch 'dev' into feature/QCG/OGUI-1854/notify-user-when-a-new-…
hehoon Jan 7, 2026
89d648b
Merge branch 'feature/QCG/OGUI-1854/notify-user-when-a-new-run-has-st…
hehoon Jan 7, 2026
af57099
Use the `BrowserNotificationPermission` enum in `header.js`
hehoon Jan 7, 2026
03d1080
Merge branch 'dev' into feature/QCG/OGUI-1854/notify-user-when-a-new-…
hehoon Jan 7, 2026
3ba3362
Do not show filters when editing the layout on the layout show page
hehoon Jan 7, 2026
c1655dc
Merge branch 'dev' into feature/QCG/OGUI-1854/notify-user-when-a-new-…
hehoon Jan 8, 2026
3f6a5f9
Update test queries
hehoon Jan 8, 2026
286b31c
Address PR feedback
hehoon Jan 9, 2026
5d82e3d
Remove unnecessary `async` from `getBrowserNotificationSetting`
hehoon Jan 9, 2026
d1f0aa2
Fix eslint errors
hehoon Jan 9, 2026
6076a27
Fix docs after attribute name change
hehoon Jan 9, 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
5 changes: 3 additions & 2 deletions QualityControl/lib/QCModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ const LOG_FACILITY = `${process.env.npm_config_log_label ?? 'qcg'}/model-setup`;

/**
* Model initialization for the QCG application
* @param {WebSocket} ws - web-ui websocket server implementation
* @param {EventEmitter} eventEmitter - Event emitter instance for inter-service communication
* @returns {Promise<object>} Multiple services and controllers that are to be used by the QCG application
*/
export const setupQcModel = async (eventEmitter) => {
export const setupQcModel = async (ws, eventEmitter) => {
const logger = LogManager.getLogger(LOG_FACILITY);

const __filename = fileURLToPath(import.meta.url);
Expand Down Expand Up @@ -117,7 +118,7 @@ export const setupQcModel = async (eventEmitter) => {

const bookkeepingService = new BookkeepingService(config.bookkeeping);
const filterService = new FilterService(bookkeepingService, config);
const runModeService = new RunModeService(config.bookkeeping, bookkeepingService, ccdbService, eventEmitter);
const runModeService = new RunModeService(config.bookkeeping, bookkeepingService, ccdbService, eventEmitter, ws);
const objectController = new ObjectController(qcObjectService, runModeService, qcdbDownloadService);

const filterController = new FilterController(filterService, runModeService);
Expand Down
2 changes: 1 addition & 1 deletion QualityControl/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export const setup = async (http, ws, eventEmitter) => {
objectGetByIdValidation,
objectsGetValidation,
objectGetContentsValidation,
} = await setupQcModel(eventEmitter);
} = await setupQcModel(ws, eventEmitter);
statusService.ws = ws;

http.get('/object/:id', objectGetByIdValidation, objectController.getObjectByIdHandler.bind(objectController));
Expand Down
12 changes: 11 additions & 1 deletion QualityControl/lib/services/RunModeService.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* or submit itself to any jurisdiction.
*/

import { LogManager } from '@aliceo2/web-ui';
import { LogManager, WebSocketMessage } from '@aliceo2/web-ui';
import { EmitterKeys } from '../../common/library/enums/emitterKeys.enum.js';
import { Transition } from '../../common/library/enums/transition.enum.js';
import { RunStatus } from '../../common/library/runStatus.enum.js';
Expand All @@ -29,16 +29,19 @@ export class RunModeService {
* @param {BookkeepingService} bookkeepingService - Used to check the status of a run.
* @param {CcdbService} dataService - Used to fetch data from the CCDB.
* @param {EventEmitter} eventEmitter - Event emitter to be used to emit events when new data is available
* @param {WebSocket} webSocketService - web-ui websocket server implementation
*/
constructor(
config,
bookkeepingService,
dataService,
eventEmitter,
webSocketService,
) {
this._bookkeepingService = bookkeepingService;
this._dataService = dataService;
this._eventEmitter = eventEmitter;
this._webSocketService = webSocketService;

this._ongoingRuns = new Map();
this._lastRunsRefresh = 0;
Expand Down Expand Up @@ -131,6 +134,13 @@ export class RunModeService {
this._logger.errorMessage(`Error fetching initial paths for run ${runNumber}: ${error.message || error}`);
}
this._ongoingRuns.set(runNumber, rawPaths);

const wsMessage = new WebSocketMessage();
wsMessage.command = `${EmitterKeys.RUN_TRACK}:${Transition.START_ACTIVITY}`;
wsMessage.payload = {
runNumber,
};
this._webSocketService.broadcast(wsMessage);
} else if (transition === Transition.STOP_ACTIVITY) {
this._ongoingRuns.delete(runNumber);
}
Expand Down
4 changes: 4 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 NotificationRunStartModel from './common/notifications/model/NotificationRunStartModel.js';

/**
* Represents the application's state and actions as a class
Expand Down Expand Up @@ -85,6 +86,9 @@
this.ws.addListener('authed', this.handleWSAuthed.bind(this));
this.ws.addListener('close', this.handleWSClose.bind(this));

this.notificationRunStartModel = new NotificationRunStartModel(this);
this.notificationRunStartModel.bubbleTo(this);

this.initModel();
}

Expand Down Expand Up @@ -276,7 +280,7 @@

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

Check warning on line 283 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
1 change: 1 addition & 0 deletions QualityControl/public/common/enums/storageKeys.enum.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@
export const StorageKeysEnum = Object.freeze({
OBJECT_VIEW_LEFT_PANEL_WIDTH: 'object-view-left-panel-width',
OBJECT_VIEW_INFO_VISIBILITY_SETTING: 'object-view-info-visibility-setting',
NOTIFICATION_START_RUN_SETTING: 'notification-start-run-setting',
OBJECT_TREE_OPEN_NODES: 'object-tree-open-nodes',
});
15 changes: 15 additions & 0 deletions QualityControl/public/common/filters/model/FilterModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,4 +440,19 @@ export default class FilterModel extends Observable {
}
return { refreshNeeded: true, data: null };
}

/**
* Returns the target model based on the current page
* @returns {object} the specific object/view model for the page
*/
getPageTargetModel() {
const { page, layout, object, objectViewModel } = this.model;

switch (page) {
case 'layoutShow': return layout;
case 'objectTree': return object;
case 'objectView': return objectViewModel;
default: return null;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const runModeCheckbox = (filterModel, viewModel) => {
'label.flex-row.g1.items-center.form-check-label',
{
style: `cursor:${isAvailable ? 'pointer' : 'not-allowed'}`,
id: 'run-mode-switch',
},
[
h(
Expand Down
56 changes: 49 additions & 7 deletions QualityControl/public/common/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
* or submit itself to any jurisdiction.
*/

import { h, iconPerson } from '/js/src/index.js';
import { h, iconPerson, getBrowserNotificationPermission, areBrowserNotificationsGranted,
requestBrowserNotificationPermissions, BrowserNotificationPermission } from '/js/src/index.js';

import { spinner } from './spinner.js';
import layoutViewHeader from '../layout/view/header.js';
Expand Down Expand Up @@ -68,14 +69,17 @@ const headerSpecific = (model) => {
* @returns {vnode} - virtual node element
*/
const filterSpecific = (model) => {
const { page, filterModel, layout, object, objectViewModel } = model;
const { page, filterModel, layout } = model;
if (page === 'layoutShow' && layout.editEnabled) {
return null;
}

switch (page) {
case 'layoutShow': return !layout.editEnabled && filtersPanel(filterModel, layout);
case 'objectTree': return filtersPanel(filterModel, object);
case 'objectView': return filtersPanel(filterModel, objectViewModel);
default: return null;
const viewModel = filterModel.getPageTargetModel();
if (!viewModel) {
return null;
}

return filtersPanel(filterModel, viewModel);
};

/**
Expand Down Expand Up @@ -110,5 +114,43 @@ const loginButton = (model) =>
model.session.personid === 0 // Anonymous user has id 0
? h('p.m3.gray-darker', 'This instance of the application does not require authentication.')
: h('a.menu-item', { onclick: () => alert('Not implemented') }, 'Logout'),
notifyOnRunStartSettingComponent(model.notificationRunStartModel),
]),
]);

/**
* Builds the toggle and its functionality of the "notify on run start" setting
* @param {NotificationRunStartModel} notificationRunStartModel - the notification run start model
* @returns {vnode} - virtual node element
*/
const notifyOnRunStartSettingComponent = (notificationRunStartModel) => {
const browserNotificationPermission = getBrowserNotificationPermission();
const notificationsAvailable = browserNotificationPermission
&& browserNotificationPermission !== BrowserNotificationPermission.DENIED;
const runStartNotificationEnabled = notificationRunStartModel.getBrowserNotificationSetting();

return h(
'label.flex-row.g1.items-center.form-check-label',
{ style: `cursor: ${notificationsAvailable ? 'pointer' : 'not-allowed'};` },
[
h('.switch', [
h('input', {
onchange: async (event) => {
let permissionGranted = false;
if (event.target.checked) {
await requestBrowserNotificationPermissions();
permissionGranted = areBrowserNotificationsGranted();
}
notificationRunStartModel.setBrowserNotificationSetting(permissionGranted);
},
type: 'checkbox',
checked: runStartNotificationEnabled,
}),
h(`span.slider.round.bg-${runStartNotificationEnabled ? 'primary' : 'gray'}`, {
style: `cursor: ${notificationsAvailable ? 'pointer' : 'not-allowed'};`,
}),
]),
'Notify on run start',
],
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* @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 { Observable, BrowserStorage, showNativeBrowserNotification,
areBrowserNotificationsGranted } from '/js/src/index.js';
import { EmitterKeys } from '../../../../library/enums/emitterKeys.enum.js';
import { StorageKeysEnum } from '../../enums/storageKeys.enum.js';

/**
* Model responsible for handling browser notifications when a new run starts.
*/
export default class NotificationRunStartModel extends Observable {
/**
* Initialize with empty values
* @param {Model} model - root model of the application
*/
constructor(model) {
super();

this.model = model;
this._browserNotificationStorage = new BrowserStorage(StorageKeysEnum.NOTIFICATION_START_RUN_SETTING);

this.model.ws.addListener('command', (message) => {
if (message.command === EmitterKeys.RUN_TRACK) {
this._handleWSRunTrack.bind(this, message.payload);
}
});
}

/**
* Returns whether browser notifications for run start events
* are enabled for the current user.
* @returns {boolean} `true` if notifications are enabled, `false` otherwise.
*/
getBrowserNotificationSetting() {
try {
if (this._browserNotificationStorage.getLocalItem(this.model.session.personid.toString())) {
return areBrowserNotificationsGranted();
}
return false;
} catch {
this._browserNotificationStorage.removeLocalItem(this.model.session.personid.toString());
return false;
}
}

/**
* Persists the browser notification preference for the current user
* and notifies all observers.
* @param {boolean} enabled - Whether notifications should be enabled.
* @returns {undefined}
*/
setBrowserNotificationSetting(enabled) {
this._browserNotificationStorage.setLocalItem(this.model.session.personid.toString(), enabled);
this.notify();
}

/**
* Handles {@link EmitterKeys.RUN_TRACK} WebSocket events.
* A native browser notification is displayed only when:
* - The transition is {@link Transition.START_ACTIVITY}
* - The user has enabled notifications
* @param {object} payload - WebSocket payload.
* @param {number} payload.runNumber - Run number that started.
* @returns {undefined}
*/
async _handleWSRunTrack({ runNumber }) {
if (!this.getBrowserNotificationSetting()) {
return;
}

showNativeBrowserNotification({
title: `RUN ${runNumber ?? 'unknown'} has started`,
onclick: () => {
// On notification click we always navigate to the `objectTree` page.
// Additionally, we view the run using the given `runNumber`.
this.model.router.go(`?page=objectTree&RunNumber=${runNumber}`);

// If RunMode is not activated, we should enable it
const { isRunModeActivated } = this.model.filterModel;
if (!isRunModeActivated) {
this.model.filterModel.activateRunsMode(this.model.filterModel.getPageTargetModel());
}

// We select the given `runNumber` in RunMode.
// We do not have to set the parameter in the URL, as this is already achieved on navigation.
this.model.filterModel.setFilterValue('RunNumber', runNumber?.toString());
},
});
}
}
25 changes: 24 additions & 1 deletion QualityControl/test/lib/services/RunModeService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ import { RunStatus } from '../../../common/library/runStatus.enum.js';
import { EmitterKeys } from '../../../common/library/enums/emitterKeys.enum.js';
import { Transition } from '../../../common/library/enums/transition.enum.js';
import { delayAndCheck } from '../../testUtils/delay.js';
import { WebSocketMessage } from '@aliceo2/web-ui';

export const runModeServiceTestSuite = async () => {
suite('RunModeService', () => {
let runModeService = undefined;
let bookkeepingService = undefined;
let dataService = undefined;
const eventEmitter = new EventEmitter();
let ws = undefined;

beforeEach(() => {
bookkeepingService = {
Expand All @@ -38,8 +40,12 @@ export const runModeServiceTestSuite = async () => {
getObjectsLatestVersionList: sinon.stub(),
};

ws = {
broadcast: sinon.stub(),
};

const config = { refreshInterval: 60000 };
runModeService = new RunModeService(config, bookkeepingService, dataService, eventEmitter);
runModeService = new RunModeService(config, bookkeepingService, dataService, eventEmitter, ws);
});
suite('retrievePathsAndSetRunStatus', () => {
test('should retrieve paths and cache them if run is ongoing', async () => {
Expand Down Expand Up @@ -153,6 +159,23 @@ export const runModeServiceTestSuite = async () => {
}));
});

test('should listen to events on RUN_TRACK and broadcast to websocket', async () => {
const runEvent = { runNumber: 1234, transition: Transition.START_ACTIVITY };
runModeService._dataService.getObjectsLatestVersionList = sinon.stub().resolves([{ path: '/path/from/event' }]);

eventEmitter.emit(EmitterKeys.RUN_TRACK, runEvent);
await delayAndCheck(() => runModeService._ongoingRuns.has(runEvent.runNumber), 500, 10);

const wsMessage = new WebSocketMessage();
wsMessage.command = EmitterKeys.RUN_TRACK;
wsMessage.payload = runEvent;

ok(
ws.broadcast.calledOnceWith(wsMessage),
`Websocket broadcast should have message: ${JSON.stringify(wsMessage)}`,
);
});

test('should remove run from ongoing runs map on STOP_ACTIVITY event', async () => {
const runEventStop = { runNumber: 5678, transition: Transition.STOP_ACTIVITY };
runModeService._ongoingRuns.set(runEventStop.runNumber, [{ path: '/some/path' }]);
Expand Down
Loading
Loading