From e4b0d93243c34685e1a919a118cca386ee49aeea Mon Sep 17 00:00:00 2001 From: Owen de Bree Date: Tue, 23 Jun 2026 21:21:10 +0200 Subject: [PATCH] Fix rollover --- Docs/LinuxDeploymentAndUpdates.md | 9 ++-- Docs/Reference/data/manifest.json | 16 +++--- .../files/Quasar.Bootstrap/Program.cs.md | 2 +- .../Quasar/Components/Pages/Updates.razor.md | 6 +-- .../Updates/QuasarUpdateService.cs.md | 4 +- .../files/Quasar/wwwroot/quasar-configs.js.md | 2 +- Docs/WindowsDeploymentAndUpdates.md | 10 ++-- Quasar.Bootstrap/Program.cs | 27 +++++++++- Quasar/Components/Pages/Updates.razor | 49 ++++++++++++++++--- .../Services/Updates/QuasarUpdateService.cs | 3 ++ Quasar/wwwroot/quasar-configs.js | 36 ++++++++++++-- 11 files changed, 131 insertions(+), 33 deletions(-) diff --git a/Docs/LinuxDeploymentAndUpdates.md b/Docs/LinuxDeploymentAndUpdates.md index d9155b0..49d698e 100644 --- a/Docs/LinuxDeploymentAndUpdates.md +++ b/Docs/LinuxDeploymentAndUpdates.md @@ -111,14 +111,17 @@ conflicts, auto-staging stops with a warning and `/settings/updates` shows a git-style conflict editor. Resolve and save the JSON there, or choose **Force release defaults** to stage the release file without local appsettings values. -Activation is explicit. The UI copies the staged payload into +Activation is explicit and requires the worker to be running under Bootstrap. +The UI copies the staged payload into `ManagedRuntime/WebService/`, writes the active-release pointer to that managed worker, updates the install-directory `appsettings.json` from the resolved staged file, and clears old staged payloads. Bootstrap copies that install-directory file into the managed worker before launch, observes the pointer change, drains the old worker, starts the managed worker on the same -public port, and leaves managed Magnetar servers running. After a successful -cutover, Bootstrap prunes inactive managed web-release directories. +public port, and leaves managed Magnetar servers running. The browser polls +`/api/health` until the activated UI version is serving, then reloads the +Updates page. After a successful cutover, Bootstrap prunes inactive managed +web-release directories. This intentionally accepts a short web/agent disconnect. `Quasar.Agent` reconnects, and managed Magnetar processes stay alive because Quasar launches diff --git a/Docs/Reference/data/manifest.json b/Docs/Reference/data/manifest.json index 7526826..073c894 100644 --- a/Docs/Reference/data/manifest.json +++ b/Docs/Reference/data/manifest.json @@ -434,8 +434,8 @@ "path": "Quasar.Bootstrap/Program.cs", "name": "Program.cs", "ext": ".cs", - "size": 95399, - "sha256": "e6606b7369b3f9f4ab529ddd1774620981d407fffecd09ec70679e133e4a5db7", + "size": 96453, + "sha256": "5330cde918b0c67442fe79125f0dcc95eff7337c4afbf1dee9513aaffa06b2dd", "module": "Quasar.Bootstrap", "tier": 1, "status": "pending" @@ -934,8 +934,8 @@ "path": "Quasar/Components/Pages/Updates.razor", "name": "Updates.razor", "ext": ".razor", - "size": 27689, - "sha256": "0769fe53ceff39972594110e53a22ca0f1bf4c090ede2cdb4301a99a5b5d30b2", + "size": 28978, + "sha256": "a13e47bceb35ff341d772b65a555595b7e6bce56c100a87b15823e36c64f448f", "module": "Quasar.Components", "tier": 2, "status": "pending" @@ -1964,8 +1964,8 @@ "path": "Quasar/Services/Updates/QuasarUpdateService.cs", "name": "QuasarUpdateService.cs", "ext": ".cs", - "size": 47252, - "sha256": "895348b11a244ed83a70f0158a875db25323723abfc7e0da86056b62ed5c544c", + "size": 47464, + "sha256": "473266643d585fd75ad67294a9a727f3aba348393ce15cbc3f0e7f54f837d2eb", "module": "Quasar.Services.Core", "tier": 2, "status": "pending" @@ -2134,8 +2134,8 @@ "path": "Quasar/wwwroot/quasar-configs.js", "name": "quasar-configs.js", "ext": ".js", - "size": 9808, - "sha256": "b0a29210fe1f8988e65a765d631069f2eb6b5e980cc99f8559ff3c82f73ffeac", + "size": 10978, + "sha256": "7f1eca665c40ce0655fbff3bac49d88f64fd73ae5cbe8fca7c67b24f00a6b0bc", "module": "Quasar.Host", "tier": 3, "status": "pending" diff --git a/Docs/Reference/files/Quasar.Bootstrap/Program.cs.md b/Docs/Reference/files/Quasar.Bootstrap/Program.cs.md index c8130c1..d03f55d 100644 --- a/Docs/Reference/files/Quasar.Bootstrap/Program.cs.md +++ b/Docs/Reference/files/Quasar.Bootstrap/Program.cs.md @@ -25,7 +25,7 @@ Entry point and core logic for the Quasar launcher. It implements three CLI comm ### `BootstrapDataDirectoryMigration` (static) - Runs once at process start before `MagnetarPaths` is used. - Treats a blank `QUASAR_DATA_DIR`, a legacy default data root (`~/.config/Quasar` / `%APPDATA%\Quasar`), or an install-root value as the default path policy. -- Uses `AppContext.BaseDirectory` as the target data root, recursively copies legacy default root contents into it, rewrites migrated `Updates/active-release.json` file/working-directory paths from the old root to the new root, removes copied legacy files/directories when possible, then sets `QUASAR_DATA_DIR` for the current process and child worker. +- Uses `AppContext.BaseDirectory` as the target data root, recursively copies legacy default root contents into it, rewrites migrated `Updates/active-release.json` file, argument, and working-directory paths from the old root to the new root, removes copied legacy files/directories when possible, then sets `QUASAR_DATA_DIR` for the current process and child worker. - Refuses the migration and falls back to the legacy root if the launcher install root is inside the legacy root, preventing recursive self-copy. - Leaves custom `QUASAR_DATA_DIR` values untouched. diff --git a/Docs/Reference/files/Quasar/Components/Pages/Updates.razor.md b/Docs/Reference/files/Quasar/Components/Pages/Updates.razor.md index 964aaae..520df66 100644 --- a/Docs/Reference/files/Quasar/Components/Pages/Updates.razor.md +++ b/Docs/Reference/files/Quasar/Components/Pages/Updates.razor.md @@ -19,7 +19,7 @@ Authorization: `QuasarPolicyNames.CanManageSecurity` - `ManagedRuntimeWarmupService` — managed Magnetar/DS snapshot plus manual update checks - `ISnackbar` — user feedback for update actions - `IDialogService` — confirmation dialogs before enabling prerelease updates or forcing Bootstrap activation -- `IJSRuntime` — starts browser-side health polling before a forced Bootstrap restart drops the circuit +- `IJSRuntime` — starts browser-side health polling before UI-worker activation or a forced Bootstrap restart drops the circuit **Key members** @@ -31,8 +31,8 @@ Authorization: `QuasarPolicyNames.CanManageSecurity` | `CheckDedicatedServerNowAsync()` | Runs an immediate managed DS check through `ManagedRuntimeWarmupService.CheckDedicatedServerNowAsync()`. | | `HandleSelectedWebVersionChanged(...)` | Selects the UI release to stage/install from the discovered list, including older rollback targets. | | `StageAsync()` | Downloads and stages the selected Quasar UI release unless it is already current or staged; if appsettings rollover conflicts, loads the conflict text and warns instead of reporting success. | -| `ActivateAsync()` | Requests staged UI activation; the update service promotes the staged payload into the managed active-release directory and writes the active-release pointer. Older staged releases are allowed for rollback. | -| `ForceActivateBootstrapAsync()` | Confirms, starts browser health polling, then asks `QuasarUpdateService` to write the Bootstrap update request file so the launcher activates the detected update immediately. Disabled when no Bootstrap update is detected or the worker is not launcher-managed. | +| `ActivateAsync()` | Starts browser health polling for the selected target version, then requests staged UI activation; disabled outside Bootstrap because no launcher is watching the active-release pointer. Older staged releases are allowed for rollback. | +| `ForceActivateBootstrapAsync()` | Confirms, starts browser health polling that waits for a restart gap, then asks `QuasarUpdateService` to write the Bootstrap update request file so the launcher activates the detected update immediately. Disabled when no Bootstrap update is detected or the worker is not launcher-managed. | | `HandleIncludePrereleaseChanged(bool)` | Confirms before enabling prerelease updates, persists the stream setting through `QuasarUpdateService`, refreshes the release list, and shows a strong warning while prereleases are enabled. | | `HandleAutoStageWebUpdatesChanged(bool)` | Persists whether release checks should automatically download/stage a newer UI release or only queue releases for manual staging. | | `LoadAppSettingsConflictAsync()` / `SaveAppSettingsResolutionAsync()` / `ForceReleaseAppSettingsAsync()` | Reads the staged conflict file, saves a manually resolved JSON file, or force-restages release defaults after confirmation. | diff --git a/Docs/Reference/files/Quasar/Services/Updates/QuasarUpdateService.cs.md b/Docs/Reference/files/Quasar/Services/Updates/QuasarUpdateService.cs.md index a1e1ab0..892ed2a 100644 --- a/Docs/Reference/files/Quasar/Services/Updates/QuasarUpdateService.cs.md +++ b/Docs/Reference/files/Quasar/Services/Updates/QuasarUpdateService.cs.md @@ -21,7 +21,7 @@ Namespace: `Quasar.Services.Updates` | `CheckNowAsync(ct)` | Checks the configured GitHub releases endpoint, builds all selectable non-draft UI releases containing the configured asset, finds only newer Bootstrap candidates, updates the selected UI version, and auto-stages a newer UI version only when auto-stage mode is enabled. | | `StageWebUpdateAsync(ct)` / `StageWebUpdateAsync(forceAppSettingsOverride, ct)` | Downloads the selected web asset, resolves/verifies its SHA-256 checksum from `SHA256SUMS`, extracts it into `Updates/Staged/`, validates required web layout files, resolves staged `appsettings.json`, and marks it staged. Current-version staging is rejected; older selected releases can be staged for rollback. | | `ReadAppSettingsConflictTextAsync(ct)` / `ResolveAppSettingsConflictAsync(text, ct)` | Supports the Updates page conflict editor by reading the staged conflict file, validating the resolved JSON, persisting it, and marking the release staged once conflict markers are gone. | -| `ActivateStagedWebUpdateAsync(ct)` | Copies the staged payload into `ManagedRuntime/WebService/`, syncs the resolved active `appsettings.json` back to the install directory when `QUASAR_INSTALL_DIR` is known, writes `QuasarActiveReleasePointer` to the active-release path so Bootstrap can swap workers, and clears old staged payloads. Staged older UI releases are valid rollback targets. | +| `ActivateStagedWebUpdateAsync(ct)` | Requires a launcher-managed worker, copies the staged payload into `ManagedRuntime/WebService/`, syncs the resolved active `appsettings.json` back to the install directory when `QUASAR_INSTALL_DIR` is known, writes `QuasarActiveReleasePointer` to the active-release path so Bootstrap can swap workers, and clears old staged payloads. Staged older UI releases are valid rollback targets. | | `RequestBootstrapUpdateActivationAsync(ct)` | Validates that a newer Bootstrap candidate exists and the worker is launcher-managed, then writes `Updates/bootstrap-update-request.json` with the detected version/asset for Bootstrap to consume and publishes an activating status message. | | `ExecuteAsync(stoppingToken)` | Runs an initial delayed check and repeats every configured interval while enabled. | | `GetReleasesAsync(ct)` / `BuildCandidates(...)` | Calls GitHub releases API (`per_page=100`), ignores drafts, optionally includes prereleases, and maps matching release assets into UI/Bootstrap candidates. | @@ -46,4 +46,4 @@ Private nested DTOs `GitHubRelease` and `GitHubAsset` model the small subset of ## Notes -UI-worker activation stays explicit from the Updates page on both Linux and Windows. Auto-stage mode only downloads/stages the newer selected UI release; manual mode queues releases until the operator stages one. Launcher updates are reported in the UI; normally Bootstrap installs them automatically from the platform asset (`quasar-installer-linux.tar.gz` on Linux, `quasar-installer-windows.zip` on Windows), but a launcher-managed worker can request immediate activation by writing `Updates/bootstrap-update-request.json` with the detected target version and asset. Bootstrap consumes that request and runs its normal verified self-update path for that requested release, restarting on Linux via systemd exit-75 or on Windows by spawning a detached replacement launcher. Staged UI payloads are rejected before activation when core Blazor/MudBlazor/app static assets are missing or `appsettings.json` has unresolved conflict markers; older staged UI payloads are allowed so the operator can roll back the worker. Active UI releases live outside `Updates/Staged`, so the Updates folder only contains transient staged payloads plus the active pointer, the Bootstrap update request file, and the stored `appsettings.json` merge base. The prerelease switch affects the running worker immediately; forced Bootstrap requests carry the detected candidate, while Bootstrap reads the persisted data-directory override for periodic checks after its next restart. +UI-worker activation stays explicit from the Updates page on both Linux and Windows and is rejected when the worker is not running under Bootstrap. Auto-stage mode only downloads/stages the newer selected UI release; manual mode queues releases until the operator stages one. Launcher updates are reported in the UI; normally Bootstrap installs them automatically from the platform asset (`quasar-installer-linux.tar.gz` on Linux, `quasar-installer-windows.zip` on Windows), but a launcher-managed worker can request immediate activation by writing `Updates/bootstrap-update-request.json` with the detected target version and asset. Bootstrap consumes that request and runs its normal verified self-update path for that requested release, restarting on Linux via systemd exit-75 or on Windows by spawning a detached replacement launcher. Staged UI payloads are rejected before activation when core Blazor/MudBlazor/app static assets are missing or `appsettings.json` has unresolved conflict markers; older staged UI payloads are allowed so the operator can roll back the worker. Active UI releases live outside `Updates/Staged`, so the Updates folder only contains transient staged payloads plus the active pointer, the Bootstrap update request file, and the stored `appsettings.json` merge base. The prerelease switch affects the running worker immediately; forced Bootstrap requests carry the detected candidate, while Bootstrap reads the persisted data-directory override for periodic checks after its next restart. diff --git a/Docs/Reference/files/Quasar/wwwroot/quasar-configs.js.md b/Docs/Reference/files/Quasar/wwwroot/quasar-configs.js.md index a09c78f..27925e5 100644 --- a/Docs/Reference/files/Quasar/wwwroot/quasar-configs.js.md +++ b/Docs/Reference/files/Quasar/wwwroot/quasar-configs.js.md @@ -20,7 +20,7 @@ Small JavaScript interop module registered as `window.quasarConfigs`. Provides u | `getScrollEdgeState(id, threshold)` | `(string, number?) → object` | Returns `{ nearTop, nearBottom }` booleans for scroll containers; retained for simple edge checks | | `attachRolloverLog(id, dotNetRef, options)` | `(string, DotNetObjectReference, object) → void` | Attaches browser-side scroll, click, and `Ctrl`/`Alt` + `PageUp`/`PageDown`/`Home`/`End` listeners for the server-log viewer; calls .NET only when a 250-line window move or start/end jump is needed | | `detachRolloverLog(id)` | `(string) → void` | Removes listeners installed by `attachRolloverLog` | -| `reloadWhenHealthy(targetUrl, options)` | `(string, object?) → void` | Used during a Quasar worker restart (the Blazor circuit drops): after an initial delay, polls the anonymous `/api/health` endpoint at `pollIntervalMs` (default 1 s) and navigates to `targetUrl` once it responds `ok`; falls back to a plain reload after `maxWaitMs` (default 120 s) | +| `reloadWhenHealthy(targetUrl, options)` | `(string, object?) → void` | Used during a Quasar worker restart (the Blazor circuit drops): after an initial delay, polls the anonymous `/api/health` endpoint at `pollIntervalMs` (default 1 s) and navigates to `targetUrl` once it responds `ok`, optionally waiting for `expectedVersion` or for an unhealthy gap via `requireUnhealthy`; falls back to a plain reload after `maxWaitMs` (default 120 s) | ## Dependencies - Called by Blazor components via `IJSRuntime` (specific callers not determinable from this file alone) diff --git a/Docs/WindowsDeploymentAndUpdates.md b/Docs/WindowsDeploymentAndUpdates.md index af2b5f5..1307f6e 100644 --- a/Docs/WindowsDeploymentAndUpdates.md +++ b/Docs/WindowsDeploymentAndUpdates.md @@ -92,10 +92,12 @@ default and lists selectable `quasar-web-win-x64.zip` releases on `AutoStageWebUpdates` enabled, a newer web asset is downloaded and staged automatically after its `SHA256SUMS` entry is verified; with it disabled, releases remain queued until the operator stages the selected version. Activation is -explicit; the UI copies the staged payload into -`\ManagedRuntime\WebService\`, clears stale staged -payloads, and Bootstrap drains the old worker, starts the managed `Quasar.exe` on -the same port, and leaves managed Magnetar servers running. +explicit and requires the worker to be running under Bootstrap; the UI copies the +staged payload into `\ManagedRuntime\WebService\`, clears +stale staged payloads, and Bootstrap drains the old worker, starts the managed +`Quasar.exe` on the same port, and leaves managed Magnetar servers running. The +browser polls `/api/health` until the activated UI version is serving, then +reloads the Updates page. Staging also resolves `appsettings.json`. Quasar uses the stored release base in the data directory (`\Updates\appsettings.base.json` by default) as the diff --git a/Quasar.Bootstrap/Program.cs b/Quasar.Bootstrap/Program.cs index f454480..9d4917e 100644 --- a/Quasar.Bootstrap/Program.cs +++ b/Quasar.Bootstrap/Program.cs @@ -658,8 +658,10 @@ private static void TryRewriteMigratedActiveReleasePointer(string legacyRoot, st var fileName = RewriteMigratedPath(pointer.FileName, legacyRoot, targetRoot); var workingDirectory = RewriteMigratedPath(pointer.WorkingDirectory, legacyRoot, targetRoot); + var arguments = RewriteMigratedArguments(pointer.Arguments, legacyRoot, targetRoot); if (string.Equals(fileName, pointer.FileName, StringComparison.Ordinal) && - string.Equals(workingDirectory, pointer.WorkingDirectory, StringComparison.Ordinal)) + string.Equals(workingDirectory, pointer.WorkingDirectory, StringComparison.Ordinal) && + string.Equals(arguments, pointer.Arguments, StringComparison.Ordinal)) { return; } @@ -668,7 +670,7 @@ private static void TryRewriteMigratedActiveReleasePointer(string legacyRoot, st { Version = pointer.Version, FileName = fileName, - Arguments = pointer.Arguments, + Arguments = arguments, WorkingDirectory = workingDirectory, ActivatedAtUtc = pointer.ActivatedAtUtc, }; @@ -694,6 +696,27 @@ private static string RewriteMigratedPath(string value, string legacyRoot, strin return Path.Combine(targetRoot, relativePath); } + private static string RewriteMigratedArguments(string value, string legacyRoot, string targetRoot) + { + if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(legacyRoot)) + return value; + + var normalizedLegacyRoot = NormalizeDirectory(legacyRoot); + var normalizedTargetRoot = NormalizeDirectory(targetRoot); + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var rewritten = value.Replace(normalizedLegacyRoot, normalizedTargetRoot, comparison); + + if (OperatingSystem.IsWindows()) + { + rewritten = rewritten.Replace( + normalizedLegacyRoot.Replace('\\', '/'), + normalizedTargetRoot.Replace('\\', '/'), + StringComparison.OrdinalIgnoreCase); + } + + return rewritten; + } + private static void MergeDirectoryContents(string sourceDirectory, string destinationDirectory) { Directory.CreateDirectory(destinationDirectory); diff --git a/Quasar/Components/Pages/Updates.razor b/Quasar/Components/Pages/Updates.razor index ea51c3a..a7eb2d6 100644 --- a/Quasar/Components/Pages/Updates.razor +++ b/Quasar/Components/Pages/Updates.razor @@ -137,11 +137,15 @@ Activate + @if (_snapshot.Web is { IsStaged: true, IsCurrent: false } && !IsUnderBootstrap) + { + Quasar is not running under Bootstrap, so this update cannot be activated from the UI. + } } @@ -387,10 +391,35 @@ private async Task ActivateAsync() { - await RunBusyAsync( - () => UpdateService.ActivateStagedWebUpdateAsync(), - "Quasar UI update activation requested. The page will reconnect shortly.", - "Failed activating update"); + if (!CanActivateWebUpdate) + { + if (!IsUnderBootstrap) + Snackbar.Add("Quasar is not running under Bootstrap, so this update cannot be activated from the UI.", Severity.Warning); + + return; + } + + var targetVersion = _snapshot.Web?.Version ?? string.Empty; + _busy = true; + try + { + await JS.InvokeVoidAsync("quasarConfigs.reloadWhenHealthy", "/settings/updates", new + { + expectedVersion = targetVersion, + initialDelayMs = 500, + }); + await UpdateService.ActivateStagedWebUpdateAsync(); + _snapshot = UpdateService.GetSnapshot(); + Snackbar.Add("Quasar UI update activation requested. The page will reconnect shortly.", Severity.Success); + } + catch (Exception exception) + { + Snackbar.Add($"Failed activating update: {exception.Message}", Severity.Error); + } + finally + { + _busy = false; + } } private async Task ForceActivateBootstrapAsync() @@ -412,7 +441,10 @@ _bootstrapActivationRequested = true; try { - await JS.InvokeVoidAsync("quasarConfigs.reloadWhenHealthy", "/settings/updates"); + await JS.InvokeVoidAsync("quasarConfigs.reloadWhenHealthy", "/settings/updates", new + { + requireUnhealthy = true, + }); await UpdateService.RequestBootstrapUpdateActivationAsync(); _snapshot = UpdateService.GetSnapshot(); Snackbar.Add("Bootstrap update activation requested. Quasar will restart shortly.", Severity.Success); @@ -642,6 +674,11 @@ IsUnderBootstrap && _snapshot.Bootstrap is { IsNewer: true }; + private bool CanActivateWebUpdate => + !_busy && + IsUnderBootstrap && + _snapshot.Web is { IsStaged: true, IsCurrent: false }; + private static string FormatWebReleaseOption(QuasarUpdateCandidate release) { var badges = new List(); diff --git a/Quasar/Services/Updates/QuasarUpdateService.cs b/Quasar/Services/Updates/QuasarUpdateService.cs index 70d0b29..40366b6 100644 --- a/Quasar/Services/Updates/QuasarUpdateService.cs +++ b/Quasar/Services/Updates/QuasarUpdateService.cs @@ -379,6 +379,9 @@ public async Task ResolveAppSettingsConflictAsync(string resolvedText, Cancellat public Task ActivateStagedWebUpdateAsync(CancellationToken cancellationToken = default) { + if (string.IsNullOrWhiteSpace(_webOptions.LauncherToken)) + throw new InvalidOperationException("Quasar is not running under Bootstrap, so staged UI updates cannot be activated from the UI."); + var candidate = GetSnapshot().Web; if (candidate is null || !candidate.IsStaged || string.IsNullOrWhiteSpace(candidate.StagedDirectory)) throw new InvalidOperationException("No staged Quasar UI update is ready to activate."); diff --git a/Quasar/wwwroot/quasar-configs.js b/Quasar/wwwroot/quasar-configs.js index 353a76f..9ca3827 100644 --- a/Quasar/wwwroot/quasar-configs.js +++ b/Quasar/wwwroot/quasar-configs.js @@ -216,7 +216,10 @@ window.quasarConfigs = window.quasarConfigs || { const pollIntervalMs = opts.pollIntervalMs || 1000; const maxWaitMs = opts.maxWaitMs || 120000; const initialDelayMs = opts.initialDelayMs || 1500; + const expectedVersion = (opts.expectedVersion || opts.ExpectedVersion || "").toString().trim().toLowerCase(); + const requireUnhealthy = !!(opts.requireUnhealthy ?? opts.RequireUnhealthy); const startedAt = Date.now(); + let observedUnhealthy = !requireUnhealthy; const scheduleNext = () => { if (Date.now() - startedAt >= maxWaitMs) { @@ -226,16 +229,43 @@ window.quasarConfigs = window.quasarConfigs || { window.setTimeout(check, pollIntervalMs); }; + const isExpectedVersion = (payload) => { + if (!expectedVersion) { + return true; + } + + const actual = (payload?.version ?? payload?.Version ?? "").toString().trim().toLowerCase(); + return actual === expectedVersion; + }; + const check = () => { fetch("/api/health", { cache: "no-store" }) - .then((response) => { - if (response.ok) { + .then(async (response) => { + if (!response.ok) { + observedUnhealthy = true; + scheduleNext(); + return; + } + + let payload = null; + if (expectedVersion) { + try { + payload = await response.json(); + } catch { + payload = null; + } + } + + if (observedUnhealthy && isExpectedVersion(payload)) { window.location.href = url; } else { scheduleNext(); } }) - .catch(scheduleNext); + .catch(() => { + observedUnhealthy = true; + scheduleNext(); + }); }; window.setTimeout(check, initialDelayMs);