Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
58 changes: 47 additions & 11 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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)

Expand Down
1 change: 1 addition & 0 deletions gearbox/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
36 changes: 36 additions & 0 deletions gearbox/internal/framework/auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
35 changes: 31 additions & 4 deletions gearbox/internal/framework/gear/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,46 @@ 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 (
// ScopeBox indicates the gear is enabled per-box (default).
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").
Expand Down
57 changes: 43 additions & 14 deletions gearbox/internal/framework/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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()
Expand All @@ -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()
Comment thread
sarg3nt marked this conversation as resolved.
Outdated
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)
Expand All @@ -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
}
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions gearbox/internal/framework/services/server_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading