controller v0.264.0: the household is told when an update is undone or held, in its language
gates / gates (push) Successful in 25s
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:
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user