Skip to content

Commit e45b10d

Browse files
sarg3ntclaude
andcommitted
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>
1 parent ac34c8f commit e45b10d

4 files changed

Lines changed: 80 additions & 1 deletion

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: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,26 @@ func (g *Gear) Initialize(ctx context.Context, deps gear.Dependencies) error {
5454
return nil
5555
}
5656

57-
// Start launches the per-box status poller.
57+
// Start launches the per-box status poller and subscribes to
58+
// box-config-changed events so toggle/edit operations refresh the
59+
// monitor's snapshot for the affected box immediately, instead of
60+
// waiting on the next 30s poll. Without this subscription, /bx would
61+
// keep rendering stale ConsoleEnabled / Enabled values for up to 30s
62+
// after the user clicked save.
5863
func (g *Gear) Start(ctx context.Context) error {
5964
if g.monitor != nil {
6065
g.monitor.Start(ctx)
6166
}
67+
if g.monitor != nil {
68+
if hub := g.GetEventHub(); hub != nil {
69+
hub.Subscribe("box.config_changed", func(e gear.Event) {
70+
if e.ServerID == "" {
71+
return
72+
}
73+
g.monitor.PokeBox(ctx, e.ServerID)
74+
})
75+
}
76+
}
6277
return nil
6378
}
6479

gearbox/internal/gears/bx/status.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,36 @@ 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 — m.set is 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 || box == nil {
131+
// Box was deleted, or the DB call failed — drop any stale
132+
// snapshot for that ID so /bx renders default-state rather
133+
// than the last-known-good values for a now-missing box.
134+
m.mu.Lock()
135+
delete(m.statuses, boxID)
136+
m.mu.Unlock()
137+
return
138+
}
139+
apiKey := ""
140+
if enc := m.encryptor(); enc != nil && len(box.APIKeyEncrypted) > 0 {
141+
if dec, err := enc.DecryptString(box.APIKeyEncrypted); err == nil {
142+
apiKey = dec
143+
}
144+
}
145+
status := m.probe(ctx, box, apiKey)
146+
m.set(status)
147+
}
148+
119149
// pollAll fans out a reachability check per configured box. The check is
120150
// the unauthenticated `/health` endpoint of each agent — same probe as the
121151
// HAProxy backend health check, with the same 5s budget.

0 commit comments

Comments
 (0)