Skip to content

Commit 3ff9a4e

Browse files
sarg3ntclaude
andauthored
fix(bx): invalidate monitor snapshot on box config changes (#146)
* fix(bx): invalidate monitor snapshot on box config changes The Bx status monitor polls every 30 seconds and caches each box's full status (level, latency, last_checked, ConsoleEnabled, etc.) in an in-memory snapshot. /bx renders from that snapshot. Side effect: toggling a per-box setting (Remote console, Enabled, …) in /settings/boxes did not show on /bx until the next 30s tick. User report described this as "I had it enabled but it was disabled when we relaunched" (no shell icon visible) plus "I went back to the box and confirmed the console feature was checked, it was, but I hit save again anyway and then back to the Bx dashboard and after a couple more refreshes, it showed up." Wiring: - Add EventTypeBoxConfigChanged ("box.config_changed") + Hub.PublishBoxConfigChanged helper. - HAProxyBoxUpdatePost and HAProxyBoxTogglePost publish the event immediately after UpdateBox / SetBoxEnabled returns. - Bx Gear.Start subscribes to "box.config_changed" via deps.EventHub and calls monitor.PokeBox(serverID) for the affected box. - New statusMonitor.PokeBox(ctx, boxID) fetches the row, runs a single probe, and writes the result via m.set — which also broadcasts to SSE subscribers, so any open /bx tab updates in place without a refresh. - PokeBox handles the "box was deleted" case by dropping the stale snapshot entry instead of leaving last-known-good values for a now-missing box. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * address Copilot review findings on PR #146 - Add statusMonitor.setAndBroadcast that always broadcasts to SSE subscribers; PokeBox now uses it so a config edit that flips only a non-level field (ConsoleEnabled, Enabled, Name, …) still pushes to open /bx tabs. 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. - PokeBox now distinguishes transient DB errors from "not found": on err != nil we log and keep the last-known-good snapshot; only box == nil drops the cached entry. Avoids poisoning the UI when SQLite hiccups. - Gear.Start stores the unsubscribe func returned by Subscribe and Gear.Stop now calls it before shutting the monitor down. Closes the events-adapter forwarder goroutine cleanly and stops new PokeBox calls from reaching a winding-down monitor. - Subscribe call now references events.EventTypeBoxConfigChanged (cast to string for the gear.EventPublisher API) instead of a raw "box.config_changed" literal — keeps publisher and subscriber on the same constant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ac34c8f commit 3ff9a4e

4 files changed

Lines changed: 117 additions & 4 deletions

File tree

gearbox/internal/framework/events/hub.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ const (
3636
// EventTypeMetadataRefreshed indicates metadata was refreshed.
3737
EventTypeMetadataRefreshed EventType = "metadata.refreshed"
3838

39+
// EventTypeBoxConfigChanged indicates a box's persisted settings have
40+
// changed (enabled flag, console toggle, agent URL, etc.). Consumers
41+
// that cache box state (e.g. the Bx status monitor) should invalidate
42+
// their cache for ServerID and re-probe so user-visible toggles
43+
// propagate without waiting on the next periodic poll.
44+
EventTypeBoxConfigChanged EventType = "box.config_changed"
45+
3946
// EventTypeStatsUpdated indicates HAProxy stats have been updated.
4047
EventTypeStatsUpdated EventType = "stats.updated"
4148

@@ -343,6 +350,17 @@ func (h *Hub) PublishMetadataRefreshed(serverID string) {
343350
})
344351
}
345352

