feat: enhance error handling with submission error overlay and logging level controls

This commit is contained in:
Ross
2026-07-05 21:59:12 +01:00
parent 348cdbf9d8
commit 58deaee5ce
4 changed files with 317 additions and 132 deletions
+73 -1
View File
@@ -1138,7 +1138,7 @@ select option:disabled {
}
}
#submit-error-overlay, #view-answers-overlay {
#view-answers-overlay {
opacity: 0.9;
position: absolute;
top: 0;
@@ -1151,6 +1151,78 @@ select option:disabled {
z-index: 5000;
}
#submit-error-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
user-select: text;
-moz-user-select: text;
-webkit-user-select: text;
overflow-y: auto;
padding: 20px;
box-sizing: border-box;
}
#submit-error-overlay .error-card {
background-color: #1a1a1a;
border: 1px solid #c62828;
border-radius: 8px;
max-width: 600px;
width: 100%;
padding: 25px;
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
color: #eee;
font-family: sans-serif;
}
#submit-error-overlay .error-card h3 {
margin-top: 0;
color: #ef5350;
font-size: 20px;
border-bottom: 1px solid #333;
padding-bottom: 10px;
margin-bottom: 15px;
}
#submit-error-overlay .error-card button {
cursor: pointer;
background-color: #311B92;
color: white;
border: none;
padding: 10px 18px;
font-size: 14px;
border-radius: 4px;
transition: background-color 0.2s;
font-weight: bold;
}
#submit-error-overlay .error-card button:hover {
background-color: #4527A0;
}
#submit-error-overlay .error-card button.btn-secondary {
background-color: #444;
}
#submit-error-overlay .error-card button.btn-secondary:hover {
background-color: #555;
}
#submit-error-overlay .error-card button.btn-danger {
background-color: #c62828;
}
#submit-error-overlay .error-card button.btn-danger:hover {
background-color: #d32f2f;
}
.cache-out-of-date {
color: red;
}
+24
View File
@@ -255,6 +255,16 @@
View local saved answers
</button>
</p>
<div id="logging-controls" style="margin-top: 15px; border-top: 1px solid #444; padding-top: 10px; font-family: sans-serif; font-size: 13px;">
<label for="select-log-level" style="font-weight: bold; color: #fff;">Logging Level:</label>
<select id="select-log-level" style="background:#222; color:#fff; border:1px solid #555; padding: 2px 5px; border-radius: 3px; font-size: 13px; margin-left: 5px; cursor: pointer;">
<option value="silent">Disabled (Silent)</option>
<option value="error">Error</option>
<option value="warn">Warn</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
</select>
</div>
</details>
</div>
<div id="review-overlay" class="fullscreen-overlay">
@@ -358,6 +368,20 @@
<button id="btn-login" class="navigation dialog-yes">Login</button>
</div>
<div id="resume-dialog" class="dialog modal noclose">
<h3 class="dialog-title">Resume Session?</h3>
<div class="dialog-text">
<p id="resume-text">Choose a previous session to continue, or start a new one:</p>
<div id="resume-sessions-list" style="margin: 15px 0; max-height: 200px; overflow-y: auto;">
<!-- Session options list -->
</div>
</div>
<div class="dialog-buttons" style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 15px;">
<button id="btn-resume-start-new" class="navigation dialog-no" style="margin: 0;">Start New</button>
<button id="btn-resume-continue" class="navigation dialog-yes" style="margin: 0;" disabled>Continue</button>
</div>
</div>
<div id="loading" class="fullscreen-overlay">
<div class="progress-block">
<div id="progress"></div>
+69 -46
View File
@@ -91,74 +91,97 @@ export function postAnswers(ans, URLS, exam_details) {
alert(`Answers sucessfully submitted.`);
}
} else {
submissionError(data, ans, exam_details);
submissionError(data, ans, exam_details, URLS);
}
})
.fail((e) => {
// Will occur with server error such as 500
console.debug("error", e);
submissionError(e, ans, exam_details);
submissionError(e, ans, exam_details, URLS);
});
// $.post( "http://localhost:8000/submit_answers", JSON.stringify(ans));
}
export function submissionError(data, answer_json, exam_details) {
// error will not be defined with server errors
if (data.error != undefined) {
alert(`Error submitting answers: ${data.error}`);
} else {
alert(`Error submitting answers`);
}
var docHeight = $(document).height();
export function submissionError(data, answer_json, exam_details, URLS) {
// Clear any existing overlay
$("#submit-error-overlay").remove();
let answers = JSON.parse(answer_json.answers)
let answers = JSON.parse(answer_json.answers);
let answer_map = {};
let answer_map = {}
answers.forEach((ans, n) => {
answers.forEach((ans) => {
if (!answer_map.hasOwnProperty(ans.qid)) {
answer_map[ans.qid] = [];
}
answer_map[ans.qid].push(ans);
});
let html = $("<ul></ul>");
exam_details.question_order.forEach((i, j) => {
console.debug(i, answer_map)
if (i in answer_map) {
console.debug("YES", i, answer_map)
let ans_array = answer_map[i];
ans_array.forEach((x, y) => {
$(html).append(`<li><b>Question ${j+1}.${y}:</b> ${x.ans}</li>`);
let $list = $("<ol style='margin-left: 20px; font-size: 13px; max-height: 150px; overflow-y: auto; color: #ccc;'></ol>");
exam_details.question_order.forEach((qid, idx) => {
if (qid in answer_map) {
let ans_array = answer_map[qid];
ans_array.forEach((ans_obj, sub_idx) => {
$list.append(`<li><strong>Question ${idx + 1}.${sub_idx + 1}:</strong> ${ans_obj.ans}</li>`);
});
}
});
})
let rawJsonStr = JSON.stringify(answer_json, null, 2);
console.debug(exam_details.question_order);
console.debug(answer_map);
console.debug(html);
if ($("#submit-error-overlay").length < 1) {
$("body").append(
`<div id='submit-error-overlay'><span style='color: white'><p>An error has occurred when submitting your answers. A copy of your answers are displayed below, you may wish to <a id="save-failed-answers" href="#">save a copy</a> if subsequent submissions still fail. Please try submitting again or refresh this page to continue (answers will be saved locally in the browser unless you clear local storage).</p><p>${html.get(0).innerHTML}</p><p>${JSON.stringify(
answer_json
)}</p></span></div>`
);
$("#btn-submit-nav").prependTo("#submit-error-overlay");
$("#submit-error-overlay").height(docHeight);
let file = new Blob([JSON.stringify(answer_json)], {type: "application/json"})
let link = document.getElementById("save-failed-answers");
link.href = URL.createObjectURL(file);
link.download = "answers.json";
let errorMsg = "An error occurred while submitting your answers.";
if (data && data.error) {
errorMsg = `Server returned error: ${data.error}`;
} else if (data && data.statusText) {
errorMsg = `Network error: ${data.statusText} (${data.status})`;
}
let $overlay = $(`
<div id="submit-error-overlay">
<div class="error-card">
<h3>Submission Failed</h3>
<p style="margin-bottom: 15px; color: #ef5350; font-weight: bold;">${errorMsg}</p>
<p style="font-size: 13px; color: #bbb; line-height: 1.4;">
Your answers are saved locally in the browser's database and will not be lost unless you clear your local data.
Please try to submit again, or save a backup file of your answers and contact support.
</p>
<div style="margin: 15px 0;">
<h4 style="margin: 0 0 5px 0; font-size: 14px; color: #eee;">Submitted Answers:</h4>
<div style="background: #111; border: 1px solid #333; padding: 10px; border-radius: 4px;">
${$list.prop("outerHTML")}
</div>
</div>
<details style="margin: 15px 0; font-size: 12px; cursor: pointer; color: #aaa;">
<summary style="font-weight: bold; margin-bottom: 5px;">View Raw Submission JSON</summary>
<pre style="background: #111; border: 1px solid #333; padding: 10px; overflow: auto; max-height: 150px; text-align: left; font-family: monospace; white-space: pre-wrap; margin: 0; color: #a5d6a7;">${rawJsonStr}</pre>
</details>
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;">
<button id="btn-err-close" class="btn-secondary">Close</button>
<a id="btn-err-backup" class="button" style="text-decoration: none;"><button class="btn-secondary">Save Backup</button></a>
<button id="btn-err-retry">Retry Submit</button>
</div>
</div>
</div>
`);
$("body").append($overlay);
// Setup download backup link
let fileBlob = new Blob([JSON.stringify(answer_json)], {type: "application/json"});
let backupUrl = URL.createObjectURL(fileBlob);
$overlay.find("#btn-err-backup").attr("href", backupUrl).attr("download", `answers_backup_${exam_details.eid.replace(/\//g, "_")}.json`);
// Close handler
$overlay.find("#btn-err-close").on("click", function() {
$("#submit-error-overlay").remove();
});
// Retry handler
$overlay.find("#btn-err-retry").on("click", function() {
postAnswers(answer_json, URLS, exam_details);
});
}
export function getQuestion(url, question_number, question_total) {
+151 -85
View File
@@ -7,8 +7,42 @@ import { db, question_db } from "./db.js";
import { initSync, triggerImmediateSync, updateSyncUI } from "./sync.js";
// const { v4: uuidv4 } = require('uuid');
log.setDefaultLevel("warn")
log.setDefaultLevel("debug")
function initLogging() {
try {
const urlParams = new URLSearchParams(window.location.search);
let logLevel = urlParams.get("log");
if (logLevel !== null) {
if (logLevel === "1" || logLevel === "true") {
logLevel = "debug";
} else if (logLevel === "0" || logLevel === "false") {
logLevel = "silent";
}
localStorage.setItem("rts.logLevel", logLevel);
} else {
logLevel = localStorage.getItem("rts.logLevel");
}
if (!logLevel || !["silent", "error", "warn", "info", "debug"].includes(logLevel)) {
logLevel = "warn";
}
log.setLevel(logLevel);
// Redirect console methods through loglevel so they can be silenced consistently
console.debug = log.debug.bind(log);
console.log = log.info.bind(log);
console.warn = log.warn.bind(log);
console.error = log.error.bind(log);
log.info(`RTS logging level initialized to: ${logLevel}`);
} catch (e) {
log.setLevel("warn");
console.warn("Failed to initialize custom log level, falling back to warn:", e);
}
}
initLogging();
let URLS = {};
@@ -309,7 +343,7 @@ function showLoadingState($btn, $targetContainer) {
}
async function retrievePacketList(target = "all", onComplete = null) {
log.debug(`Load users from database...`)
log.info(`Load users from database...`)
let cid = localStorage.getItem("cid.cid")
let passcode = localStorage.getItem("cid.passcode")
@@ -327,7 +361,7 @@ async function retrievePacketList(target = "all", onComplete = null) {
// Update the candidate details display (only shows when both values exist)
showCandidateDetails();
} else {
log.debug("Try and set CID / Passcode from current URL")
log.info("Try and set CID / Passcode from current URL")
try {
const params = new URLSearchParams(window.location.search);
const cidParam = params.get("cid");
@@ -337,8 +371,8 @@ async function retrievePacketList(target = "all", onComplete = null) {
if (cidParam !== null && passParam !== null && cidParam !== "" && passParam !== "") {
cid = cidParam;
passcode = passParam;
log.debug("CID and passcode extracted from URL", cid, passcode);
log.debug("adding to local db");
log.info("CID and passcode extracted from URL", cid, passcode);
log.info("adding to local db");
localStorage.setItem("cid.cid", cid);
localStorage.setItem("cid.passcode", passcode);
@@ -348,10 +382,10 @@ async function retrievePacketList(target = "all", onComplete = null) {
showCandidateDetails();
} else {
log.debug("CID or passcode missing in URL; not setting local storage");
log.info("CID or passcode missing in URL; not setting local storage");
}
} catch (e) {
log.debug("Error parsing URL params for CID/passcode", e);
log.error("Error parsing URL params for CID/passcode", e);
}
}
@@ -385,7 +419,7 @@ async function retrievePacketList(target = "all", onComplete = null) {
}
},
error: function(jqXHR, textStatus, errorThrown) {
log.debug("Unable to load packets, trying legacy '/packets'");
log.info("Unable to load packets, trying legacy '/packets'");
$.getJSON("packets", function(data) {
if (!data.hasOwnProperty("exams")) {
loadPacketList(data);
@@ -410,7 +444,7 @@ async function retrievePacketList(target = "all", onComplete = null) {
}
}
} catch (e) {
log.debug("Unable to set exam query url", e);
log.warn("Unable to set exam query url", e);
}
if (examUrl) {
@@ -506,17 +540,17 @@ async function retrievePacketList(target = "all", onComplete = null) {
async function loadExamList(data, clearExistingList = true, target = "all") {
// If exam mode was enabled via URL param, hide packets by default
if (typeof url_exam_mode !== "undefined" && url_exam_mode) {
log.debug("URL exam mode active - hiding packets list");
log.info("URL exam mode active - hiding packets list");
try {
$(".packet-wrapper").hide();
} catch (e) {
log.debug("Unable to hide packet UI elements", e);
log.warn("Unable to hide packet UI elements", e);
}
}
log.debug(`Load exam list: ${data}`)
log.debug("Loading saved sessions")
log.info(`Load exam list: ${data}`)
log.info("Loading saved sessions")
let sessions = await db.session.toArray().catch(function(error) {
log.debug("Error loading session", error);
log.error("Error loading session", error);
$("#database-error").text(
"Error loading the database, schema has probably changed and needs updating. You will probably need to delete the local database."
);
@@ -539,7 +573,7 @@ async function loadExamList(data, clearExistingList = true, target = "all") {
let exams_started = [];
let exams_completed = [];
log.debug("Check status of saved exams")
log.info("Check status of saved exams")
for (const s of sessions) {
log.debug(`checking status of`, s)
if (s.status == "active") {
@@ -569,7 +603,7 @@ async function loadExamList(data, clearExistingList = true, target = "all") {
$("#cache-details ul").empty();
}
log.debug("Loop available exams")
log.info("Loop available exams")
for (const exam of exam_list) {
log.debug(exam)
const name = exam["name"];
@@ -804,10 +838,10 @@ async function loadExamList(data, clearExistingList = true, target = "all") {
async function loadPacketList(data) {
// NOTE: currently the packet id is not available prior ot downloading the packet
// this severly limits what we can do with it (but to require would limit the utility)
log.debug(`loadPacketList`);
log.debug("Loading saved sessions")
log.info(`loadPacketList`);
log.info("Loading saved sessions")
let sessions = await db.session.toArray().catch(function(error) {
log.debug(`Error loading session ${error}`);
log.error(`Error loading session ${error}`);
$("#database-error").text(
"Error loading the database, schema has probably changed and needs updating. You will probably need to delete the local database."
);
@@ -826,7 +860,7 @@ async function loadPacketList(data) {
// .equals()
let packets_started = [];
let packets_completed = [];
log.debug("Check status of saved packets")
log.info("Check status of saved packets")
sessions.forEach((s) => {
log.debug(`checking status of`, s)
if (s.status == "active") {
@@ -836,16 +870,16 @@ async function loadPacketList(data) {
}
});
log.debug("Packets started")
log.info("Packets started")
log.debug(packets_started)
log.debug("Packets completed")
log.info("Packets completed")
log.debug(packets_completed)
let packet_list = [];
if (data != null) {
packet_list = data.packets;
} else {
log.debug("data == null")
log.warn("data == null")
}
log.debug(packet_list)
@@ -1146,40 +1180,38 @@ function setUpPacket(data, path) {
}
function loadSession() {
console.debug("load session", exam_details, db.session);
log.info("load session", exam_details, db.session);
// Either continue session or create a new one
db.session
// .where("status")
// .equals("active")
.where("[eid+aid]")
.between([exam_details.eid, Dexie.minKey], [exam_details.eid, Dexie.maxKey])
.toArray()
.then((sessions) => {
//console.debug("sessions", sessions);
// let active_sessions = [];
// sessions.forEach((s) => {
// if (s.status == "active") {
// active_sessions.push(s);
// }
// });
let load_previous = false;
// Start new session if no session found
if (sessions.length < 1) {
exam_details.aid = uuidv4();
date_started = Date.now();
} else {
let text = "Select session to continue (leave blank to create new):";
if (exam_details.exam_mode) {
text = "Session will be continued";
exam_details.start_time = date_started / 1000;
setUpQuestions(false);
if (!review_mode) {
initSync(exam_details, URLS);
}
//console.debug(sessions.date);
} else {
// Show resume dialog
$("#resume-sessions-list").empty();
let textPrompt = "Select a session to continue:";
if (exam_details.exam_mode) {
textPrompt = "An exam session is already in progress. You must continue the existing session:";
$("#btn-resume-start-new").hide();
} else {
$("#btn-resume-start-new").show();
}
$("#resume-text").text(textPrompt);
for (let i = 0; i < sessions.length; i++) {
let d = new Date(sessions[i].date);
// Populate session items
sessions.forEach((session, i) => {
let d = new Date(session.date);
let formatted_date =
d.getFullYear() +
"-" +
@@ -1193,58 +1225,83 @@ function loadSession() {
":" +
d.getSeconds();
if (sessions[i].status == "active") {
text = text + `\r${i}: ${formatted_date} [In progress]`;
let label = `${formatted_date}`;
if (session.status == "active") {
label += " [In progress]";
} else {
if (!exam_details.exam_mode) {
text =
text +
`\r${i}: ${formatted_date} [Complete - score ${sessions[i].score}/${sessions[i].max_score}]`;
label += ` [Complete - score ${session.score}/${session.max_score}]`;
} else {
text =
text +
`\r${i}: ${formatted_date} [Complete]`;
label += " [Complete]";
}
}
}
// If in exam mode we don't want to allow multiple sessions
let s;
let $item = $(`
<div class="resume-session-item" data-index="${i}" style="padding: 8px; margin: 4px 0; border: 1px solid #444; border-radius: 4px; cursor: pointer; background: #222; color: #eee; display: flex; align-items: center; gap: 10px;">
<input type="radio" name="resume-session-select" id="session-opt-${i}" value="${i}" style="cursor: pointer; margin: 0;">
<label for="session-opt-${i}" style="cursor: pointer; display: inline; font-size: 13px; margin: 0; color: #ccc;">${label}</label>
</div>
`);
$item.on("click", function() {
$(this).find("input[type=radio]").prop("checked", true);
$(".resume-session-item").css("border-color", "#444");
$(this).css("border-color", "#311B92");
$("#btn-resume-continue").prop("disabled", false);
});
$("#resume-sessions-list").append($item);
});
// Auto-select the first one by default if exam_mode is active
if (exam_details.exam_mode) {
s = "0";
alert(text);
$(".resume-session-item").first().click();
} else {
s = prompt(text, "0");
$("#btn-resume-continue").prop("disabled", true);
}
if (s != null && s != "") {
load_previous = true;
// Open the dialog
$("#resume-dialog").modal({
escapeClose: false,
clickClose: false,
showClose: false
});
exam_details.aid = sessions[parseInt(s)].aid;
exam_details.cid = sessions[parseInt(s)].cid;
date_started = sessions[parseInt(s)].date;
let time_left = sessions[parseInt(s)].time_left;
exam_time = time_left;
exam_details.question_order = sessions[parseInt(s)].question_order;
if (sessions[parseInt(s)].status == "complete") {
review_mode = true;
}
} else {
// If cancel is pressed or input is blank
// Unbind previous click events to prevent duplicates
$("#btn-resume-start-new").off("click").on("click", function() {
$.modal.close();
exam_details.aid = uuidv4();
date_started = Date.now();
//console.debug("new date", date_started);
}
exam_details.start_time = date_started / 1000;
}
exam_details.start_time = date_started / 1000;
setUpQuestions(false);
if (!review_mode) {
initSync(exam_details, URLS);
}
});
setUpQuestions(load_previous);
if (!review_mode) {
initSync(exam_details, URLS);
$("#btn-resume-continue").off("click").on("click", function() {
let selectedIdx = parseInt($("input[name=resume-session-select]:checked").val());
if (isNaN(selectedIdx)) return;
$.modal.close();
let selectedSession = sessions[selectedIdx];
exam_details.aid = selectedSession.aid;
exam_details.cid = selectedSession.cid;
date_started = selectedSession.date;
exam_time = selectedSession.time_left;
exam_details.question_order = selectedSession.question_order;
if (selectedSession.status == "complete") {
review_mode = true;
}
exam_details.start_time = date_started / 1000;
setUpQuestions(true);
if (!review_mode) {
initSync(exam_details, URLS);
}
});
}
});
}
@@ -3287,6 +3344,15 @@ $("#btn-reset-local").click(function(evt) {
localStorage.removeItem("cid.passcode")
} else {}
});
// Initialize logging controls
$("#select-log-level").val(localStorage.getItem("rts.logLevel") || "warn");
$("#select-log-level").on("change", function() {
let level = $(this).val();
log.setLevel(level);
localStorage.setItem("rts.logLevel", level);
log.info(`Log level changed to: ${level}`);
});
$("#btn-delete-current").click(function(evt) {
if (exam_details.question_order.length < 1) {