package nightchain import ( "fmt" "time" ) // ── The missed-backup banner (R-871, `09` decision 110 — the operator's idea) ────────────────────────────────── // // The household's dashboard says it plainly when a daily backup is missing: when the last one was, that the box // was off at the backup time (when the box's own record says so), and a suggested different time. It NEVER changes // the time by itself. The household can close it; it stays closed until the NEXT missed backup time, and it goes // away by itself after a successful night. // // "The box's own record of when it was on" is the controller's system-metrics table: one sample a minute, kept 30 // days (internal/metrics). An hour with at least 30 samples counts as "on" in that hour. // // Pinned by banner_test.go (TestBanner_*). // StaleAfter: a daily tier with no success for this long shows the banner. 26 h = one night (24 h) plus the chain's // own two hours, the same line as the hub's backupStaleAfter — so the household and the operator see the same // fact at the same time. Decided by CC — operator may reverse. const StaleAfter = 26 * time.Hour // suggestMinDays: an hour is "usually on" when the box was on in it on at least this many of the last 7 days. const suggestMinDays = 5 // HourDays counts, per Budapest hour of day, on how many of the last 7 days the box was on in that hour. type HourDays [24]int // BannerInput is everything the banner rule reads. type BannerInput struct { Now time.Time Window string // W, Budapest HH:MM Loc *time.Location SeededAt time.Time // the ledger's seed: before it nothing is known DBDumpOK time.Time // the last successful database dump OffsiteConfigured bool OffsiteOK time.Time // the off-site tier's LastSuccess Dismissed time.Time // the missed W instant the household closed OnAtW func(t time.Time) (on bool, known bool) // was the box on at t (from the metrics record) Hours *HourDays // nil = no record } // Banner is what the dashboard shows. type Banner struct { Show bool LastBackup time.Time // zero = never DaysAgo int OffAt string // "02:30" when the record says the box was off at the last backup time; "" = unknown/was on Suggest string // "21:00", or "" (no clear pattern — say only "choose a time when the box is usually on") MissedAt time.Time // the last backup time that was missed (what a dismissal records) } // ComputeBanner is the PURE rule. func ComputeBanner(in BannerInput) Banner { last := in.DBDumpOK if in.OffsiteConfigured && (in.OffsiteOK.IsZero() || in.OffsiteOK.Before(last)) { last = in.OffsiteOK } known := last if known.Before(in.SeededAt) { known = in.SeededAt // nothing before the seed is judged (an upgrade must not show a banner at once) } if in.Now.Sub(known) <= StaleAfter { return Banner{} } w, ok := LastInstant(in.Now, in.Window, in.Loc) if !ok { return Banner{} } if !w.After(in.Dismissed) { return Banner{} // closed by the household, and no backup time has been missed since } b := Banner{Show: true, LastBackup: last, MissedAt: w} if !last.IsZero() { b.DaysAgo = int(in.Now.Sub(last).Hours() / 24) } if in.OnAtW != nil { if on, known := in.OnAtW(w); known && !on { b.OffAt = w.In(in.Loc).Format("15:04") } } if in.Hours != nil { b.Suggest = Suggest(*in.Hours, w.In(in.Loc).Hour()) } return b } // Suggest proposes a window start: the LATEST hour H such that H, H+1 and H+2 were all "usually on" (the chain // needs about two hours: the dump at W, the second copy at W+1h, the off-site copy at W+1h45m). Latest, because a // later evening disturbs the household least. "" when there is no such hour, and "" when the CURRENT window's // span is already usually on (a new time would not help; the miss has another cause). Decided by CC — operator // may reverse. func Suggest(h HourDays, currentHour int) string { usual := func(start int) bool { for k := 0; k < 3; k++ { if h[(start+k)%24] < suggestMinDays { return false } } return true } if usual(currentHour) { return "" } for start := 23; start >= 0; start-- { if usual(start) { return fmt.Sprintf("%02d:00", start) } } return "" } // HourDaysFrom folds per-UTC-hour sample counts (unix hour start → samples) into "on how many of the days was the // box on in this Budapest hour": an hour with at least 30 one-minute samples counts as on. func HourDaysFrom(counts map[int64]int, loc *time.Location) HourDays { var h HourDays for start, n := range counts { if n >= 30 { h[time.Unix(start, 0).In(loc).Hour()]++ } } return h }