353+
// PublishBoxConfigChanged publishes a box-config-changed event. Caches
354+
// keyed on the box's settings (Bx status monitor, capabilities cache,
355+
// etc.) should treat this as a signal to invalidate and re-probe.
356+
func (h *Hub) PublishBoxConfigChanged(serverID string) {
357+
h.Publish(Event{
358+
Type: EventTypeBoxConfigChanged,
359+
ServerID: serverID,
360+
Timestamp: time.Now(),
361+
})
362+
}
363+
346364
// SubscriberCount returns the number of active subscribers.
347365
func (h *Hub) SubscriberCount() int {
348366
h.mu.RLock()

gearbox/internal/framework/handler/haproxy_config.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,15 @@ func (h *Handler) HAProxyBoxUpdatePost(w http.ResponseWriter, r *http.Request) {
258258
// TTL expires.
259259
h.invalidateBoxCapabilities(server.BoxID)
260260

261+
// Notify caches keyed on per-box settings (Bx status monitor's
262+
// ConsoleEnabled mirror, etc.) so toggles propagate immediately
263+
// instead of waiting on the next 30s poll. Without this, a user
264+
// flipping console_enabled on/off in this form would not see the
265+
// shell icon appear/disappear on /bx until the next periodic poll.
266+
if h.eventHub != nil {
267+
h.eventHub.PublishBoxConfigChanged(server.BoxID)
268+
}
269+
261270
// Log audit
262271
h.logAudit(r, user.ID, "haproxy_box_update", fmt.Sprintf("Updated HAProxy box: %s (%s)", server.Name, server.BoxID))
263272

@@ -365,6 +374,13 @@ func (h *Handler) HAProxyBoxTogglePost(w http.ResponseWriter, r *http.Request) {
365374
return
366375
}
367376

377+
// Notify caches keyed on per-box settings — same reasoning as the
378+
// update-form path. Toggling enabled on/off should reflect on /bx
379+
// immediately, not after the next 30s monitor tick.
380+
if h.eventHub != nil {
381+
h.eventHub.PublishBoxConfigChanged(server.BoxID)
382+
}
383+
368384
// Log audit
369385
action := "disabled"
370386
if newEnabled {

gearbox/internal/gears/bx/gear.go

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/a-h/templ"
1717
"github.com/go-chi/chi/v5"
1818

19+
"github.com/sarg3nt/gearbox/internal/framework/events"
1920
"github.com/sarg3nt/gearbox/internal/framework/gear"
2021
)
2122

@@ -26,8 +27,9 @@ func init() {
2627
// Gear implements the Bx fleet-view gear.
2728
type Gear struct {
2829
gear.BaseGear
29-
handlers *Handlers
30-
monitor *statusMonitor
30+
handlers *Handlers
31+
monitor *statusMonitor
32+
configChangeUnsub func() // returned by EventHub.Subscribe; called in Stop
3133
}
3234

3335
// Info returns gear metadata.
@@ -54,16 +56,38 @@ func (g *Gear) Initialize(ctx context.Context, deps gear.Dependencies) error {
5456
return nil
5557
}
5658

57-
// Start launches the per-box status poller.
59+
// Start launches the per-box status poller and subscribes to
60+
// box-config-changed events so toggle/edit operations refresh the
61+
// monitor's snapshot for the affected box immediately, instead of
62+
// waiting on the next 30s poll. Without this subscription, /bx would
63+
// keep rendering stale ConsoleEnabled / Enabled values for up to 30s
64+
// after the user clicked save.
5865
func (g *Gear) Start(ctx context.Context) error {
5966
if g.monitor != nil {
6067
g.monitor.Start(ctx)
6168
}
69+
if g.monitor != nil {
70+
if hub := g.GetEventHub(); hub != nil {
71+
g.configChangeUnsub = hub.Subscribe(string(events.EventTypeBoxConfigChanged), func(e gear.Event) {
72+
if e.ServerID == "" {
73+
return
74+
}
75+
g.monitor.PokeBox(ctx, e.ServerID)
76+
})
77+
}
78+
}
6279
return nil
6380
}
6481

65-
// Stop signals the background poller to wind down.
82+
// Stop signals the background poller to wind down and releases the
83+
// event subscription so the forwarder goroutine inside the events
84+
// adapter exits cleanly and no further PokeBox calls reach a
85+
// shutting-down monitor.
6686
func (g *Gear) Stop(ctx context.Context) error {
87+
if g.configChangeUnsub != nil {
88+
g.configChangeUnsub()
89+
g.configChangeUnsub = nil
90+
}
6791
if g.monitor != nil {
6892
g.monitor.Stop()
6993
}

gearbox/internal/gears/bx/status.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,48 @@ func (m *statusMonitor) run(ctx context.Context) {
116116
}
117117
}
118118

119+
// PokeBox triggers an immediate out-of-band probe of a single box,
120+
// updates the snapshot, and broadcasts to SSE subscribers. Used by the
121+
// box-config-changed event subscriber so settings edits (enable toggle,
122+
// console-enabled toggle, agent URL change, …) reflect on /bx without
123+
// waiting on the next 30s tick. Safe to call concurrently with the
124+
// regular poll loop — set/setAndBroadcast are mutex-guarded.
125+
func (m *statusMonitor) PokeBox(ctx context.Context, boxID string) {
126+
if m.db == nil {
127+
return
128+
}
129+
box, err := m.db.GetBoxByBoxID(boxID)
130+
if err != nil {
131+
// Transient DB error — don't poison the snapshot by dropping
132+
// last-known-good values. Log and bail; the next 30s poll
133+
// will reconcile.
134+
if m.deps.Logger != nil {
135+
m.deps.Logger.Warn("bx PokeBox: db lookup failed; preserving last-known status", "box_id", boxID, "error", err)
136+
}
137+
return
138+
}
139+
if box == nil {
140+
// Box was deleted — drop the stale snapshot so /bx doesn't
141+
// keep rendering last-known-good values for a now-missing box.
142+
m.mu.Lock()
143+
delete(m.statuses, boxID)
144+
m.mu.Unlock()
145+
return
146+
}
147+
apiKey := ""
148+
if enc := m.encryptor(); enc != nil && len(box.APIKeyEncrypted) > 0 {
149+
if dec, err := enc.DecryptString(box.APIKeyEncrypted); err == nil {
150+
apiKey = dec
151+
}
152+
}
153+
status := m.probe(ctx, box, apiKey)
154+
// Force-broadcast: a config edit may have flipped only a non-level
155+
// field (e.g. ConsoleEnabled). The default set() suppresses chatter
156+
// on equal-level updates, which is exactly the case we need to NOT
157+
// suppress here.
158+
m.setAndBroadcast(status)
159+
}
160+
119161
// pollAll fans out a reachability check per configured box. The check is
120162
// the unauthenticated `/health` endpoint of each agent — same probe as the
121163
// HAProxy backend health check, with the same 5s budget.
@@ -241,6 +283,19 @@ func (m *statusMonitor) set(s BoxStatus) {
241283
}
242284
}
243285

286+
// setAndBroadcast stores a status and ALWAYS broadcasts to subscribers,
287+
// even when level/reachable haven't changed. Used by PokeBox so a flip
288+
// of a non-level field (ConsoleEnabled, Enabled, Name, …) reaches open
289+
// /bx tabs immediately — the chatter-suppression in set() is desirable
290+
// for the steady-state poll loop but defeats the purpose of an
291+
// out-of-band config-change refresh.
292+
func (m *statusMonitor) setAndBroadcast(s BoxStatus) {
293+
m.mu.Lock()
294+
m.statuses[s.BoxID] = s
295+
m.mu.Unlock()
296+
m.broadcast(s)
297+
}
298+
244299
// Subscribe returns a channel of status events and an unsubscribe func.
245300
// Events are best-effort: a slow consumer that doesn't drain its channel
246301
// will simply miss events (we never block the publisher).

0 commit comments

Comments
 (0)