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:
@@ -19,6 +19,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||
@@ -373,12 +374,27 @@ func (r *Router) rescanStacks(w http.ResponseWriter, req *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) {
|
||||
func (r *Router) getStack(w http.ResponseWriter, req *http.Request, name string) {
|
||||
stack, ok := r.stackMgr.GetStack(name)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + name})
|
||||
return
|
||||
}
|
||||
// v0.264.0 (R-606): the page polls this for the Update button's label and the outcome. Rendered in
|
||||
// the reader's language; a Hungarian reader gets exactly the bytes it always got. GetStack returns a
|
||||
// COPY, so nothing here touches the manager's own state.
|
||||
if lang := r.langFor(req); lang != i18n.Default {
|
||||
stack.UpdatePhaseLabel = stacks.UpdatePhaseLabelIn(lang, stack.UpdatePhase)
|
||||
stack.UpdateError = stack.UpdateErrorIn(lang)
|
||||
if stack.HoldReason != "" && r.backupMgr != nil {
|
||||
if held, why := r.backupMgr.RestoreHoldForLang(name, lang); held && why != "" {
|
||||
stack.HoldReason = why
|
||||
if stack.UpdateErrorKey == "" && stack.UpdatePhase == stacks.UpdatePhaseFailed {
|
||||
stack.UpdateError = why // a held update's UpdateError IS the hold sentence
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack})
|
||||
}
|
||||
|
||||
|
||||
@@ -330,6 +330,13 @@ func (m *Manager) pruneUndoCopies(dumpDir, stack string) {
|
||||
// on a misconfigured box. The write side logs loudly when it cannot persist (see
|
||||
// holdAppAfterFailedRollback), which is where that case is caught.
|
||||
func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
|
||||
return m.RestoreHoldForLang(stack, m.boxLang())
|
||||
}
|
||||
|
||||
// RestoreHoldForLang is RestoreHoldFor with the sentence in lang (v0.264.0, R-606). The page and the
|
||||
// household mail ask for the READER's language; every other caller takes the box's. The Hungarian is
|
||||
// byte-identical to the literals it replaced (UpdateHoldFmt & co. — TestR606_HoldSentenceHungarianUnchanged).
|
||||
func (m *Manager) RestoreHoldForLang(stack, lang string) (bool, string) {
|
||||
if m == nil || m.settings == nil {
|
||||
return false, ""
|
||||
}
|
||||
@@ -340,43 +347,44 @@ func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
|
||||
// Slice 4: one storage, two reasons. An update hold names the copy it can be restored from; a
|
||||
// restore hold names nothing, because the restore it refers to already consumed the copy.
|
||||
if h.Reason == settings.HoldReasonUpdateFailed {
|
||||
return true, m.undoHoldPrefix(h.UndoState) + m.updateHoldSentence(stack, h)
|
||||
return true, m.undoHoldPrefix(lang, h.UndoState) + updateHoldSentence(lang, stack, h)
|
||||
}
|
||||
when := h.At
|
||||
if t, err := time.Parse(time.RFC3339, h.At); err == nil {
|
||||
when = t.Format("2006-01-02 15:04")
|
||||
}
|
||||
return true, m.note("note.reconstitute.held", stack, when)
|
||||
return true, util.Text(lang, "note.reconstitute.held", stack, when)
|
||||
}
|
||||
|
||||
// undoHoldPrefix (v0.263.0) opens the hold sentence when the box already TRIED to undo the update and
|
||||
// that failed too: what was tried, then what state the data is in. "" when no undo was attempted, so
|
||||
// every hold written before v0.263.0 reads exactly as it did.
|
||||
func (m *Manager) undoHoldPrefix(state string) string {
|
||||
func (m *Manager) undoHoldPrefix(lang, state string) string {
|
||||
switch state {
|
||||
case "untouched", "half", "not_started":
|
||||
return m.note("hold.update.undo_failed") + " " + m.note("hold.update.undo_state."+state) + " "
|
||||
return util.Text(lang, "hold.update.undo_failed") + " " + util.Text(lang, "hold.update.undo_state."+state) + " "
|
||||
case "":
|
||||
return ""
|
||||
}
|
||||
m.logger.Printf("[WARN] [backup] unknown undo state %q on a hold — rendering the plain prefix", state)
|
||||
return m.note("hold.update.undo_failed") + " "
|
||||
return util.Text(lang, "hold.update.undo_failed") + " "
|
||||
}
|
||||
|
||||
// updateHoldSentence is the update hold's own sentence (slice 4, R-475, R-479), unchanged.
|
||||
func (m *Manager) updateHoldSentence(stack string, h settings.RestoreHold) string {
|
||||
copyDate := m.note("note.reconstitute.copy_latest")
|
||||
// updateHoldSentence is the update hold's own sentence (slice 4, R-475, R-479) in lang. Its Hungarian
|
||||
// is UpdateHoldFmt / UpdateHoldTierFmt / UpdateHoldLegacyFmt byte for byte (pinned by a test).
|
||||
func updateHoldSentence(lang, stack string, h settings.RestoreHold) string {
|
||||
copyDate := util.Text(lang, "note.reconstitute.copy_latest")
|
||||
if h.CopyDate != "" {
|
||||
copyDate = fmtHoldTime(h.CopyDate)
|
||||
}
|
||||
// R-475: name the tier when the hold recorded one; an older hold keeps its own sentence.
|
||||
if label := UpdateTierLabel(h.CopyTier); label != "" && h.CopyDate != "" {
|
||||
if label := UpdateTierLabelIn(lang, h.CopyTier); label != "" && h.CopyDate != "" {
|
||||
if h.CopyHolds != "" { // R-479: name what the copy holds
|
||||
return fmt.Sprintf(UpdateHoldFmt, stack, fmtHoldTime(h.At), label, copyDate, h.CopyHolds)
|
||||
return util.Text(lang, "hold.update.sentence", stack, fmtHoldTime(h.At), label, copyDate, copyHoldsIn(lang, h.CopyHolds))
|
||||
}
|
||||
return fmt.Sprintf(UpdateHoldTierFmt, stack, fmtHoldTime(h.At), label, copyDate)
|
||||
return util.Text(lang, "hold.update.sentence_tier", stack, fmtHoldTime(h.At), label, copyDate)
|
||||
}
|
||||
return fmt.Sprintf(UpdateHoldLegacyFmt, stack, fmtHoldTime(h.At), copyDate)
|
||||
return util.Text(lang, "hold.update.sentence_legacy", stack, fmtHoldTime(h.At), copyDate)
|
||||
}
|
||||
|
||||
// holdAppAfterFailedRollback records the R-379/R-380 hold and makes sure nothing restarts the app
|
||||
|
||||
@@ -45,6 +45,11 @@ func TestUndo_HoldSentenceSaysTheUndoWasTriedAndTheDataState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A KNOWN state before the language check — the loop above ranges over a map, so its last state is
|
||||
// random (this test read "half" and failed once for exactly that reason, 2026-09-23).
|
||||
if err := m.HoldAfterFailedUpdateHolding("app", at, copyAt, UpdateTierSecondDrive, "", "not_started"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.settings.SetLanguage("en"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -52,7 +57,11 @@ func TestUndo_HoldSentenceSaysTheUndoWasTriedAndTheDataState(t *testing.T) {
|
||||
if !strings.HasPrefix(why, "The update did not succeed, and the automatic undo did not either. The data is back as it was before the update") {
|
||||
t.Errorf("an English box gets the English prefix, got %q", why)
|
||||
}
|
||||
if strings.Contains(why, "automatikus visszaállítás") {
|
||||
t.Errorf("the Hungarian prefix must be GONE on an English box, got %q", why)
|
||||
// v0.264.0 (R-606): the WHOLE sentence is English now, not only the prefix.
|
||||
if !strings.Contains(why, "The update of app at 2026-09-23 10:00 did not succeed") || !strings.Contains(why, "second drive, 2026-09-23 03:30") {
|
||||
t.Errorf("the hold's own sentence must be English on an English box, got %q", why)
|
||||
}
|
||||
if strings.Contains(why, "automatikus visszaállítás") || strings.Contains(why, "frissítése") || strings.Contains(why, "meghajtó") {
|
||||
t.Errorf("no Hungarian may remain on an English box, got %q", why)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -142,37 +143,53 @@ func (m *Manager) UpdateTierOrderFor(stackName string) []int {
|
||||
|
||||
// UpdateCopyHolds is the customer phrase for what a copy on `tier` holds for this app — the second half
|
||||
// of the R-479 ruling: the hold sentence names WHAT the chosen copy holds, not only where it is.
|
||||
//
|
||||
// v0.264.0 (R-606): the phrase is a bundle key — it is a PROMISE ABOUT WHETHER THE HOUSEHOLD'S FILES
|
||||
// COME BACK and must reach an English household in English. The value returned (and stored in the
|
||||
// hold) is still the Hungarian, byte for byte; copyHoldsIn maps it back to its key at render time, so
|
||||
// a hold written by any earlier version localises too.
|
||||
func (m *Manager) UpdateCopyHolds(stackName string, tier int) string {
|
||||
outside := m.DataOutsideUnit(stackName)
|
||||
return util.Text(i18n.Default, updateCopyHoldsKey(m.DataOutsideUnit(stackName), tier))
|
||||
}
|
||||
|
||||
func updateCopyHoldsKey(outside bool, tier int) string {
|
||||
switch tier {
|
||||
case UpdateTierLocal:
|
||||
if outside {
|
||||
return "csak a beállításokat és az adatbázist tartalmazza, a fájlokat nem"
|
||||
return "hold.copy_holds.db_only"
|
||||
}
|
||||
return "a beállításokat, az adatbázist és az adatköteteket tartalmazza"
|
||||
case UpdateTierSecondDrive:
|
||||
return "hold.copy_holds.volumes"
|
||||
case UpdateTierSecondDrive, UpdateTierOffsite:
|
||||
if outside {
|
||||
return "a beállításokat, az adatbázist és a fájlokat tartalmazza"
|
||||
return "hold.copy_holds.files"
|
||||
}
|
||||
return "a beállításokat, az adatbázist és az adatköteteket tartalmazza"
|
||||
case UpdateTierOffsite:
|
||||
if outside {
|
||||
return "a beállításokat, az adatbázist és a fájlokat tartalmazza"
|
||||
}
|
||||
return "a beállításokat, az adatbázist és az adatköteteket tartalmazza"
|
||||
return "hold.copy_holds.volumes"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UpdateTierLabel is a tier's name in the customer's hold sentence. "" for an unknown tier.
|
||||
func UpdateTierLabel(tier int) string {
|
||||
// copyHoldKeys are the phrases a hold may have stored; the stored value is the Hungarian.
|
||||
var copyHoldKeys = []string{"hold.copy_holds.db_only", "hold.copy_holds.volumes", "hold.copy_holds.files"}
|
||||
|
||||
// copyHoldsIn renders a STORED copy-holds phrase in lang. An unknown phrase is returned as stored —
|
||||
// never an empty clause (a hold must not lose the sentence that says what the copy holds).
|
||||
func copyHoldsIn(lang, stored string) string {
|
||||
for _, k := range copyHoldKeys {
|
||||
if util.Text(i18n.Default, k) == stored {
|
||||
return util.Text(lang, k)
|
||||
}
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
// UpdateTierLabel is a tier's name in the customer's hold sentence (Hungarian). "" for an unknown tier.
|
||||
func UpdateTierLabel(tier int) string { return UpdateTierLabelIn(i18n.Default, tier) }
|
||||
|
||||
// UpdateTierLabelIn is UpdateTierLabel in lang (v0.264.0, R-606).
|
||||
func UpdateTierLabelIn(lang string, tier int) string {
|
||||
switch tier {
|
||||
case UpdateTierSecondDrive:
|
||||
return "második meghajtó"
|
||||
case UpdateTierLocal:
|
||||
return "saját meghajtó"
|
||||
case UpdateTierOffsite:
|
||||
return "távoli mentés"
|
||||
case UpdateTierSecondDrive, UpdateTierLocal, UpdateTierOffsite:
|
||||
return util.Text(lang, fmt.Sprintf("hold.update.tier.%d", tier))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1356,6 +1356,7 @@
|
||||
"event.app_deploy_started": "App install started: %s",
|
||||
"event.app_deployed": "App installed: %s",
|
||||
"event.app_removed": "App removed: %s",
|
||||
"event.app_update_undone": "The update of %s at %s did not work. The box put back the previous version and its data automatically — nothing was lost, and there is nothing you need to do.",
|
||||
"event.backup_target_absent": "The whole-system backup drive is not available: %s (%s)",
|
||||
"event.backup_target_restored": "The whole-system backup drive is available again: %s (%s)",
|
||||
"event.controller_started": "Controller started (%s)",
|
||||
@@ -1477,6 +1478,15 @@
|
||||
"func.time.tomorrow_at": "tomorrow %s",
|
||||
"func.time.yesterday": "yesterday",
|
||||
"health.no_probe_container": "No health check ran: no matching container.",
|
||||
"hold.copy_holds.db_only": "holds only the settings and the database, not the files",
|
||||
"hold.copy_holds.files": "holds the settings, the database and the files",
|
||||
"hold.copy_holds.volumes": "holds the settings, the database and the data volumes",
|
||||
"hold.update.sentence": "The update of %s at %s did not succeed, and the app did not start on the new version. The app stays stopped for safety, so that its data is not damaged. It can be restored on the Backups page from this backup: %s, %s — this copy %s.",
|
||||
"hold.update.sentence_legacy": "The update of %s at %s did not succeed, and the app did not start on the new version. The app stays stopped for safety, so that its data is not damaged. It can be restored on the Backups page from the backup of %s.",
|
||||
"hold.update.sentence_tier": "The update of %s at %s did not succeed, and the app did not start on the new version. The app stays stopped for safety, so that its data is not damaged. It can be restored on the Backups page from this backup: %s, %s.",
|
||||
"hold.update.tier.1": "own drive",
|
||||
"hold.update.tier.2": "second drive",
|
||||
"hold.update.tier.3": "remote backup",
|
||||
"hold.update.undo_failed": "The update did not succeed, and the automatic undo did not either.",
|
||||
"hold.update.undo_state.half": "Putting the data back stopped part-way, so the data is in a mixed state; the copy taken before the update is kept.",
|
||||
"hold.update.undo_state.not_started": "The data is back as it was before the update, but the previous version did not start.",
|
||||
@@ -1808,6 +1818,8 @@
|
||||
"settings_notifications.alkalmazas_email": "App e-mail",
|
||||
"settings_notifications.alkalmazas_email_engedelyezese": "Allow app e-mail",
|
||||
"settings_notifications.alkalmazas_nem_fut": "App not running",
|
||||
"settings_notifications.app_update_held": "Update failed, the app is stopped",
|
||||
"settings_notifications.app_update_undone": "Update undone (the app runs on its previous version)",
|
||||
"settings_notifications.az_alkalmazasok_a_felhom_on": "Apps can send e-mail through Felhom (for example password resets, invitations),\n without setting up a separate e-mail provider. Each app’s sender address is its own\n <em><app>@felhom.eu</em> address.",
|
||||
"settings_notifications.az_ertesitesek_a_kozponti_rendszeren": "Notifications work through the central system, which is not turned on right now.",
|
||||
"settings_notifications.beallitasok_ertesitesek": "Settings — Notifications",
|
||||
@@ -2344,7 +2356,32 @@
|
||||
"tier2_config.nincs_elerheto_off_drive_cel": "No off-drive target available",
|
||||
"tier2_config.nincs_masik_adatmeghajto_automatikus_cel": "No other data drive — the automatic target is the internal SSD (database/configuration only). Add a 2nd\n data drive to back up all data off-drive too.",
|
||||
"tier2_config.vissza_a_mentesekhez": "← Back to backups",
|
||||
"update.error.backup_failed": "The update did not start, because the backup before the update did not succeed: %v. The app keeps running unchanged.",
|
||||
"update.error.backup_no_unit": "The update did not start: the backup before the update ran, but no fresh copy that can be restored was made. The app keeps running unchanged.",
|
||||
"update.error.dump_failed": "The update did not start, because the database snapshot was not made: %v. The app keeps running unchanged.",
|
||||
"update.error.hold_unsaved": "The update did not succeed and the app was stopped, but saving that it is stopped did not succeed. Do not start it again — contact us.",
|
||||
"update.error.interrupted": "The update was interrupted, because the controller restarted before the new version started. The app keeps running on its previous version.",
|
||||
"update.error.journal_failed": "The update did not start: the update's journal cannot be saved. The app keeps running unchanged.",
|
||||
"update.error.no_guards": "The update cannot start: the safety check before an update is not available on this server.",
|
||||
"update.error.pin_failed": "The update did not start: the description of the new version cannot be read. The app keeps running unchanged.",
|
||||
"update.error.pull_failed": "Downloading the new version did not succeed, so the update did not happen. The app keeps running on its previous version.",
|
||||
"update.phase.backing-up": "Making a backup before the update…",
|
||||
"update.phase.checking": "Checking…",
|
||||
"update.phase.copying": "Copying the data before the update…",
|
||||
"update.phase.done": "Updated",
|
||||
"update.phase.failed": "The update did not succeed",
|
||||
"update.phase.pinning": "Downloading the new version…",
|
||||
"update.phase.pulling": "Downloading the new version…",
|
||||
"update.phase.safety-dump": "Database snapshot…",
|
||||
"update.phase.starting": "Starting the new version…",
|
||||
"update.phase.undoing": "Putting the previous version back…",
|
||||
"update.phase.undone": "Put back to the previous version"
|
||||
"update.phase.undone": "Put back to the previous version",
|
||||
"update.phase.verifying": "Checking that it works…",
|
||||
"update.refusal.already": "An update of %s is already in progress.",
|
||||
"update.refusal.busy": "The update cannot start right now: a backup or restore is running. Try again when it has finished.",
|
||||
"update.refusal.deploying": "%s is still being installed — the update can start after that.",
|
||||
"update.refusal.disk": "Not enough free space for the update: %.1f GB free, and at least %.0f GB is needed to download the new version.",
|
||||
"update.refusal.migrating": "The update cannot start right now: data is being moved.",
|
||||
"update.refusal.no_backup": "%s cannot be updated, because it has no backup it could be restored from, and no new backup can be made of it now. Check on the Backups page that the app's drive is available — after that the update can start.",
|
||||
"update.refusal.not_deployed": "The app is not installed, so it cannot be updated."
|
||||
}
|
||||
|
||||
@@ -1347,6 +1347,7 @@
|
||||
"event.app_deploy_started": "Alkalmazás telepítése elindult: %s",
|
||||
"event.app_deployed": "Alkalmazás telepítve: %s",
|
||||
"event.app_removed": "Alkalmazás eltávolítva: %s",
|
||||
"event.app_update_undone": "A(z) %s frissítése %s-kor nem sikerült. A doboz automatikusan visszaállította az előző változatot és az adatokat — semmi nem veszett el, nincs teendőd.",
|
||||
"event.backup_target_absent": "A rendszermentés meghajtója nem érhető el: %s (%s)",
|
||||
"event.backup_target_restored": "A rendszermentés meghajtója újra elérhető: %s (%s)",
|
||||
"event.controller_started": "Controller elindult (%s)",
|
||||
@@ -1465,6 +1466,15 @@
|
||||
"func.time.tomorrow_at": "holnap %s",
|
||||
"func.time.yesterday": "tegnap",
|
||||
"health.no_probe_container": "Nem futott egészségellenőrzés: nincs hozzá tartozó konténer.",
|
||||
"hold.copy_holds.db_only": "csak a beállításokat és az adatbázist tartalmazza, a fájlokat nem",
|
||||
"hold.copy_holds.files": "a beállításokat, az adatbázist és a fájlokat tartalmazza",
|
||||
"hold.copy_holds.volumes": "a beállításokat, az adatbázist és az adatköteteket tartalmazza",
|
||||
"hold.update.sentence": "A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. Visszaállítható a Mentések oldalon ebből a biztonsági mentésből: %s, %s — ez a másolat %s.",
|
||||
"hold.update.sentence_legacy": "A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. Visszaállítható a(z) %s-i biztonsági mentésből a Mentések oldalon.",
|
||||
"hold.update.sentence_tier": "A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. Visszaállítható a Mentések oldalon ebből a biztonsági mentésből: %s, %s.",
|
||||
"hold.update.tier.1": "saját meghajtó",
|
||||
"hold.update.tier.2": "második meghajtó",
|
||||
"hold.update.tier.3": "távoli mentés",
|
||||
"hold.update.undo_failed": "A frissítés nem sikerült, és az automatikus visszaállítás sem.",
|
||||
"hold.update.undo_state.half": "Az adatok visszamásolása félbeszakadt, ezért az adatok vegyes állapotban vannak; a frissítés előtti másolat megmaradt.",
|
||||
"hold.update.undo_state.not_started": "Az adatok a frissítés előtti állapotba kerültek vissza, de az előző változat nem indult el.",
|
||||
@@ -1796,6 +1806,8 @@
|
||||
"settings_notifications.alkalmazas_email": "Alkalmazás-email",
|
||||
"settings_notifications.alkalmazas_email_engedelyezese": "Alkalmazás-email engedélyezése",
|
||||
"settings_notifications.alkalmazas_nem_fut": "Alkalmazás nem fut",
|
||||
"settings_notifications.app_update_held": "Frissítés sikertelen, az alkalmazás leállítva",
|
||||
"settings_notifications.app_update_undone": "Frissítés visszavonva (az alkalmazás a korábbi változattal fut)",
|
||||
"settings_notifications.az_alkalmazasok_a_felhom_on": "Az alkalmazások a Felhom-on keresztül küldhetnek emailt (pl. jelszó-visszaállítás, meghívók),\n külön email-szolgáltató beállítása nélkül. A feladó címe minden alkalmazásnál a saját\n <em><alkalmazás>@felhom.eu</em> címe lesz.",
|
||||
"settings_notifications.az_ertesitesek_a_kozponti_rendszeren": "Az értesítések a központi rendszeren keresztül működnek, ami jelenleg nincs bekapcsolva.",
|
||||
"settings_notifications.beallitasok_ertesitesek": "Beállítások — Értesítések",
|
||||
@@ -2332,7 +2344,32 @@
|
||||
"tier2_config.nincs_elerheto_off_drive_cel": "Nincs elérhető off-drive cél",
|
||||
"tier2_config.nincs_masik_adatmeghajto_automatikus_cel": "Nincs másik adatmeghajtó — automatikus cél a belső SSD (csak DB/konfiguráció). Egy 2.\n adatmeghajtó hozzáadásával a teljes adat is off-drive menthető.",
|
||||
"tier2_config.vissza_a_mentesekhez": "← Vissza a mentésekhez",
|
||||
"update.error.backup_failed": "A frissítés nem indult el, mert a frissítés előtti biztonsági mentés nem sikerült: %v. Az alkalmazás változatlanul fut tovább.",
|
||||
"update.error.backup_no_unit": "A frissítés nem indult el: a frissítés előtti mentés lefutott, de nem jött létre friss, visszaállítható másolat. Az alkalmazás változatlanul fut tovább.",
|
||||
"update.error.dump_failed": "A frissítés nem indult el, mert az adatbázis pillanatkép nem készült el: %v. Az alkalmazás változatlanul fut tovább.",
|
||||
"update.error.hold_unsaved": "A frissítés nem sikerült, az alkalmazás le lett állítva, de a leállítás rögzítése nem sikerült. Ne indítsd újra — vedd fel velünk a kapcsolatot.",
|
||||
"update.error.interrupted": "A frissítés megszakadt, mert a vezérlő újraindult, mielőtt az új verzió elindult volna. Az alkalmazás a korábbi verzióval fut tovább.",
|
||||
"update.error.journal_failed": "A frissítés nem indult el: a frissítés naplója nem menthető. Az alkalmazás változatlanul fut tovább.",
|
||||
"update.error.no_guards": "A frissítés nem indítható: a frissítés előtti biztonsági ellenőrzés nem érhető el ezen a szerveren.",
|
||||
"update.error.pin_failed": "A frissítés nem indult el: az új verzió leírása nem olvasható be. Az alkalmazás változatlanul fut tovább.",
|
||||
"update.error.pull_failed": "Az új verzió letöltése nem sikerült, ezért a frissítés elmaradt. Az alkalmazás a korábbi verzióval fut tovább.",
|
||||
"update.phase.backing-up": "Biztonsági mentés készül a frissítés előtt…",
|
||||
"update.phase.checking": "Ellenőrzés…",
|
||||
"update.phase.copying": "Az adatok másolása a frissítés előtt…",
|
||||
"update.phase.done": "Frissítve",
|
||||
"update.phase.failed": "A frissítés nem sikerült",
|
||||
"update.phase.pinning": "Új verzió letöltése…",
|
||||
"update.phase.pulling": "Új verzió letöltése…",
|
||||
"update.phase.safety-dump": "Adatbázis pillanatkép…",
|
||||
"update.phase.starting": "Indítás az új verzióval…",
|
||||
"update.phase.undoing": "Visszaállítás az előző változatra…",
|
||||
"update.phase.undone": "Visszaállítva az előző változatra"
|
||||
"update.phase.undone": "Visszaállítva az előző változatra",
|
||||
"update.phase.verifying": "Működés ellenőrzése…",
|
||||
"update.refusal.already": "A(z) %s frissítése már folyamatban van.",
|
||||
"update.refusal.busy": "A frissítés most nem indítható: mentés/visszaállítás folyamatban. Próbáld újra, ha befejeződött.",
|
||||
"update.refusal.deploying": "A(z) %s telepítése még folyamatban van — a frissítés utána indítható.",
|
||||
"update.refusal.disk": "Nincs elég szabad hely a frissítéshez: %.1f GB szabad, az új verzió letöltéséhez legalább %.0f GB szükséges.",
|
||||
"update.refusal.migrating": "A frissítés most nem indítható: adatáthelyezés folyamatban.",
|
||||
"update.refusal.no_backup": "A(z) %s nem frissíthető, mert nincs olyan biztonsági mentése, amelyből vissza lehetne állítani, és most új mentés sem készíthető róla. Ellenőrizd a Mentések oldalon, hogy az alkalmazás meghajtója elérhető-e — utána a frissítés elindítható.",
|
||||
"update.refusal.not_deployed": "Az alkalmazás nincs telepítve, ezért nem frissíthető."
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ type Notifier struct {
|
||||
settings *settings.Settings
|
||||
|
||||
mu sync.Mutex
|
||||
prevHealthStatus string // tracks previous health check status for change detection
|
||||
prevHealthStatus string // tracks previous health check status for change detection
|
||||
droppedSeen map[string]bool // R-620: event types a DISABLED notifier has already said it dropped
|
||||
|
||||
// oomSeen (R-514) remembers container runs already reported as OOM-killed.
|
||||
oomSeen map[string]bool
|
||||
@@ -267,6 +268,7 @@ func (n *Notifier) pushEventBoth(eventType, severity, message, messageCustomer s
|
||||
return
|
||||
}
|
||||
if !n.enabled {
|
||||
n.dropped(eventType, severity)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -357,6 +359,7 @@ func (n *Notifier) pushEventBoth(eventType, severity, message, messageCustomer s
|
||||
// Detects both degradation (ok→warn, ok→fail, warn→fail) and recovery (fail→ok, warn→ok, fail→warn).
|
||||
func (n *Notifier) NotifyHealthChange(status string, issues, warnings []string) {
|
||||
if !n.enabled {
|
||||
n.dropped("health_change", status)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1056,6 +1059,7 @@ type notifyRequest struct {
|
||||
// No local cooldown — Hub handles cooldowns.
|
||||
func (n *Notifier) Notify(eventType, severity, message, details string) {
|
||||
if !n.enabled {
|
||||
n.dropped(eventType, severity)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1138,3 +1142,72 @@ func (n *Notifier) NotifyWholeGuestBackupRecovered(tier, message string) {
|
||||
n.PushEvent("whole_guest_backup_recovered", "info", message,
|
||||
WholeGuestBackupDetails{Tier: tier})
|
||||
}
|
||||
|
||||
// dropped is R-620: a DISABLED notifier (no hub configured) says what it drops. Once per event type per
|
||||
// process at WARN — naming the type and the severity — and DEBUG for every repeat, so a box whose hub
|
||||
// configuration is absent or broken leaves a greppable trace where the alarm vanished, instead of one
|
||||
// INFO line at its last start. Measured 2026-09-21 on guest 9202: an update night's whole event half
|
||||
// was unmeasurable because every event vanished without a word.
|
||||
func (n *Notifier) dropped(eventType, severity string) {
|
||||
n.mu.Lock()
|
||||
if n.droppedSeen == nil {
|
||||
n.droppedSeen = map[string]bool{}
|
||||
}
|
||||
first := !n.droppedSeen[eventType]
|
||||
n.droppedSeen[eventType] = true
|
||||
n.mu.Unlock()
|
||||
if n.logger == nil {
|
||||
return
|
||||
}
|
||||
if first {
|
||||
n.logger.Printf("[WARN] notifier disabled (no hub configured): DROPPED event %s (severity %s) — further %s events are logged at DEBUG only", eventType, severity, eventType)
|
||||
return
|
||||
}
|
||||
if n.debug {
|
||||
n.logger.Printf("[DEBUG] notifier disabled: dropped event %s (severity %s)", eventType, severity)
|
||||
}
|
||||
}
|
||||
|
||||
// AppUpdateDetails is the payload of app_update_undone / app_update_held (v0.264.0). `stack_name` is
|
||||
// what the hub's per-app cooldown keys on (R-389's register), so two apps on one night are two mails.
|
||||
type AppUpdateDetails struct {
|
||||
App string `json:"app"`
|
||||
StackName string `json:"stack_name"`
|
||||
From map[string]string `json:"from,omitempty"`
|
||||
To map[string]string `json:"to,omitempty"`
|
||||
At string `json:"at"`
|
||||
CopyTier int `json:"copy_tier,omitempty"`
|
||||
CopyDate string `json:"copy_date,omitempty"`
|
||||
CopyHolds string `json:"copy_holds,omitempty"`
|
||||
}
|
||||
|
||||
// budapestMinute renders a time as the household reads it everywhere else (the hold sentence, the
|
||||
// page): Europe/Budapest, minute precision.
|
||||
func budapestMinute(t time.Time) string {
|
||||
if loc, err := time.LoadLocation("Europe/Budapest"); err == nil {
|
||||
t = t.In(loc)
|
||||
}
|
||||
return t.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// NotifyAppUpdateUndone (v0.264.0, `09` §3 decision 15): the box put a failed update back by itself.
|
||||
// Severity warning: nothing was lost and nothing needs doing, but the household asked for a new version
|
||||
// and did not get it. The sentence is ONE bundle key rendered twice (R-558).
|
||||
func (n *Notifier) NotifyAppUpdateUndone(app string, from, to map[string]string, at time.Time) {
|
||||
when := budapestMinute(at)
|
||||
n.pushEventMsg("app_update_undone", "warning", "event.app_update_undone",
|
||||
AppUpdateDetails{App: app, StackName: app, From: from, To: to, At: at.UTC().Format(time.RFC3339)}, app, when)
|
||||
}
|
||||
|
||||
// NotifyAppUpdateHeld (v0.264.0): the update — or its undo — failed and the app is HELD STOPPED.
|
||||
// `sentence` renders the hold's OWN sentence in a language (backup.RestoreHoldForLang, wired in
|
||||
// main.go), so the mail says exactly what the page says: what happened, which copy brings it back,
|
||||
// and what that copy holds. Severity error: the household must act (a restore).
|
||||
func (n *Notifier) NotifyAppUpdateHeld(d AppUpdateDetails, sentence func(lang string) string) {
|
||||
hu := sentence(i18n.Default)
|
||||
household := ""
|
||||
if lang := n.boxLang(); lang != i18n.Default {
|
||||
household = sentence(lang)
|
||||
}
|
||||
n.pushEventBoth("app_update_held", "error", hu, household, d)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// v0.264.0 — the two update events on the wire, and R-620.
|
||||
|
||||
type pushRec struct {
|
||||
typ, sev, msg, cust string
|
||||
details interface{}
|
||||
}
|
||||
|
||||
func recNotifier(t *testing.T, lang string) (*Notifier, *[]pushRec) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(&buf, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lang != "hu" {
|
||||
if err := sett.SetLanguage(lang); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
n := &Notifier{settings: sett, logger: log.New(&buf, "", 0)}
|
||||
var got []pushRec
|
||||
n.pushFn = func(typ, sev, msg, cust string, d interface{}) { got = append(got, pushRec{typ, sev, msg, cust, d}) }
|
||||
return n, &got
|
||||
}
|
||||
|
||||
func TestUpdateEvents_UndoneWireText(t *testing.T) {
|
||||
at := time.Date(2026, 9, 23, 10, 3, 0, 0, time.UTC) // 12:03 Budapest
|
||||
for _, tc := range []struct{ lang, cust string }{
|
||||
{"hu", ""},
|
||||
{"en", "The update of docmost at 2026-09-23 12:03 did not work. The box put back the previous version and its data automatically — nothing was lost, and there is nothing you need to do."},
|
||||
} {
|
||||
n, got := recNotifier(t, tc.lang)
|
||||
n.NotifyAppUpdateUndone("docmost", map[string]string{"docmost": "a:1"}, map[string]string{"docmost": "a:2"}, at)
|
||||
if len(*got) != 1 {
|
||||
t.Fatalf("%s: %d events", tc.lang, len(*got))
|
||||
}
|
||||
r := (*got)[0]
|
||||
if r.typ != "app_update_undone" || r.sev != "warning" {
|
||||
t.Errorf("%s: type/severity = %s/%s", tc.lang, r.typ, r.sev)
|
||||
}
|
||||
if r.msg != "A(z) docmost frissítése 2026-09-23 12:03-kor nem sikerült. A doboz automatikusan visszaállította az előző változatot és az adatokat — semmi nem veszett el, nincs teendőd." {
|
||||
t.Errorf("%s: the wire message must be the Hungarian, got %q", tc.lang, r.msg)
|
||||
}
|
||||
if r.cust != tc.cust {
|
||||
t.Errorf("%s: household copy = %q", tc.lang, r.cust)
|
||||
}
|
||||
if d, ok := r.details.(AppUpdateDetails); !ok || d.StackName != "docmost" || d.App != "docmost" || d.From["docmost"] != "a:1" {
|
||||
t.Errorf("%s: details must carry stack_name (the hub's per-app cooldown key) and the step, got %+v", tc.lang, r.details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEvents_HeldCarriesTheHoldSentenceInBothLanguages(t *testing.T) {
|
||||
sentence := func(lang string) string { return "HOLD-" + lang }
|
||||
n, got := recNotifier(t, "en")
|
||||
n.NotifyAppUpdateHeld(AppUpdateDetails{App: "romm", StackName: "romm", CopyTier: 1}, sentence)
|
||||
r := (*got)[0]
|
||||
if r.typ != "app_update_held" || r.sev != "error" || r.msg != "HOLD-hu" || r.cust != "HOLD-en" {
|
||||
t.Errorf("got %+v", r)
|
||||
}
|
||||
n2, got2 := recNotifier(t, "hu")
|
||||
n2.NotifyAppUpdateHeld(AppUpdateDetails{App: "romm", StackName: "romm"}, sentence)
|
||||
if r := (*got2)[0]; r.cust != "" {
|
||||
t.Errorf("a Hungarian household sends no second copy (R-558), got %q", r.cust)
|
||||
}
|
||||
}
|
||||
|
||||
// R-620. COMPANION RED-PROOF (REPORT.md): delete the n.dropped call in pushEventBoth — zero WARN
|
||||
// lines, and this test fails.
|
||||
func TestR620_DisabledNotifierSaysWhatItDrops(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
n := New("", "", "c1", nil, log.New(&buf, "", 0), false) // no hub → disabled
|
||||
buf.Reset()
|
||||
n.PushEvent("app_update_undone", "warning", "x", nil)
|
||||
n.PushEvent("app_update_undone", "warning", "y", nil)
|
||||
n.PushEvent("backup_failed", "error", "z", nil)
|
||||
warns := 0
|
||||
for _, l := range strings.Split(buf.String(), "\n") {
|
||||
if strings.Contains(l, "[WARN]") && strings.Contains(l, "DROPPED event") {
|
||||
warns++
|
||||
}
|
||||
}
|
||||
if warns != 2 {
|
||||
t.Fatalf("want exactly TWO WARN lines (one per event type), got %d:\n%s", warns, buf.String())
|
||||
}
|
||||
for _, want := range []string{"DROPPED event app_update_undone (severity warning)", "DROPPED event backup_failed (severity error)"} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("missing %q in:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.264.0 — the one-time, ADD-ONLY seed of the two update event types into a household's stored prefs.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): drop the seedAppUpdateEvents call from Load — the first assertion fails.
|
||||
func TestSeedAppUpdateEvents_AddOnlyOnce(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(p, []byte(`{"notifications":{"email":"h@example.hu","enabled_events":["backup_failed"]}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
s, err := Load(p, lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev := s.Notifications.EnabledEvents
|
||||
has := func(list []string, x string) bool {
|
||||
for _, e := range list {
|
||||
if e == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !has(ev, "backup_failed") || !has(ev, "app_update_undone") || !has(ev, "app_update_held") {
|
||||
t.Fatalf("the household's choice must be kept and both types added, got %v", ev)
|
||||
}
|
||||
// The household opts out of one; a restart must NOT add it back.
|
||||
s.Notifications.EnabledEvents = []string{"backup_failed", "app_update_held"}
|
||||
if err := s.save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2, err := Load(p, lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has(s2.Notifications.EnabledEvents, "app_update_undone") {
|
||||
t.Errorf("a later opt-out must stick — the seed runs once, got %v", s2.Notifications.EnabledEvents)
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,9 @@ type Settings struct {
|
||||
// existing customer's stored prefs. Persisted so a later opt-out sticks (the 3a-fix getter append
|
||||
// re-enabled it on every read — this replaces it).
|
||||
OffboxEnlargeNoticeSeeded bool `json:"offbox_enlarge_notice_seeded,omitempty"`
|
||||
// AppUpdateEventsSeeded (v0.264.0) guards the ONE-TIME add of app_update_undone / app_update_held
|
||||
// into an existing household's stored prefs (seedAppUpdateEvents).
|
||||
AppUpdateEventsSeeded bool `json:"app_update_events_seeded,omitempty"`
|
||||
|
||||
// HubEscrowIdentityPresent (v0.199.0, R-204 item 4 / R-193) caches the report ACK's
|
||||
// `escrow.identity_blob_present` — whether the HUB is holding a sealed recovery package for this
|
||||
@@ -604,6 +607,10 @@ var DefaultEnabledEvents = []string{
|
||||
"expected_backup_missed",
|
||||
"expected_dbdump_missed",
|
||||
"offbox_enlarge_blocked", // 3a-fix (warning-class): remote enlargement refused by the quota gate
|
||||
// v0.264.0 (`09` §3 decision 15): the household is told ONCE when an update is undone, and when it
|
||||
// (or its undo) failed and the app is held. On by default; seeded into existing prefs once below.
|
||||
"app_update_undone",
|
||||
"app_update_held",
|
||||
}
|
||||
|
||||
// PendingEvent is an event queued for the next Hub push cycle.
|
||||
@@ -697,6 +704,7 @@ func Load(path string, logger *log.Logger) (*Settings, error) {
|
||||
}
|
||||
s.migrateResticToRsync()
|
||||
s.seedOffboxEnlargeNotice()
|
||||
s.seedAppUpdateEvents()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -718,6 +726,29 @@ func (s *Settings) seedOffboxEnlargeNotice() {
|
||||
}
|
||||
}
|
||||
|
||||
// seedAppUpdateEvents runs ONCE (guarded by AppUpdateEventsSeeded, v0.264.0) — the
|
||||
// seedOffboxEnlargeNotice shape. A household whose stored prefs predate app_update_undone /
|
||||
// app_update_held gets both appended: they could not have switched off a type that did not exist.
|
||||
// ADD-ONLY, never removes a choice; persisted, so a LATER opt-out sticks. The hub runs the same one-time
|
||||
// add on its side — BOTH are needed, because this list is pushed to the hub on every save of the
|
||||
// notification page and would otherwise take the types away again.
|
||||
func (s *Settings) seedAppUpdateEvents() {
|
||||
if s.AppUpdateEventsSeeded {
|
||||
return
|
||||
}
|
||||
s.AppUpdateEventsSeeded = true
|
||||
if s.Notifications != nil && s.Notifications.EnabledEvents != nil {
|
||||
s.Notifications.EnabledEvents = appendIfAbsent(s.Notifications.EnabledEvents, "app_update_undone")
|
||||
s.Notifications.EnabledEvents = appendIfAbsent(s.Notifications.EnabledEvents, "app_update_held")
|
||||
if s.log != nil {
|
||||
s.log.Printf("[INFO] [settings] app update events added to the household's notification prefs (one-time, add-only)")
|
||||
}
|
||||
}
|
||||
if err := s.save(); err != nil && s.log != nil {
|
||||
s.log.Printf("[ERROR] [settings] Failed to save the app-update-events seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// migrateResticToRsync converts any cross-drive backup configs using restic to rsync.
|
||||
// Called once during Load() before the mutex is exposed.
|
||||
func (s *Settings) migrateResticToRsync() {
|
||||
|
||||
@@ -154,6 +154,12 @@ type Stack struct {
|
||||
UpdatePhase string `json:"update_phase,omitempty"`
|
||||
UpdatePhaseLabel string `json:"update_phase_label,omitempty"`
|
||||
UpdateError string `json:"update_error,omitempty"`
|
||||
// UpdateErrorKey / UpdateErrorArgs (v0.264.0, R-606) are UpdateError as a bundle key + arguments,
|
||||
// so a page renders it in the READER's language (UpdateErrorIn). UpdateError stays the Hungarian —
|
||||
// what the API, the logs and older readers have always had. Empty key = an old or composed sentence,
|
||||
// rendered as stored, never as an empty line.
|
||||
UpdateErrorKey string `json:"-"`
|
||||
UpdateErrorArgs []interface{} `json:"-"`
|
||||
// updateHeld (R-480, v0.240.0) — the last update ended HELD, so UpdateError is the hold's own
|
||||
// sentence („… leállítva marad"). It is shown only while that hold is in force; fillHoldReason.
|
||||
updateHeld bool
|
||||
@@ -243,6 +249,7 @@ type Manager struct {
|
||||
undoCopier volumeCopier
|
||||
updateUndoHealthFn func(ctx context.Context, name string, timeout time.Duration, meta *Metadata) (bool, string)
|
||||
probeRunFn func(t probeTarget) *HealthProbeResult // the health wait's network probe; nil ⇒ runChecks
|
||||
updateEventSink func(UpdateEvent) // v0.264.0: the notifier; nil ⇒ no events
|
||||
updateMemoryFn func(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal error, warning string)
|
||||
updateDiskFreeFn func() (freeGiB float64, ok bool)
|
||||
updateNowFn func() time.Time
|
||||
|
||||
@@ -401,6 +401,7 @@ func (m *Manager) tryUndo(ctx context.Context, name, dir, why string, entry *upd
|
||||
_ = m.RefreshStatus()
|
||||
m.clearJournal(name)
|
||||
m.finishUpdate(name, UpdatePhaseUndone, "")
|
||||
m.emitUpdateEvent(UpdateEventUndone, name, entry, UpdateRestorePoint{}, true)
|
||||
m.logger.Printf("[INFO] [stacks] update %s: UNDONE in %s — the previous version is running on the data from before the update (%s)", name, m.now().Sub(start).Round(time.Second), detail)
|
||||
return ""
|
||||
}
|
||||
@@ -424,3 +425,86 @@ func (m *Manager) recordUpdateUndone(name, dir string, u *UpdateUndone) {
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// ── The household and the operator are TOLD (v0.264.0, `09` §3 decision 15, §6.4 part 2) ─────────
|
||||
|
||||
// Update event kinds — the hub event types, one register for both repos (hub allowedEventTypes).
|
||||
const (
|
||||
UpdateEventUndone = "app_update_undone" // the box put the previous version back — warning
|
||||
UpdateEventHeld = "app_update_held" // the update (or its undo) failed and the app is HELD — error
|
||||
)
|
||||
|
||||
// UpdateEvent is what the update job hands to the notifier. The stacks package composes no sentence
|
||||
// for it: the notifier renders the undone line from a bundle key, and the held line is the hold's own
|
||||
// sentence, rendered by the backup side in each language (main.go wires both).
|
||||
type UpdateEvent struct {
|
||||
Kind string
|
||||
App string
|
||||
From, To map[string]string
|
||||
At time.Time
|
||||
CopyTier int
|
||||
CopyDate time.Time
|
||||
// HoldRecorded is false when the hold could not be persisted — the event still goes (the operator
|
||||
// must hear about exactly that), and the household sentence is then update.error.hold_unsaved.
|
||||
HoldRecorded bool
|
||||
}
|
||||
|
||||
// SetUpdateEventSink wires the notifier (main.go). INIT-ONLY. Nil = no events (tests, setup mode).
|
||||
// Pinned by TestUpdateEventSinkIsWiredAtStartup (an AST walk of main.go).
|
||||
func (m *Manager) SetUpdateEventSink(fn func(UpdateEvent)) {
|
||||
m.mu.Lock()
|
||||
m.updateEventSink = fn
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// emitUpdateEvent sends ONE event for an undone or held update. Called exactly once per outcome by
|
||||
// tryUndo (undone) and failAndHold (held) — the job never retries (decision 15), so one outcome is one
|
||||
// event; the hub's per-app cooldown is the second belt against a storm (R-629).
|
||||
func (m *Manager) emitUpdateEvent(kind, name string, entry *updateJournalEntry, rp UpdateRestorePoint, holdRecorded bool) {
|
||||
m.mu.RLock()
|
||||
sink := m.updateEventSink
|
||||
m.mu.RUnlock()
|
||||
if sink == nil {
|
||||
return
|
||||
}
|
||||
ev := UpdateEvent{Kind: kind, App: name, At: m.now(), CopyTier: rp.Tier, CopyDate: rp.ProvenAt, HoldRecorded: holdRecorded}
|
||||
if entry != nil {
|
||||
ev.From, ev.To = entry.PrevPin, entry.NewPin
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] update %s: event %s (hold recorded: %v)", name, kind, holdRecorded)
|
||||
sink(ev)
|
||||
}
|
||||
|
||||
// BackfillAppliedMeta is R-646's startup pass (v0.264.0), beside AdoptPins. An app pinned before
|
||||
// v0.263.2 has no applied-meta record, so its FIRST undo would judge the old version with whatever
|
||||
// .felhom.yml the catalog sync last put in place. For an app that is CURRENT with the catalog, that file
|
||||
// IS the pinned version's own — so it is recorded now. An app that is Behind or Unknown is skipped and
|
||||
// NAMED: its pinned version's file is already gone, and guessing it would be worse than saying so.
|
||||
// Idempotent; never overwrites an existing record. Returns (recorded, skipped) app names.
|
||||
func (m *Manager) BackfillAppliedMeta() (recorded, skipped []string) {
|
||||
for _, st := range m.GetStacks() {
|
||||
if !st.Deployed || st.AppConfig == nil || len(st.AppConfig.PinnedImages) == 0 {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Dir(st.ComposePath)
|
||||
if _, err := os.Stat(filepath.Join(dir, appliedMetaDir, ".felhom.yml")); err == nil {
|
||||
continue // already recorded — never overwritten
|
||||
}
|
||||
if CatalogOrder(st) != UpdateOrderCurrent {
|
||||
skipped = append(skipped, st.Name)
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, ".felhom.yml"))
|
||||
if err == nil {
|
||||
err = storeAppliedMeta(dir, b)
|
||||
}
|
||||
if err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] applied-meta backfill: %s: %v", st.Name, err)
|
||||
skipped = append(skipped, st.Name)
|
||||
continue
|
||||
}
|
||||
recorded = append(recorded, st.Name)
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] applied-meta backfill (R-646): recorded %d %v; skipped %d %v — not current with the catalog, their pinned version's .felhom.yml is no longer on the box", len(recorded), recorded, len(skipped), skipped)
|
||||
return recorded, skipped
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
@@ -352,26 +353,26 @@ func (m *Manager) UpdatePreflight(name string) *UpdateRefusal {
|
||||
return m.refuseUpdate(name, "not_found", fmt.Sprintf("stack %q not found", name), "no such stack")
|
||||
}
|
||||
if !st.Deployed {
|
||||
return m.refuseUpdate(name, "not_deployed", MsgUpdateNotDeployed, "not deployed")
|
||||
return m.refuseUpdateErr(name, "not_deployed", util.MsgError("update.refusal.not_deployed"), "not deployed")
|
||||
}
|
||||
g := m.guards()
|
||||
if g == nil {
|
||||
return m.refuseUpdate(name, "guards_unwired", MsgUpdateNoGuards, "no UpdateGuards wired — fail closed")
|
||||
return m.refuseUpdateErr(name, "guards_unwired", util.MsgError("update.error.no_guards"), "no UpdateGuards wired — fail closed")
|
||||
}
|
||||
if st.Deploying {
|
||||
return m.refuseUpdate(name, "deploying", fmt.Sprintf(MsgUpdateDeployingFmt, name), "a deploy is in progress")
|
||||
return m.refuseUpdateErr(name, "deploying", util.MsgError("update.refusal.deploying", name), "a deploy is in progress")
|
||||
}
|
||||
if st.Updating {
|
||||
return m.refuseUpdate(name, "updating", fmt.Sprintf(MsgUpdateAlreadyFmt, name), "an update is already in progress")
|
||||
return m.refuseUpdateErr(name, "updating", util.MsgError("update.refusal.already", name), "an update is already in progress")
|
||||
}
|
||||
if held, why := g.HoldFor(name); held {
|
||||
return m.refuseUpdate(name, "held", why, "the app is held")
|
||||
}
|
||||
if busy, why := g.Busy(name); busy {
|
||||
return m.refuseUpdate(name, "busy", MsgUpdateBusy, why)
|
||||
return m.refuseUpdateErr(name, "busy", util.MsgError("update.refusal.busy"), why)
|
||||
}
|
||||
if m.IsMigrating() {
|
||||
return m.refuseUpdate(name, "migrating", MsgUpdateMigrating, "a data migration is running")
|
||||
return m.refuseUpdateErr(name, "migrating", util.MsgError("update.refusal.migrating"), "a data migration is running")
|
||||
}
|
||||
// v0.261.0 — the other half of the self-update lock. The controller's swap restarts this process;
|
||||
// starting an app update into that is how an update loses its own supervisor mid-flight. TRANSIENT:
|
||||
@@ -408,7 +409,7 @@ func (m *Manager) UpdatePreflight(name string) *UpdateRefusal {
|
||||
// with a copy but no way to back up is refused too.
|
||||
if canBackUp, why := g.CanBackUp(name); !canBackUp {
|
||||
if _, found, seen := g.RestorePoints(context.Background(), name, nil); !found {
|
||||
return m.refuseUpdate(name, "no_backup", fmt.Sprintf(MsgUpdateNoBackupFmt, name),
|
||||
return m.refuseUpdateErr(name, "no_backup", util.MsgError("update.refusal.no_backup", name),
|
||||
fmt.Sprintf("no copy on any tier (found: %s) and no backup can be taken now: %s", describeRestorePoints(m.now(), seen), why))
|
||||
}
|
||||
m.logger.Printf("[WARN] [stacks] update %s: no backup can be taken now (%s) — an existing copy must carry the update", name, why)
|
||||
@@ -421,7 +422,7 @@ func (m *Manager) UpdatePreflight(name string) *UpdateRefusal {
|
||||
case !known:
|
||||
m.logger.Printf("[WARN] [stacks] update %s: free space on the Docker data root is unreadable — proceeding without the %.0f GB floor", name, updateDiskFloorGiB)
|
||||
case free < updateDiskFloorGiB:
|
||||
return m.refuseUpdate(name, "disk", fmt.Sprintf(MsgUpdateDiskFmt, free, updateDiskFloorGiB),
|
||||
return m.refuseUpdateErr(name, "disk", util.MsgError("update.refusal.disk", free, updateDiskFloorGiB),
|
||||
fmt.Sprintf("%.2f GiB free on the Docker data root, floor %.0f GiB (fixed floor — image size unknown)", free, updateDiskFloorGiB))
|
||||
}
|
||||
return nil
|
||||
@@ -478,7 +479,7 @@ func (m *Manager) StartGuardedUpdate(name string) error {
|
||||
// A second press between the preflight and here is the race this lock closes.
|
||||
if s.Updating || s.Deploying {
|
||||
m.mu.Unlock()
|
||||
return m.refuseUpdate(name, "updating", fmt.Sprintf(MsgUpdateAlreadyFmt, name), "lost the race for the Updating flag")
|
||||
return m.refuseUpdateErr(name, "updating", util.MsgError("update.refusal.already", name), "lost the race for the Updating flag")
|
||||
}
|
||||
s.Updating, s.UpdateError, s.updateHeld = true, "", false
|
||||
s.UpdatePhase, s.UpdatePhaseLabel = UpdatePhaseChecking, UpdatePhaseLabel(UpdatePhaseChecking)
|
||||
@@ -552,17 +553,49 @@ func (m *Manager) setUpdatePhase(name, phase string) {
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// finishUpdate is the ONE place Updating goes false. msg is the customer sentence on failure.
|
||||
// finishUpdate is the ONE place Updating goes false. msg is the customer sentence on failure — a
|
||||
// finished one (the hold's own sentence, or none). A sentence the job owns goes through finishUpdateKey.
|
||||
func (m *Manager) finishUpdate(name, phase, msg string) {
|
||||
m.finishUpdateKey(name, phase, "", msg)
|
||||
}
|
||||
|
||||
// finishUpdateKey is finishUpdate with the sentence as a bundle KEY (v0.264.0, R-606): UpdateError
|
||||
// keeps the Hungarian (byte-identical to the MsgUpdate* literal it replaced), and the key + args ride
|
||||
// beside it for the page. key "" = `plain` is a finished sentence and is stored as it is.
|
||||
func (m *Manager) finishUpdateKey(name, phase, key, plain string, args ...interface{}) {
|
||||
msg := plain
|
||||
if key != "" {
|
||||
msg = util.Text(i18n.Default, key, args...)
|
||||
}
|
||||
m.mu.Lock()
|
||||
if s, ok := m.stacks[name]; ok {
|
||||
s.Updating = false
|
||||
s.UpdatePhase, s.UpdatePhaseLabel = phase, UpdatePhaseLabel(phase)
|
||||
s.UpdateError = msg
|
||||
s.UpdateError, s.UpdateErrorKey, s.UpdateErrorArgs = msg, key, args
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// UpdatePhaseLabelIn is a phase's label in lang (v0.264.0, R-606). Hungarian is the updatePhaseLabels
|
||||
// map itself; another language reads `update.phase.<phase>` and falls back to the Hungarian.
|
||||
func UpdatePhaseLabelIn(lang, phase string) string {
|
||||
if lang == i18n.Default || phase == "" {
|
||||
return UpdatePhaseLabel(phase)
|
||||
}
|
||||
if b, err := i18n.Shared(); err == nil && b.Has(lang, "update.phase."+phase) {
|
||||
return b.Msg(lang, "update.phase."+phase)
|
||||
}
|
||||
return UpdatePhaseLabel(phase)
|
||||
}
|
||||
|
||||
// UpdateErrorIn is the stack's update sentence in lang (v0.264.0, R-606).
|
||||
func (s Stack) UpdateErrorIn(lang string) string {
|
||||
if s.UpdateErrorKey == "" || lang == i18n.Default {
|
||||
return s.UpdateError
|
||||
}
|
||||
return util.Text(lang, s.UpdateErrorKey, s.UpdateErrorArgs...)
|
||||
}
|
||||
|
||||
func (m *Manager) updateCompose(dir string, env []string, args ...string) (string, error) {
|
||||
if m.updateComposeFn != nil {
|
||||
return m.updateComposeFn(dir, env, args...)
|
||||
@@ -608,18 +641,18 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
|
||||
dir := filepath.Dir(st.ComposePath)
|
||||
g := m.guards()
|
||||
entry := updateJournalEntry{StartedAt: start}
|
||||
fail := func(msg, detail string) {
|
||||
fail := func(key, detail string, args ...interface{}) {
|
||||
m.logger.Printf("[ERROR] [stacks] update %s FAILED in phase %s after %s — nothing was moved: %s", name, entry.Phase, m.now().Sub(start).Round(time.Millisecond), detail)
|
||||
m.clearJournal(name)
|
||||
m.finishUpdate(name, UpdatePhaseFailed, msg)
|
||||
m.finishUpdateKey(name, UpdatePhaseFailed, key, "", args...)
|
||||
}
|
||||
|
||||
if !m.enterUpdatePhase(name, &entry, UpdatePhaseChecking) {
|
||||
m.finishUpdate(name, UpdatePhaseFailed, MsgUpdateJournalFailed)
|
||||
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.journal_failed", "")
|
||||
return
|
||||
}
|
||||
if g == nil {
|
||||
fail(MsgUpdateNoGuards, "no UpdateGuards wired")
|
||||
fail("update.error.no_guards", "no UpdateGuards wired")
|
||||
return
|
||||
}
|
||||
// R-475: the precondition is a copy on ANY tier, chosen in the order 2, 1, 3, and the age rule
|
||||
@@ -633,17 +666,17 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
|
||||
} else {
|
||||
m.logger.Printf("[INFO] [stacks] update %s: no usable copy on any tier — younger than %s and not older than this install's deploy (%s) (found: %s) — backing up first", name, maxAge, fmtDeployTime(deployedAt), describeRestorePoints(start, seen))
|
||||
if !m.enterUpdatePhase(name, &entry, UpdatePhaseBackingUp) {
|
||||
fail(MsgUpdateJournalFailed, "journal write failed")
|
||||
fail("update.error.journal_failed", "journal write failed")
|
||||
return
|
||||
}
|
||||
if err := g.BackupNow(ctx, name); err != nil {
|
||||
fail(fmt.Sprintf(MsgUpdateBackupFailFmt, err), "pre-update backup: "+err.Error())
|
||||
fail("update.error.backup_failed", "pre-update backup: "+err.Error(), err.Error())
|
||||
return
|
||||
}
|
||||
now := m.now()
|
||||
rp, ok, seen = g.RestorePoints(ctx, name, usableRestorePoint(now, maxAge, deployedAt))
|
||||
if !ok {
|
||||
fail(MsgUpdateBackupNoUnit, fmt.Sprintf("after the backup there is still no copy younger than %s on any tier (found: %s)", maxAge, describeRestorePoints(now, seen)))
|
||||
fail("update.error.backup_no_unit", fmt.Sprintf("after the backup there is still no copy younger than %s on any tier (found: %s)", maxAge, describeRestorePoints(now, seen)))
|
||||
return
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] update %s: precondition met after the backup — %s copy from %s", name, updateTierName(rp.Tier), rp.ProvenAt.UTC().Format(time.RFC3339))
|
||||
@@ -653,12 +686,12 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
|
||||
|
||||
// SAFETY DUMP BEFORE THE PIN MOVES — "a minute ago", before any migration can have run.
|
||||
if !m.enterUpdatePhase(name, &entry, UpdatePhaseSafetyDump) {
|
||||
fail(MsgUpdateJournalFailed, "journal write failed")
|
||||
fail("update.error.journal_failed", "journal write failed")
|
||||
return
|
||||
}
|
||||
paths, err := g.SafetyDump(ctx, name)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf(MsgUpdateDumpFailFmt, err), "safety dump: "+err.Error())
|
||||
fail("update.error.dump_failed", "safety dump: "+err.Error(), err.Error())
|
||||
return
|
||||
}
|
||||
m.logger.Printf("[INFO] [stacks] update %s: safety dump done (%d file(s)) %v", name, len(paths), paths)
|
||||
@@ -668,9 +701,9 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
|
||||
undoVols, perr := m.planUndoCopies(name)
|
||||
if perr != nil {
|
||||
if se, ok := perr.(*undoSpaceError); ok {
|
||||
fail(undoMsg("err.stacks.update_undo_space", se.need, se.free, updateDiskFloorGiB), "undo copy: "+perr.Error())
|
||||
fail("err.stacks.update_undo_space", "undo copy: "+perr.Error(), se.need, se.free, updateDiskFloorGiB)
|
||||
} else {
|
||||
fail(undoMsg("err.stacks.update_undo_copy_failed"), "undo copy plan: "+perr.Error())
|
||||
fail("err.stacks.update_undo_copy_failed", "undo copy plan: "+perr.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -679,11 +712,11 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
|
||||
// at any later instant can put it back (Scenario G).
|
||||
prevLive, err := os.ReadFile(st.ComposePath)
|
||||
if err != nil {
|
||||
fail(MsgUpdatePinFailed, "reading the live compose file: "+err.Error())
|
||||
fail("update.error.pin_failed", "reading the live compose file: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, preUpdateComposeFile), prevLive, 0o644); err != nil {
|
||||
fail(MsgUpdateJournalFailed, "saving the pre-update compose copy: "+err.Error())
|
||||
fail("update.error.journal_failed", "saving the pre-update compose copy: "+err.Error())
|
||||
return
|
||||
}
|
||||
entry.PrevCompose = filepath.Join(dir, preUpdateComposeFile)
|
||||
@@ -711,12 +744,12 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
|
||||
}
|
||||
if !m.enterUpdatePhase(name, &entry, UpdatePhasePinning) {
|
||||
m.removePreUpdateCopies(dir)
|
||||
fail(MsgUpdateJournalFailed, "journal write failed")
|
||||
fail("update.error.journal_failed", "journal write failed")
|
||||
return
|
||||
}
|
||||
if err := m.advancePinToCatalog(name, dir); err != nil {
|
||||
m.pinBack(name, dir, entry)
|
||||
fail(MsgUpdatePinFailed, "advancing the pin: "+err.Error())
|
||||
fail("update.error.pin_failed", "advancing the pin: "+err.Error())
|
||||
return
|
||||
}
|
||||
if cfg := LoadAppConfig(dir); cfg != nil {
|
||||
@@ -726,7 +759,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
|
||||
env := m.stackEnv(dir)
|
||||
if !m.enterUpdatePhase(name, &entry, UpdatePhasePulling) {
|
||||
m.pinBack(name, dir, entry)
|
||||
fail(MsgUpdateJournalFailed, "journal write failed")
|
||||
fail("update.error.journal_failed", "journal write failed")
|
||||
return
|
||||
}
|
||||
if _, err := m.updateCompose(dir, env, "pull"); err != nil {
|
||||
@@ -734,7 +767,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
|
||||
// state is the old pin and the old file — put both back.
|
||||
m.pinBack(name, dir, entry)
|
||||
m.logger.Printf("[ERROR] [stacks] update %s: pull failed — pin and definition PUT BACK; the app was not touched. Docker said: %v", name, err)
|
||||
fail(MsgUpdatePullFailed, "pull failed: "+err.Error())
|
||||
fail("update.error.pull_failed", "pull failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -743,7 +776,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
|
||||
// start again.
|
||||
if !m.enterUpdatePhase(name, &entry, UpdatePhaseCopying) {
|
||||
m.pinBack(name, dir, entry)
|
||||
fail(MsgUpdateJournalFailed, "journal write failed")
|
||||
fail("update.error.journal_failed", "journal write failed")
|
||||
return
|
||||
}
|
||||
if err := m.makeUndoCopies(name, dir, env, undoVols, &entry); err != nil {
|
||||
@@ -753,7 +786,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) {
|
||||
if _, uerr := m.updateCompose(dir, m.stackEnv(dir), "up", "-d", "--remove-orphans"); uerr != nil {
|
||||
m.logger.Printf("[ERROR] [stacks] update %s: restarting the previous version after the failed copy also failed: %v", name, uerr)
|
||||
}
|
||||
fail(undoMsg("err.stacks.update_undo_copy_failed"), "undo copy: "+err.Error())
|
||||
fail("err.stacks.update_undo_copy_failed", "undo copy: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -842,19 +875,25 @@ func (m *Manager) failAndHold(ctx context.Context, name, dir string, env []strin
|
||||
m.logger.Printf("[ERROR] [stacks] update %s: stopping the failed app also failed: %v", name, err)
|
||||
}
|
||||
}
|
||||
msg := MsgUpdateHoldUnsaved
|
||||
holdWhy := ""
|
||||
if g := m.guards(); g == nil {
|
||||
m.logger.Printf("[ERROR] [stacks] update %s: no UpdateGuards — the hold CANNOT be recorded", name)
|
||||
} else if err := g.HoldAfterFailedUpdate(name, m.now(), rp, undoState); err != nil {
|
||||
m.logger.Printf("[ERROR] [stacks] update %s: %v", name, err)
|
||||
} else if _, why := g.HoldFor(name); why != "" {
|
||||
msg = why
|
||||
} else if _, w := g.HoldFor(name); w != "" {
|
||||
holdWhy = w
|
||||
m.markUpdateHeld(name)
|
||||
}
|
||||
_ = m.RefreshStatus()
|
||||
m.clearJournal(name)
|
||||
m.removePreUpdateCopies(dir)
|
||||
m.finishUpdate(name, UpdatePhaseFailed, msg)
|
||||
if holdWhy == "" {
|
||||
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.hold_unsaved", "")
|
||||
} else {
|
||||
// The hold's own sentence (the page renders it per reader through RestoreHoldForLang).
|
||||
m.finishUpdate(name, UpdatePhaseFailed, holdWhy)
|
||||
}
|
||||
m.emitUpdateEvent(UpdateEventHeld, name, entry, rp, holdWhy != "")
|
||||
}
|
||||
|
||||
// pinBack restores the pin, the stored definition and the live file from the journaled copies, and
|
||||
@@ -1147,12 +1186,12 @@ func (m *Manager) RecoverUpdates() []string {
|
||||
case UpdatePhaseChecking, UpdatePhaseBackingUp, UpdatePhaseSafetyDump:
|
||||
m.logger.Printf("[WARN] [stacks] update recovery: %s was interrupted in %s (started %s) — nothing had moved; dropping it", name, e.Phase, e.StartedAt.Format(time.RFC3339))
|
||||
m.clearJournal(name)
|
||||
m.finishUpdate(name, UpdatePhaseFailed, MsgUpdateInterrupted)
|
||||
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.interrupted", "")
|
||||
case UpdatePhasePinning, UpdatePhasePulling:
|
||||
m.logger.Printf("[WARN] [stacks] update recovery: %s was interrupted in %s (started %s) — nothing had run; putting the pin back", name, e.Phase, e.StartedAt.Format(time.RFC3339))
|
||||
m.pinBack(name, dir, e)
|
||||
m.clearJournal(name)
|
||||
m.finishUpdate(name, UpdatePhaseFailed, MsgUpdateInterrupted)
|
||||
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.interrupted", "")
|
||||
case UpdatePhaseCopying:
|
||||
// v0.263.0: the app was STOPPED for the copy and nothing new ran. The partial copies go, the
|
||||
// pin goes back, and the previous version is started again.
|
||||
@@ -1163,7 +1202,7 @@ func (m *Manager) RecoverUpdates() []string {
|
||||
m.logger.Printf("[ERROR] [stacks] update recovery: %s: starting the previous version failed: %v", name, err)
|
||||
}
|
||||
m.clearJournal(name)
|
||||
m.finishUpdate(name, UpdatePhaseFailed, MsgUpdateInterrupted)
|
||||
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.interrupted", "")
|
||||
case UpdatePhaseUndoing:
|
||||
// v0.263.0: a power cut DURING the undo. Resumed like `starting` — the undo runs again from
|
||||
// the copies (still there: they are removed only after the undo succeeded) and then probes.
|
||||
@@ -1205,14 +1244,14 @@ func (m *Manager) ResumeInterruptedUpdates(ctx context.Context) int {
|
||||
for _, name := range names {
|
||||
st, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
m.finishUpdate(name, UpdatePhaseFailed, MsgUpdateInterrupted)
|
||||
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.interrupted", "")
|
||||
continue
|
||||
}
|
||||
m.updateJournalMu.Lock()
|
||||
e, ok := m.readUpdateJournal().Updates[name]
|
||||
m.updateJournalMu.Unlock()
|
||||
if !ok {
|
||||
m.finishUpdate(name, UpdatePhaseFailed, MsgUpdateInterrupted)
|
||||
m.finishUpdateKey(name, UpdatePhaseFailed, "update.error.interrupted", "")
|
||||
continue
|
||||
}
|
||||
provenAt, _ := time.Parse(time.RFC3339, e.ProvenCopyAt)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// v0.264.0 — the update's outcome is TOLD (`09` §3 decision 15): one event per undone update, one per
|
||||
// held update, handed to the sink main.go wires to the notifier. And R-646 / R-606 on the stacks side.
|
||||
|
||||
type eventRec struct {
|
||||
mu sync.Mutex
|
||||
evs []UpdateEvent
|
||||
}
|
||||
|
||||
func (r *eventRec) sink(ev UpdateEvent) { r.mu.Lock(); r.evs = append(r.evs, ev); r.mu.Unlock() }
|
||||
func (r *eventRec) list() []UpdateEvent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]UpdateEvent(nil), r.evs...)
|
||||
}
|
||||
|
||||
// COMPANION RED-PROOF (REPORT.md): delete the emitUpdateEvent call from tryUndo — no event is sent,
|
||||
// and this test fails on the count.
|
||||
func TestUpdateEvents_AnUndoneUpdateIsToldOnce(t *testing.T) {
|
||||
m, _, _, _, _ := newUndoManager(t)
|
||||
rec := &eventRec{}
|
||||
m.SetUpdateEventSink(rec.sink)
|
||||
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st := waitUpdateDone(t, m, "nextcloud"); st.UpdatePhase != UpdatePhaseUndone {
|
||||
t.Fatalf("setup: phase %q", st.UpdatePhase)
|
||||
}
|
||||
evs := rec.list()
|
||||
if len(evs) != 1 || evs[0].Kind != UpdateEventUndone || evs[0].App != "nextcloud" {
|
||||
t.Fatalf("an undone update must produce exactly ONE %s event, got %+v", UpdateEventUndone, evs)
|
||||
}
|
||||
if evs[0].From["web"] != "nextcloud:31.0.14-apache" || evs[0].To["web"] != "nextcloud:34.0.1-apache" {
|
||||
t.Errorf("the event must name the step (from → to), got %+v → %+v", evs[0].From, evs[0].To)
|
||||
}
|
||||
}
|
||||
|
||||
// COMPANION RED-PROOF (REPORT.md): delete the emitUpdateEvent call from failAndHold — a held update
|
||||
// sends nothing, and this test fails on the count.
|
||||
func TestUpdateEvents_AHeldUpdateIsToldOnce(t *testing.T) {
|
||||
m, _, _, _, fc := newUndoManager(t)
|
||||
fc.cutOff = true // the undo fails → HOLD
|
||||
rec := &eventRec{}
|
||||
m.SetUpdateEventSink(rec.sink)
|
||||
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitUpdateDone(t, m, "nextcloud")
|
||||
evs := rec.list()
|
||||
if len(evs) != 1 || evs[0].Kind != UpdateEventHeld || !evs[0].HoldRecorded {
|
||||
t.Fatalf("a held update must produce exactly ONE recorded %s event, got %+v", UpdateEventHeld, evs)
|
||||
}
|
||||
if evs[0].CopyTier != UpdateTierSecondDrive || evs[0].CopyDate.IsZero() {
|
||||
t.Errorf("the held event must name the copy the hold points at, got tier %d date %v", evs[0].CopyTier, evs[0].CopyDate)
|
||||
}
|
||||
}
|
||||
|
||||
// A hold that could not be SAVED still tells the operator — that is exactly what they must hear.
|
||||
func TestUpdateEvents_AnUnsavedHoldIsStillTold(t *testing.T) {
|
||||
m, _, g, _, fc := newUndoManager(t)
|
||||
fc.cutOff = true
|
||||
g.holdErr = os.ErrPermission
|
||||
rec := &eventRec{}
|
||||
m.SetUpdateEventSink(rec.sink)
|
||||
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := waitUpdateDone(t, m, "nextcloud")
|
||||
evs := rec.list()
|
||||
if len(evs) != 1 || evs[0].Kind != UpdateEventHeld || evs[0].HoldRecorded {
|
||||
t.Fatalf("an unsaved hold must still send ONE held event, marked unrecorded; got %+v", evs)
|
||||
}
|
||||
if st.UpdateError != MsgUpdateHoldUnsaved || st.UpdateErrorKey != "update.error.hold_unsaved" {
|
||||
t.Errorf("err=%q key=%q", st.UpdateError, st.UpdateErrorKey)
|
||||
}
|
||||
}
|
||||
|
||||
// R-646. COMPANION RED-PROOF (REPORT.md): make BackfillAppliedMeta skip the storeAppliedMeta call —
|
||||
// the current app then has no record and this test fails.
|
||||
func TestR646_BackfillRecordsOnlyAppsCurrentWithTheCatalog(t *testing.T) {
|
||||
m, dir, _, _, _ := newUndoManager(t) // nextcloud: pinned 31, catalog 34 → Behind
|
||||
if err := os.RemoveAll(filepath.Join(dir, appliedMetaDir)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second app, CURRENT: installed == catalog.
|
||||
cur := filepath.Join(m.cfg.Paths.StacksDir, "vikunja")
|
||||
if err := os.MkdirAll(cur, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(cur, "docker-compose.yml"), "services:\n web:\n image: vikunja/vikunja:2.3.0\n")
|
||||
mustWrite(t, filepath.Join(cur, ".felhom.yml"), undoMetaOld)
|
||||
mustWrite(t, filepath.Join(cur, "app.yaml"), "deployed: true\nenv: {}\npinned_images:\n web: vikunja/vikunja:2.3.0\n")
|
||||
m.mu.Lock()
|
||||
m.stacks["vikunja"] = &Stack{Name: "vikunja", Deployed: true, ComposePath: filepath.Join(cur, "docker-compose.yml"),
|
||||
AppConfig: LoadAppConfig(cur),
|
||||
CatalogImages: map[string]string{"web": "vikunja/vikunja:2.3.0"}}
|
||||
m.stacks["vikunja"].AppConfig.InstalledImages = map[string]InstalledImage{"web": {Ref: "vikunja/vikunja:2.3.0"}}
|
||||
m.stacks["nextcloud"].CatalogImages = map[string]string{"web": "nextcloud:34.0.1-apache"}
|
||||
m.stacks["nextcloud"].AppConfig.InstalledImages = map[string]InstalledImage{"web": {Ref: "nextcloud:31.0.14-apache"}}
|
||||
m.mu.Unlock()
|
||||
|
||||
recorded, skipped := m.BackfillAppliedMeta()
|
||||
if len(recorded) != 1 || recorded[0] != "vikunja" || len(skipped) != 1 || skipped[0] != "nextcloud" {
|
||||
t.Fatalf("recorded=%v skipped=%v — want the current app recorded and the behind app skipped by name", recorded, skipped)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cur, appliedMetaDir, ".felhom.yml")); err != nil {
|
||||
t.Errorf("the current app must have its record: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, appliedMetaDir, ".felhom.yml")); err == nil {
|
||||
t.Error("a BEHIND app must get no record — its pinned version's file is gone, and guessing it is worse")
|
||||
}
|
||||
// Idempotent, and never overwrites.
|
||||
mustWrite(t, filepath.Join(cur, appliedMetaDir, ".felhom.yml"), "marker: kept\n")
|
||||
if rec2, _ := m.BackfillAppliedMeta(); len(rec2) != 0 {
|
||||
t.Errorf("a second pass must record nothing, recorded %v", rec2)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(cur, appliedMetaDir, ".felhom.yml")); string(b) != "marker: kept\n" {
|
||||
t.Errorf("an existing record must never be overwritten, got %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
// R-606 — the Hungarian is byte-identical to the literals the keys replaced, and the other language is
|
||||
// really the other language.
|
||||
func TestR606_UpdateSentencesHungarianUnchangedAndTranslated(t *testing.T) {
|
||||
for key, lit := range map[string]string{
|
||||
"update.error.interrupted": MsgUpdateInterrupted,
|
||||
"update.error.pull_failed": MsgUpdatePullFailed,
|
||||
"update.error.pin_failed": MsgUpdatePinFailed,
|
||||
"update.error.journal_failed": MsgUpdateJournalFailed,
|
||||
"update.error.backup_no_unit": MsgUpdateBackupNoUnit,
|
||||
"update.error.hold_unsaved": MsgUpdateHoldUnsaved,
|
||||
"update.error.no_guards": MsgUpdateNoGuards,
|
||||
"update.error.backup_failed": MsgUpdateBackupFailFmt,
|
||||
"update.error.dump_failed": MsgUpdateDumpFailFmt,
|
||||
"update.refusal.busy": MsgUpdateBusy,
|
||||
"update.refusal.disk": MsgUpdateDiskFmt,
|
||||
"update.refusal.no_backup": MsgUpdateNoBackupFmt,
|
||||
} {
|
||||
b, _ := i18n.Shared()
|
||||
if got := b.Msg(i18n.Default, key); got != lit {
|
||||
t.Errorf("%s: Hungarian changed:\n got %q\nwant %q", key, got, lit)
|
||||
}
|
||||
if en := b.Msg("en", key); en == "" || en == lit {
|
||||
t.Errorf("%s: no English", key)
|
||||
}
|
||||
}
|
||||
for ph, lbl := range updatePhaseLabels {
|
||||
if got := UpdatePhaseLabelIn(i18n.Default, ph); got != lbl {
|
||||
t.Errorf("phase %s: Hungarian label %q, want %q", ph, got, lbl)
|
||||
}
|
||||
if en := UpdatePhaseLabelIn("en", ph); en == lbl {
|
||||
t.Errorf("phase %s: no English label", ph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R-606 — a stored update sentence renders in the reader's language; an old stored one (no key) renders
|
||||
// as stored, never empty.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): make UpdateErrorIn return s.UpdateError always — the English
|
||||
// assertion fails.
|
||||
func TestR606_UpdateErrorRendersInTheReadersLanguage(t *testing.T) {
|
||||
m, _, _, _, _ := newUndoManager(t)
|
||||
m.finishUpdateKey("nextcloud", UpdatePhaseFailed, "update.error.backup_failed", "", "disk full")
|
||||
st, _ := m.GetStack("nextcloud")
|
||||
if st.UpdateError != util.Text(i18n.Default, "update.error.backup_failed", "disk full") {
|
||||
t.Errorf("UpdateError must stay the Hungarian, got %q", st.UpdateError)
|
||||
}
|
||||
en := st.UpdateErrorIn("en")
|
||||
if en != "The update did not start, because the backup before the update did not succeed: disk full. The app keeps running unchanged." {
|
||||
t.Errorf("English = %q", en)
|
||||
}
|
||||
old := Stack{UpdateError: "egy régi, kulcs nélküli mondat"}
|
||||
if old.UpdateErrorIn("en") != "egy régi, kulcs nélküli mondat" {
|
||||
t.Error("a sentence stored by an older version must render as stored, never as an empty line")
|
||||
}
|
||||
}
|
||||
@@ -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