Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
78fd0ea
Add a unique error message for objects in ccdb not found due to filters
hehoon Nov 30, 2025
bff89b0
Add a failure callback to draw.js that passes the error as its first …
hehoon Nov 30, 2025
d8a2579
Display JSROOT drawing errors for the object view page
hehoon Nov 30, 2025
5032a42
Get any errors from the backend and display them on the frontend
hehoon Nov 30, 2025
d9ecd2a
Add test to verify error display when JSROOT object fetch fails due t…
hehoon Dec 1, 2025
7ac627a
Merge branch 'dev' of github.com:AliceO2Group/WebUi into feature/QCG/…
hehoon Dec 1, 2025
dfbb925
Add _buildFilterErrorMessage and catch errors from httpGetJson
hehoon Dec 1, 2025
d43cee7
Refactor tests and replaced overly broad .get(/.*/) with something mo…
hehoon Dec 1, 2025
cf14ff7
Fix `_buildFilterErrorMessage` to correctly append a period when mess…
hehoon Dec 1, 2025
90d277c
Update tests to pass an Error object to rejects instead of a string m…
hehoon Dec 1, 2025
1a05513
Display the actual path in the error message instead of the word 'PATH'
hehoon Dec 1, 2025
1841890
Merge branch 'dev' of github.com:AliceO2Group/WebUi into feature/QCG/…
hehoon Dec 2, 2025
c165ee7
Add tests to check for error messages on network or backend failure
hehoon Dec 2, 2025
cea8895
Bump actions/checkout from 5 to 6 (#3205)
dependabot[bot] Dec 2, 2025
844fbb2
Bump actions/setup-node from 5 to 6 (#3206)
dependabot[bot] Dec 2, 2025
6cc344b
Use NotFoundError when the object is not found. Throw an error when t…
hehoon Dec 3, 2025
6b32330
Pull from 'feature/QCG/OGUI-1830/error-message-for-object-not-found-d…
hehoon Dec 3, 2025
5422ab8
Do not display the error message prefix (from Loader) in JSROOT plott…
hehoon Dec 3, 2025
72fa347
Merge branch 'dev' into feature/QCG/OGUI-1829/display-backend-and-jsr…
hehoon Dec 3, 2025
8ad40fc
Merge branch 'dev' of github.com:AliceO2Group/WebUi into feature/QCG/…
graduta Dec 4, 2025
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
61 changes: 38 additions & 23 deletions QualityControl/public/common/object/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,21 @@ import { keyedTimerDebouncer, pointerId } from '../utils.js';
* - `Loading`: returns a loading placeholder.
* - `Failure`: returns an error box with the error message.
* - `Success`: draws the object using `drawObject`.
* @param {QCObject} qcObjectModel - the QCObject model
* @param {string} objectName - the name of the QC object to draw
* @param {RemoteData} remoteData - the RemoteData object containing {qcObject, info, timestamps}
* @param {object} options - optional options of presentation
* @param {string[]} drawingOptions - optional drawing options to be used
* @param {(Error) => void} failFn - optional function to execute upon drawing failure
* @returns {vnode} output virtual-dom, a single div with JSROOT attached to it
*/
export const draw = (qcObjectModel, objectName, options = {}, drawingOptions = []) =>
qcObjectModel.objects[objectName]?.match({
export const draw = (remoteData, options = {}, drawingOptions = [], failFn = () => {}) =>
remoteData?.match({
NotAsked: () => null,
Loading: () => h('.flex-column.items-center.justify-center', [h('.animate-slow-appearance', 'Loading')]),
Failure: (error) => h('.error-box.danger.flex-column.justify-center.f6.text-center', {}, [
h('span.error-icon', { title: 'Error' }, iconWarning()),
h('span', error),
]),
Success: (data) => drawObject(data, options, drawingOptions, qcObjectModel),
Success: (data) => drawObject(data, options, drawingOptions, failFn),
});

/**
Expand All @@ -50,11 +50,11 @@ export const draw = (qcObjectModel, objectName, options = {}, drawingOptions = [
* @param {JSON} object - {qcObject, info, timestamps}
* @param {object} options - optional options of presentation
* @param {string[]} drawingOptions - optional drawing options to be used
* @param {QCObject} qcObjectModel - the QCObject model, used to invalidate (failure) RemoteData
* @param {(Error) => void} failFn - optional function to execute upon drawing failure
* @returns {vnode} output virtual-dom, a single div with JSROOT attached to it
*/
export const drawObject = (object, options = {}, drawingOptions = [], qcObjectModel = undefined) => {
const { qcObject, name, etag } = object;
export const drawObject = (object, options = {}, drawingOptions = [], failFn = () => {}) => {
const { qcObject, etag } = object;
const { root } = qcObject;
if (isObjectOfTypeChecker(root)) {
return checkersPanel(root);
Expand All @@ -79,18 +79,18 @@ export const drawObject = (object, options = {}, drawingOptions = [], qcObjectMo
oncreate: (vnode) => {
// Setup resize function
vnode.dom.onresize = () => {
redrawOnSizeUpdate(vnode.dom, root, drawingOptions);
redrawOnSizeUpdate(vnode.dom, root, drawingOptions, failFn);
};

// Resize on window size change
window.addEventListener('resize', vnode.dom.onresize);

drawOnCreate(vnode.dom, root, drawingOptions, qcObjectModel, name);
drawOnCreate(vnode.dom, root, drawingOptions, failFn);
},
onupdate: (vnode) => {
const isRedrawn = redrawOnDataUpdate(vnode.dom, root, drawingOptions);
if (!isRedrawn) {
redrawOnSizeUpdate(vnode.dom, root, drawingOptions);
redrawOnSizeUpdate(vnode.dom, root, drawingOptions, failFn);
}
},
onremove: (vnode) => {
Expand All @@ -114,23 +114,27 @@ export const drawObject = (object, options = {}, drawingOptions = [], qcObjectMo
* @param {HTMLElement} dom - the div containing jsroot plot
* @param {object} root - root object in JSON representation
* @param {string[]} drawingOptions - list of options to be used for drawing object
* @param {QCObject} qcObjectModel - the QCObject model
* @param {string} objectName - the name of the QC object to draw
* @param {(Error) => void} failFn - function to execute upon drawing failure
* @throws {EvalError} If CSP disallows 'unsafe-eval'.
* This is typically called when the drawing is incomplete or malformed.
* @returns {undefined}
*/
const drawOnCreate = async (dom, root, drawingOptions, qcObjectModel, objectName) => {
const drawOnCreate = async (dom, root, drawingOptions, failFn) => {
const finalDrawingOptions = generateDrawingOptionString(root, drawingOptions);
JSROOT.draw(dom, root, finalDrawingOptions).then((painter) => {
if (painter === null) {
// eslint-disable-next-line no-console
console.error('null painter in JSROOT');
if (typeof failFn === 'function') {
failFn(new Error('null painter in JSROOT'));
}
}
}).catch((error) => {
// eslint-disable-next-line no-console
console.error(error);
qcObjectModel?.invalidObject(objectName);
if (typeof failFn === 'function') {
failFn(error);
}
});
dom.dataset.fingerprintRedraw = fingerprintResize(dom.clientWidth, dom.clientHeight);
dom.dataset.fingerprintData = fingerprintData(root, drawingOptions);
Expand All @@ -156,11 +160,12 @@ const drawOnCreate = async (dom, root, drawingOptions, qcObjectModel, objectName
* @param {Model} model - Root model of the application
* @param {HTMLElement} dom - Element containing the JSROOT plot
* @param {TabObject} tabObject - Object describing the graph to redraw inside `dom`
* @param {(Error) => void} failFn - Function to execute upon drawing failure
* @returns {undefined}
*/
const redrawOnSizeUpdate = keyedTimerDebouncer(
(_, dom) => dom,
(dom, root, drawingOptions) => {
(dom, root, drawingOptions, failFn) => {
let previousFingerprint = dom.dataset.fingerprintResize;

const intervalId = setInterval(() => {
Expand All @@ -175,7 +180,7 @@ const redrawOnSizeUpdate = keyedTimerDebouncer(

// Size stable across intervals (safe to redraw)
if (dom.dataset.fingerprintResize !== currentFingerprint) {
redraw(dom, root, drawingOptions);
redraw(dom, root, drawingOptions, failFn);
}

clearInterval(intervalId);
Expand All @@ -187,10 +192,10 @@ const redrawOnSizeUpdate = keyedTimerDebouncer(
}, 50);
},
200,
(dom, root, drawingOptions) => {
(dom, root, drawingOptions, failFn) => {
const resizeFingerprint = fingerprintResize(dom.clientWidth, dom.clientHeight);
if (dom.dataset.fingerprintResize !== resizeFingerprint) {
redraw(dom, root, drawingOptions);
redraw(dom, root, drawingOptions, failFn);
}
},
);
Expand All @@ -202,12 +207,13 @@ const redrawOnSizeUpdate = keyedTimerDebouncer(
* @param {HTMLElement} dom - Target element containing the JSROOT graph.
* @param {object} root - JSROOT-compatible data object to be rendered.
* @param {string[]} drawingOptions - Initial or user-provided drawing options.
* @param {(Error) => void} failFn - Function to execute upon drawing failure
* @returns {boolean} whether the JSROOT plot was redrawn
*/
const redrawOnDataUpdate = (dom, root, drawingOptions) => {
const redrawOnDataUpdate = (dom, root, drawingOptions, failFn) => {
const dataFingerprint = fingerprintData(root, drawingOptions);
if (dom.dataset.fingerprintData !== dataFingerprint) {
redraw(dom, root, drawingOptions);
redraw(dom, root, drawingOptions, failFn);
return true;
}
return false;
Expand All @@ -218,14 +224,23 @@ const redrawOnDataUpdate = (dom, root, drawingOptions) => {
* @param {HTMLElement} dom - Target element containing the JSROOT graph.
* @param {object} root - JSROOT-compatible data object to be rendered.
* @param {string[]} drawingOptions - Initial or user-provided drawing options.
* @param {(Error) => void} failFn - Function to execute upon drawing failure
* @returns {undefined}
*/
const redraw = (dom, root, drawingOptions) => {
const redraw = (dom, root, drawingOptions, failFn) => {
// A bug exists in JSROOT where the cursor gets stuck on `wait` when redrawing multiple objects simultaneously.
// We save the current cursor state here and revert back to it after redrawing is complete.
const currentCursor = document.body.style.cursor;
const finalDrawingOptions = generateDrawingOptionString(root, drawingOptions);
JSROOT.redraw(dom, root, finalDrawingOptions);
try {
JSROOT.redraw(dom, root, finalDrawingOptions);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
if (typeof failFn === 'function') {
failFn(error);
}
}
document.body.style.cursor = currentCursor;
};

Expand Down
7 changes: 6 additions & 1 deletion QualityControl/public/layout/view/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,12 @@ const drawComponent = (model, tabObject) => {
display: 'flex',
'flex-direction': 'column',
},
}, draw(model.object, name, {}, drawingOptions)),
}, draw(
model.object.objects[tabObject.name],
{},
drawingOptions,
(error) => model.object.invalidObject(tabObject.name, error.message),
)),
objectInfoResizePanel(model, tabObject),
displayTimestamp && minimalObjectInfo(runNumber, lastModified),
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,13 @@ const leafRow = (model, sideTree, level) => {
const objectPreview = (model) => {
const isSelected = model.object.selected;
if (isSelected) {
return isSelected && h('.bg-white', { style: 'height: 20em' }, draw(model.object, model.object.selected.name));
return isSelected && h(
'.bg-white',
{ style: 'height: 20em' },
draw(model.object.objects[model.object.selected.name], {}, [], (error) => {
model.object.invalidObject(model.object.selected.name, error.message);
}),
);
}
return null;
};
5 changes: 3 additions & 2 deletions QualityControl/public/object/QCObject.js
Original file line number Diff line number Diff line change
Expand Up @@ -356,10 +356,11 @@ export default class QCObject extends BaseViewModel {
/**
* Indicate that the object loaded is wrong. Used after trying to print it with jsroot
* @param {string} name - name of the object
* @param {string} reason - the reason for invalidating the object
* @returns {undefined}
*/
invalidObject(name) {
this.objects[name] = RemoteData.failure('JSROOT was unable to draw this object');
invalidObject(name, reason) {
this.objects[name] = RemoteData.failure(reason || 'JSROOT was unable to draw this object');
this.notify();
}

Expand Down
4 changes: 3 additions & 1 deletion QualityControl/public/object/objectTreePage.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ const drawPlot = (model, object) => {
iconCircleX(),
),
]),
h('', { style: 'height:77%;' }, draw(model.object, name, { }, ['stat'])),
h('', { style: 'height:77%;' }, draw(model.object.objects[name], { }, ['stat'], (error) => {
model.object.invalidObject(name, error.message);
})),
h('.scroll-y', {}, [
h('.w-100.flex-row', { style: 'justify-content: center' }, h('.w-80', timestampSelectForm(model))),
qcObjectInfoPanel(object, { 'font-size': '.875rem;' }, defaultRowAttributes(model.notification)),
Expand Down
9 changes: 9 additions & 0 deletions QualityControl/public/pages/objectView/ObjectViewModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,4 +211,13 @@ export default class ObjectViewModel extends BaseViewModel {
async triggerFilter() {
await this.init(this.model.router.params);
}

/**
* Should be called when a failure occurs when drawing a JSROOT plot
* @param {string} message - the failure message to display
*/
drawingFailureOccurred(message) {
this.selected = RemoteData.failure(message || 'Failed to draw JSROOT plot');
this.notify();
}
}
4 changes: 3 additions & 1 deletion QualityControl/public/pages/objectView/ObjectViewPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ const objectPlotAndInfo = (objectViewModel) =>
h('.flex-grow', {
// Key change forces redraw when toggling info panel
key: isObjectInfoVisible ? 'objectPlotWithoutInfoPanel' : 'objectPlotWithInfoPanel',
}, drawObject(qcObject, {}, drawingOptions)),
}, drawObject(qcObject, {}, drawingOptions, (error) => {
objectViewModel.drawingFailureOccurred(error.message);
})),
isObjectInfoVisible && h('.scroll-y.w-30', {
key: 'objectInfoPanel',
}, [
Expand Down
22 changes: 13 additions & 9 deletions QualityControl/public/services/QCObject.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export default class QCObjectService {
? { RunNumber: this.filterModel.runNumber }
: this.filterModel.filterMap;
const url = this._buildURL(`/api/object?path=${objectName}`, id, validFrom, filters);
const { result, ok } = await this.model.loader.get(url);
const { result, ok } = await this.model.loader.get(url, {}, true);
if (ok) {
result.qcObject = {
root: JSROOT.parse(result.root),
Expand All @@ -91,16 +91,18 @@ export default class QCObjectService {
that.notify();
return RemoteData.success(result);
} else {
this.objectsLoadedMap[objectName] = RemoteData.failure(`404: Object "${objectName}" could not be found.`);
const failure = RemoteData.failure(result.message || `Object "${objectName}" could not be found.`);
this.objectsLoadedMap[objectName] = failure;
that.notify();
return RemoteData.failure(`404: Object "${objectName}" could not be found.`);
return failure;
}
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
this.objectsLoadedMap[objectName] = RemoteData.failure(`404: Object "${objectName}" could not be loaded.`);
const failure = RemoteData.failure(error.message || `Object "${objectName}" could not be loaded.`);
this.objectsLoadedMap[objectName] = failure;
that.notify();
return RemoteData.failure(`Object '${objectName}' could not be loaded`);
return failure;
}
}

Expand Down Expand Up @@ -131,16 +133,18 @@ export default class QCObjectService {
that.notify();
return RemoteData.success(result);
} else {
this.objectsLoadedMap[objectId] = RemoteData.failure(`404: Object with ID: "${objectId}" could not be found.`);
const failure = RemoteData.failure(result.message || `Object with ID "${objectId}" could not be found.`);
this.objectsLoadedMap[objectId] = failure;
that.notify();
return RemoteData.failure(`404: Object with ID:"${objectId}" could not be found.`);
return failure;
}
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
this.objectsLoadedMap[objectId] = RemoteData.failure(`404: Object with ID: "${objectId}" could not be loaded.`);
const failure = RemoteData.failure(error.message || `Object with ID "${objectId}" could not be loaded.`);
this.objectsLoadedMap[objectId] = failure;
that.notify();
return RemoteData.failure(`Object with ID:"${objectId}" could not be loaded`);
return failure;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -408,4 +408,86 @@ export const objectViewFromLayoutShowTests = async (url, page, timeout = 5000, t
);
},
);

await testParent.test(
'should display an error when the JSROOT object fails to fetch due to a network failure',
{ timeout },
async () => {
const requestHandler = (interceptedRequest) => {
const url = interceptedRequest.url();

if (url.includes('/api/object')) {
interceptedRequest.abort('failed'); // simulates network failure
} else {
interceptedRequest.continue();
}
};

try {
// Enable interception and attach the handler
await page.setRequestInterception(true);
page.on('request', requestHandler);

await page.reload({ waitUntil: 'networkidle0' });
await delay(100);

const errorText = await page.evaluate(() => document.querySelector('#Error .f3')?.innerText);

strictEqual(errorText, 'Connection to server failed, please try again');
} catch (error) {
// Test failed
strictEqual(1, 0, error.message);
} finally {
// Cleanup: remove listener and disable interception
page.off('request', requestHandler);
await page.setRequestInterception(false);
}
},
);

await testParent.test(
'should display an error when the JSROOT object fails to fetch due to a backend failure',
{ timeout },
async () => {
const requestHandler = (interceptedRequest) => {
const url = interceptedRequest.url();

if (url.includes('/api/object')) {
// Respond with a backend error
interceptedRequest.respond({
status: 500,
contentType: 'application/json',
body: JSON.stringify({
message: 'JSROOT failed to open file \'url\'',
}),
});
} else {
interceptedRequest.continue();
}
};

try {
// Enable interception and attach the handler
await page.setRequestInterception(true);
page.on('request', requestHandler);

await page.reload({ waitUntil: 'networkidle0' });
await delay(100);

const errorText = await page.evaluate(() => document.querySelector('#Error .f3')?.innerText);

strictEqual(
errorText,
'Request to server failed (500 Internal Server Error): JSROOT failed to open file \'url\'',
);
} catch (error) {
// Test failed
strictEqual(1, 0, error.message);
} finally {
// Cleanup: remove listener and disable interception
page.off('request', requestHandler);
await page.setRequestInterception(false);
}
},
);
};
Loading
Loading