package api import ( "fmt" "net/http" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // keptChoiceData is what the install page needs to ask the household (`09` §3 decision 36). The // sentences come rendered in the request's language; the page shows them as they are. type keptChoiceData struct { Code string `json:"code"` // "kept_data_choice" Title string `json:"title"` Body string `json:"body"` UseLabel string `json:"use_label"` UseDesc string `json:"use_desc"` // "" when use is off UseOff string `json:"use_off"` // "" when use is offered UseOffered bool `json:"use_offered"` // a database copy exists for these files FreshLabel string `json:"fresh_label"` FreshDesc string `json:"fresh_desc"` NotBacked string `json:"not_backed_up"` } // keptDataAtInstall answers the install when the app's private drive folder already holds data: // // - no choice → 409 kept_data_choice with the sentences (nothing moved, nothing installed); // - "use" → a LOAD from the newest usable copy (backup.KeptBestCopy: own unit, second drive, or — // R-691 (2) — the off-site copy, downloaded unit-only at load time), then the app's after_load; 202. // The page names the copy and its date. Refused 409 with use_off when no copy exists; // - "fresh" → not handled here: DeployStack moves the old data aside and installs. // // Returns true when it wrote the response. func (r *Router) keptDataAtInstall(w http.ResponseWriter, req *http.Request, name, hdd, choice string) bool { old := r.stackMgr.OldAppData(name, hdd) if len(old) == 0 || choice == stacks.KeptChoiceFresh { return false } display := name if st, ok := r.stackMgr.GetStack(name); ok && st.Meta.DisplayName != "" { display = st.Meta.DisplayName } var size int64 var newest time.Time for _, p := range old { size += stacks.DirSizeBytes(p) if t := stacks.DirModTime(p); t.After(newest) { newest = t } } var cp struct { ok bool c backup.KeptCopy } if r.backupMgr != nil { cp.c, cp.ok = r.backupMgr.KeptBestCopy(req.Context(), name, hdd) } lang := r.langFor(req) data := keptChoiceData{ Code: "kept_data_choice", Title: r.msgLang(lang, "kept.choice.title"), Body: r.msgLang(lang, "kept.choice.body", display, appbackup.HumanizeBytes(size), localDay(newest)), UseLabel: r.msgLang(lang, "kept.choice.use.label"), UseOffered: cp.ok, FreshLabel: r.msgLang(lang, "kept.choice.fresh.label"), FreshDesc: r.msgLang(lang, "kept.choice.fresh.desc"), NotBacked: r.msgLang(lang, "kept.not_backed_up"), } if cp.ok { data.UseDesc = r.msgLang(lang, "kept.choice.use.desc_from", r.keptCopyName(lang, cp.c)) } else { data.UseOff = r.msgLang(lang, "kept.choice.use_off") } switch choice { case "": r.logger.Printf("[INFO] [api] Deploy %s: the drive already holds its old data %v (%d bytes) — asking the household (use offered: %v)", name, old, size, cp.ok) writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: data.Title, Data: data}) return true case stacks.KeptChoiceUse: if !cp.ok { r.logger.Printf("[WARN] [api] Deploy %s: use my kept data REFUSED — no database copy for %s", name, hdd) writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: data.UseOff, Data: data}) return true } if st := r.backupMgr.RestoreStatus(); st.Running || r.backupMgr.IsRunning() { writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msgLang(lang, "api.kept.busy")}) return true } src := cp.c.UnitDir if cp.c.Tier == backup.KeptTierOffsite { src = "off-site snapshot " + cp.c.SnapshotID } r.logger.Printf("[INFO] [api] Deploy %s: USE MY KEPT DATA — loading from %s (tier %d, %s) under the kept files %v", name, src, cp.c.Tier, cp.c.Time.UTC().Format(time.RFC3339), old) r.dropLeftoverHold(name, "use-kept-data") // R-704: this is a new install of an app that is not installed r.startKeptLoad(name, hdd, cp.c, lang, nil) writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: r.msgLang(lang, "kept.load.started", display)}) return true default: writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("kept_data %q is not a choice", choice)}) return true } } // keptCopyName names a copy for the household: which copy and its date („saját mentés, 2026-09-28 04:15"). func (r *Router) keptCopyName(lang string, c backup.KeptCopy) string { return r.msgLang(lang, backup.KeptCopyKey(c.Tier), localMinute(c.Time)) } // startKeptLoad runs the load and, when it succeeded, the app's after_load. then(ok) runs last. The // off-site copy goes through LoadKeptOffsite (download the unit alone, judge it, then the same restore). func (r *Router) startKeptLoad(name, drive string, c backup.KeptCopy, lang string, then func(ok bool)) { display := name if st, ok := r.stackMgr.GetStack(name); ok && st.Meta.DisplayName != "" { display = st.Meta.DisplayName } okMsg := func(error) string { return r.msgLang(lang, "kept.load.done", display) } failMsg := func(err error) string { return r.msgLang(lang, "kept.load.failed", display, err) } if c.Tier == backup.KeptTierOffsite { r.backupMgr.LoadKeptOffsite(name, drive, c, nil, okMsg, failMsg, r.keptAfterLoad(name, then)) return } r.backupMgr.LoadKeptApp(name, c.UnitDir, okMsg, failMsg, r.keptAfterLoad(name, then)) } // keptAfterLoad runs the app's after_load when the load succeeded, then then(ok). func (r *Router) keptAfterLoad(name string, then func(ok bool)) func(ok bool) { return func(ok bool) { if ok { if ran, err := r.stackMgr.RunAfterLoad(name, 10*time.Minute); ran && err != nil { r.logger.Printf("[WARN] [api] kept load %s: after_load failed: %v", name, err) } } if then != nil { then(ok) } } } func localDay(t time.Time) string { if t.IsZero() { return "?" } return t.In(budapest()).Format("2006-01-02") } // localMinute is localDay with the time of day — the kept list's format, so both pages print one date. func localMinute(t time.Time) string { if t.IsZero() { return "?" } return t.In(budapest()).Format("2006-01-02 15:04") } func budapest() *time.Location { loc, err := time.LoadLocation("Europe/Budapest") if err != nil { return time.UTC } return loc }