D62: restore copies kept 30 days, listed with their date, deletable by hand (typed name, never a needed copy, operator event); D63: a test-build-only fault switch for the R-1256 H4/H5 live proofs — tests red first
gates / gates (push) Successful in 33s
gates / gates (push) Successful in 33s
This commit is contained in:
1 parent
a815b37fbd
commit
e3fce09d96
24 files changed
+1992
-11
No files matched your search
@@ -1,12 +1,16 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||
)
|
||||
|
||||
// D25 (operator ruling 2026-10-21; R-1175 + R-1181): the restore page lists the second-drive copies whose source is
|
||||
@@ -121,3 +125,134 @@ func (s *Server) keptUnitRestoreHandler(w http.ResponseWriter, r *http.Request)
|
||||
}()
|
||||
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
|
||||
}
|
||||
|
||||
// ── D62 (operator ruling 2026-10-29): restore copies in the same list ───────────────────────────────────────────────
|
||||
//
|
||||
// Each restore copy shows the app, the date of the restore it protects, its size and the day the box deletes it by
|
||||
// itself (30 days after the restore). „Törlés most" deletes it earlier: a warning that names the app and the date,
|
||||
// and the app's name typed. A copy a hold or a pending put-back needs shows why in one line and has no button — and
|
||||
// the handler refuses it too. Pinned by TestD62_* (web).
|
||||
|
||||
// restoreCopyRow is one restore copy on the page.
|
||||
type restoreCopyRow struct {
|
||||
App string
|
||||
Stamp string
|
||||
Name string // the app's display name — what the household types
|
||||
Line string // „visszaállítás: <date> · <size> · automatikusan törlődik: <date>"
|
||||
NeededText string // why it cannot be deleted; "" = the button is offered
|
||||
Warning string // the confirm step's sentence (app and date)
|
||||
TypePrompt string
|
||||
}
|
||||
|
||||
// restoreCopyAppName is the app's display name, or its slug when the app is gone.
|
||||
func (s *Server) restoreCopyAppName(app string) string {
|
||||
if s.stackMgr != nil {
|
||||
if st, ok := s.stackMgr.GetStack(app); ok && st.Meta.DisplayName != "" {
|
||||
return st.Meta.DisplayName
|
||||
}
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func (s *Server) restoreCopyRows(lang string) []restoreCopyRow {
|
||||
if s.backupMgr == nil {
|
||||
return nil
|
||||
}
|
||||
var out []restoreCopyRow
|
||||
for _, c := range s.backupMgr.RestoreCopies() {
|
||||
name := s.restoreCopyAppName(c.App)
|
||||
at := c.At.In(getTimezone())
|
||||
line := s.msgLang(lang, "restore_copy.restored_on", fmtLocalTime(lang, c.At)) + " · " +
|
||||
i18n.LocalizeDecimals(lang, backup.HumanizeBytes(c.SizeBytes))
|
||||
row := restoreCopyRow{App: c.App, Stamp: c.Stamp, Name: name}
|
||||
if c.Needed == "" {
|
||||
line += " · " + s.msgLang(lang, "restore_copy.auto_delete", i18n.FormatDate(lang, c.DeleteOn.In(getTimezone())))
|
||||
row.Warning = s.msgLang(lang, "restore_copy.warning", name, strings.TrimSuffix(i18n.FormatDate(lang, at), "."))
|
||||
row.TypePrompt = s.msgLang(lang, "kept.delete.type", name)
|
||||
} else {
|
||||
row.NeededText = s.msgLang(lang, "restore_copy.needed."+c.Needed)
|
||||
}
|
||||
row.Line = line
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// restoreCopyJSON is the delete's answer: ok with the page to go to, or the refusal's sentence.
|
||||
func restoreCopyJSON(w http.ResponseWriter, code int, ok bool, msg, redirect string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": ok, "error": msg, "redirect": redirect})
|
||||
}
|
||||
|
||||
// restoreCopyDeleteHandler is „Törlés most": POST /backup/restore-copy/delete (app, stamp, confirm). JSON answer:
|
||||
// 200 deleted; 400 the typed name does not match; 404 no such copy; 409 the copy is needed, or an operation runs on
|
||||
// the box or the app; 500 the removal failed. CSRF as every state-changing form (CsrfProtect).
|
||||
func (s *Server) restoreCopyDeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
lang := s.langFor(r)
|
||||
if s.backupMgr == nil {
|
||||
restoreCopyJSON(w, http.StatusServiceUnavailable, false, s.msgLang(lang, "flash.offbox.mgr_unreachable"), "")
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
app, stamp := strings.TrimSpace(r.FormValue("app")), strings.TrimSpace(r.FormValue("stamp"))
|
||||
var found *backup.RestoreCopy
|
||||
for _, c := range s.backupMgr.RestoreCopies() {
|
||||
if c.App == app && c.Stamp == stamp {
|
||||
c := c
|
||||
found = &c
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED: %q/%q is not a listed copy (from %s)", app, stamp, clientIP(r))
|
||||
restoreCopyJSON(w, http.StatusNotFound, false, s.msgLang(lang, "restore_copy.not_found"), "")
|
||||
return
|
||||
}
|
||||
if found.Needed != "" {
|
||||
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: the copy is needed (%s)", found.Path, found.Needed)
|
||||
restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.needed."+found.Needed), "")
|
||||
return
|
||||
}
|
||||
name := s.restoreCopyAppName(app)
|
||||
if strings.TrimSpace(r.FormValue("confirm")) != name {
|
||||
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: the typed confirmation did not match", found.Path)
|
||||
restoreCopyJSON(w, http.StatusBadRequest, false, s.msgLang(lang, "kept.delete.mismatch", name), "")
|
||||
return
|
||||
}
|
||||
if s.stackMgr != nil && s.stackMgr.IsUpdating(app) {
|
||||
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: an update of %s runs", found.Path, app)
|
||||
restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.app_busy"), "")
|
||||
return
|
||||
}
|
||||
who := "the household (dashboard sign-in) from " + clientIP(r)
|
||||
c, err := s.backupMgr.DeleteRestoreCopy(app, stamp)
|
||||
switch {
|
||||
case errors.Is(err, backup.ErrRestoreCopyBusy):
|
||||
busy, mirror := s.backupMgr.RestoreBusy()
|
||||
msg, blocked := s.restoreBusyMessage(busy, mirror)
|
||||
if !blocked {
|
||||
msg = s.msgLang(lang, "restore_copy.app_busy")
|
||||
}
|
||||
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: a backup or restore operation runs", found.Path)
|
||||
restoreCopyJSON(w, http.StatusConflict, false, msg, "")
|
||||
return
|
||||
case errors.Is(err, backup.ErrRestoreCopyNeeded):
|
||||
restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.needed."+c.Needed), "")
|
||||
return
|
||||
case errors.Is(err, backup.ErrRestoreCopyNotFound):
|
||||
restoreCopyJSON(w, http.StatusNotFound, false, s.msgLang(lang, "restore_copy.not_found"), "")
|
||||
return
|
||||
case err != nil:
|
||||
s.logger.Printf("[ERROR] [web] restore-copy delete of %s failed: %v", found.Path, err)
|
||||
restoreCopyJSON(w, http.StatusInternalServerError, false, s.msgLang(lang, "restore_copy.failed", err.Error()), "")
|
||||
return
|
||||
}
|
||||
restoreAt := c.At.UTC().Format(time.RFC3339)
|
||||
s.logger.Printf("[WARN] [web] restore copy DELETED by hand (D62): app=%s restore_at=%s size=%d path=%s who=%s",
|
||||
app, restoreAt, c.SizeBytes, c.Path, who)
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyRestoreCopyDeleted(notify.RestoreCopyDeletedDetails{App: app, RestoreAt: restoreAt, SizeBytes: c.SizeBytes, Who: who, Path: c.Path})
|
||||
}
|
||||
restoreCopyJSON(w, http.StatusOK, true, "", "/backups/restore?"+flashQuery("flash", "flash.restore_copy.deleted", name))
|
||||
}
|
||||
Reference in new issue
Block a user