Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions gearbox/internal/framework/events/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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()
Expand Down
16 changes: 16 additions & 0 deletions gearbox/internal/framework/handler/haproxy_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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 {
Expand Down
32 changes: 28 additions & 4 deletions gearbox/internal/gears/bx/gear.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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.
Expand All @@ -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 == "" {
Comment thread
sarg3nt marked this conversation as resolved.
return
}
g.monitor.PokeBox(ctx, e.ServerID)
})
}
Comment thread
sarg3nt marked this conversation as resolved.
}
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()
}
Expand Down
55 changes: 55 additions & 0 deletions gearbox/internal/gears/bx/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
sarg3nt marked this conversation as resolved.
}
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.
Expand Down Expand Up @@ -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).
Expand Down
Loading