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
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:
@@ -0,0 +1,93 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.271.0 — the per-box switch for AUTOMATIC app updates (`09` §3 decision 12: ON by default) on the
|
||||
// settings page, in both languages, through the REAL router, handler and template; and the saved choice
|
||||
// read back from settings.json's accessor the leg reads.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): make GetAppUpdateUnattended return false for an absent key — the
|
||||
// first assertion fails at "a box that never saved the switch must show it ON".
|
||||
func TestAppUpdateSwitch_DefaultOnAndSaved(t *testing.T) {
|
||||
s := testPageServer(t)
|
||||
hu := html.UnescapeString(getPage(t, s, "/settings").Body.String())
|
||||
if !strings.Contains(hu, `data-app-update-unattended="true"`) || !strings.Contains(hu, "checked data-app-update-unattended") {
|
||||
t.Fatal("a box that never saved the switch must show it ON (decision 12)")
|
||||
}
|
||||
if !strings.Contains(hu, "Alkalmazások automatikus frissítése") || !strings.Contains(hu, "kipróbált lépéssel") {
|
||||
t.Error("the Hungarian page must carry the card's title and its one sentence")
|
||||
}
|
||||
en := html.UnescapeString(getPage(t, s, "/settings?lang=en").Body.String())
|
||||
if !strings.Contains(en, "Automatic app updates") || !strings.Contains(en, "only with a tested step") {
|
||||
t.Error("the English page must carry the card's title and its one sentence")
|
||||
}
|
||||
if strings.Contains(en, "kipróbált lépéssel") {
|
||||
t.Error("the Hungarian sentence must be GONE from the English page")
|
||||
}
|
||||
|
||||
// OFF: an unchecked box posts no field
|
||||
rec := postForm(t, s, "/settings/app-update", url.Values{})
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("POST off = %d", rec.Code)
|
||||
}
|
||||
if s.settings.GetAppUpdateUnattended() {
|
||||
t.Fatal("saving with the box unchecked must turn the switch OFF")
|
||||
}
|
||||
body := html.UnescapeString(rec.Body.String())
|
||||
if !strings.Contains(body, "kikapcsolva") || !strings.Contains(body, `data-app-update-unattended="false"`) {
|
||||
t.Error("the page after saving OFF must say so and show the box unchecked")
|
||||
}
|
||||
// ON again
|
||||
postForm(t, s, "/settings/app-update", url.Values{"app_update_unattended": {"on"}})
|
||||
if !s.settings.GetAppUpdateUnattended() {
|
||||
t.Fatal("saving with the box checked must turn the switch back ON")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoUpdateLine_PageSaysItOnce — a successful automatic step is told on the app page only, in the
|
||||
// request's language, and only while the app still runs what that step installed.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): drop the AutoUpdateLine block from appDetailHandler — this fails at
|
||||
// "the English page must carry the automatic-update line".
|
||||
func TestAutoUpdateLine_PageSaysItOnce(t *testing.T) {
|
||||
s, _ := credsHarness(t)
|
||||
sd := filepath.Join(s.cfg.Paths.StacksDir, "crafty")
|
||||
write := func(pin string) {
|
||||
app := "deployed: true\npinned_images:\n web: " + pin + "\nlast_auto_update:\n at: \"2026-09-25T02:21:00Z\"\n outcome: done\n to:\n web: crafty:2.0.0\n"
|
||||
if err := os.WriteFile(filepath.Join(sd, "app.yaml"), []byte(app), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = s.stackMgr.ScanStacks()
|
||||
}
|
||||
s.loadTemplates()
|
||||
render := func(lang string) string {
|
||||
rr := httptest.NewRecorder()
|
||||
s.appDetailHandler(rr, httptest.NewRequest("GET", "/apps/crafty?lang="+lang, nil), "crafty")
|
||||
return html.UnescapeString(rr.Body.String())
|
||||
}
|
||||
write("crafty:2.0.0")
|
||||
en, hu := render("en"), render("hu")
|
||||
const enLine, huLine = "Automatic update at", "Automatikus frissítés"
|
||||
if !strings.Contains(en, enLine) || !strings.Contains(en, "— done.") {
|
||||
t.Fatalf("the English page must carry the automatic-update line")
|
||||
}
|
||||
if !strings.Contains(hu, huLine+" ") || !strings.Contains(hu, "— sikeres.") || strings.Contains(hu, enLine) {
|
||||
t.Fatalf("the Hungarian page must carry its own line and not the English one")
|
||||
}
|
||||
if !strings.Contains(hu, `data-auto-update="done"`) {
|
||||
t.Error("the line must render in its own marked alert")
|
||||
}
|
||||
// a later manual update moved the pin: the line is history and goes
|
||||
write("crafty:2.1.0")
|
||||
if strings.Contains(render("en"), enLine) {
|
||||
t.Error("the line must go once the app no longer runs what the automatic step installed")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -674,6 +674,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.settingsNotificationsTestHandler(w, r)
|
||||
case path == "/settings/app-email" && r.Method == http.MethodPost:
|
||||
s.settingsAppEmailHandler(w, r)
|
||||
case path == "/settings/app-update" && r.Method == http.MethodPost:
|
||||
s.settingsAppUpdateHandler(w, r)
|
||||
case path == "/settings/storage/add" && r.Method == http.MethodPost:
|
||||
s.settingsStorageAddHandler(w, r)
|
||||
case path == "/settings/storage/remove" && r.Method == http.MethodPost:
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
<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>
|
||||
{{else if .AutoUpdateLine}}
|
||||
<div class="alert alert-info" style="margin-top:1rem" data-auto-update="done">{{.AutoUpdateLine}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .MissingStorageLabel}}
|
||||
|
||||
@@ -143,6 +143,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Automatic app updates (v0.271.0, `09` §3 decision 12) — the per-box switch, ON by default. -->
|
||||
<div class="settings-card" id="app-update">
|
||||
<h3>{{T "settings_system.app_update_title"}}</h3>
|
||||
<p class="settings-card-desc">{{T "settings_system.app_update_desc"}}</p>
|
||||
{{if .AppUpdateSuccess}}<div class="alert alert-info">{{.AppUpdateSuccess}}</div>{{end}}
|
||||
{{if .AppUpdateError}}<div class="alert alert-error">{{.AppUpdateError}}</div>{{end}}
|
||||
<form method="POST" action="/settings/app-update">
|
||||
{{.CSRFField}}
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:.5rem">
|
||||
<input type="checkbox" name="app_update_unattended" value="on" {{if .AppUpdateUnattended}}checked{{end}} data-app-update-unattended="{{.AppUpdateUnattended}}">
|
||||
{{T "settings_system.app_update_toggle"}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{T "settings_system.app_update_save"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Section: Network (R-66) — the box's own address. Every value is live-computed per render and
|
||||
stored NOWHERE (S-5: a DHCP lease persisted anywhere eventually misdirects people); a missing
|
||||
value renders „—" because an address-less row beats a wrong address. -->
|
||||
|
||||
@@ -266,6 +266,26 @@
|
||||
</div>
|
||||
|
||||
|
||||
<div class="settings-card" id="app-update">
|
||||
<h3>Alkalmazások automatikus frissítése</h3>
|
||||
<p class="settings-card-desc">Ha be van kapcsolva, a doboz minden éjjel, a távoli mentés után magától frissíti az alkalmazásaidat: egyszerre egyet, mindig csak kipróbált lépéssel, és ha valami nem sikerül, visszaállítja az előző változatot.</p>
|
||||
|
||||
|
||||
<form method="POST" action="/settings/app-update">
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:.5rem">
|
||||
<input type="checkbox" name="app_update_unattended" value="on" data-app-update-unattended="">
|
||||
Automatikus frissítés bekapcsolva
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Mentés</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="settings-card">
|
||||
<h3>Hálózat</h3>
|
||||
<div class="settings-grid">
|
||||
|
||||
@@ -306,6 +306,26 @@
|
||||
</div>
|
||||
|
||||
|
||||
<div class="settings-card" id="app-update">
|
||||
<h3>Alkalmazások automatikus frissítése</h3>
|
||||
<p class="settings-card-desc">Ha be van kapcsolva, a doboz minden éjjel, a távoli mentés után magától frissíti az alkalmazásaidat: egyszerre egyet, mindig csak kipróbált lépéssel, és ha valami nem sikerül, visszaállítja az előző változatot.</p>
|
||||
|
||||
|
||||
<form method="POST" action="/settings/app-update">
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:.5rem">
|
||||
<input type="checkbox" name="app_update_unattended" value="on" data-app-update-unattended="">
|
||||
Automatikus frissítés bekapcsolva
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Mentés</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="settings-card">
|
||||
<h3>Hálózat</h3>
|
||||
<div class="settings-grid">
|
||||
|
||||
@@ -223,6 +223,26 @@
|
||||
</div>
|
||||
|
||||
|
||||
<div class="settings-card" id="app-update">
|
||||
<h3>Alkalmazások automatikus frissítése</h3>
|
||||
<p class="settings-card-desc">Ha be van kapcsolva, a doboz minden éjjel, a távoli mentés után magától frissíti az alkalmazásaidat: egyszerre egyet, mindig csak kipróbált lépéssel, és ha valami nem sikerül, visszaállítja az előző változatot.</p>
|
||||
|
||||
|
||||
<form method="POST" action="/settings/app-update">
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:.5rem">
|
||||
<input type="checkbox" name="app_update_unattended" value="on" data-app-update-unattended="">
|
||||
Automatikus frissítés bekapcsolva
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Mentés</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="settings-card">
|
||||
<h3>Hálózat</h3>
|
||||
<div class="settings-grid">
|
||||
|
||||
@@ -256,6 +256,26 @@
|
||||
</div>
|
||||
|
||||
|
||||
<div class="settings-card" id="app-update">
|
||||
<h3>Alkalmazások automatikus frissítése</h3>
|
||||
<p class="settings-card-desc">Ha be van kapcsolva, a doboz minden éjjel, a távoli mentés után magától frissíti az alkalmazásaidat: egyszerre egyet, mindig csak kipróbált lépéssel, és ha valami nem sikerül, visszaállítja az előző változatot.</p>
|
||||
|
||||
|
||||
<form method="POST" action="/settings/app-update">
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:.5rem">
|
||||
<input type="checkbox" name="app_update_unattended" value="on" data-app-update-unattended="">
|
||||
Automatikus frissítés bekapcsolva
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Mentés</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="settings-card">
|
||||
<h3>Hálózat</h3>
|
||||
<div class="settings-grid">
|
||||
|
||||
Reference in New Issue
Block a user