v0.168.0: customer-configurable backup window (Mentési időablak)
ONE setting (window start W) drives every nightly leg at fixed, never-stored offsets: DB dump at W, tier-2 at W+60m, off-box at W+105m (wrap-safe). Precedence settings > controller.yaml db_dump_schedule > 02:30. - scheduler.UpdateDaily: retime a daily job at runtime (no restart) via a per-job buffered resched chan + a select case in runDailyJob. - new pure package internal/backupwindow (LegTimes/GateWindow/EffectiveWindow). - quiesce disk-tier window gate: scheduled cycles run only inside [W+2h,W+6h) with a safety valve (age>cadence+24h runs regardless); manual TriggerNow never gated. Backend.Due now also returns the backup age (from the agent's own /backup/due). - backup page: Mentési időablak card (time input + derived leg/gate rows); POST /backups/window validates -> saves -> UpdateDaily x3 -> flash. Tests: 5 groups, all red-proofed. Agent/cadence//backup/due untouched.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// Package backupwindow holds the pure time arithmetic for the customer-configurable backup window
|
||||
// (v0.168.0). ONE setting — the window start W — drives every nightly leg at FIXED offsets so the
|
||||
// legs can never be misordered, and never stores a derived time: the DB dump runs at W, the tier-2
|
||||
// mirror at W+60m, the off-box copy at W+105m; the whole-guest (PBS/vzdump) cycle is gated to
|
||||
// [W+2h, W+6h). Offsets are constants here, never persisted and never surfaced in the UI.
|
||||
package backupwindow
|
||||
|
||||
import "fmt"
|
||||
|
||||
// DefaultWindow is the last-resort window when neither settings nor controller.yaml supplies one.
|
||||
// It equals the historical hardcoded DB-dump time, so an un-configured box behaves exactly as before.
|
||||
const DefaultWindow = "02:30"
|
||||
|
||||
// Fixed leg offsets from the window start W (minutes). NEVER stored, NEVER exposed in the UI —
|
||||
// changing spacing/ordering is a code change here, not customer data.
|
||||
const (
|
||||
tier2OffsetMin = 60 // tier-2 mirror at W+60m
|
||||
offboxOffsetMin = 105 // off-box copy at W+105m
|
||||
gateStartMin = 120 // whole-guest gate opens at W+2h
|
||||
gateEndMin = 360 // whole-guest gate closes (exclusive) at W+6h
|
||||
)
|
||||
|
||||
// ParseHHMM parses "HH:MM" (24h) into minutes-since-midnight. It rejects anything but a valid
|
||||
// hour:minute — the same contract as the scheduler's parseDailyTime, kept here so this package is
|
||||
// dependency-free and reusable by the quiesce gate.
|
||||
func ParseHHMM(s string) (int, error) {
|
||||
var h, m int
|
||||
n, err := fmt.Sscanf(s, "%d:%d", &h, &m)
|
||||
if err != nil || n != 2 {
|
||||
return 0, fmt.Errorf("expected HH:MM format, got %q", s)
|
||||
}
|
||||
if h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
return 0, fmt.Errorf("invalid time %q: hour must be 0-23, minute 0-59", s)
|
||||
}
|
||||
return h*60 + m, nil
|
||||
}
|
||||
|
||||
// FmtHHMM renders minutes-since-midnight back to "HH:MM", wrapping across midnight (modulo 24h) so
|
||||
// derived legs past 23:59 read correctly (e.g. 23:30 + 60m → 00:30).
|
||||
func FmtHHMM(minutes int) string {
|
||||
minutes = ((minutes % 1440) + 1440) % 1440
|
||||
return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60)
|
||||
}
|
||||
|
||||
// Valid reports whether s is a well-formed HH:MM window value (nil error = valid).
|
||||
func Valid(s string) error {
|
||||
_, err := ParseHHMM(s)
|
||||
return err
|
||||
}
|
||||
|
||||
// LegTimes returns the three derived nightly-leg times (db=W, tier2=W+60m, offbox=W+105m),
|
||||
// wrap-safe across midnight. On an invalid start it returns three empty strings — callers pass a
|
||||
// value already resolved through EffectiveWindow, which never yields an invalid string.
|
||||
func LegTimes(start string) (db, tier2, offbox string) {
|
||||
m, err := ParseHHMM(start)
|
||||
if err != nil {
|
||||
return "", "", ""
|
||||
}
|
||||
return FmtHHMM(m), FmtHHMM(m + tier2OffsetMin), FmtHHMM(m + offboxOffsetMin)
|
||||
}
|
||||
|
||||
// GateWindow returns the whole-guest backup gate bounds [W+2h, W+6h) as HH:MM strings (for the UI
|
||||
// "kb. <from>–<to> között" line and the gate-denial log). Empty strings on an invalid start.
|
||||
func GateWindow(start string) (from, to string) {
|
||||
m, err := ParseHHMM(start)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return FmtHHMM(m + gateStartMin), FmtHHMM(m + gateEndMin)
|
||||
}
|
||||
|
||||
// EffectiveWindow resolves the active window by precedence: a valid settings value wins over a valid
|
||||
// controller.yaml value, which wins over DefaultWindow. An empty or corrupted value simply falls
|
||||
// through — so a bad settings string degrades to the yaml default rather than breaking scheduling.
|
||||
func EffectiveWindow(settingsVal, yamlVal string) string {
|
||||
if Valid(settingsVal) == nil {
|
||||
return settingsVal
|
||||
}
|
||||
if Valid(yamlVal) == nil {
|
||||
return yamlVal
|
||||
}
|
||||
return DefaultWindow
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package backupwindow
|
||||
|
||||
import "testing"
|
||||
|
||||
// Group A — LegTimes derives the three nightly legs at fixed offsets, wrap-safe across midnight.
|
||||
// Red-proof: drop the modulo in FmtHHMM → the 23:30 case yields "24:30"/"25:15" and fails.
|
||||
func TestLegTimes(t *testing.T) {
|
||||
cases := []struct{ start, db, tier2, offbox string }{
|
||||
{"02:30", "02:30", "03:30", "04:15"}, // the default window
|
||||
{"23:30", "23:30", "00:30", "01:15"}, // wraps past midnight
|
||||
{"22:00", "22:00", "23:00", "23:45"},
|
||||
{"00:00", "00:00", "01:00", "01:45"},
|
||||
{"2:30", "02:30", "03:30", "04:15"}, // normalizes a missing leading zero
|
||||
}
|
||||
for _, c := range cases {
|
||||
db, tier2, offbox := LegTimes(c.start)
|
||||
if db != c.db || tier2 != c.tier2 || offbox != c.offbox {
|
||||
t.Errorf("LegTimes(%q) = (%q,%q,%q), want (%q,%q,%q)", c.start, db, tier2, offbox, c.db, c.tier2, c.offbox)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group A — invalid input is rejected (LegTimes → empty; Valid → error). Callers pass a value
|
||||
// already resolved through EffectiveWindow, so an empty result is never rendered.
|
||||
func TestLegTimes_InvalidRejected(t *testing.T) {
|
||||
for _, bad := range []string{"25:77", "2200", "", "ab:cd", "24:00", "12:60", "-1:00"} {
|
||||
db, tier2, offbox := LegTimes(bad)
|
||||
if db != "" || tier2 != "" || offbox != "" {
|
||||
t.Errorf("LegTimes(%q) = (%q,%q,%q), want all empty (rejected)", bad, db, tier2, offbox)
|
||||
}
|
||||
if Valid(bad) == nil {
|
||||
t.Errorf("Valid(%q) = nil, want an error", bad)
|
||||
}
|
||||
}
|
||||
if Valid("02:30") != nil {
|
||||
t.Error("Valid(02:30) returned an error for a well-formed time")
|
||||
}
|
||||
}
|
||||
|
||||
// Group A — the whole-guest gate span is [W+2h, W+6h), wrap-safe.
|
||||
func TestGateWindow(t *testing.T) {
|
||||
if from, to := GateWindow("02:30"); from != "04:30" || to != "08:30" {
|
||||
t.Errorf("GateWindow(02:30) = (%q,%q), want (04:30,08:30)", from, to)
|
||||
}
|
||||
if from, to := GateWindow("23:00"); from != "01:00" || to != "05:00" {
|
||||
t.Errorf("GateWindow(23:00) = (%q,%q), want (01:00,05:00) — must wrap", from, to)
|
||||
}
|
||||
if from, to := GateWindow("bad"); from != "" || to != "" {
|
||||
t.Errorf("GateWindow(bad) = (%q,%q), want empties", from, to)
|
||||
}
|
||||
}
|
||||
|
||||
// Group B — precedence: a valid settings value wins over a valid yaml value, which wins over the
|
||||
// "02:30" default; an empty/corrupt settings value falls through the chain.
|
||||
func TestEffectiveWindow(t *testing.T) {
|
||||
cases := []struct{ settingsVal, yamlVal, want string }{
|
||||
{"22:00", "02:30", "22:00"}, // settings wins over yaml
|
||||
{"", "03:00", "03:00"}, // yaml when settings empty
|
||||
{"", "", "02:30"}, // default when both empty
|
||||
{"garbage", "03:00", "03:00"}, // corrupt settings → fall through to yaml
|
||||
{"garbage", "nope", "02:30"}, // both invalid → default
|
||||
{"22:00", "", "22:00"}, // settings valid, yaml empty
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := EffectiveWindow(c.settingsVal, c.yamlVal); got != c.want {
|
||||
t.Errorf("EffectiveWindow(%q,%q) = %q, want %q", c.settingsVal, c.yamlVal, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow"
|
||||
)
|
||||
|
||||
// ErrBackupInProgress is returned by TriggerNow when a scheduled or manual quiesce cycle is already
|
||||
@@ -27,9 +29,11 @@ import (
|
||||
var ErrBackupInProgress = errors.New("quiesce: a backup cycle is already in progress")
|
||||
|
||||
// Backend is the agent local-API surface the loop needs (satisfied by an adapter over
|
||||
// *agentapi.Client). Kept minimal (bool/string) so the loop is testable with plain fakes.
|
||||
// *agentapi.Client). Kept minimal (bool/int/string) so the loop is testable with plain fakes.
|
||||
// Due also returns the age of the newest successful backup in seconds (nil = none yet) — the
|
||||
// window gate's safety valve reads it so a box powered on only outside its window never starves.
|
||||
type Backend interface {
|
||||
Due(ctx context.Context) (bool, error)
|
||||
Due(ctx context.Context) (due bool, ageSecs *int64, err error)
|
||||
StartBackup(ctx context.Context) (jobID string, err error)
|
||||
BackupStatus(ctx context.Context) (phase string, err error)
|
||||
}
|
||||
@@ -68,6 +72,13 @@ type Options struct {
|
||||
StatusPoll time.Duration // how often to poll /backup/status while quiesced
|
||||
MaxQuiesce time.Duration // hard bound on app downtime (unquiesce no matter what)
|
||||
Logger *log.Logger
|
||||
// WindowStartFn returns the CURRENT effective backup-window start "HH:MM" (customer-configurable,
|
||||
// so it is read fresh each poll — a window change must take effect without restart). When nil the
|
||||
// window gate is disabled and a due cycle runs whenever the agent says due (pre-v0.168.0 behavior).
|
||||
WindowStartFn func() string
|
||||
// Cadence is the agent's backup cadence, used only by the gate's safety valve (run regardless of
|
||||
// the window once the last successful backup is older than Cadence+24h). Defaults to 24h.
|
||||
Cadence time.Duration
|
||||
}
|
||||
|
||||
// Loop is the quiesce background loop.
|
||||
@@ -80,6 +91,9 @@ type Loop struct {
|
||||
maxQuiesce time.Duration
|
||||
logger *log.Logger
|
||||
now func() time.Time
|
||||
// windowStartFn (nil = gate disabled) + cadence drive the scheduled-cycle window gate (Part 3).
|
||||
windowStartFn func() string
|
||||
cadence time.Duration
|
||||
// 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).
|
||||
@@ -100,10 +114,14 @@ func New(o Options) *Loop {
|
||||
if o.Logger == nil {
|
||||
o.Logger = log.Default()
|
||||
}
|
||||
if o.Cadence <= 0 {
|
||||
o.Cadence = 24 * time.Hour
|
||||
}
|
||||
return &Loop{
|
||||
backend: o.Backend, stacks: o.Stacks, markerPath: o.MarkerPath,
|
||||
poll: o.Poll, statusPoll: o.StatusPoll, maxQuiesce: o.MaxQuiesce,
|
||||
logger: o.Logger, now: time.Now,
|
||||
windowStartFn: o.WindowStartFn, cadence: o.Cadence,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +177,7 @@ func (l *Loop) runOnce(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
due, err := l.backend.Due(ctx)
|
||||
due, ageSecs, err := l.backend.Due(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check due: %w", err)
|
||||
}
|
||||
@@ -167,6 +185,17 @@ func (l *Loop) runOnce(ctx context.Context) error {
|
||||
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).
|
||||
if l.windowStartFn != nil {
|
||||
window := l.windowStartFn()
|
||||
if !scheduledRunAllowed(l.now().In(budapestLocation()), window, ageSecs, l.cadence) {
|
||||
from, to := gateBounds(window)
|
||||
l.logger.Printf("[DEBUG] [quiesce] scheduled backup due but outside the backup window [%s–%s) — deferring to the next poll inside it", from, to)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return l.quiesceAndPoll(ctx)
|
||||
}
|
||||
|
||||
@@ -286,6 +315,63 @@ func (l *Loop) quiesceAndPoll(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- window gate (Part 3, v0.168.0) -----------------------------------------------------
|
||||
|
||||
var (
|
||||
quiesceBudapest *time.Location
|
||||
quiesceBudapestOnce sync.Once
|
||||
)
|
||||
|
||||
func budapestLocation() *time.Location {
|
||||
quiesceBudapestOnce.Do(func() {
|
||||
loc, err := time.LoadLocation("Europe/Budapest")
|
||||
if err != nil {
|
||||
quiesceBudapest = time.UTC
|
||||
return
|
||||
}
|
||||
quiesceBudapest = loc
|
||||
})
|
||||
return quiesceBudapest
|
||||
}
|
||||
|
||||
const (
|
||||
gateOpenOffsetMin = 120 // gate opens at W+2h
|
||||
gateSpanMin = 240 // 4h span → [W+2h, W+6h)
|
||||
)
|
||||
|
||||
// scheduledRunAllowed decides whether a DUE, scheduled whole-guest backup may run at `now` (passed by
|
||||
// the caller as Budapest wall-clock — only its hour/minute are read). True when now is inside the gate
|
||||
// window [W+2h, W+6h); otherwise true ONLY if the safety valve holds — the newest successful backup is
|
||||
// missing (nil) or older than cadence+24h — so a box powered on only outside its window never starves.
|
||||
// An unparseable window fails OPEN (allow) rather than block backups forever.
|
||||
func scheduledRunAllowed(now time.Time, windowStart string, lastAgeSecs *int64, cadence time.Duration) bool {
|
||||
startMin, err := backupwindow.ParseHHMM(windowStart)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
nowMin := now.Hour()*60 + now.Minute()
|
||||
if within(nowMin, mod1440(startMin+gateOpenOffsetMin), gateSpanMin) {
|
||||
return true
|
||||
}
|
||||
// Outside the window: only the safety valve may run it.
|
||||
if lastAgeSecs == nil {
|
||||
return true // no recorded backup yet — never withhold the first one
|
||||
}
|
||||
return time.Duration(*lastAgeSecs)*time.Second > cadence+24*time.Hour
|
||||
}
|
||||
|
||||
// gateBounds returns the gate window [W+2h, W+6h) as HH:MM for the deferral log line.
|
||||
func gateBounds(windowStart string) (from, to string) {
|
||||
return backupwindow.GateWindow(windowStart)
|
||||
}
|
||||
|
||||
func mod1440(m int) int { return ((m % 1440) + 1440) % 1440 }
|
||||
|
||||
// within reports whether minute-of-day p falls in [start, start+span) modulo 24h (wrap-safe).
|
||||
func within(p, start, span int) bool {
|
||||
return mod1440(p-start) < span
|
||||
}
|
||||
|
||||
func (l *Loop) restartAll(stacks []string) {
|
||||
for _, s := range stacks {
|
||||
if err := l.stacks.StartStack(s); err != nil {
|
||||
|
||||
@@ -34,8 +34,8 @@ type eventBackend struct {
|
||||
i int
|
||||
}
|
||||
|
||||
func (b *eventBackend) Due(context.Context) (bool, error) { return true, nil }
|
||||
func (b *eventBackend) StartBackup(context.Context) (string, error) { return "job-1", nil }
|
||||
func (b *eventBackend) Due(context.Context) (bool, *int64, error) { return true, nil, nil }
|
||||
func (b *eventBackend) StartBackup(context.Context) (string, error) { return "job-1", nil }
|
||||
func (b *eventBackend) BackupStatus(context.Context) (string, error) {
|
||||
ph := b.phases[len(b.phases)-1]
|
||||
if b.i < len(b.phases) {
|
||||
|
||||
@@ -54,6 +54,7 @@ func (f *fakeStacks) stoppedNames() []string {
|
||||
// fakeBackend drives the agent-side responses.
|
||||
type fakeBackend struct {
|
||||
due bool
|
||||
dueAge *int64 // age of newest successful backup (nil = none); gate safety-valve input
|
||||
dueErr error
|
||||
startErr error
|
||||
jobID string
|
||||
@@ -64,7 +65,7 @@ type fakeBackend struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (b *fakeBackend) Due(context.Context) (bool, error) { return b.due, b.dueErr }
|
||||
func (b *fakeBackend) Due(context.Context) (bool, *int64, error) { return b.due, b.dueAge, b.dueErr }
|
||||
func (b *fakeBackend) StartBackup(context.Context) (string, error) {
|
||||
b.mu.Lock()
|
||||
b.startCalls++
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package quiesce
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func i64(v int64) *int64 { return &v }
|
||||
|
||||
// atBudapest builds a time whose Budapest wall-clock hour/minute are h:m (the predicate reads only
|
||||
// those; the caller in the loop passes now.In(Budapest)).
|
||||
func atBudapest(h, m int) time.Time {
|
||||
return time.Date(2026, 7, 24, h, m, 0, 0, budapestLocation())
|
||||
}
|
||||
|
||||
const cadence24 = 24 * time.Hour
|
||||
|
||||
// Group D — scheduledRunAllowed truth table: inside gate / outside / outside+valve / wrap / nil age.
|
||||
// Window 02:30 → gate [04:30, 08:30). Valve threshold = cadence+24h = 48h.
|
||||
// Red-proof: invert the valve comparison (`<` instead of `>`) and the starving-box case (age 49h,
|
||||
// outside window) flips to false.
|
||||
func TestScheduledRunAllowed(t *testing.T) {
|
||||
h := func(hours int64) *int64 { return i64(hours * 3600) }
|
||||
cases := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
window string
|
||||
age *int64
|
||||
want bool
|
||||
}{
|
||||
{"inside gate, recent backup", atBudapest(5, 0), "02:30", h(20), true},
|
||||
{"gate open boundary (inclusive)", atBudapest(4, 30), "02:30", h(20), true},
|
||||
{"gate close boundary (exclusive)", atBudapest(8, 30), "02:30", h(20), false},
|
||||
{"outside gate, no valve", atBudapest(12, 0), "02:30", h(20), false},
|
||||
{"outside gate, valve (age > 48h)", atBudapest(12, 0), "02:30", h(49), true},
|
||||
{"outside gate, nil age (no backup yet)", atBudapest(12, 0), "02:30", nil, true},
|
||||
{"wrap: inside gate across midnight", atBudapest(2, 0), "23:00", h(20), true},
|
||||
{"wrap: outside gate across midnight", atBudapest(12, 0), "23:00", h(20), false},
|
||||
{"unparseable window fails open", atBudapest(12, 0), "nonsense", h(20), true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := scheduledRunAllowed(c.now, c.window, c.age, cadence24); got != c.want {
|
||||
t.Errorf("%s: scheduledRunAllowed(%s, %q, age, cadence) = %v, want %v",
|
||||
c.name, c.now.Format("15:04"), c.window, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// windowLoop builds a Loop with the gate wired and now/window overridden for deterministic tests.
|
||||
func windowLoop(t *testing.T, be Backend, st Stacks, window string, now time.Time) *Loop {
|
||||
t.Helper()
|
||||
l := testLoop(t, be, st)
|
||||
l.windowStartFn = func() string { return window }
|
||||
l.cadence = cadence24
|
||||
l.now = func() time.Time { return now }
|
||||
return l
|
||||
}
|
||||
|
||||
// Group D (integration) — a DUE scheduled cycle outside the window with a recent backup is deferred:
|
||||
// no StartBackup, no stacks stopped.
|
||||
func TestRunOnce_GateDefersOutsideWindow(t *testing.T) {
|
||||
be := &fakeBackend{due: true, dueAge: i64(20 * 3600)}
|
||||
st := &fakeStacks{running: []string{"nextcloud"}}
|
||||
l := windowLoop(t, be, st, "02:30", atBudapest(12, 0)) // gate [04:30,08:30), 12:00 is outside
|
||||
|
||||
if err := l.runOnce(context.Background()); err != nil {
|
||||
t.Fatalf("runOnce: %v", err)
|
||||
}
|
||||
if be.startCalls != 0 {
|
||||
t.Errorf("gate should have deferred, but StartBackup was called %d time(s)", be.startCalls)
|
||||
}
|
||||
if len(st.stoppedNames()) != 0 {
|
||||
t.Errorf("gate should have deferred, but stacks were stopped: %v", st.stoppedNames())
|
||||
}
|
||||
}
|
||||
|
||||
// Group D (integration) — inside the window the cycle runs normally.
|
||||
func TestRunOnce_GateRunsInsideWindow(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)) // 05:00 is inside [04:30,08:30)
|
||||
|
||||
if err := l.runOnce(context.Background()); err != nil {
|
||||
t.Fatalf("runOnce: %v", err)
|
||||
}
|
||||
if be.startCalls != 1 {
|
||||
t.Errorf("inside the window the cycle should run; StartBackup calls = %d", be.startCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Group D (integration) — outside the window but the safety valve holds (box was off during its
|
||||
// window; last backup older than cadence+24h): the cycle runs regardless of the window.
|
||||
func TestRunOnce_ValveRunsOutsideWindow(t *testing.T) {
|
||||
be := &fakeBackend{due: true, dueAge: i64(49 * 3600), phases: []string{"done"}}
|
||||
st := &fakeStacks{running: []string{"nextcloud"}}
|
||||
l := windowLoop(t, be, st, "02:30", atBudapest(12, 0)) // outside, but age 49h > 48h valve
|
||||
|
||||
if err := l.runOnce(context.Background()); err != nil {
|
||||
t.Fatalf("runOnce: %v", err)
|
||||
}
|
||||
if be.startCalls != 1 {
|
||||
t.Errorf("safety valve should have run the cycle; StartBackup calls = %d", be.startCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Group D (integration) — the MANUAL trigger path is never gated: TriggerNow runs quiesceAndPoll
|
||||
// directly (bypassing runOnce), so an outside-window manual backup still fires.
|
||||
func TestTriggerNow_NotGated(t *testing.T) {
|
||||
be := &fakeBackend{due: false, phases: []string{"done"}} // not due — only the manual path can run it
|
||||
st := &fakeStacks{running: []string{"nextcloud"}}
|
||||
l := windowLoop(t, be, st, "02:30", atBudapest(12, 0)) // outside the window
|
||||
|
||||
if err := l.TriggerNow(); err != nil {
|
||||
t.Fatalf("TriggerNow: %v", err)
|
||||
}
|
||||
// TriggerNow runs asynchronously — wait for the backup to be started.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
be.mu.Lock()
|
||||
n := be.startCalls
|
||||
be.mu.Unlock()
|
||||
if n == 1 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("manual TriggerNow did not start a backup — it must never be gated by the window")
|
||||
}
|
||||
@@ -37,6 +37,10 @@ type Job struct {
|
||||
LastRun time.Time
|
||||
LastErr error
|
||||
Running bool
|
||||
// resched (daily jobs only, buffered cap 1) is the runtime-reschedule signal: UpdateDaily mutates
|
||||
// Schedule under the mutex and non-blocking-sends here so runDailyJob wakes and recomputes its next
|
||||
// run immediately, instead of waiting out the old timer. Immutable after job creation.
|
||||
resched chan struct{}
|
||||
}
|
||||
|
||||
// Scheduler manages periodic and daily jobs.
|
||||
@@ -120,6 +124,7 @@ func (s *Scheduler) Daily(name string, timeStr string, fn JobFunc) {
|
||||
Name: name,
|
||||
Fn: fn,
|
||||
Schedule: timeStr,
|
||||
resched: make(chan struct{}, 1),
|
||||
}
|
||||
s.jobs = append(s.jobs, job)
|
||||
|
||||
@@ -133,6 +138,50 @@ func (s *Scheduler) Daily(name string, timeStr string, fn JobFunc) {
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateDaily changes the schedule of an already-registered daily job at runtime (no restart) and
|
||||
// wakes its goroutine so the new time takes effect at the next scheduling pass. Returns false (and
|
||||
// logs WARN) on an invalid time or an unknown/non-daily job name — the job is left untouched.
|
||||
func (s *Scheduler) UpdateDaily(name, timeStr string) bool {
|
||||
if _, _, err := parseDailyTime(timeStr); err != nil {
|
||||
s.logger.Printf("[WARN] [scheduler] UpdateDaily %s: invalid schedule %q: %v — ignored", name, timeStr, err)
|
||||
return false
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
var job *Job
|
||||
for _, j := range s.jobs {
|
||||
if j.Name == name && j.Schedule != "" {
|
||||
job = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if job == nil {
|
||||
s.mu.Unlock()
|
||||
s.logger.Printf("[WARN] [scheduler] UpdateDaily: no daily job named %q — ignored", name)
|
||||
return false
|
||||
}
|
||||
old := job.Schedule
|
||||
if old == timeStr {
|
||||
s.mu.Unlock()
|
||||
return true // no-op: already at this time
|
||||
}
|
||||
job.Schedule = timeStr
|
||||
ch := job.resched
|
||||
s.mu.Unlock()
|
||||
|
||||
s.logger.Printf("[INFO] [scheduler] Daily job %s rescheduled %s → %s (next run %s)",
|
||||
name, old, timeStr, nextDailyRun(timeStr).Format("2006-01-02 15:04 MST"))
|
||||
// Non-blocking wake: the buffered slot coalesces bursts (three legs updated back-to-back each
|
||||
// signal their own job); if the goroutine hasn't drained yet the recompute already covers this.
|
||||
if ch != nil {
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Start begins running all registered jobs. Safe to call only once.
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
s.mu.Lock()
|
||||
@@ -234,7 +283,13 @@ func (s *Scheduler) runDailyJob(job *Job) {
|
||||
defer s.wg.Done()
|
||||
|
||||
for {
|
||||
nextRun := nextDailyRun(job.Schedule)
|
||||
// Read Schedule under the mutex — UpdateDaily mutates it concurrently. resched is immutable
|
||||
// after creation, so it is safe to read once here.
|
||||
s.mu.Lock()
|
||||
schedule := job.Schedule
|
||||
s.mu.Unlock()
|
||||
|
||||
nextRun := nextDailyRun(schedule)
|
||||
waitDuration := time.Until(nextRun)
|
||||
|
||||
if waitDuration < 0 {
|
||||
@@ -249,6 +304,11 @@ func (s *Scheduler) runDailyJob(job *Job) {
|
||||
timer.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()
|
||||
s.dbg("daily job %s: rescheduled — recomputing next run", job.Name)
|
||||
continue
|
||||
case <-timer.C:
|
||||
s.executeJob(job, false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func discardScheduler() *Scheduler { return New(log.New(io.Discard, "", 0)) }
|
||||
|
||||
func noopJob(context.Context) error { return nil }
|
||||
|
||||
// farFuture returns an HH:MM roughly n hours ahead in Budapest, so a registered daily job parks on a
|
||||
// long timer (never fires during a sub-second test) yet is a valid, distinct schedule to switch to.
|
||||
func farFuture(hoursAhead int) string {
|
||||
return time.Now().In(getBudapestLocation()).Add(time.Duration(hoursAhead) * time.Hour).Format("15:04")
|
||||
}
|
||||
|
||||
// Group C — UpdateDaily mutates the schedule and delivers the reschedule signal. Asserted on a
|
||||
// NOT-started scheduler so no goroutine competes for the buffered signal (race-free).
|
||||
func TestUpdateDaily_MutatesAndSignals(t *testing.T) {
|
||||
s := discardScheduler()
|
||||
s.Daily("db-dump", "02:30", noopJob)
|
||||
|
||||
if !s.UpdateDaily("db-dump", "22:00") {
|
||||
t.Fatal("UpdateDaily returned false for a valid change")
|
||||
}
|
||||
if got := s.jobs[0].Schedule; got != "22:00" {
|
||||
t.Errorf("Schedule = %q, want 22:00", got)
|
||||
}
|
||||
// The buffered reschedule signal must be present (no goroutine drained it).
|
||||
select {
|
||||
case <-s.jobs[0].resched:
|
||||
default:
|
||||
t.Error("no reschedule signal was sent by UpdateDaily")
|
||||
}
|
||||
}
|
||||
|
||||
// Group C — unknown or non-daily job name, and an invalid time, both return false without panicking
|
||||
// and leave everything untouched.
|
||||
func TestUpdateDaily_UnknownAndInvalid(t *testing.T) {
|
||||
s := discardScheduler()
|
||||
s.Daily("db-dump", "02:30", noopJob)
|
||||
|
||||
if s.UpdateDaily("does-not-exist", "03:00") {
|
||||
t.Error("UpdateDaily on an unknown job returned true")
|
||||
}
|
||||
if s.UpdateDaily("db-dump", "25:99") {
|
||||
t.Error("UpdateDaily with an invalid time returned true")
|
||||
}
|
||||
if got := s.jobs[0].Schedule; got != "02:30" {
|
||||
t.Errorf("Schedule was mutated by a rejected update: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Group C — a running daily goroutine picks up the new time on the next pass: it CONSUMES the
|
||||
// reschedule signal from the select and recomputes. Red-proof: remove the `case <-job.resched`
|
||||
// from runDailyJob's select and this fails (the signal stays buffered — never consumed).
|
||||
func TestUpdateDaily_GoroutineConsumesReschedule(t *testing.T) {
|
||||
s := discardScheduler()
|
||||
s.Daily("db-dump", farFuture(6), noopJob) // parked on a ~6h timer
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.Start(ctx)
|
||||
|
||||
if !s.UpdateDaily("db-dump", farFuture(8)) {
|
||||
t.Fatal("UpdateDaily returned false")
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond) // let the goroutine wake on resched and recompute
|
||||
cancel()
|
||||
s.Stop() // waits for the goroutine to exit — after this, reading resched is race-free
|
||||
|
||||
select {
|
||||
case <-s.jobs[0].resched:
|
||||
t.Fatal("reschedule signal still buffered — runDailyJob never consumed it (no resched case)")
|
||||
default:
|
||||
// drained by the goroutine → immediacy works
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,11 @@ type Settings struct {
|
||||
// Per-app backup preferences
|
||||
AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"`
|
||||
|
||||
// Customer-configurable backup-window start "HH:MM" (v0.168.0). "" = use controller.yaml
|
||||
// db_dump_schedule (then the "02:30" default). Every nightly leg derives from this at fixed
|
||||
// offsets; overrides yaml when a valid value is present (mirrors PasswordHash precedence).
|
||||
BackupWindowStart string `json:"backup_window_start,omitempty"`
|
||||
|
||||
// Storage paths registry
|
||||
StoragePaths []StoragePath `json:"storage_paths,omitempty"`
|
||||
|
||||
@@ -146,7 +151,7 @@ type AppBackupPrefs struct {
|
||||
type OffboxTarget struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"` // default 22
|
||||
Port int `json:"port"` // default 22
|
||||
User string `json:"user"`
|
||||
RepoPath string `json:"repo_path"` // absolute path on the NAS, e.g. /volume1/felhom-backup/repo
|
||||
Schedule string `json:"schedule"` // "daily" | "manual"
|
||||
@@ -217,7 +222,7 @@ type CrossDriveBackup struct {
|
||||
LastRun string `json:"last_run,omitempty"` // RFC3339
|
||||
LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running"
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastWarning string `json:"last_warning,omitempty"` // Tier-2 3b: capture-gap / state-only notice (Hungarian)
|
||||
LastWarning string `json:"last_warning,omitempty"` // Tier-2 3b: capture-gap / state-only notice (Hungarian)
|
||||
LastDuration string `json:"last_duration,omitempty"` // "2m34s"
|
||||
LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB"
|
||||
|
||||
@@ -492,6 +497,26 @@ func (s *Settings) SetPasswordHash(hash string) error {
|
||||
|
||||
// ── Guest launcher share (v0.165.0) ──────────────────────────────────────────────
|
||||
|
||||
// ── Backup window (v0.168.0) ─────────────────────────────────────────────────────
|
||||
|
||||
// GetBackupWindowStart returns the customer-set backup-window start "HH:MM" ("" = fall back to
|
||||
// controller.yaml, then the default — resolve via backupwindow.EffectiveWindow, never in isolation).
|
||||
func (s *Settings) GetBackupWindowStart() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.BackupWindowStart
|
||||
}
|
||||
|
||||
// SetBackupWindowStart stores (or clears, on "") the backup-window start and saves. The caller
|
||||
// validates the HH:MM format first (the scheduler/backupwindow gate) and fans the change out to the
|
||||
// three daily legs via UpdateDaily — this only persists the single source-of-truth value.
|
||||
func (s *Settings) SetBackupWindowStart(start string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.BackupWindowStart = start
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// GetLauncherShareToken returns the guest-launcher capability token ("" = sharing disabled).
|
||||
func (s *Settings) GetLauncherShareToken() string {
|
||||
s.mu.RLock()
|
||||
|
||||
@@ -4,13 +4,79 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/quiesce"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
)
|
||||
|
||||
// effectiveBackupWindow resolves the active backup-window start (settings > controller.yaml >
|
||||
// "02:30") for this server. Every nightly-leg display and the DB-dump next-run derive from it.
|
||||
func (s *Server) effectiveBackupWindow() string {
|
||||
return backupwindow.EffectiveWindow(s.settings.GetBackupWindowStart(), s.cfg.Backup.DBDumpSchedule)
|
||||
}
|
||||
|
||||
// backupWindowData injects the customer-configurable-window view onto the Áttekintés page: the
|
||||
// effective start, the three derived leg times (DB / helyi másolat / távoli mentés), and the
|
||||
// whole-guest gate span [W+2h, W+6h). The offsets are DERIVED here, never stored.
|
||||
func (s *Server) backupWindowData(data map[string]interface{}) {
|
||||
win := s.effectiveBackupWindow()
|
||||
db, tier2, offbox := backupwindow.LegTimes(win)
|
||||
from, to := backupwindow.GateWindow(win)
|
||||
data["BackupWindow"] = win
|
||||
data["BackupLegDB"] = db
|
||||
data["BackupLegTier2"] = tier2
|
||||
data["BackupLegOffbox"] = offbox
|
||||
data["BackupGateFrom"] = from
|
||||
data["BackupGateTo"] = to
|
||||
}
|
||||
|
||||
// backupWindowSaveHandler persists a new backup-window start and fans it out to the three daily legs
|
||||
// live (no restart) via UpdateDaily. POST /backups/window (behind RequireAuth + CsrfProtect). On an
|
||||
// invalid time nothing is stored and the jobs are untouched.
|
||||
func (s *Server) backupWindowSaveHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
start := strings.TrimSpace(r.FormValue("window_start"))
|
||||
if backupwindow.Valid(start) != nil {
|
||||
s.backupWindowRedirect(w, r, "", "Érvénytelen időpont. Használja a ÓÓ:PP formátumot (például 02:30).")
|
||||
return
|
||||
}
|
||||
if err := s.settings.SetBackupWindowStart(start); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] backup window save failed: %v", err)
|
||||
s.backupWindowRedirect(w, r, "", "A mentési időablak mentése nem sikerült.")
|
||||
return
|
||||
}
|
||||
// Fan out to the three daily legs at their fixed offsets — takes effect at the next scheduling
|
||||
// pass (no restart). The scheduler wakes each job via its reschedule signal.
|
||||
db, tier2, offbox := backupwindow.LegTimes(start)
|
||||
if s.scheduler != nil {
|
||||
s.scheduler.UpdateDaily("db-dump", db)
|
||||
s.scheduler.UpdateDaily("tier2-backup", tier2)
|
||||
s.scheduler.UpdateDaily("offbox-backup", offbox)
|
||||
}
|
||||
// Refresh the cached "next DB dump" so the display updates immediately, not at the next 5m tick.
|
||||
if s.backupMgr != nil {
|
||||
s.backupMgr.RefreshCache(scheduler.NextDailyRun(db))
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] backup window set to %s (legs %s/%s/%s)", start, db, tier2, offbox)
|
||||
s.backupWindowRedirect(w, r, "Mentési időablak frissítve.", "")
|
||||
}
|
||||
|
||||
// backupWindowRedirect PRG-redirects back to the Áttekintés page with a success or error flash.
|
||||
func (s *Server) backupWindowRedirect(w http.ResponseWriter, r *http.Request, flash, flashErr string) {
|
||||
dest := "/backups"
|
||||
if flashErr != "" {
|
||||
dest += "?flash_error=" + url.QueryEscape(flashErr)
|
||||
} else if flash != "" {
|
||||
dest += "?flash=" + url.QueryEscape(flash)
|
||||
}
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// Whole-guest backup visibility + manual trigger (spec Part 2). The agent owns whole-guest
|
||||
// vzdump/PBS backup; the controller is a read-only window onto it (GET /backup/{status,due},
|
||||
// /restore-test/status) plus a "Mentés most" trigger that goes through the quiesce loop (the
|
||||
@@ -41,8 +107,8 @@ type guestBackupView struct {
|
||||
DueReason string
|
||||
AgeHours int64 // age of the newest successful backup, hours (for "X órája")
|
||||
|
||||
HasRestoreTest bool
|
||||
RestorePass bool
|
||||
HasRestoreTest bool
|
||||
RestorePass bool
|
||||
RestoreVerified string
|
||||
RestoreTestedAt time.Time
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
)
|
||||
|
||||
// schedWith3Legs builds a real scheduler carrying the three daily legs at their default times, so
|
||||
// UpdateDaily has jobs to find.
|
||||
func schedWith3Legs() *scheduler.Scheduler {
|
||||
s := scheduler.New(log.New(io.Discard, "", 0))
|
||||
noop := func(context.Context) error { return nil }
|
||||
s.Daily("db-dump", "02:30", noop)
|
||||
s.Daily("tier2-backup", "03:30", noop)
|
||||
s.Daily("offbox-backup", "04:15", noop)
|
||||
return s
|
||||
}
|
||||
|
||||
func legTimes(t *testing.T, sch *scheduler.Scheduler, name string) string {
|
||||
t.Helper()
|
||||
for _, j := range sch.GetJobs() {
|
||||
if j.Name == name {
|
||||
return j.Schedule
|
||||
}
|
||||
}
|
||||
t.Fatalf("job %q not found", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Group E — a valid save writes the setting, fans the three legs out via UpdateDaily, and redirects
|
||||
// with a success flash. Red-proof: skip the Valid() check in the handler and an invalid value would
|
||||
// be written — TestBackupWindowSave_Invalid then fails.
|
||||
func TestBackupWindowSave_Valid(t *testing.T) {
|
||||
s := testServer(t)
|
||||
s.scheduler = schedWith3Legs()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/backups/window", strings.NewReader("window_start=22:00"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
s.backupWindowSaveHandler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "flash=") || strings.Contains(loc, "flash_error=") {
|
||||
t.Errorf("redirect Location = %q, want a success flash", loc)
|
||||
}
|
||||
if got := s.settings.GetBackupWindowStart(); got != "22:00" {
|
||||
t.Errorf("settings BackupWindowStart = %q, want 22:00", got)
|
||||
}
|
||||
// The three legs must have been rescheduled to W / W+60m / W+105m.
|
||||
if got := legTimes(t, s.scheduler, "db-dump"); got != "22:00" {
|
||||
t.Errorf("db-dump = %q, want 22:00", got)
|
||||
}
|
||||
if got := legTimes(t, s.scheduler, "tier2-backup"); got != "23:00" {
|
||||
t.Errorf("tier2-backup = %q, want 23:00", got)
|
||||
}
|
||||
if got := legTimes(t, s.scheduler, "offbox-backup"); got != "23:45" {
|
||||
t.Errorf("offbox-backup = %q, want 23:45", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Group E — an invalid time stores nothing, leaves the legs untouched, and redirects with an error
|
||||
// flash.
|
||||
func TestBackupWindowSave_Invalid(t *testing.T) {
|
||||
s := testServer(t)
|
||||
s.scheduler = schedWith3Legs()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/backups/window", strings.NewReader("window_start=2500"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
s.backupWindowSaveHandler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "flash_error=") {
|
||||
t.Errorf("redirect Location = %q, want an error flash", loc)
|
||||
}
|
||||
if got := s.settings.GetBackupWindowStart(); got != "" {
|
||||
t.Errorf("settings BackupWindowStart = %q, want empty (nothing stored)", got)
|
||||
}
|
||||
if got := legTimes(t, s.scheduler, "db-dump"); got != "02:30" {
|
||||
t.Errorf("db-dump = %q, want 02:30 (unchanged)", got)
|
||||
}
|
||||
if got := legTimes(t, s.scheduler, "offbox-backup"); got != "04:15" {
|
||||
t.Errorf("offbox-backup = %q, want 04:15 (unchanged)", got)
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Backup status
|
||||
data["BackupEnabled"] = s.cfg.Backup.Enabled
|
||||
if s.backupMgr != nil {
|
||||
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
|
||||
nextDBDump := scheduler.NextDailyRun(s.effectiveBackupWindow())
|
||||
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
|
||||
data["DBDumpStatus"] = fullStatus.LastDBDump
|
||||
// F3 (AUDIT-vacation-remote-ops-2026-07-20): the card's "Utolsó mentés" row branches on
|
||||
@@ -731,7 +731,7 @@ func (s *Server) backupsCommonData(page, title string, r *http.Request) map[stri
|
||||
data["Backup"] = nil
|
||||
return data
|
||||
}
|
||||
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
|
||||
nextDBDump := scheduler.NextDailyRun(s.effectiveBackupWindow())
|
||||
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
|
||||
|
||||
// Pass flash messages from query params (set by redirect handlers)
|
||||
@@ -818,6 +818,9 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Whole-guest backup view (agent-sourced, read-only) for the "Rendszermentés" section.
|
||||
data["GuestBackup"] = s.loadGuestBackup(r.Context())
|
||||
|
||||
// Customer-configurable backup window (v0.168.0): effective start + derived leg/gate times.
|
||||
s.backupWindowData(data)
|
||||
|
||||
if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil {
|
||||
// DB-section state — honest messaging for embedded-DB-only boxes (SQLite etc.):
|
||||
// "dumps" (real dumps) | "pending" (discovered, first run tonight) | "embedded".
|
||||
|
||||
@@ -111,9 +111,9 @@ type Server struct {
|
||||
// Same S-5 law as sambaAddrFn: live-computed per render/dump, stored nowhere.
|
||||
guestGatewayFn func() string
|
||||
guestNetFn func() stacks.GuestNetSnapshot
|
||||
netAgentFn func() (netAgent, error)
|
||||
netAgentFn func() (netAgent, error)
|
||||
// fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0).
|
||||
fabUpload uploadState
|
||||
fabUpload uploadState
|
||||
netProbeFn func(ctx context.Context, dir string) probeOutcome
|
||||
netListFn func(ctx context.Context) ([]agentapi.NetworkMountStatus, error)
|
||||
// agentLogsFn is the Debug-page agent-tab seam (v0.116.0). nil → the shared
|
||||
@@ -383,6 +383,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.stacksHandler(w, r)
|
||||
case path == "/backups":
|
||||
s.backupsHandler(w, r)
|
||||
case path == "/backups/window" && r.Method == http.MethodPost:
|
||||
s.backupWindowSaveHandler(w, r)
|
||||
// v0.124.0 IA split: the backups page's four sub-pages (old /backups deep links keep working —
|
||||
// /backups itself is the Áttekintés page).
|
||||
case path == "/backups/remote":
|
||||
|
||||
@@ -109,6 +109,26 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Section: customer-configurable backup window (v0.168.0) — one setting drives every nightly leg -->
|
||||
<div class="backup-section-card">
|
||||
<h3>Mentési időablak</h3>
|
||||
<p class="form-hint" style="margin-bottom:1rem">A mentések egymás után futnak: adatbázis-mentés, helyi másolat, távoli mentés, majd a teljes rendszermentés.</p>
|
||||
<form method="POST" action="/backups/window" class="schedule-actions" style="display:flex;align-items:flex-end;gap:.75rem;flex-wrap:wrap">
|
||||
{{.CSRFField}}
|
||||
<div class="form-group" style="margin:0">
|
||||
<label for="window_start">Mentési időablak kezdete</label>
|
||||
<input type="time" id="window_start" name="window_start" value="{{.BackupWindow}}" class="form-control" style="max-width:9rem" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Mentés</button>
|
||||
</form>
|
||||
<ul class="form-hint" style="margin-top:1rem;margin-bottom:0;list-style:none;padding:0;line-height:1.9">
|
||||
<li>Adatbázis-mentés: <strong>{{.BackupLegDB}}</strong></li>
|
||||
<li>Helyi másolat: <strong>{{.BackupLegTier2}}</strong></li>
|
||||
<li>Távoli mentés: <strong>{{.BackupLegOffbox}}</strong></li>
|
||||
<li>Teljes rendszermentés: kb. <strong>{{.BackupGateFrom}}–{{.BackupGateTo}}</strong> között</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Section 1: Status overview cards -->
|
||||
<div class="stats-grid backup-page-cards">
|
||||
{{if .Backup.LastDBDump}}
|
||||
|
||||
Reference in New Issue
Block a user