From 129201ababfdebdfa13bf96c9bf689c53af02c69 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 13 Sep 2026 12:07:35 +0200 Subject: [PATCH] v0.238.0: the page follows the update, and a held app offers no way to start it (update arc slice 4 Part 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No behaviour change on the box — the surface only. - Frissítés follows the job: the button shows the phase label (polling GET /api/stacks/{name} every 3 s) and the page reloads when updating goes false. - An updating card offers no lifecycle button; a held card (failed update OR failed restore) shows the hold sentence with a Mentések link and nothing that would start it; a failed update that held nothing shows its sentence above the buttons. app_info shows the same three notices. - The updating/held checks run BEFORE isOperational, which counts `restarting` as operational — how the 2026-09-01 spike saw a green Frissítés beside a crash loop. Pinned with StateRestarting fixtures; red-proofed by moving the checks after it (both tests fail). - No new CSS, no version number. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 22 ++++++ controller/README.md | 5 ++ controller/internal/web/slice4_page_test.go | 70 +++++++++++++++++++ .../internal/web/templates/app_info.html | 8 +++ controller/internal/web/templates/layout.html | 20 ++++++ controller/internal/web/templates/stacks.html | 13 +++- 6 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 controller/internal/web/slice4_page_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 654430f..b5a1444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## v0.238.0 — the page follows the update, and a held app offers no way to start it (2026-09-13, update arc slice 4 Part 4) + +**MinAgent: 0.129.0** (unchanged) + +**No behaviour change on the box — the surface only.** v0.237.0 made the update a job; this release +makes the page show it. + +- **Pressing `Frissítés` follows the job.** The button text becomes the phase label — + `Ellenőrzés…`, `Biztonsági mentés készül a frissítés előtt…`, `Adatbázis pillanatkép…`, + `Új verzió letöltése…`, `Indítás az új verzióval…`, `Működés ellenőrzése…` — polling + `GET /api/stacks/{name}` every 3 s, and the page reloads when `updating` goes false. It no longer + reloads the instant the request returns. +- **An updating card offers no lifecycle button** and shows the phase as a progress tag. +- **A held card** (failed update OR failed restore) shows the hold sentence and a `Mentések` link, and + offers nothing that would start or update the app — `Eltávolítás` stays. +- **A failed update that held nothing** (backup failed, pull failed, precondition vanished) shows its + sentence above the buttons. +- **The updating and held checks come BEFORE `isOperational`.** That predicate counts `restarting` as + operational, which is how the 2026-09-01 spike saw a green `Frissítés` beside a crash loop. Pinned + with fixtures in `StateRestarting`; red-proofed by moving the checks after it (both tests fail). +- The app info page shows the same three notices under its header. **No new CSS**, no version number. + ## v0.237.0 — the Update button takes a backup first, and tells the truth (2026-09-13, update arc slice 4 — R-448, R-443, R-439) **MinAgent: 0.129.0** (unchanged) diff --git a/controller/README.md b/controller/README.md index 4a66198..41abef3 100644 --- a/controller/README.md +++ b/controller/README.md @@ -596,6 +596,11 @@ the boot sweep) puts a pin back or marks an interrupted update for `ResumeInterr since v0.237.0 the drive-return gate and the nightly volume dump. The nightly capture and Tier-2 run skip a held app so its restore point is not overwritten. +**The page (v0.238.0).** `Frissítés` follows the job — the button shows the phase label and the page +reloads when the update ends. An updating card offers no lifecycle button; a held card shows the hold +sentence with a `Mentések` link and nothing that would start it; a failed update that held nothing +shows its sentence. These checks run BEFORE `isOperational`, which counts `restarting` as operational. + **Not done, deliberately:** the old version is never put back automatically — whether that works is per-app and was measured unpredictable. Reasoning: `felhom.eu/documentation/architecture/09-update-architecture.md` §6. diff --git a/controller/internal/web/slice4_page_test.go b/controller/internal/web/slice4_page_test.go new file mode 100644 index 0000000..4dfc9ea --- /dev/null +++ b/controller/internal/web/slice4_page_test.go @@ -0,0 +1,70 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// Slice 4 Part 4 — the card while an update runs, while an app is held, and after an update that +// failed without a hold. Rendered through the REAL stacks template. +// +// THE TRAP THESE PIN: isOperationalState counts `restarting` as operational — that is how the +// 2026-09-01 spike saw a green Frissítés beside a crash loop. Both fixtures below are in +// StateRestarting on purpose, so a template that checked isOperational FIRST would render all three +// lifecycle buttons and fail here. +// +// COMPANION RED-PROOF (REPORT.md): move the `{{if .Updating}}…{{else if .HoldReason}}` branches below +// `isOperational` in stacks.html — the held and updating tests fail with the buttons present. + +var slice4LifecycleButtons = []string{ + `stackAction(event, 'bookstack', 'update')`, + `stackAction(event, 'bookstack', 'restart')`, + `stackAction(event, 'bookstack', 'stop')`, + `stackAction(event, 'bookstack', 'start')`, +} + +func TestSlice4_Page_UpdatingCardShowsThePhaseAndNoLifecycleButton(t *testing.T) { + st := ubStack(nil, map[string]string{"web": "nginx:1.27"}, "2026-07-18") + st.State = stacks.StateRestarting + st.Updating, st.UpdatePhase, st.UpdatePhaseLabel = true, stacks.UpdatePhasePulling, stacks.UpdatePhaseLabel(stacks.UpdatePhasePulling) + h := renderBackupPage(t, "stacks", ubStacksData(st)) + if !strings.Contains(h, `data-update-phase="pulling"`) || !strings.Contains(h, stacks.UpdatePhaseLabel(stacks.UpdatePhasePulling)) { + t.Error("an updating card must show its phase label") + } + for _, b := range slice4LifecycleButtons { + if strings.Contains(h, b) { + t.Errorf("an updating card must offer no lifecycle button, found %q", b) + } + } +} + +func TestSlice4_Page_HeldCardShowsTheHoldAndTheWayBack(t *testing.T) { + st := ubStack(nil, map[string]string{"web": "nginx:1.27"}, "2026-07-18") + st.State = stacks.StateRestarting + st.HoldReason = "A(z) bookstack frissítése 2026-09-13 10:00-kor nem sikerült — HOLD" + h := renderBackupPage(t, "stacks", ubStacksData(st)) + if !strings.Contains(h, `data-held="true"`) || !strings.Contains(h, "HOLD") || !strings.Contains(h, `href="/backups/apps"`) { + t.Error("a held card must show the hold sentence and link Mentések") + } + for _, b := range slice4LifecycleButtons { + if strings.Contains(h, b) { + t.Errorf("a held card must offer nothing that starts or updates it, found %q", b) + } + } +} + +func TestSlice4_Page_FailedUpdateWithoutHoldKeepsTheButtonsAndSaysWhy(t *testing.T) { + st := ubStack(nil, map[string]string{"web": "nginx:1.27"}, "2026-07-18") + st.UpdateError = stacks.MsgUpdatePullFailed + h := renderBackupPage(t, "stacks", ubStacksData(st)) + if !strings.Contains(h, `data-update-error="true"`) || !strings.Contains(h, stacks.MsgUpdatePullFailed) { + t.Error("a failed update that held nothing must still say what happened") + } + for _, b := range slice4LifecycleButtons[:3] { + if !strings.Contains(h, b) { + t.Errorf("positive control: a running, unheld app keeps %q", b) + } + } +} diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index d9772c2..f94edfb 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -28,6 +28,14 @@ +{{if .Stack.Updating}} +
{{.Stack.UpdatePhaseLabel}}
+{{else if .Stack.HoldReason}} +
{{.Stack.HoldReason}} Mentések
+{{else if .Stack.UpdateError}} +
{{.Stack.UpdateError}}
+{{end}} + {{if .MissingStorageLabel}}
Hiányzó tárhely: {{.MissingStorageLabel}}
diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index cd9c339..4e7e480 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -284,6 +284,13 @@ btn.classList.remove('loading'); return; } + // Slice 4: an update is ACCEPTED, not finished (HTTP 202). Follow its phases on this + // button until the box says it is done or failed — never reload into a page that still + // shows the old state as if nothing were happening. + if (action === 'update') { + followUpdate(name, btn); + return; + } window.location.reload(); } catch (err) { showAlert('Hálózati hiba: ' + err.message); @@ -292,6 +299,19 @@ btn.classList.remove('loading'); } } + function followUpdate(name, btn) { + var started = Date.now(); + var timer = setInterval(async function() { + if (Date.now() - started > 30 * 60 * 1000) { clearInterval(timer); window.location.reload(); return; } + try { + var r = await fetch('/api/stacks/' + name); + var d = await r.json(); + if (!d.ok || !d.data) return; + if (d.data.update_phase_label) btn.textContent = d.data.update_phase_label; + if (!d.data.updating) { clearInterval(timer); window.location.reload(); } + } catch (e) {} + }, 3000); + } async function deleteOrphanStack(name) { var modal = document.createElement('div'); modal.className = 'modal-overlay'; diff --git a/controller/internal/web/templates/stacks.html b/controller/internal/web/templates/stacks.html index 7542959..3d909b7 100644 --- a/controller/internal/web/templates/stacks.html +++ b/controller/internal/web/templates/stacks.html @@ -86,11 +86,22 @@ {{if canInstall .Meta}}Telepítés{{end}} Részletek {{else}} - {{if isOperational .State}} + {{/* Slice 4 (v0.238.0): the UPDATING and HELD checks come BEFORE isOperational. That + predicate counts `restarting` as operational, which is how the 2026-09-01 spike saw + a green Frissítés beside a crash loop. An app being updated offers no lifecycle + button; a held app offers none that would start it, only the way back. */}} + {{if .Updating}} + {{.UpdatePhaseLabel}} + {{else if .HoldReason}} +
{{.HoldReason}} Mentések
+ {{if not .Orphaned}}{{end}} + {{else if isOperational .State}} + {{if .UpdateError}}
{{.UpdateError}}
{{end}} {{if not .Orphaned}}{{end}} {{else}} + {{if .UpdateError}}
{{.UpdateError}}
{{end}} {{if not .Orphaned}}{{end}} {{end}}