From 1d1652c39a2aa3351b372c43feaa3bcbcf461f77 Mon Sep 17 00:00:00 2001 From: Dave Sargent Date: Mon, 11 May 2026 20:58:51 -0700 Subject: [PATCH 1/4] feat(bx): add Bx gear management UI and status monitoring - Implemented Bx sidebar icon in icons.go for consistent UI. - Created pages.templ for the Bx fleet view, including index page, headers, and empty state. - Developed status.go to manage box status monitoring, including polling and SSE for live updates. - Added bx-page.js for client-side behaviors, including row navigation and relative time formatting. - Enhanced box-selector.js and introduced box-switcher.js for improved box selection and navigation. --- DESIGN.md | 58 +++- gearbox/cmd/server/main.go | 1 + gearbox/internal/framework/auth/middleware.go | 36 +++ gearbox/internal/framework/gear/interface.go | 35 ++- gearbox/internal/framework/handler/handler.go | 57 +++- .../framework/services/server_adapter.go | 8 + .../framework/templates/layouts/base.templ | 207 ++++++++++++- gearbox/internal/gears/bx/README.md | 114 +++++++ gearbox/internal/gears/bx/gear.go | 109 +++++++ gearbox/internal/gears/bx/handlers.go | 170 +++++++++++ gearbox/internal/gears/bx/icons.go | 33 ++ gearbox/internal/gears/bx/pages.templ | 212 +++++++++++++ gearbox/internal/gears/bx/status.go | 281 ++++++++++++++++++ gearbox/static/js/bx/bx-page.js | 147 +++++++++ gearbox/static/js/common/box-selector.js | 45 ++- gearbox/static/js/common/box-switcher.js | 273 +++++++++++++++++ 16 files changed, 1733 insertions(+), 53 deletions(-) create mode 100644 gearbox/internal/gears/bx/README.md create mode 100644 gearbox/internal/gears/bx/gear.go create mode 100644 gearbox/internal/gears/bx/handlers.go create mode 100644 gearbox/internal/gears/bx/icons.go create mode 100644 gearbox/internal/gears/bx/pages.templ create mode 100644 gearbox/internal/gears/bx/status.go create mode 100644 gearbox/static/js/bx/bx-page.js create mode 100644 gearbox/static/js/common/box-switcher.js diff --git a/DESIGN.md b/DESIGN.md index 4c2f41d..6be47ea 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -71,17 +71,53 @@ Each gear is self-contained: it defines its own routes, handlers, templates, and Gears progress through a state machine: `disabled` → `alpha` → `beta` → `production`. Alpha and beta gears must be explicitly enabled by the user. Production gears are enabled by default. The `disabled` state excludes the gear from the build entirely. -### Dashboard Gears (7) - -| Gear | Purpose | -|--------------|----------------------------------------------| -| HAProxy | HAProxy overview, status grid, and backend/frontend/server monitoring | -| Metrics | Historical CPU, memory, disk, network charts | -| Services | Systemd service monitoring and control | -| Certificates | TLS certificate expiration tracking | -| Logs | Real-time log viewing and search | -| Traffic | Traffic analysis and GeoIP visualization | -| Alerts | Alert rules, notifications, and history | +### Gear Scopes + +Each gear declares a `Scope` controlling where its rows live in the database +and how the sidebar treats it (see [`internal/framework/gear/interface.go`](gearbox/internal/framework/gear/interface.go)): + +- **`ScopeBox`** *(default)* — one row per (box_id, gear_name) in the gears + table. The gear is shown in the sidebar only when an active box context + is set, because its UI is meaningless without one. Examples: HAProxy, + Metrics, Logs, Services, Certificates, Traffic, Alerts, OS Updates. +- **`ScopeSystem`** — a single install-wide row keyed by the + `SystemServerID` sentinel. The gear is always visible in the sidebar and + ignores box context. Example: the Home dashboard. +- **`ScopeBoxAgnostic`** — install-wide like `ScopeSystem` but + semantically the gear lists or aggregates *across* boxes rather than + ignoring them. Always visible; box-specific gears are hidden when no box + is active because this gear is the place to pick one. Example: the Bx + fleet view. + +### Multi-box UX + +A single Gearbox dashboard connects to many agents. The user picks which box +they are "in" via two affordances backed by the same `?box_id=` query +convention: + +1. **Bx fleet view** at `/bx` — a `ScopeBoxAgnostic` gear that lists every + configured box with live status dots and click-through to that box. +2. **Persistent box-switcher chip** in the chrome — opens a Cockpit-style + command palette (search + arrow-keys; `g b` shortcut) for jumping + between boxes mid-task without losing place. + +When no box is selected, box-scoped gears are hidden from the sidebar; the +user sees only `Bx`, `Home`, and `Settings`. Selecting a box hydrates the +sidebar with that box's enabled gears. + +### Dashboard Gears (8) + +| Gear | Scope | Purpose | +|--------------|--------------|-----------------------------------------------| +| Bx | box-agnostic | Fleet overview: list + status + switcher home | +| Home | system | App dashboard with launcher tiles and widgets | +| HAProxy | box | HAProxy overview, status grid, and backend/frontend/server monitoring | +| Metrics | box | Historical CPU, memory, disk, network charts | +| Services | box | Systemd service monitoring and control | +| Certificates | box | TLS certificate expiration tracking | +| Logs | box | Real-time log viewing and search | +| Traffic | box | Traffic analysis and GeoIP visualization | +| Alerts | box | Alert rules, notifications, and history | ### Agent Gears (7) diff --git a/gearbox/cmd/server/main.go b/gearbox/cmd/server/main.go index 49b6562..3d81c0b 100644 --- a/gearbox/cmd/server/main.go +++ b/gearbox/cmd/server/main.go @@ -31,6 +31,7 @@ import ( // Import gears - blank identifier triggers init() registration _ "github.com/sarg3nt/gearbox/internal/gears/alerts" + _ "github.com/sarg3nt/gearbox/internal/gears/bx" _ "github.com/sarg3nt/gearbox/internal/gears/certificates" _ "github.com/sarg3nt/gearbox/internal/gears/haproxy" _ "github.com/sarg3nt/gearbox/internal/gears/home" diff --git a/gearbox/internal/framework/auth/middleware.go b/gearbox/internal/framework/auth/middleware.go index fc63dca..dbb419d 100644 --- a/gearbox/internal/framework/auth/middleware.go +++ b/gearbox/internal/framework/auth/middleware.go @@ -14,6 +14,8 @@ const userContextKey contextKey = "user" const integrationStatusContextKey contextKey = "integrationStatus" const integrationOrderContextKey contextKey = "integrationOrder" const userPermissionsContextKey contextKey = "userPermissions" +const selectedBoxContextKey contextKey = "selectedBox" +const allBoxesContextKey contextKey = "allBoxes" // SidebarIntegration represents an integration for sidebar rendering with order information. type SidebarIntegration struct { @@ -186,3 +188,37 @@ func HasPermissionFromContext(ctx context.Context, component models.Component, p } return perms.HasPermission(component, permission) } + +// SetSelectedBox stores the active box context (the box the user is currently +// "in") on the request context. A nil value is a valid signal meaning "no box +// is currently selected" — i.e. the user is on a box-agnostic page such as +// the Bx fleet view, Home dashboard, or Settings. +func SetSelectedBox(ctx context.Context, box *models.BoxConfig) context.Context { + return context.WithValue(ctx, selectedBoxContextKey, box) +} + +// GetSelectedBoxFromContext retrieves the active box, if any. The second +// return value reports whether a box is actually selected — `nil, false` +// means box-agnostic context. +func GetSelectedBoxFromContext(ctx context.Context) (*models.BoxConfig, bool) { + box, ok := ctx.Value(selectedBoxContextKey).(*models.BoxConfig) + if !ok || box == nil { + return nil, false + } + return box, true +} + +// SetAllBoxes stores the full enabled-box roster on the request context so +// templates (the header chip, the switcher palette) can render it without +// re-querying the database per page. +func SetAllBoxes(ctx context.Context, boxes []models.BoxConfig) context.Context { + return context.WithValue(ctx, allBoxesContextKey, boxes) +} + +// GetAllBoxesFromContext retrieves the enabled-box roster. Returns an empty +// slice if not present (first-run, or middleware not wired) — callers should +// treat that as "no boxes configured." +func GetAllBoxesFromContext(ctx context.Context) []models.BoxConfig { + boxes, _ := ctx.Value(allBoxesContextKey).([]models.BoxConfig) + return boxes +} diff --git a/gearbox/internal/framework/gear/interface.go b/gearbox/internal/framework/gear/interface.go index 74b996e..095da11 100644 --- a/gearbox/internal/framework/gear/interface.go +++ b/gearbox/internal/framework/gear/interface.go @@ -56,10 +56,28 @@ type Gear interface { Migrations() []Migration } -// Scope describes whether a gear is scoped to a specific monitored box -// or applies system-wide. Box-scoped gears (the default) have one row per -// (server_id, name) in the gears table. System-scoped gears have a single -// row keyed by the SystemServerID sentinel. +// Scope describes how a gear relates to monitored boxes. It controls both +// where the gear's row(s) live in the gears table and when the gear is +// shown in the sidebar. +// +// Three values are recognized: +// +// - ScopeBox — the default; one row per (server_id, name) in the +// gears table. The gear is shown in the sidebar only +// when the active box context has it enabled. Examples: +// HAProxy, Metrics, Logs, Services, Certificates, +// Traffic, Alerts, OS Updates. +// - ScopeSystem — a single row keyed by the SystemServerID sentinel; +// the gear is install-wide and is shown in the sidebar +// regardless of which box (if any) is selected. +// Example: the Home dashboard. +// - ScopeBoxAgnostic — a single install-wide row, like ScopeSystem, but +// semantically the gear lists or aggregates *across* +// boxes rather than ignoring boxes entirely. The +// sidebar shows it independent of any box selection, +// and box-specific gears are hidden when no box is +// active (this gear is the place to pick one). +// Example: the Bx (Boxes) fleet view. type Scope string const ( @@ -67,8 +85,17 @@ const ( ScopeBox Scope = "box" // ScopeSystem indicates the gear is enabled globally for the whole install. ScopeSystem Scope = "system" + // ScopeBoxAgnostic indicates the gear is install-wide and lists/aggregates + // across boxes. It is always visible in the sidebar. + ScopeBoxAgnostic Scope = "box_agnostic" ) +// IsBoxScoped reports whether the scope ties the gear to a specific box. +// Box-scoped gears are hidden from the sidebar when no box is selected. +func (s Scope) IsBoxScoped() bool { + return s == "" || s == ScopeBox +} + // Info contains metadata about a gear. type Info struct { // Name is the internal identifier (e.g., "logs", "metrics"). diff --git a/gearbox/internal/framework/handler/handler.go b/gearbox/internal/framework/handler/handler.go index 196f244..6914bbd 100644 --- a/gearbox/internal/framework/handler/handler.go +++ b/gearbox/internal/framework/handler/handler.go @@ -184,13 +184,23 @@ func (h *Handler) getDefaultServerID() string { return "" } -// InjectIntegrationStatus is middleware that adds integration status and user permissions to the request context. -// This enables server-side conditional rendering of navigation items based on integration status and permissions. +// InjectIntegrationStatus is middleware that adds integration status, the +// active-box context, the enabled-box roster, and user permissions to the +// request context. This is what drives the sidebar's scope-aware rendering +// and the header's box-switcher chip. // -// System-scoped gears (Home, etc. — keyed by database.SystemServerID) are -// always loaded, even when no boxes are registered. Box-scoped gears load -// from the default box if one exists; otherwise they're explicitly disabled -// so the sidebar stays clean during first-run. +// Active-box resolution: +// - If `?box_id=` is present in the URL and refers to an enabled box, +// that box is the active context. The sidebar shows that box's enabled +// gears (plus all ScopeBoxAgnostic / ScopeSystem gears). +// - Otherwise the active context is empty ("box-agnostic"). The sidebar +// hides ScopeBox gears (they require a selection) and shows only +// ScopeBoxAgnostic + ScopeSystem entries. +// +// System gears (keyed by database.SystemServerID) are loaded unconditionally +// because they are install-wide. The legacy "fall back to the first enabled +// box" behavior is gone — the Bx fleet view is now the user's entry point +// when no box is explicitly selected. func (h *Handler) InjectIntegrationStatus(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -201,10 +211,29 @@ func (h *Handler) InjectIntegrationStatus(next http.Handler) http.Handler { ctx = auth.SetUserPermissions(ctx, perms) } + // Publish the enabled-box roster so the header chip and switcher + // palette can render without re-querying the DB. + allBoxes := h.getEnabledServers() + ctx = auth.SetAllBoxes(ctx, allBoxes) + + // Resolve the active box from ?box_id= (if any and valid). + var activeBox *models.BoxConfig + if requested := r.URL.Query().Get("box_id"); requested != "" { + for i := range allBoxes { + if allBoxes[i].ID == requested { + activeBox = &allBoxes[i] + break + } + } + } + if activeBox != nil { + ctx = auth.SetSelectedBox(ctx, activeBox) + } + status := make(map[string]bool) orderedIntegrations := make([]auth.SidebarIntegration, 0) - // System gears go first so they render at the head of the nav. + // System / box-agnostic gears go first so they render at the head of the nav. systemGears, err := h.db.GetGears(database.SystemServerID) if err != nil { h.logger.Warn("failed to load system gears for sidebar", "error", err) @@ -218,15 +247,15 @@ func (h *Handler) InjectIntegrationStatus(next http.Handler) http.Handler { }) } - if boxID := h.getDefaultServerID(); boxID != "" { - integrations, err := h.db.GetGears(boxID) + if activeBox != nil { + integrations, err := h.db.GetGears(activeBox.ID) if err != nil { // Fail-open: a partial gear list could collapse the sidebar // to system-gears-only, hiding box features the user // actually has. Leave the gear-status/order context unset // so OrderedIntegrationLinks falls back to its default // (full) rendering branch. - h.logger.Error("failed to get box integrations for sidebar", "error", err) + h.logger.Error("failed to get box integrations for sidebar", "error", err, "box_id", activeBox.ID) next.ServeHTTP(w, r.WithContext(ctx)) return } @@ -239,10 +268,10 @@ func (h *Handler) InjectIntegrationStatus(next http.Handler) http.Handler { }) } } else { - // No box configured — explicitly mark box-scoped gears off so - // the sidebar doesn't fall back to fail-open and clutter - // first-run with disabled items. System gears (Home) are - // already injected above and remain visible if enabled. + // No box selected — explicitly mark box-scoped gears off so the + // sidebar renderer hides them. ScopeBoxAgnostic gears (Bx, etc.) + // and ScopeSystem gears (Home) are injected above as system + // rows and remain visible. for _, n := range []string{"haproxy", "metrics", "logs", "services", "certificates", "traffic", "alerts", "os_updates"} { if _, present := status[n]; !present { status[n] = false diff --git a/gearbox/internal/framework/services/server_adapter.go b/gearbox/internal/framework/services/server_adapter.go index 0392896..ac1c42f 100644 --- a/gearbox/internal/framework/services/server_adapter.go +++ b/gearbox/internal/framework/services/server_adapter.go @@ -28,6 +28,14 @@ func NewServerAdapter(db *database.DB, encryptor *crypto.Encryptor, fallback []m } } +// GetDB exposes the underlying *database.DB for gears that need to query +// box rows beyond what the gear.ServerRegistry facade exposes (notably the +// Bx gear's status monitor, which needs Location + APIKeyEncrypted to +// probe every agent — fields the trimmed ServerConfig drops). +func (a *ServerAdapter) GetDB() *database.DB { + return a.db +} + // GetEnabledBoxes returns all boxes that are currently enabled. func (a *ServerAdapter) GetEnabledBoxes() []gear.ServerConfig { dbServers, err := a.db.GetEnabledBoxes() diff --git a/gearbox/internal/framework/templates/layouts/base.templ b/gearbox/internal/framework/templates/layouts/base.templ index 7b01964..cb44011 100644 --- a/gearbox/internal/framework/templates/layouts/base.templ +++ b/gearbox/internal/framework/templates/layouts/base.templ @@ -1,12 +1,42 @@ package layouts import "context" +import "encoding/json" import "github.com/sarg3nt/gearbox/internal/framework/auth" import "github.com/sarg3nt/gearbox/internal/framework/models" import "github.com/sarg3nt/gearbox/internal/framework/ui" import "github.com/sarg3nt/gearbox/internal/framework/middleware" import "strings" +// activeBoxIDForChip returns the active box's ID, or "" if no box is +// selected — used as the data-active-box-id attribute on the chip so the +// switcher palette can mark the current row. +func activeBoxIDForChip(active *models.BoxConfig, has bool) string { + if !has || active == nil { + return "" + } + return active.ID +} + +// encodeBoxesJSON serializes the box roster for the switcher palette's +// dataset. We only ship `id` and `name` — the agent URL and API key never +// leave the server, even though they are present in the source slice. +func encodeBoxesJSON(boxes []models.BoxConfig) string { + type lite struct { + ID string `json:"id"` + Name string `json:"name"` + } + out := make([]lite, 0, len(boxes)) + for _, b := range boxes { + out = append(out, lite{ID: b.ID, Name: b.Name}) + } + b, err := json.Marshal(out) + if err != nil { + return "[]" + } + return string(b) +} + // isActivePath checks if the current path matches the nav link func isActivePath(href, currentPath string) bool { if href == "/" { @@ -890,6 +920,7 @@ templ Base(title string, user *models.User, currentPath ...string) { + @ui.CollapsibleRestoreScript() @@ -1726,6 +1757,8 @@ func firstEnabledIntegrationPath(ctx context.Context) string { // Maps integration names to component View permissions. func canViewIntegration(ctx context.Context, integrationName string) bool { switch integrationName { + case "bx": + return true // Bx (fleet view) is the box switcher's home — always visible. case "home": return true // Home is visible to all authenticated users when enabled. case "metrics": @@ -1743,22 +1776,54 @@ func canViewIntegration(ctx context.Context, integrationName string) bool { } } +// isBoxScopedIntegration reports whether a sidebar integration is tied to a +// specific box. Box-scoped integrations are hidden when no box is active in +// the request context — the user has no place for them to point at. +// Mirrors gear.Scope.IsBoxScoped() but kept as a template-local helper +// because the sidebar renders from the integration *name* (a DB row), not +// from the gear registry directly. The Bx (fleet view) and Home are the +// only two install-wide entries today. +func isBoxScopedIntegration(name string) bool { + switch name { + case "bx", "home": + return false + } + return true +} + // OrderedIntegrationLinks renders sidebar links for integrations in the user's custom order. // Falls back to default order if no custom order is stored. -// Links are only shown if the integration is enabled AND the user has View permission. +// +// Two filters compose here: +// +// 1. Permissions — canViewIntegration(ctx, name). +// 2. Active-box scope — when no box is selected (auth.GetSelectedBoxFromContext +// returns ok=false), box-scoped integrations are hidden. The user has +// no place for them to land — Bx (the fleet picker) is the way in. +// Install-wide entries (Bx, Home) are always shown. +// +// The fallback branch (when no integration order is in context) always +// shows everything for backwards compatibility — that branch fires before +// gears have any rows, e.g. an empty database, and is harmless because +// the user has no boxes yet anyway. templ OrderedIntegrationLinks(currentPath string) { if integrations, ok := auth.GetGearOrderFromContext(ctx); ok { - if len(integrations) > 0 { - // Show ordered integrations from database - for _, integration := range integrations { - if integration.Enabled && canViewIntegration(ctx, integration.Name) { - @renderIntegrationLink(integration.Name, currentPath) - } + // Always render the Bx fleet entry first when permitted — even if + // the integration table doesn't have a row for it yet (fresh install). + if !hasIntegration(integrations, "bx") && canViewIntegration(ctx, "bx") { + @SidebarLinkDraggable("/bx", "Bx", bxSidebarIcon(), currentPath, "bx") + } + {{ _, boxActive := auth.GetSelectedBoxFromContext(ctx) }} + for _, integration := range integrations { + if integration.Enabled && canViewIntegration(ctx, integration.Name) && (!isBoxScopedIntegration(integration.Name) || boxActive) { + @renderIntegrationLink(integration.Name, currentPath) } } - // If integrations list is empty (no server configured), show nothing } else { // Fallback to default order only if context doesn't exist at all (backwards compatibility) + if canViewIntegration(ctx, "bx") { + @SidebarLinkWithIntegration("/bx", "Bx", bxSidebarIcon(), currentPath, "bx") + } if canViewIntegration(ctx, "haproxy") { @SidebarLinkWithIntegration("/haproxy", "HAProxy", SidebarIconHAProxy(), currentPath, "haproxy") } @@ -1786,9 +1851,23 @@ templ OrderedIntegrationLinks(currentPath string) { } } +// hasIntegration reports whether an integration name is already in the +// ordered list. Used by OrderedIntegrationLinks to decide whether to +// inject the Bx synthetic entry on installs that predate the gear. +func hasIntegration(list []auth.SidebarIntegration, name string) bool { + for _, i := range list { + if i.Name == name { + return true + } + } + return false +} + // renderIntegrationLink renders a single integration link based on the integration name. templ renderIntegrationLink(name string, currentPath string) { switch name { + case "bx": + @SidebarLinkDraggable("/bx", "Bx", bxSidebarIcon(), currentPath, "bx") case "home": @SidebarLinkDraggable("/home", "Home", SidebarIconHome(), currentPath, "home") case "haproxy": @@ -1812,11 +1891,121 @@ templ renderIntegrationLink(name string, currentPath string) { templ Header() {
-
+
+ + @boxSwitcherChip()
+ @boxSwitcherPalette() +} + +// boxSwitcherChip is the persistent host pill in the top-left of the +// header chrome. It is the primary box-switching affordance — clicking it +// opens the searchable palette. On installs with no boxes, or for users +// who lack any box, the chip renders as a "Choose a box" CTA. On +// box-agnostic pages without an active selection, it renders as +// "All boxes" so the user can tell at a glance what context they are in. +templ boxSwitcherChip() { + {{ boxes := auth.GetAllBoxesFromContext(ctx) }} + {{ active, hasActive := auth.GetSelectedBoxFromContext(ctx) }} + if len(boxes) == 0 { + + @boxChipIcon() + Add a box + + } else { + + } +} + +// boxSwitcherPalette is the command-palette-style picker that opens when +// the chip is clicked or `g b` is pressed. Rendered hidden by default; +// box-switcher.js handles open/close + search filter + arrow-key nav. +// +// Boxes are JSON-encoded into the dataset so the palette renders without +// a round-trip; status dots upgrade in place via SSE from /bx/api/events. +templ boxSwitcherPalette() { + {{ boxes := auth.GetAllBoxesFromContext(ctx) }} + {{ active, _ := auth.GetSelectedBoxFromContext(ctx) }} + if len(boxes) > 0 { + + } +} + +// boxChipIcon is the small 2×2 grid drawn inside the header chip when no +// box is selected. Kept local to base.templ (rather than imported from the +// bx gear) to avoid a layouts → gears import cycle. +templ boxChipIcon() { + + + + + + +} + +// bxSidebarIcon is the sidebar variant of the Bx icon. Same artwork as +// the chip icon but at sidebar size and color. Local to layouts so the +// base template doesn't have to import the bx gear (which would create a +// layouts → gears → layouts cycle). +templ bxSidebarIcon() { + + + + + + } diff --git a/gearbox/internal/gears/bx/README.md b/gearbox/internal/gears/bx/README.md new file mode 100644 index 0000000..8b9a3c2 --- /dev/null +++ b/gearbox/internal/gears/bx/README.md @@ -0,0 +1,114 @@ +# Bx Gear + +The **Bx** gear is Gearbox's fleet-overview page and the home of the +box-switching chrome. It lists every configured monitored box in one +place — name, location, agent host, latency, status dot — and is the +default entry point when no specific box is active. + +## Why it exists + +Before this gear, every page in Gearbox had its own `` dropdown wired to + * `onchange="switchBox(this.value)"`. + * + * Both paths converge on the same URL convention: a `?box_id=` query + * parameter on the current page. The server-side InjectIntegrationStatus + * middleware reads that parameter, hydrates the sidebar with that box's + * enabled gears, and renders the active-box chip. */ /** - * Switch to a different server (reloads page with new server parameter) - * @param {string} boxID - The server ID to switch to + * Navigate the current tab to the same path with `?box_id=` set. Drops + * any existing `box_id` even if the new id is empty (which clears box + * context — useful for going from a box-specific page to a box-agnostic + * one). The page reloads; there is no SPA layer in play. + * + * @param {string|null} boxID — the box ID to activate, or empty/null to clear. + * @param {string|null} [path] — optional target path; defaults to the current page. */ -function switchBox(boxID) { - if (!boxID) return; - - // Get current URL +function switchBox(boxID, path) { const url = new URL(window.location.href); - - // Update or add box_id parameter - url.searchParams.set('box_id', boxID); - - // Reload page with new server - window.location.href = url.toString(); + if (path) { + url.pathname = path; + } + if (boxID) { + url.searchParams.set('box_id', boxID); + } else { + url.searchParams.delete('box_id'); + } + window.location.assign(url.toString()); } -// Make function available globally for onclick handlers +// Make function available globally for onclick handlers (legacy dropdowns). window.switchBox = switchBox; diff --git a/gearbox/static/js/common/box-switcher.js b/gearbox/static/js/common/box-switcher.js new file mode 100644 index 0000000..8549ad5 --- /dev/null +++ b/gearbox/static/js/common/box-switcher.js @@ -0,0 +1,273 @@ +/** + * Box switcher — keyboard-driven command palette for picking the active box. + * + * UI: a centered modal opened by clicking the header chip or pressing `g b` + * (Cockpit-style). Type to filter; arrow keys to navigate; to pick; + * to close. Live status dots are kept in sync with the Bx fleet + * monitor over SSE — the chip dot, palette dots, and the fleet page all + * read from the same source. + * + * Routing: picking a box reuses the legacy `?box_id=` query convention + * (see box-selector.js) so every existing page handler keeps working + * without a route migration. Picking from a box-agnostic page (Bx, Home) + * routes to Home with the new box selected; picking from a box-specific + * page rewrites the same path with the new box_id. + */ +(function () { + 'use strict'; + + const STATUS_DOT_CLASSES = { + green: 'bg-green-500 ring-1 ring-inset ring-green-600/40', + yellow: 'bg-amber-400 ring-1 ring-inset ring-amber-600/40', + red: 'bg-red-500 ring-1 ring-inset ring-red-700/40', + gray: 'bg-gray-400 ring-1 ring-inset ring-gray-500/40', + unknown: 'bg-gray-300 ring-1 ring-inset ring-gray-400/40 animate-pulse', + }; + + /** All known boxes [{id, name}] decoded from the palette dataset. */ + let allBoxes = []; + /** Current filter substring, lowercased. */ + let filter = ''; + /** Index of the currently-highlighted item in the filtered list. */ + let cursor = 0; + /** Map of boxID → status row (level, latency_ms, last_checked). */ + const statusByBox = new Map(); + /** Currently active box ID, or '' if none. */ + let activeBoxID = ''; + + function $(id) { return document.getElementById(id); } + + function init() { + const overlay = $('box-switcher-overlay'); + const list = $('box-switcher-list'); + const search = $('box-switcher-search'); + if (!overlay || !list || !search) return; // nothing to wire (no boxes configured) + + try { + allBoxes = JSON.parse(list.dataset.boxes || '[]'); + } catch (_) { + allBoxes = []; + } + activeBoxID = overlay.dataset.activeBoxId || ''; + + // Filter + arrow-key wiring. + search.addEventListener('input', function () { + filter = (search.value || '').toLowerCase().trim(); + cursor = 0; + render(); + }); + search.addEventListener('keydown', function (e) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + cursor = Math.min(cursor + 1, visible().length - 1); + render(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + cursor = Math.max(cursor - 1, 0); + render(); + } else if (e.key === 'Enter') { + e.preventDefault(); + const picked = visible()[cursor]; + if (picked) pick(picked.id); + } else if (e.key === 'Escape') { + e.preventDefault(); + close(); + } + }); + + overlay.addEventListener('click', function (e) { + // Backdrop click closes; clicks inside the panel don't bubble here. + if (e.target === overlay) close(); + }); + + // Global keyboard shortcut: `g b` (mnemonic = "go box"). Avoids + // conflicts with typing — only fires when no field has focus. + let gPressed = false; + let gTimer = 0; + document.addEventListener('keydown', function (e) { + const t = e.target; + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) { + return; + } + if (e.key === 'g' && !e.metaKey && !e.ctrlKey && !e.altKey) { + gPressed = true; + clearTimeout(gTimer); + gTimer = setTimeout(function () { gPressed = false; }, 800); + return; + } + if (gPressed && e.key === 'b') { + e.preventDefault(); + gPressed = false; + open(); + } + }); + + // Pre-populate from /bx/api/status so the palette opens with live dots. + fetchStatus(); + // Live updates via SSE — same stream the Bx page uses. + subscribeSSE(); + } + + function visible() { + if (!filter) return allBoxes; + return allBoxes.filter(function (b) { + return b.name.toLowerCase().includes(filter); + }); + } + + function render() { + const list = $('box-switcher-list'); + if (!list) return; + // Clear children. + while (list.firstChild) list.removeChild(list.firstChild); + + const rows = visible(); + if (rows.length === 0) { + const empty = document.createElement('li'); + empty.className = 'px-3 py-6 text-sm text-center text-gray-500 dark:text-gray-400'; + empty.textContent = 'No boxes match'; + list.appendChild(empty); + return; + } + rows.forEach(function (b, i) { + list.appendChild(buildRow(b, i === cursor)); + }); + } + + function buildRow(box, isCursor) { + const li = document.createElement('li'); + const status = statusByBox.get(box.id); + const level = status && status.level ? status.level : 'unknown'; + const isActive = box.id === activeBoxID; + + li.className = 'flex items-center gap-3 px-3 py-2 cursor-pointer ' + + (isCursor ? 'bg-blue-50 dark:bg-slate-700' : 'hover:bg-gray-50 dark:hover:bg-slate-700/50'); + li.dataset.boxId = box.id; + li.addEventListener('mouseenter', function () { + cursor = visible().indexOf(box); + render(); + }); + li.addEventListener('click', function () { pick(box.id); }); + + const dot = document.createElement('span'); + dot.className = 'inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 ' + + (STATUS_DOT_CLASSES[level] || STATUS_DOT_CLASSES.unknown); + li.appendChild(dot); + + const name = document.createElement('span'); + name.className = 'flex-1 text-sm text-gray-800 dark:text-gray-100 truncate'; + name.textContent = box.name; + li.appendChild(name); + + if (status && typeof status.latency_ms === 'number' && status.latency_ms > 0) { + const lat = document.createElement('span'); + lat.className = 'text-[11px] font-mono tabular-nums text-gray-400 dark:text-gray-500'; + lat.textContent = status.latency_ms + 'ms'; + li.appendChild(lat); + } + + if (isActive) { + const tag = document.createElement('span'); + tag.className = 'text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded ' + + 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300'; + tag.textContent = 'Active'; + li.appendChild(tag); + } + return li; + } + + function open() { + const overlay = $('box-switcher-overlay'); + const search = $('box-switcher-search'); + if (!overlay || !search) return; + overlay.classList.remove('hidden'); + cursor = 0; + filter = ''; + search.value = ''; + render(); + // Defer focus to let display:hidden→block flush. + setTimeout(function () { search.focus(); }, 0); + } + + function close() { + const overlay = $('box-switcher-overlay'); + if (overlay) overlay.classList.add('hidden'); + } + + function pick(boxID) { + // Navigate to the same path with ?box_id= updated. On box-agnostic + // pages (Bx, Home, Settings) jump to Home in that box's context; + // the chip stays visible everywhere. + const path = window.location.pathname; + const boxAgnostic = ['/bx', '/home', '/home/', '/settings'].some(function (p) { + return path === p || path.startsWith(p + '/'); + }); + if (typeof window.switchBox === 'function') { + window.switchBox(boxID, boxAgnostic ? '/home' : null); + } else { + const url = new URL(window.location.href); + url.searchParams.set('box_id', boxID); + if (boxAgnostic) url.pathname = '/home'; + window.location.assign(url.toString()); + } + } + + /* -------------------------------------------------------------- * + * Status sync + * -------------------------------------------------------------- */ + function applyStatus(s) { + if (!s || !s.box_id) return; + statusByBox.set(s.box_id, s); + // If this is the active box, update the chip's dot too. + if (s.box_id === activeBoxID) { + const chipDot = $('box-switcher-chip-dot'); + if (chipDot) { + const level = s.level || 'unknown'; + chipDot.className = 'inline-block w-2 h-2 rounded-full ' + + (STATUS_DOT_CLASSES[level] || STATUS_DOT_CLASSES.unknown); + } + } + // If the palette is open, re-render the affected row in place. + const overlay = $('box-switcher-overlay'); + if (overlay && !overlay.classList.contains('hidden')) { + render(); + } + } + + function fetchStatus() { + fetch('/bx/api/status', { credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (data) { + if (!data || !Array.isArray(data.rows)) return; + data.rows.forEach(applyStatus); + }) + .catch(function () { /* best-effort */ }); + } + + let evt = null; + function subscribeSSE() { + if (typeof EventSource === 'undefined') return; + try { + evt = new EventSource('/bx/api/events'); + } catch (_) { return; } + evt.addEventListener('box.status', function (e) { + try { applyStatus(JSON.parse(e.data)); } catch (_) {} + }); + document.addEventListener('visibilitychange', function () { + if (document.hidden) { + if (evt) { evt.close(); evt = null; } + } else if (!evt) { + subscribeSSE(); + } + }); + } + + // Make open() callable from the chip's onclick attribute in base.templ. + window.openBoxSwitcher = open; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); From d1e32b5f054b962f9075dd206556beb444f19459 Mon Sep 17 00:00:00 2001 From: Dave Sargent Date: Tue, 12 May 2026 09:17:37 -0700 Subject: [PATCH 2/4] fix(bx): address Copilot review findings on #61 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - statusMonitor.Start now guards against multi-start with sync.Once (matches its docstring; previously spawned a new goroutine per call). - bx.Gear.Start passes its lifecycle ctx through to the monitor instead of context.Background(), so framework shutdown actually cancels it. - The per-probe 5s budget is now enforced: probe uses agent.NewClientWithTimeout(..., m.timeout) instead of the default 30s agent client. Removed the unused pctx that the old comment promised. - canViewIntegration("bx") now checks bx:view via auth.HasPermissionFromContext, matching the handler-side gate and the SidebarItem.RequiresPermission — so the sidebar no longer renders a Bx link for users who'd just get a 403 clicking it. - Fix stale comment on boxTable: the page reconciles via the SSE snapshot pushed on (re)connect, not a /bx/api/status fetch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../framework/templates/layouts/base.templ | 6 ++++- gearbox/internal/gears/bx/gear.go | 2 +- gearbox/internal/gears/bx/pages.templ | 4 ++-- gearbox/internal/gears/bx/status.go | 22 ++++++++----------- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/gearbox/internal/framework/templates/layouts/base.templ b/gearbox/internal/framework/templates/layouts/base.templ index cb44011..68b6d24 100644 --- a/gearbox/internal/framework/templates/layouts/base.templ +++ b/gearbox/internal/framework/templates/layouts/base.templ @@ -1758,7 +1758,11 @@ func firstEnabledIntegrationPath(ctx context.Context) string { func canViewIntegration(ctx context.Context, integrationName string) bool { switch integrationName { case "bx": - return true // Bx (fleet view) is the box switcher's home — always visible. + // Bx (fleet view) is gated on bx:view in handlers and the sidebar + // config (SidebarItem.RequiresPermission); mirror that here so the + // sidebar link doesn't render for users who'd get a 403 clicking it. + // `models.Component("bx")` matches the gear-declared permission name. + return auth.HasPermissionFromContext(ctx, models.Component("bx"), models.PermissionView) case "home": return true // Home is visible to all authenticated users when enabled. case "metrics": diff --git a/gearbox/internal/gears/bx/gear.go b/gearbox/internal/gears/bx/gear.go index d524fe8..46b4f4a 100644 --- a/gearbox/internal/gears/bx/gear.go +++ b/gearbox/internal/gears/bx/gear.go @@ -57,7 +57,7 @@ func (g *Gear) Initialize(ctx context.Context, deps gear.Dependencies) error { // Start launches the per-box status poller. func (g *Gear) Start(ctx context.Context) error { if g.monitor != nil { - g.monitor.Start(context.Background()) + g.monitor.Start(ctx) } return nil } diff --git a/gearbox/internal/gears/bx/pages.templ b/gearbox/internal/gears/bx/pages.templ index 7f885bb..600a1e7 100644 --- a/gearbox/internal/gears/bx/pages.templ +++ b/gearbox/internal/gears/bx/pages.templ @@ -129,8 +129,8 @@ templ emptyState() { } // boxTable renders the fleet view. Server-side render is the source of truth -// for initial paint; the page's JS reconciles rows with /bx/api/status and -// listens on /bx/api/events for live updates. +// for initial paint; the page's JS subscribes to /bx/api/events (SSE), which +// pushes a full snapshot on every (re)connect and live transitions thereafter. // // Column order is deliberately minimal: status dot, name, location, agent // host, latency, last-checked. Anything heavier (per-box CPU/mem diff --git a/gearbox/internal/gears/bx/status.go b/gearbox/internal/gears/bx/status.go index 1271b1b..fc80d09 100644 --- a/gearbox/internal/gears/bx/status.go +++ b/gearbox/internal/gears/bx/status.go @@ -57,8 +57,9 @@ type statusMonitor struct { mu sync.RWMutex statuses map[string]BoxStatus // keyed by BoxConfig.ID (UUID string) - stop chan struct{} - once sync.Once + stop chan struct{} + startOnce sync.Once + stopOnce sync.Once subsMu sync.Mutex subs map[int64]chan BoxStatus @@ -83,14 +84,14 @@ func newStatusMonitor(deps gear.Dependencies) *statusMonitor { } // Start launches the polling loop. Safe to call multiple times — only the -// first call wins. +// first call wins; subsequent calls are no-ops. func (m *statusMonitor) Start(ctx context.Context) { - go m.run(ctx) + m.startOnce.Do(func() { go m.run(ctx) }) } // Stop signals the polling loop to exit. Subsequent calls are no-ops. func (m *statusMonitor) Stop() { - m.once.Do(func() { close(m.stop) }) + m.stopOnce.Do(func() { close(m.stop) }) } func (m *statusMonitor) run(ctx context.Context) { @@ -164,14 +165,9 @@ func (m *statusMonitor) probe(ctx context.Context, b *database.BoxDB, apiKey str return bs } - // Per-probe context bounded by the timeout; do not let a single hung - // agent stall the whole poll cycle. - pctx, cancel := context.WithTimeout(ctx, m.timeout) - defer cancel() - _ = pctx // reserved for when the agent client supports ctx; the http - // client's per-request timeout already provides the upper bound. - - client := agent.NewClient(b.AgentURL, apiKey) + // Per-probe timeout bounds the HTTP request itself so a single hung + // agent can't stall the poll cycle past m.timeout. + client := agent.NewClientWithTimeout(b.AgentURL, apiKey, m.timeout) t0 := time.Now() _, err := client.Health() bs.LatencyMs = time.Since(t0).Milliseconds() From 4e3ea413f4b5e755ec467bc1979a2ecda01b9760 Mon Sep 17 00:00:00 2001 From: Dave Sargent Date: Tue, 12 May 2026 09:41:34 -0700 Subject: [PATCH 3/4] fix(bx): drop unused statusMonitor.logger field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing dead field from the original commit; golangci-lint v2.8.0 (running on PR CI) flagged it as unused. Removed rather than wired up — m.deps.Logger is already available when logging is needed later. Co-Authored-By: Claude Opus 4.7 (1M context) --- gearbox/internal/gears/bx/status.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/gearbox/internal/gears/bx/status.go b/gearbox/internal/gears/bx/status.go index fc80d09..bd1575a 100644 --- a/gearbox/internal/gears/bx/status.go +++ b/gearbox/internal/gears/bx/status.go @@ -47,9 +47,8 @@ type BoxStatus struct { // mature. Designed so the heavy lift can move to a framework-level // boxhealth service later without changing the Bx page's API. type statusMonitor struct { - deps gear.Dependencies - db *database.DB - logger interface{} + deps gear.Dependencies + db *database.DB interval time.Duration timeout time.Duration From 42bf22ae21a21c7b2dd239e9012b9620b896299c Mon Sep 17 00:00:00 2001 From: Dave Sargent Date: Tue, 12 May 2026 10:28:05 -0700 Subject: [PATCH 4/4] fix(bx): address second-pass Copilot review on #61 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - box-switcher.js: register the visibilitychange listener once at module load instead of inside subscribeSSE(). Previously each tab-show added a new listener, eventually opening multiple EventSource connections. subscribeSSE() now also short-circuits if evt is already set. - status.go probe(): `/health` is unauthenticated, so a missing API key no longer skips the reachability check. Boxes with an Agent URL but no key now probe normally; if Health() succeeds, the rollup degrades to Yellow with "API key missing — authenticated endpoints unavailable" as a contributor instead of being permanently Gray. - handler.go InjectIntegrationStatus: publish the *full* configured-box roster (not just enabled-and-UsesAgentAPI) so the Bx fleet view and switcher palette can show disabled / partially-configured boxes — the StatusGray semantic only works if those rows are actually rendered. Active-box resolution (?box_id=) still uses the enabled+configured subset; landing on a disabled box has no gears to render anyway. - base.templ box-switcher palette: add `role="dialog"`, `aria-modal="true"`, `aria-labelledby="box-switcher-title"`, a visually- hidden h2 title, `aria-label` on the search input, and `aria-hidden` on the decorative magnifier SVG. Matches the existing confirm/prompt/ alert dialog patterns in this template. Co-Authored-By: Claude Opus 4.7 (1M context) --- gearbox/internal/framework/handler/handler.go | 43 +++++++++++++++---- .../framework/templates/layouts/base.templ | 6 ++- gearbox/internal/gears/bx/status.go | 14 ++++-- gearbox/static/js/common/box-switcher.js | 8 +++- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/gearbox/internal/framework/handler/handler.go b/gearbox/internal/framework/handler/handler.go index 6914bbd..43723ea 100644 --- a/gearbox/internal/framework/handler/handler.go +++ b/gearbox/internal/framework/handler/handler.go @@ -175,6 +175,28 @@ func (h *Handler) getEnabledServers() []models.BoxConfig { return servers } +// fullBoxRoster returns every configured box — including disabled and +// partially-configured ones — without the UsesAgentAPI() filter applied by +// getEnabledServers(). Used to publish the roster for the Bx fleet view + +// switcher chrome, where StatusGray rows are meaningful and the user needs +// to *see* the misconfigured boxes in order to fix them. +// +// API keys are intentionally not decrypted here — the roster is for UI +// rendering only; agents that need authenticated calls go through the +// existing per-request agent-client path which does its own decryption. +func (h *Handler) fullBoxRoster() []models.BoxConfig { + dbBoxes, err := h.db.GetBoxes() + if err != nil { + h.logger.Error("failed to load full box roster", "error", err) + return h.getEnabledServers() // safe fallback: at least show what works + } + out := make([]models.BoxConfig, 0, len(dbBoxes)) + for _, b := range dbBoxes { + out = append(out, b.ToBoxConfig("")) + } + return out +} + // getDefaultServerID returns the ID of the first enabled server, or empty string if none. func (h *Handler) getDefaultServerID() string { servers := h.getEnabledServers() @@ -211,17 +233,22 @@ func (h *Handler) InjectIntegrationStatus(next http.Handler) http.Handler { ctx = auth.SetUserPermissions(ctx, perms) } - // Publish the enabled-box roster so the header chip and switcher - // palette can render without re-querying the DB. - allBoxes := h.getEnabledServers() - ctx = auth.SetAllBoxes(ctx, allBoxes) + // Publish the *full* configured-box roster so the header chip, + // switcher palette, and Bx fleet view can render disabled or + // partially-configured boxes (the Bx page's StatusGray semantic). + // Active-box resolution below still uses the enabled+agent-API-using + // subset — landing on a disabled box has no gears to show. + fullRoster := h.fullBoxRoster() + ctx = auth.SetAllBoxes(ctx, fullRoster) + + enabled := h.getEnabledServers() - // Resolve the active box from ?box_id= (if any and valid). + // Resolve the active box from ?box_id= (if any, enabled, and valid). var activeBox *models.BoxConfig if requested := r.URL.Query().Get("box_id"); requested != "" { - for i := range allBoxes { - if allBoxes[i].ID == requested { - activeBox = &allBoxes[i] + for i := range enabled { + if enabled[i].ID == requested { + activeBox = &enabled[i] break } } diff --git a/gearbox/internal/framework/templates/layouts/base.templ b/gearbox/internal/framework/templates/layouts/base.templ index 68b6d24..48670e2 100644 --- a/gearbox/internal/framework/templates/layouts/base.templ +++ b/gearbox/internal/framework/templates/layouts/base.templ @@ -1962,16 +1962,18 @@ templ boxSwitcherPalette() { {{ boxes := auth.GetAllBoxesFromContext(ctx) }} {{ active, _ := auth.GetSelectedBoxFromContext(ctx) }} if len(boxes) > 0 { -