package web import ( "net/http" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/metrics" "gitea.dooplex.hu/admin/felhom-controller/internal/nightchain" ) // The missed-backup banner (R-871, `09` decision 110 — the operator's idea). The rule is the pure // nightchain.ComputeBanner; this file only gathers its inputs and renders. It hangs off executeTemplate like the // escrow bar (R-543), so it reaches every dashboard page of a logged-in household. Unlike that bar its dismissal is // DURABLE (the night ledger): closed until the NEXT missed backup time, gone by itself after a successful night. // SetMissedBackupBanner wires the night ledger and the metrics record (nil ledger = no banner: backups off). func (s *Server) SetMissedBackupBanner(l *nightchain.Ledger, ms *metrics.MetricsStore) { s.nightLedger, s.metricsRecord = l, ms } // missedBackupBanner computes the banner for now (nil = nothing to show). func (s *Server) missedBackupBanner(now time.Time) *nightchain.Banner { if s.nightLedger == nil || s.settings == nil { return nil } loc := budapestLocation() seeded, dumpOK, _, dismissed, _ := s.nightLedger.Snapshot() in := nightchain.BannerInput{ Now: now, Window: s.effectiveBackupWindow(), Loc: loc, SeededAt: seeded, DBDumpOK: dumpOK, Dismissed: dismissed, } if s.backupMgr != nil && s.backupMgr.OffboxConfigured() { if t := s.settings.GetOffboxTarget(); t != nil && t.Enabled { in.OffsiteConfigured = true if ok, err := time.Parse(time.RFC3339, t.LastSuccess); err == nil { in.OffsiteOK = ok } } } if s.metricsRecord != nil { ms := s.metricsRecord in.OnAtW = func(w time.Time) (bool, bool) { n, err := ms.SampleCount(w.Add(-5*time.Minute), w.Add(5*time.Minute)) if err != nil { return false, false } return n > 0, true } if counts, err := ms.HourSampleCounts(now.Add(-7*24*time.Hour), now); err == nil { h := nightchain.HourDaysFrom(counts, loc) in.Hours = &h } } b := nightchain.ComputeBanner(in) if !b.Show { return nil } return &b } // addMissedBackupBanner is called from executeTemplate for every authenticated page. func (s *Server) addMissedBackupBanner(data map[string]interface{}, r *http.Request) { if data == nil || r == nil || !s.hasAdminSession(r) { return } b := s.missedBackupBanner(time.Now()) if b == nil { return } lang := s.langFor(r) msg := s.i18n.Msg(lang, "banner.missed_backup.never") if !b.LastBackup.IsZero() { msg = s.i18n.Msgf(lang, "banner.missed_backup.last", b.DaysAgo) } if b.OffAt != "" { msg += " " + s.i18n.Msgf(lang, "banner.missed_backup.off_at", b.OffAt) } hint := s.i18n.Msg(lang, "banner.missed_backup.choose") if b.Suggest != "" { hint = s.i18n.Msgf(lang, "banner.missed_backup.suggest", b.Suggest) } data["MissedBackupBanner"] = true data["MissedBackupMessage"] = msg data["MissedBackupHint"] = hint data["MissedBackupBack"] = r.URL.Path if data["CSRFField"] == nil { data["CSRFField"] = s.csrfField(r) } if s.isDebug() { s.logger.Printf("[DEBUG] [web] missed-backup banner: rendered on %s (last=%s off_at=%q suggest=%q)", r.URL.Path, b.LastBackup.Format(time.RFC3339), b.OffAt, b.Suggest) } } // missedBackupBannerDismissHandler (POST /backups/missed-banner/dismiss) closes the banner until the NEXT missed // backup time — durable, in the night ledger. func (s *Server) missedBackupBannerDismissHandler(w http.ResponseWriter, r *http.Request) { if b := s.missedBackupBanner(time.Now()); b != nil && s.nightLedger != nil { s.nightLedger.DismissBanner(b.MissedAt) s.logger.Printf("[INFO] [web] missed-backup banner: closed by the household until the next missed backup time (after %s)", b.MissedAt.Format(time.RFC3339)) } http.Redirect(w, r, redirectBackTo(r, "/launcher"), http.StatusFound) } // budapestLocation is the backup window's clock. func budapestLocation() *time.Location { loc, err := time.LoadLocation("Europe/Budapest") if err != nil { return time.Local } return loc }