Files

126 lines
4.7 KiB
Go

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
}