diff --git a/gearbox/internal/framework/events/hub.go b/gearbox/internal/framework/events/hub.go index 8383257..d5e2cd2 100644 --- a/gearbox/internal/framework/events/hub.go +++ b/gearbox/internal/framework/events/hub.go @@ -36,6 +36,13 @@ const ( // EventTypeMetadataRefreshed indicates metadata was refreshed. EventTypeMetadataRefreshed EventType = "metadata.refreshed" + // EventTypeBoxConfigChanged indicates a box's persisted settings have + // changed (enabled flag, console toggle, agent URL, etc.). Consumers + // that cache box state (e.g. the Bx status monitor) should invalidate + // their cache for ServerID and re-probe so user-visible toggles + // propagate without waiting on the next periodic poll. + EventTypeBoxConfigChanged EventType = "box.config_changed" + // EventTypeStatsUpdated indicates HAProxy stats have been updated. EventTypeStatsUpdated EventType = "stats.updated" @@ -343,6 +350,17 @@ func (h *Hub) PublishMetadataRefreshed(serverID string) { }) } +// PublishBoxConfigChanged publishes a box-config-changed event. Caches +// keyed on the box's settings (Bx status monitor, capabilities cache, +// etc.) should treat this as a signal to invalidate and re-probe. +func (h *Hub) PublishBoxConfigChanged(serverID string) { + h.Publish(Event{ + Type: EventTypeBoxConfigChanged, + ServerID: serverID, + Timestamp: time.Now(), + }) +} + // SubscriberCount returns the number of active subscribers. func (h *Hub) SubscriberCount() int { h.mu.RLock() diff --git a/gearbox/internal/framework/handler/haproxy_config.go b/gearbox/internal/framework/handler/haproxy_config.go index 5597e71..221d8f6 100644 --- a/gearbox/internal/framework/handler/haproxy_config.go +++ b/gearbox/internal/framework/handler/haproxy_config.go @@ -258,6 +258,15 @@ func (h *Handler) HAProxyBoxUpdatePost(w http.ResponseWriter, r *http.Request) { // TTL expires. h.invalidateBoxCapabilities(server.BoxID) + // Notify caches keyed on per-box settings (Bx status monitor's + // ConsoleEnabled mirror, etc.) so toggles propagate immediately + // instead of waiting on the next 30s poll. Without this, a user + // flipping console_enabled on/off in this form would not see the + // shell icon appear/disappear on /bx until the next periodic poll. + if h.eventHub != nil { + h.eventHub.PublishBoxConfigChanged(server.BoxID) + } + // Log audit h.logAudit(r, user.ID, "haproxy_box_update", fmt.Sprintf("Updated HAProxy box: %s (%s)", server.Name, server.BoxID)) @@ -365,6 +374,13 @@ func (h *Handler) HAProxyBoxTogglePost(w http.ResponseWriter, r *http.Request) { return } + // Notify caches keyed on per-box settings — same reasoning as the + // update-form path. Toggling enabled on/off should reflect on /bx + // immediately, not after the next 30s monitor tick. + if h.eventHub != nil { + h.eventHub.PublishBoxConfigChanged(server.BoxID) + } + // Log audit action := "disabled" if newEnabled { diff --git a/gearbox/internal/gears/bx/gear.go b/gearbox/internal/gears/bx/gear.go index 46b4f4a..b447d2c 100644 --- a/gearbox/internal/gears/bx/gear.go +++ b/gearbox/internal/gears/bx/gear.go @@ -16,6 +16,7 @@ import ( "github.com/a-h/templ" "github.com/go-chi/chi/v5" + "github.com/sarg3nt/gearbox/internal/framework/events" "github.com/sarg3nt/gearbox/internal/framework/gear" ) @@ -26,8 +27,9 @@ func init() { // Gear implements the Bx fleet-view gear. type Gear struct { gear.BaseGear - handlers *Handlers - monitor *statusMonitor + handlers *Handlers + monitor *statusMonitor + configChangeUnsub func() // returned by EventHub.Subscribe; called in Stop } // Info returns gear metadata. @@ -54,16 +56,38 @@ func (g *Gear) Initialize(ctx context.Context, deps gear.Dependencies) error { return nil } -// Start launches the per-box status poller. +// Start launches the per-box status poller and subscribes to +// box-config-changed events so toggle/edit operations refresh the +// monitor's snapshot for the affected box immediately, instead of +// waiting on the next 30s poll. Without this subscription, /bx would +// keep rendering stale ConsoleEnabled / Enabled values for up to 30s +// after the user clicked save. func (g *Gear) Start(ctx context.Context) error { if g.monitor != nil { g.monitor.Start(ctx) } + if g.monitor != nil { + if hub := g.GetEventHub(); hub != nil { + g.configChangeUnsub = hub.Subscribe(string(events.EventTypeBoxConfigChanged), func(e gear.Event) { + if e.ServerID == "" { + return + } + g.monitor.PokeBox(ctx, e.ServerID) + }) + } + } return nil } -// Stop signals the background poller to wind down. +// Stop signals the background poller to wind down and releases the +// event subscription so the forwarder goroutine inside the events +// adapter exits cleanly and no further PokeBox calls reach a +// shutting-down monitor. func (g *Gear) Stop(ctx context.Context) error { + if g.configChangeUnsub != nil { + g.configChangeUnsub() + g.configChangeUnsub = nil + } if g.monitor != nil { g.monitor.Stop() } diff --git a/gearbox/internal/gears/bx/status.go b/gearbox/internal/gears/bx/status.go index 1c9a52d..69a9782 100644 --- a/gearbox/internal/gears/bx/status.go +++ b/gearbox/internal/gears/bx/status.go @@ -116,6 +116,48 @@ func (m *statusMonitor) run(ctx context.Context) { } } +// PokeBox triggers an immediate out-of-band probe of a single box, +// updates the snapshot, and broadcasts to SSE subscribers. Used by the +// box-config-changed event subscriber so settings edits (enable toggle, +// console-enabled toggle, agent URL change, …) reflect on /bx without +// waiting on the next 30s tick. Safe to call concurrently with the +// regular poll loop — set/setAndBroadcast are mutex-guarded. +func (m *statusMonitor) PokeBox(ctx context.Context, boxID string) { + if m.db == nil { + return + } + box, err := m.db.GetBoxByBoxID(boxID) + if err != nil { + // Transient DB error — don't poison the snapshot by dropping + // last-known-good values. Log and bail; the next 30s poll + // will reconcile. + if m.deps.Logger != nil { + m.deps.Logger.Warn("bx PokeBox: db lookup failed; preserving last-known status", "box_id", boxID, "error", err) + } + return + } + if box == nil { + // Box was deleted — drop the stale snapshot so /bx doesn't + // keep rendering last-known-good values for a now-missing box. + m.mu.Lock() + delete(m.statuses, boxID) + m.mu.Unlock() + return + } + apiKey := "" + if enc := m.encryptor(); enc != nil && len(box.APIKeyEncrypted) > 0 { + if dec, err := enc.DecryptString(box.APIKeyEncrypted); err == nil { + apiKey = dec + } + } + status := m.probe(ctx, box, apiKey) + // Force-broadcast: a config edit may have flipped only a non-level + // field (e.g. ConsoleEnabled). The default set() suppresses chatter + // on equal-level updates, which is exactly the case we need to NOT + // suppress here. + m.setAndBroadcast(status) +} + // pollAll fans out a reachability check per configured box. The check is // the unauthenticated `/health` endpoint of each agent — same probe as the // HAProxy backend health check, with the same 5s budget. @@ -241,6 +283,19 @@ func (m *statusMonitor) set(s BoxStatus) { } } +// setAndBroadcast stores a status and ALWAYS broadcasts to subscribers, +// even when level/reachable haven't changed. Used by PokeBox so a flip +// of a non-level field (ConsoleEnabled, Enabled, Name, …) reaches open +// /bx tabs immediately — the chatter-suppression in set() is desirable +// for the steady-state poll loop but defeats the purpose of an +// out-of-band config-change refresh. +func (m *statusMonitor) setAndBroadcast(s BoxStatus) { + m.mu.Lock() + m.statuses[s.BoxID] = s + m.mu.Unlock() + m.broadcast(s) +} + // Subscribe returns a channel of status events and an unsubscribe func. // Events are best-effort: a slow consumer that doesn't drain its channel // will simply miss events (we never block the publisher).