feat(report): v0.139.0 — immediate out-of-cycle hub report on user actions (Direction 1)
New report.Trigger (buffered-1 chan + worker; quiet 2s, min spacing 15s, trailing-edge coalescing) generalizes the v0.70.0 geo out-of-band push. One canonical fire closure in main.go; wired: geo save/sync + app deploy/remove/delete (api reportPushNow), escrow recovery-code claim, notification-prefs save, app-email toggle, offsite config + per-app toggle, customer claim (web SetReportTrigger seam, nil-safe, fired only after a successful local commit). 15-min hub-report cycle untouched as the reconciliation backbone; hub.enabled=false stays a strict no-op. Tests: trigger engine (2 red-proofs), seam fires-after-commit-only, nil-seam no-ops.
This commit is contained in:
@@ -1778,7 +1778,7 @@ Periodic JSON push (default every 15 min) to the central felhom-hub service:
|
||||
|
||||
Bearer token authentication, 3-attempt retry with 5-second backoff. Push status tracked via `PushStatus` struct (LastAttempt, LastSuccess, LastError, consecutive failures) — used by the monitoring page and alert system to show Hub connection health.
|
||||
|
||||
**Immediate report push on geo change (v0.70.0):** besides the periodic cycle, a successful geo settings save and a successful manual geo sync fire an **out-of-band, non-blocking** report push (`Router.reportPushNow`, wired in `main.go` to `BuildReport`+`Push` in a goroutine), so the Hub reflects the new geo state / clears a stale `last_sync_error` within seconds instead of after the next ~15-min cycle. Currently scoped to the geo handlers; the same seam can be reused for other settings later.
|
||||
**Immediate out-of-cycle report on user actions (v0.139.0, generalizing the v0.70.0 geo push):** besides the periodic cycle, user actions with hub-side effects fire a **debounced, coalescing out-of-cycle report push** (`report.Trigger` in `internal/report/trigger.go`: buffered-1 signal channel + single worker; quiet window 2 s, min spacing 15 s, trailing-edge — a burst coalesces to ≤ 1 + ceil(burst/15 s) pushes and the LAST state always reaches the Hub). One canonical fire closure in `main.go` does the full `BuildReport`+`Claimed`+`Push`; the trigger adds NO retry of its own (the Pusher owns retries) and every failure degrades to the 15-min cycle, which stays the reconciliation backbone. Wired call sites: geo settings save/manual sync + app deploy/remove/delete (`api.Router.reportPushNow`), and via the `web.Server.SetReportTrigger` seam (`reportTriggerNow`, fired only AFTER a successful local commit): escrow recovery-code claim (the ACK hash-match flips pending→escrowed in seconds), notification-prefs save, app-email toggle, offsite target config + per-app offsite toggle, customer claim completion. `hub.enabled: false` → the seams stay nil (strict no-op).
|
||||
|
||||
#### Config apply + self-restart (`internal/api/router.go`, `internal/api/selfrestart.go`)
|
||||
|
||||
|
||||
@@ -795,20 +795,30 @@ func main() {
|
||||
alertMgr.Refresh(report, cfg, backupMgr, false, "")
|
||||
}()
|
||||
|
||||
// --- Out-of-cycle report trigger (v0.139.0, Direction 1) ---
|
||||
// ONE canonical fire closure (full BuildReport + Claimed + Push) behind a debounced,
|
||||
// coalescing trigger — user actions with hub-side effects (geo, escrow claim, settings
|
||||
// save, offsite toggle, app deploy/remove, customer claim) round-trip in seconds instead
|
||||
// of the next ~15-min cycle. The scheduled hub-report job above stays the reconciliation
|
||||
// backbone; the trigger is best-effort on top (its failures degrade to the cycle).
|
||||
// nil when hub reporting is off → the api/web seams stay unset (strict no-op).
|
||||
var reportTrigger *report.Trigger
|
||||
if hubPusher != nil && cfg.Hub.Enabled {
|
||||
fireReport := func() error {
|
||||
rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
|
||||
rep.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
|
||||
return hubPusher.Push(rep)
|
||||
}
|
||||
reportTrigger = report.NewTrigger(fireReport, logger)
|
||||
go reportTrigger.Run(ctx)
|
||||
}
|
||||
|
||||
// --- Initialize API router ---
|
||||
apiRouter := api.NewRouter(cfg, *configPath, sett, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, updater, notifier, logger)
|
||||
if hubPusher != nil {
|
||||
// Out-of-band, non-blocking hub report push (e.g. after a geo settings change) so
|
||||
if reportTrigger != nil {
|
||||
// Out-of-cycle, non-blocking hub report push (geo changes + app deploy/remove) so
|
||||
// the hub reflects the new state immediately instead of after the next ~15-min cycle.
|
||||
apiRouter.SetReportPushTrigger(func() {
|
||||
go func() {
|
||||
rep := report.BuildReport(cfg, *configPath, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths(), sett.GetGeoRestriction(), logger)
|
||||
rep.Claimed = sett.GetClaimed() // v0.122.0 (F-4): set-only claim flag for the hub
|
||||
if err := hubPusher.Push(rep); err != nil {
|
||||
logger.Printf("[WARN] [report] Out-of-band geo report push failed: %v", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
apiRouter.SetReportPushTrigger(reportTrigger.Fire)
|
||||
}
|
||||
if assetsSyncer != nil {
|
||||
apiRouter.SetAssetsSyncer(assetsSyncer)
|
||||
@@ -883,6 +893,12 @@ func main() {
|
||||
webServer.SetEscrowStale(escrowConfirmer.StaleBlob)
|
||||
}
|
||||
webServer.SetIntegrationManager(integrationMgr)
|
||||
if reportTrigger != nil {
|
||||
// Out-of-cycle report push after hub-relevant user actions (escrow claim, settings
|
||||
// save, offsite config/toggle, customer claim) — same debounced trigger as the api
|
||||
// router's; nil (hub reporting off) leaves the seam a strict no-op.
|
||||
webServer.SetReportTrigger(reportTrigger.Fire)
|
||||
}
|
||||
if quiesceLoop != nil {
|
||||
webServer.SetBackupTrigger(quiesceLoop) // "Mentés most" → app-consistent backup via the quiesce loop
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
// Group D (Scenario D, v0.139.0): with the seam unset (hub reporting disabled → main.go
|
||||
// never calls SetReportPushTrigger), reportPushNow is a strict nil-safe no-op — the
|
||||
// deploy/remove/geo handlers calling it must never panic.
|
||||
func TestReportPushNow_NilSeamIsNoOp(t *testing.T) {
|
||||
r := &Router{}
|
||||
r.reportPushNow() // must not panic with triggerReportPush == nil
|
||||
}
|
||||
@@ -476,6 +476,9 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
go r.OnGeoRelevantChange()
|
||||
}
|
||||
|
||||
// v0.139.0: the hub sees the deploy in seconds (debounced trigger, not per-request)
|
||||
r.reportPushNow()
|
||||
|
||||
// Re-apply integrations that target this newly deployed stack
|
||||
if r.integrationMgr != nil {
|
||||
go r.integrationMgr.OnStackStart(context.Background(), name)
|
||||
@@ -774,6 +777,9 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
if r.OnGeoRelevantChange != nil {
|
||||
go r.OnGeoRelevantChange()
|
||||
}
|
||||
|
||||
// v0.139.0: the hub sees the removal in seconds (debounced trigger, not per-request)
|
||||
r.reportPushNow()
|
||||
}
|
||||
|
||||
func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {
|
||||
@@ -805,6 +811,9 @@ func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " deleted"})
|
||||
|
||||
// v0.139.0: the hub sees the delete in seconds (debounced trigger, not per-request)
|
||||
r.reportPushNow()
|
||||
}
|
||||
|
||||
func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// trigger.go — the generic, debounced out-of-cycle report trigger (v0.139.0, Direction 1).
|
||||
//
|
||||
// User actions with hub-side effects (escrow claim, settings save, offsite toggle, app
|
||||
// deploy/remove, customer claim) fire this trigger so the hub sees the new state in seconds
|
||||
// instead of after the next ~15-min hub-report cycle. The scheduled cycle stays the
|
||||
// reconciliation backbone — the trigger is best-effort on top, and every failure degrades
|
||||
// to the cycle (no retry loop of its own; the Pusher owns retries).
|
||||
//
|
||||
// Semantics: COALESCE AND EVENTUALLY FIRE (trailing edge). A burst of fires collapses into
|
||||
// at most 1 + ceil(burst/minInterval) pushes, and the LAST push always happens after the
|
||||
// last fire — never a refused/lost update (the internal/sync refuse-debounce is the wrong
|
||||
// shape here on purpose). Shape copied from felhom.eu/hub/internal/wgsync/reconciler.go
|
||||
// (buffered-1 trigger channel + worker loop).
|
||||
package report
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
|
||||
)
|
||||
|
||||
const (
|
||||
// triggerQuietWindow is how long the worker waits after the first fire before pushing,
|
||||
// so a burst of related saves (multi-field form, wizard steps) coalesces into one report.
|
||||
triggerQuietWindow = 2 * time.Second
|
||||
// triggerMinInterval is the minimum spacing between out-of-cycle pushes — the hard
|
||||
// ceiling against report storms (pushes <= 1 + ceil(burst/minInterval) per burst).
|
||||
triggerMinInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
// Trigger coalesces "push a report now" requests into paced calls of an opaque fire func.
|
||||
// Fire() is safe from any goroutine and never blocks (HTTP handlers call it); the single
|
||||
// worker (Run) does the waiting and the pushing. Report building stays with the caller —
|
||||
// the fire closure in main.go does BuildReport + Pusher.Push.
|
||||
type Trigger struct {
|
||||
fire func() error
|
||||
quiet time.Duration
|
||||
minInterval time.Duration
|
||||
signal chan struct{}
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// NewTrigger builds a Trigger with the production pacing. fire is the full report
|
||||
// build+push closure; it must be safe to call repeatedly from one goroutine.
|
||||
func NewTrigger(fire func() error, logger *log.Logger) *Trigger {
|
||||
return newTriggerWithPacing(fire, triggerQuietWindow, triggerMinInterval, logger)
|
||||
}
|
||||
|
||||
// newTriggerWithPacing is the test constructor — tests shrink the windows to keep runs fast.
|
||||
func newTriggerWithPacing(fire func() error, quiet, minInterval time.Duration, logger *log.Logger) *Trigger {
|
||||
return &Trigger{
|
||||
fire: fire,
|
||||
quiet: quiet,
|
||||
minInterval: minInterval,
|
||||
signal: make(chan struct{}, 1),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Fire requests an out-of-cycle report push. Non-blocking: a pending signal already covers
|
||||
// this request (the eventual push carries the FULL current state either way).
|
||||
func (t *Trigger) Fire() {
|
||||
select {
|
||||
case t.signal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run is the worker loop; main.go starts it under the process context. Per signal:
|
||||
// wait the quiet window (coalescing the burst), drain, enforce minInterval spacing since
|
||||
// the last push, drain again, then fire ONCE. A fire error is logged and the loop
|
||||
// continues — the scheduled cycle reconciles. Exits on context cancel (a pending fire may
|
||||
// be dropped then; the cycle covers it).
|
||||
func (t *Trigger) Run(ctx context.Context) {
|
||||
var lastPush time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.signal:
|
||||
}
|
||||
// Quiet window: let the burst finish, then collapse it into one push.
|
||||
if !t.sleep(ctx, t.quiet) {
|
||||
return
|
||||
}
|
||||
t.drain()
|
||||
// Pacing: never push more often than minInterval. time.Since(zero) is huge,
|
||||
// so the first push after startup is never delayed.
|
||||
if remaining := t.minInterval - time.Since(lastPush); remaining > 0 {
|
||||
if !t.sleep(ctx, remaining) {
|
||||
return
|
||||
}
|
||||
t.drain()
|
||||
}
|
||||
if err := t.fire(); err != nil {
|
||||
logx.Warnf(t.logger, "[report] out-of-cycle push failed: %v — next cycle reconciles", err)
|
||||
}
|
||||
lastPush = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// drain clears a pending signal that arrived during a wait — those requests are covered
|
||||
// by the push about to happen (full state, not deltas).
|
||||
func (t *Trigger) drain() {
|
||||
select {
|
||||
case <-t.signal:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// sleep waits d or until ctx is cancelled; false = cancelled (caller returns promptly,
|
||||
// never blocking shutdown on a pending wait).
|
||||
func (t *Trigger) sleep(ctx context.Context, d time.Duration) bool {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fireRecorder is the fake fire func: counts calls, records their times, and returns a
|
||||
// settable error — the trigger tests never touch HTTP (felhom-testing doctrine).
|
||||
type fireRecorder struct {
|
||||
mu sync.Mutex
|
||||
calls []time.Time
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fireRecorder) fire() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, time.Now())
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *fireRecorder) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.calls)
|
||||
}
|
||||
|
||||
func (f *fireRecorder) lastCall() time.Time {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.calls) == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return f.calls[len(f.calls)-1]
|
||||
}
|
||||
|
||||
func (f *fireRecorder) setErr(err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.err = err
|
||||
}
|
||||
|
||||
// startTrigger runs tr.Run under a test-scoped context and returns a done channel that
|
||||
// closes when the worker exits.
|
||||
func startTrigger(t *testing.T, tr *Trigger) chan struct{} {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
tr.Run(ctx)
|
||||
close(done)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
// waitForCount polls until the recorder reaches want calls or the deadline passes.
|
||||
func waitForCount(t *testing.T, rec *fireRecorder, want int, within time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(within)
|
||||
for time.Now().Before(deadline) {
|
||||
if rec.count() >= want {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("fire count = %d, want >= %d within %s", rec.count(), want, within)
|
||||
}
|
||||
|
||||
// Group A (Scenario A): a single fire produces exactly ONE push, within quiet + ε.
|
||||
func TestTrigger_SingleFireExactlyOnePush(t *testing.T) {
|
||||
rec := &fireRecorder{}
|
||||
tr := newTriggerWithPacing(rec.fire, 30*time.Millisecond, 400*time.Millisecond, nil)
|
||||
startTrigger(t, tr)
|
||||
|
||||
tr.Fire()
|
||||
waitForCount(t, rec, 1, 2*time.Second)
|
||||
|
||||
// Exactly one: no second push may appear (a duplicate would double-report for nothing).
|
||||
time.Sleep(600 * time.Millisecond) // > quiet + minInterval
|
||||
if got := rec.count(); got != 1 {
|
||||
t.Fatalf("single Fire produced %d pushes, want exactly 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Group A: Fire() is non-blocking even when nothing consumes the signal (the worker is
|
||||
// mid-sleep in production) — the buffered-1 channel's default branch returns immediately.
|
||||
func TestTrigger_FireNonBlocking(t *testing.T) {
|
||||
rec := &fireRecorder{}
|
||||
tr := newTriggerWithPacing(rec.fire, time.Hour, time.Hour, nil)
|
||||
// Deliberately NOT started: the signal buffer fills after one Fire, so every
|
||||
// subsequent call exercises the "worker not listening" path.
|
||||
for i := 0; i < 100; i++ {
|
||||
start := time.Now()
|
||||
tr.Fire()
|
||||
if elapsed := time.Since(start); elapsed > time.Millisecond {
|
||||
t.Fatalf("Fire() call %d took %s, want < 1ms (must never block a handler)", i, elapsed)
|
||||
}
|
||||
}
|
||||
if rec.count() != 0 {
|
||||
t.Fatalf("Fire without a running worker pushed %d times, want 0", rec.count())
|
||||
}
|
||||
}
|
||||
|
||||
// Group B (Scenario B): a 10-fire burst coalesces under the hard ceiling
|
||||
// (1 + ceil(burst/minInterval) = 2 for this pacing) and the LAST push happens after the
|
||||
// last fire (trailing edge — the final action's state reaches the hub, never lost).
|
||||
// RED-PROOF (recorded in REPORT.md): deliver every signal straight to fire (naive
|
||||
// `for { <-signal; fire() }` loop) → this test fails with ~10 calls.
|
||||
func TestTrigger_BurstCoalescesTrailingEdge(t *testing.T) {
|
||||
rec := &fireRecorder{}
|
||||
// quiet 30ms, minInterval 400ms; burst spans ~45ms → ceiling = 1 + ceil(45/400) = 2.
|
||||
tr := newTriggerWithPacing(rec.fire, 30*time.Millisecond, 400*time.Millisecond, nil)
|
||||
startTrigger(t, tr)
|
||||
|
||||
var lastFire time.Time
|
||||
for i := 0; i < 10; i++ {
|
||||
tr.Fire()
|
||||
lastFire = time.Now()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Let the burst fully settle: quiet + minInterval + generous margin.
|
||||
waitForCount(t, rec, 1, 2*time.Second)
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
|
||||
got := rec.count()
|
||||
if got < 1 || got > 2 {
|
||||
t.Fatalf("10-fire burst produced %d pushes, want 1..2 (ceiling = 1 + ceil(burst/minInterval))", got)
|
||||
}
|
||||
if last := rec.lastCall(); !last.After(lastFire) {
|
||||
t.Fatalf("last push at %s is not after the last fire at %s — trailing edge lost (10th action's state would wait for the 15-min cycle)", last.Format(time.RFC3339Nano), lastFire.Format(time.RFC3339Nano))
|
||||
}
|
||||
}
|
||||
|
||||
// Group C (Scenario C): a fire error is isolated — the worker keeps running and a
|
||||
// SUBSEQUENT fire still pushes (the failed state is reconciled by the next cycle, the
|
||||
// trigger itself adds no retry).
|
||||
// RED-PROOF (recorded in REPORT.md): make Run return on fire error → the second
|
||||
// waitForCount here fails (no push ever comes).
|
||||
func TestTrigger_FireErrorWorkerContinues(t *testing.T) {
|
||||
rec := &fireRecorder{err: errors.New("hub unreachable")}
|
||||
tr := newTriggerWithPacing(rec.fire, 20*time.Millisecond, 50*time.Millisecond, nil)
|
||||
startTrigger(t, tr)
|
||||
|
||||
tr.Fire()
|
||||
waitForCount(t, rec, 1, 2*time.Second) // the failing push was attempted
|
||||
|
||||
rec.setErr(nil) // "hub back up"
|
||||
tr.Fire()
|
||||
waitForCount(t, rec, 2, 2*time.Second) // the worker survived the error and pushed again
|
||||
}
|
||||
|
||||
// §8: the worker exits promptly on context cancel, even while mid-wait (a pending fire
|
||||
// may be dropped — the scheduled cycle covers it; shutdown never hangs on the trigger).
|
||||
func TestTrigger_CancelDuringWaitExitsPromptly(t *testing.T) {
|
||||
rec := &fireRecorder{}
|
||||
tr := newTriggerWithPacing(rec.fire, time.Hour, time.Hour, nil) // waits would block ~forever
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
tr.Run(ctx)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
tr.Fire() // worker enters the hour-long quiet sleep
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Run did not exit within 1s of context cancel")
|
||||
}
|
||||
if rec.count() != 0 {
|
||||
t.Fatalf("cancelled mid-quiet-window but fired %d times, want 0", rec.count())
|
||||
}
|
||||
}
|
||||
@@ -349,6 +349,9 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.settings.SetClaimed(); err != nil {
|
||||
s.logger.Printf("[WARN] [web] claim: marking claimed failed: %v", err)
|
||||
}
|
||||
// v0.139.0: report out-of-cycle so the hub's Claimed flag (and claim-code consumption)
|
||||
// flips in seconds — the operator sees the customer claim land immediately.
|
||||
s.reportTriggerNow()
|
||||
s.claimClearFailures(ip)
|
||||
s.invalidateAllSessions() // reset: kill old sessions; first-claim: none exist
|
||||
|
||||
|
||||
@@ -284,6 +284,10 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}); err != nil {
|
||||
s.logger.Printf("[WARN] [web] escrow claim: ceremony timestamp not persisted: %v", err)
|
||||
}
|
||||
// v0.139.0: the blob is already uploaded at claim time — an immediate report lets the
|
||||
// hub's ACK hash-match flip pending→escrowed in seconds (EscrowAutoConfirmer, unchanged)
|
||||
// instead of after the next ~15-min cycle. Non-blocking; a failed push degrades to the cycle.
|
||||
s.reportTriggerNow()
|
||||
escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code}, "")
|
||||
code = "" // drop the reference promptly (GC caveat: best-effort)
|
||||
_ = code
|
||||
|
||||
@@ -1491,6 +1491,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] [web] Notification preferences updated: email=%s, events=%v", email, enabledEvents)
|
||||
s.reportTriggerNow() // v0.139.0: hub reflects the saved prefs in seconds, not next cycle
|
||||
|
||||
// Sync preferences to hub
|
||||
data := s.notificationsPageData()
|
||||
@@ -1521,6 +1522,9 @@ func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request)
|
||||
s.executeTemplate(w, r, "settings_notifications", data)
|
||||
return
|
||||
}
|
||||
// v0.139.0: the toggle is committed (the shim reconcile below is runtime state, not the
|
||||
// setting) — report out-of-cycle so the hub sees it in seconds.
|
||||
s.reportTriggerNow()
|
||||
// Reconcile the shim's running state with the new toggle.
|
||||
if s.mailShim != nil {
|
||||
if err := s.mailShim.Apply(enabled); err != nil {
|
||||
|
||||
@@ -112,6 +112,7 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] off-box target configured: %s@%s:%s (port %d, enabled=%v, escrow=%s)", user, host, repoPath, port, tgt.Enabled, tgt.EscrowState)
|
||||
s.reportTriggerNow() // v0.139.0: offsite enable/disable reaches the hub in seconds
|
||||
offboxRedirect(w, r, "A távoli mentési cél elmentve."+stageErr, stageErr != "")
|
||||
}
|
||||
|
||||
@@ -193,6 +194,7 @@ func (s *Server) offboxToggleHandler(w http.ResponseWriter, r *http.Request) {
|
||||
offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
|
||||
return
|
||||
}
|
||||
s.reportTriggerNow() // v0.139.0: per-app offsite toggle reaches the hub in seconds
|
||||
offboxRedirect(w, r, "A távoli mentés beállítása frissítve.", false)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// Group D (Scenario D): with the seam unset (hub reporting off / nil trigger), the helper
|
||||
// is a strict nil-safe no-op — a handler calling it must never panic.
|
||||
func TestReportTriggerNow_NilSeamIsNoOp(t *testing.T) {
|
||||
s := &Server{}
|
||||
s.reportTriggerNow() // must not panic with reportTriggerFn == nil
|
||||
}
|
||||
|
||||
// The wired seam fires on a SUCCESSFUL local commit and does NOT fire on the error path
|
||||
// (§Part 3: after the successful local commit — never before, never on error). Exercised
|
||||
// through a real handler (offbox per-app toggle) so deleting the call site fails this test.
|
||||
func TestReportTriggerNow_FiresAfterSuccessfulCommitOnly(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fired := 0
|
||||
s := &Server{settings: sett, logger: lg}
|
||||
s.SetReportTrigger(func() { fired++ })
|
||||
|
||||
// Error path: missing app param → refused, trigger must NOT fire.
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/backup/offbox/toggle", strings.NewReader(url.Values{"enabled": {"on"}}.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
s.offboxToggleHandler(w, req)
|
||||
if fired != 0 {
|
||||
t.Fatalf("trigger fired %d time(s) on the refused save, want 0 (never on the error path)", fired)
|
||||
}
|
||||
|
||||
// Success path: the setting commits → the trigger fires exactly once.
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("POST", "/backup/offbox/toggle", strings.NewReader(url.Values{"app": {"immich"}, "enabled": {"on"}}.Encode()))
|
||||
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
s.offboxToggleHandler(w2, req2)
|
||||
if !sett.IsAppOffbox("immich") {
|
||||
t.Fatal("toggle did not commit — precondition for the trigger assertion")
|
||||
}
|
||||
if fired != 1 {
|
||||
t.Fatalf("trigger fired %d time(s) after the successful save, want exactly 1", fired)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,13 @@ type Server struct {
|
||||
// Hub push status callback — set via SetHubPushStatus for monitoring page
|
||||
hubPushStatusFn func() HubPushStatusData
|
||||
|
||||
// Out-of-cycle hub report trigger (v0.139.0, Direction 1) — set via SetReportTrigger to
|
||||
// report.Trigger.Fire. Fired via reportTriggerNow() AFTER a successful hub-relevant local
|
||||
// commit (escrow claim, notification/app-email save, offsite config/toggle, customer
|
||||
// claim) so the hub reflects the new state in seconds instead of the next ~15-min cycle.
|
||||
// nil (hub reporting off / tests) = strict no-op.
|
||||
reportTriggerFn func()
|
||||
|
||||
// Fork-4 hygiene seam: wipes the agent-staged offsite repo password when EscrowState flips to
|
||||
// escrowed (DELETE /escrow/stage-secret). nil → the default agentClient()-backed impl; tests inject.
|
||||
wipeStagedEscrowFn func(ctx context.Context) error
|
||||
@@ -235,6 +242,22 @@ func (s *Server) SetAssetsSyncer(as *assets.Syncer) {
|
||||
s.assetsSyncer = as
|
||||
}
|
||||
|
||||
// SetReportTrigger wires the out-of-cycle hub report trigger (report.Trigger.Fire). The
|
||||
// provided func MUST be non-blocking — it is called from request handlers (the trigger's
|
||||
// worker does the waiting/pushing). Init-time only, like every Set* here.
|
||||
func (s *Server) SetReportTrigger(fn func()) {
|
||||
s.reportTriggerFn = fn
|
||||
}
|
||||
|
||||
// reportTriggerNow fires the out-of-cycle report trigger if wired (mirrors
|
||||
// api.Router.reportPushNow). Call AFTER a successful hub-relevant local commit — never
|
||||
// before it, never on an error path. Nil-safe no-op when hub reporting is off.
|
||||
func (s *Server) reportTriggerNow() {
|
||||
if s.reportTriggerFn != nil {
|
||||
s.reportTriggerFn()
|
||||
}
|
||||
}
|
||||
|
||||
// SetIntegrationManager sets the app-to-app integration manager.
|
||||
func (s *Server) SetIntegrationManager(mgr *integrations.Manager) {
|
||||
s.integrationMgr.Store(mgr)
|
||||
|
||||
Reference in New Issue
Block a user