Skip to content

Commit e66c01b

Browse files
authored
[OGUI-1829] Display actual backend and frontend errors (#3204)
* Displays errors related to plotting: * When the back-end responded with an error message * When the request was timed out due to bad connectivity * When plotting failed due to an error caused by JSROOT * The `draw` and `drawObject` functions in `draw.js` have been rewritten to support a failure callback method which returns the error from (re)drawing the JSROOT plot in its parameters. * The object view page now displays JSROOT plotting errors too (instead of displaying a white canvas)
1 parent b5503bb commit e66c01b

File tree

10 files changed

+165
-39
lines changed

10 files changed

+165
-39
lines changed

QualityControl/public/common/object/draw.js

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,21 @@ import { keyedTimerDebouncer, pointerId } from '../utils.js';
2626
* - `Loading`: returns a loading placeholder.
2727
* - `Failure`: returns an error box with the error message.
2828
* - `Success`: draws the object using `drawObject`.
29-
* @param {QCObject} qcObjectModel - the QCObject model
30-
* @param {string} objectName - the name of the QC object to draw
29+
* @param {RemoteData} remoteData - the RemoteData object containing {qcObject, info, timestamps}
3130
* @param {object} options - optional options of presentation
3231
* @param {string[]} drawingOptions - optional drawing options to be used
32+
* @param {(Error) => void} failFn - optional function to execute upon drawing failure
3333
* @returns {vnode} output virtual-dom, a single div with JSROOT attached to it
3434
*/
35-
export const draw = (qcObjectModel, objectName, options = {}, drawingOptions = []) =>
36-
qcObjectModel.objects[objectName]?.match({
35+
export const draw = (remoteData, options = {}, drawingOptions = [], failFn = () => {}) =>
36+
remoteData?.match({
3737
NotAsked: () => null,
3838
Loading: () => h('.flex-column.items-center.justify-center', [h('.animate-slow-appearance', 'Loading')]),
3939
Failure: (error) => h('.error-box.danger.flex-column.justify-center.f6.text-center', {}, [
4040
h('span.error-icon', { title: 'Error' }, iconWarning()),
4141
h('span', error),
4242
]),
43-
Success: (data) => drawObject(data, options, drawingOptions, qcObjectModel),
43+
Success: (data) => drawObject(data, options, drawingOptions, failFn),
4444
});
4545

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

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

88-
drawOnCreate(vnode.dom, root, drawingOptions, qcObjectModel, name);
88+
drawOnCreate(vnode.dom, root, drawingOptions, failFn);
8989
},
9090
onupdate: (vnode) => {
9191
const isRedrawn = redrawOnDataUpdate(vnode.dom, root, drawingOptions);
9292
if (!isRedrawn) {
93-
redrawOnSizeUpdate(vnode.dom, root, drawingOptions);
93+
redrawOnSizeUpdate(vnode.dom, root, drawingOptions, failFn);
9494
}
9595
},
9696
onremove: (vnode) => {
@@ -114,23 +114,27 @@ export const drawObject = (object, options = {}, drawingOptions = [], qcObjectMo
114114
* @param {HTMLElement} dom - the div containing jsroot plot
115115
* @param {object} root - root object in JSON representation
116116
* @param {string[]} drawingOptions - list of options to be used for drawing object
117-
* @param {QCObject} qcObjectModel - the QCObject model
118-
* @param {string} objectName - the name of the QC object to draw
117+
* @param {(Error) => void} failFn - function to execute upon drawing failure
119118
* @throws {EvalError} If CSP disallows 'unsafe-eval'.
120119
* This is typically called when the drawing is incomplete or malformed.
121120
* @returns {undefined}
122121
*/
123-
const drawOnCreate = async (dom, root, drawingOptions, qcObjectModel, objectName) => {
122+
const drawOnCreate = async (dom, root, drawingOptions, failFn) => {
124123
const finalDrawingOptions = generateDrawingOptionString(root, drawingOptions);
125124
JSROOT.draw(dom, root, finalDrawingOptions).then((painter) => {
126125
if (painter === null) {
127126
// eslint-disable-next-line no-console
128127
console.error('null painter in JSROOT');
128+
if (typeof failFn === 'function') {
129+
failFn(new Error('null painter in JSROOT'));
130+
}
129131
}
130132
}).catch((error) => {
131133
// eslint-disable-next-line no-console
132134
console.error(error);
133-
qcObjectModel?.invalidObject(objectName);
135+
if (typeof failFn === 'function') {
136+
failFn(error);
137+
}
134138
});
135139
dom.dataset.fingerprintRedraw = fingerprintResize(dom.clientWidth, dom.clientHeight);
136140
dom.dataset.fingerprintData = fingerprintData(root, drawingOptions);
@@ -156,11 +160,12 @@ const drawOnCreate = async (dom, root, drawingOptions, qcObjectModel, objectName
156160
* @param {Model} model - Root model of the application
157161
* @param {HTMLElement} dom - Element containing the JSROOT plot
158162
* @param {TabObject} tabObject - Object describing the graph to redraw inside `dom`
163+
* @param {(Error) => void} failFn - Function to execute upon drawing failure
159164
* @returns {undefined}
160165
*/
161166
const redrawOnSizeUpdate = keyedTimerDebouncer(
162167
(_, dom) => dom,
163-
(dom, root, drawingOptions) => {
168+
(dom, root, drawingOptions, failFn) => {
164169
let previousFingerprint = dom.dataset.fingerprintResize;
165170

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

176181
// Size stable across intervals (safe to redraw)
177182
if (dom.dataset.fingerprintResize !== currentFingerprint) {
178-
redraw(dom, root, drawingOptions);
183+
redraw(dom, root, drawingOptions, failFn);
179184
}
180185

181186
clearInterval(intervalId);
@@ -187,10 +192,10 @@ const redrawOnSizeUpdate = keyedTimerDebouncer(
187192
}, 50);
188193
},
189194
200,
190-
(dom, root, drawingOptions) => {
195+
(dom, root, drawingOptions, failFn) => {
191196
const resizeFingerprint = fingerprintResize(dom.clientWidth, dom.clientHeight);
192197
if (dom.dataset.fingerprintResize !== resizeFingerprint) {
193-
redraw(dom, root, drawingOptions);
198+
redraw(dom, root, drawingOptions, failFn);
194199
}
195200
},
196201
);
@@ -202,12 +207,13 @@ const redrawOnSizeUpdate = keyedTimerDebouncer(
202207
* @param {HTMLElement} dom - Target element containing the JSROOT graph.
203208
* @param {object} root - JSROOT-compatible data object to be rendered.
204209
* @param {string[]} drawingOptions - Initial or user-provided drawing options.
210+
* @param {(Error) => void} failFn - Function to execute upon drawing failure
205211
* @returns {boolean} whether the JSROOT plot was redrawn
206212
*/
207-
const redrawOnDataUpdate = (dom, root, drawingOptions) => {
213+
const redrawOnDataUpdate = (dom, root, drawingOptions, failFn) => {
208214
const dataFingerprint = fingerprintData(root, drawingOptions);
209215
if (dom.dataset.fingerprintData !== dataFingerprint) {
210-
redraw(dom, root, drawingOptions);
216+
redraw(dom, root, drawingOptions, failFn);
211217
return true;
212218
}
213219
return false;
@@ -218,14 +224,23 @@ const redrawOnDataUpdate = (dom, root, drawingOptions) => {
218224
* @param {HTMLElement} dom - Target element containing the JSROOT graph.
219225
* @param {object} root - JSROOT-compatible data object to be rendered.
220226
* @param {string[]} drawingOptions - Initial or user-provided drawing options.
227+
* @param {(Error) => void} failFn - Function to execute upon drawing failure
221228
* @returns {undefined}
222229
*/
223-
const redraw = (dom, root, drawingOptions) => {
230+
const redraw = (dom, root, drawingOptions, failFn) => {
224231
// A bug exists in JSROOT where the cursor gets stuck on `wait` when redrawing multiple objects simultaneously.
225232
// We save the current cursor state here and revert back to it after redrawing is complete.
226233
const currentCursor = document.body.style.cursor;
227234
const finalDrawingOptions = generateDrawingOptionString(root, drawingOptions);
228-
JSROOT.redraw(dom, root, finalDrawingOptions);
235+
try {
236+
JSROOT.redraw(dom, root, finalDrawingOptions);
237+
} catch (error) {
238+
// eslint-disable-next-line no-console
239+
console.error(error);
240+
if (typeof failFn === 'function') {
241+
failFn(error);
242+
}
243+
}
229244
document.body.style.cursor = currentCursor;
230245
};
231246

QualityControl/public/layout/view/page.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,12 @@ const drawComponent = (model, tabObject) => {
210210
display: 'flex',
211211
'flex-direction': 'column',
212212
},
213-
}, draw(model.object, name, {}, drawingOptions)),
213+
}, draw(
214+
model.object.objects[tabObject.name],
215+
{},
216+
drawingOptions,
217+
(error) => model.object.invalidObject(tabObject.name, error.message),
218+
)),
214219
objectInfoResizePanel(model, tabObject),
215220
displayTimestamp && minimalObjectInfo(runNumber, lastModified),
216221
]);

QualityControl/public/layout/view/panels/objectTreeSidebar.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,13 @@ const leafRow = (model, sideTree, level) => {
193193
const objectPreview = (model) => {
194194
const isSelected = model.object.selected;
195195
if (isSelected) {
196-
return isSelected && h('.bg-white', { style: 'height: 20em' }, draw(model.object, model.object.selected.name));
196+
return isSelected && h(
197+
'.bg-white',
198+
{ style: 'height: 20em' },
199+
draw(model.object.objects[model.object.selected.name], {}, [], (error) => {
200+
model.object.invalidObject(model.object.selected.name, error.message);
201+
}),
202+
);
197203
}
198204
return null;
199205
};

QualityControl/public/object/QCObject.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,10 +356,11 @@ export default class QCObject extends BaseViewModel {
356356
/**
357357
* Indicate that the object loaded is wrong. Used after trying to print it with jsroot
358358
* @param {string} name - name of the object
359+
* @param {string} reason - the reason for invalidating the object
359360
* @returns {undefined}
360361
*/
361-
invalidObject(name) {
362-
this.objects[name] = RemoteData.failure('JSROOT was unable to draw this object');
362+
invalidObject(name, reason) {
363+
this.objects[name] = RemoteData.failure(reason || 'JSROOT was unable to draw this object');
363364
this.notify();
364365
}
365366

QualityControl/public/object/objectTreePage.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ const drawPlot = (model, object) => {
121121
iconCircleX(),
122122
),
123123
]),
124-
h('', { style: 'height:77%;' }, draw(model.object, name, { }, ['stat'])),
124+
h('', { style: 'height:77%;' }, draw(model.object.objects[name], { }, ['stat'], (error) => {
125+
model.object.invalidObject(name, error.message);
126+
})),
125127
h('.scroll-y', {}, [
126128
h('.w-100.flex-row', { style: 'justify-content: center' }, h('.w-80', timestampSelectForm(model))),
127129
qcObjectInfoPanel(object, { 'font-size': '.875rem;' }, defaultRowAttributes(model.notification)),

QualityControl/public/pages/objectView/ObjectViewModel.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,4 +211,13 @@ export default class ObjectViewModel extends BaseViewModel {
211211
async triggerFilter() {
212212
await this.init(this.model.router.params);
213213
}
214+
215+
/**
216+
* Should be called when a failure occurs when drawing a JSROOT plot
217+
* @param {string} message - the failure message to display
218+
*/
219+
drawingFailureOccurred(message) {
220+
this.selected = RemoteData.failure(message || 'Failed to draw JSROOT plot');
221+
this.notify();
222+
}
214223
}

QualityControl/public/pages/objectView/ObjectViewPage.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ const objectPlotAndInfo = (objectViewModel) =>
8282
h('.flex-grow', {
8383
// Key change forces redraw when toggling info panel
8484
key: isObjectInfoVisible ? 'objectPlotWithoutInfoPanel' : 'objectPlotWithInfoPanel',
85-
}, drawObject(qcObject, {}, drawingOptions)),
85+
}, drawObject(qcObject, {}, drawingOptions, (error) => {
86+
objectViewModel.drawingFailureOccurred(error.message);
87+
})),
8688
isObjectInfoVisible && h('.scroll-y.w-30', {
8789
key: 'objectInfoPanel',
8890
}, [

QualityControl/public/services/QCObject.service.js

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export default class QCObjectService {
7979
? { RunNumber: this.filterModel.runNumber }
8080
: this.filterModel.filterMap;
8181
const url = this._buildURL(`/api/object?path=${objectName}`, id, validFrom, filters);
82-
const { result, ok } = await this.model.loader.get(url);
82+
const { result, ok } = await this.model.loader.get(url, {}, true);
8383
if (ok) {
8484
result.qcObject = {
8585
root: JSROOT.parse(result.root),
@@ -91,16 +91,18 @@ export default class QCObjectService {
9191
that.notify();
9292
return RemoteData.success(result);
9393
} else {
94-
this.objectsLoadedMap[objectName] = RemoteData.failure(`404: Object "${objectName}" could not be found.`);
94+
const failure = RemoteData.failure(result.message || `Object "${objectName}" could not be found.`);
95+
this.objectsLoadedMap[objectName] = failure;
9596
that.notify();
96-
return RemoteData.failure(`404: Object "${objectName}" could not be found.`);
97+
return failure;
9798
}
9899
} catch (error) {
99100
// eslint-disable-next-line no-console
100101
console.error(error);
101-
this.objectsLoadedMap[objectName] = RemoteData.failure(`404: Object "${objectName}" could not be loaded.`);
102+
const failure = RemoteData.failure(error.message || `Object "${objectName}" could not be loaded.`);
103+
this.objectsLoadedMap[objectName] = failure;
102104
that.notify();
103-
return RemoteData.failure(`Object '${objectName}' could not be loaded`);
105+
return failure;
104106
}
105107
}
106108

@@ -131,16 +133,18 @@ export default class QCObjectService {
131133
that.notify();
132134
return RemoteData.success(result);
133135
} else {
134-
this.objectsLoadedMap[objectId] = RemoteData.failure(`404: Object with ID: "${objectId}" could not be found.`);
136+
const failure = RemoteData.failure(result.message || `Object with ID "${objectId}" could not be found.`);
137+
this.objectsLoadedMap[objectId] = failure;
135138
that.notify();
136-
return RemoteData.failure(`404: Object with ID:"${objectId}" could not be found.`);
139+
return failure;
137140
}
138141
} catch (error) {
139142
// eslint-disable-next-line no-console
140143
console.error(error);
141-
this.objectsLoadedMap[objectId] = RemoteData.failure(`404: Object with ID: "${objectId}" could not be loaded.`);
144+
const failure = RemoteData.failure(error.message || `Object with ID "${objectId}" could not be loaded.`);
145+
this.objectsLoadedMap[objectId] = failure;
142146
that.notify();
143-
return RemoteData.failure(`Object with ID:"${objectId}" could not be loaded`);
147+
return failure;
144148
}
145149
}
146150

QualityControl/test/public/pages/object-view-from-layout-show.test.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,4 +408,86 @@ export const objectViewFromLayoutShowTests = async (url, page, timeout = 5000, t
408408
);
409409
},
410410
);
411+
412+
await testParent.test(
413+
'should display an error when the JSROOT object fails to fetch due to a network failure',
414+
{ timeout },
415+
async () => {
416+
const requestHandler = (interceptedRequest) => {
417+
const url = interceptedRequest.url();
418+
419+
if (url.includes('/api/object')) {
420+
interceptedRequest.abort('failed'); // simulates network failure
421+
} else {
422+
interceptedRequest.continue();
423+
}
424+
};
425+
426+
try {
427+
// Enable interception and attach the handler
428+
await page.setRequestInterception(true);
429+
page.on('request', requestHandler);
430+
431+
await page.reload({ waitUntil: 'networkidle0' });
432+
await delay(100);
433+
434+
const errorText = await page.evaluate(() => document.querySelector('#Error .f3')?.innerText);
435+
436+
strictEqual(errorText, 'Connection to server failed, please try again');
437+
} catch (error) {
438+
// Test failed
439+
strictEqual(1, 0, error.message);
440+
} finally {
441+
// Cleanup: remove listener and disable interception
442+
page.off('request', requestHandler);
443+
await page.setRequestInterception(false);
444+
}
445+
},
446+
);
447+
448+
await testParent.test(
449+
'should display an error when the JSROOT object fails to fetch due to a backend failure',
450+
{ timeout },
451+
async () => {
452+
const requestHandler = (interceptedRequest) => {
453+
const url = interceptedRequest.url();
454+
455+
if (url.includes('/api/object')) {
456+
// Respond with a backend error
457+
interceptedRequest.respond({
458+
status: 500,
459+
contentType: 'application/json',
460+
body: JSON.stringify({
461+
message: 'JSROOT failed to open file \'url\'',
462+
}),
463+
});
464+
} else {
465+
interceptedRequest.continue();
466+
}
467+
};
468+
469+
try {
470+
// Enable interception and attach the handler
471+
await page.setRequestInterception(true);
472+
page.on('request', requestHandler);
473+
474+
await page.reload({ waitUntil: 'networkidle0' });
475+
await delay(100);
476+
477+
const errorText = await page.evaluate(() => document.querySelector('#Error .f3')?.innerText);
478+
479+
strictEqual(
480+
errorText,
481+
'Request to server failed (500 Internal Server Error): JSROOT failed to open file \'url\'',
482+
);
483+
} catch (error) {
484+
// Test failed
485+
strictEqual(1, 0, error.message);
486+
} finally {
487+
// Cleanup: remove listener and disable interception
488+
page.off('request', requestHandler);
489+
await page.setRequestInterception(false);
490+
}
491+
},
492+
);
411493
};

0 commit comments

Comments
 (0)