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:
@@ -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