controller v0.264.0: the household is told when an update is undone or held, in its language
gates / gates (push) Successful in 25s

app_update_undone / app_update_held events (09 decision 15), on by
default and seeded once on existing boxes; R-606 update sentences as
key+args rendered per reader; R-646 startup applied-meta backfill for
apps current with the catalog; R-620 a disabled notifier WARNs once per
event type. Needs hub v0.120.0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-23 13:51:21 +02:00
parent c3a2aba0d2
commit bc278944a3
27 changed files with 1086 additions and 86 deletions
+21
View File
@@ -128,6 +128,12 @@ func (s *Server) templateFuncMap() template.FuncMap {
return "off"
}
},
// v0.264.0 (R-606): the update path's sentences, rendered per reader. These are the HUNGARIAN
// forms — exactly what the templates printed before (.UpdatePhaseLabel / .UpdateError /
// .HoldReason); localeFuncs overrides all three for another language.
"updatePhaseText": func(st interface{}) string { return stackOf(st).UpdatePhaseLabel },
"updateErrorText": func(st interface{}) string { return stackOf(st).UpdateError },
"holdText": func(st interface{}) string { return stackOf(st).HoldReason },
"stateLabel": func(state stacks.ContainerState) string {
switch state {
case stacks.StateRunning:
@@ -491,3 +497,18 @@ func fmtRFC3339Local(s string) string {
}
return t.In(getTimezone()).Format("2006-01-02 15:04")
}
// stackOf lets the update-path template funcs take a stack the way each page holds it (app_info: a
// *stacks.Stack; stacks: a stacks.Stack in a range). Anything else is an empty stack — the func then
// prints an empty string, which a render test would see.
func stackOf(v interface{}) stacks.Stack {
switch st := v.(type) {
case stacks.Stack:
return st
case *stacks.Stack:
if st != nil {
return *st
}
}
return stacks.Stack{}
}
+4 -1
View File
@@ -1524,7 +1524,7 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus, lang string
// the customer's data may not be intact. Measured 2026-08-22: after a failed MariaDB replay
// the app reported `health=healthy, running=true, restarts=0` while its schema-version table
// held zero rows.
if held, why := s.backupMgr.RestoreHoldFor(app.StackName); held {
if held, why := s.backupMgr.RestoreHoldForLang(app.StackName, lang); held {
row.Status = "red"
row.StatusText = why
row.RestoreHeld = true
@@ -2656,6 +2656,9 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
// regardless: processOperator consults operatorOn, the address and a cooldown, and never the
// customer's preferences. This toggle governs the customer leg only.
"app_start_failed",
// v0.264.0: the update outcomes (default ON). A type the page cannot render is a type every
// Save drops — so they are listed here AND carry a checkbox.
"app_update_undone", "app_update_held",
"storage_reconnected", "health_recovered",
} {
if r.FormValue("event_"+evt) == "on" {
+20
View File
@@ -313,6 +313,26 @@ func (s *Server) localeFuncs(lang string) template.FuncMap {
"stateLabel": func(state stacks.ContainerState) string {
return b.Msg(lang, stateLabelKey(state))
},
// v0.264.0 (R-606): the update path in the reader's language. A stored sentence with no key (an
// older version's, or a composed one) renders as it is — never as an empty line.
"updatePhaseText": func(st interface{}) string {
stk := stackOf(st)
if l := stacks.UpdatePhaseLabelIn(lang, stk.UpdatePhase); l != "" && l != stacks.UpdatePhaseLabel(stk.UpdatePhase) {
return l
}
return stk.UpdatePhaseLabel // no translation for this phase: the stored label, never an empty line
},
"updateErrorText": func(st interface{}) string { return stackOf(st).UpdateErrorIn(lang) },
"holdText": func(st interface{}) string {
stk := stackOf(st)
if stk.HoldReason == "" || s.backupMgr == nil {
return stk.HoldReason
}
if held, why := s.backupMgr.RestoreHoldForLang(stk.Name, lang); held && why != "" {
return why
}
return stk.HoldReason
},
"timeAgo": func(t time.Time) string {
if t.IsZero() {
return "–"
@@ -0,0 +1,96 @@
package web
import (
"html"
"io"
"log"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// R-606 (v0.264.0) — every sentence of the update path reaches the page in the READER's language, on
// BOTH pages that carry it (the app page and the apps list): the phase label, the update error and the
// hold sentence. Asserted both ways — the English present AND the Hungarian gone — because a page
// carrying both would pass a presence check (the composed-sentence class, R-573/R-590/R-596/R-598).
//
// COMPANION RED-PROOF (REPORT.md): make localeFuncs' updateErrorText return .UpdateError — the English
// page carries the Hungarian sentence and this test fails.
func TestR606_UpdateSentencesFollowTheReader(t *testing.T) {
s := i18nTestServer(t)
b := backup.NewManager(s.cfg, s.settings, log.New(io.Discard, "", 0))
s.backupMgr = b
at := time.Date(2026, 9, 23, 8, 0, 0, 0, time.UTC)
if err := b.HoldAfterFailedUpdateHolding("kimai", at, at.Add(-6*time.Hour), backup.UpdateTierLocal, b.UpdateCopyHolds("kimai", backup.UpdateTierLocal), ""); err != nil {
t.Fatal(err)
}
_, holdHU := b.RestoreHoldForLang("kimai", "hu")
errStack := i18nStack("gokapi", "Go Kapi", stacks.StateRunning, true)
errStack.UpdateErrorKey, errStack.UpdateErrorArgs = "update.error.pull_failed", nil
errStack.UpdateError = stacks.MsgUpdatePullFailed
upd := i18nStack("romm", "Rom Manager", stacks.StateRunning, true)
upd.Updating, upd.UpdatePhase, upd.UpdatePhaseLabel = true, stacks.UpdatePhaseCopying, stacks.UpdatePhaseLabel(stacks.UpdatePhaseCopying)
held := i18nStack("kimai", "Kimai Time", stacks.StateStopped, true)
held.HoldReason = holdHU
pages := []i18nCase{
{"r606_list", "stacks", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Alkalmazások")
d["Stacks"] = []stacks.Stack{errStack, upd, held}
for _, k := range []string{"Subdomains", "MissingStorage", "NetworkStubs", "NetworkWarnings", "StorageLabels"} {
d[k] = map[string]string{}
}
return d
}},
{"r606_app_error", "app_info", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Go Kapi")
st := errStack
d["Stack"], d["Meta"], d["AppInfo"] = &st, st.Meta, st.Meta.AppInfo
return d
}},
{"r606_app_held", "app_info", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Kimai")
st := held
d["Stack"], d["Meta"], d["AppInfo"] = &st, st.Meta, st.Meta.AppInfo
return d
}},
{"r606_app_updating", "app_info", func() map[string]interface{} {
d := i18nLayoutData("stacks", "RomM")
st := upd
d["Stack"], d["Meta"], d["AppInfo"] = &st, st.Meta, st.Meta.AppInfo
return d
}},
}
type want struct{ en, hu string }
sentences := map[string][]want{
"r606_list": {
{util.Text("en", "update.error.pull_failed"), stacks.MsgUpdatePullFailed},
{"Copying the data before the update…", "Az adatok másolása a frissítés előtt…"},
{"own drive", "saját meghajtó"},
{"holds the settings, the database and the data volumes", "a beállításokat, az adatbázist és az adatköteteket tartalmazza"},
},
"r606_app_error": {{util.Text("en", "update.error.pull_failed"), stacks.MsgUpdatePullFailed}},
"r606_app_held": {{"The update of kimai at 2026-09-23 10:00 did not succeed", "A(z) kimai frissítése 2026-09-23 10:00-kor nem sikerült"}},
"r606_app_updating": {{"Copying the data before the update…", "Az adatok másolása a frissítés előtt…"}},
}
for _, c := range pages {
en := html.UnescapeString(renderI18nCase(t, s, "en", c))
hu := html.UnescapeString(renderI18nCase(t, s, "hu", c))
for _, w := range sentences[c.name] {
if !strings.Contains(en, w.en) {
t.Errorf("%s: the English page lacks %q", c.name, w.en)
}
if strings.Contains(en, w.hu) {
t.Errorf("%s: the Hungarian %q must be GONE from the English page", c.name, w.hu)
}
if !strings.Contains(hu, w.hu) {
t.Errorf("%s: the Hungarian page lacks %q", c.name, w.hu)
}
}
}
}
@@ -29,11 +29,11 @@
</div>
{{if .Stack.Updating}}
<div class="alert alert-info" style="margin-top:1rem" data-update-phase="{{.Stack.UpdatePhase}}">{{.Stack.UpdatePhaseLabel}}</div>
<div class="alert alert-info" style="margin-top:1rem" data-update-phase="{{.Stack.UpdatePhase}}">{{updatePhaseText .Stack}}</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">{{T "app_info.mentesek"}}</a></div>
<div class="alert alert-error" style="margin-top:1rem" data-held="true">{{holdText .Stack}} <a href="/backups/apps" class="btn btn-sm btn-outline">{{T "app_info.mentesek"}}</a></div>
{{else if .Stack.UpdateError}}
<div class="alert alert-warning" style="margin-top:1rem" data-update-error="true">{{.Stack.UpdateError}}</div>
<div class="alert alert-warning" style="margin-top:1rem" data-update-error="true">{{updateErrorText .Stack}}</div>
{{else if .UpdateUndoneLine}}
<div class="alert alert-info" style="margin-top:1rem" data-update-undone="true">{{.UpdateUndoneLine}}</div>
{{end}}
@@ -74,6 +74,14 @@
<input type="checkbox" name="event_app_start_failed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_start_failed"}}checked{{end}}{{end}}{{end}}>
<span class="toggle-label">{{T "settings_notifications.alkalmazas_nem_fut"}}</span>
</label>
<label class="toggle">
<input type="checkbox" name="event_app_update_undone" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_update_undone"}}checked{{end}}{{end}}{{end}}>
<span class="toggle-label">{{T "settings_notifications.app_update_undone"}}</span>
</label>
<label class="toggle">
<input type="checkbox" name="event_app_update_held" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_update_held"}}checked{{end}}{{end}}{{end}}>
<span class="toggle-label">{{T "settings_notifications.app_update_held"}}</span>
</label>
</div>
</div>
<div class="form-group">
@@ -91,17 +91,17 @@
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>
<span class="tag tag-progress" data-update-phase="{{.UpdatePhase}}"><span class="dot"></span>{{updatePhaseText .}}</span>
{{else if .HoldReason}}
<div class="alert alert-error" data-held="true">{{.HoldReason}} <a href="/backups/apps" class="btn btn-sm btn-outline">{{T "stacks.mentesek"}}</a></div>
<div class="alert alert-error" data-held="true">{{holdText .}} <a href="/backups/apps" class="btn btn-sm btn-outline">{{T "stacks.mentesek"}}</a></div>
{{if not .Orphaned}}<button class="btn btn-danger" onclick="removeStack('{{.Name}}')">{{T "common.eltavolitas"}}</button>{{end}}
{{else if isOperational .State}}
{{if .UpdateError}}<div class="alert alert-warning" data-update-error="true">{{.UpdateError}}</div>{{end}}
{{if .UpdateError}}<div class="alert alert-warning" data-update-error="true">{{updateErrorText .}}</div>{{end}}
{{if not .Orphaned}}<button class="btn btn-success" onclick="stackAction(event, '{{.Name}}', 'update')">{{T "stacks.frissites"}}</button>{{end}}
<button class="btn btn-warning" onclick="stackAction(event, '{{.Name}}', 'restart')">{{T "common.ujrainditas"}}</button>
<button class="btn btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')">{{T "stacks.leallitas"}}</button>
{{else}}
{{if .UpdateError}}<div class="alert alert-warning" data-update-error="true">{{.UpdateError}}</div>{{end}}
{{if .UpdateError}}<div class="alert alert-warning" data-update-error="true">{{updateErrorText .}}</div>{{end}}
<button class="btn btn-success" onclick="stackAction(event, '{{.Name}}', 'start')">{{T "stacks.inditas"}}</button>
{{if not .Orphaned}}<button class="btn btn-danger" onclick="removeStack('{{.Name}}')">{{T "common.eltavolitas"}}</button>{{end}}
{{end}}
@@ -240,6 +240,14 @@
<input type="checkbox" name="event_app_start_failed" >
<span class="toggle-label">Alkalmazás nem fut</span>
</label>
<label class="toggle">
<input type="checkbox" name="event_app_update_undone" >
<span class="toggle-label">Frissítés visszavonva (az alkalmazás a korábbi változattal fut)</span>
</label>
<label class="toggle">
<input type="checkbox" name="event_app_update_held" >
<span class="toggle-label">Frissítés sikertelen, az alkalmazás leállítva</span>
</label>
</div>
</div>
<div class="form-group">