v0.238.0: the page follows the update, and a held app offers no way to start it (update arc slice 4 Part 4)
gates / gates (push) Successful in 13s
gates / gates (push) Successful in 13s
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
This commit is contained in:
@@ -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)
|
## 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)
|
**MinAgent: 0.129.0** (unchanged)
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
**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.
|
per-app and was measured unpredictable. Reasoning: `felhom.eu/documentation/architecture/09-update-architecture.md` §6.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .Stack.Updating}}
|
||||||
|
<div class="alert alert-info" style="margin-top:1rem" data-update-phase="{{.Stack.UpdatePhase}}">{{.Stack.UpdatePhaseLabel}}</div>
|
||||||
|
{{else if .Stack.HoldReason}}
|
||||||
|
<div class="alert alert-error" style="margin-top:1rem" data-held="true">{{.Stack.HoldReason}} <a href="/backups/apps" class="btn btn-sm btn-outline">Mentések</a></div>
|
||||||
|
{{else if .Stack.UpdateError}}
|
||||||
|
<div class="alert alert-warning" style="margin-top:1rem" data-update-error="true">{{.Stack.UpdateError}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .MissingStorageLabel}}
|
{{if .MissingStorageLabel}}
|
||||||
<div class="alert alert-warning" style="margin-top:1rem">
|
<div class="alert alert-warning" style="margin-top:1rem">
|
||||||
<strong><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> Hiányzó tárhely: {{.MissingStorageLabel}}</strong><br>
|
<strong><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> Hiányzó tárhely: {{.MissingStorageLabel}}</strong><br>
|
||||||
|
|||||||
@@ -284,6 +284,13 @@
|
|||||||
btn.classList.remove('loading');
|
btn.classList.remove('loading');
|
||||||
return;
|
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();
|
window.location.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showAlert('Hálózati hiba: ' + err.message);
|
showAlert('Hálózati hiba: ' + err.message);
|
||||||
@@ -292,6 +299,19 @@
|
|||||||
btn.classList.remove('loading');
|
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) {
|
async function deleteOrphanStack(name) {
|
||||||
var modal = document.createElement('div');
|
var modal = document.createElement('div');
|
||||||
modal.className = 'modal-overlay';
|
modal.className = 'modal-overlay';
|
||||||
|
|||||||
@@ -86,11 +86,22 @@
|
|||||||
{{if canInstall .Meta}}<a href="/stacks/{{.Name}}/deploy" class="btn btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">Telepítés</a>{{end}}
|
{{if canInstall .Meta}}<a href="/stacks/{{.Name}}/deploy" class="btn btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">Telepítés</a>{{end}}
|
||||||
<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>
|
<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>
|
||||||
{{else}}
|
{{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}}
|
||||||
|
<span class="tag tag-progress" data-update-phase="{{.UpdatePhase}}"><span class="dot"></span>{{.UpdatePhaseLabel}}</span>
|
||||||
|
{{else if .HoldReason}}
|
||||||
|
<div class="alert alert-error" data-held="true">{{.HoldReason}} <a href="/backups/apps" class="btn btn-sm btn-outline">Mentések</a></div>
|
||||||
|
{{if not .Orphaned}}<button class="btn btn-danger" onclick="removeStack('{{.Name}}')">Eltávolítás</button>{{end}}
|
||||||
|
{{else if isOperational .State}}
|
||||||
|
{{if .UpdateError}}<div class="alert alert-warning" data-update-error="true">{{.UpdateError}}</div>{{end}}
|
||||||
{{if not .Orphaned}}<button class="btn btn-success" onclick="stackAction(event, '{{.Name}}', 'update')">Frissítés</button>{{end}}
|
{{if not .Orphaned}}<button class="btn btn-success" onclick="stackAction(event, '{{.Name}}', 'update')">Frissítés</button>{{end}}
|
||||||
<button class="btn btn-warning" onclick="stackAction(event, '{{.Name}}', 'restart')">Újraindítás</button>
|
<button class="btn btn-warning" onclick="stackAction(event, '{{.Name}}', 'restart')">Újraindítás</button>
|
||||||
<button class="btn btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')">Leállítás</button>
|
<button class="btn btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')">Leállítás</button>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
{{if .UpdateError}}<div class="alert alert-warning" data-update-error="true">{{.UpdateError}}</div>{{end}}
|
||||||
<button class="btn btn-success" onclick="stackAction(event, '{{.Name}}', 'start')">Indítás</button>
|
<button class="btn btn-success" onclick="stackAction(event, '{{.Name}}', 'start')">Indítás</button>
|
||||||
{{if not .Orphaned}}<button class="btn btn-danger" onclick="removeStack('{{.Name}}')">Eltávolítás</button>{{end}}
|
{{if not .Orphaned}}<button class="btn btn-danger" onclick="removeStack('{{.Name}}')">Eltávolítás</button>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user