From 81c1b429279494e81e9d685725f1535112b0e82e Mon Sep 17 00:00:00 2001 From: bring42 Date: Fri, 24 Jul 2026 12:03:21 +0200 Subject: [PATCH 1/2] feat(ota): atomic update + filesystem version self-heal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kills the split-update footgun: updating firmware alone would bump the running version to match the manifest, which flipped fsAvailable false and stranded the web UI at the old version with no way to pull it. Root cause: availability was isNewer(manifest, FIRMWARE_VERSION) with fsAvailable = that && hasFs β€” the device never knew its *filesystem* version, only the firmware's. Fix (two parts): - Self-heal: stamp the LittleFS image with its version at build (scripts/version.py -> data/fsver, gitignored) and read it back in the updater. Availability is now (appBehind || fsBehind), each compared independently. A legacy image with no stamp reads as behind, so a device that only got the firmware update re-detects its stale UI on the next check and offers the update automatically β€” no manual flash. - Atomic apply: new requestUpdate()/POST /api/firmware/update flashes whatever is behind (filesystem first, then firmware) and reboots ONCE, so the two images can never drift again. The console UI now shows a single 'Install Update' button. Per-image endpoints/functions remain for recovery/debug. Firmware compiles (seeed_xiao_esp32c3, 90.2% flash); 65 native tests pass. Euclid skin still uses the per-image endpoints (unchanged, still work). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + scripts/version.py | 15 +++ src/api/firmware.cpp | 18 +++- src/api/firmware.h | 19 ++-- src/network/server.cpp | 5 +- src/network/updater.cpp | 120 +++++++++++++++++---- src/network/updater.h | 30 ++++-- ui-concepts/_engine/engine.js | 6 +- ui-concepts/console-euclid-live/app.js | 80 ++++++-------- ui-concepts/console-euclid-live/index.html | 3 +- 10 files changed, 205 insertions(+), 92 deletions(-) diff --git a/.gitignore b/.gitignore index 04a155d..bad7436 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ src/secrets.h # generated at buildfs/uploadfs/CI β€” not source data/**/*.gz +data/fsver diff --git a/scripts/version.py b/scripts/version.py index 9f6ff4d..f461093 100644 --- a/scripts/version.py +++ b/scripts/version.py @@ -67,6 +67,21 @@ def _clean_semver(raw): env.Append(CPPDEFINES=defines) +# Stamp the filesystem with its version so the running device can tell whether +# its UI (LittleFS image) is up to date INDEPENDENTLY of the firmware β€” the +# updater reads /fsver and compares it to the release manifest. Written for +# device builds only (native/host builds have no board_id). The value matches +# the firmware version above; the "1.0.0" fallback matches constants.h so an +# untagged local build stays self-consistent. data/fsver is a build artifact +# (gitignored); buildfs packs it into littlefs.bin (it is not gzipped). +if board_id: + project_dir = env.subst("$PROJECT_DIR") + data_dir = os.path.join(project_dir, "data") + if os.path.isdir(data_dir): + fs_version = version or "1.0.0" + with open(os.path.join(data_dir, "fsver"), "w") as fsver_file: + fsver_file.write(fs_version + "\n") + print( "πŸ”– version.py: env=%s version=%s hash=%s board_id=%s" % (pioenv, version or "(fallback)", build_hash, board_id or "(fallback)") diff --git a/src/api/firmware.cpp b/src/api/firmware.cpp index 3969431..fb9640c 100644 --- a/src/api/firmware.cpp +++ b/src/api/firmware.cpp @@ -18,8 +18,9 @@ static void serializeStatus(const lume::UpdateStatus& s, String& out) { JsonDocument doc; doc["phase"] = lume::updatePhaseName(s.phase); doc["current"] = s.current; + doc["fsVersion"] = s.fsVersion[0] ? s.fsVersion : (const char*)nullptr; doc["latest"] = s.latest[0] ? s.latest : (const char*)nullptr; - doc["updateAvailable"] = s.updateAvailable; // == appAvailable (compat) + doc["updateAvailable"] = s.updateAvailable; // app OR fs behind doc["appAvailable"] = s.appAvailable; doc["fsAvailable"] = s.fsAvailable; doc["notes"] = s.notes[0] ? s.notes : (const char*)nullptr; @@ -46,6 +47,21 @@ void handleApiFirmwareStatus(AsyncWebServerRequest* request) { request->send(200, "application/json", out); } +void handleApiFirmwareUpdate(AsyncWebServerRequest* request) { + if (!checkAuth(request)) { sendUnauthorized(request); return; } + lume::UpdateStatus s = lume::updaterStatus(); + if (!s.updateAvailable) { + request->send(400, "application/json", + "{\"error\":\"No update available; run /api/firmware/check first\"}"); + return; + } + if (!lume::requestUpdate()) { + request->send(409, "application/json", "{\"error\":\"Updater busy\"}"); + return; + } + request->send(202, "application/json", "{\"status\":\"updating\",\"target\":\"both\"}"); +} + void handleApiFirmwareUpdateApp(AsyncWebServerRequest* request) { if (!checkAuth(request)) { sendUnauthorized(request); return; } lume::UpdateStatus s = lume::updaterStatus(); diff --git a/src/api/firmware.h b/src/api/firmware.h index 4bdd075..255fe15 100644 --- a/src/api/firmware.h +++ b/src/api/firmware.h @@ -6,17 +6,20 @@ // // POST /api/firmware/check -> 202; async GitHub check (both images). // GET /api/firmware/status -> 200; updater state + last check result. -// POST /api/firmware/update/app -> 202; flash the firmware image only (async). -// POST /api/firmware/update/fs -> 202; flash the filesystem image only (async). +// POST /api/firmware/update -> 202; ATOMIC: flash whatever is behind +// (filesystem + firmware), one reboot. UI path. +// POST /api/firmware/update/app -> 202; flash the firmware image only (recovery). +// POST /api/firmware/update/fs -> 202; flash the filesystem image only (recovery). // -// A single check reports availability for BOTH the app and fs images, but the -// apply step is split into two fully independent operations β€” flashing one never -// triggers the other. Check/update are asynchronous because they do a blocking -// HTTPS transfer that must not run on the AsyncTCP task (same rationale as -// /api/prompt). The UI triggers, then polls /status (whose "stage" field says -// which target, if any, is in progress). +// A single check reports whether the app and/or fs are behind. The UI drives the +// atomic /update (app and fs are one versioned release, so they update together +// and can't half-update). The per-image endpoints remain for recovery/debug. +// Check/update are asynchronous because they do a blocking HTTPS transfer that +// must not run on the AsyncTCP task (same rationale as /api/prompt). The UI +// triggers, then polls /status (whose "stage" field says which target is active). void handleApiFirmwareCheck(AsyncWebServerRequest* request); void handleApiFirmwareStatus(AsyncWebServerRequest* request); +void handleApiFirmwareUpdate(AsyncWebServerRequest* request); void handleApiFirmwareUpdateApp(AsyncWebServerRequest* request); void handleApiFirmwareUpdateFs(AsyncWebServerRequest* request); diff --git a/src/network/server.cpp b/src/network/server.cpp index a511271..e1d8ce3 100644 --- a/src/network/server.cpp +++ b/src/network/server.cpp @@ -291,8 +291,9 @@ void setupServer() { // triggers the other. server.on("/api/firmware/check", HTTP_POST, handleApiFirmwareCheck); server.on("/api/firmware/status", HTTP_GET, handleApiFirmwareStatus); - server.on("/api/firmware/update/app", HTTP_POST, handleApiFirmwareUpdateApp); - server.on("/api/firmware/update/fs", HTTP_POST, handleApiFirmwareUpdateFs); + server.on("/api/firmware/update", HTTP_POST, handleApiFirmwareUpdate); // atomic (UI) + server.on("/api/firmware/update/app", HTTP_POST, handleApiFirmwareUpdateApp); // recovery + server.on("/api/firmware/update/fs", HTTP_POST, handleApiFirmwareUpdateFs); // recovery // =========================================================================== diff --git a/src/network/updater.cpp b/src/network/updater.cpp index 2b7f09d..78c9290 100644 --- a/src/network/updater.cpp +++ b/src/network/updater.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -80,9 +81,9 @@ const char* updatePhaseName(UpdatePhase p) { namespace { // ── Worker plumbing ────────────────────────────────────────────────────────── -// ApplyApp and ApplyFs are fully independent operations β€” neither triggers the -// other. This mirrors the dev flow (`pio run -t upload` vs `-t uploadfs`). -enum class Cmd : uint8_t { Check, ApplyApp, ApplyFs }; +// ApplyBoth is the normal atomic path (flash whichever images are behind, one +// reboot). ApplyApp/ApplyFs remain as low-level recovery/debug operations. +enum class Cmd : uint8_t { Check, ApplyBoth, ApplyApp, ApplyFs }; QueueHandle_t g_cmdQueue = nullptr; TaskHandle_t g_task = nullptr; @@ -102,7 +103,9 @@ struct Target { String appUrl, appSha; size_t appSize = 0; String fsUrl, fsSha; size_t fsSize = 0; bool hasFs = false; - bool available = false; + bool available = false; // appBehind || fsBehind + bool appBehind = false; // running firmware older than latest + bool fsBehind = false; // installed filesystem older than latest (or unstamped) } g_target; // ── Status helpers (locked) ────────────────────────────────────────────────── @@ -304,6 +307,19 @@ bool downloadVerifyFlash(const String& url, const String& expectedSha, return true; } +// Read the filesystem's own version stamp, written into the LittleFS image at +// build time (scripts/version.py -> data/fsver). Returns "" if the file is +// absent β€” which is the case for any image built before the stamp existed, so +// the caller treats "" as "older than any release" and self-heals it. +String readInstalledFsVersion() { + File f = LittleFS.open("/fsver", "r"); + if (!f) return String(); + String v = f.readStringUntil('\n'); + f.close(); + v.trim(); + return v; +} + // ── Check ──────────────────────────────────────────────────────────────────── bool doCheck(String& err) { setPhase(UpdatePhase::Checking); @@ -371,30 +387,40 @@ bool doCheck(String& err) { return false; } - bool avail = isNewer(g_target.latest, String(FIRMWARE_VERSION)); + // Compare BOTH installed images to the latest release independently. The app + // version is baked in (FIRMWARE_VERSION); the fs version is read from the + // running LittleFS image's /fsver stamp. An unstamped fs (legacy image) reads + // as "" and is treated as behind, so a device that only ever got a firmware + // update β€” the classic footgun β€” still self-heals its stale UI on next check. + const String fsInstalled = readInstalledFsVersion(); + + bool appBehind = isNewer(g_target.latest, String(FIRMWARE_VERSION)); + bool fsBehind; + if (!g_target.hasFs) fsBehind = false; // no fs image published + else if (fsInstalled.length() == 0) fsBehind = true; // unstamped legacy fs -> stale + else fsBehind = isNewer(g_target.latest, fsInstalled); + + bool avail = appBehind || fsBehind; + g_target.appBehind = appBehind; + g_target.fsBehind = fsBehind; g_target.available = avail; - // App and FS are versioned together (one release), so both become available - // when a newer version is published β€” but only the app is guaranteed to - // exist; the FS image is offered only when the manifest actually carries it. - bool appAvail = avail; - bool fsAvail = avail && g_target.hasFs; - const char* notes = doc["notes"] | ""; lock(); copyStr(g_status.latest, sizeof(g_status.latest), g_target.latest.c_str()); + copyStr(g_status.fsVersion, sizeof(g_status.fsVersion), fsInstalled.c_str()); copyStr(g_status.notes, sizeof(g_status.notes), notes); - g_status.updateAvailable = appAvail; - g_status.appAvailable = appAvail; - g_status.fsAvailable = fsAvail; + g_status.updateAvailable = avail; + g_status.appAvailable = appBehind; + g_status.fsAvailable = fsBehind; g_status.phase = avail ? UpdatePhase::Available : UpdatePhase::UpToDate; unlock(); - LOG_INFO(LogTag::OTA, "Updater: current=%s latest=%s -> %s (fs=%s)", - FIRMWARE_VERSION, g_target.latest.c_str(), - avail ? "UPDATE AVAILABLE" : "up to date", - fsAvail ? "yes" : "no"); + LOG_INFO(LogTag::OTA, "Updater: app=%s fs=%s latest=%s -> %s (app=%s fs=%s)", + FIRMWARE_VERSION, fsInstalled.length() ? fsInstalled.c_str() : "(unstamped)", + g_target.latest.c_str(), avail ? "UPDATE AVAILABLE" : "up to date", + appBehind ? "yes" : "no", fsBehind ? "yes" : "no"); return true; } @@ -447,6 +473,47 @@ void doApplyFs() { ESP.restart(); } +// ── Apply: atomic (filesystem + firmware) ──────────────────────────────────── +// The normal update path. Flashes only the images that are actually behind β€” +// filesystem first (single partition, in-place), then firmware (inactive A/B +// slot) β€” and reboots ONCE at the end so the two images move together. On any +// failure it stops and reports; a later check re-detects whatever is still +// behind (the fs stamp makes a half-finished update self-healing), so the user +// just retries. Order matters: if the app flash fails after the fs succeeded, +// we boot the old app on the new UI (tolerated); we never boot a new app on an +// old UI. +void doApplyBoth() { + if (!g_target.available) { + setError("No update available (run check first)"); + return; + } + LOG_INFO(LogTag::OTA, "Updater: applying update %s (fs=%s app=%s)", + g_target.latest.c_str(), + (g_target.fsBehind && g_target.hasFs) ? "yes" : "no", + g_target.appBehind ? "yes" : "no"); + + String err; + if (g_target.fsBehind && g_target.hasFs) { + if (!downloadVerifyFlash(g_target.fsUrl, g_target.fsSha, g_target.fsSize, + U_SPIFFS, "fs", err)) { + setError(err); + return; + } + } + if (g_target.appBehind) { + if (!downloadVerifyFlash(g_target.appUrl, g_target.appSha, g_target.appSize, + U_FLASH, "app", err)) { + setError(err); + return; + } + } + + setPhase(UpdatePhase::Rebooting, "", 100); + LOG_INFO(LogTag::OTA, "Updater: update complete, rebooting"); + delay(1500); // let the polling UI observe the "rebooting" status + ESP.restart(); +} + void workerTask(void*) { Cmd cmd; for (;;) { @@ -454,9 +521,10 @@ void workerTask(void*) { String err; switch (cmd) { - case Cmd::Check: if (!doCheck(err)) setError(err); break; - case Cmd::ApplyApp: doApplyApp(); break; // sets its own error / reboots - case Cmd::ApplyFs: doApplyFs(); break; // sets its own error / reboots + case Cmd::Check: if (!doCheck(err)) setError(err); break; + case Cmd::ApplyBoth: doApplyBoth(); break; // sets its own error / reboots + case Cmd::ApplyApp: doApplyApp(); break; // sets its own error / reboots + case Cmd::ApplyFs: doApplyFs(); break; // sets its own error / reboots } g_busy.store(false); } @@ -488,6 +556,16 @@ bool requestUpdateCheck() { return true; } +bool requestUpdate() { + if (!g_cmdQueue) return false; + if (!g_target.available) return false; + bool expected = false; + if (!g_busy.compare_exchange_strong(expected, true)) return false; + Cmd c = Cmd::ApplyBoth; + if (xQueueSend(g_cmdQueue, &c, 0) != pdTRUE) { g_busy.store(false); return false; } + return true; +} + bool requestAppUpdate() { if (!g_cmdQueue) return false; if (!g_target.available) return false; diff --git a/src/network/updater.h b/src/network/updater.h index 4cbf378..1d6e8e4 100644 --- a/src/network/updater.h +++ b/src/network/updater.h @@ -29,13 +29,16 @@ const char* updatePhaseName(UpdatePhase p); // worker's mutation of the live struct. struct UpdateStatus { UpdatePhase phase = UpdatePhase::Idle; - char current[24] = {0}; // running firmware version + char current[24] = {0}; // running firmware (app) version + char fsVersion[24] = {0}; // installed filesystem version (from /fsver; "" if unstamped) char latest[24] = {0}; // latest published version (after a check) - // A single check reports availability for BOTH images from one manifest. - // The apply step, by contrast, is split into two independent operations. - bool updateAvailable = false; // == appAvailable (kept for compatibility) - bool appAvailable = false; // newer firmware image is published - bool fsAvailable = false; // newer filesystem image is published + // A single check reports whether EITHER image is behind the latest release. + // updateAvailable is true if the app OR the filesystem needs updating, so a + // device whose two images have drifted (e.g. an interrupted update, or a + // legacy filesystem with no version stamp) still self-heals on the next check. + bool updateAvailable = false; // app OR fs behind + bool appAvailable = false; // running firmware is behind the latest + bool fsAvailable = false; // installed filesystem is behind the latest char notes[192] = {0}; // release notes (truncated) uint8_t percent = 0; // 0..100 within the active phase char stage[8] = {0}; // which target is in progress: "app"|"fs"|"" @@ -50,14 +53,21 @@ void initUpdater(); // One check populates both appAvailable and fsAvailable from the manifest. bool requestUpdateCheck(); +// Apply the update ATOMICALLY: flash whichever images are behind (filesystem +// first, then firmware) and reboot ONCE. This is the normal update path β€” the +// two images are versioned together as one release, so they're always updated +// together and can never drift into a half-updated state. Returns false if the +// worker is busy or nothing is available. +bool requestUpdate(); + // Apply the firmware (app-slot) image ONLY: download β†’ verify β†’ flash the -// inactive OTA slot β†’ reboot. A/B protected; a failure never bricks. Returns -// false if the worker is busy or no firmware update is available. +// inactive OTA slot β†’ reboot. Low-level recovery/debug path; the UI uses the +// atomic requestUpdate() instead. A/B protected; a failure never bricks. bool requestAppUpdate(); // Apply the filesystem (LittleFS) image ONLY: download β†’ verify β†’ flash the FS -// partition β†’ reboot. Independent of the firmware update β€” flashing one never -// triggers the other. Returns false if busy or no FS update is available. +// partition β†’ reboot. Low-level recovery/debug path; the UI uses the atomic +// requestUpdate() instead. Returns false if busy or no FS update is available. bool requestFsUpdate(); // Thread-safe snapshot of the current updater status. diff --git a/ui-concepts/_engine/engine.js b/ui-concepts/_engine/engine.js index 891e7b2..0b13fe3 100644 --- a/ui-concepts/_engine/engine.js +++ b/ui-concepts/_engine/engine.js @@ -883,7 +883,10 @@ }); } - // Two independent apply actions (mirror `pio run -t upload` / `-t uploadfs`). + // The normal path: one atomic update that flashes whatever is behind + // (filesystem + firmware) and reboots once β€” the device can't half-update. + function applyUpdate(onProgress) { return applyTarget("/api/firmware/update", onProgress); } + // Low-level per-image applies, kept for recovery/debug. function updateFirmware(onProgress) { return applyTarget("/api/firmware/update/app", onProgress); } function updateWebUi(onProgress) { return applyTarget("/api/firmware/update/fs", onProgress); } @@ -921,6 +924,7 @@ getWarmth: getWarmth, setWarmth: setWarmth, checkFirmware: checkFirmware, + applyUpdate: applyUpdate, updateFirmware: updateFirmware, updateWebUi: updateWebUi, firmwareStatus: firmwareStatus, diff --git a/ui-concepts/console-euclid-live/app.js b/ui-concepts/console-euclid-live/app.js index 87d1210..5efe113 100644 --- a/ui-concepts/console-euclid-live/app.js +++ b/ui-concepts/console-euclid-live/app.js @@ -1062,18 +1062,18 @@ $("#sacnUniverse").addEventListener("change", (e) => { }); }); -// OTA: real pull-based update, with the firmware and the web UI (filesystem) as -// two INDEPENDENT actions (mirroring `pio run -t upload` vs `-t uploadfs`). One -// unified check reveals what's available; each action then runs its own -// confirm β†’ progress β†’ reboot. The device does the work asynchronously (engine -// polls /api/firmware/status); this just drives the buttons/status/progress. +// OTA: real pull-based update. Firmware and web UI (filesystem) are one +// versioned release and update ATOMICALLY β€” a single check reveals whether +// anything is behind, and one "Install Update" flashes whatever's stale (fs + +// app) and reboots once. No half-updates. The device does the work +// asynchronously (engine polls /api/firmware/status); this drives the button, +// status text, and progress bar. (function wireOta() { const btn = $("#otaBtn"); if (!btn) return; const statusEl = $("#otaStatus"); const actions = $("#otaActions"); - const btnApp = $("#otaBtnApp"); - const btnFs = $("#otaBtnFs"); + const btnUpdate = $("#otaBtnUpdate"); const wrap = $("#otaProgressWrap"); const fill = $("#otaProgressFill"); let busy = false; @@ -1085,29 +1085,7 @@ $("#sacnUniverse").addEventListener("change", (e) => { function showActions(show) { if (actions) actions.style.display = show ? "flex" : "none"; } function setBusy(on) { busy = on; - btn.disabled = on; if (btnApp) btnApp.disabled = on; if (btnFs) btnFs.disabled = on; - } - - // Run one independent apply action (app or fs). `apply` is the engine method. - function runApply(kind, apply, label) { - setBusy(true); - if (statusEl) statusEl.textContent = "Installing " + label + "…"; - setProgress(0); - apply((st) => { - if (!st) return; - if (st.percent != null) setProgress(st.percent); - if (statusEl) statusEl.textContent = "Installing " + label + - (st.stage ? " (" + st.stage + ")" : "") + "… " + (st.percent || 0) + "%"; - }).then((final) => { - if (!final || final.phase === "rebooting") { - if (statusEl) statusEl.textContent = label + " installed β€” device rebooting. Reload in ~30 s."; - setProgress(100); - showToast(label + " updated β€” rebooting"); - } else { - if (statusEl) statusEl.textContent = label + " update failed" + (final.error ? ": " + final.error : ""); - setProgress(null); setBusy(false); - } - }); + btn.disabled = on; if (btnUpdate) btnUpdate.disabled = on; } btn.addEventListener("click", () => { @@ -1123,32 +1101,40 @@ $("#sacnUniverse").addEventListener("change", (e) => { if (statusEl) statusEl.textContent = "Check failed" + (s && s.error ? ": " + s.error : ""); return; } - if (!s.appAvailable && !s.fsAvailable) { + if (!s.updateAvailable) { if (statusEl) statusEl.textContent = "Up to date (v" + (s.current || "?") + "). Last checked just now."; return; } if (statusEl) statusEl.textContent = "Update available: v" + s.latest + - (s.notes ? " β€” " + s.notes : "") + ". Choose what to install."; - if (btnApp) btnApp.disabled = !s.appAvailable; - if (btnFs) btnFs.disabled = !s.fsAvailable; + (s.notes ? " β€” " + s.notes : "") + "."; showActions(true); }); }); - if (btnApp) btnApp.addEventListener("click", () => { - if (busy || btnApp.disabled) return; - if (!window.confirm("Update the device FIRMWARE now?\n\nThe device will reboot and be " + - "briefly offline. Firmware updates are A/B-protected β€” a failed download can't brick it. " + - "Do NOT power it off during the update.")) return; - runApply("app", engine.updateFirmware, "Firmware"); - }); + if (btnUpdate) btnUpdate.addEventListener("click", () => { + if (busy || btnUpdate.disabled) return; + if (!window.confirm("Install the update now?\n\nThis flashes the firmware and web UI together, then " + + "the device reboots and is briefly offline. Firmware is A/B-protected β€” a failed download can't " + + "brick it. Do NOT power it off during the update.")) return; - if (btnFs) btnFs.addEventListener("click", () => { - if (busy || btnFs.disabled) return; - if (!window.confirm("Update the WEB UI (filesystem) now?\n\nThe device will reboot and be " + - "briefly offline. If interrupted, the UI may need re-flashing (recoverable). " + - "Do NOT power it off during the update.")) return; - runApply("fs", engine.updateWebUi, "Web UI"); + setBusy(true); + if (statusEl) statusEl.textContent = "Installing update…"; + setProgress(0); + engine.applyUpdate((st) => { + if (!st) return; + if (st.percent != null) setProgress(st.percent); + if (statusEl) statusEl.textContent = "Installing update" + + (st.stage ? " (" + st.stage + ")" : "") + "… " + (st.percent || 0) + "%"; + }).then((final) => { + if (!final || final.phase === "rebooting") { + if (statusEl) statusEl.textContent = "Update installed β€” device rebooting. Reload in ~30 s."; + setProgress(100); + showToast("Update installed β€” rebooting"); + } else { + if (statusEl) statusEl.textContent = "Update failed" + (final.error ? ": " + final.error : ""); + setProgress(null); setBusy(false); + } + }); }); })(); diff --git a/ui-concepts/console-euclid-live/index.html b/ui-concepts/console-euclid-live/index.html index b55d4d2..2a42114 100644 --- a/ui-concepts/console-euclid-live/index.html +++ b/ui-concepts/console-euclid-live/index.html @@ -493,8 +493,7 @@