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

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:
2026-10-05 09:27:16 +02:00
parent e730a629fd
commit 635c33d381
24 changed files with 2074 additions and 18 deletions
+8
View File
@@ -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",
+8
View File
@@ -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",
+28
View File
@@ -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()
}
+125
View File
@@ -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:
}
}
+6
View File
@@ -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{}) {
+26
View File
@@ -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)
}
}
+47 -7
View File
@@ -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")
}
}
+9
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</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>