diff --git a/QualityControl/public/common/object/draw.js b/QualityControl/public/common/object/draw.js index e622cb427..b51acdcfa 100644 --- a/QualityControl/public/common/object/draw.js +++ b/QualityControl/public/common/object/draw.js @@ -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), }); /** @@ -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); @@ -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) => { @@ -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); @@ -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(() => { @@ -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); @@ -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); } }, ); @@ -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; @@ -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; }; diff --git a/QualityControl/public/layout/view/page.js b/QualityControl/public/layout/view/page.js index 114a2295b..9bb70a8df 100644 --- a/QualityControl/public/layout/view/page.js +++ b/QualityControl/public/layout/view/page.js @@ -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), ]); diff --git a/QualityControl/public/layout/view/panels/objectTreeSidebar.js b/QualityControl/public/layout/view/panels/objectTreeSidebar.js index 28f2f7033..ba9b06236 100644 --- a/QualityControl/public/layout/view/panels/objectTreeSidebar.js +++ b/QualityControl/public/layout/view/panels/objectTreeSidebar.js @@ -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; }; diff --git a/QualityControl/public/object/QCObject.js b/QualityControl/public/object/QCObject.js index f0770c334..840215994 100644 --- a/QualityControl/public/object/QCObject.js +++ b/QualityControl/public/object/QCObject.js @@ -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(); } diff --git a/QualityControl/public/object/objectTreePage.js b/QualityControl/public/object/objectTreePage.js index 868527d92..f53b09d38 100644 --- a/QualityControl/public/object/objectTreePage.js +++ b/QualityControl/public/object/objectTreePage.js @@ -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)), diff --git a/QualityControl/public/pages/objectView/ObjectViewModel.js b/QualityControl/public/pages/objectView/ObjectViewModel.js index 24b052385..0063f7fc8 100644 --- a/QualityControl/public/pages/objectView/ObjectViewModel.js +++ b/QualityControl/public/pages/objectView/ObjectViewModel.js @@ -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(); + } } diff --git a/QualityControl/public/pages/objectView/ObjectViewPage.js b/QualityControl/public/pages/objectView/ObjectViewPage.js index ec9fba3bc..b296c716c 100644 --- a/QualityControl/public/pages/objectView/ObjectViewPage.js +++ b/QualityControl/public/pages/objectView/ObjectViewPage.js @@ -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', }, [ diff --git a/QualityControl/public/services/QCObject.service.js b/QualityControl/public/services/QCObject.service.js index d4ef8e353..15d953238 100644 --- a/QualityControl/public/services/QCObject.service.js +++ b/QualityControl/public/services/QCObject.service.js @@ -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), @@ -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; } } @@ -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; } } diff --git a/QualityControl/test/public/pages/object-view-from-layout-show.test.js b/QualityControl/test/public/pages/object-view-from-layout-show.test.js index eae8410cc..07b65f280 100644 --- a/QualityControl/test/public/pages/object-view-from-layout-show.test.js +++ b/QualityControl/test/public/pages/object-view-from-layout-show.test.js @@ -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); + } + }, + ); }; diff --git a/QualityControl/test/public/pages/object-view-from-object-tree.test.js b/QualityControl/test/public/pages/object-view-from-object-tree.test.js index 9a73b9540..b9a70e26e 100644 --- a/QualityControl/test/public/pages/object-view-from-object-tree.test.js +++ b/QualityControl/test/public/pages/object-view-from-object-tree.test.js @@ -63,7 +63,7 @@ export const objectViewFromObjectTreeTests = async (url, page, timeout = 5000, t (element) => document.querySelector(element).textContent, errorMessageElement, ); - strictEqual(message, `404: Object "${objectName}" could not be found.`); + strictEqual(message, 'Failed to fetch object at url \'/latest/NOT_FOUND_OBJECT\' and path \'NOT_FOUND_OBJECT\'.'); }, );