v0.295.0: a box that was off at its backup time catches up once (R-871, decision 109); the missed-backup banner (decision 110); a late daily timer after a host suspend is skipped
gates / gates (push) Successful in 31s
gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -1116,6 +1116,18 @@ Per-app export creates a self-contained `.fab` file (tar.gz, optionally encrypte
|
||||
The backup system implements a **3-2-1 backup architecture**. Each tier is a **complete,
|
||||
self-sufficient backup** — any single tier can fully restore an app.
|
||||
|
||||
**A box that was off at its backup time catches up (v0.295.0, R-871, `09` decisions 109–110).**
|
||||
`internal/nightchain`. The night ledger (`<data>/night-ledger.json`) records when each nightly backup leg (database
|
||||
dump, second copy, off-site copy) last ran to its end. On a controller start or a host resume, a leg that missed its
|
||||
last scheduled time is made up ONCE, 15 minutes later, in the night's order — backup legs only, never the app-update
|
||||
leg or a Docker step. Several missed nights are one catch-up; a scheduled leg and a catch-up never overlap; the
|
||||
whole-guest backup and the catch-up wait for each other. A daily job whose timer fires over 60 minutes late (a host
|
||||
suspend) is skipped, not run late. The household gets one timeline line (`backup_catchup_done`).
|
||||
**The missed-backup banner:** when the last daily backup is over 26 h old, every page says when it was, that the box
|
||||
was off at the backup time (from the metrics record, one sample a minute), and suggests a time when the box is usually
|
||||
on — it never changes the time itself. Closing it lasts until the next missed backup time; a successful night removes
|
||||
it. Design: `felhom.eu/documentation/architecture/07-backup-architecture.md` §6.1.1.
|
||||
|
||||
**The restore carries the customer's own previous answers (v0.217.0, R-351).**
|
||||
`internal/backup/offbox_placement.go`. Every recovery unit's `manifest.json` records `drive` and
|
||||
`namespace_root`, and its `compose/app.yaml` records `SUBDOMAIN`/`DOMAIN`. Until v0.217.0 nothing read
|
||||
|
||||
@@ -43,6 +43,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/mailrelay"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/nightchain"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/offsiteapply"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/quiesce"
|
||||
@@ -1110,7 +1111,30 @@ func main() {
|
||||
|
||||
// App-data backup: daily database dumps. Disk-tier (restic snapshots,
|
||||
// cross-drive, integrity check, infra backup) has moved to the host agent.
|
||||
sched.Daily("db-dump", dbLeg, func(ctx context.Context) error {
|
||||
// R-871 (v0.295.0, `09` decision 109): the night ledger records when each leg ran to its end, so a box that
|
||||
// was off at W makes the night up ONCE when it comes back (internal/nightchain). Every leg body is wrapped
|
||||
// once and shared by the scheduled run and the catch-up — the two can never drift apart.
|
||||
nightLedger, nerr := nightchain.Open(filepath.Join(cfg.Paths.DataDir, "night-ledger.json"), time.Now())
|
||||
if nerr != nil {
|
||||
logger.Printf("[WARN] [catch-up] the night ledger cannot be read — no catch-up on this box until it can: %v", nerr)
|
||||
}
|
||||
webNightLedger = nightLedger
|
||||
withLeg := func(leg nightchain.Leg, fn func(context.Context) error) func(context.Context) error {
|
||||
return func(ctx context.Context) error {
|
||||
if nightLedger == nil {
|
||||
return fn(ctx)
|
||||
}
|
||||
nightLedger.LegLock.Lock() // a scheduled leg and a catch-up never run at the same time
|
||||
defer nightLedger.LegLock.Unlock()
|
||||
err := fn(ctx)
|
||||
nightLedger.MarkEnded(leg, time.Now())
|
||||
if leg == nightchain.LegDBDump && err == nil {
|
||||
nightLedger.MarkDBDumpOK(time.Now())
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
dbDumpLeg := withLeg(nightchain.LegDBDump, func(ctx context.Context) error {
|
||||
err := backupMgr.RunDBDumps(ctx)
|
||||
if err != nil {
|
||||
notifier.NotifyDBDumpFailed("Adatbázis mentés sikertelen", err.Error())
|
||||
@@ -1119,6 +1143,7 @@ func main() {
|
||||
}
|
||||
return err
|
||||
})
|
||||
sched.Daily("db-dump", dbLeg, dbDumpLeg)
|
||||
|
||||
// ── R-218, THE CONSUME HALF (v0.203.0) ────────────────────────────────────────────────
|
||||
//
|
||||
@@ -1204,10 +1229,11 @@ func main() {
|
||||
})
|
||||
}
|
||||
})
|
||||
sched.Daily("tier2-backup", tier2Leg, func(ctx context.Context) error {
|
||||
tier2LegFn := withLeg(nightchain.LegTier2, func(ctx context.Context) error {
|
||||
backupMgr.RunAllTier2()
|
||||
return nil
|
||||
})
|
||||
sched.Daily("tier2-backup", tier2Leg, tier2LegFn)
|
||||
|
||||
// Off-box (NAS) restic-SFTP backup (Part B): the off-site leg. A failure (incl. a fail-fast dead-NAS
|
||||
// error) alerts the operator via the allowlisted backup_failed event. Daily after Tier 2.
|
||||
@@ -1353,16 +1379,49 @@ func main() {
|
||||
// off-site job — it runs when this function's off-site half has ended, on every path: configured
|
||||
// or not, ok, failed, any of RunOffboxBackup's early returns, even a panic. A box with no off-site
|
||||
// target runs it at W+105m. One call site, no signal to go stale.
|
||||
sched.Daily("offbox-backup", offboxLeg, func(ctx context.Context) error {
|
||||
return chainUpdateLeg(ctx, func(ctx context.Context) error {
|
||||
t := sett.GetOffboxTarget()
|
||||
if t == nil || !t.Enabled || t.Schedule != "daily" || !backupMgr.OffboxConfigured() {
|
||||
logger.Printf("[INFO] [offbox] no scheduled off-site target on this box — the off-site leg does nothing; the update leg runs now")
|
||||
return nil // not configured / not scheduled
|
||||
}
|
||||
return backupMgr.RunOffboxBackup(ctx)
|
||||
}, func(ctx context.Context) { stackMgr.RunUpdateLeg(ctx, "after-offsite") })
|
||||
offsiteOnly := withLeg(nightchain.LegOffsite, func(ctx context.Context) error {
|
||||
t := sett.GetOffboxTarget()
|
||||
if t == nil || !t.Enabled || t.Schedule != "daily" || !backupMgr.OffboxConfigured() {
|
||||
logger.Printf("[INFO] [offbox] no scheduled off-site target on this box — the off-site leg does nothing")
|
||||
return nil // not configured / not scheduled
|
||||
}
|
||||
return backupMgr.RunOffboxBackup(ctx)
|
||||
})
|
||||
sched.Daily("offbox-backup", offboxLeg, func(ctx context.Context) error {
|
||||
return chainUpdateLeg(ctx, offsiteOnly, func(ctx context.Context) { stackMgr.RunUpdateLeg(ctx, "after-offsite") })
|
||||
})
|
||||
// R-871: the catch-up — the three BACKUP legs only (no update leg: it restarts apps and waits for a real
|
||||
// night). Triggered at start and on a host resume; it waits 15 minutes, and it waits for a whole-guest
|
||||
// backup (and that backup waits for it). Pinned by internal/nightchain + TestR871_CatchUpWiring.
|
||||
if nightLedger != nil {
|
||||
catchUp := &nightchain.CatchUp{
|
||||
Ledger: nightLedger,
|
||||
Window: func() string {
|
||||
return backupwindow.EffectiveWindow(sett.GetBackupWindowStart(), cfg.Backup.DBDumpSchedule)
|
||||
},
|
||||
Legs: catchUpLegs(dbDumpLeg, tier2LegFn, offsiteOnly),
|
||||
QuiesceBusy: func() bool {
|
||||
return quiesceLoop != nil && len(quiesceLoop.SuppressedStacks()) > 0
|
||||
},
|
||||
Event: func(missedAt time.Time) {
|
||||
notifier.NotifyBackupCatchUp(missedAt.In(budapestLoc()).Format("15:04"))
|
||||
},
|
||||
Logger: logger,
|
||||
Loc: budapestLoc(),
|
||||
}
|
||||
if quiesceLoop != nil {
|
||||
quiesceLoop.SetCatchUpFn(catchUp.Running)
|
||||
}
|
||||
catchUp.Evaluate(ctx, "controller start")
|
||||
resumeTick := time.NewTicker(time.Minute)
|
||||
go nightchain.ResumeWatch(ctx, resumeTick.C,
|
||||
func() time.Time { return time.Now().Round(0) },
|
||||
func() time.Duration { return time.Since(startTime) },
|
||||
5*time.Minute,
|
||||
func(slept time.Duration) {
|
||||
catchUp.Evaluate(ctx, fmt.Sprintf("host resume (suspended ~%s)", slept.Round(time.Minute)))
|
||||
})
|
||||
}
|
||||
// R-241 — the abandonment terminal step. DAILY and not on the backup leg, deliberately: it must
|
||||
// run on a box whose off-site tier is NOT configured for runs (an abandoning box may be sitting
|
||||
// with escrow pending), and tying it to the backup leg would make the deletion depend on a
|
||||
@@ -1815,6 +1874,8 @@ func main() {
|
||||
|
||||
// --- Initialize web server ---
|
||||
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, sett, alertMgr, notifier, updater, logger, Version)
|
||||
// R-871 (decision 110): the missed-backup banner reads the night ledger and the box's own on/off record.
|
||||
webServer.SetMissedBackupBanner(webNightLedger, metricsStore)
|
||||
// Migration done-hook: a decommission-initiated migration finalizes the source decommission on
|
||||
// success (soft-mark + agent). Wire it before RecoverMigration so a resumed one still finalizes.
|
||||
stackMgr.SetMigrationDoneHook(webServer.OnMigrationDone)
|
||||
@@ -3910,6 +3971,19 @@ func stopUnhealthyApps(logger *log.Logger, d unhealthyDeps, ooms []stacks.OOMCon
|
||||
// returned nil (ran, or had nothing to do), returned an error, or panicked. The off-site half's error is
|
||||
// returned (the scheduler logs it); a panic becomes an error. `09` §6.4.2 point 2; pinned by
|
||||
// TestChainUpdateLeg_EveryPath.
|
||||
// catchUpLegs is the catch-up's leg table (R-871): the three backup legs and NOTHING else — no update leg, no
|
||||
// Docker step. A function so the table is one reviewable place; pinned by TestR871_CatchUpWiring.
|
||||
func catchUpLegs(db, tier2, offsite func(context.Context) error) map[nightchain.Leg]func(context.Context) error {
|
||||
return map[nightchain.Leg]func(context.Context) error{
|
||||
nightchain.LegDBDump: db,
|
||||
nightchain.LegTier2: tier2,
|
||||
nightchain.LegOffsite: offsite,
|
||||
}
|
||||
}
|
||||
|
||||
// webNightLedger hands the ledger to the web server (the missed-backup banner); nil when backups are off.
|
||||
var webNightLedger *nightchain.Ledger
|
||||
|
||||
func chainUpdateLeg(ctx context.Context, offsite func(context.Context) error, leg func(context.Context)) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go/ast"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/nightchain"
|
||||
)
|
||||
|
||||
// TestR871_CatchUpWiring — v0.295.0 (`09` decision 109): the catch-up is built, triggered at start and on a host
|
||||
// resume, and holds the whole-guest cycle — the seam-built-but-never-wired class (four earlier instances).
|
||||
// COMPANION RED-PROOF: delete the `catchUp.Evaluate(ctx, "controller start")` line → this fails.
|
||||
func TestR871_CatchUpWiring(t *testing.T) {
|
||||
lines, _, _ := slice4CallLines(t)
|
||||
for _, call := range []string{"ResumeWatch", "SetCatchUpFn", "catchUpLegs", "NotifyBackupCatchUp"} {
|
||||
if len(lines[call]) == 0 {
|
||||
t.Errorf("%s is never called in main.go — the catch-up is built but not wired", call)
|
||||
}
|
||||
}
|
||||
// Both triggers, by their reason argument: the start (a literal) and the resume (inside ResumeWatch's callback).
|
||||
_, f, _ := slice4CallLines(t)
|
||||
start := 0
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if call, ok := n.(*ast.CallExpr); ok {
|
||||
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Evaluate" && len(call.Args) == 2 {
|
||||
if lit, ok := call.Args[1].(*ast.BasicLit); ok && lit.Value == `"controller start"` {
|
||||
start++
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if start != 1 {
|
||||
t.Errorf("the catch-up's START trigger (Evaluate(ctx, \"controller start\")) is called %d times, want 1", start)
|
||||
}
|
||||
}
|
||||
|
||||
// The catch-up's leg table holds the three BACKUP legs and nothing else, and the off-site body it shares with the
|
||||
// night never calls the update leg (that is chained only around the SCHEDULED off-site job).
|
||||
// COMPANION RED-PROOF: put `stackMgr.RunUpdateLeg` inside offsiteOnly's body → "the update leg is inside".
|
||||
func TestR871_CatchUpRunsNoUpdateLeg(t *testing.T) {
|
||||
noop := func(context.Context) error { return nil }
|
||||
legs := catchUpLegs(noop, noop, noop)
|
||||
if len(legs) != 3 || legs[nightchain.LegDBDump] == nil || legs[nightchain.LegTier2] == nil || legs[nightchain.LegOffsite] == nil {
|
||||
t.Fatalf("catchUpLegs = %v, want exactly db-dump, tier2, offsite", legs)
|
||||
}
|
||||
src, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
i := strings.Index(s, "offsiteOnly := withLeg(nightchain.LegOffsite,")
|
||||
j := strings.Index(s[i:], "sched.Daily(\"offbox-backup\"")
|
||||
if i < 0 || j < 0 {
|
||||
t.Fatal("the shared off-site leg body was not found in main.go")
|
||||
}
|
||||
if body := s[i : i+j]; strings.Contains(body, "RunUpdateLeg") || strings.Contains(body, "chainUpdateLeg") {
|
||||
t.Fatal("the update leg is inside the off-site body the catch-up runs")
|
||||
}
|
||||
}
|
||||
@@ -1374,6 +1374,14 @@
|
||||
"event.app_update_undone": "The update of %s at %s did not work. The box put back the previous version and its data automatically — nothing was lost, and there is nothing you need to do.",
|
||||
"event.backup_target_absent": "The whole-system backup drive is not available: %s (%s)",
|
||||
"event.backup_target_restored": "The whole-system backup drive is available again: %s (%s)",
|
||||
"event.backup_catchup_done": "Missed backup made now: the box was off at %s, so the backup ran when it came back on.",
|
||||
"banner.missed_backup.never": "No daily backup has been made yet.",
|
||||
"banner.missed_backup.last": "Your last backup was %d day(s) ago.",
|
||||
"banner.missed_backup.off_at": "The box was off at backup time (%s).",
|
||||
"banner.missed_backup.choose": "Choose a time when the box is usually on.",
|
||||
"banner.missed_backup.suggest": "Choose a time when the box is usually on — for example %s.",
|
||||
"layout.missed_backup_change": "Change the backup time",
|
||||
"layout.missed_backup_close": "Close",
|
||||
"event.controller_started": "Controller started (%s)",
|
||||
"event.controller_update_failed": "Controller update failed: %s → %s",
|
||||
"event.controller_updated": "Controller updated: %s → %s",
|
||||
|
||||
@@ -1365,6 +1365,14 @@
|
||||
"event.app_update_undone": "A(z) %s frissítése %s-kor nem sikerült. A doboz automatikusan visszaállította az előző változatot és az adatokat — semmi nem veszett el, nincs teendőd.",
|
||||
"event.backup_target_absent": "A rendszermentés meghajtója nem érhető el: %s (%s)",
|
||||
"event.backup_target_restored": "A rendszermentés meghajtója újra elérhető: %s (%s)",
|
||||
"event.backup_catchup_done": "Kimaradt mentés pótolva: a doboz ki volt kapcsolva %s-kor, a mentés most elkészült.",
|
||||
"banner.missed_backup.never": "Még nem készült napi mentés.",
|
||||
"banner.missed_backup.last": "A legutóbbi mentés %d nappal ezelőtt készült.",
|
||||
"banner.missed_backup.off_at": "A doboz ki volt kapcsolva a mentés idején (%s).",
|
||||
"banner.missed_backup.choose": "Válassz egy olyan időpontot, amikor a doboz általában be van kapcsolva.",
|
||||
"banner.missed_backup.suggest": "Válassz egy olyan időpontot, amikor a doboz általában be van kapcsolva — például %s.",
|
||||
"layout.missed_backup_change": "Mentési idő módosítása",
|
||||
"layout.missed_backup_close": "Bezárás",
|
||||
"event.controller_started": "Controller elindult (%s)",
|
||||
"event.controller_update_failed": "Controller frissítés sikertelen: %s → %s",
|
||||
"event.controller_updated": "Controller frissítve: %s → %s",
|
||||
|
||||
@@ -329,3 +329,31 @@ func (s *MetricsStore) Prune(olderThan time.Duration) (int64, error) {
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// SampleCount is how many system samples (one a minute) exist in [from, to) — the box's own record of whether it
|
||||
// was on (R-871 banner: "the box was off at 02:30").
|
||||
func (s *MetricsStore) SampleCount(from, to time.Time) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM system_metrics WHERE ts >= ? AND ts < ?`, from.Unix(), to.Unix()).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// HourSampleCounts returns the number of system samples per UTC hour start (unix seconds) in [from, to) — the
|
||||
// banner's "when is this box usually on" pattern (R-871). At most 24×7 rows for a week.
|
||||
func (s *MetricsStore) HourSampleCounts(from, to time.Time) (map[int64]int, error) {
|
||||
rows, err := s.db.Query(`SELECT (ts / 3600) * 3600 AS h, COUNT(*) FROM system_metrics WHERE ts >= ? AND ts < ? GROUP BY h`, from.Unix(), to.Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int64]int{}
|
||||
for rows.Next() {
|
||||
var h int64
|
||||
var n int
|
||||
if err := rows.Scan(&h, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[h] = n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package nightchain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-871 / decision 110 — the banner's rules. Red-proofs: catchup-2026-10-05/partB/red-proofs.txt.
|
||||
|
||||
func laptop() *HourDays { // on 18:00–23:59 every day of the week (Tester 2's shape)
|
||||
var h HourDays
|
||||
for k := 18; k <= 23; k++ {
|
||||
h[k] = 7
|
||||
}
|
||||
return &h
|
||||
}
|
||||
|
||||
func offAt(on bool) func(time.Time) (bool, bool) {
|
||||
return func(time.Time) (bool, bool) { return on, true }
|
||||
}
|
||||
|
||||
func base(now time.Time) BannerInput {
|
||||
return BannerInput{Now: now, Window: "02:30", Loc: bud, SeededAt: at(1, 12, 0), DBDumpOK: at(3, 2, 31),
|
||||
OnAtW: offAt(false), Hours: laptop()}
|
||||
}
|
||||
|
||||
// Shown: the last dump is 2+ days old, the box was off at 02:30, and the record suggests 21:00.
|
||||
// RED-PROOF: return Banner{} when stale (the pre-v0.295.0 silence) → "not shown".
|
||||
func TestBanner_ShownWithReasonAndSuggestion(t *testing.T) {
|
||||
b := ComputeBanner(base(at(5, 19, 0)))
|
||||
if !b.Show {
|
||||
t.Fatal("not shown although the last backup is 2 days old")
|
||||
}
|
||||
if b.OffAt != "02:30" || b.Suggest != "21:00" || b.DaysAgo != 2 {
|
||||
t.Fatalf("banner = %+v, want off at 02:30, suggest 21:00, 2 days ago", b)
|
||||
}
|
||||
}
|
||||
|
||||
// Not shown: a backup within the last 26 h.
|
||||
func TestBanner_NotShownWhenFresh(t *testing.T) {
|
||||
in := base(at(5, 19, 0))
|
||||
in.DBDumpOK = at(5, 2, 31)
|
||||
if b := ComputeBanner(in); b.Show {
|
||||
t.Fatalf("shown although the last dump is 16 h old: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// Not shown right after an upgrade (the ledger was seeded less than 26 h ago and knows no dump).
|
||||
func TestBanner_NotShownBeforeTheRecordKnows(t *testing.T) {
|
||||
in := base(at(5, 19, 0))
|
||||
in.SeededAt, in.DBDumpOK = at(5, 9, 0), time.Time{}
|
||||
if b := ComputeBanner(in); b.Show {
|
||||
t.Fatalf("shown on the day of the upgrade: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// Dismissed: closed until the NEXT missed backup time, then back.
|
||||
// RED-PROOF: drop the Dismissed check → "a closed banner came back without a new miss".
|
||||
func TestBanner_DismissedThenBackAfterANewMiss(t *testing.T) {
|
||||
in := base(at(5, 19, 0))
|
||||
b := ComputeBanner(in)
|
||||
in.Dismissed = b.MissedAt // the household closed it (02:30 on the 5th)
|
||||
if ComputeBanner(in).Show {
|
||||
t.Fatal("a closed banner came back without a new miss")
|
||||
}
|
||||
in.Now = at(5, 23, 0) // same evening — still the same miss
|
||||
if ComputeBanner(in).Show {
|
||||
t.Fatal("a closed banner came back the same evening")
|
||||
}
|
||||
in.Now = at(6, 19, 0) // off again at 02:30 on the 6th
|
||||
if !ComputeBanner(in).Show {
|
||||
t.Fatal("the banner did not come back after the next missed backup time")
|
||||
}
|
||||
}
|
||||
|
||||
// Gone by itself after a successful night (e.g. the catch-up ran).
|
||||
func TestBanner_GoneAfterASuccess(t *testing.T) {
|
||||
in := base(at(5, 19, 0))
|
||||
in.DBDumpOK = at(5, 18, 20) // the catch-up's dump
|
||||
if ComputeBanner(in).Show {
|
||||
t.Fatal("still shown after a successful backup")
|
||||
}
|
||||
}
|
||||
|
||||
// The off-site tier counts when configured: a fresh dump with a 3-day-old off-site copy still shows.
|
||||
func TestBanner_OffsiteTierCounts(t *testing.T) {
|
||||
in := base(at(5, 19, 0))
|
||||
in.DBDumpOK = at(5, 18, 20)
|
||||
in.OffsiteConfigured, in.OffsiteOK = true, at(2, 4, 20)
|
||||
b := ComputeBanner(in)
|
||||
if !b.Show || b.DaysAgo != 3 {
|
||||
t.Fatalf("banner = %+v, want shown with the off-site copy's 3 days", b)
|
||||
}
|
||||
}
|
||||
|
||||
// No clear pattern → no suggestion ("choose a time when the box is usually on"); box ON at W → no "off at".
|
||||
// RED-PROOF: let Suggest return the first start without checking the days → a time is suggested from noise.
|
||||
func TestBanner_NoPatternNoSuggestion(t *testing.T) {
|
||||
in := base(at(5, 19, 0))
|
||||
var noisy HourDays
|
||||
for k := 18; k <= 23; k++ {
|
||||
noisy[k] = 2 // on some evenings — 2 of 7 days is not "usually"
|
||||
}
|
||||
in.Hours, in.OnAtW = &noisy, offAt(true)
|
||||
b := ComputeBanner(in)
|
||||
if !b.Show || b.Suggest != "" || b.OffAt != "" {
|
||||
t.Fatalf("banner = %+v, want shown, no suggestion, no 'off at'", b)
|
||||
}
|
||||
}
|
||||
|
||||
// A box usually ON at its window gets no new time (the miss has another cause).
|
||||
func TestBanner_UsuallyOnAtWNoSuggestion(t *testing.T) {
|
||||
var always HourDays
|
||||
for k := range always {
|
||||
always[k] = 7
|
||||
}
|
||||
if s := Suggest(always, 2); s != "" {
|
||||
t.Fatalf("suggested %q for a box that is on at its window", s)
|
||||
}
|
||||
if s := Suggest(*laptop(), 2); s != "21:00" {
|
||||
t.Fatalf("laptop suggestion %q, want 21:00", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// Package nightchain makes a missed night's backups up once, when the box comes back (R-871, `09` §3 decision 109,
|
||||
// design `07` §6.1.1).
|
||||
//
|
||||
// THE PROBLEM, MEASURED (2026-10-05 Part F spike, Tester 2 — a laptop switched off at night): the controller's
|
||||
// daily jobs always schedule the NEXT future time (scheduler.nextDailyRun), and nothing remembers that a night was
|
||||
// missed. A box that is off at its window W never gets its database dumps, its second copy or its off-site copy —
|
||||
// for ever, with no alarm.
|
||||
//
|
||||
// THE MECHANISM:
|
||||
// - A LEDGER (`night-ledger.json` in the data dir) records when each of the three nightly backup legs last RAN TO
|
||||
// ITS END. It is an attempt record and is used for ONE question only — "was this leg's last scheduled time
|
||||
// missed?" — never as evidence that a backup exists (that is LastSuccess's job, R-100). A leg that ran and
|
||||
// failed was NOT missed: failures have their own alarms.
|
||||
// - On a controller START and on a host RESUME (a suspended laptop: Go timers run on CLOCK_MONOTONIC, which does
|
||||
// not count suspended time), Evaluate asks the ledger which legs missed their last scheduled time. If any did,
|
||||
// ONE catch-up is scheduled 15 minutes later (apps settle; a box switched on and off again at once does nothing).
|
||||
// - The catch-up runs ONLY the backup legs, in their normal order (database dump, second copy, off-site copy).
|
||||
// It never runs app updates or Docker steps — they restart apps and wait for a real night.
|
||||
// - Several missed nights are ONE catch-up (the question is about the LAST scheduled time only). A normal night
|
||||
// followed by a daytime restart is none. A power cut in the middle of the chain makes up only the legs that did
|
||||
// not end.
|
||||
// - A leg whose own scheduled time is less than 30 minutes away is left to its normal run.
|
||||
// - Every leg — scheduled or catch-up — holds one mutex (`Ledger.LegLock`), so a catch-up and a scheduled leg
|
||||
// never run at the same time. The whole-guest backup (agent-side, quiesce) waits while a catch-up runs
|
||||
// (quiesce.Options.CatchUpFn) and the catch-up waits while a quiesce holds the apps (QuiesceBusy).
|
||||
//
|
||||
// A ledger that does not exist yet (the first start on this release) is SEEDED at that moment and nothing before
|
||||
// it counts as missed — an upgrade must not set off a surprise catch-up on every box.
|
||||
//
|
||||
// Pinned by nightchain_test.go (TestCatchUp_*), each rule with a red-proof.
|
||||
package nightchain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow"
|
||||
)
|
||||
|
||||
// Leg is one nightly backup leg.
|
||||
type Leg string
|
||||
|
||||
const (
|
||||
LegDBDump Leg = "db-dump"
|
||||
LegTier2 Leg = "tier2"
|
||||
LegOffsite Leg = "offsite"
|
||||
)
|
||||
|
||||
// Order is the night chain's own order (07 §6.1).
|
||||
var Order = []Leg{LegDBDump, LegTier2, LegOffsite}
|
||||
|
||||
const (
|
||||
// DefaultDelay: the catch-up waits this long after the trigger (decision 109's "soon after it is switched on").
|
||||
DefaultDelay = 15 * time.Minute
|
||||
// leaveToNormal: a leg whose own scheduled time is closer than this runs normally, not in the catch-up.
|
||||
leaveToNormal = 30 * time.Minute
|
||||
// quiesceWaitMax: how long a catch-up waits for a whole-guest backup that holds the apps.
|
||||
quiesceWaitMax = 2 * time.Hour
|
||||
)
|
||||
|
||||
type ledgerData struct {
|
||||
SeededAt time.Time `json:"seeded_at"`
|
||||
Ended map[Leg]time.Time `json:"ended"` // ATTEMPT record: the leg ran to its end (success or failure)
|
||||
// DBDumpOK is the last SUCCESSFUL database dump (the banner's evidence; the off-site tier has its own
|
||||
// LastSuccess in settings). Written only on success (R-100's rule).
|
||||
DBDumpOK time.Time `json:"db_dump_ok,omitempty"`
|
||||
LastCatchUp time.Time `json:"last_catch_up,omitempty"`
|
||||
BannerDismissed time.Time `json:"banner_dismissed_through,omitempty"` // the missed W instant the household closed
|
||||
}
|
||||
|
||||
// Ledger is the persisted night record.
|
||||
type Ledger struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
d ledgerData
|
||||
// LegLock is held by every leg run, scheduled or catch-up.
|
||||
LegLock sync.Mutex
|
||||
}
|
||||
|
||||
// Open reads the ledger, or seeds a new one at now.
|
||||
func Open(path string, now time.Time) (*Ledger, error) {
|
||||
l := &Ledger{path: path, d: ledgerData{Ended: map[Leg]time.Time{}}}
|
||||
b, err := os.ReadFile(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
if jerr := json.Unmarshal(b, &l.d); jerr != nil {
|
||||
return nil, fmt.Errorf("nightchain: ledger unreadable: %w", jerr)
|
||||
}
|
||||
if l.d.Ended == nil {
|
||||
l.d.Ended = map[Leg]time.Time{}
|
||||
}
|
||||
case os.IsNotExist(err):
|
||||
l.d.SeededAt = now.UTC()
|
||||
if serr := l.saveLocked(); serr != nil {
|
||||
return nil, serr
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("nightchain: ledger: %w", err)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *Ledger) saveLocked() error {
|
||||
b, _ := json.MarshalIndent(l.d, "", " ")
|
||||
if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := l.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, l.path)
|
||||
}
|
||||
|
||||
// MarkEnded records that a leg ran to its end (success or failure).
|
||||
func (l *Ledger) MarkEnded(leg Leg, at time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.d.Ended[leg] = at.UTC()
|
||||
_ = l.saveLocked()
|
||||
}
|
||||
|
||||
// MarkDBDumpOK records a successful database dump.
|
||||
func (l *Ledger) MarkDBDumpOK(at time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.d.DBDumpOK = at.UTC()
|
||||
_ = l.saveLocked()
|
||||
}
|
||||
|
||||
func (l *Ledger) markCatchUp(at time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.d.LastCatchUp = at.UTC()
|
||||
_ = l.saveLocked()
|
||||
}
|
||||
|
||||
// DismissBanner records that the household closed the banner for every miss up to `through`.
|
||||
func (l *Ledger) DismissBanner(through time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.d.BannerDismissed = through.UTC()
|
||||
_ = l.saveLocked()
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the record (for the banner and the debug page).
|
||||
func (l *Ledger) Snapshot() (seeded, dbDumpOK, lastCatchUp, dismissed time.Time, ended map[Leg]time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
ended = map[Leg]time.Time{}
|
||||
for k, v := range l.d.Ended {
|
||||
ended[k] = v
|
||||
}
|
||||
return l.d.SeededAt, l.d.DBDumpOK, l.d.LastCatchUp, l.d.BannerDismissed, ended
|
||||
}
|
||||
|
||||
// legOffsets: each leg's time relative to W — taken from backupwindow.LegTimes so the two can never disagree.
|
||||
func legTimes(window string) map[Leg]string {
|
||||
db, t2, off := backupwindow.LegTimes(window)
|
||||
return map[Leg]string{LegDBDump: db, LegTier2: t2, LegOffsite: off}
|
||||
}
|
||||
|
||||
// LastInstant is the most recent occurrence of the Budapest clock time hhmm at or before now.
|
||||
func LastInstant(now time.Time, hhmm string, loc *time.Location) (time.Time, bool) {
|
||||
min, err := backupwindow.ParseHHMM(hhmm)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
n := now.In(loc)
|
||||
t := time.Date(n.Year(), n.Month(), n.Day(), min/60, min%60, 0, 0, loc)
|
||||
if t.After(n) {
|
||||
t = time.Date(n.Year(), n.Month(), n.Day()-1, min/60, min%60, 0, 0, loc)
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// NextInstant is the next occurrence of hhmm strictly after now.
|
||||
func NextInstant(now time.Time, hhmm string, loc *time.Location) (time.Time, bool) {
|
||||
last, ok := LastInstant(now, hhmm, loc)
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Date(last.Year(), last.Month(), last.Day()+1, last.Hour(), last.Minute(), 0, 0, loc), true
|
||||
}
|
||||
|
||||
// Missed is the PURE rule: the legs (in chain order) whose last scheduled time passed after the ledger was seeded
|
||||
// and that have not run to their end since — minus a leg whose next scheduled time is under 30 minutes away.
|
||||
// lastW is the most recent missed leg instant (the banner's "the box was off at …").
|
||||
func (l *Ledger) Missed(now time.Time, window string, loc *time.Location) (legs []Leg, lastW time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
lt := legTimes(window)
|
||||
for _, leg := range Order {
|
||||
inst, ok := LastInstant(now, lt[leg], loc)
|
||||
if !ok || !inst.After(l.d.SeededAt) {
|
||||
continue
|
||||
}
|
||||
if !l.d.Ended[leg].Before(inst) {
|
||||
continue // ran to its end at or after its last scheduled time
|
||||
}
|
||||
if next, ok := NextInstant(now, lt[leg], loc); ok && next.Sub(now) < leaveToNormal {
|
||||
continue // its normal run is about to happen
|
||||
}
|
||||
legs = append(legs, leg)
|
||||
if inst.After(lastW) {
|
||||
lastW = inst
|
||||
}
|
||||
}
|
||||
return legs, lastW
|
||||
}
|
||||
|
||||
// CatchUp runs at most one catch-up at a time.
|
||||
type CatchUp struct {
|
||||
Ledger *Ledger
|
||||
Window func() string // the household's backup window W (settings > config > 02:30)
|
||||
Legs map[Leg]func(context.Context) error // the leg bodies — WITHOUT the update leg
|
||||
QuiesceBusy func() bool // a whole-guest backup holds the apps now
|
||||
Event func(missedAt time.Time) // the household's timeline line
|
||||
Logger *log.Logger
|
||||
Delay time.Duration
|
||||
Loc *time.Location
|
||||
Now func() time.Time
|
||||
// Sleep waits d or until ctx ends (false). A seam for tests.
|
||||
Sleep func(ctx context.Context, d time.Duration) bool
|
||||
|
||||
mu sync.Mutex
|
||||
pending bool
|
||||
running bool
|
||||
}
|
||||
|
||||
func (c *CatchUp) now() time.Time {
|
||||
if c.Now != nil {
|
||||
return c.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (c *CatchUp) sleep(ctx context.Context, d time.Duration) bool {
|
||||
if c.Sleep != nil {
|
||||
return c.Sleep(ctx, d)
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CatchUp) delay() time.Duration {
|
||||
if c.Delay > 0 {
|
||||
return c.Delay
|
||||
}
|
||||
return DefaultDelay
|
||||
}
|
||||
|
||||
// Running reports whether a catch-up is running its legs now (the whole-guest backup waits for it).
|
||||
func (c *CatchUp) Running() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.running
|
||||
}
|
||||
|
||||
// Evaluate is the trigger (controller start, host resume). It schedules ONE catch-up when a leg was missed and
|
||||
// none is pending; it returns what it scheduled (nil = nothing missed or one already pending). The work runs in
|
||||
// its own goroutine; Wait-style callers use EvaluateSync.
|
||||
func (c *CatchUp) Evaluate(ctx context.Context, why string) []Leg {
|
||||
legs, lastW := c.Ledger.Missed(c.now(), c.Window(), c.Loc)
|
||||
if len(legs) == 0 {
|
||||
c.Logger.Printf("[INFO] [catch-up] %s: no backup leg missed its last scheduled time — nothing to make up", why)
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.pending || c.running {
|
||||
c.mu.Unlock()
|
||||
c.Logger.Printf("[INFO] [catch-up] %s: missed %v, but a catch-up is already scheduled", why, legs)
|
||||
return nil
|
||||
}
|
||||
c.pending = true
|
||||
c.mu.Unlock()
|
||||
c.Logger.Printf("[INFO] [catch-up] %s: the box missed %v (last scheduled %s) — ONE catch-up in %s (backup legs only; app updates wait for a real night)",
|
||||
why, legs, lastW.In(c.Loc).Format("2006-01-02 15:04"), c.delay())
|
||||
go c.run(ctx, why)
|
||||
return legs
|
||||
}
|
||||
|
||||
// run waits, re-reads the ledger (a normal run may have happened meanwhile), waits out a whole-guest backup, then
|
||||
// runs the still-missed legs in chain order.
|
||||
func (c *CatchUp) run(ctx context.Context, why string) {
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.pending, c.running = false, false
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
if !c.sleep(ctx, c.delay()) {
|
||||
return
|
||||
}
|
||||
legs, lastW := c.Ledger.Missed(c.now(), c.Window(), c.Loc)
|
||||
if len(legs) == 0 {
|
||||
c.Logger.Printf("[INFO] [catch-up] (%s) nothing is missed any more — not run", why)
|
||||
return
|
||||
}
|
||||
for waited := time.Duration(0); c.QuiesceBusy != nil && c.QuiesceBusy(); waited += time.Minute {
|
||||
if waited >= quiesceWaitMax {
|
||||
c.Logger.Printf("[WARN] [catch-up] (%s) a whole-guest backup held the apps for %s — catch-up dropped; the next start or resume tries again", why, waited)
|
||||
return
|
||||
}
|
||||
if !c.sleep(ctx, time.Minute) {
|
||||
return
|
||||
}
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.running = true
|
||||
c.mu.Unlock()
|
||||
start := c.now()
|
||||
var names []string
|
||||
for _, leg := range legs {
|
||||
fn := c.Legs[leg]
|
||||
if fn == nil {
|
||||
continue
|
||||
}
|
||||
c.Logger.Printf("[INFO] [catch-up] running the missed %s leg", leg)
|
||||
if err := fn(ctx); err != nil {
|
||||
c.Logger.Printf("[WARN] [catch-up] the %s leg ended with an error (its own alarm reports it): %v", leg, err)
|
||||
}
|
||||
names = append(names, string(leg))
|
||||
}
|
||||
c.Ledger.markCatchUp(c.now())
|
||||
sort.Strings(names)
|
||||
c.Logger.Printf("[INFO] [catch-up] done: %s in %s (missed at %s)", strings.Join(names, ", "),
|
||||
c.now().Sub(start).Round(time.Second), lastW.In(c.Loc).Format("2006-01-02 15:04"))
|
||||
if c.Event != nil {
|
||||
c.Event(lastW)
|
||||
}
|
||||
}
|
||||
|
||||
// ResumeWatch calls onResume when, between two ticks, the WALL clock advanced more than gap beyond the MONOTONIC
|
||||
// clock — the shape of a host suspend/resume: Go's timers and monotonic readings run on CLOCK_MONOTONIC, which does
|
||||
// not count suspended time, while the wall clock does. Seams: wall() is the wall clock, mono() a monotonic elapsed
|
||||
// duration (production: time.Now().Round(0) and time.Since(a fixed start)). Pinned by TestResumeWatch_*.
|
||||
func ResumeWatch(ctx context.Context, tick <-chan time.Time, wall func() time.Time, mono func() time.Duration, gap time.Duration, onResume func(slept time.Duration)) {
|
||||
pw, pm := wall(), mono()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick:
|
||||
}
|
||||
cw, cm := wall(), mono()
|
||||
if d := cw.Sub(pw) - (cm - pm); d > gap {
|
||||
onResume(d)
|
||||
}
|
||||
pw, pm = cw, cm
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package nightchain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-871 (`09` decision 109). Every rule of the catch-up, each with a COMPANION RED-PROOF recorded in
|
||||
// felhom.eu/documentation/audits/catchup-2026-10-05/partA/red-proofs.txt.
|
||||
|
||||
var bud = func() *time.Location { l, _ := time.LoadLocation("Europe/Budapest"); return l }()
|
||||
|
||||
func at(day, hh, mm int) time.Time { return time.Date(2026, 10, day, hh, mm, 0, 0, bud) }
|
||||
|
||||
type harness struct {
|
||||
l *Ledger
|
||||
c *CatchUp
|
||||
mu sync.Mutex
|
||||
ran []Leg
|
||||
events []time.Time
|
||||
slept []time.Duration
|
||||
now time.Time
|
||||
busy int // QuiesceBusy answers true this many times
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T, seeded, now time.Time) *harness {
|
||||
t.Helper()
|
||||
l, err := Open(filepath.Join(t.TempDir(), "night-ledger.json"), seeded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &harness{l: l, now: now, done: make(chan struct{}, 4)}
|
||||
leg := func(g Leg) func(context.Context) error {
|
||||
return func(context.Context) error {
|
||||
h.mu.Lock()
|
||||
h.ran = append(h.ran, g)
|
||||
h.mu.Unlock()
|
||||
l.MarkEnded(g, h.clock())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
h.c = &CatchUp{
|
||||
Ledger: l, Window: func() string { return "02:30" }, Loc: bud,
|
||||
Legs: map[Leg]func(context.Context) error{LegDBDump: leg(LegDBDump), LegTier2: leg(LegTier2), LegOffsite: leg(LegOffsite)},
|
||||
Logger: log.New(io.Discard, "", 0),
|
||||
Now: h.clock,
|
||||
Sleep: func(_ context.Context, d time.Duration) bool {
|
||||
h.mu.Lock()
|
||||
h.slept = append(h.slept, d)
|
||||
h.now = h.now.Add(d)
|
||||
h.mu.Unlock()
|
||||
return true
|
||||
},
|
||||
QuiesceBusy: func() bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.busy > 0 {
|
||||
h.busy--
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
Event: func(w time.Time) {
|
||||
h.mu.Lock()
|
||||
h.events = append(h.events, w)
|
||||
h.mu.Unlock()
|
||||
h.done <- struct{}{}
|
||||
},
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *harness) clock() time.Time { h.mu.Lock(); defer h.mu.Unlock(); return h.now }
|
||||
|
||||
func (h *harness) wait(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-h.done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the catch-up never finished")
|
||||
}
|
||||
}
|
||||
|
||||
// Off at W (02:30 on the 5th), switched on at 09:00: ONE catch-up, 15 minutes later, all three legs in chain order.
|
||||
// RED-PROOF: make Missed return nothing (the pre-v0.295.0 behaviour) → "no catch-up was scheduled".
|
||||
func TestCatchUp_OffAtWThenOn_OneCatchUpAfter15Min(t *testing.T) {
|
||||
h := newHarness(t, at(1, 12, 0), at(5, 9, 0))
|
||||
h.l.MarkEnded(LegDBDump, at(4, 2, 31)) // the night of the 4th ran
|
||||
h.l.MarkEnded(LegTier2, at(4, 3, 31))
|
||||
h.l.MarkEnded(LegOffsite, at(4, 4, 20))
|
||||
legs := h.c.Evaluate(context.Background(), "start")
|
||||
if len(legs) != 3 {
|
||||
t.Fatalf("no catch-up was scheduled for a box off across W (missed %v)", legs)
|
||||
}
|
||||
h.wait(t)
|
||||
if len(h.slept) == 0 || h.slept[0] != DefaultDelay {
|
||||
t.Fatalf("the catch-up must wait %s first, slept %v", DefaultDelay, h.slept)
|
||||
}
|
||||
if got := []Leg{LegDBDump, LegTier2, LegOffsite}; len(h.ran) != 3 || h.ran[0] != got[0] || h.ran[1] != got[1] || h.ran[2] != got[2] {
|
||||
t.Fatalf("legs ran %v, want the chain order %v", h.ran, got)
|
||||
}
|
||||
if len(h.events) != 1 || !h.events[0].Equal(at(5, 4, 15)) {
|
||||
t.Fatalf("household line %v, want one for the missed 04:15 leg", h.events)
|
||||
}
|
||||
}
|
||||
|
||||
// On at W (the night ran) then a daytime restart: no catch-up.
|
||||
// RED-PROOF: drop the `!Ended.Before(inst)` check → the restart makes up a night that ran.
|
||||
func TestCatchUp_NightRan_DaytimeRestart_None(t *testing.T) {
|
||||
h := newHarness(t, at(1, 12, 0), at(5, 11, 0))
|
||||
h.l.MarkEnded(LegDBDump, at(5, 2, 31))
|
||||
h.l.MarkEnded(LegTier2, at(5, 3, 31))
|
||||
h.l.MarkEnded(LegOffsite, at(5, 4, 20))
|
||||
if legs := h.c.Evaluate(context.Background(), "start"); legs != nil {
|
||||
t.Fatalf("a normal night was made up again: %v", legs)
|
||||
}
|
||||
}
|
||||
|
||||
// Two missed nights: ONE catch-up, each leg once.
|
||||
func TestCatchUp_TwoMissedNights_One(t *testing.T) {
|
||||
h := newHarness(t, at(1, 12, 0), at(6, 18, 0))
|
||||
h.l.MarkEnded(LegDBDump, at(4, 2, 31))
|
||||
h.l.MarkEnded(LegTier2, at(4, 3, 31))
|
||||
h.l.MarkEnded(LegOffsite, at(4, 4, 20))
|
||||
h.c.Evaluate(context.Background(), "start")
|
||||
if again := h.c.Evaluate(context.Background(), "resume"); again != nil {
|
||||
t.Fatalf("a second catch-up was scheduled while one was pending: %v", again)
|
||||
}
|
||||
h.wait(t)
|
||||
if len(h.ran) != 3 {
|
||||
t.Fatalf("two missed nights ran %v — want each leg once", h.ran)
|
||||
}
|
||||
if legs := h.c.Evaluate(context.Background(), "start"); legs != nil {
|
||||
t.Fatalf("after the catch-up nothing is missed any more, got %v", legs)
|
||||
}
|
||||
}
|
||||
|
||||
// A power cut in the middle of the chain: only the legs that did not end.
|
||||
func TestCatchUp_PowerCutMidChain_FinishesTheRest(t *testing.T) {
|
||||
h := newHarness(t, at(1, 12, 0), at(5, 10, 0))
|
||||
h.l.MarkEnded(LegDBDump, at(5, 2, 31)) // the dump ended, then the power went
|
||||
h.l.MarkEnded(LegTier2, at(4, 3, 31))
|
||||
h.l.MarkEnded(LegOffsite, at(4, 4, 20))
|
||||
h.c.Evaluate(context.Background(), "start")
|
||||
h.wait(t)
|
||||
if len(h.ran) != 2 || h.ran[0] != LegTier2 || h.ran[1] != LegOffsite {
|
||||
t.Fatalf("ran %v, want only tier2 + offsite", h.ran)
|
||||
}
|
||||
}
|
||||
|
||||
// The first start on this release seeds the ledger: nothing before it is "missed" (no surprise catch-up on upgrade).
|
||||
func TestCatchUp_FreshLedger_None(t *testing.T) {
|
||||
h := newHarness(t, at(5, 9, 0), at(5, 9, 0))
|
||||
if legs := h.c.Evaluate(context.Background(), "start"); legs != nil {
|
||||
t.Fatalf("a freshly seeded ledger made up %v", legs)
|
||||
}
|
||||
}
|
||||
|
||||
// Started at 02:10 (the dump is due at 02:30): the dump is left to its normal run.
|
||||
func TestCatchUp_LegAboutToRun_LeftToNormal(t *testing.T) {
|
||||
h := newHarness(t, at(1, 12, 0), at(5, 2, 10))
|
||||
h.l.MarkEnded(LegDBDump, at(3, 2, 31))
|
||||
h.l.MarkEnded(LegTier2, at(3, 3, 31))
|
||||
h.l.MarkEnded(LegOffsite, at(3, 4, 20))
|
||||
legs, _ := h.l.Missed(at(5, 2, 10), "02:30", bud)
|
||||
for _, l := range legs {
|
||||
if l == LegDBDump {
|
||||
t.Fatalf("the dump due in 20 minutes was put in the catch-up: %v", legs)
|
||||
}
|
||||
}
|
||||
if len(legs) != 2 {
|
||||
t.Fatalf("missed %v, want tier2 + offsite of the night of the 4th", legs)
|
||||
}
|
||||
}
|
||||
|
||||
// App updates are never in a catch-up: only the chain's backup legs are run, whatever else Legs holds.
|
||||
// RED-PROOF: iterate c.Legs instead of the missed chain legs → "update" runs.
|
||||
func TestCatchUp_NeverRunsAnythingButBackupLegs(t *testing.T) {
|
||||
h := newHarness(t, at(1, 12, 0), at(5, 9, 0))
|
||||
updateRan := false
|
||||
h.c.Legs["update"] = func(context.Context) error { updateRan = true; return nil }
|
||||
h.c.Evaluate(context.Background(), "start")
|
||||
h.wait(t)
|
||||
if updateRan {
|
||||
t.Fatal("an app update ran inside a catch-up")
|
||||
}
|
||||
for _, l := range Order {
|
||||
if l == "update" || l == "docker" {
|
||||
t.Fatalf("the chain order names a non-backup leg %q", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A whole-guest backup holding the apps: the catch-up waits for it.
|
||||
// RED-PROOF: drop the QuiesceBusy loop → the legs run while busy (ran before the waits).
|
||||
func TestCatchUp_WaitsForAWholeGuestBackup(t *testing.T) {
|
||||
h := newHarness(t, at(1, 12, 0), at(5, 9, 0))
|
||||
h.busy = 3
|
||||
h.c.Evaluate(context.Background(), "start")
|
||||
h.wait(t)
|
||||
if len(h.slept) != 4 || h.slept[1] != time.Minute {
|
||||
t.Fatalf("slept %v — want the 15 min delay then 3 one-minute waits for the quiesce", h.slept)
|
||||
}
|
||||
if len(h.ran) != 3 {
|
||||
t.Fatalf("ran %v", h.ran)
|
||||
}
|
||||
}
|
||||
|
||||
// A host resume is a trigger too: the wall clock jumps ahead of the monotonic clock.
|
||||
// RED-PROOF: compare wall with wall → the suspend is never seen.
|
||||
func TestResumeWatch_SeesASuspend(t *testing.T) {
|
||||
wall := at(5, 1, 0)
|
||||
var mono time.Duration
|
||||
tick := make(chan time.Time)
|
||||
got := make(chan time.Duration, 2)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var mu sync.Mutex
|
||||
go ResumeWatch(ctx, tick, func() time.Time { mu.Lock(); defer mu.Unlock(); return wall },
|
||||
func() time.Duration { mu.Lock(); defer mu.Unlock(); return mono }, 5*time.Minute, func(d time.Duration) { got <- d })
|
||||
step := func(w, m time.Duration) {
|
||||
mu.Lock()
|
||||
wall, mono = wall.Add(w), mono+m
|
||||
mu.Unlock()
|
||||
tick <- time.Time{}
|
||||
}
|
||||
step(time.Minute, time.Minute) // a normal minute
|
||||
step(7*time.Hour, time.Minute) // suspended 02:00 → 09:00: wall +7h, monotonic +1 min
|
||||
select {
|
||||
case d := <-got:
|
||||
if d < 6*time.Hour {
|
||||
t.Fatalf("slept %s, want ~7h", d)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("a 7-hour suspend was not seen")
|
||||
}
|
||||
select {
|
||||
case d := <-got:
|
||||
t.Fatalf("a normal minute was read as a resume (%s)", d)
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -541,6 +541,12 @@ func (n *Notifier) NotifyControllerUpdated(fromVer, toVer string, success bool)
|
||||
n.pushEventMsg("controller_updated", severity, key, details, fromVer, toVer)
|
||||
}
|
||||
|
||||
// NotifyBackupCatchUp (R-871, v0.295.0) — the household's timeline line after a catch-up made a missed night's
|
||||
// backups: "the box was off at <HH:MM>; the missed backup is made now". Info: recorded, never mailed.
|
||||
func (n *Notifier) NotifyBackupCatchUp(missedAt string) {
|
||||
n.pushEventMsg("backup_catchup_done", "info", "event.backup_catchup_done", map[string]string{"missed_at": missedAt}, missedAt)
|
||||
}
|
||||
|
||||
// NotifyControllerStarted sends a controller startup event.
|
||||
// details may include self-test summary (e.g., {"selftest_pass": 8, "selftest_warn": 1, "selftest_fail": 0}).
|
||||
func (n *Notifier) NotifyControllerStarted(version string, details map[string]interface{}) {
|
||||
|
||||
@@ -100,6 +100,9 @@ type Loop struct {
|
||||
windowStartFn func() string
|
||||
cadence time.Duration
|
||||
updateLegFn func() (active, stepRunning bool)
|
||||
// catchUpMu/catchUpFn (R-871, v0.295.0): a running catch-up (internal/nightchain) holds the scheduled cycle.
|
||||
catchUpMu sync.Mutex
|
||||
catchUpFn func() bool
|
||||
// mu single-flights the quiesce cycle across the scheduled loop AND the manual trigger, so the
|
||||
// two can never stop the same stacks concurrently (the persisted marker covers crash-safety across
|
||||
// restarts; this covers concurrency within the process — which a manual trigger introduces).
|
||||
@@ -235,6 +238,15 @@ func (l *Loop) runOnce(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// R-871 (v0.295.0): a catch-up of a missed night is making its database dumps and copies right now — the
|
||||
// whole-guest backup would stop the apps under it. SCHEDULED path only (TriggerNow is the household's press);
|
||||
// the catch-up waits for a quiesce the same way (nightchain.CatchUp.QuiesceBusy), so neither starves: the
|
||||
// catch-up is minutes, and this is re-asked at the next poll. Pinned by TestR871_ScheduledCycleWaitsForCatchUp.
|
||||
if l.catchUpRunning() {
|
||||
l.logger.Printf("[INFO] [quiesce] full-system backup due, but a catch-up of a missed night is running — deferring to the next poll (R-871)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Window gate (Part 3) — SCHEDULED path only. TriggerNow calls quiesceAndPoll directly and is
|
||||
// never gated. Disabled when no window fn is wired (pre-v0.168.0 behavior).
|
||||
//
|
||||
@@ -842,3 +854,17 @@ func updateLegDefers(fn func() (bool, bool), now time.Time, windowStart string)
|
||||
|
||||
// legStepGraceMin bounds how long past W+5h the gate waits for a step already in flight.
|
||||
const legStepGraceMin = 30
|
||||
|
||||
// SetCatchUpFn wires the catch-up's "running" state (R-871). Safe to call after Run has started.
|
||||
func (l *Loop) SetCatchUpFn(fn func() bool) {
|
||||
l.catchUpMu.Lock()
|
||||
defer l.catchUpMu.Unlock()
|
||||
l.catchUpFn = fn
|
||||
}
|
||||
|
||||
func (l *Loop) catchUpRunning() bool {
|
||||
l.catchUpMu.Lock()
|
||||
fn := l.catchUpFn
|
||||
l.catchUpMu.Unlock()
|
||||
return fn != nil && fn()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package quiesce
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-871 (v0.295.0): a scheduled whole-guest cycle waits while a catch-up of a missed night runs (it would stop the
|
||||
// apps under the catch-up's database dumps), and runs at the next poll once the catch-up has ended.
|
||||
// COMPANION RED-PROOF: drop the catchUpRunning() check in runOnce → "the backup started while a catch-up ran".
|
||||
func TestR871_ScheduledCycleWaitsForCatchUp(t *testing.T) {
|
||||
be := &fakeBackend{due: true, dueAge: i64(20 * 3600), phases: []string{"done"}}
|
||||
st := &fakeStacks{running: []string{"nextcloud"}}
|
||||
l := windowLoop(t, be, st, "02:30", atBudapest(5, 0)) // inside [W+2h, W+6h)
|
||||
running := true
|
||||
l.SetCatchUpFn(func() bool { return running })
|
||||
if err := l.runOnce(context.Background()); err != nil {
|
||||
t.Fatalf("runOnce: %v", err)
|
||||
}
|
||||
if be.startCalls != 0 || len(st.stoppedNames()) != 0 {
|
||||
t.Fatalf("the backup started while a catch-up ran: start=%d stopped=%v", be.startCalls, st.stoppedNames())
|
||||
}
|
||||
running = false
|
||||
if err := l.runOnce(context.Background()); err != nil {
|
||||
t.Fatalf("runOnce: %v", err)
|
||||
}
|
||||
if be.startCalls != 1 {
|
||||
t.Fatalf("after the catch-up the backup must run at the next poll; start=%d", be.startCalls)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,23 @@ type Scheduler struct {
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
started bool
|
||||
// nowFn / afterFn are seams for the late-fire guard's tests (nil = time.Now / time.After).
|
||||
nowFn func() time.Time
|
||||
afterFn func(d time.Duration) <-chan time.Time
|
||||
}
|
||||
|
||||
// DailyLateLimit (R-871, v0.295.0): a daily job whose timer fires more than this after its scheduled wall-clock
|
||||
// time is SKIPPED, not run late. Go's timers run on CLOCK_MONOTONIC, which does not count a host suspend, so on a
|
||||
// laptop that slept through the night the 02:30 timer fires hours late — and would start the app-update leg at
|
||||
// noon while the household uses the box. A skipped backup leg is made up by the catch-up (internal/nightchain),
|
||||
// which the resume watch triggers; the update leg waits for a real night. Pinned by TestDaily_LateFireIsSkipped.
|
||||
const DailyLateLimit = 60 * time.Minute
|
||||
|
||||
func (s *Scheduler) now() time.Time {
|
||||
if s.nowFn != nil {
|
||||
return s.nowFn()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// SetDebug enables or disables debug logging.
|
||||
@@ -289,8 +306,8 @@ func (s *Scheduler) runDailyJob(job *Job) {
|
||||
schedule := job.Schedule
|
||||
s.mu.Unlock()
|
||||
|
||||
nextRun := nextDailyRun(schedule)
|
||||
waitDuration := time.Until(nextRun)
|
||||
nextRun := nextDailyRunAt(schedule, s.now())
|
||||
waitDuration := nextRun.Sub(s.now())
|
||||
|
||||
if waitDuration < 0 {
|
||||
waitDuration = 0
|
||||
@@ -298,18 +315,36 @@ func (s *Scheduler) runDailyJob(job *Job) {
|
||||
|
||||
s.dbg("daily job %s: next run at %s (waiting %s)", job.Name, nextRun.Format("2006-01-02 15:04:05 MST"), waitDuration.Round(time.Second))
|
||||
|
||||
timer := time.NewTimer(waitDuration)
|
||||
var fire <-chan time.Time
|
||||
var timer *time.Timer
|
||||
if s.afterFn != nil {
|
||||
fire = s.afterFn(waitDuration)
|
||||
} else {
|
||||
timer = time.NewTimer(waitDuration)
|
||||
fire = timer.C
|
||||
}
|
||||
stop := func() {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
timer.Stop()
|
||||
stop()
|
||||
s.dbg("daily job %s: context cancelled, stopping", job.Name)
|
||||
return
|
||||
case <-job.resched:
|
||||
// Runtime reschedule: abandon the current timer and recompute against the new Schedule.
|
||||
timer.Stop()
|
||||
stop()
|
||||
s.dbg("daily job %s: rescheduled — recomputing next run", job.Name)
|
||||
continue
|
||||
case <-timer.C:
|
||||
case <-fire:
|
||||
// The wall clock, not the timer, decides: a host suspend stops the timer's clock (R-871).
|
||||
if late := s.now().Round(0).Sub(nextRun); late > DailyLateLimit {
|
||||
s.logger.Printf("[INFO] [scheduler] Daily job %s SKIPPED: its timer fired %s after %s (the host was suspended) — not run late; a missed backup leg is made up by the catch-up, everything else waits for its next time",
|
||||
job.Name, late.Round(time.Minute), nextRun.Format("2006-01-02 15:04 MST"))
|
||||
continue
|
||||
}
|
||||
s.executeJob(job, false)
|
||||
}
|
||||
}
|
||||
@@ -387,6 +422,11 @@ func NextDailyRun(timeStr string) time.Time {
|
||||
|
||||
// nextDailyRun calculates the next occurrence of the daily schedule in Europe/Budapest timezone.
|
||||
func nextDailyRun(timeStr string) time.Time {
|
||||
return nextDailyRunAt(timeStr, time.Now())
|
||||
}
|
||||
|
||||
// nextDailyRunAt is nextDailyRun for a given "now" (the scheduler's clock seam).
|
||||
func nextDailyRunAt(timeStr string, at time.Time) time.Time {
|
||||
hour, min, err := parseDailyTime(timeStr)
|
||||
if err != nil {
|
||||
// Should not happen — validated at registration
|
||||
@@ -395,7 +435,7 @@ func nextDailyRun(timeStr string) time.Time {
|
||||
|
||||
loc := getBudapestLocation()
|
||||
|
||||
now := time.Now().In(loc)
|
||||
now := at.In(loc)
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, min, 0, 0, loc)
|
||||
|
||||
// If the time has already passed today, schedule for tomorrow.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -79,3 +80,60 @@ func TestUpdateDaily_GoroutineConsumesReschedule(t *testing.T) {
|
||||
// drained by the goroutine → immediacy works
|
||||
}
|
||||
}
|
||||
|
||||
// R-871 (v0.295.0): a daily timer that fires long after its wall-clock time (a host suspend: the timer's clock stops)
|
||||
// SKIPS the job; one that fires on time runs it.
|
||||
// COMPANION RED-PROOF: drop the `late > DailyLateLimit` branch → "a 6-hour-late fire ran the job".
|
||||
func TestDaily_LateFireIsSkipped(t *testing.T) {
|
||||
loc := getBudapestLocation()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
firedAt time.Time
|
||||
wantRun bool
|
||||
}{
|
||||
{"on time", time.Date(2026, 10, 5, 2, 30, 1, 0, loc), true},
|
||||
{"after a suspend", time.Date(2026, 10, 5, 8, 31, 0, 0, loc), false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := discardScheduler()
|
||||
var mu sync.Mutex
|
||||
now := time.Date(2026, 10, 5, 1, 0, 0, 0, loc)
|
||||
s.nowFn = func() time.Time { mu.Lock(); defer mu.Unlock(); return now }
|
||||
fired := make(chan time.Time, 1)
|
||||
armed := make(chan struct{}, 1)
|
||||
calls := 0
|
||||
s.afterFn = func(time.Duration) <-chan time.Time {
|
||||
mu.Lock()
|
||||
calls++
|
||||
first := calls == 1
|
||||
mu.Unlock()
|
||||
if !first {
|
||||
return make(chan time.Time) // never: park after the first fire
|
||||
}
|
||||
armed <- struct{}{} // the next run (02:30) is computed against 01:00
|
||||
return fired
|
||||
}
|
||||
ran := make(chan struct{}, 1)
|
||||
s.Daily("offbox-backup", "02:30", func(context.Context) error { ran <- struct{}{}; return nil })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.Start(ctx)
|
||||
<-armed
|
||||
mu.Lock()
|
||||
now = tc.firedAt
|
||||
mu.Unlock()
|
||||
fired <- tc.firedAt
|
||||
select {
|
||||
case <-ran:
|
||||
if !tc.wantRun {
|
||||
t.Fatal("a 6-hour-late fire ran the job (the app-update leg would start at noon)")
|
||||
}
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
if tc.wantRun {
|
||||
t.Fatal("an on-time fire did not run the job")
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
s.Stop()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,16 @@ func i18nCases() []i18nCase {
|
||||
d["EscrowBannerBack"] = "/launcher"
|
||||
return d
|
||||
}},
|
||||
// R-871 (v0.295.0, decision 110): the missed-backup banner. Its two sentences are computed in Go per reader
|
||||
// language (missed_backup_banner.go); here the Hungarian ones, as the hu render receives them.
|
||||
{"launcher_missed_backup", "launcher", func() map[string]interface{} {
|
||||
d := i18nLayoutData("launcher", "Indítópult")
|
||||
d["MissedBackupBanner"] = true
|
||||
d["MissedBackupMessage"] = "A legutóbbi mentés 2 nappal ezelőtt készült. A doboz ki volt kapcsolva a mentés idején (02:30)."
|
||||
d["MissedBackupHint"] = "Válassz egy olyan időpontot, amikor a doboz általában be van kapcsolva — például 21:00."
|
||||
d["MissedBackupBack"] = "/launcher"
|
||||
return d
|
||||
}},
|
||||
{"launcher_nopassword", "launcher", func() map[string]interface{} {
|
||||
d := i18nLayoutData("launcher", "Indítópult")
|
||||
d["RecoveryBanner"] = true
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/nightchain"
|
||||
)
|
||||
|
||||
// R-871 / `09` decision 110 — the missed-backup banner through the REAL pages (ServeHTTP → executeTemplate) and the
|
||||
// REAL dismiss route. The rule itself is pinned in internal/nightchain/banner_test.go.
|
||||
// COMPANION RED-PROOF: remove `s.addMissedBackupBanner(data, r)` from executeTemplate → "not shown on /launcher".
|
||||
|
||||
const missedBannerID = `id="missed-backup-banner"`
|
||||
|
||||
func missedServer(t *testing.T, lastDump time.Time) (*Server, *nightchain.Ledger) {
|
||||
t.Helper()
|
||||
s := newDashboardServer(t, time.Time{})
|
||||
l, err := nightchain.Open(filepath.Join(t.TempDir(), "night-ledger.json"), time.Now().Add(-10*24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !lastDump.IsZero() {
|
||||
l.MarkDBDumpOK(lastDump)
|
||||
}
|
||||
s.SetMissedBackupBanner(l, nil)
|
||||
return s, l
|
||||
}
|
||||
|
||||
func TestR871_BannerShownThenClosedUntilTheNextMiss(t *testing.T) {
|
||||
s, l := missedServer(t, time.Now().Add(-3*24*time.Hour))
|
||||
body := getPage(t, s, "/launcher").Body.String()
|
||||
if !strings.Contains(body, missedBannerID) {
|
||||
t.Fatal("not shown on /launcher although the last backup is 3 days old")
|
||||
}
|
||||
if !strings.Contains(body, "A legutóbbi mentés 3 nappal ezelőtt készült.") || !strings.Contains(body, `href="/backups#window_start"`) {
|
||||
t.Fatalf("the banner lacks its sentence or its button:\n%s", body[strings.Index(body, missedBannerID):][:600])
|
||||
}
|
||||
rec := postForm(t, s, "/backups/missed-banner/dismiss", url.Values{"back": {"/launcher"}})
|
||||
if rec.Code/100 != 3 {
|
||||
t.Fatalf("dismiss = %d", rec.Code)
|
||||
}
|
||||
if strings.Contains(getPage(t, s, "/launcher").Body.String(), missedBannerID) {
|
||||
t.Fatal("still shown after the household closed it")
|
||||
}
|
||||
if _, _, _, dismissed, _ := l.Snapshot(); dismissed.IsZero() {
|
||||
t.Fatal("the dismissal was not recorded in the ledger (it would not survive a restart)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestR871_BannerNotShownAfterASuccessfulNight(t *testing.T) {
|
||||
s, _ := missedServer(t, time.Now().Add(-2*time.Hour))
|
||||
if strings.Contains(getPage(t, s, "/launcher").Body.String(), missedBannerID) {
|
||||
t.Fatal("shown although the last backup is 2 hours old")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/family"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/nightchain"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -36,6 +38,10 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
// R-871: the missed-backup banner's inputs (SetMissedBackupBanner); nil = no banner.
|
||||
nightLedger *nightchain.Ledger
|
||||
metricsRecord *metrics.MetricsStore
|
||||
|
||||
cfg *config.Config
|
||||
stackMgr *stacks.Manager
|
||||
cpuCollector *system.CPUCollector
|
||||
@@ -765,6 +771,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case path == "/backup/shares/place" && r.Method == http.MethodPost:
|
||||
s.sharesPlaceHandler(w, r)
|
||||
// Controller-driven escrow ceremony wizard (v0.127.0): the customer-facing R flow.
|
||||
case path == "/backups/missed-banner/dismiss" && r.Method == http.MethodPost:
|
||||
s.missedBackupBannerDismissHandler(w, r)
|
||||
case path == "/backup/escrow/banner/dismiss" && r.Method == http.MethodPost:
|
||||
s.escrowBannerDismissHandler(w, r)
|
||||
case path == "/backup/escrow" && r.Method == http.MethodGet:
|
||||
@@ -1005,6 +1013,7 @@ func (s *Server) executeTemplate(w http.ResponseWriter, r *http.Request, name st
|
||||
// through here (TestI18nDirectRenderPagesHaveNoAdminChrome); the session check inside is a second
|
||||
// fence for the guest share page, which has no admin session.
|
||||
s.addEscrowBanner(data, r)
|
||||
s.addMissedBackupBanner(data, r) // R-871 (decision 110)
|
||||
lang := s.langFor(r)
|
||||
s.addLanguageData(data, r, lang)
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -178,6 +178,24 @@
|
||||
until the household creates its recovery code (zero-knowledge escrow: their code is the only key).
|
||||
The pause is the design; what was missing was ASKING. Same mechanism as the R-241 bar above —
|
||||
dismissable for the visit, back at the next one, gone for good when the escrow completes. */}}
|
||||
{{/* R-871 (v0.295.0, `09` decision 110) — the missed-backup banner: when the last daily backup is over 26 h old,
|
||||
why (the box was off at the backup time, from the box's own record) and a suggested time. Closing it lasts until
|
||||
the NEXT missed backup time (durable, the night ledger); a successful night removes it. Never changes the time. */ -}}
|
||||
{{if .MissedBackupBanner}}
|
||||
<div class="alerts-container">
|
||||
<div class="alert-banner alert-banner-warning" id="missed-backup-banner">
|
||||
<span class="alert-icon"><svg class="ico"><use href="#i-triangle-alert"/></svg></span>
|
||||
<span class="alert-message">{{.MissedBackupMessage}} {{.MissedBackupHint}}</span>
|
||||
<span class="alert-actions">
|
||||
<a href="/backups#window_start" class="btn btn-sm btn-primary">{{T "layout.missed_backup_change"}}</a>
|
||||
<form method="POST" action="/backups/missed-banner/dismiss" style="display:inline">
|
||||
{{.CSRFField}}<input type="hidden" name="back" value="{{.MissedBackupBack}}">
|
||||
<button type="submit" class="btn btn-sm btn-outline">{{T "layout.missed_backup_close"}}</button>
|
||||
</form>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end -}}
|
||||
{{if .EscrowBanner}}
|
||||
<div class="alerts-container">
|
||||
<div class="alert-banner alert-banner-warning">
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu" class="no-js">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Indítópult — Felhom.eu</title>
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
|
||||
<link rel="stylesheet" href="/static/style.css?v=0.247.0">
|
||||
<meta name="csrf-token" content="tok">
|
||||
<script>
|
||||
|
||||
|
||||
document.documentElement.className=document.documentElement.className.replace('no-js','js');
|
||||
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function felhomConfirm(el,question,onYes){
|
||||
if(!el||el.dataset.fcOpen)return;
|
||||
el.dataset.fcOpen='1';
|
||||
var wrap=document.createElement('span');wrap.className='inline-confirm';
|
||||
var q=document.createElement('span');q.className='inline-confirm-q';q.textContent=question;
|
||||
var yes=document.createElement('button');yes.type='button';yes.className='btn btn-xs btn-danger';yes.textContent='Igen';
|
||||
var no=document.createElement('button');no.type='button';no.className='btn btn-xs btn-outline';no.textContent='Mégse';
|
||||
wrap.appendChild(q);wrap.appendChild(yes);wrap.appendChild(no);
|
||||
el.style.display='none';el.parentNode.insertBefore(wrap,el.nextSibling);
|
||||
function close(){wrap.remove();el.style.display='';delete el.dataset.fcOpen;}
|
||||
no.addEventListener('click',close);
|
||||
yes.addEventListener('click',function(){close();onYes();});
|
||||
}
|
||||
document.addEventListener('click',function(e){
|
||||
var btn=e.target.closest?e.target.closest('[data-confirm]'):null;
|
||||
if(!btn)return;
|
||||
e.preventDefault();
|
||||
felhomConfirm(btn,btn.getAttribute('data-confirm'),function(){
|
||||
var form=btn.closest('form');
|
||||
if(form){if(form.requestSubmit)form.requestSubmit(btn);else form.submit();}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
document.addEventListener('click',function(e){
|
||||
var btn=e.target.closest?e.target.closest('.nav-group-toggle'):null;
|
||||
if(!btn)return;
|
||||
var group=btn.closest('.nav-group');
|
||||
if(!group)return;
|
||||
var willOpen=!group.classList.contains('is-open');
|
||||
document.querySelectorAll('.nav-group.is-open').forEach(function(g){
|
||||
g.classList.remove('is-open');
|
||||
var t=g.querySelector('.nav-group-toggle');
|
||||
if(t)t.setAttribute('aria-expanded','false');
|
||||
});
|
||||
if(willOpen){group.classList.add('is-open');btn.setAttribute('aria-expanded','true');}
|
||||
});
|
||||
function showAlert(msg){var o=document.createElement('div');o.className='modal-overlay';o.id='alert-modal';o.addEventListener('click',function(e){if(e.target===o)o.remove();});var c=document.createElement('div');c.className='modal-card';c.innerHTML='<h3>Üzenet</h3><pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-2);padding:.75rem;border-radius:.375rem;font-size:.85rem;max-height:60vh;overflow-y:auto;user-select:text;cursor:text">'+msg.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
|
||||
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01" /> <path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> <path d="M21.946 12.013H2.054" /> <path d="M6 16h.01" /></symbol>
|
||||
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v2" /> <path d="M12 2v2" /> <path d="M17 20v2" /> <path d="M17 2v2" /> <path d="M2 12h2" /> <path d="M2 17h2" /> <path d="M2 7h2" /> <path d="M20 12h2" /> <path d="M20 17h2" /> <path d="M20 7h2" /> <path d="M7 20v2" /> <path d="M7 2v2" /> <rect x="4" y="4" width="16" height="16" rx="2" /> <rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
|
||||
<symbol id="i-memory-stick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v-2" /> <path d="M12 18v-2" /> <path d="M16 12v-2" /> <path d="M16 18v-2" /> <path d="M2 11h1.5" /> <path d="M20 18v-2" /> <path d="M20.5 11H22" /> <path d="M4 18v-2" /> <path d="M8 12v-2" /> <path d="M8 18v-2" /> <rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
|
||||
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /></symbol>
|
||||
<symbol id="i-triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" /></symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
|
||||
<symbol id="i-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
|
||||
<symbol id="i-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
|
||||
<symbol id="i-rotate-cw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" /></symbol>
|
||||
<symbol id="i-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /></symbol>
|
||||
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
|
||||
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
|
||||
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
|
||||
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
|
||||
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></symbol>
|
||||
<symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" /></symbol>
|
||||
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
|
||||
<symbol id="i-trash-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
|
||||
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
|
||||
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /> <path d="M12 5v14" /></symbol>
|
||||
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
|
||||
</svg>
|
||||
<header class="mobile-topbar">
|
||||
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu"></a>
|
||||
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
|
||||
<svg class="ico"><use href="#i-menu"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="nav-backdrop" hidden></div>
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu" class="sidebar-logo">
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/launcher" class="active"><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
|
||||
<li><a href="/dashboard" class=""><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
|
||||
<li><a href="/stacks" class=""><svg class="ico"><use href="#i-cloud"/></svg>Alkalmazások</a></li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-storage"><svg class="ico"><use href="#i-hard-drive"/></svg>Tárhely<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-storage" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/storage" class="">Meghajtók</a></li>
|
||||
<li><a href="/storage/network" class="">Hálózati tárhely</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-backups"><svg class="ico"><use href="#i-shield"/></svg>Biztonsági mentés<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-backups" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/backups" class="">Áttekintés</a></li>
|
||||
<li><a href="/backups/remote" class="">Távoli mentés</a></li>
|
||||
<li><a href="/backups/apps" class="">Alkalmazások</a></li>
|
||||
<li><a href="/backups/restore" class="">Visszaállítás</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="nav-group">
|
||||
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-sharing"><svg class="ico"><use href="#i-share"/></svg>Megosztás<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
|
||||
<ul id="nav-group-sharing" class="nav-links nav-links-sub nav-links-nested">
|
||||
<li><a href="/sharing" class="">Hálózati megosztás</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="/monitoring" class=""><svg class="ico"><use href="#i-cpu"/></svg>Rendszermonitor</a></li>
|
||||
<li><a href="/debug" class=""><svg class="ico"><use href="#i-wrench"/></svg>Debug</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-bottom">
|
||||
<div class="nav-group-label">Beállítások</div>
|
||||
<ul class="nav-links nav-links-sub">
|
||||
<li><a href="/settings" class=""><svg class="ico"><use href="#i-settings"/></svg>Rendszer</a></li>
|
||||
<li><a href="/settings/notifications" class=""><svg class="ico"><use href="#i-bell"/></svg>Értesítések</a></li>
|
||||
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
|
||||
</ul>
|
||||
<div class="sidebar-footer">
|
||||
<span class="version">0.247.0</span><details class="lang-globe">
|
||||
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
|
||||
<ul class="lang-globe-menu">
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
|
||||
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
|
||||
</ul>
|
||||
</details>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="content">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="alerts-container">
|
||||
<div class="alert-banner alert-banner-warning" id="missed-backup-banner">
|
||||
<span class="alert-icon"><svg class="ico"><use href="#i-triangle-alert"/></svg></span>
|
||||
<span class="alert-message">A legutóbbi mentés 2 nappal ezelőtt készült. A doboz ki volt kapcsolva a mentés idején (02:30). Válassz egy olyan időpontot, amikor a doboz általában be van kapcsolva — például 21:00.</span>
|
||||
<span class="alert-actions">
|
||||
<a href="/backups#window_start" class="btn btn-sm btn-primary">Mentési idő módosítása</a>
|
||||
<form method="POST" action="/backups/missed-banner/dismiss" style="display:inline">
|
||||
<input type="hidden" name="back" value="/launcher">
|
||||
<button type="submit" class="btn btn-sm btn-outline">Bezárás</button>
|
||||
</form>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="page-header">
|
||||
<h2>Indítópult</h2>
|
||||
<span class="domain-badge">example.hu</span>
|
||||
<button type="button" class="btn btn-outline share-open-btn" onclick="openShareModal()"><svg class="ico"><use href="#i-share"/></svg>Indítópult megosztása</button>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="empty-state">
|
||||
<p>Még nincs telepített alkalmazás.</p>
|
||||
<p><a href="/stacks" class="empty-state-link">Alkalmazások telepítése</a></p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal-overlay" id="share-modal" style="display:none">
|
||||
<div class="modal-card">
|
||||
<h3>Indítópult megosztása</h3>
|
||||
<p class="share-lede">Egy megosztható link, amely csak megmutatja az alkalmazásokat és új lapon megnyitja őket. Fiók nélkül, felügyeleti hozzáférés nélkül — minden alkalmazás a saját bejelentkezése mögött marad.</p>
|
||||
|
||||
<p class="share-note">A megosztás jelenleg ki van kapcsolva.</p>
|
||||
<form method="POST" action="/launcher/share/enable">
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeShareModal()">Bezárás</button>
|
||||
<button type="submit" class="btn btn-primary">Megosztás bekapcsolása</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openShareModal(){var m=document.getElementById('share-modal');if(m)m.style.display='flex';}
|
||||
function closeShareModal(){var m=document.getElementById('share-modal');if(m)m.style.display='none';}
|
||||
function copyShareLink(){
|
||||
var inp=document.getElementById('share-link-input');
|
||||
var btn=document.getElementById('share-copy-btn');
|
||||
if(!inp)return;
|
||||
function done(){if(btn){var o=btn.textContent;btn.textContent='Másolva';setTimeout(function(){btn.textContent=o;},1500);}}
|
||||
if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(inp.value).then(done,function(){inp.select();done();});}
|
||||
else{inp.select();try{document.execCommand('copy');}catch(e){}done();}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function nativeShare(){
|
||||
var inp=document.getElementById('share-link-input');
|
||||
if(!inp||!navigator.share)return;
|
||||
navigator.share({title:'Indítópult — example.hu',text:'Az otthoni alkalmazások egy helyen.',url:inp.value})
|
||||
.catch(function(e){if(e&&e.name==='AbortError')return;copyShareLink();});
|
||||
}
|
||||
(function(){
|
||||
var m=document.getElementById('share-modal');
|
||||
if(m){m.addEventListener('click',function(e){if(e.target===m)closeShareModal();});}
|
||||
if(navigator.share){var nb=document.getElementById('share-native-btn');if(nb)nb.style.display='';}
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
</main>
|
||||
<script>
|
||||
|
||||
|
||||
|
||||
(function(){
|
||||
var burger=document.querySelector('.nav-burger');
|
||||
var sidebar=document.getElementById('sidebar');
|
||||
var backdrop=document.querySelector('.nav-backdrop');
|
||||
if(!burger||!sidebar||!backdrop)return;
|
||||
function setOpen(open){
|
||||
sidebar.classList.toggle('is-open',open);
|
||||
document.body.classList.toggle('nav-open',open);
|
||||
backdrop.hidden=!open;
|
||||
burger.setAttribute('aria-expanded',open?'true':'false');
|
||||
}
|
||||
burger.addEventListener('click',function(){setOpen(!sidebar.classList.contains('is-open'));});
|
||||
backdrop.addEventListener('click',function(){setOpen(false);});
|
||||
document.addEventListener('keydown',function(e){
|
||||
if(e.key==='Escape'&&sidebar.classList.contains('is-open'))setOpen(false);
|
||||
});
|
||||
})();
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
|
||||
var card = e.target.closest('[data-href]');
|
||||
if (card) window.location.href = card.dataset.href;
|
||||
});
|
||||
async function checkBeforeDeploy(e, name) {
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name);
|
||||
var data = await resp.json();
|
||||
if (data.ok && data.data && data.data.deployed) {
|
||||
e.preventDefault();
|
||||
showAlert('Ez az alkalmazás már telepítve van.');
|
||||
window.location.reload();
|
||||
return false;
|
||||
}
|
||||
} catch(err) {}
|
||||
return true;
|
||||
}
|
||||
async function syncTemplates() {
|
||||
const btn = document.getElementById('sync-btn');
|
||||
const toast = document.getElementById('sync-toast');
|
||||
if (!btn) return;
|
||||
const origText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '↻ Frissítés...';
|
||||
btn.classList.add('loading');
|
||||
try {
|
||||
const resp = await fetch('/api/sync', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (toast) {
|
||||
toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
|
||||
toast.style.display = 'block';
|
||||
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
||||
}
|
||||
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
if (toast) {
|
||||
toast.textContent = 'Hálózati hiba: ' + err.message;
|
||||
toast.className = 'sync-toast sync-toast-err';
|
||||
toast.style.display = 'block';
|
||||
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
||||
}
|
||||
}
|
||||
btn.innerHTML = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
async function stackAction(event, name, action) {
|
||||
const btn = event.currentTarget;
|
||||
const origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Folyamatban...';
|
||||
btn.classList.add('loading');
|
||||
try {
|
||||
const resp = await fetch('/api/stacks/' + name + '/' + action, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (action === 'update') {
|
||||
followUpdate(name, btn);
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
function followUpdate(name, btn) {
|
||||
var started = Date.now();
|
||||
var timer = setInterval(async function() {
|
||||
if (Date.now() - started > 30 * 60 * 1000) { clearInterval(timer); window.location.reload(); return; }
|
||||
try {
|
||||
var r = await fetch('/api/stacks/' + name);
|
||||
var d = await r.json();
|
||||
if (!d.ok || !d.data) return;
|
||||
if (d.data.update_phase_label) btn.textContent = d.data.update_phase_label;
|
||||
if (!d.data.updating) { clearInterval(timer); window.location.reload(); }
|
||||
} catch (e) {}
|
||||
}, 3000);
|
||||
}
|
||||
async function deleteOrphanStack(name) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.id = 'delete-modal';
|
||||
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); });
|
||||
document.body.appendChild(modal);
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name + '/hdd-data');
|
||||
var data = await resp.json();
|
||||
var hddInfo = '';
|
||||
var checkboxHTML = '';
|
||||
if (data.ok && data.data && data.data.has_hdd_data) {
|
||||
hddInfo = '<div class="modal-hdd-info"><strong>Felhasználói adatok a merevlemezen:</strong>';
|
||||
data.data.hdd_paths.forEach(function(p) {
|
||||
hddInfo += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
});
|
||||
hddInfo += '</div>';
|
||||
checkboxHTML = '<label class="modal-checkbox"><input type="checkbox" id="delete-hdd-check"> Felhasználói adatok törlése a merevlemezről</label>';
|
||||
}
|
||||
|
||||
if (data.ok && data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
var udPaths = '';
|
||||
data.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
|
||||
hddInfo += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Alkalmazás törlése: ' + name + '</h3>' +
|
||||
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.</p>' +
|
||||
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
|
||||
hddInfo + checkboxHTML +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn btn-outline" onclick="closeDeleteModal()">Mégsem</button>' +
|
||||
'<button class="btn btn-danger" id="confirm-delete-btn" onclick="confirmDelete(\'' + name + '\')">Törlés</button>' +
|
||||
'</div>';
|
||||
} catch (err) {
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
|
||||
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeDeleteModal()">Bezárás</button></div>';
|
||||
}
|
||||
}
|
||||
function closeDeleteModal() {
|
||||
var modal = document.getElementById('delete-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
async function confirmDelete(name) {
|
||||
var btn = document.getElementById('confirm-delete-btn');
|
||||
var checkbox = document.getElementById('delete-hdd-check');
|
||||
var removeHDD = checkbox ? checkbox.checked : false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Törlés folyamatban...';
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name, {
|
||||
method: 'DELETE',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
var modal = document.getElementById('delete-modal');
|
||||
var removedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
||||
removedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
var preservedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
||||
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.hdd_note) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
|
||||
}
|
||||
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Sikeresen törölve!</h3>' +
|
||||
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') törölve lett.</p>' +
|
||||
removedInfo + preservedInfo +
|
||||
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
|
||||
} else {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
}
|
||||
async function removeStack(name) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.id = 'remove-modal';
|
||||
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeRemoveModal(); });
|
||||
document.body.appendChild(modal);
|
||||
try {
|
||||
var [hddResp, backupResp] = await Promise.all([
|
||||
fetch('/api/stacks/' + name + '/hdd-data').then(function(r) { return r.json(); }),
|
||||
fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); })
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
'<li>Docker kötetek (adatbázis, alkalmazás konfiguráció)</li>' +
|
||||
'<li>Telepítési konfiguráció (app.yaml)</li>' +
|
||||
'<li>Másodlagos mentés ütemezése</li>' +
|
||||
'</ul></div>';
|
||||
|
||||
var hddCheckbox = '';
|
||||
if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) {
|
||||
var hddPaths = '';
|
||||
hddResp.data.hdd_paths.forEach(function(p) {
|
||||
hddPaths += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
});
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Felhasználói adatok a merevlemezen:</strong>' + hddPaths +
|
||||
'<label class="modal-checkbox"><input type="checkbox" id="remove-hdd-check"> Felhasználói adatok törlése</label>' +
|
||||
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem" id="remove-hdd-keep-warning">' +
|
||||
'Ha újratelepíti az alkalmazást, az adatokat újra importálnia kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.' +
|
||||
'</div></div>';
|
||||
hddCheckbox = '\x3Cscript>document.getElementById("remove-hdd-check").addEventListener("change",function(){document.getElementById("remove-hdd-keep-warning").style.display=this.checked?"none":"";});<\/script>';
|
||||
}
|
||||
|
||||
var backupCheckbox = '';
|
||||
if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) {
|
||||
var bkPaths = '';
|
||||
backupResp.data.backup_paths.forEach(function(p) {
|
||||
if (p.exists) bkPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
});
|
||||
if (bkPaths) {
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mentési adatok:</strong>' + bkPaths +
|
||||
'<label class="modal-checkbox"><input type="checkbox" id="remove-backup-check"> Mentési adatok törlése</label>' +
|
||||
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem">' +
|
||||
'Az éjszakai restic pillanatképek nem törölhetők egyenként — a megőrzési szabályok szerint automatikusan elavulnak.' +
|
||||
'</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (hddResp.ok && hddResp.data && hddResp.data.userdata_kept && hddResp.data.userdata_kept.length > 0) {
|
||||
var udPaths = '';
|
||||
hddResp.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
|
||||
sections += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Alkalmazás eltávolítása: ' + name + '</h3>' +
|
||||
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Az alkalmazás visszaáll "Nincs telepítve" állapotba. A sablon megmarad, újratelepíthető.</p>' +
|
||||
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
|
||||
sections +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn btn-outline" onclick="closeRemoveModal()">Mégsem</button>' +
|
||||
'<button class="btn btn-danger" id="confirm-remove-btn" onclick="confirmRemoveStack(\'' + name + '\')">Eltávolítás</button>' +
|
||||
'</div>' + hddCheckbox;
|
||||
} catch (err) {
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
|
||||
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeRemoveModal()">Bezárás</button></div>';
|
||||
}
|
||||
}
|
||||
function closeRemoveModal() {
|
||||
var modal = document.getElementById('remove-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
async function confirmRemoveStack(name) {
|
||||
var btn = document.getElementById('confirm-remove-btn');
|
||||
var hddCheck = document.getElementById('remove-hdd-check');
|
||||
var backupCheck = document.getElementById('remove-backup-check');
|
||||
var removeHDD = hddCheck ? hddCheck.checked : false;
|
||||
var removeBackups = backupCheck ? backupCheck.checked : false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Eltávolítás folyamatban...';
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name + '/remove', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
var modal = document.getElementById('remove-modal');
|
||||
var removedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
||||
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.backup_paths_removed && data.data.backup_paths_removed.length > 0) {
|
||||
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt mentések: ' + data.data.backup_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
var preservedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
||||
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.hdd_note) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
|
||||
}
|
||||
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.backup_paths_refused && data.data.backup_paths_refused.length > 0) {
|
||||
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Nem törölt mentések: ' + data.data.backup_paths_refused.join('; ') + '</p>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Sikeresen eltávolítva!</h3>' +
|
||||
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') eltávolítva. Újratelepíthető a Telepítés gombbal.</p>' +
|
||||
removedInfo + preservedInfo +
|
||||
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
|
||||
} else {
|
||||
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Eltávolítás';
|
||||
}
|
||||
} catch (err) {
|
||||
showAlert('Hálózati hiba: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Eltávolítás';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"_comment": "Localisation slice 2 (R-557). key -> the base-commit Go literal it replaced, or the ORDERED list of literals a concatenation joined. Checked by scripts/i18n_go_parity.py against scripts/i18n_go_base.json, which is frozen at 736f54b49610 (the base commit of release v0.252.0). A key whose Hungarian text is not byte-identical to what the Go code said fails the gate.",
|
||||
"_preexisting": {
|
||||
"banner.missed_backup.never": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 110) -- a NEW sentence of the missed-backup banner, never a Go literal; pinned in Hungarian by TestR871_BannerShownThenClosedUntilTheNextMiss and the parity fixture launcher_missed_backup.",
|
||||
"banner.missed_backup.last": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 110) -- a NEW sentence of the missed-backup banner, never a Go literal; pinned in Hungarian by TestR871_BannerShownThenClosedUntilTheNextMiss and the parity fixture launcher_missed_backup.",
|
||||
"banner.missed_backup.off_at": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 110) -- a NEW sentence of the missed-backup banner, never a Go literal; pinned in Hungarian by TestR871_BannerShownThenClosedUntilTheNextMiss and the parity fixture launcher_missed_backup.",
|
||||
"banner.missed_backup.choose": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 110) -- a NEW sentence of the missed-backup banner, never a Go literal; pinned in Hungarian by TestR871_BannerShownThenClosedUntilTheNextMiss and the parity fixture launcher_missed_backup.",
|
||||
"banner.missed_backup.suggest": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 110) -- a NEW sentence of the missed-backup banner, never a Go literal; pinned in Hungarian by TestR871_BannerShownThenClosedUntilTheNextMiss and the parity fixture launcher_missed_backup.",
|
||||
"event.backup_catchup_done": "BORN AS A KEY, v0.295.0 (R-871, `09` decision 109) -- the catch-up's NEW timeline line, never a Go literal; sent by Notifier.NotifyBackupCatchUp (wiring pinned by TestR871_CatchUpWiring).",
|
||||
"badge.lifecycle.abandoned": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
|
||||
"badge.lifecycle.abandoned.title": "R-589 (v0.258.0) -- localeFuncs; the Hungarian form stays in templateFuncMap, pinned by TestLocaleFuncsHungarianBundleMatchesFuncMap",
|
||||
"badge.update.ahead.title": "BORN AS A KEY, v0.260.0 (R-524) -- a NEW sentence, never a Go literal, so there is nothing in the base capture to measure it against. Pinned in both languages by TestUpdateBadgeFollowsTheLanguage.",
|
||||
|
||||
Reference in New Issue
Block a user