controller v0.271.0: automatic app updates — the update leg after the off-site copy, the backup gate waits, the switch (09 6.4 part 7; R-680, R-678, R-643)
gates / gates (push) Successful in 24s

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-24 22:07:24 +02:00
parent 1495ca43fb
commit 9cf13a3add
33 changed files with 1672 additions and 14 deletions
+55
View File
@@ -839,6 +839,14 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
if found.Deployed && found.AppConfig != nil && found.AppConfig.LastUpdateUndone != nil {
data["UpdateUndoneLine"] = s.msg(r, "app_info.update_undone", found.Name, undoneWhen(found.AppConfig.LastUpdateUndone.At))
}
// v0.271.0 (`09` §6.4 part 7): a SUCCESSFUL automatic update is told on the page only — no mail.
// An undone or held automatic step keeps the lines above (they already say it), so this line is for
// `done` alone, and only while the app still runs what that step installed (a later manual update or
// a restore makes it history, and the page stops saying it).
if found.Deployed && found.AppConfig != nil && found.AppConfig.LastAutoUpdate != nil &&
found.AppConfig.LastAutoUpdate.Outcome == stacks.LegOutcomeDone && sameImageMap(found.AppConfig.LastAutoUpdate.To, found.AppConfig.PinnedImages) {
data["AutoUpdateLine"] = s.msg(r, "app_info.auto_update_done", undoneWhen(found.AppConfig.LastAutoUpdate.At))
}
s.executeTemplate(w, r, "app_info", data)
}
@@ -2144,6 +2152,9 @@ func (s *Server) systemPageData() map[string]interface{} {
data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase
data["HubEnabled"] = s.cfg.Hub.Enabled
// v0.271.0: the automatic app-update switch (absent = ON).
data["AppUpdateUnattended"] = s.settings == nil || s.settings.GetAppUpdateUnattended()
// Self-update status
data["SelfUpdateEnabled"] = s.cfg.SelfUpdate.Enabled
if s.updater != nil {
@@ -2824,6 +2835,37 @@ func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request)
s.executeTemplate(w, r, "settings_notifications", data)
}
// settingsAppUpdateHandler saves the per-box switch for AUTOMATIC app updates (v0.271.0, `09` §3
// decision 12). An unchecked box posts nothing, so absence of the field means OFF here — this is the
// household's explicit save, unlike the stored default (absent = ON). The leg reads the switch at its
// next start and before every press, so the choice takes effect before the next night.
func (s *Server) settingsAppUpdateHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
on := r.FormValue("app_update_unattended") == "on" || r.FormValue("app_update_unattended") == "true"
if s.settings == nil {
data := s.systemPageData()
data["AppUpdateError"] = s.msg(r, "settings.app_update_save_error")
s.executeTemplate(w, r, "settings_system", data)
return
}
if err := s.settings.SetAppUpdateUnattended(on); err != nil {
s.logger.Printf("[ERROR] [web] saving app_update.unattended=%v failed: %v", on, err)
data := s.systemPageData()
data["AppUpdateError"] = s.msg(r, "settings.app_update_save_error")
s.executeTemplate(w, r, "settings_system", data)
return
}
s.logger.Printf("[INFO] [web] automatic app updates switched %s by the household (app_update.unattended=%v)", map[bool]string{true: "ON", false: "OFF"}[on], on)
s.reportTriggerNow()
data := s.systemPageData()
if on {
data["AppUpdateSuccess"] = s.msg(r, "settings.app_update_on")
} else {
data["AppUpdateSuccess"] = s.msg(r, "settings.app_update_off")
}
s.executeTemplate(w, r, "settings_system", data)
}
func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) {
data := s.notificationsPageData()
@@ -3539,6 +3581,19 @@ func generateFileBrowserConfig(paths []settings.StoragePath, importSource bool)
// undoneWhen renders last_update_undone.at for the page line, in the box's time zone; the raw value
// when it does not parse (a record is never hidden for its format).
// sameImageMap reports whether two service→image maps are equal (nil equals empty).
func sameImageMap(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if b[k] != v {
return false
}
}
return true
}
func undoneWhen(rfc3339 string) string {
t, err := time.Parse(time.RFC3339, rfc3339)
if err != nil {